Skip to content

Commit

Permalink
Add internal session plugin to the React Native tracker
Browse files Browse the repository at this point in the history
  • Loading branch information
matus-tomlein committed Nov 21, 2024
1 parent 0a00be2 commit 19af78c
Show file tree
Hide file tree
Showing 11 changed files with 470 additions and 30 deletions.
4 changes: 2 additions & 2 deletions common/config/rush/browser-approved-packages.json
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@
},
{
"name": "@types/uuid",
"allowedCategories": [ "libraries", "plugins" ]
"allowedCategories": [ "libraries", "plugins", "trackers" ]
},
{
"name": "@types/vimeo__player",
Expand Down Expand Up @@ -440,7 +440,7 @@
},
{
"name": "uuid",
"allowedCategories": [ "libraries", "plugins" ]
"allowedCategories": [ "libraries", "plugins", "trackers" ]
},
{
"name": "wdio-chromedriver-service",
Expand Down
6 changes: 6 additions & 0 deletions common/config/rush/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion common/config/rush/repo-state.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush.
{
"pnpmShrinkwrapHash": "f5c19c955ef1b13843dbacdb20bdd6461a9a5042",
"pnpmShrinkwrapHash": "bf1ad132a0781c6f74cda6742bc190f7802256f1",
"preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f"
}
4 changes: 3 additions & 1 deletion trackers/react-native-tracker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"@snowplow/tracker-core": "workspace:*",
"@react-native-async-storage/async-storage": "~2.0.0",
"react-native-get-random-values": "~1.11.0",
"tslib": "^2.3.1"
"tslib": "^2.3.1",
"uuid": "^10.0.0"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "~5.15.0",
Expand All @@ -59,6 +60,7 @@
"typescript": "~4.6.2",
"@types/jest": "~28.1.1",
"@types/node": "~14.6.0",
"@types/uuid": "^10.0.0",
"jest": "~28.1.3",
"react": "18.2.0",
"ts-jest": "~28.0.8",
Expand Down
4 changes: 4 additions & 0 deletions trackers/react-native-tracker/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const FOREGROUND_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/application_foreground/jsonschema/1-0-0';
export const BACKGROUND_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/application_background/jsonschema/1-0-0';

export const CLIENT_SESSION_ENTITY_SCHEMA ='iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2'
117 changes: 117 additions & 0 deletions trackers/react-native-tracker/src/plugins/session/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { CorePluginConfiguration, PayloadBuilder } from '@snowplow/tracker-core';
import { SessionConfiguration, SessionState, TrackerConfiguration } from '../../types';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { v4 as uuidv4 } from 'uuid';
import { BACKGROUND_EVENT_SCHEMA, CLIENT_SESSION_ENTITY_SCHEMA, FOREGROUND_EVENT_SCHEMA } from '../../constants';
import { getUsefulSchema } from '../../utils';

interface StoredSessionState {
userId: string;
sessionId: string;
sessionIndex: number;
}

interface SessionPlugin extends CorePluginConfiguration {
getSessionUserId: () => Promise<string | undefined>;
getSessionId: () => Promise<string | undefined>;
getSessionIndex: () => Promise<number | undefined>;
getSessionState: () => Promise<SessionState>;
startNewSession: () => Promise<void>;
}

async function storeSessionState(namespace: string, state: StoredSessionState) {
const { userId, sessionId, sessionIndex } = state;
await AsyncStorage.setItem(`snowplow_${namespace}_session`, JSON.stringify({ userId, sessionId, sessionIndex }));
}

async function resumeStoredSession(namespace: string): Promise<SessionState> {
const storedState = await AsyncStorage.getItem(`snowplow_${namespace}_session`);
if (storedState) {
const state = JSON.parse(storedState) as StoredSessionState;
return {
userId: state.userId,
sessionId: uuidv4(),
previousSessionId: state.sessionId,
sessionIndex: state.sessionIndex + 1,
storageMechanism: 'LOCAL_STORAGE',
};
} else {
return {
userId: uuidv4(),
sessionId: uuidv4(),
sessionIndex: 1,
storageMechanism: 'LOCAL_STORAGE',
};
}
}

export async function newSessionPlugin({
namespace,
foregroundSessionTimeout,
backgroundSessionTimeout,
}: TrackerConfiguration & SessionConfiguration): Promise<SessionPlugin> {
let sessionState = await resumeStoredSession(namespace);
await storeSessionState(namespace, sessionState);

let inBackground = false;
let lastUpdateTs = new Date().getTime();

const startNewSession = async () => {
sessionState = {
userId: sessionState.userId,
storageMechanism: sessionState.storageMechanism,
sessionId: uuidv4(),
sessionIndex: sessionState.sessionIndex + 1,
previousSessionId: sessionState.sessionId,
};
};

const getTimeoutMs = () => {
return ((inBackground ? backgroundSessionTimeout : foregroundSessionTimeout) ?? 30 * 60) * 1000;
};

const beforeTrack = (payloadBuilder: PayloadBuilder) => {
// check if session has timed out and start a new one if necessary
const now = new Date();
const timeDiff = now.getTime() - lastUpdateTs;
if (timeDiff > getTimeoutMs()) {
startNewSession();
storeSessionState(namespace, sessionState);
}
lastUpdateTs = now.getTime();

// update event properties
sessionState.eventIndex = (sessionState.eventIndex ?? 0) + 1;
if (sessionState.eventIndex === 1) {
sessionState.firstEventId = payloadBuilder.getPayload().eid as string;
sessionState.firstEventTimestamp = now.toISOString();
}

// update background state
if (payloadBuilder.getPayload().e === 'ue') {
const schema = getUsefulSchema(payloadBuilder);
if (schema === FOREGROUND_EVENT_SCHEMA) {
inBackground = false;
} else if (schema === BACKGROUND_EVENT_SCHEMA) {
inBackground = true;
}
}

// add session context to the payload
payloadBuilder.addContextEntity({
schema: CLIENT_SESSION_ENTITY_SCHEMA,
data: { ...sessionState },
});
};

return {
getSessionUserId: () => Promise.resolve(sessionState.userId),
getSessionId: () => Promise.resolve(sessionState.sessionId),
getSessionIndex: () => Promise.resolve(sessionState.sessionIndex),
getSessionState: () => Promise.resolve(sessionState),
startNewSession,
plugin: {
beforeTrack,
},
};
}
10 changes: 9 additions & 1 deletion trackers/react-native-tracker/src/tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
SubjectConfiguration,
TrackerConfiguration,
} from './types';
import { newSessionPlugin } from './plugins/session';

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

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

