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 8167552fb0d6..d53b141acd35 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,84 +20,17 @@ 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), - segment_id: 0, - replay_type: 'session', - event_id: expect.stringMatching(/\w{32}/), - environment: 'production', - 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(replayEvent0).toEqual( + getExpectedReplayEvent({ + segment_id: 0, + }), + ); 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), - segment_id: 1, - replay_type: 'session', - event_id: expect.stringMatching(/\w{32}/), - environment: 'production', - 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).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 f6c3dcf17b23..f4743dc58070 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,84 +20,17 @@ 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), - segment_id: 0, - replay_type: 'session', - event_id: expect.stringMatching(/\w{32}/), - environment: 'production', - 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(replayEvent0).toEqual( + getExpectedReplayEvent({ + segment_id: 0, + }), + ); 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), - segment_id: 1, - replay_type: 'session', - event_id: expect.stringMatching(/\w{32}/), - environment: 'production', - 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).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 53a9e733a908..e0b192437993 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -7,7 +7,8 @@ const DEFAULT_REPLAY_EVENT = { type: 'replay_event', timestamp: expect.any(Number), error_ids: [], - trace_ids: [], + 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/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..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 && replayContext.traceIds.size < 100) { - replayContext.traceIds.add(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 49e8ce092edd..32f3ce85c1a8 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 a2c84d6c4bbe..d08b168fa67e 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -335,7 +335,7 @@ export interface PopEventContext extends CommonEventContext { /** * List of Sentry trace ids that have occurred during a replay segment */ - traceIds: Array; + traceIds: Array<[number, string]>; } /** @@ -348,9 +348,9 @@ export interface InternalEventContext extends CommonEventContext { errorIds: Set; /** - * Set of Sentry trace ids that have occurred during a replay segment + * List of for Sentry traces that have occurred during a replay segment */ - traceIds: Set; + 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 4f40934f37d3..a1fe0b847079 100644 --- a/packages/replay-internal/src/util/sendReplayRequest.ts +++ b/packages/replay-internal/src/util/sendReplayRequest.ts @@ -37,12 +37,16 @@ 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, + trace_ids: uniqueTraceIds, + traces_by_timestamp: traceIds + .filter(([_ts, traceId]) => uniqueTraceIds.includes(traceId)) + .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..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,13 +84,16 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { handler(transaction4, { statusCode: undefined }); expect(Array.from(replay.getContext().errorIds)).toEqual([]); - expect(Array.from(replay.getContext().traceIds)).toEqual(['tr2']); + 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([]); - expect(Array.from(replay.getContext().traceIds)).toEqual(['tr2']); + // Verify traceIds are still there after advancing timers + 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'); @@ -119,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 () => { @@ -141,11 +144,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 () => { 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)]], }); });