Skip to content
Open
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
8 changes: 6 additions & 2 deletions src/analytics/MetaRouterAnalyticsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { log, setDebugLogging, warn, error } from './utils/logger';
import { IdentityManager } from './IdentityManager';
import { enrichEvent } from './utils/enrichEvent';
import { getContextInfo, clearContextCache } from './utils/contextInfo';
import { AppContext, loadAppContext } from './utils/appContext';
import {
getIdentityField,
setIdentityField,
Expand Down Expand Up @@ -35,6 +36,7 @@ export class MetaRouterAnalyticsClient {
private ingestionHost: string;
private writeKey: string;
private context!: EventContext;
private appContext!: AppContext;
private appState: AppStateStatus = AppState.currentState;
private appStateSubscription: { remove?: () => void } | null = null;
private identityManager: IdentityManager;
Expand Down Expand Up @@ -216,7 +218,9 @@ export class MetaRouterAnalyticsClient {
const persistedAdvertisingId =
await getIdentityField(ADVERTISING_ID_KEY);

this.appContext = loadAppContext();
this.context = await getContextInfo(
this.appContext,
persistedAdvertisingId || undefined
);

Expand Down Expand Up @@ -482,7 +486,7 @@ export class MetaRouterAnalyticsClient {
log('Setting advertising ID');
await setIdentityField(ADVERTISING_ID_KEY, advertisingId);
clearContextCache();
this.context = await getContextInfo(advertisingId);
this.context = await getContextInfo(this.appContext, advertisingId);
log('Advertising ID updated, persisted, and context refreshed');
}

Expand All @@ -504,7 +508,7 @@ export class MetaRouterAnalyticsClient {
log('Clearing advertising ID');
await removeIdentityField(ADVERTISING_ID_KEY);
clearContextCache();
this.context = await getContextInfo();
this.context = await getContextInfo(this.appContext);
log('Advertising ID cleared from storage and context');
}

Expand Down
148 changes: 148 additions & 0 deletions src/analytics/lifecycle/lifecycleEvents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import {
LifecycleEmitter,
APPLICATION_INSTALLED,
APPLICATION_UPDATED,
APPLICATION_OPENED,
APPLICATION_BACKGROUNDED,
UNKNOWN_PREVIOUS,
} from './lifecycleEvents';
import type { AppContext } from '../utils/appContext';
import type Dispatcher from '../dispatcher';
import type { EnrichedEventPayload } from '../types';

describe('LifecycleEmitter', () => {
const appContext: AppContext = {
name: 'TestApp',
version: '1.4.0',
build: '42',
namespace: 'com.metarouter.test',
};

const setup = () => {
const dispatcherEnqueue = jest.fn<void, [EnrichedEventPayload]>();
const dispatcher = { enqueue: dispatcherEnqueue } as unknown as Dispatcher;
const createTrackEvent = jest.fn(
(event: string, properties?: Record<string, any>): EnrichedEventPayload =>
({
type: 'track',
event,
properties,
timestamp: '2026-04-28T00:00:00.000Z',
anonymousId: 'anon-test',
messageId: 'msg-test',
writeKey: 'wk-test',
context: { app: appContext } as any,
}) as EnrichedEventPayload
);
const emitter = new LifecycleEmitter(
dispatcher,
createTrackEvent,
appContext
);
return { emitter, dispatcherEnqueue, createTrackEvent };
};

it('emits Application Installed with version + build from appContext', () => {
const { emitter, dispatcherEnqueue, createTrackEvent } = setup();

emitter.emitInstalled();

expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_INSTALLED, {
version: '1.4.0',
build: '42',
});
expect(dispatcherEnqueue).toHaveBeenCalledTimes(1);
expect(dispatcherEnqueue.mock.calls[0][0].event).toBe(
APPLICATION_INSTALLED
);
});

it('emits Application Updated with previous version + build', () => {
const { emitter, createTrackEvent } = setup();

emitter.emitUpdated({ version: '1.3.0', build: '40' });

expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_UPDATED, {
version: '1.4.0',
build: '42',
previous_version: '1.3.0',
previous_build: '40',
});
});

it('emits Application Updated with unknown sentinel for SDK-upgrade case', () => {
const { emitter, createTrackEvent } = setup();

emitter.emitUpdated({
version: UNKNOWN_PREVIOUS,
build: UNKNOWN_PREVIOUS,
});

expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_UPDATED, {
version: '1.4.0',
build: '42',
previous_version: 'unknown',
previous_build: 'unknown',
});
});

it('emits Application Opened with from_background false on cold launch', () => {
const { emitter, createTrackEvent } = setup();

emitter.emitOpened(false);

expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_OPENED, {
from_background: false,
version: '1.4.0',
build: '42',
});
});

it('emits Application Opened with from_background true on resume', () => {
const { emitter, createTrackEvent } = setup();

emitter.emitOpened(true);

expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_OPENED, {
from_background: true,
version: '1.4.0',
build: '42',
});
});

it('includes url + referring_application when deep link is provided', () => {
const { emitter, createTrackEvent } = setup();

emitter.emitOpened(false, {
url: 'myapp://product/123',
referringApplication: 'com.example.referrer',
});

expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_OPENED, {
from_background: false,
version: '1.4.0',
build: '42',
url: 'myapp://product/123',
referring_application: 'com.example.referrer',
});
});

