From 939388c34ed70e5ed6c4364f0d841bea428eaaaa Mon Sep 17 00:00:00 2001 From: Matus Tomlein Date: Thu, 28 Nov 2024 14:21:24 +0100 Subject: [PATCH] Add internal session plugin to the React Native tracker (#1388) --- .../markdown/react-native-tracker.md | 1 + ...react-native-tracker.reactnativetracker.md | 6 +- ...-native-tracker.sessionstate.eventindex.md | 13 ++ ...ative-tracker.sessionstate.firsteventid.md | 13 ++ ...racker.sessionstate.firsteventtimestamp.md | 13 ++ .../react-native-tracker.sessionstate.md | 27 +++ ...-tracker.sessionstate.previoussessionid.md | 13 ++ ...t-native-tracker.sessionstate.sessionid.md | 13 ++ ...ative-tracker.sessionstate.sessionindex.md | 13 ++ ...e-tracker.sessionstate.storagemechanism.md | 13 ++ ...eact-native-tracker.sessionstate.userid.md | 13 ++ .../react-native-tracker.api.md | 16 ++ ...react_native_session_2024-11-21-13-47.json | 10 + .../rush/browser-approved-packages.json | 4 +- common/config/rush/pnpm-lock.yaml | 6 + common/config/rush/repo-state.json | 2 +- trackers/react-native-tracker/package.json | 4 +- .../react-native-tracker/src/constants.ts | 4 + .../src/plugins/session/index.ts | 124 ++++++++++ trackers/react-native-tracker/src/tracker.ts | 10 +- trackers/react-native-tracker/src/types.ts | 76 ++++-- trackers/react-native-tracker/src/utils.ts | 17 ++ .../test/plugins/session.test.ts | 221 ++++++++++++++++++ .../react-native-tracker/test/tracker.test.ts | 31 ++- 24 files changed, 632 insertions(+), 31 deletions(-) create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.eventindex.md create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.firsteventid.md create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.firsteventtimestamp.md create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.md create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.previoussessionid.md create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.sessionid.md create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.sessionindex.md create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.storagemechanism.md create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.userid.md create mode 100644 common/changes/@snowplow/react-native-tracker/issue-react_native_session_2024-11-21-13-47.json create mode 100644 trackers/react-native-tracker/src/constants.ts create mode 100644 trackers/react-native-tracker/src/plugins/session/index.ts create mode 100644 trackers/react-native-tracker/src/utils.ts create mode 100644 trackers/react-native-tracker/test/plugins/session.test.ts diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md index 10f2ebe20..48913f89f 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md @@ -41,6 +41,7 @@ | [PayloadBuilder](./react-native-tracker.payloadbuilder.md) | Interface for mutable object encapsulating tracker payload | | [RuleSet](./react-native-tracker.ruleset.md) | A ruleset has accept or reject properties that contain rules for matching Iglu schema URIs | | [SessionConfiguration](./react-native-tracker.sessionconfiguration.md) | Configuration for session tracking | +| [SessionState](./react-native-tracker.sessionstate.md) | Current session state that is tracked in events. | | [StructuredEvent](./react-native-tracker.structuredevent.md) | A Structured Event A classic style of event tracking, allows for easier movement between analytics systems. A loosely typed event, creating a Self Describing event is preferred, but useful for interoperability. | | [SubjectConfiguration](./react-native-tracker.subjectconfiguration.md) | Configuration of subject properties tracked with events | | [TrackerConfiguration](./react-native-tracker.trackerconfiguration.md) | The configuration object for initialising the tracker | diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md index 00f678ae0..6d8f6dd05 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md @@ -33,7 +33,11 @@ export declare type ReactNativeTracker = { readonly setScreenViewport: (newView: ScreenSize) => void; readonly setColorDepth: (newLang: number) => void; readonly setSubjectData: (config: SubjectConfiguration) => void; + readonly getSessionUserId: () => Promise; + readonly getSessionId: () => Promise; + readonly getSessionIndex: () => Promise; + readonly getSessionState: () => Promise; }; ``` -References: [EventContext](./react-native-tracker.eventcontext.md), [TimingProps](./react-native-tracker.timingprops.md), [MessageNotificationProps](./react-native-tracker.messagenotificationprops.md), [ScreenSize](./react-native-tracker.screensize.md), [SubjectConfiguration](./react-native-tracker.subjectconfiguration.md) +References: [EventContext](./react-native-tracker.eventcontext.md), [TimingProps](./react-native-tracker.timingprops.md), [MessageNotificationProps](./react-native-tracker.messagenotificationprops.md), [ScreenSize](./react-native-tracker.screensize.md), [SubjectConfiguration](./react-native-tracker.subjectconfiguration.md), [SessionState](./react-native-tracker.sessionstate.md) diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.eventindex.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.eventindex.md new file mode 100644 index 000000000..c6bd011e1 --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.eventindex.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [SessionState](./react-native-tracker.sessionstate.md) > [eventIndex](./react-native-tracker.sessionstate.eventindex.md) + +## SessionState.eventIndex property + +Optional index of the current event in the session + +Signature: + +```typescript +eventIndex?: number; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.firsteventid.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.firsteventid.md new file mode 100644 index 000000000..fa6fa9ea6 --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.firsteventid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [SessionState](./react-native-tracker.sessionstate.md) > [firstEventId](./react-native-tracker.sessionstate.firsteventid.md) + +## SessionState.firstEventId property + +The optional identifier of the first event for this session + +Signature: + +```typescript +firstEventId?: string; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.firsteventtimestamp.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.firsteventtimestamp.md new file mode 100644 index 000000000..b7530cbc5 --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.firsteventtimestamp.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [SessionState](./react-native-tracker.sessionstate.md) > [firstEventTimestamp](./react-native-tracker.sessionstate.firsteventtimestamp.md) + +## SessionState.firstEventTimestamp property + +Optional date-time timestamp of when the first event in the session was tracked + +Signature: + +```typescript +firstEventTimestamp?: string; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.md new file mode 100644 index 000000000..3146ea3b2 --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.md @@ -0,0 +1,27 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [SessionState](./react-native-tracker.sessionstate.md) + +## SessionState interface + +Current session state that is tracked in events. + +Signature: + +```typescript +export interface SessionState +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [eventIndex?](./react-native-tracker.sessionstate.eventindex.md) | number | (Optional) Optional index of the current event in the session | +| [firstEventId?](./react-native-tracker.sessionstate.firsteventid.md) | string | (Optional) The optional identifier of the first event for this session | +| [firstEventTimestamp?](./react-native-tracker.sessionstate.firsteventtimestamp.md) | string | (Optional) Optional date-time timestamp of when the first event in the session was tracked | +| [previousSessionId?](./react-native-tracker.sessionstate.previoussessionid.md) | string | (Optional) The previous session identifier for this user | +| [sessionId](./react-native-tracker.sessionstate.sessionid.md) | string | An identifier for the session | +| [sessionIndex](./react-native-tracker.sessionstate.sessionindex.md) | number | The index of the current session for this user | +| [storageMechanism](./react-native-tracker.sessionstate.storagemechanism.md) | string | The mechanism that the session information has been stored on the device | +| [userId](./react-native-tracker.sessionstate.userid.md) | string | An identifier for the user of the session | + diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.previoussessionid.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.previoussessionid.md new file mode 100644 index 000000000..159517a4b --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.previoussessionid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [SessionState](./react-native-tracker.sessionstate.md) > [previousSessionId](./react-native-tracker.sessionstate.previoussessionid.md) + +## SessionState.previousSessionId property + +The previous session identifier for this user + +Signature: + +```typescript +previousSessionId?: string; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.sessionid.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.sessionid.md new file mode 100644 index 000000000..1da413386 --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.sessionid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [SessionState](./react-native-tracker.sessionstate.md) > [sessionId](./react-native-tracker.sessionstate.sessionid.md) + +## SessionState.sessionId property + +An identifier for the session + +Signature: + +```typescript +sessionId: string; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.sessionindex.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.sessionindex.md new file mode 100644 index 000000000..6c4e5ee6c --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.sessionindex.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [SessionState](./react-native-tracker.sessionstate.md) > [sessionIndex](./react-native-tracker.sessionstate.sessionindex.md) + +## SessionState.sessionIndex property + +The index of the current session for this user + +Signature: + +```typescript +sessionIndex: number; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.storagemechanism.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.storagemechanism.md new file mode 100644 index 000000000..1e96c113c --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.storagemechanism.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [SessionState](./react-native-tracker.sessionstate.md) > [storageMechanism](./react-native-tracker.sessionstate.storagemechanism.md) + +## SessionState.storageMechanism property + +The mechanism that the session information has been stored on the device + +Signature: + +```typescript +storageMechanism: string; +``` diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.userid.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.userid.md new file mode 100644 index 000000000..a2151d0b2 --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.sessionstate.userid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [SessionState](./react-native-tracker.sessionstate.md) > [userId](./react-native-tracker.sessionstate.userid.md) + +## SessionState.userId property + +An identifier for the user of the session + +Signature: + +```typescript +userId: string; +``` diff --git a/api-docs/docs/react-native-tracker/react-native-tracker.api.md b/api-docs/docs/react-native-tracker/react-native-tracker.api.md index a48e04ddf..8f8131227 100644 --- a/api-docs/docs/react-native-tracker/react-native-tracker.api.md +++ b/api-docs/docs/react-native-tracker/react-native-tracker.api.md @@ -300,6 +300,10 @@ export type ReactNativeTracker = { readonly setScreenViewport: (newView: ScreenSize) => void; readonly setColorDepth: (newLang: number) => void; readonly setSubjectData: (config: SubjectConfiguration) => void; + readonly getSessionUserId: () => Promise; + readonly getSessionId: () => Promise; + readonly getSessionIndex: () => Promise; + readonly getSessionState: () => Promise; }; // @public @@ -366,6 +370,18 @@ export interface SessionConfiguration { foregroundSessionTimeout?: number; } +// @public +export interface SessionState { + eventIndex?: number; + firstEventId?: string; + firstEventTimestamp?: string; + previousSessionId?: string; + sessionId: string; + sessionIndex: number; + storageMechanism: string; + userId: string; +} + // @public export interface StructuredEvent { // (undocumented) diff --git a/common/changes/@snowplow/react-native-tracker/issue-react_native_session_2024-11-21-13-47.json b/common/changes/@snowplow/react-native-tracker/issue-react_native_session_2024-11-21-13-47.json new file mode 100644 index 000000000..89cef31e3 --- /dev/null +++ b/common/changes/@snowplow/react-native-tracker/issue-react_native_session_2024-11-21-13-47.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/react-native-tracker", + "comment": "Add internal session plugin to the React Native tracker (#1388)", + "type": "none" + } + ], + "packageName": "@snowplow/react-native-tracker" +} \ No newline at end of file diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index e6db33654..8157b3617 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -212,7 +212,7 @@ }, { "name": "@types/uuid", - "allowedCategories": [ "libraries", "plugins" ] + "allowedCategories": [ "libraries", "plugins", "trackers" ] }, { "name": "@types/vimeo__player", @@ -440,7 +440,7 @@ }, { "name": "uuid", - "allowedCategories": [ "libraries", "plugins" ] + "allowedCategories": [ "libraries", "plugins", "trackers" ] }, { "name": "wdio-chromedriver-service", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 74df296f4..fc5f51ccc 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -2370,6 +2370,9 @@ importers: tslib: specifier: ^2.3.1 version: 2.7.0 + uuid: + specifier: ^10.0.0 + version: 10.0.0 devDependencies: '@types/jest': specifier: ~28.1.1 @@ -2380,6 +2383,9 @@ importers: '@types/react': specifier: ^18.2.44 version: 18.3.12 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 '@typescript-eslint/eslint-plugin': specifier: ~5.15.0 version: 5.15.0(@typescript-eslint/parser@5.15.0(eslint@8.11.0)(typescript@4.6.4))(eslint@8.11.0)(typescript@4.6.4) diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index eab5a64ce..d50fdf0a1 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "8169e5436cbe751223029ee55f3f12295943b739", + "pnpmShrinkwrapHash": "50fa04b4f2a1f9fdbf83a84ce901759859832bfd", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/trackers/react-native-tracker/package.json b/trackers/react-native-tracker/package.json index 612cca887..159a4b961 100644 --- a/trackers/react-native-tracker/package.json +++ b/trackers/react-native-tracker/package.json @@ -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", @@ -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", diff --git a/trackers/react-native-tracker/src/constants.ts b/trackers/react-native-tracker/src/constants.ts new file mode 100644 index 000000000..390c80228 --- /dev/null +++ b/trackers/react-native-tracker/src/constants.ts @@ -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' diff --git a/trackers/react-native-tracker/src/plugins/session/index.ts b/trackers/react-native-tracker/src/plugins/session/index.ts new file mode 100644 index 000000000..70b32776a --- /dev/null +++ b/trackers/react-native-tracker/src/plugins/session/index.ts @@ -0,0 +1,124 @@ +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; + getSessionId: () => Promise; + getSessionIndex: () => Promise; + getSessionState: () => Promise; + startNewSession: () => Promise; +} + +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 { + 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', + }; + } +} + +/** + * Creates a new session plugin for tracking the session information. + * The plugin will add the session context to all events and start a new session if the current one has timed out. + * + * The session state is stored in AsyncStorage. + * Each restart of the app or creation of a new tracker instance will trigger a new session with reference to the previous session. + */ +export async function newSessionPlugin({ + namespace, + foregroundSessionTimeout, + backgroundSessionTimeout, +}: TrackerConfiguration & SessionConfiguration): Promise { + 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, + }, + }; +} diff --git a/trackers/react-native-tracker/src/tracker.ts b/trackers/react-native-tracker/src/tracker.ts index fca43fc5c..ac5013d0e 100644 --- a/trackers/react-native-tracker/src/tracker.ts +++ b/trackers/react-native-tracker/src/tracker.ts @@ -12,6 +12,7 @@ import { SubjectConfiguration, TrackerConfiguration, } from './types'; +import { newSessionPlugin } from './plugins/session'; const initializedTrackers: Record = {}; @@ -40,6 +41,9 @@ 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); @@ -47,7 +51,7 @@ export async function newTracker( core.setAppId(appId); } - const tracker = { + const tracker: ReactNativeTracker = { ...newTrackEventFunctions(core), ...subject.properties, setAppId: core.setAppId, @@ -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; diff --git a/trackers/react-native-tracker/src/types.ts b/trackers/react-native-tracker/src/types.ts index a7d31fade..a3f91361e 100755 --- a/trackers/react-native-tracker/src/types.ts +++ b/trackers/react-native-tracker/src/types.ts @@ -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 */ @@ -542,29 +580,25 @@ export type ReactNativeTracker = { */ readonly setSubjectData: (config: SubjectConfiguration) => void; - // TODO: - // /** - // * Gets the identifier for the user of the session - // * - // * @returns {Promise} - // */ - // readonly getSessionUserId: () => Promise; + /** + * Gets the identifier for the user of the session + */ + readonly getSessionUserId: () => Promise; - // TODO: - // /** - // * Gets the identifier for the session - // * - // * @returns {Promise} - // */ - // readonly getSessionId: () => Promise; + /** + * Gets the identifier for the session + */ + readonly getSessionId: () => Promise; - // TODO: - // /** - // * Gets the index of the current session for this user - // * - // * @returns {Promise} - // */ - // readonly getSessionIndex: () => Promise; + /** + * Gets the index of the current session for this user + */ + readonly getSessionIndex: () => Promise; + + /** + * Gets the current session state + */ + readonly getSessionState: () => Promise; // TODO: // /** diff --git a/trackers/react-native-tracker/src/utils.ts b/trackers/react-native-tracker/src/utils.ts new file mode 100644 index 000000000..f39190da0 --- /dev/null +++ b/trackers/react-native-tracker/src/utils.ts @@ -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 self-describing 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)['schema']; + if (typeof schema == 'string') { + return schema; + } + } + } + return ''; +} diff --git a/trackers/react-native-tracker/test/plugins/session.test.ts b/trackers/react-native-tracker/test/plugins/session.test.ts new file mode 100644 index 000000000..993fdf995 --- /dev/null +++ b/trackers/react-native-tracker/test/plugins/session.test.ts @@ -0,0 +1,221 @@ +import { newSessionPlugin } from '../../src/plugins/session'; +import { buildPageView, buildSelfDescribingEvent, Payload, trackerCore } from '@snowplow/tracker-core'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +describe('Session plugin', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + beforeEach(async () => { + await AsyncStorage.clear(); + }); + + afterAll(() => { + jest.clearAllTimers(); + }); + + it('starts a new session when necessary', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + const sessionPlugin = await newSessionPlugin({ + namespace: 'test', + foregroundSessionTimeout: 5, + backgroundSessionTimeout: 5, + }); + + const sessionState = await sessionPlugin.getSessionState(); + expect(sessionState.sessionIndex).toBe(1); + + jest.setSystemTime(new Date('2022-04-17T00:00:10.000Z')); + + const tracker = trackerCore({ corePlugins: [sessionPlugin.plugin] }); + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + + const newSessionState = await sessionPlugin.getSessionState(); + expect(newSessionState.sessionIndex).toBe(2); + expect(newSessionState.previousSessionId).toBe(sessionState.sessionId); + }); + + it('attaches session context to events with the correct properties', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + const sessionPlugin = await newSessionPlugin({ + namespace: 'test', + foregroundSessionTimeout: 5, + backgroundSessionTimeout: 5, + }); + + const payloads: Payload[] = []; + const tracker = trackerCore({ + corePlugins: [sessionPlugin.plugin], + callback: (pb) => payloads.push(pb.build()), + base64: false, + }); + + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + + expect(payloads.length).toBe(2); + expect(JSON.parse(payloads[0]?.co as string).data).toEqual([ + { + schema: 'iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2', + data: { + userId: await sessionPlugin.getSessionUserId(), + sessionId: await sessionPlugin.getSessionId(), + sessionIndex: 1, + storageMechanism: 'LOCAL_STORAGE', + eventIndex: 1, + firstEventId: payloads[0]?.eid, + firstEventTimestamp: '2022-04-17T00:00:00.000Z', + }, + }, + ]); + expect(JSON.parse(payloads[1]?.co as string).data).toEqual([ + { + schema: 'iglu:com.snowplowanalytics.snowplow/client_session/jsonschema/1-0-2', + data: { + userId: await sessionPlugin.getSessionUserId(), + sessionId: await sessionPlugin.getSessionId(), + sessionIndex: 1, + storageMechanism: 'LOCAL_STORAGE', + eventIndex: 2, + firstEventId: payloads[0]?.eid, + firstEventTimestamp: '2022-04-17T00:00:00.000Z', + }, + }, + ]); + }); + + it('creates a new session when new tracker is created', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + const sessionPlugin = await newSessionPlugin({ + namespace: 'test', + foregroundSessionTimeout: 5, + backgroundSessionTimeout: 5, + }); + + const tracker1 = trackerCore({ corePlugins: [sessionPlugin.plugin] }); + tracker1.track(buildPageView({ pageUrl: 'http://localhost' })); + + const sessionState = await sessionPlugin.getSessionState(); + expect(sessionState.sessionIndex).toBe(1); + + const sessionPlugin2 = await newSessionPlugin({ + namespace: 'test', + foregroundSessionTimeout: 5, + backgroundSessionTimeout: 5, + }); + + const newSessionState = await sessionPlugin2.getSessionState(); + expect(newSessionState.sessionIndex).toBe(2); + expect(newSessionState.previousSessionId).toBe(sessionState.sessionId); + }); + + it('uses a background timeout when in background', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + const sessionPlugin = await newSessionPlugin({ + namespace: 'test', + foregroundSessionTimeout: 1000, + backgroundSessionTimeout: 5, + }); + + const tracker = trackerCore({ corePlugins: [sessionPlugin.plugin] }); + tracker.track(buildSelfDescribingEvent({ + event: { + schema: 'iglu:com.snowplowanalytics.snowplow/application_background/jsonschema/1-0-0', + data: {}, + } + })); + + let sessionState = await sessionPlugin.getSessionState(); + expect(sessionState.sessionIndex).toBe(1); + const firstSessionId = sessionState.sessionId; + + jest.setSystemTime(new Date('2022-04-17T00:00:05.000Z')); + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + sessionState = await sessionPlugin.getSessionState(); + expect(sessionState.sessionIndex).toBe(1); + + jest.setSystemTime(new Date('2022-04-17T00:00:15.000Z')); + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + + sessionState = await sessionPlugin.getSessionState(); + expect(sessionState.sessionIndex).toBe(2); + expect(sessionState.previousSessionId).toBe(firstSessionId); + }); + + it('uses a foreground timeout when in foreground', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + const sessionPlugin = await newSessionPlugin({ + namespace: 'test', + foregroundSessionTimeout: 5, + backgroundSessionTimeout: 1, + }); + + const tracker = trackerCore({ corePlugins: [sessionPlugin.plugin] }); + + let sessionState = await sessionPlugin.getSessionState(); + expect(sessionState.sessionIndex).toBe(1); + const firstSessionId = sessionState.sessionId; + + jest.setSystemTime(new Date('2022-04-17T00:00:02.000Z')); + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + expect(sessionState.sessionIndex).toBe(1); + + jest.setSystemTime(new Date('2022-04-17T00:00:10.000Z')); + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + sessionState = await sessionPlugin.getSessionState(); + expect(sessionState.sessionIndex).toBe(2); + expect(sessionState.previousSessionId).toBe(firstSessionId); + }); + + it('has separate session state for different namespaces', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + const sessionPlugin1 = await newSessionPlugin({ + namespace: 'test1', + foregroundSessionTimeout: 5, + backgroundSessionTimeout: 5, + }); + + const sessionPlugin2 = await newSessionPlugin({ + namespace: 'test2', + foregroundSessionTimeout: 5, + backgroundSessionTimeout: 5, + }); + + const tracker1 = trackerCore({ corePlugins: [sessionPlugin1.plugin] }); + const tracker2 = trackerCore({ corePlugins: [sessionPlugin2.plugin] }); + + tracker1.track(buildPageView({ pageUrl: 'http://localhost' })); + tracker2.track(buildPageView({ pageUrl: 'http://localhost' })); + + const sessionState1 = await sessionPlugin1.getSessionState(); + const sessionState2 = await sessionPlugin2.getSessionState(); + + expect(sessionState1.sessionIndex).toBe(1); + expect(sessionState2.sessionIndex).toBe(1); + }); + + it('retrieves the correct information from the session plugin', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + const sessionPlugin = await newSessionPlugin({ + namespace: 'test', + foregroundSessionTimeout: 5, + backgroundSessionTimeout: 5, + }); + + const tracker = trackerCore({ corePlugins: [sessionPlugin.plugin] }); + tracker.track(buildPageView({ pageUrl: 'http://localhost' })); + + const userId = await sessionPlugin.getSessionUserId(); + const sessionId = await sessionPlugin.getSessionId(); + const sessionIndex = await sessionPlugin.getSessionIndex(); + const sessionState = await sessionPlugin.getSessionState(); + + expect(userId).toBeDefined(); + expect(sessionId).toBeDefined(); + expect(sessionIndex).toBe(1); + expect(sessionState.userId).toEqual(userId); + expect(sessionState.sessionId).toEqual(sessionId); + expect(sessionState.sessionIndex).toEqual(sessionIndex); + }); +}); diff --git a/trackers/react-native-tracker/test/tracker.test.ts b/trackers/react-native-tracker/test/tracker.test.ts index 2305fb1a1..c1aef82b2 100644 --- a/trackers/react-native-tracker/test/tracker.test.ts +++ b/trackers/react-native-tracker/test/tracker.test.ts @@ -68,6 +68,30 @@ describe('Tracker', () => { expect(event.aid).toBe('my-app'); }); + it('tracks session along with events', async () => { + const tracker = await newTracker({ + namespace: 'test', + appId: 'my-app', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + }); + tracker.trackPageViewEvent({ + pageUrl: 'http://localhost:9090', + pageTitle: 'Home', + }); + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + expect(payload.data[0].co).toContain('/client_session/'); + expect(payload.data[0].co).toContain(await tracker.getSessionId()); + expect(payload.data[0].co).toContain(await tracker.getSessionUserId()); + expect(await tracker.getSessionId()).toBeDefined(); + expect(await tracker.getSessionUserId()).toBeDefined(); + }); + it('adds a tracker plugin', async () => { const tracker = await newTracker({ namespace: 'test', @@ -122,10 +146,9 @@ describe('Tracker', () => { const [event] = payload.data; expect(event.co).toBeDefined(); const context = JSON.parse(event.co as string); - expect(context.data.length).toBe(1); - const [{ schema, data }] = context.data; + expect(context.data.length).toBeGreaterThanOrEqual(1); + const { data } = context.data.find((c: any) => c.schema === 'iglu:com.acme/user/jsonschema/1-0-0'); - expect(schema).toBe('iglu:com.acme/user/jsonschema/1-0-0'); expect(data.userType).toBe('tester'); }); @@ -157,7 +180,7 @@ describe('Tracker', () => { expect(payload.data.length).toBe(1); const [event] = payload.data; - expect(event.co).toBeUndefined(); + expect(event.co).not.toContain(context.schema); }); }); });