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
16 changes: 16 additions & 0 deletions packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { saveSession } from '../session/saveSession';
import type { ReplayContainer } from '../types';
import { isErrorEvent, isFeedbackEvent, isReplayEvent, isTransactionEvent } from '../util/eventUtils';
import { isRrwebError } from '../util/isRrwebError';
import { isSessionExpired } from '../util/isSessionExpired';
import { debug } from '../util/logger';
import { resetReplayIdOnDynamicSamplingContext } from '../util/resetReplayIdOnDynamicSamplingContext';
import { addFeedbackBreadcrumb } from './util/addFeedbackBreadcrumb';
Expand All @@ -15,6 +16,21 @@ import { shouldSampleForBufferEvent } from './util/shouldSampleForBufferEvent';
export function handleGlobalEventListener(replay: ReplayContainer): (event: Event, hint: EventHint) => Event | null {
return Object.assign(
(event: Event, hint: EventHint) => {
// Aggressively check for expired session and clean stale replay_id from DSC.
// This must run BEFORE the isEnabled/isPaused guards because when paused,
// the guards short-circuit without cleaning DSC. The cached DSC on the scope
// (set by browserTracingIntegration when the idle span ended) persists the
// stale replay_id indefinitely until explicitly deleted.
if (
replay.session &&
isSessionExpired(replay.session, {
maxReplayDuration: replay.getOptions().maxReplayDuration,
sessionIdleExpire: replay.timeouts.sessionIdleExpire,
})
) {
resetReplayIdOnDynamicSamplingContext();
}

// Do nothing if replay has been disabled or paused
if (!replay.isEnabled() || replay.isPaused()) {
return event;
Expand Down
13 changes: 12 additions & 1 deletion packages/replay-internal/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ import { getHandleRecordingEmit } from './util/handleRecordingEmit';
import { isExpired } from './util/isExpired';
import { isSessionExpired } from './util/isSessionExpired';
import { debug } from './util/logger';
import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext';
import {
resetReplayIdOnDynamicSamplingContext,
setReplayIdOnDynamicSamplingContext,
} from './util/resetReplayIdOnDynamicSamplingContext';
import { closestElementOfNode } from './util/rrweb';
import { sendReplay } from './util/sendReplay';
import { RateLimitError, ReplayDurationLimitError } from './util/sendReplayRequest';
Expand Down Expand Up @@ -863,6 +866,13 @@ export class ReplayContainer implements ReplayContainerInterface {
this._isPaused = false;

this.startRecording();

// Update the cached DSC with the new replay_id when in session mode.
// The cached DSC on the scope (set by browserTracingIntegration) persists
// across session refreshes, and the `createDsc` hook won't fire for it.
if (this.recordingMode === 'session' && this.session) {
setReplayIdOnDynamicSamplingContext(this.session.id);
}
}

/**
Expand Down Expand Up @@ -994,6 +1004,7 @@ export class ReplayContainer implements ReplayContainerInterface {
});

if (expired) {
resetReplayIdOnDynamicSamplingContext();
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,24 @@ export function resetReplayIdOnDynamicSamplingContext(): void {
delete (dsc as Partial<DynamicSamplingContext>).replay_id;
}
}

/**
* Set the `replay_id` field on the cached DSC.
* This is needed after a session refresh because the cached DSC on the scope
* (set by browserTracingIntegration when the idle span ended) persists across
* session boundaries. Without updating it, the new session's replay_id would
* never appear in DSC since `getDynamicSamplingContextFromClient` (and its
* `createDsc` hook) is not called when a cached DSC already exists.
*/
export function setReplayIdOnDynamicSamplingContext(replayId: string): void {
const dsc = getCurrentScope().getPropagationContext().dsc;
if (dsc) {
dsc.replay_id = replayId;
}

const activeSpan = getActiveSpan();
if (activeSpan) {
const dsc = getDynamicSamplingContextFromSpan(activeSpan);
(dsc as Partial<DynamicSamplingContext>).replay_id = replayId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
import type { Event } from '@sentry/core';
import { getClient } from '@sentry/core';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants';
import {
MAX_REPLAY_DURATION,
REPLAY_EVENT_NAME,
SESSION_IDLE_EXPIRE_DURATION,
SESSION_IDLE_PAUSE_DURATION,

Check warning on line 13 in packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-unused-vars)

Identifier 'SESSION_IDLE_PAUSE_DURATION' is imported but never used.

Check warning on line 13 in packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-unused-vars)

Identifier 'SESSION_IDLE_PAUSE_DURATION' is imported but never used.
} from '../../../src/constants';
import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent';
import type { ReplayContainer } from '../../../src/replay';
import { makeSession } from '../../../src/session/Session';
Expand Down Expand Up @@ -435,4 +440,103 @@

expect(resetReplayIdSpy).toHaveBeenCalledTimes(2);
});

it('resets replayId on DSC when replay is paused and session has expired', () => {
const now = Date.now();

replay.session = makeSession({
id: 'test-session-id',
segmentId: 0,
lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1,
started: now - SESSION_IDLE_EXPIRE_DURATION - 1,
sampled: 'session',
});

replay['_isPaused'] = true;

const resetReplayIdSpy = vi.spyOn(
resetReplayIdOnDynamicSamplingContextModule,
'resetReplayIdOnDynamicSamplingContext',
);

const errorEvent = Error();
handleGlobalEventListener(replay)(errorEvent, {});

// Should have been called even though replay is paused
expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
});

it('does not reset replayId on DSC when replay is paused but session is still valid', () => {
const now = Date.now();

replay.session = makeSession({
id: 'test-session-id',
segmentId: 0,
lastActivity: now,
started: now,
sampled: 'session',
});

replay['_isPaused'] = true;

const resetReplayIdSpy = vi.spyOn(
resetReplayIdOnDynamicSamplingContextModule,
'resetReplayIdOnDynamicSamplingContext',
);

const errorEvent = Error();
handleGlobalEventListener(replay)(errorEvent, {});

// Should NOT have been called because session is still valid
expect(resetReplayIdSpy).not.toHaveBeenCalled();
});

it('resets replayId on DSC when replay is paused and session exceeds max duration', () => {
const now = Date.now();

replay.session = makeSession({
id: 'test-session-id',
segmentId: 0,
// Recent activity, but session started too long ago
lastActivity: now,
started: now - MAX_REPLAY_DURATION - 1,
sampled: 'session',
});

replay['_isPaused'] = true;

const resetReplayIdSpy = vi.spyOn(
resetReplayIdOnDynamicSamplingContextModule,
'resetReplayIdOnDynamicSamplingContext',
);

const errorEvent = Error();
handleGlobalEventListener(replay)(errorEvent, {});

expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
});

it('resets replayId on DSC when replay is disabled and session has expired', () => {
const now = Date.now();

replay.session = makeSession({
id: 'test-session-id',
segmentId: 0,
lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1,
started: now - SESSION_IDLE_EXPIRE_DURATION - 1,
sampled: 'session',
});

replay['_isEnabled'] = false;

const resetReplayIdSpy = vi.spyOn(
resetReplayIdOnDynamicSamplingContextModule,
'resetReplayIdOnDynamicSamplingContext',
);

const errorEvent = Error();
handleGlobalEventListener(replay)(errorEvent, {});

expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
});
});
51 changes: 51 additions & 0 deletions packages/replay-internal/test/integration/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,57 @@ describe('Integration | session', () => {
);
});