it('omits url + referring_application when not provided', () => {
const { emitter, createTrackEvent } = setup();

emitter.emitOpened(true, {});

const props = createTrackEvent.mock.calls[0][1] as Record<string, any>;
expect(props).not.toHaveProperty('url');
expect(props).not.toHaveProperty('referring_application');
});

it('emits Application Backgrounded with empty properties', () => {
const { emitter, createTrackEvent, dispatcherEnqueue } = setup();

emitter.emitBackgrounded();

expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_BACKGROUNDED, {});
expect(dispatcherEnqueue).toHaveBeenCalledTimes(1);
});
});
102 changes: 102 additions & 0 deletions src/analytics/lifecycle/lifecycleEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* Names + property keys for the four Application lifecycle events.
*/

import Dispatcher from '../dispatcher';
import { EnrichedEventPayload } from '../types';
import { AppContext } from '../utils/appContext';

export const APPLICATION_INSTALLED = 'Application Installed';
export const APPLICATION_UPDATED = 'Application Updated';
export const APPLICATION_OPENED = 'Application Opened';
export const APPLICATION_BACKGROUNDED = 'Application Backgrounded';

export const PROP_VERSION = 'version';
export const PROP_BUILD = 'build';
export const PROP_PREVIOUS_VERSION = 'previous_version';
export const PROP_PREVIOUS_BUILD = 'previous_build';
export const PROP_FROM_BACKGROUND = 'from_background';
export const PROP_REFERRING_APPLICATION = 'referring_application';
export const PROP_URL = 'url';

/** Unknown previous_version/previous_build for SDK upgrades from a pre-lifecycle build. */
export const UNKNOWN_PREVIOUS = 'unknown';

export interface DeepLinkInfo {
url?: string;
referringApplication?: string;
}

/**
* Builds a fully-enriched track-type EnrichedEventPayload (identity + writeKey
* + context + messageId + timestamp). Provided by the analytics client so the
* emitter does not need to know how identity/enrichment are wired.
*/
export type CreateTrackEvent = (
event: string,
properties?: Record<string, any>
) => EnrichedEventPayload;

/**
* Thin emitter that wraps the dispatch path with the lifecycle event shapes.
* Mirrors the iOS LifecycleEventEmitter: takes a Dispatcher and an enrichment
* callable plus a process-stable AppContext, then constructs Installed /
* Updated / Opened / Backgrounded payloads. Construct only when lifecycle
* tracking is enabled; callers should skip construction entirely when the
* flag is off.
*/
export class LifecycleEmitter {
private readonly dispatcher: Dispatcher;
private readonly createTrackEvent: CreateTrackEvent;
private readonly appContext: AppContext;

constructor(
dispatcher: Dispatcher,
createTrackEvent: CreateTrackEvent,
appContext: AppContext
) {
this.dispatcher = dispatcher;
this.createTrackEvent = createTrackEvent;
this.appContext = appContext;
}

emitInstalled(): void {
this.dispatch(APPLICATION_INSTALLED, {
[PROP_VERSION]: this.appContext.version,
[PROP_BUILD]: this.appContext.build,
});
}

emitUpdated(previous: { version: string; build: string }): void {
this.dispatch(APPLICATION_UPDATED, {
[PROP_VERSION]: this.appContext.version,
[PROP_BUILD]: this.appContext.build,
[PROP_PREVIOUS_VERSION]: previous.version,
[PROP_PREVIOUS_BUILD]: previous.build,
});
}

emitOpened(fromBackground: boolean, deepLink?: DeepLinkInfo): void {
const props: Record<string, any> = {
[PROP_FROM_BACKGROUND]: fromBackground,
[PROP_VERSION]: this.appContext.version,
[PROP_BUILD]: this.appContext.build,
};
if (deepLink?.url) {
props[PROP_URL] = deepLink.url;
}
if (deepLink?.referringApplication) {
props[PROP_REFERRING_APPLICATION] = deepLink.referringApplication;
}
this.dispatch(APPLICATION_OPENED, props);
}

emitBackgrounded(): void {
this.dispatch(APPLICATION_BACKGROUNDED, {});
}

private dispatch(event: string, properties: Record<string, any>): void {
const enriched = this.createTrackEvent(event, properties);
this.dispatcher.enqueue(enriched);
}
}
37 changes: 37 additions & 0 deletions src/analytics/utils/appContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import pkg from '../../../package.json';

let DeviceInfo: any = null;

try {
DeviceInfo = require('react-native-device-info');
} catch {
DeviceInfo = null;
}

/**
* Snapshot of the host app's identity (name, version, build, bundle id).
* Read once at SDK init and reused across every event — both as the `app:`
* block on the EventContext and as version/build properties on lifecycle
* events. Mirrors the iOS `AppContext` so a single source of truth flows
* through the same places on both platforms.
*/
export interface AppContext {
name: string;
version: string;
build: string;
namespace: string;
}

/**
* Reads the current app identity from `react-native-device-info`. Falls back
* to package.json version + 'unknown' for everything else if the native
* module is missing (e.g. unit tests, Expo Go without the dev client).
*/
export function loadAppContext(): AppContext {
return {
name: DeviceInfo?.getApplicationName?.() ?? 'unknown',
version: DeviceInfo?.getVersion?.() ?? pkg.version ?? 'unknown',
build: DeviceInfo?.getBuildNumber?.() ?? 'unknown',
namespace: DeviceInfo?.getBundleId?.() ?? 'unknown',
};
}
Loading
Loading