diff --git a/trackers/react-native-tracker/src/index.ts b/trackers/react-native-tracker/src/index.ts index 6c29689cb..a4cdb5590 100644 --- a/trackers/react-native-tracker/src/index.ts +++ b/trackers/react-native-tracker/src/index.ts @@ -2,4 +2,5 @@ import 'react-native-get-random-values'; export * from './types'; -export * from './tracker'; +export { newTracker, getTracker, getAllTrackers, removeTracker, removeAllTrackers } from './tracker'; +export * from './web_view_interface'; diff --git a/trackers/react-native-tracker/src/tracker.ts b/trackers/react-native-tracker/src/tracker.ts index ef6608f92..6eafe7ae9 100644 --- a/trackers/react-native-tracker/src/tracker.ts +++ b/trackers/react-native-tracker/src/tracker.ts @@ -143,6 +143,31 @@ export function getTracker(trackerNamespace: string): ReactNativeTracker | undef return initializedTrackers[trackerNamespace]?.tracker; } +/** + * Retrieves all initialized trackers + * @returns All initialized trackers + */ +export function getAllTrackers(): ReactNativeTracker[] { + return Object.values(initializedTrackers).map(({ tracker }) => tracker); +} + +/** + * Internal function to retrieve the tracker core given its namespace + * @param trackerNamespace - Tracker namespace + * @returns Tracker core if exists + */ +export function getTrackerCore(trackerNamespace: string): TrackerCore | undefined { + return initializedTrackers[trackerNamespace]?.core; +} + +/** + * Internal function to retrieve all initialized tracker cores + * @returns All initialized tracker cores + */ +export function getAllTrackerCores(): TrackerCore[] { + return Object.values(initializedTrackers).map(({ core }) => core); +} + /** * Removes a tracker given its namespace * diff --git a/trackers/react-native-tracker/src/types.ts b/trackers/react-native-tracker/src/types.ts index 6441399e5..f08638872 100755 --- a/trackers/react-native-tracker/src/types.ts +++ b/trackers/react-native-tracker/src/types.ts @@ -584,6 +584,10 @@ export interface SessionState { firstEventTimestamp?: string; } +export type StructuredProps = StructuredEvent; + +export type SelfDescribing> = SelfDescribingJson; + /** * The ReactNativeTracker type */ @@ -600,7 +604,7 @@ export type ReactNativeTracker = { * @typeParam TData - The type of the data object within the SelfDescribing object */ readonly trackSelfDescribingEvent: = Record>( - argmap: SelfDescribingJson, + argmap: SelfDescribing, contexts?: EventContext[] ) => void; @@ -854,12 +858,49 @@ export type ReactNativeTracker = { readonly refreshPlatformContext: () => Promise; }; +/** + * Internal event type for page views tracked using the WebView tracker. + */ +export interface WebViewPageViewEvent { + title?: string | null; + url?: string; + referrer?: string; +} + +export interface WebViewEvent { + selfDescribingEventData?: SelfDescribing; + eventName?: string; + trackerVersion?: string; + useragent?: string; + pageUrl?: string; + pageTitle?: string; + referrer?: string; + category?: string; + action?: string; + label?: string; + property?: string; + value?: number; + pingXOffsetMin?: number; + pingXOffsetMax?: number; + pingYOffsetMin?: number; + pingYOffsetMax?: number; +} + +/** + * Internal type exchanged in messages received from the WebView tracker in Web views through the web view callback. + */ +export type WebViewMessage = { + command: 'trackSelfDescribingEvent' | 'trackStructEvent' | 'trackPageView' | 'trackScreenView' | 'trackWebViewEvent'; + event: StructuredProps | SelfDescribing | ScreenViewProps | WebViewPageViewEvent | WebViewEvent; + context?: Array | null; + trackers?: Array; +}; + export { version, PageViewEvent, StructuredEvent, FormFocusOrChangeEvent, - SelfDescribingJson, Timestamp, PayloadBuilder, Payload, diff --git a/trackers/react-native-tracker/src/web_view_interface.ts b/trackers/react-native-tracker/src/web_view_interface.ts new file mode 100644 index 000000000..757a83e57 --- /dev/null +++ b/trackers/react-native-tracker/src/web_view_interface.ts @@ -0,0 +1,177 @@ +'use strict'; + +import { buildSelfDescribingEvent, payloadBuilder } from '@snowplow/tracker-core'; +import { getAllTrackerCores, getAllTrackers, getTracker, getTrackerCore } from './tracker'; +import type { + ScreenViewProps, + SelfDescribing, + StructuredProps, + WebViewPageViewEvent, + WebViewMessage, + ReactNativeTracker, + TrackerCore, + WebViewEvent, + PayloadBuilder, + Payload, +} from './types'; + +function forEachTracker(trackers: Array | undefined, iterator: (tracker: ReactNativeTracker) => void): void { + if (trackers && trackers.length > 0) { + trackers + .map(getTracker) + .filter((t) => t !== undefined) + .map((t) => t!) + .forEach(iterator); + } else { + getAllTrackers().forEach(iterator); + } +} + +function forEachTrackerCore(trackers: Array | undefined, iterator: (tracker: TrackerCore) => void): void { + if (trackers && trackers.length > 0) { + trackers + .map(getTrackerCore) + .filter((t) => t !== undefined) + .map((t) => t!) + .forEach(iterator); + } else { + getAllTrackerCores().forEach(iterator); + } +} + +/** + * Wrapper around the PayloadBuilder that disables overriding property values. + * This is to prevent the tracker overriding values like tracker version set in the WebView. + */ +function webViewPayloadBuilder(pb: PayloadBuilder): PayloadBuilder { + const addedKeys = new Set(); + + const add = (key: string, value: unknown): void => { + if (!addedKeys.has(key)) { + addedKeys.add(key); + pb.add(key, value); + } + }; + + const addDict = (dict: Payload): void => { + for (const key in dict) { + if (Object.prototype.hasOwnProperty.call(dict, key)) { + add(key, dict[key]); + } + } + }; + + return { + ...pb, + add, + addDict, + }; +} + +/** + * Enables tracking events from apps rendered in react-native-webview components. + * The apps need to use the Snowplow WebView tracker to track the events. + * + * To subscribe for the events, set the `onMessage` attribute: + * + * + * @returns Callback to subscribe for events from Web views tracked using the Snowplow WebView tracker. + */ +export function getWebViewCallback() { + return (message: { nativeEvent: { data: string } }): void => { + const data = JSON.parse(message.nativeEvent.data) as WebViewMessage; + switch (data.command) { + case 'trackSelfDescribingEvent': + forEachTracker(data.trackers, (tracker) => { + tracker.trackSelfDescribingEvent(data.event as SelfDescribing, data.context || []); + }); + break; + + case 'trackStructEvent': + forEachTracker(data.trackers, (tracker) => { + tracker.trackStructuredEvent(data.event as StructuredProps, data.context || []); + }); + break; + + case 'trackPageView': + forEachTracker(data.trackers, (tracker) => { + const event = data.event as WebViewPageViewEvent; + tracker.trackPageViewEvent({ + pageTitle: event.title ?? '', + pageUrl: event.url ?? '', + referrer: event.referrer, + }); + }); + break; + + case 'trackScreenView': + forEachTracker(data.trackers, (tracker) => { + tracker.trackScreenViewEvent(data.event as ScreenViewProps, data.context || []); + }); + break; + + case 'trackWebViewEvent': + forEachTrackerCore(data.trackers, (tracker) => { + const event = data.event as WebViewEvent; + let pb: PayloadBuilder; + if (event.selfDescribingEventData) { + pb = buildSelfDescribingEvent({ event: event.selfDescribingEventData }); + } else { + pb = payloadBuilder(); + } + pb = webViewPayloadBuilder(pb); + + if (event.eventName !== undefined) { + pb.add('e', event.eventName); + } + if (event.action !== undefined) { + pb.add('se_ac', event.action); + } + if (event.category !== undefined) { + pb.add('se_ca', event.category); + } + if (event.label !== undefined) { + pb.add('se_la', event.label); + } + if (event.property !== undefined) { + pb.add('se_pr', event.property); + } + if (event.value !== undefined) { + pb.add('se_va', event.value.toString()); + } + if (event.pageUrl !== undefined) { + pb.add('url', event.pageUrl); + } + if (event.pageTitle !== undefined) { + pb.add('page', event.pageTitle); + } + if (event.referrer !== undefined) { + pb.add('refr', event.referrer); + } + if (event.pingXOffsetMin !== undefined) { + pb.add('pp_mix', event.pingXOffsetMin.toString()); + } + if (event.pingXOffsetMax !== undefined) { + pb.add('pp_max', event.pingXOffsetMax.toString()); + } + if (event.pingYOffsetMin !== undefined) { + pb.add('pp_miy', event.pingYOffsetMin.toString()); + } + if (event.pingYOffsetMax !== undefined) { + pb.add('pp_may', event.pingYOffsetMax.toString()); + } + if (event.trackerVersion !== undefined) { + pb.add('tv', event.trackerVersion); + } + if (event.useragent !== undefined) { + pb.add('ua', event.useragent); + } + tracker.track(pb, data.context || []); + }); + break; + + default: + console.warn(`Unknown command from WebView: ${data.command}`); + } + }; +} diff --git a/trackers/react-native-tracker/test/web_view_interface.test.ts b/trackers/react-native-tracker/test/web_view_interface.test.ts new file mode 100644 index 000000000..da33775af --- /dev/null +++ b/trackers/react-native-tracker/test/web_view_interface.test.ts @@ -0,0 +1,386 @@ +import { getWebViewCallback, newTracker, removeTracker } from '../src'; + +function createMockFetch(status: number, requests: Request[]) { + return async (input: Request) => { + requests.push(input); + let response = new Response(null, { status }); + return response; + }; +} + +describe('WebView interface', () => { + let requests: Request[]; + let mockFetch: ReturnType; + + beforeEach(async () => { + requests = []; + mockFetch = createMockFetch(200, requests); + }); + + afterEach(() => { + removeTracker('test'); + }); + + it('tracks a page view event', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + }); + + const webViewInterface = getWebViewCallback(); + webViewInterface({ + nativeEvent: { + data: JSON.stringify({ + command: 'trackPageView', + event: { + title: 'Home', + url: 'http://localhost:9090', + referrer: 'http://refr.com', + }, + }), + }, + }); + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + + const [event] = payload.data; + expect(event.e).toBe('pv'); + expect(event.url).toBe('http://localhost:9090'); + expect(event.refr).toBe('http://refr.com'); + expect(event.page).toBe('Home'); + }); + + it('tracks a self-describing event', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + encodeBase64: false, + }); + + const webViewInterface = getWebViewCallback(); + webViewInterface({ + nativeEvent: { + data: JSON.stringify({ + command: 'trackSelfDescribingEvent', + event: { + schema: 'iglu:com.snowplowanalytics.snowplow/event/jsonschema/1-0-0', + data: { + key: 'value', + }, + }, + }), + }, + }); + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + + const [event] = payload.data; + const { e, ue_pr } = event; + expect(e).toBe('ue'); + expect(ue_pr).toBeDefined(); + const { data } = JSON.parse(ue_pr); + expect(data.schema).toBe('iglu:com.snowplowanalytics.snowplow/event/jsonschema/1-0-0'); + expect(data.data.key).toBe('value'); + }); + + it('tracks a structured event', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + }); + + const webViewInterface = getWebViewCallback(); + webViewInterface({ + nativeEvent: { + data: JSON.stringify({ + command: 'trackStructEvent', + event: { + category: 'category', + action: 'action', + label: 'label', + property: 'property', + value: 1, + }, + }), + }, + }); + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + + const [event] = payload.data; + const { e, se_ca, se_ac, se_la, se_pr, se_va } = event; + expect(e).toBe('se'); + expect(se_ca).toBe('category'); + expect(se_ac).toBe('action'); + expect(se_la).toBe('label'); + expect(se_pr).toBe('property'); + expect(se_va).toBe('1'); + }); + + it('tracks a screen view event', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + encodeBase64: false, + }); + + const webViewInterface = getWebViewCallback(); + webViewInterface({ + nativeEvent: { + data: JSON.stringify({ + command: 'trackScreenView', + event: { + name: 'Home', + id: 'home', + }, + }), + }, + }); + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + + const [event] = payload.data; + expect(event.e).toBe('ue'); + expect(event.ue_pr).toBeDefined(); + const { data } = JSON.parse(event.ue_pr); + expect(data.schema).toBe('iglu:com.snowplowanalytics.mobile/screen_view/jsonschema/1-0-0'); + expect(data.data.name).toBe('Home'); + expect(data.data.id).toBe('home'); + }); + + describe('WebView event tracking', () => { + + it('tracks a page view event', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + }); + + const webViewInterface = getWebViewCallback(); + webViewInterface({ + nativeEvent: { + data: JSON.stringify({ + command: 'trackWebViewEvent', + event: { + eventName: 'pv', + pageTitle: 'Home', + pageUrl: 'http://localhost:9090', + referrer: 'http://refr.com', + }, + }), + }, + }); + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + + const [event] = payload.data; + expect(event.e).toBe('pv'); + expect(event.url).toBe('http://localhost:9090'); + expect(event.refr).toBe('http://refr.com'); + expect(event.page).toBe('Home'); + }); + + it('tracks a self-describing event', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + encodeBase64: false, + }); + + const webViewInterface = getWebViewCallback(); + webViewInterface({ + nativeEvent: { + data: JSON.stringify({ + command: 'trackWebViewEvent', + event: { + selfDescribingEventData: { + schema: 'iglu:com.snowplowanalytics.snowplow/event/jsonschema/1-0-0', + data: { + key: 'value', + }, + }, + }, + }), + }, + }); + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + + const [event] = payload.data; + const { e, ue_pr } = event; + expect(e).toBe('ue'); + expect(ue_pr).toBeDefined(); + const { data } = JSON.parse(ue_pr); + expect(data.schema).toBe('iglu:com.snowplowanalytics.snowplow/event/jsonschema/1-0-0'); + expect(data.data.key).toBe('value'); + }); + + it('tracks a structured event', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + }); + + const webViewInterface = getWebViewCallback(); + webViewInterface({ + nativeEvent: { + data: JSON.stringify({ + command: 'trackWebViewEvent', + event: { + eventName: 'se', + category: 'category', + action: 'action', + label: 'label', + property: 'property', + value: 1, + }, + }), + }, + }); + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + + const [event] = payload.data; + const { e, se_ca, se_ac, se_la, se_pr, se_va } = event; + expect(e).toBe('se'); + expect(se_ca).toBe('category'); + expect(se_ac).toBe('action'); + expect(se_la).toBe('label'); + expect(se_pr).toBe('property'); + expect(se_va).toBe('1'); + }); + + it('tracks a page ping event', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + }); + + const webViewInterface = getWebViewCallback(); + webViewInterface({ + nativeEvent: { + data: JSON.stringify({ + command: 'trackWebViewEvent', + event: { + eventName: 'pp', + pageTitle: 'Home', + pageUrl: 'http://localhost:9090', + referrer: 'http://refr.com', + pingXOffsetMin: 1, + pingXOffsetMax: 2, + pingYOffsetMin: 3, + pingYOffsetMax: 4, + }, + }), + }, + }); + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + + const [event] = payload.data; + expect(event.e).toBe('pp'); + expect(event.url).toBe('http://localhost:9090'); + expect(event.refr).toBe('http://refr.com'); + expect(event.page).toBe('Home'); + expect(event.pp_mix).toBe('1'); + expect(event.pp_max).toBe('2'); + expect(event.pp_miy).toBe('3'); + expect(event.pp_may).toBe('4'); + }); + }); + + it('tracks tracker version and useragent', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + }); + + const webViewInterface = getWebViewCallback(); + webViewInterface({ + nativeEvent: { + data: JSON.stringify({ + command: 'trackWebViewEvent', + event: { + eventName: 'pv', + pageTitle: 'Home', + pageUrl: 'http://localhost:9090', + trackerVersion: 'wv-1.0.0', + useragent: 'Mozilla/5.0', + }, + }), + }, + }); + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + + const [event] = payload.data; + expect(event.e).toBe('pv'); + expect(event.url).toBe('http://localhost:9090'); + expect(event.page).toBe('Home'); + expect(event.tv).toBe('wv-1.0.0'); + expect(event.ua).toBe('Mozilla/5.0'); + }); +});