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']();