Skip to content

Commit 9281810

Browse files
committed
feat(replay): Reset replay id from DSC on session expiry/refresh
Its possible that a user returns to an old Sentry tab, an error gets thrown and ingested w/ the expired replay id in DSC. This error then gets link in our UI because of the replay id in DSC and causes the duration to appear to be very long (>>> 1 hr). This PR adds a check in handleGlobalEvent to clear the replay id from DSC if the replay session is expired. It also updates the DSC when in session mode and replay session is refreshed.
1 parent 33f65c3 commit 9281810

5 files changed

Lines changed: 205 additions & 2 deletions

File tree

packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { saveSession } from '../session/saveSession';
44
import type { ReplayContainer } from '../types';
55
import { isErrorEvent, isFeedbackEvent, isReplayEvent, isTransactionEvent } from '../util/eventUtils';
66
import { isRrwebError } from '../util/isRrwebError';
7+
import { isSessionExpired } from '../util/isSessionExpired';
78
import { debug } from '../util/logger';
89
import { resetReplayIdOnDynamicSamplingContext } from '../util/resetReplayIdOnDynamicSamplingContext';
910
import { addFeedbackBreadcrumb } from './util/addFeedbackBreadcrumb';
@@ -15,6 +16,21 @@ import { shouldSampleForBufferEvent } from './util/shouldSampleForBufferEvent';
1516
export function handleGlobalEventListener(replay: ReplayContainer): (event: Event, hint: EventHint) => Event | null {
1617
return Object.assign(
1718
(event: Event, hint: EventHint) => {
19+
// Aggressively check for expired session and clean stale replay_id from DSC.
20+
// This must run BEFORE the isEnabled/isPaused guards because when paused,
21+
// the guards short-circuit without cleaning DSC. The cached DSC on the scope
22+
// (set by browserTracingIntegration when the idle span ended) persists the
23+
// stale replay_id indefinitely until explicitly deleted.
24+
if (
25+
replay.session &&
26+
isSessionExpired(replay.session, {
27+
maxReplayDuration: replay.getOptions().maxReplayDuration,
28+
sessionIdleExpire: replay.timeouts.sessionIdleExpire,
29+
})
30+
) {
31+
resetReplayIdOnDynamicSamplingContext();
32+
}
33+
1834
// Do nothing if replay has been disabled or paused
1935
if (!replay.isEnabled() || replay.isPaused()) {
2036
return event;

packages/replay-internal/src/replay.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ import { getHandleRecordingEmit } from './util/handleRecordingEmit';
5252
import { isExpired } from './util/isExpired';
5353
import { isSessionExpired } from './util/isSessionExpired';
5454
import { debug } from './util/logger';
55-
import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext';
55+
import {
56+
resetReplayIdOnDynamicSamplingContext,
57+
setReplayIdOnDynamicSamplingContext,
58+
} from './util/resetReplayIdOnDynamicSamplingContext';
5659
import { closestElementOfNode } from './util/rrweb';
5760
import { sendReplay } from './util/sendReplay';
5861
import { RateLimitError, ReplayDurationLimitError } from './util/sendReplayRequest';
@@ -863,6 +866,13 @@ export class ReplayContainer implements ReplayContainerInterface {
863866
this._isPaused = false;
864867

865868
this.startRecording();
869+
870+
// Update the cached DSC with the new replay_id when in session mode.
871+
// The cached DSC on the scope (set by browserTracingIntegration) persists
872+
// across session refreshes, and the `createDsc` hook won't fire for it.
873+
if (this.recordingMode === 'session' && this.session) {
874+
setReplayIdOnDynamicSamplingContext(this.session.id);
875+
}
866876
}
867877

868878
/**
@@ -994,6 +1004,7 @@ export class ReplayContainer implements ReplayContainerInterface {
9941004
});
9951005

9961006
if (expired) {
1007+
resetReplayIdOnDynamicSamplingContext();
9971008
return;
9981009
}
9991010

packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,24 @@ export function resetReplayIdOnDynamicSamplingContext(): void {
1818
delete (dsc as Partial<DynamicSamplingContext>).replay_id;
1919
}
2020
}
21+
22+
/**
23+
* Set the `replay_id` field on the cached DSC.
24+
* This is needed after a session refresh because the cached DSC on the scope
25+
* (set by browserTracingIntegration when the idle span ended) persists across
26+
* session boundaries. Without updating it, the new session's replay_id would
27+
* never appear in DSC since `getDynamicSamplingContextFromClient` (and its
28+
* `createDsc` hook) is not called when a cached DSC already exists.
29+
*/
30+
export function setReplayIdOnDynamicSamplingContext(replayId: string): void {
31+
const dsc = getCurrentScope().getPropagationContext().dsc;
32+
if (dsc) {
33+
dsc.replay_id = replayId;
34+
}
35+
36+
const activeSpan = getActiveSpan();
37+
if (activeSpan) {
38+
const dsc = getDynamicSamplingContextFromSpan(activeSpan);
39+
(dsc as Partial<DynamicSamplingContext>).replay_id = replayId;
40+
}
41+
}

packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import '../../utils/mock-internal-setTimeout';
66
import type { Event } from '@sentry/core';
77
import { getClient } from '@sentry/core';
88
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
9-
import { REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants';
9+
import {
10+
MAX_REPLAY_DURATION,
11+
REPLAY_EVENT_NAME,
12+
SESSION_IDLE_EXPIRE_DURATION,
13+
SESSION_IDLE_PAUSE_DURATION,
14+
} from '../../../src/constants';
1015
import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent';
1116
import type { ReplayContainer } from '../../../src/replay';
1217
import { makeSession } from '../../../src/session/Session';
@@ -435,4 +440,103 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => {
435440

436441
expect(resetReplayIdSpy).toHaveBeenCalledTimes(2);
437442
});
443+
444+
it('resets replayId on DSC when replay is paused and session has expired', () => {
445+
const now = Date.now();
446+
447+
replay.session = makeSession({
448+
id: 'test-session-id',
449+
segmentId: 0,
450+
lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1,
451+
started: now - SESSION_IDLE_EXPIRE_DURATION - 1,
452+
sampled: 'session',
453+
});
454+
455+
replay['_isPaused'] = true;
456+
457+
const resetReplayIdSpy = vi.spyOn(
458+
resetReplayIdOnDynamicSamplingContextModule,
459+
'resetReplayIdOnDynamicSamplingContext',
460+
);
461+
462+
const errorEvent = Error();
463+
handleGlobalEventListener(replay)(errorEvent, {});
464+
465+
// Should have been called even though replay is paused
466+
expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
467+
});
468+
469+
it('does not reset replayId on DSC when replay is paused but session is still valid', () => {
470+
const now = Date.now();
471+
472+
replay.session = makeSession({
473+
id: 'test-session-id',
474+
segmentId: 0,
475+
lastActivity: now,
476+
started: now,
477+
sampled: 'session',
478+
});
479+
480+
replay['_isPaused'] = true;
481+
482+
const resetReplayIdSpy = vi.spyOn(
483+
resetReplayIdOnDynamicSamplingContextModule,
484+
'resetReplayIdOnDynamicSamplingContext',
485+
);
486+
487+
const errorEvent = Error();
488+
handleGlobalEventListener(replay)(errorEvent, {});
489+
490+
// Should NOT have been called because session is still valid
491+
expect(resetReplayIdSpy).not.toHaveBeenCalled();
492+
});
493+
494+
it('resets replayId on DSC when replay is paused and session exceeds max duration', () => {
495+
const now = Date.now();
496+
497+
replay.session = makeSession({
498+
id: 'test-session-id',
499+
segmentId: 0,
500+
// Recent activity, but session started too long ago
501+
lastActivity: now,
502+
started: now - MAX_REPLAY_DURATION - 1,
503+
sampled: 'session',
504+
});
505+
506+
replay['_isPaused'] = true;
507+
508+
const resetReplayIdSpy = vi.spyOn(
509+
resetReplayIdOnDynamicSamplingContextModule,
510+
'resetReplayIdOnDynamicSamplingContext',
511+
);
512+
513+
const errorEvent = Error();
514+
handleGlobalEventListener(replay)(errorEvent, {});
515+
516+
expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
517+
});
518+
519+
it('resets replayId on DSC when replay is disabled and session has expired', () => {
520+
const now = Date.now();
521+
522+
replay.session = makeSession({
523+
id: 'test-session-id',
524+
segmentId: 0,
525+
lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1,
526+
started: now - SESSION_IDLE_EXPIRE_DURATION - 1,
527+
sampled: 'session',
528+
});
529+
530+
replay['_isEnabled'] = false;
531+
532+
const resetReplayIdSpy = vi.spyOn(
533+
resetReplayIdOnDynamicSamplingContextModule,
534+
'resetReplayIdOnDynamicSamplingContext',
535+
);
536+
537+
const errorEvent = Error();
538+
handleGlobalEventListener(replay)(errorEvent, {});
539+
540+
expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
541+
});
438542
});

packages/replay-internal/test/integration/session.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,57 @@ describe('Integration | session', () => {
438438
);
439439
});
440440

441+
it('updates DSC with new replay_id after session refresh', async () => {
442+
const { getCurrentScope } = await import('@sentry/core');
443+
444+
const initialSession = { ...replay.session } as Session;
445+
446+
// Simulate a cached DSC on the scope (as browserTracingIntegration does
447+
// when the idle span ends) with the old session's replay_id.
448+
const scope = getCurrentScope();
449+
scope.setPropagationContext({
450+
...scope.getPropagationContext(),
451+
dsc: {
452+
trace_id: 'test-trace-id',
453+
public_key: 'test-public-key',
454+
replay_id: initialSession.id,
455+
},
456+
});
457+
458+
// Idle past expiration
459+
const ELAPSED = SESSION_IDLE_EXPIRE_DURATION + 1;
460+
vi.advanceTimersByTime(ELAPSED);
461+
462+
// Emit a recording event to put replay into paused state (mirrors the
463+
// "creates a new session" test which does this before clicking)
464+
const TEST_EVENT = getTestEventIncremental({
465+
data: { name: 'lost event' },
466+
timestamp: BASE_TIMESTAMP,
467+
});
468+
mockRecord._emitter(TEST_EVENT);
469+
await new Promise(process.nextTick);
470+
471+
expect(replay.isPaused()).toBe(true);
472+
473+
// Trigger user activity to cause session refresh
474+
domHandler({
475+
name: 'click',
476+
event: new Event('click'),
477+
});
478+
479+
// _refreshSession is async (calls await stop() then initializeSampling)
480+
await vi.advanceTimersByTimeAsync(DEFAULT_FLUSH_MIN_DELAY);
481+
await new Promise(process.nextTick);
482+
483+
// Should be a new session
484+
expect(replay).not.toHaveSameSession(initialSession);
485+
486+
// The cached DSC should now have the NEW session's replay_id, not the old one
487+
const dsc = scope.getPropagationContext().dsc;
488+
expect(dsc?.replay_id).toBe(replay.session?.id);
489+
expect(dsc?.replay_id).not.toBe(initialSession.id);
490+
});
491+
441492
it('increases segment id after each event', async () => {
442493
clearSession(replay);
443494
replay['_initializeSessionForSampling']();

0 commit comments

Comments
 (0)