diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index d80f47a6704b..0e399cc68730 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'; @@ -616,6 +619,7 @@ export class ReplayContainer implements ReplayContainerInterface { this._updateUserActivity(activityTime); this._updateSessionActivity(activityTime); this._maybeSaveSession(); + setReplayIdOnDynamicSamplingContext(this.session.id); } this.startRecording(); diff --git a/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts index 7d3139aa447d..490f712af063 100644 --- a/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts +++ b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts @@ -18,3 +18,21 @@ export function resetReplayIdOnDynamicSamplingContext(): void { delete (dsc as Partial).replay_id; } } + +/** + * Set the `replay_id` field on the cached DSC. + * The cached DSC on the scope (set by browserTracingIntegration) persists across + * session boundaries, and `createDsc` won't fire 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/errorSampleRate.test.ts b/packages/replay-internal/test/integration/errorSampleRate.test.ts index b49882b72034..cf12ff990f00 100644 --- a/packages/replay-internal/test/integration/errorSampleRate.test.ts +++ b/packages/replay-internal/test/integration/errorSampleRate.test.ts @@ -3,7 +3,7 @@ */ import '../utils/mock-internal-setTimeout'; -import { captureException, getClient } from '@sentry/core'; +import { captureException, getClient, getCurrentScope } from '@sentry/core'; import type { MockInstance } from 'vitest'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -383,6 +383,28 @@ describe('Integration | errorSampleRate', () => { }); }); + it('sets replay_id on DSC after converting from buffer to session mode', async () => { + const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); + mockRecord._emitter(TEST_EVENT); + + // Simulate a cached DSC on the scope (as browserTracingIntegration would set) + getCurrentScope().setPropagationContext({ + traceId: '00000000000000000000000000000000', + sampleRand: 0, + dsc: { trace_id: '00000000000000000000000000000000', sampled: 'true' }, + }); + + expect(replay.recordingMode).toBe('buffer'); + const dsc = getCurrentScope().getPropagationContext().dsc!; + expect(dsc.replay_id).toBeUndefined(); + + await replay.sendBufferedReplayOrFlush({ continueRecording: true }); + await vi.advanceTimersToNextTimerAsync(); + + expect(replay.recordingMode).toBe('session'); + expect(dsc.replay_id).toBe(replay.getSessionId()); + }); + // This tests a regression where we were calling flush indiscriminantly in `stop()` it('does not upload a replay event if error is not sampled', async () => { // We are trying to replicate the case where error rate is 0 and session