Skip to content

Commit 19af78c

Browse files
committed
Add internal session plugin to the React Native tracker
1 parent 0a00be2 commit 19af78c

File tree

11 files changed

+470
-30
lines changed

11 files changed

+470
-30
lines changed

common/config/rush/browser-approved-packages.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@
212212
},
213213
{
214214
"name": "@types/uuid",
215-
"allowedCategories": [ "libraries", "plugins" ]
215+
"allowedCategories": [ "libraries", "plugins", "trackers" ]
216216
},
217217
{
218218
"name": "@types/vimeo__player",
@@ -440,7 +440,7 @@
440440
},
441441
{
442442
"name": "uuid",
443-
"allowedCategories": [ "libraries", "plugins" ]
443+
"allowedCategories": [ "libraries", "plugins", "trackers" ]
444444
},
445445
{
446446
"name": "wdio-chromedriver-service",

common/config/rush/pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/config/rush/repo-state.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
22
{
3-
"pnpmShrinkwrapHash": "f5c19c955ef1b13843dbacdb20bdd6461a9a5042",
3+
"pnpmShrinkwrapHash": "bf1ad132a0781c6f74cda6742bc190f7802256f1",
44
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
55
}

trackers/react-native-tracker/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@
5050
"@snowplow/tracker-core": "workspace:*",
5151
"@react-native-async-storage/async-storage": "~2.0.0",
5252
"react-native-get-random-values": "~1.11.0",
53-
"tslib": "^2.3.1"
53+
"tslib": "^2.3.1",
54+
"uuid": "^10.0.0"
5455
},
5556
"devDependencies": {
5657
"@typescript-eslint/eslint-plugin": "~5.15.0",
@@ -59,6 +60,7 @@
5960
"typescript": "~4.6.2",
6061
"@types/jest": "~28.1.1",
6162
"@types/node": "~14.6.0",
63+
"@types/uuid": "^10.0.0",
6264
"jest": "~28.1.3",
6365
"react": "18.2.0",
6466
"ts-jest": "~28.0.8",
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const FOREGROUND_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/application_foreground/jsonschema/1-0-0';
2+
export const BACKGROUND_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/application_background/jsonschema/1-0-0';
3+
4+
export const CLIENT_SESSION_ENTITY_SCHEMA ='iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2'
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { CorePluginConfiguration, PayloadBuilder } from '@snowplow/tracker-core';
2+
import { SessionConfiguration, SessionState, TrackerConfiguration } from '../../types';
3+
import AsyncStorage from '@react-native-async-storage/async-storage';
4+
import { v4 as uuidv4 } from 'uuid';
5+
import { BACKGROUND_EVENT_SCHEMA, CLIENT_SESSION_ENTITY_SCHEMA, FOREGROUND_EVENT_SCHEMA } from '../../constants';
6+
import { getUsefulSchema } from '../../utils';
7+
8+
interface StoredSessionState {
9+
userId: string;
10+
sessionId: string;
11+
sessionIndex: number;
12+
}
13+
14+
interface SessionPlugin extends CorePluginConfiguration {
15+
getSessionUserId: () => Promise<string | undefined>;
16+
getSessionId: () => Promise<string | undefined>;
17+
getSessionIndex: () => Promise<number | undefined>;
18+
getSessionState: () => Promise<SessionState>;
19+
startNewSession: () => Promise<void>;
20+
}
21+
22+
async function storeSessionState(namespace: string, state: StoredSessionState) {
23+
const { userId, sessionId, sessionIndex } = state;
24+
await AsyncStorage.setItem(`snowplow_${namespace}_session`, JSON.stringify({ userId, sessionId, sessionIndex }));
25+
}
26+
27+
async function resumeStoredSession(namespace: string): Promise<SessionState> {
28+
const storedState = await AsyncStorage.getItem(`snowplow_${namespace}_session`);
29+
if (storedState) {
30+
const state = JSON.parse(storedState) as StoredSessionState;
31+
return {
32+
userId: state.userId,
33+
sessionId: uuidv4(),
34+
previousSessionId: state.sessionId,
35+
sessionIndex: state.sessionIndex + 1,
36+
storageMechanism: 'LOCAL_STORAGE',
37+
};
38+
} else {
39+
return {
40+
userId: uuidv4(),
41+
sessionId: uuidv4(),
42+
sessionIndex: 1,
43+
storageMechanism: 'LOCAL_STORAGE',
44+
};
45+
}
46+
}
47+
48+
export async function newSessionPlugin({
49+
namespace,
50+
foregroundSessionTimeout,
51+
backgroundSessionTimeout,
52+
}: TrackerConfiguration & SessionConfiguration): Promise<SessionPlugin> {
53+
let sessionState = await resumeStoredSession(namespace);
54+
await storeSessionState(namespace, sessionState);
55+
56+
let inBackground = false;
57+
let lastUpdateTs = new Date().getTime();
58+
59+
const startNewSession = async () => {
60+
sessionState = {
61+
userId: sessionState.userId,
62+
storageMechanism: sessionState.storageMechanism,
63+
sessionId: uuidv4(),
64+
sessionIndex: sessionState.sessionIndex + 1,
65+
previousSessionId: sessionState.sessionId,
66+
};
67+
};
68+
69+
const getTimeoutMs = () => {
70+
return ((inBackground ? backgroundSessionTimeout : foregroundSessionTimeout) ?? 30 * 60) * 1000;
71+
};
72+
73+
const beforeTrack = (payloadBuilder: PayloadBuilder) => {
74+
// check if session has timed out and start a new one if necessary
75+
const now = new Date();
76+
const timeDiff = now.getTime() - lastUpdateTs;
77+
if (timeDiff > getTimeoutMs()) {
78+
startNewSession();
79+
storeSessionState(namespace, sessionState);
80+
}
81+
lastUpdateTs = now.getTime();
82+
83+
// update event properties
84+
sessionState.eventIndex = (sessionState.eventIndex ?? 0) + 1;
85+
if (sessionState.eventIndex === 1) {
86+
sessionState.firstEventId = payloadBuilder.getPayload().eid as string;
87+
sessionState.firstEventTimestamp = now.toISOString();
88+
}
89+
90+
// update background state
91+
if (payloadBuilder.getPayload().e === 'ue') {
92+
const schema = getUsefulSchema(payloadBuilder);
93+
if (schema === FOREGROUND_EVENT_SCHEMA) {
94+
inBackground = false;
95+
} else if (schema === BACKGROUND_EVENT_SCHEMA) {
96+
inBackground = true;
97+
}
98+
}
99+
100+
// add session context to the payload
101+
payloadBuilder.addContextEntity({
102+
schema: CLIENT_SESSION_ENTITY_SCHEMA,
103+
data: { ...sessionState },
104+
});
105+
};
106+
107+
return {
108+
getSessionUserId: () => Promise.resolve(sessionState.userId),
109+
getSessionId: () => Promise.resolve(sessionState.sessionId),
110+
getSessionIndex: () => Promise.resolve(sessionState.sessionIndex),
111+
getSessionState: () => Promise.resolve(sessionState),
112+
startNewSession,
113+
plugin: {
114+
beforeTrack,
115+
},
116+
};
117+
}

trackers/react-native-tracker/src/tracker.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
SubjectConfiguration,
1313
TrackerConfiguration,
1414
} from './types';
15+
import { newSessionPlugin } from './plugins/session';
1516

