diff --git a/trackers/react-native-tracker/package.json b/trackers/react-native-tracker/package.json index bbe1bd521..20cf3282b 100644 --- a/trackers/react-native-tracker/package.json +++ b/trackers/react-native-tracker/package.json @@ -43,6 +43,7 @@ "test": "jest" }, "peerDependencies": { + "@react-native-async-storage/async-storage": "~2.0.0", "react": "*", "react-native": "*", "react-native-get-random-values": "^1.11.0" @@ -51,11 +52,11 @@ "@snowplow/tracker-core": "workspace:*", "@snowplow/browser-tracker-core": "workspace:*", "@snowplow/browser-plugin-screen-tracking": "workspace:*", - "@react-native-async-storage/async-storage": "~2.0.0", "tslib": "^2.3.1", "uuid": "^10.0.0" }, "devDependencies": { + "@react-native-async-storage/async-storage": "~2.0.0", "@snowplow/browser-plugin-snowplow-ecommerce": "workspace:*", "@typescript-eslint/eslint-plugin": "~5.15.0", "@typescript-eslint/parser": "~5.15.0", diff --git a/trackers/react-native-tracker/src/event_store.ts b/trackers/react-native-tracker/src/event_store.ts index c98259302..4fd3c6a27 100644 --- a/trackers/react-native-tracker/src/event_store.ts +++ b/trackers/react-native-tracker/src/event_store.ts @@ -1,17 +1,20 @@ -import { EventStore, newInMemoryEventStore, EventStorePayload } from '@snowplow/tracker-core'; -import { EventStoreConfiguration, TrackerConfiguration } from './types'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import { EventStore, EventStorePayload, newInMemoryEventStore } from '@snowplow/tracker-core'; +import { AsyncStorage, EventStoreConfiguration, TrackerConfiguration } from './types'; + +type Configuration = Omit & + TrackerConfiguration & { asyncStorage: AsyncStorage }; export async function newReactNativeEventStore({ namespace, maxEventStoreSize = 1000, useAsyncStorageForEventStore: useAsyncStorage = true, -}: EventStoreConfiguration & TrackerConfiguration): Promise { + asyncStorage, +}: Configuration): Promise { const queueName = `snowplow_${namespace}`; async function newInMemoryEventStoreForReactNative() { if (useAsyncStorage) { - const data = await AsyncStorage.getItem(queueName); + const data = await asyncStorage.getItem(queueName); const events: EventStorePayload[] = data ? JSON.parse(data) : []; return newInMemoryEventStore({ maxSize: maxEventStoreSize, events }); } else { @@ -26,7 +29,7 @@ export async function newReactNativeEventStore({ async function sync() { if (useAsyncStorage) { const events = await getAll(); - await AsyncStorage.setItem(queueName, JSON.stringify(events)); + await asyncStorage.setItem(queueName, JSON.stringify(events)); } } diff --git a/trackers/react-native-tracker/src/plugins/app_install/index.ts b/trackers/react-native-tracker/src/plugins/app_install/index.ts index 4e9c8ac58..954f41296 100644 --- a/trackers/react-native-tracker/src/plugins/app_install/index.ts +++ b/trackers/react-native-tracker/src/plugins/app_install/index.ts @@ -1,7 +1,7 @@ -import { buildSelfDescribingEvent, CorePluginConfiguration, TrackerCore } from '@snowplow/tracker-core'; -import { AppLifecycleConfiguration, TrackerConfiguration } from '../../types'; +import type { CorePluginConfiguration, TrackerCore } from '@snowplow/tracker-core'; +import { buildSelfDescribingEvent } from '@snowplow/tracker-core'; import { APPLICATION_INSTALL_EVENT_SCHEMA } from '../../constants'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { AppLifecycleConfiguration, AsyncStorage, TrackerConfiguration } from '../../types'; /** * Tracks an application install event on the first run of the app. @@ -10,14 +10,18 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; * Event schema: `iglu:com.snowplowanalytics.mobile/application_install/jsonschema/1-0-0` */ export function newAppInstallPlugin( - { namespace, installAutotracking = false }: TrackerConfiguration & AppLifecycleConfiguration, + { + asyncStorage, + namespace, + installAutotracking = false, + }: TrackerConfiguration & AppLifecycleConfiguration & { asyncStorage: AsyncStorage }, core: TrackerCore ): CorePluginConfiguration { if (installAutotracking) { // Track install event on first run const key = `snowplow_${namespace}_install`; setTimeout(async () => { - const installEvent = await AsyncStorage.getItem(key); + const installEvent = await asyncStorage.getItem(key); if (!installEvent) { core.track( buildSelfDescribingEvent({ @@ -27,7 +31,7 @@ export function newAppInstallPlugin( }, }) ); - await AsyncStorage.setItem(key, new Date().toISOString()); + await asyncStorage.setItem(key, new Date().toISOString()); } }, 0); } diff --git a/trackers/react-native-tracker/src/plugins/session/index.ts b/trackers/react-native-tracker/src/plugins/session/index.ts index c276cf8dd..41dfdd5e6 100644 --- a/trackers/react-native-tracker/src/plugins/session/index.ts +++ b/trackers/react-native-tracker/src/plugins/session/index.ts @@ -1,8 +1,7 @@ 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 { AsyncStorage, SessionConfiguration, SessionState, TrackerConfiguration } from '../../types'; import { getUsefulSchema } from '../../utils'; interface StoredSessionState { @@ -19,13 +18,13 @@ interface SessionPlugin extends CorePluginConfiguration { startNewSession: () => Promise; } -async function storeSessionState(namespace: string, state: StoredSessionState) { +async function storeSessionState(namespace: string, state: StoredSessionState, asyncStorage: AsyncStorage) { const { userId, sessionId, sessionIndex } = state; - await AsyncStorage.setItem(`snowplow_${namespace}_session`, JSON.stringify({ userId, sessionId, sessionIndex })); + 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`); +async function resumeStoredSession(namespace: string, asyncStorage: AsyncStorage): Promise { + const storedState = await asyncStorage.getItem(`snowplow_${namespace}_session`); if (storedState) { const state = JSON.parse(storedState) as StoredSessionState; return { @@ -48,18 +47,19 @@ async function resumeStoredSession(namespace: string): Promise { /** * 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. + * + * The session state is stored in the defined application storage. * 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({ + asyncStorage, namespace, sessionContext = true, foregroundSessionTimeout, backgroundSessionTimeout, -}: TrackerConfiguration & SessionConfiguration): Promise { - let sessionState = await resumeStoredSession(namespace); - await storeSessionState(namespace, sessionState); +}: TrackerConfiguration & SessionConfiguration & { asyncStorage: AsyncStorage }): Promise { + let sessionState = await resumeStoredSession(namespace, asyncStorage); + await storeSessionState(namespace, sessionState, asyncStorage); let inBackground = false; let lastUpdateTs = new Date().getTime(); @@ -84,7 +84,7 @@ export async function newSessionPlugin({ const timeDiff = now.getTime() - lastUpdateTs; if (timeDiff > getTimeoutMs()) { startNewSession(); - storeSessionState(namespace, sessionState); + storeSessionState(namespace, sessionState, asyncStorage); } lastUpdateTs = now.getTime(); diff --git a/trackers/react-native-tracker/src/tracker.ts b/trackers/react-native-tracker/src/tracker.ts index 5abdbc501..6a0738d01 100644 --- a/trackers/react-native-tracker/src/tracker.ts +++ b/trackers/react-native-tracker/src/tracker.ts @@ -33,38 +33,64 @@ import { newPlatformContextPlugin } from './plugins/platform_context'; import { newAppLifecyclePlugin } from './plugins/app_lifecycle'; import { newAppInstallPlugin } from './plugins/app_install'; import { newAppContextPlugin } from './plugins/app_context'; +import DefaultAsyncStorage from '@react-native-async-storage/async-storage'; const initializedTrackers: Record = {}; +type SetPropertiesAsNonNullable = Omit & { + [K in Properties]-?: NonNullable; +}; + +type Configuration = TrackerConfiguration & + EmitterConfiguration & + SessionConfiguration & + SubjectConfiguration & + EventStoreConfiguration & + ScreenTrackingConfiguration & + PlatformContextConfiguration & + DeepLinkConfiguration & + AppLifecycleConfiguration; + +type NormalizedConfiguration = SetPropertiesAsNonNullable< + Configuration, + 'asyncStorage' | 'devicePlatform' | 'encodeBase64' | 'eventStore' | 'plugins' +>; + +const normalizeTrackerConfiguration = async (configuration: Configuration): Promise => { + const eventStore = configuration.eventStore ?? (await newReactNativeEventStore(configuration)); + const asyncStorage = configuration.asyncStorage ?? DefaultAsyncStorage; + const plugins = configuration.plugins ?? []; + const devicePlatform = configuration.devicePlatform ?? 'mob'; + const encodeBase64 = configuration.encodeBase64 ?? false; + + return { + ...configuration, + devicePlatform, + encodeBase64, + asyncStorage, + eventStore, + plugins, + }; +}; + /** * Creates a new tracker instance with the given configuration * @param configuration - Configuration for the tracker * @returns Tracker instance */ -export async function newTracker( - configuration: TrackerConfiguration & - EmitterConfiguration & - SessionConfiguration & - SubjectConfiguration & - EventStoreConfiguration & - ScreenTrackingConfiguration & - PlatformContextConfiguration & - DeepLinkConfiguration & - AppLifecycleConfiguration -): Promise { - const { namespace, appId, encodeBase64 = false } = configuration; - if (configuration.eventStore === undefined) { - configuration.eventStore = await newReactNativeEventStore(configuration); - } +export async function newTracker(configuration: Configuration): Promise { + const normalizedConfiguration = await normalizeTrackerConfiguration(configuration); + + const { namespace, appId, encodeBase64 } = normalizedConfiguration; - const emitter = newEmitter(configuration); + const emitter = newEmitter(normalizedConfiguration); const callback = (payload: PayloadBuilder): void => { emitter.input(payload.build()); }; const core = trackerCore({ base64: encodeBase64, callback }); - core.setPlatform(configuration.devicePlatform ?? 'mob'); + core.setPlatform(normalizedConfiguration.devicePlatform); core.setTrackerVersion('rn-' + version); core.setTrackerNamespace(namespace); if (appId) { @@ -73,31 +99,31 @@ export async function newTracker( const { addPlugin } = newPlugins(namespace, core); - const sessionPlugin = await newSessionPlugin(configuration); + const sessionPlugin = await newSessionPlugin(normalizedConfiguration); addPlugin(sessionPlugin); - const deepLinksPlugin = await newDeepLinksPlugin(configuration, core); + const deepLinksPlugin = newDeepLinksPlugin(normalizedConfiguration, core); addPlugin(deepLinksPlugin); - const subject = newSubject(core, configuration); + const subject = newSubject(core, normalizedConfiguration); addPlugin(subject.subjectPlugin); - const screenPlugin = ScreenTrackingPlugin(configuration); + const screenPlugin = ScreenTrackingPlugin(normalizedConfiguration); addPlugin({ plugin: screenPlugin }); - const platformContextPlugin = await newPlatformContextPlugin(configuration); + const platformContextPlugin = await newPlatformContextPlugin(normalizedConfiguration); addPlugin(platformContextPlugin); - const lifecyclePlugin = await newAppLifecyclePlugin(configuration, core); + const lifecyclePlugin = await newAppLifecyclePlugin(normalizedConfiguration, core); addPlugin(lifecyclePlugin); - const installPlugin = newAppInstallPlugin(configuration, core); + const installPlugin = newAppInstallPlugin(normalizedConfiguration, core); addPlugin(installPlugin); - const appContextPlugin = newAppContextPlugin(configuration); + const appContextPlugin = newAppContextPlugin(normalizedConfiguration); addPlugin(appContextPlugin); - (configuration.plugins ?? []).forEach((plugin) => addPlugin({ plugin })); + normalizedConfiguration.plugins.forEach((plugin) => addPlugin({ plugin })); const tracker: ReactNativeTracker = { ...newTrackEventFunctions(core), diff --git a/trackers/react-native-tracker/src/types.ts b/trackers/react-native-tracker/src/types.ts index 6e2d8aaa2..f12fa2d07 100755 --- a/trackers/react-native-tracker/src/types.ts +++ b/trackers/react-native-tracker/src/types.ts @@ -7,6 +7,11 @@ import { StructuredEvent, } from '@snowplow/tracker-core'; +export interface AsyncStorage { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; +} + /** * Configuration for the event store */ @@ -24,6 +29,15 @@ export interface EventStoreConfiguration { * @defaultValue true */ useAsyncStorageForEventStore?: boolean; + + /** + * The Async storage implementation. + * In environments where AsyncStorage is not available or where another kind of storage is used, + * you can provide a custom implementation. + * + * @defaultValue AsyncStorage from {@link https://react-native-async-storage.github.io/async-storage/ @react-native-async-storage/async-storage} + * */ + asyncStorage?: AsyncStorage; } /** @@ -918,7 +932,6 @@ export { ContextGenerator, ContextFilter, EventPayloadAndContext, - EventStore, EventStoreIterator, EventStorePayload, TrackerCore, diff --git a/trackers/react-native-tracker/test/event_store.test.ts b/trackers/react-native-tracker/test/event_store.test.ts index 47d5030b8..8c95e51ed 100644 --- a/trackers/react-native-tracker/test/event_store.test.ts +++ b/trackers/react-native-tracker/test/event_store.test.ts @@ -1,8 +1,23 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; import { newReactNativeEventStore } from '../src/event_store'; +function createAsyncStorageMock() { + const storageState: Record = {}; + + return { + getItem: (key: string) => Promise.resolve(storageState[key] ?? null), + setItem: (key: string, value: string) => { + storageState[key] = value; + + return Promise.resolve(); + }, + }; +} + describe('React Native event store', () => { it('keeps track of added events', async () => { const eventStore = await newReactNativeEventStore({ + asyncStorage: AsyncStorage, namespace: 'test', }); @@ -21,9 +36,11 @@ describe('React Native event store', () => { it('separates event stores by namespace', async () => { const eventStore1 = await newReactNativeEventStore({ + asyncStorage: AsyncStorage, namespace: 'test1', }); const eventStore2 = await newReactNativeEventStore({ + asyncStorage: AsyncStorage, namespace: 'test2', }); @@ -39,6 +56,25 @@ describe('React Native event store', () => { it('syncs with AsyncStorage', async () => { const eventStore1 = await newReactNativeEventStore({ + asyncStorage: AsyncStorage, + namespace: 'testA', + }); + + await eventStore1.add({ payload: { e: 'pv' } }); + await eventStore1.add({ payload: { e: 'pp' } }); + + const eventStore2 = await newReactNativeEventStore({ + asyncStorage: AsyncStorage, + namespace: 'testA', + }); + + expect(await eventStore2.count()).toBe(2); + }); + + it('syncs with the custom async storage implementation', async () => { + const asyncStorage = createAsyncStorageMock(); + const eventStore1 = await newReactNativeEventStore({ + asyncStorage, namespace: 'testA', }); @@ -46,6 +82,7 @@ describe('React Native event store', () => { await eventStore1.add({ payload: { e: 'pp' } }); const eventStore2 = await newReactNativeEventStore({ + asyncStorage, namespace: 'testA', }); diff --git a/trackers/react-native-tracker/test/plugins/app_install.test.ts b/trackers/react-native-tracker/test/plugins/app_install.test.ts index 88b9409f5..9a99287d4 100644 --- a/trackers/react-native-tracker/test/plugins/app_install.test.ts +++ b/trackers/react-native-tracker/test/plugins/app_install.test.ts @@ -15,6 +15,7 @@ describe('Application install plugin', () => { }); const appInstallPlugin = newAppInstallPlugin( { + asyncStorage: AsyncStorage, namespace: 'test', installAutotracking: true, }, @@ -39,6 +40,7 @@ describe('Application install plugin', () => { tracker1.addPlugin( newAppInstallPlugin( { + asyncStorage: AsyncStorage, namespace: 'test', installAutotracking: true, }, @@ -57,6 +59,7 @@ describe('Application install plugin', () => { tracker2.addPlugin( newAppInstallPlugin( { + asyncStorage: AsyncStorage, namespace: 'test', installAutotracking: true, }, @@ -75,6 +78,7 @@ describe('Application install plugin', () => { }); const appInstallPlugin = newAppInstallPlugin( { + asyncStorage: AsyncStorage, namespace: 'test', }, tracker diff --git a/trackers/react-native-tracker/test/plugins/session.test.ts b/trackers/react-native-tracker/test/plugins/session.test.ts index 993fdf995..bd8fd3563 100644 --- a/trackers/react-native-tracker/test/plugins/session.test.ts +++ b/trackers/react-native-tracker/test/plugins/session.test.ts @@ -1,7 +1,20 @@ -import { newSessionPlugin } from '../../src/plugins/session'; import { buildPageView, buildSelfDescribingEvent, Payload, trackerCore } from '@snowplow/tracker-core'; +import { newSessionPlugin } from '../../src/plugins/session'; import AsyncStorage from '@react-native-async-storage/async-storage'; +function createAsyncStorageMock() { + const storageState: Record = {}; + + return { + getItem: (key: string) => Promise.resolve(storageState[key] ?? null), + setItem: (key: string, value: string) => { + storageState[key] = value; + + return Promise.resolve(); + }, + }; +} + describe('Session plugin', () => { beforeAll(() => { jest.useFakeTimers(); @@ -18,6 +31,7 @@ describe('Session plugin', () => { it('starts a new session when necessary', async () => { jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); const sessionPlugin = await newSessionPlugin({ + asyncStorage: AsyncStorage, namespace: 'test', foregroundSessionTimeout: 5, backgroundSessionTimeout: 5, @@ -39,6 +53,7 @@ describe('Session plugin', () => { 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({ + asyncStorage: AsyncStorage, namespace: 'test', foregroundSessionTimeout: 5, backgroundSessionTimeout: 5, @@ -88,6 +103,7 @@ describe('Session plugin', () => { 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({ + asyncStorage: AsyncStorage, namespace: 'test', foregroundSessionTimeout: 5, backgroundSessionTimeout: 5, @@ -100,6 +116,7 @@ describe('Session plugin', () => { expect(sessionState.sessionIndex).toBe(1); const sessionPlugin2 = await newSessionPlugin({ + asyncStorage: AsyncStorage, namespace: 'test', foregroundSessionTimeout: 5, backgroundSessionTimeout: 5, @@ -113,18 +130,21 @@ describe('Session plugin', () => { it('uses a background timeout when in background', async () => { jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); const sessionPlugin = await newSessionPlugin({ + asyncStorage: AsyncStorage, 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: {}, - } - })); + 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); @@ -146,6 +166,7 @@ describe('Session plugin', () => { it('uses a foreground timeout when in foreground', async () => { jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); const sessionPlugin = await newSessionPlugin({ + asyncStorage: AsyncStorage, namespace: 'test', foregroundSessionTimeout: 5, backgroundSessionTimeout: 1, @@ -171,12 +192,14 @@ describe('Session plugin', () => { it('has separate session state for different namespaces', async () => { jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); const sessionPlugin1 = await newSessionPlugin({ + asyncStorage: AsyncStorage, namespace: 'test1', foregroundSessionTimeout: 5, backgroundSessionTimeout: 5, }); const sessionPlugin2 = await newSessionPlugin({ + asyncStorage: AsyncStorage, namespace: 'test2', foregroundSessionTimeout: 5, backgroundSessionTimeout: 5, @@ -198,6 +221,32 @@ describe('Session plugin', () => { it('retrieves the correct information from the session plugin', async () => { jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); const sessionPlugin = await newSessionPlugin({ + asyncStorage: AsyncStorage, + 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); + }); + + it('retrieves the correct information from the session plugin with a custom async storage', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + const sessionPlugin = await newSessionPlugin({ + asyncStorage: createAsyncStorageMock(), namespace: 'test', foregroundSessionTimeout: 5, backgroundSessionTimeout: 5,