Skip to content

Allow configuring a custom event store in React Native tracker (closes #1413) #1418

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion trackers/react-native-tracker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"test": "jest"
},
"peerDependencies": {
"@react-native-async-storage/async-storage": "~2.0.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand that it makes more sense to set async-storage as a peer dependency now so that it can more easily be ignored but unfortunately that'd be a breaking change for users who have previously relied on it being included in the tracker package (in the react-native-get-random-values case we did move it to peer dependencies in a minor version but that was because it had to be installed as a peer dependency anyway in the app so users probably had it installed like that anyway).

Could you please keep it as a dependency? Otherwise this will block us from releasing the change in the v4 tracker. Happy to move it to peer dependencies in v5 but that will take some time for us to get to.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I can move it. 😊

Before proceeding, I have a little bit of doubt. I agree that moving it to the peerDependencies force this Pull Request to introduce a breaking change. However, doesn't it apply the same way for crypto.getRandomValues and react-native-get-random-values? Currently, in 4.4.0 and below, consumers are not notified to install @react-native-async-storage/async-storage (e.g., Yarn notifies consumers when a peer dependency is not correctly satisfied). They likely get a runtime error when the Snowplow tracker tries to access the global AsyncStorage, given there might not be available platform bindings, the same that happens when the tracker tries to access crypto.getRandomValues.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay on this.

Regarding the react-native-get-random-values case, I was under the impression that even though it was not a peer dependency, the tracker would not work without it being explicitly included in the app dependencies – that's what issue #1409 reported. I am not aware that the async-storage also needs to explicitly included in the app. At least based on my tests I didn't need to do so, but could be wrong?

Copy link
Contributor Author

@valeriobelli valeriobelli Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @matus-tomlein, sorry for being late here. 🙇🏽

As far as I can see, React Native AsyncStorage must be linked, given it exposes React Native bindings.

In other words, the app will crash if a consumer omits @react-native-async-storage/async-storage in the package.json. This is a small repro.

The following is a preview of what would happen when omitted

image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, thank you for that reproduction!

"react": "*",
"react-native": "*",
"react-native-get-random-values": "^1.11.0"
Expand All @@ -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",
Expand Down
15 changes: 9 additions & 6 deletions trackers/react-native-tracker/src/event_store.ts
Original file line number Diff line number Diff line change
@@ -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<EventStoreConfiguration, 'asyncStorage'> &
TrackerConfiguration & { asyncStorage: AsyncStorage };

export async function newReactNativeEventStore({
namespace,
maxEventStoreSize = 1000,
useAsyncStorageForEventStore: useAsyncStorage = true,
}: EventStoreConfiguration & TrackerConfiguration): Promise<EventStore> {
asyncStorage,
}: Configuration): Promise<EventStore> {
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 {
Expand All @@ -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));
}
}

Expand Down
16 changes: 10 additions & 6 deletions trackers/react-native-tracker/src/plugins/app_install/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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({
Expand All @@ -27,7 +31,7 @@ export function newAppInstallPlugin(
},
})
);
await AsyncStorage.setItem(key, new Date().toISOString());
await asyncStorage.setItem(key, new Date().toISOString());
}
}, 0);
}
Expand Down
24 changes: 12 additions & 12 deletions trackers/react-native-tracker/src/plugins/session/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -19,13 +18,13 @@ interface SessionPlugin extends CorePluginConfiguration {
startNewSession: () => Promise<void>;
}

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<SessionState> {
const storedState = await AsyncStorage.getItem(`snowplow_${namespace}_session`);
async function resumeStoredSession(namespace: string, asyncStorage: AsyncStorage): Promise<SessionState> {
const storedState = await asyncStorage.getItem(`snowplow_${namespace}_session`);
if (storedState) {
const state = JSON.parse(storedState) as StoredSessionState;
return {
Expand All @@ -48,18 +47,19 @@ async function resumeStoredSession(namespace: string): Promise<SessionState> {
/**
* 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<SessionPlugin> {
let sessionState = await resumeStoredSession(namespace);
await storeSessionState(namespace, sessionState);
}: TrackerConfiguration & SessionConfiguration & { asyncStorage: AsyncStorage }): Promise<SessionPlugin> {
let sessionState = await resumeStoredSession(namespace, asyncStorage);
await storeSessionState(namespace, sessionState, asyncStorage);

let inBackground = false;
let lastUpdateTs = new Date().getTime();
Expand All @@ -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();

Expand Down
78 changes: 52 additions & 26 deletions trackers/react-native-tracker/src/tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { tracker: ReactNativeTracker; core: TrackerCore }> = {};

type SetPropertiesAsNonNullable<Obj, Properties extends keyof Obj> = Omit<Obj, Properties> & {
[K in Properties]-?: NonNullable<Obj[K]>;
};

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<NormalizedConfiguration> => {
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<ReactNativeTracker> {
const { namespace, appId, encodeBase64 = false } = configuration;
if (configuration.eventStore === undefined) {
configuration.eventStore = await newReactNativeEventStore(configuration);
}
export async function newTracker(configuration: Configuration): Promise<ReactNativeTracker> {
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) {
Expand All @@ -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),
Expand Down
15 changes: 14 additions & 1 deletion trackers/react-native-tracker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import {
StructuredEvent,
} from '@snowplow/tracker-core';

export interface AsyncStorage {
getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>;
}

/**
* Configuration for the event store
*/
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -918,7 +932,6 @@ export {
ContextGenerator,
ContextFilter,
EventPayloadAndContext,
EventStore,
EventStoreIterator,
EventStorePayload,
TrackerCore,
Expand Down
37 changes: 37 additions & 0 deletions trackers/react-native-tracker/test/event_store.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { newReactNativeEventStore } from '../src/event_store';

function createAsyncStorageMock() {
const storageState: Record<string, string> = {};

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',
});

Expand All @@ -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',
});

Expand All @@ -39,13 +56,33 @@ 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',
});

await eventStore1.add({ payload: { e: 'pv' } });
await eventStore1.add({ payload: { e: 'pp' } });

const eventStore2 = await newReactNativeEventStore({
asyncStorage,
namespace: 'testA',
});

Expand Down
Loading
Loading