From 554ae4191a09a0bae26a1a7cd14c535f5112a606 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 16:21:14 -0600 Subject: [PATCH 1/2] feat: add lifecycle event emitter [rn] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `LifecycleEmitter` class — a thin wrapper that maps the four lifecycle events to the standard `track()` shape with snake_case properties matching the iOS / Android wire format exactly: - Application Installed {version, build} - Application Updated {version, build, previous_version, previous_build} - Application Opened {from_background, version, build, url?, referring_application?} - Application Backgrounded {} The emitter holds the `enabled` flag (mirrors `trackLifecycleEvents`) so every emit is a no-op when the feature is disabled — callers do not need to gate at every call site. Property names use snake_case while event names use Title Case to match the cross-platform SDK contract; the `UNKNOWN_PREVIOUS` sentinel is used for the SDK-upgrade case (existing user, no prior lifecycle storage) where the previous version is genuinely unknown rather than the literal string the host shipped. Slice 2 of 4 in the RN lifecycle stack (sc-36800). --- .../lifecycle/lifecycleEvents.test.ts | 139 ++++++++++++++++++ src/analytics/lifecycle/lifecycleEvents.ts | 97 ++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/analytics/lifecycle/lifecycleEvents.test.ts create mode 100644 src/analytics/lifecycle/lifecycleEvents.ts diff --git a/src/analytics/lifecycle/lifecycleEvents.test.ts b/src/analytics/lifecycle/lifecycleEvents.test.ts new file mode 100644 index 0000000..b2da0eb --- /dev/null +++ b/src/analytics/lifecycle/lifecycleEvents.test.ts @@ -0,0 +1,139 @@ +import { + LifecycleEmitter, + APPLICATION_INSTALLED, + APPLICATION_UPDATED, + APPLICATION_OPENED, + APPLICATION_BACKGROUNDED, + UNKNOWN_PREVIOUS, +} from './lifecycleEvents'; + +describe('LifecycleEmitter', () => { + const versionInfo = { version: '1.4.0', build: '42' }; + + it('emits Application Installed with version + build', () => { + const track = jest.fn(); + const emitter = new LifecycleEmitter(track, true); + + emitter.emitInstalled(versionInfo); + + expect(track).toHaveBeenCalledWith(APPLICATION_INSTALLED, { + version: '1.4.0', + build: '42', + }); + }); + + it('emits Application Updated with previous version + build', () => { + const track = jest.fn(); + const emitter = new LifecycleEmitter(track, true); + + emitter.emitUpdated(versionInfo, { version: '1.3.0', build: '40' }); + + expect(track).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 track = jest.fn(); + const emitter = new LifecycleEmitter(track, true); + + emitter.emitUpdated(versionInfo, { + version: UNKNOWN_PREVIOUS, + build: UNKNOWN_PREVIOUS, + }); + + expect(track).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 track = jest.fn(); + const emitter = new LifecycleEmitter(track, true); + + emitter.emitOpened(versionInfo, false); + + expect(track).toHaveBeenCalledWith(APPLICATION_OPENED, { + from_background: false, + version: '1.4.0', + build: '42', + }); + }); + + it('emits Application Opened with from_background true on resume', () => { + const track = jest.fn(); + const emitter = new LifecycleEmitter(track, true); + + emitter.emitOpened(versionInfo, true); + + expect(track).toHaveBeenCalledWith(APPLICATION_OPENED, { + from_background: true, + version: '1.4.0', + build: '42', + }); + }); + + it('includes url + referring_application when deep link is provided', () => { + const track = jest.fn(); + const emitter = new LifecycleEmitter(track, true); + + emitter.emitOpened(versionInfo, false, { + url: 'myapp://product/123', + referringApplication: 'com.example.referrer', + }); + + expect(track).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 track = jest.fn(); + const emitter = new LifecycleEmitter(track, true); + + emitter.emitOpened(versionInfo, true, {}); + + const props = track.mock.calls[0][1]; + expect(props).not.toHaveProperty('url'); + expect(props).not.toHaveProperty('referring_application'); + }); + + it('emits Application Backgrounded with empty properties', () => { + const track = jest.fn(); + const emitter = new LifecycleEmitter(track, true); + + emitter.emitBackgrounded(); + + expect(track).toHaveBeenCalledWith(APPLICATION_BACKGROUNDED, {}); + }); + + describe('disabled emitter', () => { + it('does not call track for any event when disabled', () => { + const track = jest.fn(); + const emitter = new LifecycleEmitter(track, false); + + emitter.emitInstalled(versionInfo); + emitter.emitUpdated(versionInfo, { version: '1.0.0', build: '1' }); + emitter.emitOpened(versionInfo, false); + emitter.emitOpened(versionInfo, true); + emitter.emitBackgrounded(); + + expect(track).not.toHaveBeenCalled(); + }); + + it('isEnabled reflects the constructor flag', () => { + expect(new LifecycleEmitter(jest.fn(), true).isEnabled()).toBe(true); + expect(new LifecycleEmitter(jest.fn(), false).isEnabled()).toBe(false); + }); + }); +}); diff --git a/src/analytics/lifecycle/lifecycleEvents.ts b/src/analytics/lifecycle/lifecycleEvents.ts new file mode 100644 index 0000000..fb81467 --- /dev/null +++ b/src/analytics/lifecycle/lifecycleEvents.ts @@ -0,0 +1,97 @@ +/** + * Names + property keys for the four Application lifecycle events. Matches the + * iOS/Android wire format exactly (Title Case event names, snake_case props). + */ + +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'; + +/** Sentinel previous_version/previous_build for SDK upgrades from a pre-lifecycle build. */ +export const UNKNOWN_PREVIOUS = 'unknown'; + +export interface VersionInfo { + version: string; + build: string; +} + +export interface DeepLinkInfo { + url?: string; + referringApplication?: string; +} + +type TrackFn = (event: string, properties?: Record) => void; + +/** + * Thin emitter that wraps the client's track() with the lifecycle event + * shapes. Honors the trackLifecycleEvents flag — when disabled every emit is a + * no-op so callers do not need to gate at every call site. + */ +export class LifecycleEmitter { + private readonly track: TrackFn; + private readonly enabled: boolean; + + constructor(track: TrackFn, enabled: boolean) { + this.track = track; + this.enabled = enabled; + } + + emitInstalled(info: VersionInfo): void { + if (!this.enabled) return; + this.track(APPLICATION_INSTALLED, { + [PROP_VERSION]: info.version, + [PROP_BUILD]: info.build, + }); + } + + emitUpdated( + info: VersionInfo, + previous: { version: string; build: string } + ): void { + if (!this.enabled) return; + this.track(APPLICATION_UPDATED, { + [PROP_VERSION]: info.version, + [PROP_BUILD]: info.build, + [PROP_PREVIOUS_VERSION]: previous.version, + [PROP_PREVIOUS_BUILD]: previous.build, + }); + } + + emitOpened( + info: VersionInfo, + fromBackground: boolean, + deepLink?: DeepLinkInfo + ): void { + if (!this.enabled) return; + const props: Record = { + [PROP_FROM_BACKGROUND]: fromBackground, + [PROP_VERSION]: info.version, + [PROP_BUILD]: info.build, + }; + if (deepLink?.url) { + props[PROP_URL] = deepLink.url; + } + if (deepLink?.referringApplication) { + props[PROP_REFERRING_APPLICATION] = deepLink.referringApplication; + } + this.track(APPLICATION_OPENED, props); + } + + emitBackgrounded(): void { + if (!this.enabled) return; + this.track(APPLICATION_BACKGROUNDED, {}); + } + + isEnabled(): boolean { + return this.enabled; + } +} From 3675d61631e190f1a16a5b5a31616cdeccdc5a9c Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Tue, 28 Apr 2026 12:39:01 -0600 Subject: [PATCH 2/2] fix: appContext and removing flag / gate from EventEmitter --- src/analytics/MetaRouterAnalyticsClient.ts | 8 +- .../lifecycle/lifecycleEvents.test.ts | 121 ++++++++++-------- src/analytics/lifecycle/lifecycleEvents.ts | 91 ++++++------- src/analytics/utils/appContext.ts | 37 ++++++ src/analytics/utils/contextInfo.test.ts | 54 ++++---- src/analytics/utils/contextInfo.ts | 17 ++- 6 files changed, 195 insertions(+), 133 deletions(-) create mode 100644 src/analytics/utils/appContext.ts diff --git a/src/analytics/MetaRouterAnalyticsClient.ts b/src/analytics/MetaRouterAnalyticsClient.ts index 0231755..1c4989f 100644 --- a/src/analytics/MetaRouterAnalyticsClient.ts +++ b/src/analytics/MetaRouterAnalyticsClient.ts @@ -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, @@ -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; @@ -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 ); @@ -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'); } @@ -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'); } diff --git a/src/analytics/lifecycle/lifecycleEvents.test.ts b/src/analytics/lifecycle/lifecycleEvents.test.ts index b2da0eb..8168906 100644 --- a/src/analytics/lifecycle/lifecycleEvents.test.ts +++ b/src/analytics/lifecycle/lifecycleEvents.test.ts @@ -6,29 +6,63 @@ import { 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 versionInfo = { version: '1.4.0', build: '42' }; - - it('emits Application Installed with version + build', () => { - const track = jest.fn(); - const emitter = new LifecycleEmitter(track, true); - - emitter.emitInstalled(versionInfo); - - expect(track).toHaveBeenCalledWith(APPLICATION_INSTALLED, { + const appContext: AppContext = { + name: 'TestApp', + version: '1.4.0', + build: '42', + namespace: 'com.metarouter.test', + }; + + const setup = () => { + const dispatcherEnqueue = jest.fn(); + const dispatcher = { enqueue: dispatcherEnqueue } as unknown as Dispatcher; + const createTrackEvent = jest.fn( + (event: string, properties?: Record): 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 track = jest.fn(); - const emitter = new LifecycleEmitter(track, true); + const { emitter, createTrackEvent } = setup(); - emitter.emitUpdated(versionInfo, { version: '1.3.0', build: '40' }); + emitter.emitUpdated({ version: '1.3.0', build: '40' }); - expect(track).toHaveBeenCalledWith(APPLICATION_UPDATED, { + expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_UPDATED, { version: '1.4.0', build: '42', previous_version: '1.3.0', @@ -37,15 +71,14 @@ describe('LifecycleEmitter', () => { }); it('emits Application Updated with unknown sentinel for SDK-upgrade case', () => { - const track = jest.fn(); - const emitter = new LifecycleEmitter(track, true); + const { emitter, createTrackEvent } = setup(); - emitter.emitUpdated(versionInfo, { + emitter.emitUpdated({ version: UNKNOWN_PREVIOUS, build: UNKNOWN_PREVIOUS, }); - expect(track).toHaveBeenCalledWith(APPLICATION_UPDATED, { + expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_UPDATED, { version: '1.4.0', build: '42', previous_version: 'unknown', @@ -54,12 +87,11 @@ describe('LifecycleEmitter', () => { }); it('emits Application Opened with from_background false on cold launch', () => { - const track = jest.fn(); - const emitter = new LifecycleEmitter(track, true); + const { emitter, createTrackEvent } = setup(); - emitter.emitOpened(versionInfo, false); + emitter.emitOpened(false); - expect(track).toHaveBeenCalledWith(APPLICATION_OPENED, { + expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_OPENED, { from_background: false, version: '1.4.0', build: '42', @@ -67,12 +99,11 @@ describe('LifecycleEmitter', () => { }); it('emits Application Opened with from_background true on resume', () => { - const track = jest.fn(); - const emitter = new LifecycleEmitter(track, true); + const { emitter, createTrackEvent } = setup(); - emitter.emitOpened(versionInfo, true); + emitter.emitOpened(true); - expect(track).toHaveBeenCalledWith(APPLICATION_OPENED, { + expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_OPENED, { from_background: true, version: '1.4.0', build: '42', @@ -80,15 +111,14 @@ describe('LifecycleEmitter', () => { }); it('includes url + referring_application when deep link is provided', () => { - const track = jest.fn(); - const emitter = new LifecycleEmitter(track, true); + const { emitter, createTrackEvent } = setup(); - emitter.emitOpened(versionInfo, false, { + emitter.emitOpened(false, { url: 'myapp://product/123', referringApplication: 'com.example.referrer', }); - expect(track).toHaveBeenCalledWith(APPLICATION_OPENED, { + expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_OPENED, { from_background: false, version: '1.4.0', build: '42', @@ -98,42 +128,21 @@ describe('LifecycleEmitter', () => { }); it('omits url + referring_application when not provided', () => { - const track = jest.fn(); - const emitter = new LifecycleEmitter(track, true); + const { emitter, createTrackEvent } = setup(); - emitter.emitOpened(versionInfo, true, {}); + emitter.emitOpened(true, {}); - const props = track.mock.calls[0][1]; + const props = createTrackEvent.mock.calls[0][1] as Record; expect(props).not.toHaveProperty('url'); expect(props).not.toHaveProperty('referring_application'); }); it('emits Application Backgrounded with empty properties', () => { - const track = jest.fn(); - const emitter = new LifecycleEmitter(track, true); + const { emitter, createTrackEvent, dispatcherEnqueue } = setup(); emitter.emitBackgrounded(); - expect(track).toHaveBeenCalledWith(APPLICATION_BACKGROUNDED, {}); - }); - - describe('disabled emitter', () => { - it('does not call track for any event when disabled', () => { - const track = jest.fn(); - const emitter = new LifecycleEmitter(track, false); - - emitter.emitInstalled(versionInfo); - emitter.emitUpdated(versionInfo, { version: '1.0.0', build: '1' }); - emitter.emitOpened(versionInfo, false); - emitter.emitOpened(versionInfo, true); - emitter.emitBackgrounded(); - - expect(track).not.toHaveBeenCalled(); - }); - - it('isEnabled reflects the constructor flag', () => { - expect(new LifecycleEmitter(jest.fn(), true).isEnabled()).toBe(true); - expect(new LifecycleEmitter(jest.fn(), false).isEnabled()).toBe(false); - }); + expect(createTrackEvent).toHaveBeenCalledWith(APPLICATION_BACKGROUNDED, {}); + expect(dispatcherEnqueue).toHaveBeenCalledTimes(1); }); }); diff --git a/src/analytics/lifecycle/lifecycleEvents.ts b/src/analytics/lifecycle/lifecycleEvents.ts index fb81467..02460df 100644 --- a/src/analytics/lifecycle/lifecycleEvents.ts +++ b/src/analytics/lifecycle/lifecycleEvents.ts @@ -1,8 +1,11 @@ /** - * Names + property keys for the four Application lifecycle events. Matches the - * iOS/Android wire format exactly (Title Case event names, snake_case props). + * 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'; @@ -16,66 +19,68 @@ export const PROP_FROM_BACKGROUND = 'from_background'; export const PROP_REFERRING_APPLICATION = 'referring_application'; export const PROP_URL = 'url'; -/** Sentinel previous_version/previous_build for SDK upgrades from a pre-lifecycle build. */ +/** Unknown previous_version/previous_build for SDK upgrades from a pre-lifecycle build. */ export const UNKNOWN_PREVIOUS = 'unknown'; -export interface VersionInfo { - version: string; - build: string; -} - export interface DeepLinkInfo { url?: string; referringApplication?: string; } -type TrackFn = (event: string, properties?: Record) => void; +/** + * 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 +) => EnrichedEventPayload; /** - * Thin emitter that wraps the client's track() with the lifecycle event - * shapes. Honors the trackLifecycleEvents flag — when disabled every emit is a - * no-op so callers do not need to gate at every call site. + * 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 track: TrackFn; - private readonly enabled: boolean; + private readonly dispatcher: Dispatcher; + private readonly createTrackEvent: CreateTrackEvent; + private readonly appContext: AppContext; - constructor(track: TrackFn, enabled: boolean) { - this.track = track; - this.enabled = enabled; + constructor( + dispatcher: Dispatcher, + createTrackEvent: CreateTrackEvent, + appContext: AppContext + ) { + this.dispatcher = dispatcher; + this.createTrackEvent = createTrackEvent; + this.appContext = appContext; } - emitInstalled(info: VersionInfo): void { - if (!this.enabled) return; - this.track(APPLICATION_INSTALLED, { - [PROP_VERSION]: info.version, - [PROP_BUILD]: info.build, + emitInstalled(): void { + this.dispatch(APPLICATION_INSTALLED, { + [PROP_VERSION]: this.appContext.version, + [PROP_BUILD]: this.appContext.build, }); } - emitUpdated( - info: VersionInfo, - previous: { version: string; build: string } - ): void { - if (!this.enabled) return; - this.track(APPLICATION_UPDATED, { - [PROP_VERSION]: info.version, - [PROP_BUILD]: info.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( - info: VersionInfo, - fromBackground: boolean, - deepLink?: DeepLinkInfo - ): void { - if (!this.enabled) return; + emitOpened(fromBackground: boolean, deepLink?: DeepLinkInfo): void { const props: Record = { [PROP_FROM_BACKGROUND]: fromBackground, - [PROP_VERSION]: info.version, - [PROP_BUILD]: info.build, + [PROP_VERSION]: this.appContext.version, + [PROP_BUILD]: this.appContext.build, }; if (deepLink?.url) { props[PROP_URL] = deepLink.url; @@ -83,15 +88,15 @@ export class LifecycleEmitter { if (deepLink?.referringApplication) { props[PROP_REFERRING_APPLICATION] = deepLink.referringApplication; } - this.track(APPLICATION_OPENED, props); + this.dispatch(APPLICATION_OPENED, props); } emitBackgrounded(): void { - if (!this.enabled) return; - this.track(APPLICATION_BACKGROUNDED, {}); + this.dispatch(APPLICATION_BACKGROUNDED, {}); } - isEnabled(): boolean { - return this.enabled; + private dispatch(event: string, properties: Record): void { + const enriched = this.createTrackEvent(event, properties); + this.dispatcher.enqueue(enriched); } } diff --git a/src/analytics/utils/appContext.ts b/src/analytics/utils/appContext.ts new file mode 100644 index 0000000..196c762 --- /dev/null +++ b/src/analytics/utils/appContext.ts @@ -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', + }; +} diff --git a/src/analytics/utils/contextInfo.test.ts b/src/analytics/utils/contextInfo.test.ts index a23ef50..7b6ef78 100644 --- a/src/analytics/utils/contextInfo.test.ts +++ b/src/analytics/utils/contextInfo.test.ts @@ -6,6 +6,14 @@ jest.mock('../../../package.json', () => ({ version: '1.2.3', })); +const buildAppContext = (overrides: Partial> = {}) => ({ + name: 'unknown', + version: '2.3.4', + build: '567', + namespace: 'unknown', + ...overrides, +}); + describe('getContextInfo', () => { beforeEach(() => { jest.resetModules(); @@ -23,16 +31,13 @@ describe('getContextInfo', () => { getDeviceId: () => 'iPhone17,2', getSystemName: () => 'iOS', getSystemVersion: () => '17.0', - getVersion: () => '2.3.4', - getBuildNumber: () => '567', - isWifiEnabled: () => Promise.resolve(true), })); // Re-import the module to get the mocked version const { getContextInfo: getContextInfoMocked } = require('./contextInfo'); - const context = await getContextInfoMocked(); + const context = await getContextInfoMocked(buildAppContext()); expect(context).toEqual({ app: { @@ -73,11 +78,13 @@ describe('getContextInfo', () => { // Re-import the module to get the mocked version const { getContextInfo: getContextInfoMocked } = require('./contextInfo'); - const context = await getContextInfoMocked(); + const context = await getContextInfoMocked( + buildAppContext({ version: '1.2.3' }) + ); expect(context.device.manufacturer).toBe('unknown'); expect(context.device.model).toBe('unknown'); - expect(context.app.version).toBe('1.2.3'); // fallback to pkg.version + expect(context.app.version).toBe('1.2.3'); }); it('includes advertisingId in device context when provided', async () => { @@ -87,9 +94,6 @@ describe('getContextInfo', () => { getDeviceId: () => 'iPhone17,2', getSystemName: () => 'iOS', getSystemVersion: () => '17.0', - getVersion: () => '2.3.4', - getBuildNumber: () => '567', - isWifiEnabled: () => Promise.resolve(true), })); @@ -97,7 +101,10 @@ describe('getContextInfo', () => { const { getContextInfo: getContextInfoMocked } = require('./contextInfo'); const advertisingId = 'IDFA-12345-67890-ABCDEF'; - const context = await getContextInfoMocked(advertisingId); + const context = await getContextInfoMocked( + buildAppContext(), + advertisingId + ); expect(context.device.advertisingId).toBe(advertisingId); }); @@ -109,16 +116,13 @@ describe('getContextInfo', () => { getDeviceId: () => 'iPhone17,2', getSystemName: () => 'iOS', getSystemVersion: () => '17.0', - getVersion: () => '2.3.4', - getBuildNumber: () => '567', - isWifiEnabled: () => Promise.resolve(true), })); // Re-import the module to get the mocked version const { getContextInfo: getContextInfoMocked } = require('./contextInfo'); - const context = await getContextInfoMocked(); + const context = await getContextInfoMocked(buildAppContext()); expect(context.device.advertisingId).toBeUndefined(); }); @@ -145,15 +149,18 @@ describe('getContextInfo', () => { getDeviceId: () => 'o1s', getSystemName: () => 'Android', getSystemVersion: () => '14', - getVersion: () => '1.5.0', - getBuildNumber: () => '127', - getApplicationName: () => 'TestApp', - getBundleId: () => 'com.example.testapp', isWifiEnabled: () => Promise.resolve(true), })); const { getContextInfo: getContextInfoMocked } = require('./contextInfo'); - const context = await getContextInfoMocked(); + const context = await getContextInfoMocked( + buildAppContext({ + name: 'TestApp', + version: '1.5.0', + build: '127', + namespace: 'com.example.testapp', + }) + ); expect(context.device).toEqual({ manufacturer: 'Samsung', @@ -173,13 +180,11 @@ describe('getContextInfo', () => { getDevice: () => Promise.resolve('iPhone17,2'), getSystemName: () => 'iOS', getSystemVersion: () => '17.0', - getVersion: () => '2.3.4', - getBuildNumber: () => '567', isWifiEnabled: () => Promise.resolve(true), })); const { getContextInfo: getContextInfoMocked } = require('./contextInfo'); - const context = await getContextInfoMocked(); + const context = await getContextInfoMocked(buildAppContext()); expect(context.device).toEqual({ manufacturer: 'Apple', @@ -196,16 +201,13 @@ describe('getContextInfo', () => { getDeviceId: () => 'iPhone17,2', getSystemName: () => 'iOS', getSystemVersion: () => '17.0', - getVersion: () => '2.3.4', - getBuildNumber: () => '567', - isWifiEnabled: () => Promise.resolve(true), })); // Re-import the module to get the mocked version const { getContextInfo: getContextInfoMocked } = require('./contextInfo'); - const context = await getContextInfoMocked(undefined); + const context = await getContextInfoMocked(buildAppContext(), undefined); expect(context.device.advertisingId).toBeUndefined(); }); diff --git a/src/analytics/utils/contextInfo.ts b/src/analytics/utils/contextInfo.ts index decd557..5a6d731 100644 --- a/src/analytics/utils/contextInfo.ts +++ b/src/analytics/utils/contextInfo.ts @@ -3,6 +3,7 @@ import { Dimensions, PixelRatio, Platform } from 'react-native'; import { getTimeZone } from './timezone'; import pkg from '../../../package.json'; import { EventContext } from '../types'; +import { AppContext } from './appContext'; let cachedContext: EventContext | null = null; let cachedAdvertisingId: string | undefined; @@ -25,15 +26,19 @@ export function clearContextCache(): void { /** * Gathers and caches device, app, and environment context information for analytics events. * - * - Collects details such as app name/version, device model/type, OS, screen size, locale, timezone, and network status. - * - Uses `react-native-device-info` and the current environment to populate fields. + * - Collects details such as device model/type, OS, screen size, locale, timezone, and network status. + * - Uses `react-native-device-info` and the current environment to populate device/os fields. + * - Reuses the caller-provided AppContext for the `app:` block so version/build + * are sourced from the same snapshot used by lifecycle events. * - Caches the result for the lifetime of the app to avoid redundant async calls. * - Returns a context object suitable for event enrichment. * + * @param appContext - The host app identity snapshot (name, version, build, namespace). * @param advertisingId - Optional advertising identifier (IDFA on iOS, GAID on Android) for ad tracking and attribution. * @returns {Promise} A promise that resolves to the context information object. */ export async function getContextInfo( + appContext: AppContext, advertisingId?: string ): Promise { // Return cached context only if it exists AND the advertising ID hasn't changed @@ -76,10 +81,10 @@ export async function getContextInfo( version: DeviceInfo?.getSystemVersion?.() ?? 'unknown', }, app: { - name: DeviceInfo?.getApplicationName?.() ?? 'unknown', - version: DeviceInfo?.getVersion?.() ?? pkg.version ?? 'unknown', - build: DeviceInfo?.getBuildNumber?.() ?? 'unknown', - namespace: DeviceInfo?.getBundleId?.() ?? 'unknown', + name: appContext.name, + version: appContext.version, + build: appContext.build, + namespace: appContext.namespace, }, screen: { width: Math.round(width),