Skip to content

Commit

Permalink
Add WebView tracker integration to the React Native tracker
Browse files Browse the repository at this point in the history
  • Loading branch information
matus-tomlein committed Dec 10, 2024
1 parent d6a87a0 commit bdb48ce
Show file tree
Hide file tree
Showing 5 changed files with 633 additions and 3 deletions.
3 changes: 2 additions & 1 deletion trackers/react-native-tracker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
25 changes: 25 additions & 0 deletions trackers/react-native-tracker/src/tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
45 changes: 43 additions & 2 deletions trackers/react-native-tracker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,10 @@ export interface SessionState {
firstEventTimestamp?: string;
}

export type StructuredProps = StructuredEvent;

export type SelfDescribing<T = Record<string, unknown>> = SelfDescribingJson<T>;

/**
* The ReactNativeTracker type
*/
Expand All @@ -600,7 +604,7 @@ export type ReactNativeTracker = {
* @typeParam TData - The type of the data object within the SelfDescribing object
*/
readonly trackSelfDescribingEvent: <T extends Record<string, unknown> = Record<string, unknown>>(
argmap: SelfDescribingJson<T>,
argmap: SelfDescribing<T>,
contexts?: EventContext[]
) => void;

Expand Down Expand Up @@ -854,12 +858,49 @@ export type ReactNativeTracker = {
readonly refreshPlatformContext: () => Promise<void>;
};

/**
* 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<SelfDescribing> | null;
trackers?: Array<string>;
};

export {
version,
PageViewEvent,
StructuredEvent,
FormFocusOrChangeEvent,
SelfDescribingJson,
Timestamp,
PayloadBuilder,
Payload,
Expand Down
177 changes: 177 additions & 0 deletions trackers/react-native-tracker/src/web_view_interface.ts
Original file line number Diff line number Diff line change
@@ -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<string> | 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<string> | 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<string>();

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:
* <WebView onMessage={getWebViewCallback()} ... />
*
* @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}`);
}
};
}
Loading

0 comments on commit bdb48ce

Please sign in to comment.