it('updates DSC with new replay_id after session refresh', async () => {
const { getCurrentScope } = await import('@sentry/core');

const initialSession = { ...replay.session } as Session;

// Simulate a cached DSC on the scope (as browserTracingIntegration does
// when the idle span ends) with the old session's replay_id.
const scope = getCurrentScope();
scope.setPropagationContext({
...scope.getPropagationContext(),
dsc: {
trace_id: 'test-trace-id',
public_key: 'test-public-key',
replay_id: initialSession.id,
},
});

// Idle past expiration
const ELAPSED = SESSION_IDLE_EXPIRE_DURATION + 1;
vi.advanceTimersByTime(ELAPSED);

// Emit a recording event to put replay into paused state (mirrors the
// "creates a new session" test which does this before clicking)
const TEST_EVENT = getTestEventIncremental({
data: { name: 'lost event' },
timestamp: BASE_TIMESTAMP,
});
mockRecord._emitter(TEST_EVENT);
await new Promise(process.nextTick);

expect(replay.isPaused()).toBe(true);

// Trigger user activity to cause session refresh
domHandler({
name: 'click',
event: new Event('click'),
});

// _refreshSession is async (calls await stop() then initializeSampling)
await vi.advanceTimersByTimeAsync(DEFAULT_FLUSH_MIN_DELAY);
await new Promise(process.nextTick);

// Should be a new session
expect(replay).not.toHaveSameSession(initialSession);

// The cached DSC should now have the NEW session's replay_id, not the old one
const dsc = scope.getPropagationContext().dsc;
expect(dsc?.replay_id).toBe(replay.session?.id);
expect(dsc?.replay_id).not.toBe(initialSession.id);
});

it('increases segment id after each event', async () => {
clearSession(replay);
replay['_initializeSessionForSampling']();
Expand Down
Loading