diff --git a/.size-limit.js b/.size-limit.js index 5da293511976..8a7670e6d638 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -180,7 +180,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay)', path: createCDNPath('bundle.tracing.replay.min.js'), gzip: true, - limit: '73 KB', + limit: '74 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', diff --git a/dev-packages/browser-integration-tests/suites/replay/dsc/subject.js b/dev-packages/browser-integration-tests/suites/replay/dsc/subject.js new file mode 100644 index 000000000000..01227681eb61 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/dsc/subject.js @@ -0,0 +1,5 @@ +import * as Sentry from '@sentry/browser'; + +window._triggerError = function (errorCount) { + Sentry.captureException(new Error(`This is error #${errorCount}`)); +}; diff --git a/dev-packages/browser-integration-tests/suites/replay/dsc/test.ts b/dev-packages/browser-integration-tests/suites/replay/dsc/test.ts index e46c958497f6..b6bb4da00abb 100644 --- a/dev-packages/browser-integration-tests/suites/replay/dsc/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/dsc/test.ts @@ -3,7 +3,12 @@ import type * as Sentry from '@sentry/browser'; import type { EventEnvelopeHeaders } from '@sentry/types'; import { sentryTest } from '../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../utils/helpers'; +import { + envelopeRequestParser, + shouldSkipTracingTest, + waitForErrorRequest, + waitForTransactionRequest, +} from '../../../utils/helpers'; import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRunning } from '../../../utils/replayHelpers'; type TestWindow = Window & { @@ -216,3 +221,77 @@ sentryTest( }); }, ); + +sentryTest('should add replay_id to error DSC while replay is active', async ({ getLocalTestPath, page }) => { + if (shouldSkipReplayTest()) { + sentryTest.skip(); + } + + const hasTracing = !shouldSkipTracingTest(); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + await page.goto(url); + + const error1Req = waitForErrorRequest(page, event => event.exception?.values?.[0].value === 'This is error #1'); + const error2Req = waitForErrorRequest(page, event => event.exception?.values?.[0].value === 'This is error #2'); + + // We want to wait for the transaction to be done, to ensure we have a consistent test + const transactionReq = hasTracing ? waitForTransactionRequest(page) : Promise.resolve(); + + // Wait for this to be available + await page.waitForFunction('!!window.Replay'); + + // We have to start replay before we finish the transaction, otherwise the DSC will not be frozen with the Replay ID + await page.evaluate('window.Replay.start();'); + await waitForReplayRunning(page); + await transactionReq; + + await page.evaluate('window._triggerError(1)'); + + const error1Header = envelopeRequestParser(await error1Req, 0) as EventEnvelopeHeaders; + const replay = await getReplaySnapshot(page); + + expect(replay.session?.id).toBeDefined(); + + expect(error1Header.trace).toBeDefined(); + expect(error1Header.trace).toEqual({ + environment: 'production', + trace_id: expect.any(String), + public_key: 'public', + replay_id: replay.session?.id, + ...(hasTracing + ? { + sample_rate: '1', + sampled: 'true', + } + : {}), + }); + + // Now end replay and trigger another error, it should not have a replay_id in DSC anymore + await page.evaluate('window.Replay.stop();'); + await page.waitForFunction('!window.Replay.getReplayId();'); + await page.evaluate('window._triggerError(2)'); + + const error2Header = envelopeRequestParser(await error2Req, 0) as EventEnvelopeHeaders; + + expect(error2Header.trace).toBeDefined(); + expect(error2Header.trace).toEqual({ + environment: 'production', + trace_id: expect.any(String), + public_key: 'public', + ...(hasTracing + ? { + sample_rate: '1', + sampled: 'true', + } + : {}), + }); +}); diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 898cef6a67dd..3163ef8d6a60 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -199,7 +199,7 @@ export async function waitForTransactionRequestOnUrl(page: Page, url: string): P return req; } -export function waitForErrorRequest(page: Page): Promise { +export function waitForErrorRequest(page: Page, callback?: (event: Event) => boolean): Promise { return page.waitForRequest(req => { const postData = req.postData(); if (!postData) { @@ -209,7 +209,15 @@ export function waitForErrorRequest(page: Page): Promise { try { const event = envelopeRequestParser(req); - return !event.type; + if (event.type) { + return false; + } + + if (callback) { + return callback(event); + } + + return true; } catch { return false; } diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index ea7df8b7afa7..563938fe4d43 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -53,6 +53,7 @@ import { debounce } from './util/debounce'; import { getHandleRecordingEmit } from './util/handleRecordingEmit'; import { isExpired } from './util/isExpired'; import { isSessionExpired } from './util/isSessionExpired'; +import { resetReplayIdOnDynamicSamplingContext } from './util/resetReplayIdOnDynamicSamplingContext'; import { sendReplay } from './util/sendReplay'; import { RateLimitError } from './util/sendReplayRequest'; import type { SKIPPED } from './util/throttle'; @@ -446,6 +447,8 @@ export class ReplayContainer implements ReplayContainerInterface { try { DEBUG_BUILD && logger.info(`Stopping Replay${reason ? ` triggered by ${reason}` : ''}`); + resetReplayIdOnDynamicSamplingContext(); + this._removeListeners(); this.stopRecording(); diff --git a/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts new file mode 100644 index 000000000000..b38de9e3312d --- /dev/null +++ b/packages/replay-internal/src/util/resetReplayIdOnDynamicSamplingContext.ts @@ -0,0 +1,20 @@ +import { getActiveSpan, getCurrentScope, getDynamicSamplingContextFromSpan } from '@sentry/core'; +import type { DynamicSamplingContext } from '@sentry/types'; + +/** + * Reset the `replay_id` field on the DSC. + */ +export function resetReplayIdOnDynamicSamplingContext(): void { + // Reset DSC on the current scope, if there is one + const dsc = getCurrentScope().getPropagationContext().dsc; + if (dsc) { + delete dsc.replay_id; + } + + // Clear it from frozen DSC on the active span + const activeSpan = getActiveSpan(); + if (activeSpan) { + const dsc = getDynamicSamplingContextFromSpan(activeSpan); + delete (dsc as Partial).replay_id; + } +}