diff --git a/packages/segment-session-replay-plugin-react-native/src/segment-session-replay-plugin.ts b/packages/segment-session-replay-plugin-react-native/src/segment-session-replay-plugin.ts index 742ff1a8cb..0788127567 100644 --- a/packages/segment-session-replay-plugin-react-native/src/segment-session-replay-plugin.ts +++ b/packages/segment-session-replay-plugin-react-native/src/segment-session-replay-plugin.ts @@ -2,6 +2,7 @@ import { Plugin, PluginType, type SegmentEvent, EventType, SegmentClient } from import { type SessionReplayConfig, + getSessionId as getSRSessionId, getSessionReplayProperties, init, setDeviceId, @@ -41,6 +42,10 @@ export class SegmentSessionReplayPlugin extends Plugin { // because `configure` is not asynchronous private initPromise: Promise | null = null; + // True when start() was called but deferred because no valid session ID (> 0) + // was available yet. Flushed by execute() once a valid id arrives. + private pendingStart = false; + constructor(config: SessionReplayConfig) { super(); this.sessionReplayConfig = config; @@ -64,6 +69,16 @@ export class SegmentSessionReplayPlugin extends Plugin { await setSessionId(sessionId); await setDeviceId(deviceId); + // Flush a deferred start() once the first valid session ID arrives. + // start() may have been called before any event flowed through here, at + // which point the native SDK's session ID was still -1 (the default + // sentinel). We wait until we have a real id (> 0) before starting + // native recording to avoid corrupting the replay with sessionId -1. + if (this.pendingStart && sessionId > 0) { + this.pendingStart = false; + await start(); + } + if (event.type === EventType.TrackEvent || event.type === EventType.ScreenEvent) { const properties = await getSessionReplayProperties(); event.properties = { ...event.properties, ...properties }; @@ -79,7 +94,16 @@ export class SegmentSessionReplayPlugin extends Plugin { async start(): Promise { await this.initPromise; - await start(); + // The native SDK defaults sessionId to -1 until the first Segment event + // flows through execute() and calls setSessionId() with a real value. + // Starting under -1 would tag the entire recording with an invalid session, + // so we defer if the current id is not yet valid (> 0). + const currentSessionId = await getSRSessionId(); + if (currentSessionId !== null && currentSessionId > 0) { + await start(); + } else { + this.pendingStart = true; + } } async stop(): Promise { diff --git a/packages/segment-session-replay-plugin-react-native/test/segment-session-replay-plugin.test.ts b/packages/segment-session-replay-plugin-react-native/test/segment-session-replay-plugin.test.ts index 092b8d62f3..a02a9143d9 100644 --- a/packages/segment-session-replay-plugin-react-native/test/segment-session-replay-plugin.test.ts +++ b/packages/segment-session-replay-plugin-react-native/test/segment-session-replay-plugin.test.ts @@ -34,6 +34,7 @@ import { init, setDeviceId, setSessionId, + getSessionId, getSessionReplayProperties, start, stop, @@ -45,6 +46,7 @@ jest.mock('@amplitude/session-replay-react-native', () => ({ init: jest.fn(), setDeviceId: jest.fn(), setSessionId: jest.fn(), + getSessionId: jest.fn(), getSessionReplayProperties: jest.fn(), start: jest.fn(), stop: jest.fn(), @@ -58,6 +60,9 @@ describe('SegmentSessionReplayPlugin', () => { beforeEach(() => { jest.clearAllMocks(); + // Default: native SDK has not yet received a real session ID (the -1 sentinel). + (getSessionId as jest.Mock).mockResolvedValue(-1); + mockConfig = { apiKey: 'test-api-key', }; @@ -266,9 +271,85 @@ describe('SegmentSessionReplayPlugin', () => { }); describe('start', () => { - it('should call start', async () => { + it('should call native start immediately when a valid session ID already exists', async () => { + (getSessionId as jest.Mock).mockResolvedValue(1700000000000); + + await plugin.start(); + + expect(start).toHaveBeenCalledTimes(1); + }); + + it('should NOT call native start when session ID is -1 (autoStart:false bug)', async () => { + // SR SDK still has the default -1 sentinel — no real session ID yet. + (getSessionId as jest.Mock).mockResolvedValue(-1); + + await plugin.start(); + + expect(start).not.toHaveBeenCalled(); + }); + + it('should NOT call native start when session ID is null', async () => { + (getSessionId as jest.Mock).mockResolvedValue(null); + + await plugin.start(); + + expect(start).not.toHaveBeenCalled(); + }); + + it('should flush deferred start on the first execute() with a valid session ID', async () => { + // Simulate autoStart:false → start() before any event + (getSessionId as jest.Mock).mockResolvedValue(-1); await plugin.start(); - expect(start).toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + + // First real event arrives carrying the actual session ID + const mockEvent: SegmentEvent = { + type: EventType.TrackEvent, + event: 'app_opened', + properties: { session_id: '1700000000000' }, + context: { device: { id: 'device-abc' } }, + } as any; + (getSessionReplayProperties as jest.Mock).mockResolvedValue({}); + + await plugin.execute(mockEvent); + + // Native start() should now have been called exactly once with the real id. + expect(start).toHaveBeenCalledTimes(1); + }); + + it('should NOT flush deferred start when execute() carries sessionId -1', async () => { + (getSessionId as jest.Mock).mockResolvedValue(-1); + await plugin.start(); + + const mockEvent: SegmentEvent = { + type: EventType.TrackEvent, + event: 'no_session_yet', + properties: {}, + context: {}, + } as any; + (getSessionReplayProperties as jest.Mock).mockResolvedValue({}); + + await plugin.execute(mockEvent); + + expect(start).not.toHaveBeenCalled(); + }); + + it('should flush deferred start only once across multiple execute() calls', async () => { + (getSessionId as jest.Mock).mockResolvedValue(-1); + await plugin.start(); + + const eventWithSession: SegmentEvent = { + type: EventType.TrackEvent, + event: 'button_clicked', + properties: { session_id: '1700000000000' }, + context: { device: { id: 'device-abc' } }, + } as any; + (getSessionReplayProperties as jest.Mock).mockResolvedValue({}); + + await plugin.execute(eventWithSession); + await plugin.execute(eventWithSession); + + expect(start).toHaveBeenCalledTimes(1); }); });