From 803b1750765fb9f68a786c0a2c16f9c0330e994e Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 28 Oct 2025 18:05:19 -0400 Subject: [PATCH 1/9] feat(replay): Add `traces_by_timestamp` to replay event In order to support moving our custom rrweb events to EAP, we need to send timestamps w/ trace ids so that we can identify which trace the event belongs to. In order to avoid breaking changes, we should not change the current type of trace_ids field in the replay event, instead we add a new field traces_by_timestamp --- packages/core/src/types-hoist/replay.ts | 1 + .../src/coreHandlers/handleAfterSendEvent.ts | 4 ++-- packages/replay-internal/src/types/replay.ts | 6 ++--- .../src/util/sendReplayRequest.ts | 3 ++- .../coreHandlers/handleAfterSendEvent.test.ts | 22 ++++++++++++++++--- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/core/src/types-hoist/replay.ts b/packages/core/src/types-hoist/replay.ts index 65641ce011bd..8527145cb4df 100644 --- a/packages/core/src/types-hoist/replay.ts +++ b/packages/core/src/types-hoist/replay.ts @@ -9,6 +9,7 @@ export interface ReplayEvent extends Event { replay_start_timestamp?: number; error_ids: string[]; trace_ids: string[]; + traces_by_timestamp: [number, string][]; replay_id: string; segment_id: number; replay_type: ReplayRecordingMode; diff --git a/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts b/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts index 4df1b62532ac..e1f4699b92b0 100644 --- a/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts @@ -37,8 +37,8 @@ function handleTransactionEvent(replay: ReplayContainer, event: TransactionEvent // Collect traceIds in _context regardless of `recordingMode` // In error mode, _context gets cleared on every checkout // We limit to max. 100 transactions linked - if (event.contexts?.trace?.trace_id && replayContext.traceIds.size < 100) { - replayContext.traceIds.add(event.contexts.trace.trace_id); + if (event.contexts?.trace?.trace_id && event.timestamp && replayContext.traceIds.size < 100) { + replayContext.traceIds.add([event.timestamp, event.contexts.trace.trace_id]); } } diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 6f8d836611bb..4250fa73b802 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -352,7 +352,7 @@ export interface PopEventContext extends CommonEventContext { /** * List of Sentry trace ids that have occurred during a replay segment */ - traceIds: Array; + traceIds: Array<[number, string]>; } /** @@ -365,9 +365,9 @@ export interface InternalEventContext extends CommonEventContext { errorIds: Set; /** - * Set of Sentry trace ids that have occurred during a replay segment + * Set of [timestamp, trace_id] tuples for Sentry traces that have occurred during a replay segment */ - traceIds: Set; + traceIds: Set<[number, string]>; } export type Sampled = false | 'session' | 'buffer'; diff --git a/packages/replay-internal/src/util/sendReplayRequest.ts b/packages/replay-internal/src/util/sendReplayRequest.ts index 777b3f970712..d9cfc60367b1 100644 --- a/packages/replay-internal/src/util/sendReplayRequest.ts +++ b/packages/replay-internal/src/util/sendReplayRequest.ts @@ -42,7 +42,8 @@ export async function sendReplayRequest({ replay_start_timestamp: initialTimestamp / 1000, timestamp: timestamp / 1000, error_ids: errorIds, - trace_ids: traceIds, + trace_ids: traceIds.map(([_ts, traceId]) => traceId), + traces_by_timestamp: traceIds.map(([ts, traceId]) => [ts, traceId]), urls, replay_id: replayId, segment_id, diff --git a/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts index f45441a34caf..377c7e495907 100644 --- a/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts +++ b/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts @@ -84,13 +84,20 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { handler(transaction4, { statusCode: undefined }); expect(Array.from(replay.getContext().errorIds)).toEqual([]); - expect(Array.from(replay.getContext().traceIds)).toEqual(['tr2']); + // traceIds is now a Set of [timestamp, trace_id] tuples + const traceIds = Array.from(replay.getContext().traceIds); + expect(traceIds).toHaveLength(1); + expect(traceIds[0][1]).toBe('tr2'); + expect(typeof traceIds[0][0]).toBe('number'); // Does not affect error session await vi.advanceTimersToNextTimerAsync(); expect(Array.from(replay.getContext().errorIds)).toEqual([]); - expect(Array.from(replay.getContext().traceIds)).toEqual(['tr2']); + // Verify traceIds are still there after advancing timers + const traceIdsAfter = Array.from(replay.getContext().traceIds); + expect(traceIdsAfter).toHaveLength(1); + expect(traceIdsAfter[0][1]).toBe('tr2'); expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); expect(replay.recordingMode).toBe('buffer'); @@ -141,11 +148,20 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { } expect(Array.from(replay.getContext().errorIds)).toEqual([]); - expect(Array.from(replay.getContext().traceIds)).toEqual( + // traceIds is now a Set of [timestamp, trace_id] tuples + const traceIds = Array.from(replay.getContext().traceIds); + expect(traceIds).toHaveLength(100); + // Check that all trace IDs are present + expect(traceIds.map(([_timestamp, traceId]) => traceId)).toEqual( Array(100) .fill(undefined) .map((_, i) => `tr-${i}`), ); + // Check that all tuples have timestamps + traceIds.forEach(([timestamp, _traceId]) => { + expect(typeof timestamp).toBe('number'); + expect(timestamp).toBeGreaterThan(0); + }); }); it('flushes when in buffer mode', async () => { From 17cb7b40c33967fe1d2980aebcdfa2581eab2fa6 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 29 Oct 2025 12:40:23 -0400 Subject: [PATCH 2/9] fix tests --- .../suites/replay/captureReplay/test.ts | 99 ++----------------- .../captureReplayFromReplayPackage/test.ts | 97 ++---------------- .../utils/replayEventTemplates.ts | 1 + 3 files changed, 14 insertions(+), 183 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts b/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts index ff200ae09869..130ff7f69116 100644 --- a/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test'; -import { SDK_VERSION } from '@sentry/browser'; -import { sentryTest, TEST_HOST } from '../../../utils/fixtures'; +import { sentryTest } from '../../../utils/fixtures'; +import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates'; import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers'; sentryTest('should capture replays (@sentry/browser export)', async ({ getLocalTestUrl, page }) => { @@ -20,98 +20,13 @@ sentryTest('should capture replays (@sentry/browser export)', async ({ getLocalT const replayEvent1 = getReplayEvent(await reqPromise1); expect(replayEvent0).toBeDefined(); - expect(replayEvent0).toEqual({ - type: 'replay_event', - timestamp: expect.any(Number), - error_ids: [], - trace_ids: [], - urls: [`${TEST_HOST}/index.html`], - replay_id: expect.stringMatching(/\w{32}/), - replay_start_timestamp: expect.any(Number), + expect(replayEvent0).toEqual(getExpectedReplayEvent({ segment_id: 0, - replay_type: 'session', - event_id: expect.stringMatching(/\w{32}/), - environment: 'production', - contexts: { - culture: { - locale: expect.any(String), - timezone: expect.any(String), - calendar: expect.any(String), - }, - }, - sdk: { - integrations: expect.arrayContaining([ - 'InboundFilters', - 'FunctionToString', - 'BrowserApiErrors', - 'Breadcrumbs', - 'GlobalHandlers', - 'LinkedErrors', - 'Dedupe', - 'HttpContext', - 'BrowserSession', - 'Replay', - ]), - version: SDK_VERSION, - name: 'sentry.javascript.browser', - settings: { - infer_ip: 'never', - }, - }, - request: { - url: `${TEST_HOST}/index.html`, - headers: { - 'User-Agent': expect.stringContaining(''), - }, - }, - platform: 'javascript', - }); + })); expect(replayEvent1).toBeDefined(); - expect(replayEvent1).toEqual({ - type: 'replay_event', - timestamp: expect.any(Number), - error_ids: [], - trace_ids: [], - urls: [], - replay_id: expect.stringMatching(/\w{32}/), - replay_start_timestamp: expect.any(Number), + expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, - replay_type: 'session', - event_id: expect.stringMatching(/\w{32}/), - environment: 'production', - contexts: { - culture: { - locale: expect.any(String), - timezone: expect.any(String), - calendar: expect.any(String), - }, - }, - sdk: { - integrations: expect.arrayContaining([ - 'InboundFilters', - 'FunctionToString', - 'BrowserApiErrors', - 'Breadcrumbs', - 'GlobalHandlers', - 'LinkedErrors', - 'Dedupe', - 'HttpContext', - 'BrowserSession', - 'Replay', - ]), - version: SDK_VERSION, - name: 'sentry.javascript.browser', - settings: { - infer_ip: 'never', - }, - }, - request: { - url: `${TEST_HOST}/index.html`, - headers: { - 'User-Agent': expect.stringContaining(''), - }, - }, - platform: 'javascript', - }); + urls: [], + })); }); diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts b/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts index f5d50726d437..7669775783a5 100644 --- a/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts @@ -1,6 +1,6 @@ import { expect } from '@playwright/test'; -import { SDK_VERSION } from '@sentry/browser'; -import { sentryTest, TEST_HOST } from '../../../utils/fixtures'; +import { sentryTest } from '../../../utils/fixtures'; +import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates'; import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers'; sentryTest('should capture replays (@sentry-internal/replay export)', async ({ getLocalTestUrl, page }) => { @@ -20,98 +20,13 @@ sentryTest('should capture replays (@sentry-internal/replay export)', async ({ g const replayEvent1 = getReplayEvent(await reqPromise1); expect(replayEvent0).toBeDefined(); - expect(replayEvent0).toEqual({ - type: 'replay_event', - timestamp: expect.any(Number), - error_ids: [], - trace_ids: [], - urls: [`${TEST_HOST}/index.html`], - replay_id: expect.stringMatching(/\w{32}/), - replay_start_timestamp: expect.any(Number), + expect(replayEvent0).toEqual(getExpectedReplayEvent({ segment_id: 0, - replay_type: 'session', - event_id: expect.stringMatching(/\w{32}/), - environment: 'production', - contexts: { - culture: { - locale: expect.any(String), - timezone: expect.any(String), - calendar: expect.any(String), - }, - }, - sdk: { - integrations: expect.arrayContaining([ - 'InboundFilters', - 'FunctionToString', - 'BrowserApiErrors', - 'Breadcrumbs', - 'GlobalHandlers', - 'LinkedErrors', - 'Dedupe', - 'HttpContext', - 'BrowserSession', - 'Replay', - ]), - version: SDK_VERSION, - name: 'sentry.javascript.browser', - settings: { - infer_ip: 'never', - }, - }, - request: { - url: `${TEST_HOST}/index.html`, - headers: { - 'User-Agent': expect.stringContaining(''), - }, - }, - platform: 'javascript', - }); + })); expect(replayEvent1).toBeDefined(); - expect(replayEvent1).toEqual({ - type: 'replay_event', - timestamp: expect.any(Number), - error_ids: [], - trace_ids: [], + expect(replayEvent1).toEqual(getExpectedReplayEvent({ urls: [], - replay_id: expect.stringMatching(/\w{32}/), - replay_start_timestamp: expect.any(Number), segment_id: 1, - replay_type: 'session', - event_id: expect.stringMatching(/\w{32}/), - environment: 'production', - contexts: { - culture: { - locale: expect.any(String), - timezone: expect.any(String), - calendar: expect.any(String), - }, - }, - sdk: { - integrations: expect.arrayContaining([ - 'InboundFilters', - 'FunctionToString', - 'BrowserApiErrors', - 'Breadcrumbs', - 'GlobalHandlers', - 'LinkedErrors', - 'Dedupe', - 'HttpContext', - 'BrowserSession', - 'Replay', - ]), - version: SDK_VERSION, - name: 'sentry.javascript.browser', - settings: { - infer_ip: 'never', - }, - }, - request: { - url: `${TEST_HOST}/index.html`, - headers: { - 'User-Agent': expect.stringContaining(''), - }, - }, - platform: 'javascript', - }); + })); }); diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index b84818738d74..868fbed3dfb6 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -8,6 +8,7 @@ const DEFAULT_REPLAY_EVENT = { timestamp: expect.any(Number), error_ids: [], trace_ids: [], + traces_by_timestamp: [], urls: [expect.stringContaining('/index.html')], replay_id: expect.stringMatching(/\w{32}/), replay_start_timestamp: expect.any(Number), From 483def2fc855d681d07cf3c58fc057bece7c827d Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 29 Oct 2025 16:52:01 -0400 Subject: [PATCH 3/9] keep most recent trace id, use the current trace id if no transaction event --- .../utils/replayEventTemplates.ts | 6 +++-- .../src/coreHandlers/handleAfterSendEvent.ts | 4 +-- packages/replay-internal/src/replay.ts | 25 ++++++++++++++++--- packages/replay-internal/src/types/replay.ts | 4 +-- .../src/util/sendReplayRequest.ts | 5 ++-- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index 868fbed3dfb6..f3f45b059519 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -7,8 +7,10 @@ const DEFAULT_REPLAY_EVENT = { type: 'replay_event', timestamp: expect.any(Number), error_ids: [], - trace_ids: [], - traces_by_timestamp: [], + trace_ids: [expect.any(String)], + traces_by_timestamp: [ + [expect.any(Number), expect.any(String)], + ], urls: [expect.stringContaining('/index.html')], replay_id: expect.stringMatching(/\w{32}/), replay_start_timestamp: expect.any(Number), diff --git a/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts b/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts index e1f4699b92b0..3752457721f9 100644 --- a/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts @@ -37,8 +37,8 @@ function handleTransactionEvent(replay: ReplayContainer, event: TransactionEvent // Collect traceIds in _context regardless of `recordingMode` // In error mode, _context gets cleared on every checkout // We limit to max. 100 transactions linked - if (event.contexts?.trace?.trace_id && event.timestamp && replayContext.traceIds.size < 100) { - replayContext.traceIds.add([event.timestamp, event.contexts.trace.trace_id]); + if (event.contexts?.trace?.trace_id && event.start_timestamp && replayContext.traceIds.length < 100) { + replayContext.traceIds.push([event.start_timestamp, event.contexts.trace.trace_id]); } } diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 10dba8758d8a..eed8858e7b94 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -1,6 +1,13 @@ /* eslint-disable max-lines */ // TODO: We might want to split this file up import type { ReplayRecordingMode, Span } from '@sentry/core'; -import { getActiveSpan, getClient, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON } from '@sentry/core'; +import { + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanToJSON, +} from '@sentry/core'; import { EventType, record } from '@sentry-internal/rrweb'; import { BUFFER_CHECKOUT_TIME, @@ -192,7 +199,7 @@ export class ReplayContainer implements ReplayContainerInterface { this._hasInitializedCoreListeners = false; this._context = { errorIds: new Set(), - traceIds: new Set(), + traceIds: [], urls: [], initialTimestamp: Date.now(), initialUrl: '', @@ -1098,7 +1105,11 @@ export class ReplayContainer implements ReplayContainerInterface { private _clearContext(): void { // XXX: `initialTimestamp` and `initialUrl` do not get cleared this._context.errorIds.clear(); - this._context.traceIds.clear(); + // We want to preserve the most recent trace id for the next replay segment. + // This is so that we can associate replay events w/ the trace. + if (this._context.traceIds.length > 1) { + this._context.traceIds = this._context.traceIds.slice(-1); + } this._context.urls = []; } @@ -1126,11 +1137,17 @@ export class ReplayContainer implements ReplayContainerInterface { * Return and clear _context */ private _popEventContext(): PopEventContext { + if (this._context.traceIds.length === 0) { + const currentTraceId = getCurrentScope().getPropagationContext().traceId; + if (currentTraceId) { + this._context.traceIds.push([-1, currentTraceId]); + } + } const _context = { initialTimestamp: this._context.initialTimestamp, initialUrl: this._context.initialUrl, errorIds: Array.from(this._context.errorIds), - traceIds: Array.from(this._context.traceIds), + traceIds: this._context.traceIds, urls: this._context.urls, }; diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 4250fa73b802..111829176254 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -365,9 +365,9 @@ export interface InternalEventContext extends CommonEventContext { errorIds: Set; /** - * Set of [timestamp, trace_id] tuples for Sentry traces that have occurred during a replay segment + * List of for Sentry traces that have occurred during a replay segment */ - traceIds: Set<[number, string]>; + traceIds: Array<[number, string]>; } export type Sampled = false | 'session' | 'buffer'; diff --git a/packages/replay-internal/src/util/sendReplayRequest.ts b/packages/replay-internal/src/util/sendReplayRequest.ts index d9cfc60367b1..eb796467ae70 100644 --- a/packages/replay-internal/src/util/sendReplayRequest.ts +++ b/packages/replay-internal/src/util/sendReplayRequest.ts @@ -37,13 +37,14 @@ export async function sendReplayRequest({ return Promise.resolve({}); } + const uniqueTraceIds = Array.from(new Set(traceIds.map(([_ts, traceId]) => traceId))); const baseEvent: ReplayEvent = { type: REPLAY_EVENT_NAME, replay_start_timestamp: initialTimestamp / 1000, timestamp: timestamp / 1000, error_ids: errorIds, - trace_ids: traceIds.map(([_ts, traceId]) => traceId), - traces_by_timestamp: traceIds.map(([ts, traceId]) => [ts, traceId]), + trace_ids: uniqueTraceIds, + traces_by_timestamp: traceIds.filter(([_ts, traceId]) => uniqueTraceIds.includes(traceId)).map(([ts, traceId]) => [ts, traceId]), urls, replay_id: replayId, segment_id, From 10c28251e0f8e99ff5143f360cae60f67d496ba4 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 29 Oct 2025 17:02:32 -0400 Subject: [PATCH 4/9] format --- .../suites/replay/captureReplay/test.ts | 18 +++++++++++------- .../captureReplayFromReplayPackage/test.ts | 18 +++++++++++------- .../utils/replayEventTemplates.ts | 4 +--- .../src/util/sendReplayRequest.ts | 4 +++- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts b/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts index 130ff7f69116..d53b141acd35 100644 --- a/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts @@ -20,13 +20,17 @@ sentryTest('should capture replays (@sentry/browser export)', async ({ getLocalT const replayEvent1 = getReplayEvent(await reqPromise1); expect(replayEvent0).toBeDefined(); - expect(replayEvent0).toEqual(getExpectedReplayEvent({ - segment_id: 0, - })); + expect(replayEvent0).toEqual( + getExpectedReplayEvent({ + segment_id: 0, + }), + ); expect(replayEvent1).toBeDefined(); - expect(replayEvent1).toEqual(getExpectedReplayEvent({ - segment_id: 1, - urls: [], - })); + expect(replayEvent1).toEqual( + getExpectedReplayEvent({ + segment_id: 1, + urls: [], + }), + ); }); diff --git a/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts b/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts index 7669775783a5..f4743dc58070 100644 --- a/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts @@ -20,13 +20,17 @@ sentryTest('should capture replays (@sentry-internal/replay export)', async ({ g const replayEvent1 = getReplayEvent(await reqPromise1); expect(replayEvent0).toBeDefined(); - expect(replayEvent0).toEqual(getExpectedReplayEvent({ - segment_id: 0, - })); + expect(replayEvent0).toEqual( + getExpectedReplayEvent({ + segment_id: 0, + }), + ); expect(replayEvent1).toBeDefined(); - expect(replayEvent1).toEqual(getExpectedReplayEvent({ - urls: [], - segment_id: 1, - })); + expect(replayEvent1).toEqual( + getExpectedReplayEvent({ + urls: [], + segment_id: 1, + }), + ); }); diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index f3f45b059519..05cb9cdd3257 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -8,9 +8,7 @@ const DEFAULT_REPLAY_EVENT = { timestamp: expect.any(Number), error_ids: [], trace_ids: [expect.any(String)], - traces_by_timestamp: [ - [expect.any(Number), expect.any(String)], - ], + traces_by_timestamp: [[expect.any(Number), expect.any(String)]], urls: [expect.stringContaining('/index.html')], replay_id: expect.stringMatching(/\w{32}/), replay_start_timestamp: expect.any(Number), diff --git a/packages/replay-internal/src/util/sendReplayRequest.ts b/packages/replay-internal/src/util/sendReplayRequest.ts index eb796467ae70..f3df39080c34 100644 --- a/packages/replay-internal/src/util/sendReplayRequest.ts +++ b/packages/replay-internal/src/util/sendReplayRequest.ts @@ -44,7 +44,9 @@ export async function sendReplayRequest({ timestamp: timestamp / 1000, error_ids: errorIds, trace_ids: uniqueTraceIds, - traces_by_timestamp: traceIds.filter(([_ts, traceId]) => uniqueTraceIds.includes(traceId)).map(([ts, traceId]) => [ts, traceId]), + traces_by_timestamp: traceIds + .filter(([_ts, traceId]) => uniqueTraceIds.includes(traceId)) + .map(([ts, traceId]) => [ts, traceId]), urls, replay_id: replayId, segment_id, From 6ffdce09b2c6119404a3fd2c62a905bed0312988 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 29 Oct 2025 17:36:56 -0400 Subject: [PATCH 5/9] fix integration tests --- .../coreHandlers/handleAfterSendEvent.test.ts | 16 ++++++---------- .../test/integration/errorSampleRate.test.ts | 6 ++++-- .../test/integration/sampling.test.ts | 4 ++-- .../test/integration/sendReplayEvent.test.ts | 3 ++- .../test/integration/session.test.ts | 2 +- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts index 377c7e495907..879049ddc9cd 100644 --- a/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts +++ b/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts @@ -53,7 +53,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { handler(error4, { statusCode: undefined }); expect(Array.from(replay.getContext().errorIds)).toEqual(['err2']); - expect(Array.from(replay.getContext().traceIds)).toEqual([]); + expect(Array.from(replay.getContext().traceIds)).toEqual([[-1, expect.any(String)]]); }); it('records traceIds from sent transaction events', async () => { @@ -84,20 +84,16 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { handler(transaction4, { statusCode: undefined }); expect(Array.from(replay.getContext().errorIds)).toEqual([]); - // traceIds is now a Set of [timestamp, trace_id] tuples - const traceIds = Array.from(replay.getContext().traceIds); - expect(traceIds).toHaveLength(1); - expect(traceIds[0][1]).toBe('tr2'); - expect(typeof traceIds[0][0]).toBe('number'); + const traceIds = replay.getContext().traceIds; + expect(traceIds).toEqual([[expect.any(Number), 'tr2']]); // Does not affect error session await vi.advanceTimersToNextTimerAsync(); expect(Array.from(replay.getContext().errorIds)).toEqual([]); // Verify traceIds are still there after advancing timers - const traceIdsAfter = Array.from(replay.getContext().traceIds); - expect(traceIdsAfter).toHaveLength(1); - expect(traceIdsAfter[0][1]).toBe('tr2'); + const traceIdsAfter = replay.getContext().traceIds; + expect(traceIdsAfter).toEqual([[expect.any(Number), 'tr2']]); expect(replay.isEnabled()).toBe(true); expect(replay.isPaused()).toBe(false); expect(replay.recordingMode).toBe('buffer'); @@ -126,7 +122,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { .fill(undefined) .map((_, i) => `err-${i}`), ); - expect(Array.from(replay.getContext().traceIds)).toEqual([]); + expect(replay.getContext().traceIds).toEqual([[-1, expect.any(String)]]); }); it('limits traceIds to max. 100', async () => { diff --git a/packages/replay-internal/test/integration/errorSampleRate.test.ts b/packages/replay-internal/test/integration/errorSampleRate.test.ts index b49882b72034..397064b9a7f4 100644 --- a/packages/replay-internal/test/integration/errorSampleRate.test.ts +++ b/packages/replay-internal/test/integration/errorSampleRate.test.ts @@ -722,7 +722,8 @@ describe('Integration | errorSampleRate', () => { replay_start_timestamp: BASE_TIMESTAMP / 1000, timestamp: (BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + DEFAULT_FLUSH_MIN_DELAY) / 1000, error_ids: [expect.any(String)], - trace_ids: [], + trace_ids: [expect.any(String)], + traces_by_timestamp: [[-1, expect.any(String)]], urls: ['http://localhost:3000/'], replay_id: expect.any(String), }), @@ -930,7 +931,8 @@ describe('Integration | errorSampleRate', () => { replayEventPayload: expect.objectContaining({ replay_start_timestamp: (BASE_TIMESTAMP + stepDuration * steps) / 1000, error_ids: [expect.any(String)], - trace_ids: [], + trace_ids: [expect.any(String)], + traces_by_timestamp: [[-1, expect.any(String)]], urls: ['http://localhost:3000/'], replay_id: expect.any(String), }), diff --git a/packages/replay-internal/test/integration/sampling.test.ts b/packages/replay-internal/test/integration/sampling.test.ts index 9ffa00c349c6..2feb4a77db67 100644 --- a/packages/replay-internal/test/integration/sampling.test.ts +++ b/packages/replay-internal/test/integration/sampling.test.ts @@ -37,7 +37,7 @@ describe('Integration | sampling', () => { // This is what the `_context` member is initialized with expect(replay.getContext()).toEqual({ errorIds: new Set(), - traceIds: new Set(), + traceIds: [], urls: [], initialTimestamp: expect.any(Number), initialUrl: '', @@ -78,7 +78,7 @@ describe('Integration | sampling', () => { errorIds: new Set(), initialTimestamp: expect.any(Number), initialUrl: 'http://localhost:3000/', - traceIds: new Set(), + traceIds: [], urls: ['http://localhost:3000/'], }); expect(replay.recordingMode).toBe('buffer'); diff --git a/packages/replay-internal/test/integration/sendReplayEvent.test.ts b/packages/replay-internal/test/integration/sendReplayEvent.test.ts index 1e870a8f577b..a24dd1de8ffe 100644 --- a/packages/replay-internal/test/integration/sendReplayEvent.test.ts +++ b/packages/replay-internal/test/integration/sendReplayEvent.test.ts @@ -340,7 +340,8 @@ describe('Integration | sendReplayEvent', () => { replay_start_timestamp: BASE_TIMESTAMP / 1000, // timestamp is set on first try, after 5s flush timestamp: (BASE_TIMESTAMP + 5000) / 1000, - trace_ids: [], + trace_ids: [expect.any(String)], + traces_by_timestamp: [[-1, expect.any(String)]], urls: ['http://localhost:3000/'], }), recordingPayloadHeader: { segment_id: 0 }, diff --git a/packages/replay-internal/test/integration/session.test.ts b/packages/replay-internal/test/integration/session.test.ts index f867c43efbe8..3c4644959210 100644 --- a/packages/replay-internal/test/integration/session.test.ts +++ b/packages/replay-internal/test/integration/session.test.ts @@ -252,7 +252,7 @@ describe('Integration | session', () => { initialTimestamp: newTimestamp - DEFAULT_FLUSH_MIN_DELAY, urls: [], errorIds: new Set(), - traceIds: new Set(), + traceIds: [[-1, expect.any(String)]], }); }); From 5a8c6b3744da6f0655ead9218a638e1563cce31e Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 5 Feb 2026 13:56:33 -0500 Subject: [PATCH 6/9] address code review --- packages/core/src/types-hoist/replay.ts | 1 + packages/replay-internal/src/replay.ts | 11 ++++++++++- .../replay-internal/src/util/sendReplayRequest.ts | 4 +--- .../coreHandlers/handleAfterSendEvent.test.ts | 4 ++-- .../test/integration/errorSampleRate.test.ts | 4 ++-- .../test/integration/sendReplayEvent.test.ts | 2 +- .../replay-internal/test/integration/session.test.ts | 2 +- 7 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/core/src/types-hoist/replay.ts b/packages/core/src/types-hoist/replay.ts index 8527145cb4df..6ed9ce01a0ac 100644 --- a/packages/core/src/types-hoist/replay.ts +++ b/packages/core/src/types-hoist/replay.ts @@ -9,6 +9,7 @@ export interface ReplayEvent extends Event { replay_start_timestamp?: number; error_ids: string[]; trace_ids: string[]; + // Data expected is [timestamp (seconds), trace id] traces_by_timestamp: [number, string][]; replay_id: string; segment_id: number; diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index eed8858e7b94..d61d23cb9403 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -1140,7 +1140,16 @@ export class ReplayContainer implements ReplayContainerInterface { if (this._context.traceIds.length === 0) { const currentTraceId = getCurrentScope().getPropagationContext().traceId; if (currentTraceId) { - this._context.traceIds.push([-1, currentTraceId]); + // Previously, in order to associate a replay with a trace, sdk waits + // until after a `type: transaction` event is sent successfully. it's + // possible that the replay integration is loaded after this event is + // sent and never gets associated with any trace. in this case, use the + // trace from current scope and propagation context. We associate the + // current trace w/ the earliest timestamp in event buffer. + // + // This is in seconds to be consistent with how we normally collect + // trace ids from the SDK hook event + this._context.traceIds.push([this._context.initialTimestamp / 1000, currentTraceId]); } } const _context = { diff --git a/packages/replay-internal/src/util/sendReplayRequest.ts b/packages/replay-internal/src/util/sendReplayRequest.ts index f3df39080c34..dcd5b27438be 100644 --- a/packages/replay-internal/src/util/sendReplayRequest.ts +++ b/packages/replay-internal/src/util/sendReplayRequest.ts @@ -44,9 +44,7 @@ export async function sendReplayRequest({ timestamp: timestamp / 1000, error_ids: errorIds, trace_ids: uniqueTraceIds, - traces_by_timestamp: traceIds - .filter(([_ts, traceId]) => uniqueTraceIds.includes(traceId)) - .map(([ts, traceId]) => [ts, traceId]), + traces_by_timestamp: traceIds.map(([ts, traceId]) => [ts, traceId]), urls, replay_id: replayId, segment_id, diff --git a/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts index 879049ddc9cd..cacad3e1ed43 100644 --- a/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts +++ b/packages/replay-internal/test/integration/coreHandlers/handleAfterSendEvent.test.ts @@ -53,7 +53,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { handler(error4, { statusCode: undefined }); expect(Array.from(replay.getContext().errorIds)).toEqual(['err2']); - expect(Array.from(replay.getContext().traceIds)).toEqual([[-1, expect.any(String)]]); + expect(Array.from(replay.getContext().traceIds)).toEqual([[expect.any(Number), expect.any(String)]]); }); it('records traceIds from sent transaction events', async () => { @@ -122,7 +122,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { .fill(undefined) .map((_, i) => `err-${i}`), ); - expect(replay.getContext().traceIds).toEqual([[-1, expect.any(String)]]); + expect(replay.getContext().traceIds).toEqual([[expect.any(Number), expect.any(String)]]); }); it('limits traceIds to max. 100', async () => { diff --git a/packages/replay-internal/test/integration/errorSampleRate.test.ts b/packages/replay-internal/test/integration/errorSampleRate.test.ts index 397064b9a7f4..a84a1e63bf8f 100644 --- a/packages/replay-internal/test/integration/errorSampleRate.test.ts +++ b/packages/replay-internal/test/integration/errorSampleRate.test.ts @@ -723,7 +723,7 @@ describe('Integration | errorSampleRate', () => { timestamp: (BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + DEFAULT_FLUSH_MIN_DELAY) / 1000, error_ids: [expect.any(String)], trace_ids: [expect.any(String)], - traces_by_timestamp: [[-1, expect.any(String)]], + traces_by_timestamp: [[expect.any(Number), expect.any(String)]], urls: ['http://localhost:3000/'], replay_id: expect.any(String), }), @@ -932,7 +932,7 @@ describe('Integration | errorSampleRate', () => { replay_start_timestamp: (BASE_TIMESTAMP + stepDuration * steps) / 1000, error_ids: [expect.any(String)], trace_ids: [expect.any(String)], - traces_by_timestamp: [[-1, expect.any(String)]], + traces_by_timestamp: [[expect.any(Number), expect.any(String)]], urls: ['http://localhost:3000/'], replay_id: expect.any(String), }), diff --git a/packages/replay-internal/test/integration/sendReplayEvent.test.ts b/packages/replay-internal/test/integration/sendReplayEvent.test.ts index a24dd1de8ffe..f1023e9c0923 100644 --- a/packages/replay-internal/test/integration/sendReplayEvent.test.ts +++ b/packages/replay-internal/test/integration/sendReplayEvent.test.ts @@ -341,7 +341,7 @@ describe('Integration | sendReplayEvent', () => { // timestamp is set on first try, after 5s flush timestamp: (BASE_TIMESTAMP + 5000) / 1000, trace_ids: [expect.any(String)], - traces_by_timestamp: [[-1, expect.any(String)]], + traces_by_timestamp: [[expect.any(Number), expect.any(String)]], urls: ['http://localhost:3000/'], }), recordingPayloadHeader: { segment_id: 0 }, diff --git a/packages/replay-internal/test/integration/session.test.ts b/packages/replay-internal/test/integration/session.test.ts index 3c4644959210..204e149e7cec 100644 --- a/packages/replay-internal/test/integration/session.test.ts +++ b/packages/replay-internal/test/integration/session.test.ts @@ -252,7 +252,7 @@ describe('Integration | session', () => { initialTimestamp: newTimestamp - DEFAULT_FLUSH_MIN_DELAY, urls: [], errorIds: new Set(), - traceIds: [[-1, expect.any(String)]], + traceIds: [[expect.any(Number), expect.any(String)]], }); }); From f50a1cb1f85699f2a8ea6ac170d7752d29211986 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 5 Feb 2026 16:29:51 -0500 Subject: [PATCH 7/9] add more tests --- .../test/integration/traceIds.test.ts | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 packages/replay-internal/test/integration/traceIds.test.ts diff --git a/packages/replay-internal/test/integration/traceIds.test.ts b/packages/replay-internal/test/integration/traceIds.test.ts new file mode 100644 index 000000000000..919590184f51 --- /dev/null +++ b/packages/replay-internal/test/integration/traceIds.test.ts @@ -0,0 +1,304 @@ +/** + * @vitest-environment jsdom + */ + +import '../utils/mock-internal-setTimeout'; +import { getCurrentScope } from '@sentry/core'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { handleAfterSendEvent } from '../../src/coreHandlers/handleAfterSendEvent'; +import type { ReplayContainer } from '../../src/replay'; +import { Transaction } from '../fixtures/transaction'; +import { BASE_TIMESTAMP } from '../index'; +import type { RecordMock } from '../mocks/mockRrweb'; +import { resetSdkMock } from '../mocks/resetSdkMock'; +import { getTestEventIncremental } from '../utils/getTestEvent'; + +let replay: ReplayContainer; +let mockRecord: RecordMock; + +describe('Integration | traceIds', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + replay.stop(); + }); + + it('preserves the most recent trace id across flushes', async () => { + ({ replay, mockRecord } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const handler = handleAfterSendEvent(replay); + + // After initial flush from resetSdkMock, the context has a propagation + // context trace ID. Clear it for a clean test. + replay['_context'].traceIds = []; + + // Simulate 3 transaction events with known trace IDs + handler(Transaction('trace-aaa'), { statusCode: 200 }); + handler(Transaction('trace-bbb'), { statusCode: 200 }); + handler(Transaction('trace-ccc'), { statusCode: 200 }); + + expect(replay.getContext().traceIds).toHaveLength(3); + + // Emit a recording event so the event buffer is not empty (flush needs events) + mockRecord._emitter(getTestEventIncremental({ timestamp: BASE_TIMESTAMP })); + + // Trigger a flush by advancing timers + await vi.advanceTimersToNextTimerAsync(); + + // After the flush, _clearContext should preserve only the most recent trace id + expect(replay.getContext().traceIds).toHaveLength(1); + expect(replay.getContext().traceIds[0]![1]).toBe('trace-ccc'); + + // Verify the sent replay event contained all 3 trace IDs + expect(replay).toHaveLastSentReplay({ + replayEventPayload: expect.objectContaining({ + trace_ids: ['trace-aaa', 'trace-bbb', 'trace-ccc'], + traces_by_timestamp: [ + [expect.any(Number), 'trace-aaa'], + [expect.any(Number), 'trace-bbb'], + [expect.any(Number), 'trace-ccc'], + ], + }), + }); + }); + + it('carries over last trace id to next segment', async () => { + ({ replay, mockRecord } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const handler = handleAfterSendEvent(replay); + + // Clear initial propagation context trace for clean test + replay['_context'].traceIds = []; + + // Add two trace IDs + handler(Transaction('trace-first'), { statusCode: 200 }); + handler(Transaction('trace-second'), { statusCode: 200 }); + + // Emit a recording event so the buffer has events for the flush + mockRecord._emitter(getTestEventIncremental({ timestamp: BASE_TIMESTAMP })); + + // Flush segment 1 + await vi.advanceTimersToNextTimerAsync(); + + // After flush, last trace id should carry over + expect(replay.getContext().traceIds).toHaveLength(1); + expect(replay.getContext().traceIds[0]![1]).toBe('trace-second'); + + // Add a new trace ID for the next segment + handler(Transaction('trace-third'), { statusCode: 200 }); + + // Emit another recording event for the next flush + mockRecord._emitter(getTestEventIncremental({ timestamp: BASE_TIMESTAMP + 5000 })); + + // Flush segment 2 + await vi.advanceTimersToNextTimerAsync(); + + // The second segment should include the carried-over trace-second plus the new trace-third + expect(replay).toHaveLastSentReplay({ + replayEventPayload: expect.objectContaining({ + trace_ids: ['trace-second', 'trace-third'], + traces_by_timestamp: [ + [expect.any(Number), 'trace-second'], + [expect.any(Number), 'trace-third'], + ], + }), + }); + }); + + it('falls back to propagation context trace id when no transaction events are captured', async () => { + ({ replay, mockRecord } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + // Clear initial trace IDs + replay['_context'].traceIds = []; + + // Set a known trace ID on the current scope's propagation context + const knownTraceId = 'abc123def456abc123def456abc123de'; + getCurrentScope().setPropagationContext({ + traceId: knownTraceId, + sampleRand: 1, + }); + + // Emit a recording event so the buffer has events for the flush + mockRecord._emitter(getTestEventIncremental({ timestamp: BASE_TIMESTAMP })); + + // Flush without sending any transaction events + await vi.advanceTimersToNextTimerAsync(); + + // The replay event should contain the propagation context trace ID + expect(replay).toHaveLastSentReplay({ + replayEventPayload: expect.objectContaining({ + trace_ids: [knownTraceId], + traces_by_timestamp: [[expect.any(Number), knownTraceId]], + }), + }); + }); + + it('does not use propagation context fallback when transaction trace ids exist', async () => { + ({ replay, mockRecord } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const handler = handleAfterSendEvent(replay); + + // Clear initial trace IDs + replay['_context'].traceIds = []; + + // Set a known propagation context trace ID that should NOT appear + getCurrentScope().setPropagationContext({ + traceId: 'propagation00000000000000000000', + sampleRand: 1, + }); + + // Send a transaction event + handler(Transaction('actual-trace-id'), { statusCode: 200 }); + + // Emit a recording event so the buffer has events for the flush + mockRecord._emitter(getTestEventIncremental({ timestamp: BASE_TIMESTAMP })); + + // Flush + await vi.advanceTimersToNextTimerAsync(); + + // The replay event should only contain the transaction trace ID, not propagation context + expect(replay).toHaveLastSentReplay({ + replayEventPayload: expect.objectContaining({ + trace_ids: ['actual-trace-id'], + traces_by_timestamp: [[expect.any(Number), 'actual-trace-id']], + }), + }); + }); + + it('deduplicates trace_ids but preserves all entries in traces_by_timestamp', async () => { + ({ replay, mockRecord } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const handler = handleAfterSendEvent(replay); + + // Clear initial trace IDs + replay['_context'].traceIds = []; + + // Send multiple transactions with the same trace ID + handler(Transaction('same-trace-id'), { statusCode: 200 }); + handler(Transaction('same-trace-id'), { statusCode: 200 }); + handler(Transaction('different-trace-id'), { statusCode: 200 }); + + // Emit a recording event so the buffer has events for the flush + mockRecord._emitter(getTestEventIncremental({ timestamp: BASE_TIMESTAMP })); + + // Flush + await vi.advanceTimersToNextTimerAsync(); + + // trace_ids should be deduplicated, but traces_by_timestamp should have all entries + expect(replay).toHaveLastSentReplay({ + replayEventPayload: expect.objectContaining({ + trace_ids: ['same-trace-id', 'different-trace-id'], + traces_by_timestamp: [ + [expect.any(Number), 'same-trace-id'], + [expect.any(Number), 'same-trace-id'], + [expect.any(Number), 'different-trace-id'], + ], + }), + }); + }); + + it('skips transaction events without start_timestamp', async () => { + ({ replay } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const handler = handleAfterSendEvent(replay); + + // Clear initial trace IDs + replay['_context'].traceIds = []; + + // Create a transaction without start_timestamp + const transactionWithoutTimestamp = Transaction('trace-no-ts'); + delete transactionWithoutTimestamp.start_timestamp; + + handler(transactionWithoutTimestamp, { statusCode: 200 }); + + // Also send a valid transaction + handler(Transaction('trace-valid'), { statusCode: 200 }); + + // Only the valid transaction should be recorded + const traceIds = replay.getContext().traceIds; + expect(traceIds).toHaveLength(1); + expect(traceIds[0]![1]).toBe('trace-valid'); + }); + + it('preserves single trace id in _clearContext when only one exists', async () => { + ({ replay, mockRecord } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const handler = handleAfterSendEvent(replay); + + // Clear initial trace IDs + replay['_context'].traceIds = []; + + // Add a single transaction + handler(Transaction('only-trace'), { statusCode: 200 }); + + expect(replay.getContext().traceIds).toHaveLength(1); + + // Emit a recording event so the buffer has events for the flush + mockRecord._emitter(getTestEventIncremental({ timestamp: BASE_TIMESTAMP })); + + // Flush + await vi.advanceTimersToNextTimerAsync(); + + // With only 1 trace id, _clearContext should preserve it (length <= 1, no slicing needed) + expect(replay.getContext().traceIds).toHaveLength(1); + expect(replay.getContext().traceIds[0]![1]).toBe('only-trace'); + }); +}); From 31a8e9ae90fd9c173114e00a75ec918d829919be Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 18 Feb 2026 22:01:30 +0100 Subject: [PATCH 8/9] bump bundle size limits --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 4f86a9f8a2ea..a6de2f4faccd 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -214,7 +214,7 @@ module.exports = [ name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics)', path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: true, - limit: '81 KB', + limit: '82 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Feedback)', @@ -269,7 +269,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '245 KB', + limit: '246 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed', From f47fdc3ed3f2c5eb8dce20b4f36a889f5f20aa10 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 25 Feb 2026 15:26:30 +0100 Subject: [PATCH 9/9] chore: Bump size limits for replay bundles --- .size-limit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 5f38b492a302..4bb70a54998f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -82,7 +82,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '86 KB', + limit: '87 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', @@ -262,7 +262,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '209 KB', + limit: '210 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',