Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Plugin, PluginType, type SegmentEvent, EventType, SegmentClient } from

import {
type SessionReplayConfig,
getSessionId as getSRSessionId,
getSessionReplayProperties,
init,
setDeviceId,
Expand Down Expand Up @@ -41,6 +42,10 @@ export class SegmentSessionReplayPlugin extends Plugin {
// because `configure` is not asynchronous
private initPromise: Promise<void> | 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;
Expand All @@ -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 };
Expand All @@ -79,7 +94,16 @@ export class SegmentSessionReplayPlugin extends Plugin {

async start(): Promise<void> {
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<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
init,
setDeviceId,
setSessionId,
getSessionId,
getSessionReplayProperties,
start,
stop,
Expand All @@ -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(),
Expand All @@ -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',
};
Expand Down Expand Up @@ -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);
});
});

Expand Down
Loading