From 5f2a0f6e117ed10f5860c3736c740aeea1d22006 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Mon, 27 Apr 2026 17:43:51 -0600 Subject: [PATCH] feat: wire lifecycle events into analytics client + add openURL [rn] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the storage foundation (#34) and emitter (#35) into `MetaRouterAnalyticsClient` and adds the public `openURL` API. Behavior - Cold-launch state machine: detects fresh install vs SDK-upgrade vs version change vs no-op, emits Installed/Updated *before* the first Application Opened so attribution pipelines see the install/update before the session start. - Background-launched processes (push, headless JS) suppress the cold-launch Application Opened; the next background→active emits with from_background:false as the cold-launch bridge. - inactive→active transitions (Control Center, FaceID, system alerts) are suppressed — only background→active emits Application Opened. - Application Backgrounded is enqueued *before* the dispatcher's flush-to-disk pass so the event ships in the same drain. - Auto-captures cold-launch URL via Linking.getInitialURL() and runtime URLs via Linking.addEventListener('url', ...). One-shot deep-link buffer with last-write-wins overwrite, cleared on the next Application Opened emit. Public API - `openURL(url, sourceApplication?)` — forwards a URL the host received (Linking, UIScene URL handler, Android Intent, custom deep-link library) so it is attached to the next Application Opened. No-op with a Logger.warn when trackLifecycleEvents is disabled — silent no-ops are bad DX, hosts wiring this up should know they have the feature flag off. - Wired through AnalyticsInterface, the proxy (so pre-bind calls are queued), and init.ts boundClient. Defaults - `trackLifecycleEvents` defaults to **false** (opt-in). Existing customers upgrading the SDK do not begin emitting these events without explicitly setting the flag — matches iOS / Android v1.5. App metadata - Single `appContext` snapshot held on the client, populated once from `this.context.app` after `getContextInfo` resolves. Replaces three sites that independently re-derived `{version, build}` from the cached context — single source of truth, parity with the iOS `AppContext` consolidation. Tests - `disableDispatcherFlush` helper added to the lifecycle describe block: the dispatcher's flush() synchronously drains the in-memory queue inside its first while iteration (drainBatch runs before the await), which empties events that were emitted right before `this.flush()` in handleAppStateChange. Production behavior is correct (event still ships via fetch); the helper just keeps the queue snapshot intact for assertions. - 6 new tests cover the openURL public API: buffered URL on next Opened, sourceApplication → referring_application, last-write-wins, one-shot clear, disabled no-op + warning, invalid input rejection. - 1 new test confirms the opt-in default (omitted flag → no events). - Pre-existing `preserves lifecycle storage across reset()` test fixed: AsyncStorage mock module shape is `{ default: { ... } }`, so the spy must target the `default` export, not the top object. Slice 3 of 4 in the RN lifecycle stack (sc-36800). --- jest.setup.js | 4 + .../MetaRouterAnalyticsClient.test.ts | 545 ++++++++++++++++++ src/analytics/MetaRouterAnalyticsClient.ts | 248 +++++++- src/analytics/init.ts | 2 + src/analytics/proxy/proxyClient.ts | 2 + src/analytics/types.ts | 16 + 6 files changed, 815 insertions(+), 2 deletions(-) diff --git a/jest.setup.js b/jest.setup.js index f921127..fcf5043 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -11,6 +11,10 @@ jest.mock('react-native', () => ({ OS: 'ios', select: jest.fn((obj) => obj.ios || obj.default), }, + Linking: { + getInitialURL: jest.fn(() => Promise.resolve(null)), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + }, NativeModules: { MetaRouterQueueStorage: { exists: jest.fn(() => Promise.resolve(false)), diff --git a/src/analytics/MetaRouterAnalyticsClient.test.ts b/src/analytics/MetaRouterAnalyticsClient.test.ts index df566a4..8a806da 100644 --- a/src/analytics/MetaRouterAnalyticsClient.test.ts +++ b/src/analytics/MetaRouterAnalyticsClient.test.ts @@ -18,10 +18,23 @@ jest.mock('./utils/identityStorage', () => ({ ADVERTISING_ID_KEY: 'metarouter:advertising_id', })); +jest.mock('./utils/lifecycleStorage', () => ({ + getLifecycleVersion: jest.fn(() => Promise.resolve(null)), + getLifecycleBuild: jest.fn(() => Promise.resolve(null)), + setLifecycleVersionBuild: jest.fn(() => Promise.resolve()), + LIFECYCLE_VERSION_KEY: 'metarouter:lifecycle:version', + LIFECYCLE_BUILD_KEY: 'metarouter:lifecycle:build', +})); + +// Existing tests assert specific queue lengths and order. Disable lifecycle +// emission here so the auto-emitted Application Opened / Updated events do +// not skew those assertions. A dedicated `describe('lifecycle events')` +// block below covers the behavior with the flag enabled. const opts: InitOptions = { ingestionHost: 'https://example.com', writeKey: 'test_write_key', flushIntervalSeconds: 5, + trackLifecycleEvents: false, }; describe('MetaRouterAnalyticsClient', () => { @@ -1100,4 +1113,536 @@ describe('MetaRouterAnalyticsClient', () => { expect(flushSpy).not.toHaveBeenCalled(); }); }); + + describe('lifecycle events', () => { + const lifecycleOpts: InitOptions = { + ingestionHost: 'https://example.com', + writeKey: 'test_write_key', + flushIntervalSeconds: 5, + trackLifecycleEvents: true, + }; + + const getLifecycleEvents = (client: MetaRouterAnalyticsClient) => + client.queue.filter( + (e) => e.type === 'track' && e.event?.startsWith('Application ') + ); + + // Stop the dispatcher's flush() from synchronously draining the in-memory + // queue inside its first iteration (`drainBatch` runs before the await). + // Without this, events emitted right before `this.flush()` in + // handleAppStateChange disappear from `client.queue` before assertions + // can read them — the production behavior is correct (event was shipped + // via fetch), but the queue snapshot is empty. + const disableDispatcherFlush = (client: MetaRouterAnalyticsClient) => { + jest + .spyOn((client as any).dispatcher, 'flush') + .mockResolvedValue(undefined); + }; + + let lifecycleStorage: any; + + beforeEach(() => { + lifecycleStorage = require('./utils/lifecycleStorage'); + (lifecycleStorage.getLifecycleVersion as jest.Mock).mockResolvedValue( + null + ); + (lifecycleStorage.getLifecycleBuild as jest.Mock).mockResolvedValue(null); + ( + lifecycleStorage.setLifecycleVersionBuild as jest.Mock + ).mockResolvedValue(undefined); + // Default jest setup mock — reset for each test. + const RN = require('react-native'); + RN.AppState.currentState = 'active'; + (RN.Linking.getInitialURL as jest.Mock).mockResolvedValue(null); + (RN.Linking.addEventListener as jest.Mock).mockReturnValue({ + remove: jest.fn(), + }); + }); + + it('emits Application Installed then Opened on a fresh install', async () => { + const identityStorage = require('./utils/identityStorage'); + // No identity storage — true fresh install. + (identityStorage.getIdentityField as jest.Mock).mockImplementation( + async () => null + ); + + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + + const events = getLifecycleEvents(client); + expect(events.map((e) => e.event)).toEqual([ + 'Application Installed', + 'Application Opened', + ]); + expect(events[0].properties).toMatchObject({ + version: '1.7.0', + build: 'unknown', + }); + expect(events[1].properties).toMatchObject({ + from_background: false, + version: '1.7.0', + build: 'unknown', + }); + expect(lifecycleStorage.setLifecycleVersionBuild).toHaveBeenCalledWith( + '1.7.0', + 'unknown' + ); + }); + + it('emits Application Updated with unknown sentinel for SDK upgrades', async () => { + // Identity exists (anon-123 from default mock) but no lifecycle storage: + // this is an existing user upgrading from a pre-lifecycle SDK build. + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + + const events = getLifecycleEvents(client); + expect(events.map((e) => e.event)).toEqual([ + 'Application Updated', + 'Application Opened', + ]); + expect(events[0].properties).toMatchObject({ + version: '1.7.0', + build: 'unknown', + previous_version: 'unknown', + previous_build: 'unknown', + }); + }); + + it('emits only Application Opened when stored version matches', async () => { + (lifecycleStorage.getLifecycleVersion as jest.Mock).mockResolvedValue( + '1.7.0' + ); + (lifecycleStorage.getLifecycleBuild as jest.Mock).mockResolvedValue( + 'unknown' + ); + + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + + const events = getLifecycleEvents(client); + expect(events.map((e) => e.event)).toEqual(['Application Opened']); + expect(events[0].properties).toMatchObject({ + from_background: false, + }); + }); + + it('emits Application Updated with the previous values when version differs', async () => { + (lifecycleStorage.getLifecycleVersion as jest.Mock).mockResolvedValue( + '1.6.0' + ); + (lifecycleStorage.getLifecycleBuild as jest.Mock).mockResolvedValue('40'); + + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + + const events = getLifecycleEvents(client); + expect(events.map((e) => e.event)).toEqual([ + 'Application Updated', + 'Application Opened', + ]); + expect(events[0].properties).toMatchObject({ + version: '1.7.0', + build: 'unknown', + previous_version: '1.6.0', + previous_build: '40', + }); + }); + + it('suppresses cold-launch Application Opened when process is in background', async () => { + const RN = require('react-native'); + RN.AppState.currentState = 'background'; + + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + disableDispatcherFlush(client); + + const events = getLifecycleEvents(client); + // Updated may still fire (identity exists), but no Opened. + expect(events.some((e) => e.event === 'Application Opened')).toBe(false); + + // Clear queue, then simulate background→active transition. + const handler = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + // Seed lastAppState to 'background' so the transition is recognized. + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + RN.AppState.currentState = 'active'; + handler('active'); + + const opened = client.queue.find( + (e) => e.type === 'track' && e.event === 'Application Opened' + ); + expect(opened).toBeDefined(); + expect(opened!.properties).toMatchObject({ from_background: false }); + }); + + it('emits Application Backgrounded before flushing on background entry', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + disableDispatcherFlush(client); + + const initialCount = client.queue.length; + const handler = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + + const flushSpy = jest.spyOn(client, 'flush'); + + (client as any).appState = 'active'; + handler('background'); + + // Backgrounded enqueued synchronously before flush() awaits. + const bgEvent = client.queue + .slice(initialCount) + .find( + (e) => e.type === 'track' && e.event === 'Application Backgrounded' + ); + expect(bgEvent).toBeDefined(); + expect(flushSpy).toHaveBeenCalled(); + }); + + it('does not emit Application Backgrounded on inactive transitions', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + + const handler = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + (client as any).appState = 'active'; + handler('inactive'); + + const bgEvent = client.queue.find( + (e) => e.type === 'track' && e.event === 'Application Backgrounded' + ); + expect(bgEvent).toBeUndefined(); + }); + + it('emits Application Opened with from_background=true on background→active', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + disableDispatcherFlush(client); + + const startIndex = client.queue.length; + const handler = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const opened = client.queue + .slice(startIndex) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + expect(opened).toBeDefined(); + expect(opened!.properties).toMatchObject({ from_background: true }); + }); + + it('does not emit Application Opened on inactive→active transitions', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + + const startIndex = client.queue.length; + const handler = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + + (client as any).appState = 'inactive'; + (client as any).lastAppState = 'inactive'; + handler('active'); + + const opened = client.queue + .slice(startIndex) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + expect(opened).toBeUndefined(); + }); + + it('attaches the cold-launch URL from Linking.getInitialURL to Application Opened', async () => { + const RN = require('react-native'); + (RN.Linking.getInitialURL as jest.Mock).mockResolvedValue( + 'myapp://path/to/resource' + ); + + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + + const opened = client.queue.find( + (e) => e.type === 'track' && e.event === 'Application Opened' + ); + expect(opened).toBeDefined(); + expect(opened!.properties?.url).toBe('myapp://path/to/resource'); + }); + + it('attaches a runtime URL event to the next Application Opened (one-shot)', async () => { + const RN = require('react-native'); + let urlListener: ((event: { url: string }) => void) | null = null; + (RN.Linking.addEventListener as jest.Mock).mockImplementation( + (_evt: string, cb: any) => { + urlListener = cb; + return { remove: jest.fn() }; + } + ); + + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + disableDispatcherFlush(client); + + // Simulate a runtime URL arriving while the app is active. + urlListener!({ url: 'myapp://from-runtime' }); + + const startIndex = client.queue.length; + const handler = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const opened = client.queue + .slice(startIndex) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + expect(opened).toBeDefined(); + expect(opened!.properties?.url).toBe('myapp://from-runtime'); + + // Buffer should be cleared after emit; second foreground does not carry the same URL. + const startIndex2 = client.queue.length; + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + const opened2 = client.queue + .slice(startIndex2) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + expect(opened2).toBeDefined(); + expect(opened2!.properties?.url).toBeUndefined(); + }); + + it('emits no lifecycle events when trackLifecycleEvents is false', async () => { + const client = new MetaRouterAnalyticsClient({ + ...lifecycleOpts, + trackLifecycleEvents: false, + }); + await client.init(); + + const startIndex = client.queue.length; + expect(getLifecycleEvents(client)).toHaveLength(0); + + const handler = (AppState.addEventListener as jest.Mock).mock.calls[0][1]; + (client as any).appState = 'active'; + handler('background'); + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const newEvents = client.queue.slice(startIndex); + const lifecycleNames = newEvents + .filter((e) => e.event?.startsWith('Application ')) + .map((e) => e.event); + expect(lifecycleNames).toHaveLength(0); + }); + + it('preserves lifecycle storage across reset()', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + + // The mock module shape is `{ __esModule: true, default: { ... } }`, + // so spy on the `default` export's removeItem. + const AsyncStorageMock = require('@react-native-async-storage/async-storage'); + const removeSpy = jest.spyOn(AsyncStorageMock.default, 'removeItem'); + + await client.reset(); + + // Lifecycle keys must NOT be removed by reset(). + const removedKeys = removeSpy.mock.calls.map((c) => c[0]); + expect(removedKeys).not.toContain('metarouter:lifecycle:version'); + expect(removedKeys).not.toContain('metarouter:lifecycle:build'); + }); + + describe('openURL public API', () => { + it('attaches the buffered URL to the next Application Opened', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + disableDispatcherFlush(client); + + client.openURL('myapp://from-host'); + + const startIndex = client.queue.length; + const handler = (AppState.addEventListener as jest.Mock).mock + .calls[0][1]; + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const opened = client.queue + .slice(startIndex) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + expect(opened).toBeDefined(); + expect(opened!.properties?.url).toBe('myapp://from-host'); + }); + + it('populates referring_application when sourceApplication is provided', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + disableDispatcherFlush(client); + + client.openURL('myapp://from-host', 'com.example.referrer'); + + const startIndex = client.queue.length; + const handler = (AppState.addEventListener as jest.Mock).mock + .calls[0][1]; + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const opened = client.queue + .slice(startIndex) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + expect(opened!.properties).toMatchObject({ + url: 'myapp://from-host', + referring_application: 'com.example.referrer', + }); + }); + + it('is last-write-wins when called multiple times before Opened', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + disableDispatcherFlush(client); + + client.openURL('myapp://first'); + client.openURL('myapp://second'); + client.openURL('myapp://third'); + + const startIndex = client.queue.length; + const handler = (AppState.addEventListener as jest.Mock).mock + .calls[0][1]; + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const opened = client.queue + .slice(startIndex) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + expect(opened!.properties?.url).toBe('myapp://third'); + }); + + it('clears the buffer after the next Opened (one-shot)', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + disableDispatcherFlush(client); + + client.openURL('myapp://once'); + + const handler = (AppState.addEventListener as jest.Mock).mock + .calls[0][1]; + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const startIndex2 = client.queue.length; + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const opened2 = client.queue + .slice(startIndex2) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + expect(opened2).toBeDefined(); + expect(opened2!.properties?.url).toBeUndefined(); + }); + + it('warns and is a no-op when trackLifecycleEvents is disabled', async () => { + const warnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + const client = new MetaRouterAnalyticsClient({ + ...lifecycleOpts, + trackLifecycleEvents: false, + }); + await client.init(); + + client.openURL('myapp://ignored'); + + const startIndex = client.queue.length; + const handler = (AppState.addEventListener as jest.Mock).mock + .calls[0][1]; + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const opened = client.queue + .slice(startIndex) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + // No Opened emit at all when feature is disabled. + expect(opened).toBeUndefined(); + // And the call surfaced a warning so callers can detect the misconfig. + expect(warnSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('trackLifecycleEvents is disabled') + ); + warnSpy.mockRestore(); + }); + + it('ignores invalid url input (empty or non-string)', async () => { + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + disableDispatcherFlush(client); + + client.openURL(''); + client.openURL(undefined as unknown as string); + + const startIndex = client.queue.length; + const handler = (AppState.addEventListener as jest.Mock).mock + .calls[0][1]; + (client as any).appState = 'background'; + (client as any).lastAppState = 'background'; + handler('active'); + + const opened = client.queue + .slice(startIndex) + .find((e) => e.type === 'track' && e.event === 'Application Opened'); + expect(opened).toBeDefined(); + expect(opened!.properties?.url).toBeUndefined(); + }); + }); + + it('defaults trackLifecycleEvents to false (opt-in) when option is omitted', async () => { + const identityStorage = require('./utils/identityStorage'); + (identityStorage.getIdentityField as jest.Mock).mockImplementation( + async () => null + ); + + const client = new MetaRouterAnalyticsClient({ + ingestionHost: 'https://example.com', + writeKey: 'test_write_key', + }); + await client.init(); + + // No lifecycle events at all — opt-in default. + const events = client.queue.filter( + (e) => e.type === 'track' && e.event?.startsWith('Application ') + ); + expect(events).toHaveLength(0); + }); + + it('does not re-emit Application Installed after reset() when version is unchanged', async () => { + // First launch: fresh install. + const identityStorage = require('./utils/identityStorage'); + (identityStorage.getIdentityField as jest.Mock).mockImplementation( + async () => null + ); + + const client = new MetaRouterAnalyticsClient(lifecycleOpts); + await client.init(); + + // After init, lifecycle storage should record the current version. + // Re-arm the mock to return what it would have stored. + (lifecycleStorage.getLifecycleVersion as jest.Mock).mockResolvedValue( + '1.7.0' + ); + (lifecycleStorage.getLifecycleBuild as jest.Mock).mockResolvedValue( + 'unknown' + ); + + await client.reset(); + + // Second launch: same version, lifecycle storage intact → no Installed. + const client2 = new MetaRouterAnalyticsClient(lifecycleOpts); + await client2.init(); + + const events = getLifecycleEvents(client2); + expect(events.some((e) => e.event === 'Application Installed')).toBe( + false + ); + expect(events.some((e) => e.event === 'Application Updated')).toBe(false); + expect(events.some((e) => e.event === 'Application Opened')).toBe(true); + }); + }); }); diff --git a/src/analytics/MetaRouterAnalyticsClient.ts b/src/analytics/MetaRouterAnalyticsClient.ts index 0231755..7cb7a75 100644 --- a/src/analytics/MetaRouterAnalyticsClient.ts +++ b/src/analytics/MetaRouterAnalyticsClient.ts @@ -1,5 +1,10 @@ import { EventContext, EventPayload, InitOptions, Lifecycle } from './types'; -import { AppState, AppStateStatus } from 'react-native'; +import { + AppState, + AppStateStatus, + Linking, + type EmitterSubscription, +} from 'react-native'; import { log, setDebugLogging, warn, error } from './utils/logger'; import { IdentityManager } from './IdentityManager'; import { enrichEvent } from './utils/enrichEvent'; @@ -9,7 +14,21 @@ import { setIdentityField, removeIdentityField, ADVERTISING_ID_KEY, + ANONYMOUS_ID_KEY, + USER_ID_KEY, + GROUP_ID_KEY, } from './utils/identityStorage'; +import { + getLifecycleVersion, + getLifecycleBuild, + setLifecycleVersionBuild, +} from './utils/lifecycleStorage'; +import { + LifecycleEmitter, + type DeepLinkInfo, + type VersionInfo, + UNKNOWN_PREVIOUS, +} from './lifecycle/lifecycleEvents'; import CircuitBreaker from './utils/circuitBreaker'; import Dispatcher from './dispatcher'; import { PersistentEventQueue } from './persistence/PersistentEventQueue'; @@ -52,6 +71,31 @@ export class MetaRouterAnalyticsClient { private networkMonitor: NetworkReachability; private networkStatus: NetworkStatus = 'connected'; private unsubscribeNetwork: (() => void) | null = null; + private lifecycleEmitter!: LifecycleEmitter; + // Opt-in by default. Existing customers upgrading the SDK do not begin + // emitting lifecycle events without explicitly setting this to true. + private trackLifecycleEvents: boolean = false; + // Snapshot of the bundle-derived app metadata. Populated once during init + // (from this.context.app) and reused everywhere lifecycle needs version / + // build, so the cold-launch / resume / background paths do not re-derive + // the same fields independently. + private appContext!: { + name: string; + version: string; + build: string; + namespace: string; + }; + private lastAppState: AppStateStatus = AppState.currentState; + // Buffers a deep-link captured by Linking.addEventListener('url') so the + // next Application Opened can carry it. One-shot — cleared on emit. + private pendingDeepLink: DeepLinkInfo | null = null; + private linkingSubscription: + | EmitterSubscription + | { remove?: () => void } + | null = null; + // Suppressed cold-launch Application Opened (process woke in background). + // The next background→active transition emits an Opened with from_background:false. + private coldLaunchOpenDeferred: boolean = false; /** * Initializes the analytics client with the provided options. @@ -112,6 +156,7 @@ export class MetaRouterAnalyticsClient { } setDebugLogging(options.debug ?? false); + this.trackLifecycleEvents = options.trackLifecycleEvents ?? false; this.identityManager = new IdentityManager(); // Default: wrap the raw native monitor with the asymmetric debounce // (immediate offline, 2s stable-online). If a caller injects their own @@ -176,6 +221,10 @@ export class MetaRouterAnalyticsClient { this.persistentQueue = new PersistentEventQueue(this.dispatcher, { maxDiskEvents: this.maxDiskEvents, }); + this.lifecycleEmitter = new LifecycleEmitter( + (name, properties) => this.track(name, properties), + this.trackLifecycleEvents + ); } /** @@ -219,9 +268,16 @@ export class MetaRouterAnalyticsClient { this.context = await getContextInfo( persistedAdvertisingId || undefined ); + this.appContext = this.context.app; this.lifecycle = 'ready'; + // Lifecycle: detect install/update + capture deep link, then emit + // the cold-launch sequence. Runs after `ready` so track() accepts + // the events, and before the network/disk drain block below so + // these events join the first flush batch. + await this.runColdLaunchLifecycle(); + // Set initial network state and subscribe to changes this.networkStatus = this.networkMonitor.currentStatus; this.unsubscribeNetwork = this.networkMonitor.onStatusChange( @@ -354,19 +410,155 @@ export class MetaRouterAnalyticsClient { * Sets up the app state listener. */ private setupAppStateListener() { + this.lastAppState = AppState.currentState; this.appStateSubscription = AppState.addEventListener( 'change', this.handleAppStateChange ); } + /** + * Snapshot of the cached app version + build, used by every lifecycle + * emit path so they all observe the same single source of truth. + */ + private versionInfo(): VersionInfo { + return { + version: this.appContext?.version ?? 'unknown', + build: this.appContext?.build ?? 'unknown', + }; + } + + /** + * Runs the cold-launch lifecycle sequence: detect install vs update vs + * neither, persist the current version/build, capture any cold-launch + * deep link, and (when the process is foregrounded) emit Application + * Opened with from_background=false. + * + * No-ops when trackLifecycleEvents is false. + */ + private async runColdLaunchLifecycle(): Promise { + if (!this.lifecycleEmitter.isEnabled()) { + // Still register the deep-link listener? No — without lifecycle events + // there is no consumer for it. Stay completely silent. + return; + } + + const versionInfo = this.versionInfo(); + + try { + const [storedVersion, storedBuild] = await Promise.all([ + getLifecycleVersion(), + getLifecycleBuild(), + ]); + + if (storedVersion == null && storedBuild == null) { + const upgradedFromPreLifecycle = await this.hasIdentityState(); + if (upgradedFromPreLifecycle) { + this.lifecycleEmitter.emitUpdated(versionInfo, { + version: UNKNOWN_PREVIOUS, + build: UNKNOWN_PREVIOUS, + }); + } else { + this.lifecycleEmitter.emitInstalled(versionInfo); + } + } else if ( + storedVersion !== versionInfo.version || + storedBuild !== versionInfo.build + ) { + this.lifecycleEmitter.emitUpdated(versionInfo, { + version: storedVersion ?? UNKNOWN_PREVIOUS, + build: storedBuild ?? UNKNOWN_PREVIOUS, + }); + } + + await setLifecycleVersionBuild(versionInfo.version, versionInfo.build); + } catch (err) { + warn('Lifecycle install/update detection failed:', err); + } + + // Capture any deep link that launched the app. + try { + const initialUrl = await Linking.getInitialURL(); + if (initialUrl) { + this.pendingDeepLink = { url: initialUrl }; + } + } catch { + // Linking unavailable in this environment — proceed without deep link. + } + + // Register runtime URL listener for future deep links. + this.setupLinkingListener(); + + // Cold-launch Opened only fires when the process is in foreground. + // Background-launched processes (push, headless JS) defer to the next + // background→active transition. + if (AppState.currentState === 'active') { + const deepLink = this.consumePendingDeepLink(); + this.lifecycleEmitter.emitOpened(versionInfo, false, deepLink); + } else { + this.coldLaunchOpenDeferred = true; + } + } + + /** + * True if any identity field is present in storage. Used to distinguish a + * fresh install from an existing user upgrading from a pre-lifecycle SDK + * build. Best-effort: failures are treated as "no identity state". + */ + private async hasIdentityState(): Promise { + try { + const [anon, user, group] = await Promise.all([ + getIdentityField(ANONYMOUS_ID_KEY), + getIdentityField(USER_ID_KEY), + getIdentityField(GROUP_ID_KEY), + ]); + return !!(anon || user || group); + } catch { + return false; + } + } + + private setupLinkingListener() { + try { + const sub = Linking.addEventListener('url', (event: { url: string }) => { + if (event?.url) { + this.pendingDeepLink = { url: event.url }; + } + }); + this.linkingSubscription = sub as + | EmitterSubscription + | { remove?: () => void }; + } catch { + // Linking unavailable in test/non-RN environments — silently skip. + } + } + + private consumePendingDeepLink(): DeepLinkInfo | undefined { + if (!this.pendingDeepLink) return undefined; + const dl = this.pendingDeepLink; + this.pendingDeepLink = null; + return dl; + } + /** * Handles the app state change event. * @param nextState - The new app state. */ private handleAppStateChange = async (nextState: AppStateStatus) => { - if (this.appState === 'active' && nextState.match(/inactive|background/)) { + const isBackgroundEntry = + this.appState === 'active' && nextState === 'background'; + const isInactiveEntry = + this.appState === 'active' && nextState === 'inactive'; + + if (isBackgroundEntry || isInactiveEntry) { log('App moved to background'); + // Emit Application Backgrounded only on a true background entry + // (matches iOS/Android semantics: inactive transitions are suppressed). + // The track() enqueue runs synchronously before the flush below so the + // event is part of the same drain. + if (isBackgroundEntry && this.lifecycle === 'ready') { + this.lifecycleEmitter.emitBackgrounded(); + } this.stopFlushLoop(); this.clearNextTimer(); try { @@ -379,12 +571,60 @@ export class MetaRouterAnalyticsClient { } if (nextState === 'active' && this.lifecycle === 'ready') { log('App moved to foreground'); + // Application Opened semantics: + // - background→active: emit with from_background=true + // - inactive→active (Control Center, FaceID, system alert): suppressed + // - first foreground after a background-launched cold start: emit + // with from_background=false (deferred cold-launch Opened) + if (this.coldLaunchOpenDeferred) { + this.lifecycleEmitter.emitOpened( + this.versionInfo(), + false, + this.consumePendingDeepLink() + ); + this.coldLaunchOpenDeferred = false; + } else if (this.lastAppState === 'background') { + this.lifecycleEmitter.emitOpened( + this.versionInfo(), + true, + this.consumePendingDeepLink() + ); + } this.startFlushLoop(); this.flush(); } this.appState = nextState; + this.lastAppState = nextState; }; + /** + * Forward a URL the host received (e.g. from `Linking.getInitialURL`, + * `Linking.addEventListener('url', ...)`, a UIScene URL handler, or an + * Android Intent) so it is attached to the next `Application Opened` + * event as `url` (and `referring_application` if `sourceApplication` + * is provided). One-shot — the buffer is cleared on the next Opened + * emit. Last-write-wins if called multiple times before the next Opened. + * + * No-op with a debug warning when `trackLifecycleEvents` is disabled — + * silent no-ops are bad DX, hosts wiring this up should know they have + * the feature flag off. + */ + openURL(url: string, sourceApplication?: string): void { + if (!this.lifecycleEmitter || !this.lifecycleEmitter.isEnabled()) { + warn( + 'openURL called but trackLifecycleEvents is disabled — buffered URL ignored. Set trackLifecycleEvents: true in InitOptions to enable.' + ); + return; + } + if (!url || typeof url !== 'string') { + warn('openURL called with invalid url — ignored'); + return; + } + this.pendingDeepLink = sourceApplication + ? { url, referringApplication: sourceApplication } + : { url }; + } + /** * Tracks an event. * @param event - The event to track. @@ -606,6 +846,10 @@ export class MetaRouterAnalyticsClient { this.dispatcher.stop(); this.appStateSubscription?.remove?.(); this.appStateSubscription = null; + this.linkingSubscription?.remove?.(); + this.linkingSubscription = null; + this.pendingDeepLink = null; + this.coldLaunchOpenDeferred = false; this.dispatcher.reset(); await this.persistentQueue.deleteSnapshot(); diff --git a/src/analytics/init.ts b/src/analytics/init.ts index 0edeb56..30408f6 100644 --- a/src/analytics/init.ts +++ b/src/analytics/init.ts @@ -32,6 +32,8 @@ export function createAnalyticsClient( screen: (name, props) => instance.screen(name, props), page: (name, props) => instance.page(name, props), alias: (newUserId) => instance.alias(newUserId), + openURL: (url, sourceApplication) => + instance.openURL(url, sourceApplication), setAdvertisingId: (advertisingId) => instance.setAdvertisingId(advertisingId), clearAdvertisingId: () => instance.clearAdvertisingId(), diff --git a/src/analytics/proxy/proxyClient.ts b/src/analytics/proxy/proxyClient.ts index bde0b88..46fd518 100644 --- a/src/analytics/proxy/proxyClient.ts +++ b/src/analytics/proxy/proxyClient.ts @@ -144,6 +144,8 @@ export const proxyClient: AnalyticsInterface = { screen: (name, props) => handleMethodCall('screen', name, props), page: (name, props) => handleMethodCall('page', name, props), alias: (newUserId) => handleMethodCall('alias', newUserId), + openURL: (url, sourceApplication) => + handleMethodCall('openURL', url, sourceApplication), setAdvertisingId: (advertisingId) => handleMethodCall('setAdvertisingId', advertisingId) as Promise, clearAdvertisingId: () => diff --git a/src/analytics/types.ts b/src/analytics/types.ts index fbbbed0..364095e 100644 --- a/src/analytics/types.ts +++ b/src/analytics/types.ts @@ -40,6 +40,13 @@ export interface InitOptions { * on app kill — documented tradeoff). */ maxDiskEvents?: number; + /** + * Emit Application Opened/Backgrounded/Installed/Updated events. + * Defaults to false (opt-in). Matches iOS/Android parity — existing + * customers upgrading the SDK do not begin emitting lifecycle events + * until they explicitly set this to true. + */ + trackLifecycleEvents?: boolean; } export interface AnalyticsInterface { @@ -49,6 +56,15 @@ export interface AnalyticsInterface { screen: (name: string, props?: Record) => void; page: (name: string, props?: Record) => void; alias: (newUserId: string) => void; + /** + * Forward a URL the host received from `Linking` (or platform-specific + * URL handlers) so it is attached to the next `Application Opened` + * event. One-shot — buffer is cleared on emit; last-write-wins if + * called multiple times before the next Opened. + * + * No-op with a debug warning when `trackLifecycleEvents` is disabled. + */ + openURL: (url: string, sourceApplication?: string) => void; setAdvertisingId: (advertisingId: string) => Promise; clearAdvertisingId: () => Promise; setTracing: (enabled: boolean) => void;