diff --git a/packages/replay-internal/src/coreHandlers/handleAfterSegmentSpanEnd.ts b/packages/replay-internal/src/coreHandlers/handleAfterSegmentSpanEnd.ts new file mode 100644 index 000000000000..6d98b94559ec --- /dev/null +++ b/packages/replay-internal/src/coreHandlers/handleAfterSegmentSpanEnd.ts @@ -0,0 +1,19 @@ +import type { Span } from '@sentry/core'; +import { spanIsSampled } from '@sentry/core'; +import type { ReplayContainer } from '../types'; +import { addTraceIdToContext } from './util/addTraceIdToContext'; + +type AfterSegmentSpanEndCallback = (segmentSpan: Span) => void; + +export function handleAfterSegmentSpanEnd(replay: ReplayContainer): AfterSegmentSpanEndCallback { + return (segmentSpan: Span) => { + if (!replay.isEnabled() || !spanIsSampled(segmentSpan)) { + return; + } + + const traceId = segmentSpan.spanContext().traceId; + if (traceId) { + addTraceIdToContext(replay, traceId); + } + }; +} diff --git a/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts b/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts index 4eb89ff95fef..3e78959580f0 100644 --- a/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts @@ -2,6 +2,7 @@ import type { ErrorEvent, Event, TransactionEvent, TransportMakeRequestResponse import { setTimeout } from '@sentry/browser-utils'; import type { ReplayContainer } from '../types'; import { isErrorEvent, isTransactionEvent } from '../util/eventUtils'; +import { addTraceIdToContext } from './util/addTraceIdToContext'; type AfterSendEventCallback = (event: Event, sendResponse: TransportMakeRequestResponse) => void; @@ -32,13 +33,9 @@ export function handleAfterSendEvent(replay: ReplayContainer): AfterSendEventCal } function handleTransactionEvent(replay: ReplayContainer, event: TransactionEvent): void { - const replayContext = replay.getContext(); - - // 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); + const traceId = event.contexts?.trace?.trace_id; + if (traceId) { + addTraceIdToContext(replay, traceId); } } diff --git a/packages/replay-internal/src/coreHandlers/util/addTraceIdToContext.ts b/packages/replay-internal/src/coreHandlers/util/addTraceIdToContext.ts new file mode 100644 index 000000000000..a3302c1ba661 --- /dev/null +++ b/packages/replay-internal/src/coreHandlers/util/addTraceIdToContext.ts @@ -0,0 +1,10 @@ +import type { ReplayContainer } from '../../types'; + +const MAX_TRACE_IDS = 100; + +export function addTraceIdToContext(replay: ReplayContainer, traceId: string): void { + const replayContext = replay.getContext(); + if (replayContext.traceIds.size < MAX_TRACE_IDS) { + replayContext.traceIds.add(traceId); + } +} diff --git a/packages/replay-internal/src/util/addGlobalListeners.ts b/packages/replay-internal/src/util/addGlobalListeners.ts index afd5d780e81b..7ca4fd91f4f1 100644 --- a/packages/replay-internal/src/util/addGlobalListeners.ts +++ b/packages/replay-internal/src/util/addGlobalListeners.ts @@ -1,6 +1,7 @@ import type { DynamicSamplingContext } from '@sentry/core'; import { addEventProcessor, getClient } from '@sentry/core'; import { addClickKeypressInstrumentationHandler, addHistoryInstrumentationHandler } from '@sentry/browser-utils'; +import { handleAfterSegmentSpanEnd } from '../coreHandlers/handleAfterSegmentSpanEnd'; import { handleAfterSendEvent } from '../coreHandlers/handleAfterSendEvent'; import { handleBeforeSendEvent } from '../coreHandlers/handleBeforeSendEvent'; import { handleBreadcrumbs } from '../coreHandlers/handleBreadcrumbs'; @@ -43,6 +44,8 @@ export function addGlobalListeners(replay: ReplayContainer): void { } }); + client.on('afterSegmentSpanEnd', handleAfterSegmentSpanEnd(replay)); + client.on('spanStart', span => { replay.lastActiveSpan = span; }); diff --git a/packages/replay-internal/test/integration/coreHandlers/handleAfterSegmentSpanEnd.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleAfterSegmentSpanEnd.test.ts new file mode 100644 index 000000000000..45c754ce153a --- /dev/null +++ b/packages/replay-internal/test/integration/coreHandlers/handleAfterSegmentSpanEnd.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ + +import type { Span } from '@sentry/core'; +import { getClient } from '@sentry/core'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { ReplayContainer } from '../../../src/replay'; +import { resetSdkMock } from '../../mocks/resetSdkMock'; + +let replay: ReplayContainer; + +describe('Integration | coreHandlers | handleAfterSegmentSpanEnd', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + replay.stop(); + }); + + it('records traceIds from afterSegmentSpanEnd', async () => { + ({ replay } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const client = getClient()!; + + client.emit('afterSegmentSpanEnd', { + spanContext: () => ({ traceId: 'trace-stream-1', spanId: 'span1', traceFlags: 1 }), + } as unknown as Span); + + client.emit('afterSegmentSpanEnd', { + spanContext: () => ({ traceId: 'trace-stream-2', spanId: 'span2', traceFlags: 1 }), + } as unknown as Span); + + expect(Array.from(replay.getContext().traceIds)).toEqual(['trace-stream-1', 'trace-stream-2']); + }); + + it('limits traceIds from afterSegmentSpanEnd to max. 100', async () => { + ({ replay } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const client = getClient()!; + + for (let i = 0; i < 150; i++) { + client.emit('afterSegmentSpanEnd', { + spanContext: () => ({ traceId: `tr-${i}`, spanId: `sp-${i}`, traceFlags: 1 }), + } as unknown as Span); + } + + expect(replay.getContext().traceIds.size).toBe(100); + expect(Array.from(replay.getContext().traceIds)).toEqual( + Array(100) + .fill(undefined) + .map((_, i) => `tr-${i}`), + ); + }); + + it('does not record traceIds from afterSegmentSpanEnd when replay is disabled', async () => { + ({ replay } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const client = getClient()!; + + replay['_isEnabled'] = false; + + client.emit('afterSegmentSpanEnd', { + spanContext: () => ({ traceId: 'trace-stream-1', spanId: 'span1', traceFlags: 1 }), + } as unknown as Span); + + expect(Array.from(replay.getContext().traceIds)).toEqual([]); + }); + + it('does not record traceIds for unsampled spans', async () => { + ({ replay } = await resetSdkMock({ + replayOptions: { + stickySession: false, + }, + sentryOptions: { + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + }, + })); + + const client = getClient()!; + + client.emit('afterSegmentSpanEnd', { + spanContext: () => ({ traceId: 'trace-unsampled', spanId: 'span1', traceFlags: 0 }), + } as unknown as Span); + + expect(Array.from(replay.getContext().traceIds)).toEqual([]); + }); +});