From 9281810b17c6ff2639e636fa12bd88871ab87667 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 8 Apr 2026 16:31:24 -0400 Subject: [PATCH] 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. --- .../src/coreHandlers/handleGlobalEvent.ts | 16 +++ packages/replay-internal/src/replay.ts | 13 ++- .../resetReplayIdOnDynamicSamplingContext.ts | 21 ++++ .../coreHandlers/handleGlobalEvent.test.ts | 106 +++++++++++++++++- .../test/integration/session.test.ts | 51 +++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index b28d4547265e..4b86f7476cdc 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -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'; @@ -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; diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index cab408ca9d5d..5342e318bfb4 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -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'; @@ -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); + } } /** @@ -994,6 +1004,7 @@ export class ReplayContainer implements ReplayContainerInterface { }); if (expired) { + resetReplayIdOnDynamicSamplingContext(); return; } diff --git a/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts index 7d3139aa447d..4839300d7fd2 100644 --- a/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts +++ b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts @@ -18,3 +18,24 @@ export function resetReplayIdOnDynamicSamplingContext(): void { delete (dsc as Partial).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).replay_id = replayId; + } +} diff --git a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts index 956b8a93e72b..17ac9ec14227 100644 --- a/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts +++ b/packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts @@ -6,7 +6,12 @@ import '../../utils/mock-internal-setTimeout'; 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, +} from '../../../src/constants'; import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent'; import type { ReplayContainer } from '../../../src/replay'; import { makeSession } from '../../../src/session/Session'; @@ -435,4 +440,103 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => { 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); + }); }); diff --git a/packages/replay-internal/test/integration/session.test.ts b/packages/replay-internal/test/integration/session.test.ts index f867c43efbe8..1c4b49bb1fad 100644 --- a/packages/replay-internal/test/integration/session.test.ts +++ b/packages/replay-internal/test/integration/session.test.ts @@ -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']();