From e7dec08709fb77dd5693c96bb43d64c3ee1322f7 Mon Sep 17 00:00:00 2001 From: Sunita Prajapati Date: Fri, 18 Sep 2026 14:17:10 +0530 Subject: [PATCH 1/2] fix(plugin-amplitudeSession): align session tracking with Swift/Kotlin --- .../src/AmplitudeSessionPlugin.tsx | 363 ++++--- .../__tests__/AmplitudeSessionPlugin.test.ts | 913 +++++++----------- 2 files changed, 554 insertions(+), 722 deletions(-) diff --git a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx index 95bebe04e..d2df00637 100644 --- a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx +++ b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx @@ -11,15 +11,25 @@ import { UpdateType, AliasEventType, SegmentClient, + SegmentAPIIntegrations, } from '@segment/analytics-react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { AppState } from 'react-native'; +import { + AppState, + type AppStateStatus, + type NativeEventSubscription, +} from 'react-native'; const MAX_SESSION_TIME_IN_MS = 300000; const SESSION_ID_KEY = 'previous_session_id'; -const EVENT_SESSION_ID_KEY = 'event_session_id'; const LAST_EVENT_TIME_KEY = 'last_event_time'; +// Written on every event by previous versions; removed on reset so upgrades don't leave it behind +const LEGACY_EVENT_SESSION_ID_KEY = 'event_session_id'; +const LAST_EVENT_TIME_PERSIST_INTERVAL_IN_MS = 10000; +// Matches Swift/Kotlin: real cloud-mode names are like "[Amplitude] Application Opened" +const AMP_PREFIX = '[Amplitude] '; +const ALL_INTEGRATIONS_KEY = 'All'; const AMP_SESSION_START_EVENT = 'session_start'; const AMP_SESSION_END_EVENT = 'session_end'; @@ -27,61 +37,21 @@ export class AmplitudeSessionPlugin extends EventPlugin { type = PluginType.enrichment; key = 'Actions Amplitude'; active = false; - private _sessionId = -1; - private _eventSessionId = -1; - private _lastEventTime = -1; - resetPending = false; - - get eventSessionId() { - return this._eventSessionId; - } - set eventSessionId(value: number) { - this._eventSessionId = value; - if (value !== -1) { - AsyncStorage.setItem(EVENT_SESSION_ID_KEY, value.toString()).catch( - (err) => - console.warn( - '[AmplitudeSessionPlugin] Failed to persist eventSessionId:', - err - ) - ); - } - } - get lastEventTime() { - return this._lastEventTime; - } - set lastEventTime(value: number) { - this._lastEventTime = value; - if (value !== -1) { - AsyncStorage.setItem(LAST_EVENT_TIME_KEY, value.toString()).catch((err) => - console.warn( - '[AmplitudeSessionPlugin] Failed to persist lastEventTime:', - err - ) - ); - } - } + sessionId = -1; + lastEventTime = -1; - get sessionId() { - return this._sessionId; - } - set sessionId(value: number) { - this._sessionId = value; - if (value !== -1) { - AsyncStorage.setItem(SESSION_ID_KEY, value.toString()).catch((err) => - console.warn( - '[AmplitudeSessionPlugin] Failed to persist sessionId:', - err - ) - ); - } - } + private initPromise?: Promise; + private appStateSubscription?: NativeEventSubscription; + private appState: AppStateStatus | 'unknown' = 'unknown'; + private lastPersistedEventTime = -1; - configure = async (analytics: SegmentClient): Promise => { + configure = (analytics: SegmentClient): Promise => { this.analytics = analytics; - await this.loadSessionData(); - AppState.addEventListener('change', this.handleAppStateChange); + if (this.initPromise === undefined) { + this.initPromise = this.initialize(); + } + return this.initPromise; }; update(settings: SegmentAPISettings, type: UpdateType) { @@ -96,10 +66,10 @@ export class AmplitudeSessionPlugin extends EventPlugin { return event; } - if (this.sessionId === -1 || this.lastEventTime === -1) { - await this.loadSessionData(); - } - await this.startNewSessionIfNecessary(); + // configure() is not awaited by the client, so events can arrive before storage has loaded + await this.initPromise; + this.startNewSessionIfNecessary(); + let result = event; switch (result.type) { case EventType.IdentifyEvent: @@ -119,8 +89,7 @@ export class AmplitudeSessionPlugin extends EventPlugin { break; } - this.lastEventTime = Date.now(); - //await this.saveSessionData(); + this.setLastEventTime(Date.now()); return result; } @@ -131,27 +100,14 @@ export class AmplitudeSessionPlugin extends EventPlugin { track(event: TrackEventType) { const eventName = event.event; - if (eventName === AMP_SESSION_START_EVENT) { - this.resetPending = false; - this.eventSessionId = this.sessionId; - } - - if (eventName === AMP_SESSION_END_EVENT) { - console.log(`[AmplitudeSession] EndSession = ${this.eventSessionId}`); - } - if ( - eventName.startsWith('Amplitude') || + eventName.includes(AMP_PREFIX) || eventName === AMP_SESSION_START_EVENT || eventName === AMP_SESSION_END_EVENT ) { - const integrations = this.disableAllIntegrations(event.integrations); return { ...event, - integrations: { - ...integrations, - [this.key]: { session_id: this.eventSessionId }, - }, + integrations: this.disableCloudIntegrations(this.readSessionId(event)), }; } @@ -175,153 +131,196 @@ export class AmplitudeSessionPlugin extends EventPlugin { } async reset() { + const endedSessionId = this.sessionId; + const endedAt = this.lastEventTime; + this.sessionId = -1; - this.eventSessionId = -1; this.lastEventTime = -1; - await AsyncStorage.removeItem(SESSION_ID_KEY); - } + this.lastPersistedEventTime = -1; - private insertSession = (event: SegmentEvent) => { - const integrations = event.integrations || {}; - const existingIntegration = integrations[this.key]; - const hasSessionId = - typeof existingIntegration === 'object' && - existingIntegration !== null && - 'session_id' in existingIntegration; - - if (hasSessionId) { - return event; + await Promise.all([ + AsyncStorage.removeItem(SESSION_ID_KEY), + AsyncStorage.removeItem(LAST_EVENT_TIME_KEY), + AsyncStorage.removeItem(LEGACY_EVENT_SESSION_ID_KEY), + ]).catch((err) => this.warn('Failed to clear session data', err)); + + if (endedSessionId >= 0) { + this.endSession(endedSessionId, endedAt); } + this.startNewSessionIfNecessary(); + } - return { - ...event, - integrations: { - ...integrations, - [this.key]: { session_id: this.sessionId }, - }, - }; - }; + /** Removes the AppState listener. Call when tearing down the client. */ + cleanup() { + this.appStateSubscription?.remove(); + this.appStateSubscription = undefined; + } - private onBackground = () => { - this.lastEventTime = Date.now(); - }; + private async initialize() { + try { + const [storedSessionId, storedLastEventTime] = await Promise.all([ + AsyncStorage.getItem(SESSION_ID_KEY), + AsyncStorage.getItem(LAST_EVENT_TIME_KEY), + ]); + this.sessionId = storedSessionId != null ? Number(storedSessionId) : -1; + this.lastEventTime = + storedLastEventTime != null ? Number(storedLastEventTime) : -1; + this.lastPersistedEventTime = this.lastEventTime; + } catch (err) { + this.warn('Failed to load session data', err); + } - private onForeground = () => { this.startNewSessionIfNecessary(); - }; - - private async startNewSessionIfNecessary() { - if (this.eventSessionId === -1) { - this.eventSessionId = this.sessionId; - } + this.appStateSubscription = AppState.addEventListener( + 'change', + this.handleAppStateChange + ); + } - if (this.resetPending) { + // Must stay synchronous: with no await inside, concurrent events cannot interleave here + private startNewSessionIfNecessary() { + const current = Date.now(); + if ( + this.sessionId >= 0 && + current - this.lastEventTime < MAX_SESSION_TIME_IN_MS + ) { return; } - const current = Date.now(); - const withinSessionLimit = this.withinMinSessionTime(current); + // Captured before the overwrite below, so session_end can be dated to real activity + const endedAt = this.lastEventTime; - const isSessionExpired = - this.sessionId === -1 || this.lastEventTime === -1 || !withinSessionLimit; + // Must precede endSession: while sessionId is still the old one, this closes the guard above + this.setLastEventTime(current, true); - if (this.sessionId >= 0 && !isSessionExpired) { - return; + if (this.sessionId >= 0) { + this.endSession(this.sessionId, endedAt); } + this.setSessionId(current); + this.trackSessionStart(current); + } - // End old session and start a new one - await this.startNewSession(); + private trackSessionStart(sessionId: number) { + void this.analytics?.track(AMP_SESSION_START_EVENT, undefined, (event) => + this.withSessionId(event, sessionId) + ); } - /** - * Handles the entire process of starting a new session. - * Can be called directly or from startNewSessionIfNecessary() - */ - private async startNewSession() { - if (this.resetPending) { - return; - } + private endSession(sessionId: number, endedAt: number) { + void this.analytics?.track(AMP_SESSION_END_EVENT, undefined, (event) => + this.withSessionId(event, sessionId, endedAt) + ); + } - this.resetPending = true; + // Binds a snapshot of the id to one event, so it cannot drift before the event is enriched + private withSessionId = ( + event: SegmentEvent, + sessionId: number, + // A backgrounded app cannot send, so session_end is dated to the last activity, not to delivery + occurredAt?: number + ): SegmentEvent => ({ + ...event, + timestamp: + occurredAt !== undefined && occurredAt > 0 + ? new Date(occurredAt).toISOString() + : event.timestamp, + integrations: { + ...event.integrations, + [this.key]: { session_id: sessionId }, + }, + }); - const oldSessionId = this.sessionId; - if (oldSessionId >= 0) { - await this.endSession(oldSessionId); + private insertSession = (event: SegmentEvent) => { + if (this.hasSessionId(event)) { + return event; } - const newSessionId = Date.now(); - this.sessionId = newSessionId; - this.eventSessionId = - this.eventSessionId === -1 ? newSessionId : this.eventSessionId; - this.lastEventTime = newSessionId; - - console.log(`[AmplitudeSession] startNewSession -> ${newSessionId}`); - - await this.trackSessionStart(newSessionId); - } - - /** - * Extracted analytics tracking into its own method - */ - private async trackSessionStart(sessionId: number) { - this.analytics?.track(AMP_SESSION_START_EVENT, { + return { + ...event, integrations: { - [this.key]: { session_id: sessionId }, + ...(event.integrations ?? {}), + [this.key]: { session_id: this.sessionId }, }, - }); + }; + }; + + private hasSessionId(event: SegmentEvent) { + const existing = event.integrations?.[this.key]; + return ( + typeof existing === 'object' && + existing !== null && + 'session_id' in existing + ); } - private async endSession(sessionId: number) { - if (this.sessionId === -1) { - return; + // Falls back to the current id if the enrichment closure was dropped by pre-init buffering + private readSessionId(event: SegmentEvent) { + const existing = event.integrations?.[this.key]; + if (this.hasSessionId(event)) { + return (existing as { session_id: number }).session_id; } + return this.sessionId; + } - console.log(`[AmplitudeSession] endSession -> ${this.sessionId}`); + private setSessionId(value: number) { + this.sessionId = value; + this.persist(SESSION_ID_KEY, value); + } - this.analytics?.track(AMP_SESSION_END_EVENT, { - integrations: { - [this.key]: { session_id: sessionId }, - }, - }); + private setLastEventTime(value: number, force = false) { + this.lastEventTime = value; + // Throttled because this fires on every event; the comparison window is 5 minutes + if ( + force || + value - this.lastPersistedEventTime >= + LAST_EVENT_TIME_PERSIST_INTERVAL_IN_MS + ) { + this.lastPersistedEventTime = value; + this.persist(LAST_EVENT_TIME_KEY, value); + } } - private async loadSessionData() { - const storedSessionId = await AsyncStorage.getItem(SESSION_ID_KEY); - const storedLastEventTime = await AsyncStorage.getItem(LAST_EVENT_TIME_KEY); - const storedEventSessionId = await AsyncStorage.getItem( - EVENT_SESSION_ID_KEY + private persist(key: string, value: number) { + AsyncStorage.setItem(key, value.toString()).catch((err) => + this.warn(`Failed to persist ${key}`, err) ); - - this.sessionId = storedSessionId != null ? Number(storedSessionId) : -1; - this.lastEventTime = - storedLastEventTime != null ? Number(storedLastEventTime) : -1; - this.eventSessionId = - storedEventSessionId != null ? Number(storedEventSessionId) : -1; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private disableAllIntegrations(integrations?: Record) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result: Record = {}; - if (!integrations) { - return result; - } - for (const key of Object.keys(integrations)) { - result[key] = false; - } - return result; + private warn(message: string, err: unknown) { + this.analytics?.logger.warn( + `[AmplitudeSessionPlugin] ${message}: ${String(err)}` + ); } - private withinMinSessionTime(timestamp: number): boolean { - const timeDelta = timestamp - this.lastEventTime; - return timeDelta < MAX_SESSION_TIME_IN_MS; + // Mirrors Kotlin's disableCloudIntegrations: every other destination is dropped behind "All": false + private disableCloudIntegrations(sessionId: number): SegmentAPIIntegrations { + return { + [ALL_INTEGRATIONS_KEY]: false, + [this.key]: { session_id: sessionId }, + }; } - private handleAppStateChange = (nextAppState: string) => { + private onBackground = () => { + this.setLastEventTime(Date.now(), true); + }; + + private onForeground = () => { + this.startNewSessionIfNecessary(); + }; + + private handleAppStateChange = (nextAppState: AppStateStatus) => { + const previousAppState = this.appState; + this.appState = nextAppState; + if (nextAppState === 'active') { - this.onForeground(); - } else if (nextAppState === 'background') { - this.onBackground(); + // Only a real return to the foreground, not iOS inactive/active churn + if (previousAppState !== 'active') { + this.onForeground(); + } + } else if (nextAppState === 'background' || nextAppState === 'inactive') { + if (previousAppState === 'active' || previousAppState === 'unknown') { + this.onBackground(); + } } }; } diff --git a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts index 17eeaf5d4..d570b3148 100644 --- a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts +++ b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts @@ -1,8 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { AmplitudeSessionPlugin } from '../AmplitudeSessionPlugin'; -// Import the constant for consistent timeout values -const MAX_SESSION_TIME_IN_MS = 300000; import AsyncStorage from '@react-native-async-storage/async-storage'; import { EventType, @@ -10,680 +8,496 @@ import { IdentifyEventType, ScreenEventType, SegmentAPISettings, + SegmentEvent, UpdateType, } from '@segment/analytics-react-native'; import { AppState } from 'react-native'; -// AppState will be mocked by the base setup, we'll spy on it in the tests +const MAX_SESSION_TIME_IN_MS = 300000; +const KEY = 'Actions Amplitude'; + +interface EmittedSessionEvent { + name: string; + sessionId?: number; + timestamp?: string; +} + +type Enrichment = (event: SegmentEvent) => SegmentEvent; + +const sessionIdOf = (event: SegmentEvent) => + (event.integrations?.[KEY] as { session_id?: number } | undefined) + ?.session_id; + +const makeTrackEvent = ( + event: string, + overrides: Partial = {} +): TrackEventType => ({ + type: EventType.TrackEvent, + event, + properties: {}, + messageId: `msg-${event}`, + timestamp: '2023-01-01T00:00:00.000Z', + anonymousId: 'anon-1', + ...overrides, +}); describe('AmplitudeSessionPlugin', () => { let plugin: AmplitudeSessionPlugin; let mockAsyncStorage: jest.Mocked; + let emitted: EmittedSessionEvent[]; + let client: any; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); plugin = new AmplitudeSessionPlugin(); + emitted = []; mockAsyncStorage = AsyncStorage as jest.Mocked; mockAsyncStorage.getItem.mockResolvedValue(null); mockAsyncStorage.setItem.mockResolvedValue(); mockAsyncStorage.removeItem.mockResolvedValue(); + + client = { + logger: { warn: jest.fn(), info: jest.fn() }, + // Mirrors the timeline: enrichment plugins run, then the closure is applied to the result + track: jest.fn( + (name: string, _props: unknown, enrichment?: Enrichment) => { + const raw = makeTrackEvent(name, { + messageId: `msg-${emitted.length}`, + // Core stamps this at process() entry, before the closure runs + timestamp: new Date(Date.now()).toISOString(), + }); + const enriched = enrichment === undefined ? raw : enrichment(raw); + emitted.push({ + name, + sessionId: sessionIdOf(enriched), + timestamp: enriched.timestamp, + }); + return Promise.resolve(); + } + ), + }; }); afterEach(() => { + plugin.cleanup(); jest.useRealTimers(); }); const setupPluginWithClient = async () => { - const mockClient = { - track: jest.fn(), - } as any; - - await plugin.configure(mockClient); + await plugin.configure(client); plugin.update( - { integrations: { 'Actions Amplitude': {} } } as SegmentAPISettings, + { integrations: { [KEY]: {} } } as SegmentAPISettings, UpdateType.initial ); - - return { client: mockClient }; + return { client }; }; - describe('startNewSession scenarios', () => { - beforeEach(async () => { + const named = (name: string) => emitted.filter((e) => e.name === name); + const starts = () => named('session_start'); + const ends = () => named('session_end'); + + describe('session lifecycle', () => { + it('starts exactly one session on a cold start', async () => { await setupPluginWithClient(); - }); - it('should start a new session when sessionId is -1', async () => { - plugin.sessionId = -1; - plugin.lastEventTime = -1; - plugin.resetPending = false; + expect(plugin.sessionId).toBeGreaterThan(0); + expect(starts()).toHaveLength(1); + expect(ends()).toHaveLength(0); + expect(starts()[0].sessionId).toBe(plugin.sessionId); + }); - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; + it('does not start a new session while the current one is live', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + await setupPluginWithClient(); - await plugin.execute(mockEvent); + const sessionId = plugin.sessionId; + jest.setSystemTime(baseTime + 30000); + await plugin.execute(makeTrackEvent('test_event')); - expect(plugin.sessionId).toBeGreaterThan(0); - expect(plugin.analytics?.track).toHaveBeenCalledWith('session_start', { - integrations: { - 'Actions Amplitude': { session_id: plugin.sessionId }, - }, - }); + expect(plugin.sessionId).toBe(sessionId); + expect(starts()).toHaveLength(1); }); - it('should start a new session when session has expired (>MAX_SESSION_TIME_IN_MS)', async () => { + it('rotates the session once it has expired', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); - plugin.active = true; - plugin.sessionId = baseTime - 1000; - plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 1000); // 61 seconds ago - plugin.resetPending = false; - - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; + await setupPluginWithClient(); const oldSessionId = plugin.sessionId; - await plugin.execute(mockEvent); + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1000); + await plugin.execute(makeTrackEvent('test_event')); - expect(plugin.sessionId).not.toBe(oldSessionId); expect(plugin.sessionId).toBeGreaterThan(oldSessionId); - expect(plugin.analytics?.track).toHaveBeenCalledWith('session_end', { - integrations: { - 'Actions Amplitude': { session_id: oldSessionId }, - }, - }); - expect(plugin.analytics?.track).toHaveBeenCalledWith('session_start', { - integrations: { - 'Actions Amplitude': { session_id: plugin.sessionId }, - }, - }); + expect(starts()).toHaveLength(2); + expect(ends()).toHaveLength(1); + expect(ends()[0].sessionId).toBe(oldSessionId); + expect(starts()[1].sessionId).toBe(plugin.sessionId); }); - it('should NOT start a new session when session is still active', async () => { + it('expires exactly at MAX_SESSION_TIME_IN_MS', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); - - plugin.sessionId = baseTime - 1000; - plugin.lastEventTime = baseTime - 30000; // 30 seconds ago - plugin.resetPending = false; - - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; + await setupPluginWithClient(); const oldSessionId = plugin.sessionId; - await plugin.execute(mockEvent); + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS); + await plugin.execute(makeTrackEvent('test_event')); - expect(plugin.sessionId).toBe(oldSessionId); - expect(plugin.analytics?.track).not.toHaveBeenCalledWith( - 'session_start', - expect.any(Object) - ); + expect(plugin.sessionId).not.toBe(oldSessionId); + expect(ends()[0].sessionId).toBe(oldSessionId); }); - }); - describe('bug detection: multiple startNewSession calls', () => { - beforeEach(async () => { + it('does not expire one millisecond before the limit', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); await setupPluginWithClient(); + + const sessionId = plugin.sessionId; + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS - 1); + await plugin.execute(makeTrackEvent('test_event')); + + expect(plugin.sessionId).toBe(sessionId); + expect(starts()).toHaveLength(1); + expect(ends()).toHaveLength(0); }); + }); - it('BUG: should detect multiple session starts for rapid events (currently masked by 1000ms guard)', async () => { + describe('regressions: duplicate and mismatched sessions', () => { + it('mints one session for concurrent events on a cold start', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); + await setupPluginWithClient(); - plugin.sessionId = -1; - plugin.lastEventTime = -1; - plugin.resetPending = false; - - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; - - // First call should start session - await plugin.execute(mockEvent); - const firstSessionId = plugin.sessionId; + const events = Array.from({ length: 5 }, (_, i) => + makeTrackEvent(`test_event_${i}`, { messageId: `msg-${i}` }) + ); + const results = await Promise.all( + events.map((event) => plugin.execute(event)) + ); - expect(plugin.analytics?.track).toHaveBeenCalledWith('session_start', { - integrations: { - 'Actions Amplitude': { session_id: firstSessionId }, - }, + expect(starts()).toHaveLength(1); + results.forEach((result) => { + expect(sessionIdOf(result)).toBe(plugin.sessionId); }); - - // Advance time by only 500ms - jest.setSystemTime(baseTime + 500); - - // Force expired condition artificially - this should be impossible in real scenarios - plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 10000); // MAX_SESSION_TIME_IN_MS + 10 seconds ago, definitely expired - - // This scenario should NEVER happen in practice, but if it does, it's a bug - // The current implementation prevents this with a 1000ms guard, masking the bug - await plugin.execute(mockEvent); - - // CURRENT BEHAVIOR (with guard): Only one session_start - // EXPECTED BEHAVIOR (without bugs): Should never reach this scenario - expect(plugin.analytics?.track).toHaveBeenCalledTimes(1); - - // This test documents the current guard behavior but highlights it's a bug mask - console.warn( - '🐛 BUG MASKED: Multiple session start attempts should never occur' - ); }); - it('BUG: should detect race conditions in parallel event execution', async () => { + it('never stamps a session id of -1', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); + await setupPluginWithClient(); - plugin.sessionId = -1; - plugin.lastEventTime = -1; - plugin.resetPending = false; - - const mockEvents = Array.from({ length: 5 }, (_, i) => ({ - type: EventType.TrackEvent, - event: `test_event_${i}`, - properties: {}, - messageId: `msg-${i}`, - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - })) as TrackEventType[]; - - // Execute multiple events in parallel - this could cause race conditions - const promises = mockEvents.map((event) => plugin.execute(event)); - await Promise.all(promises); - - // Count session_start calls - const trackMock = plugin.analytics?.track as jest.Mock; - const sessionStartCalls = trackMock.mock.calls.filter( - (call: any) => call[0] === 'session_start' + const results = await Promise.all( + Array.from({ length: 5 }, (_, i) => + plugin.execute(makeTrackEvent(`e_${i}`, { messageId: `msg-${i}` })) + ) ); - // IDEAL: Should only have 1 session_start call - // REALITY: May have multiple due to race conditions - if (sessionStartCalls.length > 1) { - console.error( - `🐛 BUG DETECTED: ${sessionStartCalls.length} session_start calls for parallel events` - ); - // This test will fail if the bug exists, which is expected - expect(sessionStartCalls).toHaveLength(1); - } else { - // If this passes, the implementation handles parallel calls correctly - expect(sessionStartCalls).toHaveLength(1); - } + results.forEach((result) => + expect(sessionIdOf(result)).toBeGreaterThan(0) + ); + emitted.forEach((event) => expect(event.sessionId).toBeGreaterThan(0)); }); - // it('BUG: should detect session restart loops from app state changes', async () => { - // const baseTime = Date.now(); - // jest.setSystemTime(baseTime); - - // // Start with an active session - // plugin.sessionId = baseTime; - // plugin.lastEventTime = baseTime; - - // // Spy on startNewSessionIfNecessary to detect multiple calls - // const startNewSessionSpy = jest.spyOn(plugin as any, 'startNewSessionIfNecessary'); - // const endSessionSpy = jest.spyOn(plugin as any, 'endSession'); - // const startSessionSpy = jest.spyOn(plugin as any, 'startNewSession'); - - // // Simulate rapid app state changes - // const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener'); - // await setupPluginWithClient(); - // const appStateChangeHandler = addEventListenerSpy.mock.calls[0][1]; - - // // Rapid background/foreground cycles - // appStateChangeHandler('background'); - // appStateChangeHandler('active'); - // appStateChangeHandler('background'); - // appStateChangeHandler('active'); - - // // Wait for any async operations - // await new Promise(resolve => setTimeout(resolve, 0)); - - // // Should not cause multiple session operations for non-expired session - // const startNewSessionCalls = startNewSessionSpy.mock.calls.length; - // const endSessionCalls = endSessionSpy.mock.calls.length; - // const startSessionCalls = startSessionSpy.mock.calls.length; + it('ends the old session with the old id, not the newly started one', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + await setupPluginWithClient(); - // if (startNewSessionCalls > 2 || endSessionCalls > 0 || startSessionCalls > 0) { - // console.error(`🐛 BUG DETECTED: Unnecessary session operations - startNewSessionIfNecessary: ${startNewSessionCalls}, endSession: ${endSessionCalls}, startNewSession: ${startSessionCalls}`); - // } + const oldSessionId = plugin.sessionId; + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1); + await plugin.execute(makeTrackEvent('test_event')); - // // For a non-expired session, we shouldn't have any actual session restarts - // expect(endSessionCalls).toBe(0); - // expect(startSessionCalls).toBe(0); - // }); + expect(ends()[0].sessionId).toBe(oldSessionId); + expect(ends()[0].sessionId).not.toBe(plugin.sessionId); + }); - it('BUG: should detect inconsistent session state', async () => { + it('dates session_end to the last activity, not to when it was delivered', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); + await setupPluginWithClient(); - // Set up inconsistent state that should never happen - plugin.sessionId = baseTime; - plugin.lastEventTime = -1; // Inconsistent: have sessionId but no lastEventTime - plugin.resetPending = false; + jest.setSystemTime(baseTime + 60000); + await plugin.execute(makeTrackEvent('last_real_activity')); - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; + // App killed for an hour; the rotation only happens on relaunch + jest.setSystemTime(baseTime + 3600000); + await plugin.execute(makeTrackEvent('after_relaunch')); - // This inconsistent state might cause unexpected behavior - await plugin.execute(mockEvent); - - // Check if the plugin handled inconsistent state correctly - const trackMock = plugin.analytics?.track as jest.Mock; - const sessionStartCalls = trackMock.mock.calls.filter( - (call: any) => call[0] === 'session_start' - ); - const sessionEndCalls = trackMock.mock.calls.filter( - (call: any) => call[0] === 'session_end' + expect(ends()).toHaveLength(1); + expect(ends()[0].timestamp).toBe( + new Date(baseTime + 60000).toISOString() ); - - // Inconsistent state should be resolved without multiple session events - if (sessionStartCalls.length > 1 || sessionEndCalls.length > 1) { - console.error( - `🐛 BUG DETECTED: Inconsistent state caused multiple session events - starts: ${sessionStartCalls.length}, ends: ${sessionEndCalls.length}` - ); - } - - // Should have resolved to a consistent state - expect(plugin.sessionId).toBeGreaterThan(0); - expect(plugin.lastEventTime).toBeGreaterThan(0); }); - it('BUG: should detect async race conditions in startNewSessionIfNecessary', async () => { + it('leaves session_start dated when it actually happened', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); + await setupPluginWithClient(); - plugin.sessionId = -1; - plugin.lastEventTime = -1; - - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; + expect(starts()[0].timestamp).toBe(new Date(baseTime).toISOString()); + }); - // Spy on the async methods to detect overlapping calls - const startNewSessionIfNecessarySpy = jest.spyOn( - plugin as any, - 'startNewSessionIfNecessary' - ); - const endSessionSpy = jest.spyOn(plugin as any, 'endSession'); - const startNewSessionSpy = jest.spyOn(plugin as any, 'startNewSession'); - - // Call execute multiple times rapidly before any async operations complete - // This tests if the implementation properly handles concurrent calls to startNewSessionIfNecessary - const promises = [ - plugin.execute({ ...mockEvent, messageId: 'msg-1' }), - plugin.execute({ ...mockEvent, messageId: 'msg-2' }), - plugin.execute({ ...mockEvent, messageId: 'msg-3' }), - ]; - - await Promise.all(promises); - - const startNewSessionIfNecessaryCalls = - startNewSessionIfNecessarySpy.mock.calls.length; - const endSessionCalls = endSessionSpy.mock.calls.length; - const startNewSessionCalls = startNewSessionSpy.mock.calls.length; - - // For initial session creation, we should only have: - // - Multiple calls to startNewSessionIfNecessary (one per execute) - // - But only ONE actual startNewSession call - // - Zero endSession calls (no existing session to end) - - console.log( - `📊 Session operations: startNewSessionIfNecessary: ${startNewSessionIfNecessaryCalls}, endSession: ${endSessionCalls}, startNewSession: ${startNewSessionCalls}` + it('does not backdate session_end when there was no prior activity', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + mockAsyncStorage.getItem.mockImplementation((key: string) => + Promise.resolve(key === 'previous_session_id' ? '12345' : null) ); - if (startNewSessionCalls > 1) { - console.error( - `🐛 CRITICAL BUG DETECTED: ${startNewSessionCalls} startNewSession calls from concurrent execute operations` - ); - // This should fail if there are race conditions - expect(startNewSessionCalls).toBe(1); - } + const resumed = new AmplitudeSessionPlugin(); + await resumed.configure(client); - if (endSessionCalls > 1) { - console.error( - `🐛 BUG DETECTED: ${endSessionCalls} endSession calls from concurrent operations` - ); - expect(endSessionCalls).toBeLessThanOrEqual(1); - } - - // Should have properly created exactly one session - expect(plugin.sessionId).toBeGreaterThan(0); - expect(plugin.lastEventTime).toBeGreaterThan(0); + expect(ends()).toHaveLength(1); + expect(ends()[0].timestamp).toBe(new Date(baseTime).toISOString()); + resumed.cleanup(); }); - it('BUG: should detect overlapping session end/start operations', async () => { + it('emits one session_end per rotation under concurrent events', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); + await setupPluginWithClient(); - // Start with an existing session that will expire - plugin.sessionId = baseTime - 1000; - plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 10000); // MAX_SESSION_TIME_IN_MS + 10 seconds ago, expired - - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; + const oldSessionId = plugin.sessionId; + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1); - // Spy on session operations - const endSessionSpy = jest.spyOn(plugin as any, 'endSession'); - const startNewSessionSpy = jest.spyOn(plugin as any, 'startNewSession'); + await Promise.all( + Array.from({ length: 3 }, (_, i) => + plugin.execute(makeTrackEvent(`e_${i}`, { messageId: `msg-${i}` })) + ) + ); - // Execute multiple events that should all trigger session restart - const promises = [ - plugin.execute({ ...mockEvent, messageId: 'msg-1' }), - plugin.execute({ ...mockEvent, messageId: 'msg-2' }), - plugin.execute({ ...mockEvent, messageId: 'msg-3' }), - ]; + expect(ends()).toHaveLength(1); + expect(starts()).toHaveLength(2); + expect(ends()[0].sessionId).toBe(oldSessionId); + }); - await Promise.all(promises); + it('gives session markers the same id as the events around them', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + await setupPluginWithClient(); - const endSessionCalls = endSessionSpy.mock.calls.length; - const startNewSessionCalls = startNewSessionSpy.mock.calls.length; + const before = await plugin.execute(makeTrackEvent('before')); + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1); + const after = await plugin.execute(makeTrackEvent('after')); - // For session restart, we should have: - // - Exactly ONE endSession call (to end the expired session) - // - Exactly ONE startNewSession call (to start the new session) + expect(sessionIdOf(before)).toBe(starts()[0].sessionId); + expect(ends()[0].sessionId).toBe(sessionIdOf(before)); + expect(sessionIdOf(after)).toBe(starts()[1].sessionId); + }); - if (endSessionCalls > 1) { - console.error( - `🐛 BUG DETECTED: ${endSessionCalls} endSession calls from concurrent operations` - ); - expect(endSessionCalls).toBe(1); - } + it('keeps rotating sessions when a session_start is dropped downstream', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + await setupPluginWithClient(); - if (startNewSessionCalls > 1) { - console.error( - `🐛 CRITICAL BUG DETECTED: ${startNewSessionCalls} startNewSession calls from concurrent operations` - ); - expect(startNewSessionCalls).toBe(1); - } + // Simulate a downstream plugin swallowing the session_start event + client.track.mockImplementation(() => Promise.resolve()); - // Verify the track calls - const trackMock = plugin.analytics?.track as jest.Mock; - const sessionEndCalls = trackMock.mock.calls.filter( - (call: any) => call[0] === 'session_end' - ); - const sessionStartCalls = trackMock.mock.calls.filter( - (call: any) => call[0] === 'session_start' - ); + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1); + await plugin.execute(makeTrackEvent('one')); + const secondSessionId = plugin.sessionId; - if (sessionEndCalls.length > 1 || sessionStartCalls.length > 1) { - console.error( - `🐛 BUG DETECTED: Multiple session events - ends: ${sessionEndCalls.length}, starts: ${sessionStartCalls.length}` - ); - } + jest.setSystemTime(baseTime + 2 * (MAX_SESSION_TIME_IN_MS + 1)); + await plugin.execute(makeTrackEvent('two')); - expect(sessionEndCalls).toHaveLength(1); - expect(sessionStartCalls).toHaveLength(1); + expect(plugin.sessionId).toBeGreaterThan(secondSessionId); }); - it('EXPECTED BEHAVIOR: single session for sequential events within session timeout', async () => { + it('round-trips its own session events without cascading', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); - plugin.sessionId = -1; - plugin.lastEventTime = -1; - - const mockEvents = Array.from({ length: 5 }, (_, i) => ({ - type: EventType.TrackEvent, - event: `test_event_${i}`, - properties: {}, - messageId: `msg-${i}`, - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - })) as TrackEventType[]; - - // Execute events sequentially with small time gaps (within session timeout) - for (let i = 0; i < mockEvents.length; i++) { - jest.setSystemTime(baseTime + i * 10000); // 10 seconds apart - await plugin.execute(mockEvents[i]); - } - - // Should only have one session_start call for all events - const trackMock = plugin.analytics?.track as jest.Mock; - const sessionStartCalls = trackMock.mock.calls.filter( - (call: any) => call[0] === 'session_start' + // Feed emitted session events back through the plugin, as the timeline does + client.track.mockImplementation( + async (name: string, _props: unknown, enrichment?: Enrichment) => { + const raw = makeTrackEvent(name, { + messageId: `msg-${emitted.length}`, + }); + const processed = await plugin.execute(raw); + const enriched = + enrichment === undefined ? processed : enrichment(processed); + emitted.push({ name, sessionId: sessionIdOf(enriched) }); + } ); - expect(sessionStartCalls).toHaveLength(1); + await setupPluginWithClient(); + await plugin.execute(makeTrackEvent('test_event')); - // All events should have the same session ID - const sessionId = plugin.sessionId; - expect(sessionId).toBeGreaterThan(0); + expect(starts()).toHaveLength(1); + expect(starts()[0].sessionId).toBe(plugin.sessionId); }); }); - describe('session expiration scenarios', () => { + describe('app state changes', () => { + let handler: (nextAppState: any) => void; + beforeEach(async () => { + const spy = jest.spyOn(AppState, 'addEventListener'); await setupPluginWithClient(); + expect(spy).toHaveBeenCalledWith('change', expect.any(Function)); + handler = spy.mock.calls[0][1]; }); - it('should expire session exactly at MAX_SESSION_TIME_IN_MS', async () => { + it('starts a new session when foregrounding after expiry', () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); - - plugin.sessionId = baseTime - 1000; - plugin.lastEventTime = baseTime - MAX_SESSION_TIME_IN_MS; // Exactly 60 seconds - plugin.resetPending = false; - - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; - const oldSessionId = plugin.sessionId; - await plugin.execute(mockEvent); - expect(plugin.sessionId).not.toBe(oldSessionId); - expect(plugin.analytics?.track).toHaveBeenCalledWith('session_end', { - integrations: { - 'Actions Amplitude': { session_id: oldSessionId }, - }, - }); - }); + handler('background'); + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1000); + handler('active'); - it('should NOT expire session at MAX_SESSION_TIME_IN_MS - 1 second', async () => { - // ✅ Freeze Date.now for this test only - const fixedNow = 1761550980000; - const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => fixedNow); + expect(plugin.sessionId).toBeGreaterThan(oldSessionId); + expect(ends()[0].sessionId).toBe(oldSessionId); + }); - plugin.sessionId = fixedNow - 1000; - plugin.lastEventTime = fixedNow - (MAX_SESSION_TIME_IN_MS - 2); // within limit + it('does not start a new session when foregrounding before expiry', () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + const sessionId = plugin.sessionId; - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; + handler('background'); + jest.setSystemTime(baseTime + 20000); + handler('active'); - const oldSessionId = plugin.sessionId; + expect(plugin.sessionId).toBe(sessionId); + expect(starts()).toHaveLength(1); + }); - await plugin.execute(mockEvent); + it('ignores inactive/active churn without a real backgrounding', () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + const sessionId = plugin.sessionId; - expect(plugin.sessionId).toBe(oldSessionId); - expect(plugin.analytics?.track).not.toHaveBeenCalledWith( - 'session_start', - expect.any(Object) - ); - expect(plugin.analytics?.track).not.toHaveBeenCalledWith( - 'session_end', - expect.any(Object) - ); + // iOS fires these for Control Center, notification banners and permission dialogs + handler('inactive'); + handler('active'); + handler('inactive'); + handler('active'); - nowSpy.mockRestore(); // ✅ restores Date.now, unaffected by useRealTimers + expect(plugin.sessionId).toBe(sessionId); + expect(starts()).toHaveLength(1); + expect(ends()).toHaveLength(0); }); - }); - - describe('app state change scenarios', () => { - let appStateChangeHandler: (nextAppState: any) => void; - beforeEach(async () => { - // Spy on AppState methods - const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener'); + it('records lastEventTime when backgrounding', () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); - await setupPluginWithClient(); + handler('background'); - // Capture the app state change handler - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'change', - expect.any(Function) + expect(plugin.lastEventTime).toBe(baseTime); + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + 'last_event_time', + baseTime.toString() ); - appStateChangeHandler = addEventListenerSpy.mock.calls[0][1]; }); - it('should start new session when app comes to foreground after expiration', async () => { + it('stops responding to app state changes after cleanup', () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); + const sessionId = plugin.sessionId; - // Set up an active session that will be expired - plugin.sessionId = baseTime - 1000; - plugin.lastEventTime = baseTime - (MAX_SESSION_TIME_IN_MS + 10000); // MAX_SESSION_TIME_IN_MS + 10 seconds ago, already expired - - // Spy on the startNewSessionIfNecessary method to ensure it gets called - const startNewSessionSpy = jest.spyOn( - plugin as any, - 'startNewSessionIfNecessary' - ); - - // Simulate app coming to foreground - appStateChangeHandler('active'); + plugin.cleanup(); + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1000); + handler('background'); + handler('active'); - // Should call startNewSessionIfNecessary - expect(startNewSessionSpy).toHaveBeenCalled(); + expect(plugin.sessionId).toBe(sessionId); }); + }); - it('should NOT start new session when app comes to foreground before expiration', async () => { + describe('persistence', () => { + it('resumes a live session from storage without starting a new one', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); - // Set up an active session - plugin.sessionId = baseTime - 1000; - plugin.lastEventTime = baseTime - 30000; // 30 seconds ago, still active - - // Simulate app going to background - appStateChangeHandler('background'); - - // Advance time but not beyond session timeout - jest.setSystemTime(baseTime + 20000); // 20 seconds later (total 50 seconds) + mockAsyncStorage.getItem + .mockResolvedValueOnce(String(baseTime - 1000)) + .mockResolvedValueOnce(String(baseTime - 1000)); - // Simulate app coming to foreground - appStateChangeHandler('active'); + await plugin.configure(client); - // Should NOT trigger new session - expect(plugin.analytics?.track).not.toHaveBeenCalled(); + expect(plugin.sessionId).toBe(baseTime - 1000); + expect(plugin.lastEventTime).toBe(baseTime - 1000); + expect(starts()).toHaveLength(0); }); - it('should update lastEventTime when app goes to background', async () => { + it('rotates a stored session that expired while the app was killed', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); + const storedSessionId = baseTime - 3 * 24 * 60 * 60 * 1000; - plugin.sessionId = baseTime - 1000; - plugin.lastEventTime = baseTime - 30000; + mockAsyncStorage.getItem + .mockResolvedValueOnce(String(storedSessionId)) + .mockResolvedValueOnce(String(storedSessionId)); - // Simulate app going to background - appStateChangeHandler('background'); + await plugin.configure(client); - expect(plugin.lastEventTime).toBe(baseTime); - expect(mockAsyncStorage.setItem).toHaveBeenCalled(); + expect(ends()).toHaveLength(1); + expect(ends()[0].sessionId).toBe(storedSessionId); + expect(starts()).toHaveLength(1); + expect(starts()[0].sessionId).toBe(baseTime); }); - }); - describe('session data persistence', () => { - it('should load session data from AsyncStorage on configure', async () => { - const mockSessionId = '1234567890'; - const mockLastEventTime = '1234567000'; + it('persists the session id', async () => { + await setupPluginWithClient(); - mockAsyncStorage.getItem - .mockResolvedValueOnce(mockSessionId) // SESSION_ID_KEY - .mockResolvedValueOnce(mockLastEventTime); // LAST_EVENT_TIME_KEY + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + 'previous_session_id', + plugin.sessionId.toString() + ); + }); + + it('does not reload storage once initialised', async () => { + await setupPluginWithClient(); + const callsAfterInit = mockAsyncStorage.getItem.mock.calls.length; - const mockClient = { track: jest.fn() } as any; - await plugin.configure(mockClient); + await plugin.execute(makeTrackEvent('a')); + await plugin.execute(makeTrackEvent('b')); - expect(plugin.sessionId).toBe(1234567890); - expect(plugin.lastEventTime).toBe(1234567000); + expect(mockAsyncStorage.getItem).toHaveBeenCalledTimes(callsAfterInit); }); - it('should save session data to AsyncStorage after events', async () => { + it('throttles lastEventTime writes across rapid events', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); await setupPluginWithClient(); - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; - - await plugin.execute(mockEvent); + mockAsyncStorage.setItem.mockClear(); + for (let i = 0; i < 5; i++) { + jest.setSystemTime(baseTime + i * 100); + await plugin.execute(makeTrackEvent(`e_${i}`)); + } - expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( - 'event_session_id', - plugin.sessionId.toString() - ); - expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( - 'last_event_time', - plugin.lastEventTime.toString() - ); + expect(mockAsyncStorage.setItem).not.toHaveBeenCalled(); }); - it('should clear session data on reset', async () => { + it('clears all session keys on reset', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); await setupPluginWithClient(); + const oldSessionId = plugin.sessionId; + jest.setSystemTime(baseTime + 1000); await plugin.reset(); - expect(plugin.sessionId).toBe(-1); - expect(plugin.lastEventTime).toBe(-1); - expect(plugin.eventSessionId).toBe(-1); expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( 'previous_session_id' ); + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( + 'last_event_time' + ); + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( + 'event_session_id' + ); + expect(ends()[0].sessionId).toBe(oldSessionId); + expect(plugin.sessionId).not.toBe(oldSessionId); + expect(plugin.sessionId).toBeGreaterThan(0); }); }); @@ -692,25 +506,15 @@ describe('AmplitudeSessionPlugin', () => { await setupPluginWithClient(); }); - it('should add session_id to track events', async () => { - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; - - const result = await plugin.execute(mockEvent); - - expect(result.integrations?.['Actions Amplitude']).toEqual({ + it('adds session_id to track events', async () => { + const result = await plugin.execute(makeTrackEvent('test_event')); + expect(result.integrations?.[KEY]).toEqual({ session_id: plugin.sessionId, }); }); - it('should add session_id to identify events', async () => { - const mockEvent: IdentifyEventType = { + it('adds session_id to identify events', async () => { + const event: IdentifyEventType = { type: EventType.IdentifyEvent, traits: {}, messageId: 'msg-1', @@ -718,15 +522,14 @@ describe('AmplitudeSessionPlugin', () => { anonymousId: 'anon-1', }; - const result = await plugin.execute(mockEvent); - - expect(result.integrations?.['Actions Amplitude']).toEqual({ + const result = await plugin.execute(event); + expect(result.integrations?.[KEY]).toEqual({ session_id: plugin.sessionId, }); }); - it('should add name property to screen events', async () => { - const mockEvent: ScreenEventType = { + it('adds the screen name to screen event properties', async () => { + const event: ScreenEventType = { type: EventType.ScreenEvent, name: 'Home Screen', properties: { existing: 'prop' }, @@ -735,35 +538,65 @@ describe('AmplitudeSessionPlugin', () => { anonymousId: 'anon-1', }; - const result = (await plugin.execute(mockEvent)) as ScreenEventType; + const result = (await plugin.execute(event)) as ScreenEventType; expect(result.properties).toEqual({ existing: 'prop', name: 'Home Screen', }); - expect(result.integrations?.['Actions Amplitude']).toEqual({ + expect(result.integrations?.[KEY]).toEqual({ session_id: plugin.sessionId, }); }); - it('should NOT modify events when session_id already exists', async () => { - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - integrations: { - 'Actions Amplitude': { session_id: 999999 }, - }, - }; + it('preserves an existing session_id', async () => { + const result = await plugin.execute( + makeTrackEvent('test_event', { + integrations: { [KEY]: { session_id: 999999 } }, + }) + ); + + expect(result.integrations?.[KEY]).toEqual({ session_id: 999999 }); + }); + + it('disables other integrations for Amplitude cloud-mode events', async () => { + const result = await plugin.execute( + makeTrackEvent('[Amplitude] Application Opened', { + integrations: { Braze: true, Mixpanel: true }, + }) + ); + + expect(result.integrations).toEqual({ + All: false, + [KEY]: { session_id: plugin.sessionId }, + }); + }); - const result = await plugin.execute(mockEvent); + it('does not disable integrations for an ordinary event named after Amplitude', async () => { + const result = await plugin.execute( + makeTrackEvent('Amplitude Settings Changed', { + integrations: { Braze: true }, + }) + ); - expect(result.integrations?.['Actions Amplitude']).toEqual({ - session_id: 999999, // Should preserve existing session_id + expect(result.integrations).toEqual({ + Braze: true, + [KEY]: { session_id: plugin.sessionId }, }); }); + + it('leaves events untouched when the destination is not configured', async () => { + const inactive = new AmplitudeSessionPlugin(); + await inactive.configure(client); + inactive.update( + { integrations: {} } as SegmentAPISettings, + UpdateType.initial + ); + + const result = await inactive.execute(makeTrackEvent('test_event')); + + expect(result.integrations?.[KEY]).toBeUndefined(); + inactive.cleanup(); + }); }); }); From 20e0eb4393ac27b6037668dea8cd0967f0f3533d Mon Sep 17 00:00:00 2001 From: Andrea Bueide Date: Fri, 18 Sep 2026 12:39:13 -0500 Subject: [PATCH 2/2] fix(plugin-amplitudeSession): tighten cloud-mode match, harden fallback - Match the Amplitude cloud-mode prefix with startsWith() instead of includes(), so a customer event name that merely contains "[Amplitude] " isn't mistaken for a real cloud-mode event and doesn't get every other destination silently disabled on it. - Remember the id of the session that was just ended so track()'s fallback path (used before the enrichment closure runs, or if it never runs) resolves session_end to the ended session rather than the one that already replaced it. - Wire the existing cleanup() into the Plugin base class's shutdown() hook, so this plugin does the right thing if core ever starts calling it, in addition to the manual cleanup() path. Follow-ups from review of #1329. --- .../src/AmplitudeSessionPlugin.tsx | 26 ++++++++-- .../__tests__/AmplitudeSessionPlugin.test.ts | 52 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx index d2df00637..2aacb079d 100644 --- a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx +++ b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx @@ -45,6 +45,7 @@ export class AmplitudeSessionPlugin extends EventPlugin { private appStateSubscription?: NativeEventSubscription; private appState: AppStateStatus | 'unknown' = 'unknown'; private lastPersistedEventTime = -1; + private lastEndedSessionId = -1; configure = (analytics: SegmentClient): Promise => { this.analytics = analytics; @@ -101,7 +102,7 @@ export class AmplitudeSessionPlugin extends EventPlugin { const eventName = event.event; if ( - eventName.includes(AMP_PREFIX) || + eventName.startsWith(AMP_PREFIX) || eventName === AMP_SESSION_START_EVENT || eventName === AMP_SESSION_END_EVENT ) { @@ -156,6 +157,12 @@ export class AmplitudeSessionPlugin extends EventPlugin { this.appStateSubscription = undefined; } + // Nothing in core calls this yet, but it's the documented Plugin teardown hook - + // wired to cleanup() so this plugin does the right thing if that ever changes. + shutdown() { + this.cleanup(); + } + private async initialize() { try { const [storedSessionId, storedLastEventTime] = await Promise.all([ @@ -207,6 +214,8 @@ export class AmplitudeSessionPlugin extends EventPlugin { } private endSession(sessionId: number, endedAt: number) { + // Remembered so readSessionId() can recover if the enrichment closure below never runs + this.lastEndedSessionId = sessionId; void this.analytics?.track(AMP_SESSION_END_EVENT, undefined, (event) => this.withSessionId(event, sessionId, endedAt) ); @@ -253,12 +262,23 @@ export class AmplitudeSessionPlugin extends EventPlugin { ); } - // Falls back to the current id if the enrichment closure was dropped by pre-init buffering - private readSessionId(event: SegmentEvent) { + // Falls back if the enrichment closure was dropped (e.g. pre-init buffering, or a + // downstream plugin swallowing it). For session_end this must resolve to the session + // that just ended, not the new one - this.sessionId has already moved on by the time + // track() runs. Doesn't help across a process restart: a buffered session_end that + // survives a crash loses its closure on replay in a fresh instance with no memory of + // lastEndedSessionId, so it still falls back to the (wrong) current id in that case. + private readSessionId(event: TrackEventType) { const existing = event.integrations?.[this.key]; if (this.hasSessionId(event)) { return (existing as { session_id: number }).session_id; } + if ( + event.event === AMP_SESSION_END_EVENT && + this.lastEndedSessionId !== -1 + ) { + return this.lastEndedSessionId; + } return this.sessionId; } diff --git a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts index d570b3148..35ac37684 100644 --- a/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts +++ b/packages/plugins/plugin-amplitudeSession/src/__tests__/AmplitudeSessionPlugin.test.ts @@ -307,6 +307,28 @@ describe('AmplitudeSessionPlugin', () => { expect(plugin.sessionId).toBeGreaterThan(secondSessionId); }); + it('falls back to the ended session id if a session_end event loses its enrichment closure', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + await setupPluginWithClient(); + + const oldSessionId = plugin.sessionId; + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1); + // Triggers the real endSession()/trackSessionStart() calls, which record + // lastEndedSessionId as a side effect - same as any other rotation. + ( + plugin as unknown as { startNewSessionIfNecessary: () => void } + ).startNewSessionIfNecessary(); + + // Simulate the timeline handing session_end to track() before its enrichment + // closure has run (as core always does), and imagine that closure is then + // never applied - e.g. dropped by pre-init buffering or a downstream plugin. + const bareSessionEnd = plugin.track(makeTrackEvent('session_end')); + + expect(sessionIdOf(bareSessionEnd)).toBe(oldSessionId); + expect(sessionIdOf(bareSessionEnd)).not.toBe(plugin.sessionId); + }); + it('round-trips its own session events without cascading', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); @@ -409,6 +431,21 @@ describe('AmplitudeSessionPlugin', () => { expect(plugin.sessionId).toBe(sessionId); }); + + it('stops responding to app state changes after shutdown', () => { + // shutdown() is the documented Plugin teardown hook; nothing in core calls it + // yet, but this plugin should behave correctly if that ever changes. + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + const sessionId = plugin.sessionId; + + plugin.shutdown(); + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1000); + handler('background'); + handler('active'); + + expect(plugin.sessionId).toBe(sessionId); + }); }); describe('persistence', () => { @@ -585,6 +622,21 @@ describe('AmplitudeSessionPlugin', () => { }); }); + it('does not disable integrations when the Amplitude prefix appears mid-string', async () => { + // A customer event name that merely contains the substring must not match - + // only a real cloud-mode event, which has it as a prefix, should. + const result = await plugin.execute( + makeTrackEvent('User clicked [Amplitude] banner', { + integrations: { Braze: true }, + }) + ); + + expect(result.integrations).toEqual({ + Braze: true, + [KEY]: { session_id: plugin.sessionId }, + }); + }); + it('leaves events untouched when the destination is not configured', async () => { const inactive = new AmplitudeSessionPlugin(); await inactive.configure(client);