From ab7d89642d70ce4e34f82833c5ec41f27cd30983 Mon Sep 17 00:00:00 2001 From: Aliaksandr Kazarez Date: Fri, 5 Jun 2026 18:41:29 -0700 Subject: [PATCH] fix(segment-session-replay-plugin-react-native): defer start() when session ID is -1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With autoStart:false, customers call plugin.start() before any Segment event flows through execute(), so the native SDK's sessionId is still the default -1 sentinel at that point. Recording would begin under -1, making the entire replay unroutable in Amplitude. Root cause (segment-session-replay-plugin.ts ~L80): start() called native start() unconditionally, without checking whether the native SDK already held a valid (> 0) session ID. Fix (plugin-level, mirrors iOS canStart / Android isSessionIdValid guards): - Import getSessionId from @amplitude/session-replay-react-native (already exported, previously unused in this plugin). - In start(): read the current native session ID; if it is null or <= 0, set pendingStart = true and return — do NOT start native recording. - In execute(): after setSessionId() applies the first valid id (> 0), if pendingStart is set, clear it and call native start() immediately. Customers using autoStart:true are unaffected (execute() runs first). Resolves: Zendesk #396256 / YaraPlus org 56519 Co-authored-by: Cursor --- .../src/segment-session-replay-plugin.ts | 26 +++++- .../segment-session-replay-plugin.test.ts | 85 ++++++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) 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); }); });