1617
const initializedTrackers: Record<string, { tracker: ReactNativeTracker; core: TrackerCore }> = {};
1718

@@ -40,14 +41,17 @@ export async function newTracker(
4041
const subject = newSubject(core, configuration);
4142
core.addPlugin(subject.subjectPlugin);
4243

44+
const sessionPlugin = await newSessionPlugin(configuration);
45+
core.addPlugin(sessionPlugin);
46+
4347
core.setPlatform('mob'); // default platform
4448
core.setTrackerVersion('rn-' + version);
4549
core.setTrackerNamespace(namespace);
4650
if (appId) {
4751
core.setAppId(appId);
4852
}
4953

50-
const tracker = {
54+
const tracker: ReactNativeTracker = {
5155
...newTrackEventFunctions(core),
5256
...subject.properties,
5357
setAppId: core.setAppId,
@@ -57,6 +61,10 @@ export async function newTracker(
5761
removeGlobalContexts: core.removeGlobalContexts,
5862
clearGlobalContexts: core.clearGlobalContexts,
5963
addPlugin: core.addPlugin,
64+
getSessionId: sessionPlugin.getSessionId,
65+
getSessionIndex: sessionPlugin.getSessionIndex,
66+
getSessionUserId: sessionPlugin.getSessionUserId,
67+
getSessionState: sessionPlugin.getSessionState,
6068
};
6169
initializedTrackers[namespace] = { tracker, core };
6270
return tracker;

trackers/react-native-tracker/src/types.ts

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,44 @@ export type DeepLinkReceivedProps = {
333333
referrer?: string;
334334
};
335335

336+
/**
337+
* Current session state that is tracked in events.
338+
*/
339+
export interface SessionState {
340+
/**
341+
* An identifier for the user of the session
342+
*/
343+
userId: string;
344+
/**
345+
* An identifier for the session
346+
*/
347+
sessionId: string;
348+
/**
349+
* The index of the current session for this user
350+
*/
351+
sessionIndex: number;
352+
/**
353+
* Optional index of the current event in the session
354+
*/
355+
eventIndex?: number;
356+
/**
357+
* The previous session identifier for this user
358+
*/
359+
previousSessionId?: string;
360+
/**
361+
* The mechanism that the session information has been stored on the device
362+
*/
363+
storageMechanism: string;
364+
/**
365+
* The optional identifier of the first event for this session
366+
*/
367+
firstEventId?: string;
368+
/**
369+
* Optional date-time timestamp of when the first event in the session was tracked
370+
*/
371+
firstEventTimestamp?: string;
372+
}
373+
336374
/**
337375
* The ReactNativeTracker type
338376
*/
@@ -542,29 +580,33 @@ export type ReactNativeTracker = {
542580
*/
543581
readonly setSubjectData: (config: SubjectConfiguration) => void;
544582

545-
// TODO:
546-
// /**
547-
// * Gets the identifier for the user of the session
548-
// *
549-
// * @returns {Promise<string | undefined>}
550-
// */
551-
// readonly getSessionUserId: () => Promise<string | undefined>;
583+
/**
584+
* Gets the identifier for the user of the session
585+
*
586+
* @returns {Promise<string | undefined>}
587+
*/
588+
readonly getSessionUserId: () => Promise<string | undefined>;
552589

553-
// TODO:
554-
// /**
555-
// * Gets the identifier for the session
556-
// *
557-
// * @returns {Promise<string | undefined>}
558-
// */
559-
// readonly getSessionId: () => Promise<string | undefined>;
590+
/**
591+
* Gets the identifier for the session
592+
*
593+
* @returns {Promise<string | undefined>}
594+
*/
595+
readonly getSessionId: () => Promise<string | undefined>;
560596

561-
// TODO:
562-
// /**
563-
// * Gets the index of the current session for this user
564-
// *
565-
// * @returns {Promise<number | undefined>}
566-
// */
567-
// readonly getSessionIndex: () => Promise<number | undefined>;
597+
/**
598+
* Gets the index of the current session for this user
599+
*
600+
* @returns {Promise<number | undefined>}
601+
*/
602+
readonly getSessionIndex: () => Promise<number | undefined>;
603+
604+
/**
605+
* Gets the current session state
606+
*
607+
* @returns {Promise<SessionState | undefined>}
608+
*/
609+
readonly getSessionState: () => Promise<SessionState | undefined>;
568610

569611
// TODO:
570612
// /**
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { PayloadBuilder } from '@snowplow/tracker-core';
2+
3+
// Returns the "useful" schema, i.e. what would someone want to use to identify events.
4+
// For some events this is the 'e' property but for unstructured events, this is the
5+
// 'schema' from the 'ue_px' field.
6+
export function getUsefulSchema(sb: PayloadBuilder): string {
7+
let eventJson = sb.getJson();
8+
for (const json of eventJson) {
9+
if (json.keyIfEncoded === 'ue_px' && typeof json.json['data'] === 'object') {
10+
const schema = (json.json['data'] as Record<string, unknown>)['schema'];
11+
if (typeof schema == 'string') {
12+
return schema;
13+
}
14+
}
15+
}
16+
return '';
17+
}

0 commit comments

Comments
 (0)