const sessionPlugin = await newSessionPlugin(configuration);
core.addPlugin(sessionPlugin);

core.setPlatform('mob'); // default platform
core.setTrackerVersion('rn-' + version);
core.setTrackerNamespace(namespace);
if (appId) {
core.setAppId(appId);
}

const tracker = {
const tracker: ReactNativeTracker = {
...newTrackEventFunctions(core),
...subject.properties,
setAppId: core.setAppId,
Expand All @@ -57,6 +61,10 @@ export async function newTracker(
removeGlobalContexts: core.removeGlobalContexts,
clearGlobalContexts: core.clearGlobalContexts,
addPlugin: core.addPlugin,
getSessionId: sessionPlugin.getSessionId,
getSessionIndex: sessionPlugin.getSessionIndex,
getSessionUserId: sessionPlugin.getSessionUserId,
getSessionState: sessionPlugin.getSessionState,
};
initializedTrackers[namespace] = { tracker, core };
return tracker;
Expand Down
84 changes: 63 additions & 21 deletions trackers/react-native-tracker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,44 @@ export type DeepLinkReceivedProps = {
referrer?: string;
};

/**
* Current session state that is tracked in events.
*/
export interface SessionState {
/**
* An identifier for the user of the session
*/
userId: string;
/**
* An identifier for the session
*/
sessionId: string;
/**
* The index of the current session for this user
*/
sessionIndex: number;
/**
* Optional index of the current event in the session
*/
eventIndex?: number;
/**
* The previous session identifier for this user
*/
previousSessionId?: string;
/**
* The mechanism that the session information has been stored on the device
*/
storageMechanism: string;
/**
* The optional identifier of the first event for this session
*/
firstEventId?: string;
/**
* Optional date-time timestamp of when the first event in the session was tracked
*/
firstEventTimestamp?: string;
}

/**
* The ReactNativeTracker type
*/
Expand Down Expand Up @@ -542,29 +580,33 @@ export type ReactNativeTracker = {
*/
readonly setSubjectData: (config: SubjectConfiguration) => void;

// TODO:
// /**
// * Gets the identifier for the user of the session
// *
// * @returns {Promise<string | undefined>}
// */
// readonly getSessionUserId: () => Promise<string | undefined>;
/**
* Gets the identifier for the user of the session
*
* @returns {Promise<string | undefined>}
*/
readonly getSessionUserId: () => Promise<string | undefined>;

// TODO:
// /**
// * Gets the identifier for the session
// *
// * @returns {Promise<string | undefined>}
// */
// readonly getSessionId: () => Promise<string | undefined>;
/**
* Gets the identifier for the session
*
* @returns {Promise<string | undefined>}
*/
readonly getSessionId: () => Promise<string | undefined>;

// TODO:
// /**
// * Gets the index of the current session for this user
// *
// * @returns {Promise<number | undefined>}
// */
// readonly getSessionIndex: () => Promise<number | undefined>;
/**
* Gets the index of the current session for this user
*
* @returns {Promise<number | undefined>}
*/
readonly getSessionIndex: () => Promise<number | undefined>;

/**
* Gets the current session state
*
* @returns {Promise<SessionState | undefined>}
*/
readonly getSessionState: () => Promise<SessionState | undefined>;

// TODO:
// /**
Expand Down
17 changes: 17 additions & 0 deletions trackers/react-native-tracker/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { PayloadBuilder } from '@snowplow/tracker-core';

// Returns the "useful" schema, i.e. what would someone want to use to identify events.
// For some events this is the 'e' property but for unstructured events, this is the
// 'schema' from the 'ue_px' field.
export function getUsefulSchema(sb: PayloadBuilder): string {
let eventJson = sb.getJson();
for (const json of eventJson) {
if (json.keyIfEncoded === 'ue_px' && typeof json.json['data'] === 'object') {
const schema = (json.json['data'] as Record<string, unknown>)['schema'];
if (typeof schema == 'string') {
return schema;
}
}
}
return '';
}
Loading

0 comments on commit 19af78c

Please sign in to comment.