Skip to content

Commit a8ab715

Browse files
authored
feat(replay): Reset replay id from DSC on session expiry/refresh (#20129)
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 7efc03f commit a8ab715

5 files changed

Lines changed: 252 additions & 4 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 { shouldRefreshSession } from '../session/shouldRefreshSession';
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+
// 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. Uses shouldRefreshSession
22+
// instead of isSessionExpired to respect the buffer-mode carve-out:
23+
// buffer sessions with segmentId === 0 are kept alive even when time-expired.
24+
if (
25+
replay.session &&
26+
shouldRefreshSession(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: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ import { debounce } from './util/debounce';
5050
import { getRecordingSamplingOptions } from './util/getRecordingSamplingOptions';
5151
import { getHandleRecordingEmit } from './util/handleRecordingEmit';
5252
import { isExpired } from './util/isExpired';
53-
import { isSessionExpired } from './util/isSessionExpired';
5453
import { debug } from './util/logger';
55-
import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext';
54+
import {
55+
resetReplayIdOnDynamicSamplingContext,
56+
setReplayIdOnDynamicSamplingContext,
57+
} from './util/resetReplayIdOnDynamicSamplingContext';
5658
import { closestElementOfNode } from './util/rrweb';
5759
import { sendReplay } from './util/sendReplay';
5860
import { RateLimitError, ReplayDurationLimitError } from './util/sendReplayRequest';
@@ -876,6 +878,13 @@ export class ReplayContainer implements ReplayContainerInterface {
876878
}
877879

878880
this.startRecording();
881+
882+
// Update the cached DSC with the new replay_id when in session mode.
883+
// The cached DSC on the scope (set by browserTracingIntegration) persists
884+
// across session refreshes, and the `createDsc` hook won't fire for it.
885+
if (this.recordingMode === 'session' && this.session) {
886+
setReplayIdOnDynamicSamplingContext(this.session.id);
887+
}
879888
}
880889

881890
/**
@@ -1001,12 +1010,13 @@ export class ReplayContainer implements ReplayContainerInterface {
10011010
return;
10021011
}
10031012

1004-
const expired = isSessionExpired(this.session, {
1013+
const expired = shouldRefreshSession(this.session, {
10051014
maxReplayDuration: this._options.maxReplayDuration,
10061015
sessionIdleExpire: this.timeouts.sessionIdleExpire,
10071016
});
10081017

10091018
if (expired) {
1019+
resetReplayIdOnDynamicSamplingContext();
10101020
return;
10111021
}
10121022

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: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ 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 { MAX_REPLAY_DURATION, REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants';
1010
import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent';
1111
import type { ReplayContainer } from '../../../src/replay';
1212
import { makeSession } from '../../../src/session/Session';
@@ -435,4 +435,154 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => {
435435

436436
expect(resetReplayIdSpy).toHaveBeenCalledTimes(2);
437437
});
438+
439+
it('resets replayId on DSC when replay is paused and session has expired', () => {
440+
const now = Date.now();
441+
442+
replay.session = makeSession({
443+
id: 'test-session-id',
444+
segmentId: 0,
445+
lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1,
446+
started: now - SESSION_IDLE_EXPIRE_DURATION - 1,
447+
sampled: 'session',
448+
});
449+
450+
replay['_isPaused'] = true;
451+
452+
const resetReplayIdSpy = vi.spyOn(
453+
resetReplayIdOnDynamicSamplingContextModule,
454+
'resetReplayIdOnDynamicSamplingContext',
455+
);
456+
457+
const errorEvent = Error();
458+
handleGlobalEventListener(replay)(errorEvent, {});
459+
460+
// Should have been called even though replay is paused
461+
expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
462+
});
463+
464+
it('does not reset replayId on DSC when replay is paused but session is still valid', () => {
465+
const now = Date.now();
466+
467+
replay.session = makeSession({
468+
id: 'test-session-id',
469+
segmentId: 0,
470+
lastActivity: now,
471+
started: now,
472+
sampled: 'session',
473+
});
474+
475+
replay['_isPaused'] = true;
476+
477+
const resetReplayIdSpy = vi.spyOn(
478+
resetReplayIdOnDynamicSamplingContextModule,
479+
'resetReplayIdOnDynamicSamplingContext',
480+
);
481+
482+
const errorEvent = Error();
483+
handleGlobalEventListener(replay)(errorEvent, {});
484+
485+
// Should NOT have been called because session is still valid
486+
expect(resetReplayIdSpy).not.toHaveBeenCalled();
487+
});
488+
489+
it('resets replayId on DSC when replay is paused and session exceeds max duration', () => {
490+
const now = Date.now();
491+
492+
replay.session = makeSession({
493+
id: 'test-session-id',
494+
segmentId: 0,
495+
// Recent activity, but session started too long ago
496+
lastActivity: now,
497+
started: now - MAX_REPLAY_DURATION - 1,
498+
sampled: 'session',
499+
});
500+
501+
replay['_isPaused'] = true;
502+
503+
const resetReplayIdSpy = vi.spyOn(
504+
resetReplayIdOnDynamicSamplingContextModule,
505+
'resetReplayIdOnDynamicSamplingContext',
506+
);
507+
508+
const errorEvent = Error();
509+
handleGlobalEventListener(replay)(errorEvent, {});
510+
511+
expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
512+
});
513+
514+
it('does not reset replayId on DSC for expired buffer session with segmentId 0', () => {
515+
const now = Date.now();
516+
517+
replay.session = makeSession({
518+
id: 'test-session-id',
519+
segmentId: 0,
520+
lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1,
521+
started: now - SESSION_IDLE_EXPIRE_DURATION - 1,
522+
sampled: 'buffer',
523+
});
524+
525+
replay['_isPaused'] = true;
526+
527+
const resetReplayIdSpy = vi.spyOn(
528+
resetReplayIdOnDynamicSamplingContextModule,
529+
'resetReplayIdOnDynamicSamplingContext',
530+
);
531+
532+
const errorEvent = Error();
533+
handleGlobalEventListener(replay)(errorEvent, {});
534+
535+
// Should NOT reset DSC: buffer sessions with segmentId 0 are kept alive
536+
// even when time-expired (shouldRefreshSession carve-out)
537+
expect(resetReplayIdSpy).not.toHaveBeenCalled();
538+
});
539+
540+
it('resets replayId on DSC for expired buffer session with segmentId > 0', () => {
541+
const now = Date.now();
542+
543+
replay.session = makeSession({
544+
id: 'test-session-id',
545+
segmentId: 1,
546+
lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1,
547+
started: now - SESSION_IDLE_EXPIRE_DURATION - 1,
548+
sampled: 'buffer',
549+
});
550+
551+
replay['_isPaused'] = true;
552+
553+
const resetReplayIdSpy = vi.spyOn(
554+
resetReplayIdOnDynamicSamplingContextModule,
555+
'resetReplayIdOnDynamicSamplingContext',
556+
);
557+
558+
const errorEvent = Error();
559+
handleGlobalEventListener(replay)(errorEvent, {});
560+
561+
// Buffer session with segmentId > 0 that is expired SHOULD have DSC reset
562+
expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
563+
});
564+
565+
it('resets replayId on DSC when replay is disabled and session has expired', () => {
566+
const now = Date.now();
567+
568+
replay.session = makeSession({
569+
id: 'test-session-id',
570+
segmentId: 0,
571+
lastActivity: now - SESSION_IDLE_EXPIRE_DURATION - 1,
572+
started: now - SESSION_IDLE_EXPIRE_DURATION - 1,
573+
sampled: 'session',
574+
});
575+
576+
replay['_isEnabled'] = false;
577+
578+
const resetReplayIdSpy = vi.spyOn(
579+
resetReplayIdOnDynamicSamplingContextModule,
580+
'resetReplayIdOnDynamicSamplingContext',
581+
);
582+
583+
const errorEvent = Error();
584+
handleGlobalEventListener(replay)(errorEvent, {});
585+
586+
expect(resetReplayIdSpy).toHaveBeenCalledTimes(1);
587+
});
438588
});

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)