diff --git a/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx b/packages/plugins/plugin-amplitudeSession/src/AmplitudeSessionPlugin.tsx index 95bebe04e..2aacb079d 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,22 @@ 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; + private lastEndedSessionId = -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 +67,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 +90,7 @@ export class AmplitudeSessionPlugin extends EventPlugin { break; } - this.lastEventTime = Date.now(); - //await this.saveSessionData(); + this.setLastEventTime(Date.now()); return result; } @@ -131,27 +101,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.startsWith(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 +132,215 @@ 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)); - return { - ...event, - integrations: { - ...integrations, - [this.key]: { session_id: this.sessionId }, - }, - }; - }; + if (endedSessionId >= 0) { + this.endSession(endedSessionId, endedAt); + } + this.startNewSessionIfNecessary(); + } - private onBackground = () => { - this.lastEventTime = Date.now(); - }; + /** Removes the AppState listener. Call when tearing down the client. */ + cleanup() { + this.appStateSubscription?.remove(); + this.appStateSubscription = undefined; + } - private onForeground = () => { - this.startNewSessionIfNecessary(); - }; + // 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 startNewSessionIfNecessary() { - if (this.eventSessionId === -1) { - this.eventSessionId = this.sessionId; + 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); } - if (this.resetPending) { + this.startNewSessionIfNecessary(); + this.appStateSubscription = AppState.addEventListener( + 'change', + this.handleAppStateChange + ); + } + + // 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) { + // 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) + ); + } - 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 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; + } - 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..35ac37684 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,533 @@ 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; - - const mockEvent: TrackEventType = { - type: EventType.TrackEvent, - event: 'test_event', - properties: {}, - messageId: 'msg-1', - timestamp: '2023-01-01T00:00:00.000Z', - anonymousId: 'anon-1', - }; + jest.setSystemTime(baseTime + 60000); + await plugin.execute(makeTrackEvent('last_real_activity')); - // This inconsistent state might cause unexpected behavior - await plugin.execute(mockEvent); + // App killed for an hour; the rotation only happens on relaunch + jest.setSystemTime(baseTime + 3600000); + await plugin.execute(makeTrackEvent('after_relaunch')); - // 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' + expect(ends()).toHaveLength(1); + expect(ends()[0].timestamp).toBe( + new Date(baseTime + 60000).toISOString() ); - const sessionEndCalls = trackMock.mock.calls.filter( - (call: any) => call[0] === 'session_end' - ); - - // 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); - } - - if (endSessionCalls > 1) { - console.error( - `🐛 BUG DETECTED: ${endSessionCalls} endSession calls from concurrent operations` - ); - expect(endSessionCalls).toBeLessThanOrEqual(1); - } + const resumed = new AmplitudeSessionPlugin(); + await resumed.configure(client); - // 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('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(); - 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[]; + 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); + }); - // 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]); - } + it('round-trips its own session events without cascading', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); - // 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 }, - }, - }); - }); - - 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); - - plugin.sessionId = fixedNow - 1000; - plugin.lastEventTime = fixedNow - (MAX_SESSION_TIME_IN_MS - 2); // within limit - - 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 + MAX_SESSION_TIME_IN_MS + 1000); + handler('active'); - const oldSessionId = plugin.sessionId; + expect(plugin.sessionId).toBeGreaterThan(oldSessionId); + expect(ends()[0].sessionId).toBe(oldSessionId); + }); - await plugin.execute(mockEvent); + it('does not start a new session when foregrounding before expiry', () => { + 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) - ); + handler('background'); + jest.setSystemTime(baseTime + 20000); + handler('active'); - nowSpy.mockRestore(); // ✅ restores Date.now, unaffected by useRealTimers + expect(plugin.sessionId).toBe(sessionId); + expect(starts()).toHaveLength(1); }); - }); - - describe('app state change scenarios', () => { - let appStateChangeHandler: (nextAppState: any) => void; - beforeEach(async () => { - // Spy on AppState methods - const addEventListenerSpy = jest.spyOn(AppState, 'addEventListener'); + it('ignores inactive/active churn without a real backgrounding', () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + const sessionId = plugin.sessionId; - await setupPluginWithClient(); + // iOS fires these for Control Center, notification banners and permission dialogs + handler('inactive'); + handler('active'); + handler('inactive'); + handler('active'); - // Capture the app state change handler - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'change', - expect.any(Function) - ); - appStateChangeHandler = addEventListenerSpy.mock.calls[0][1]; + expect(plugin.sessionId).toBe(sessionId); + expect(starts()).toHaveLength(1); + expect(ends()).toHaveLength(0); }); - it('should start new session when app comes to foreground after expiration', async () => { + it('records lastEventTime when backgrounding', () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); - // 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 + handler('background'); - // Spy on the startNewSessionIfNecessary method to ensure it gets called - const startNewSessionSpy = jest.spyOn( - plugin as any, - 'startNewSessionIfNecessary' + expect(plugin.lastEventTime).toBe(baseTime); + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + 'last_event_time', + baseTime.toString() ); - - // Simulate app coming to foreground - appStateChangeHandler('active'); - - // Should call startNewSessionIfNecessary - expect(startNewSessionSpy).toHaveBeenCalled(); }); - it('should NOT start new session when app comes to foreground before 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 - plugin.sessionId = baseTime - 1000; - plugin.lastEventTime = baseTime - 30000; // 30 seconds ago, still active + plugin.cleanup(); + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1000); + handler('background'); + handler('active'); - // Simulate app going to background - appStateChangeHandler('background'); + expect(plugin.sessionId).toBe(sessionId); + }); - // Advance time but not beyond session timeout - jest.setSystemTime(baseTime + 20000); // 20 seconds later (total 50 seconds) + 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; - // Simulate app coming to foreground - appStateChangeHandler('active'); + plugin.shutdown(); + jest.setSystemTime(baseTime + MAX_SESSION_TIME_IN_MS + 1000); + handler('background'); + handler('active'); - // Should NOT trigger new session - expect(plugin.analytics?.track).not.toHaveBeenCalled(); + expect(plugin.sessionId).toBe(sessionId); }); + }); - it('should update lastEventTime when app goes to background', async () => { + describe('persistence', () => { + it('resumes a live session from storage without starting a new one', async () => { const baseTime = Date.now(); jest.setSystemTime(baseTime); - plugin.sessionId = baseTime - 1000; - plugin.lastEventTime = baseTime - 30000; + mockAsyncStorage.getItem + .mockResolvedValueOnce(String(baseTime - 1000)) + .mockResolvedValueOnce(String(baseTime - 1000)); - // Simulate app going to background - appStateChangeHandler('background'); + await plugin.configure(client); - expect(plugin.lastEventTime).toBe(baseTime); - expect(mockAsyncStorage.setItem).toHaveBeenCalled(); + expect(plugin.sessionId).toBe(baseTime - 1000); + expect(plugin.lastEventTime).toBe(baseTime - 1000); + expect(starts()).toHaveLength(0); }); - }); - describe('session data persistence', () => { - it('should load session data from AsyncStorage on configure', async () => { - const mockSessionId = '1234567890'; - const mockLastEventTime = '1234567000'; + 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; mockAsyncStorage.getItem - .mockResolvedValueOnce(mockSessionId) // SESSION_ID_KEY - .mockResolvedValueOnce(mockLastEventTime); // LAST_EVENT_TIME_KEY + .mockResolvedValueOnce(String(storedSessionId)) + .mockResolvedValueOnce(String(storedSessionId)); - const mockClient = { track: jest.fn() } as any; - await plugin.configure(mockClient); + await plugin.configure(client); - expect(plugin.sessionId).toBe(1234567890); - expect(plugin.lastEventTime).toBe(1234567000); + expect(ends()).toHaveLength(1); + expect(ends()[0].sessionId).toBe(storedSessionId); + expect(starts()).toHaveLength(1); + expect(starts()[0].sessionId).toBe(baseTime); }); - it('should save session data to AsyncStorage after events', async () => { + it('persists the session id', async () => { 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); - expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( - 'event_session_id', + 'previous_session_id', plugin.sessionId.toString() ); - expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( - 'last_event_time', - plugin.lastEventTime.toString() - ); }); - it('should clear session data on reset', async () => { + it('does not reload storage once initialised', async () => { await setupPluginWithClient(); + const callsAfterInit = mockAsyncStorage.getItem.mock.calls.length; + + await plugin.execute(makeTrackEvent('a')); + await plugin.execute(makeTrackEvent('b')); + + expect(mockAsyncStorage.getItem).toHaveBeenCalledTimes(callsAfterInit); + }); + it('throttles lastEventTime writes across rapid events', async () => { + const baseTime = Date.now(); + jest.setSystemTime(baseTime); + await setupPluginWithClient(); + + mockAsyncStorage.setItem.mockClear(); + for (let i = 0; i < 5; i++) { + jest.setSystemTime(baseTime + i * 100); + await plugin.execute(makeTrackEvent(`e_${i}`)); + } + + expect(mockAsyncStorage.setItem).not.toHaveBeenCalled(); + }); + + 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 +543,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 +559,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 +575,80 @@ 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 }); + }); - const result = await plugin.execute(mockEvent); + 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?.['Actions Amplitude']).toEqual({ - session_id: 999999, // Should preserve existing session_id + expect(result.integrations).toEqual({ + All: false, + [KEY]: { session_id: plugin.sessionId }, }); }); + + 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).toEqual({ + Braze: true, + [KEY]: { session_id: plugin.sessionId }, + }); + }); + + 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); + inactive.update( + { integrations: {} } as SegmentAPISettings, + UpdateType.initial + ); + + const result = await inactive.execute(makeTrackEvent('test_event')); + + expect(result.integrations?.[KEY]).toBeUndefined(); + inactive.cleanup(); + }); }); });