From c0f2daae00001a1cf8879aef0760a8b7087a1e4b Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Mon, 29 Jun 2026 11:45:07 -0400 Subject: [PATCH 1/2] feat(replays): Record segment names that occur during replay --- packages/core/src/types/replay.ts | 1 + .../coreHandlers/handleAfterSegmentSpanEnd.ts | 19 ----- .../src/coreHandlers/handleAfterSendEvent.ts | 6 +- .../coreHandlers/handleProcessSegmentSpan.ts | 19 +++++ .../util/addSegmentDetailsToContext.ts | 13 ++++ .../coreHandlers/util/addTraceIdToContext.ts | 10 --- packages/replay-internal/src/replay.ts | 3 + packages/replay-internal/src/types/replay.ts | 10 +++ .../src/util/addGlobalListeners.ts | 4 +- .../src/util/sendReplayRequest.ts | 3 +- ...st.ts => handleProcessSegmentSpan.test.ts} | 76 +++++++++++++------ .../test/integration/sampling.test.ts | 2 + .../test/integration/session.test.ts | 1 + 13 files changed, 110 insertions(+), 57 deletions(-) delete mode 100644 packages/replay-internal/src/coreHandlers/handleAfterSegmentSpanEnd.ts create mode 100644 packages/replay-internal/src/coreHandlers/handleProcessSegmentSpan.ts create mode 100644 packages/replay-internal/src/coreHandlers/util/addSegmentDetailsToContext.ts delete mode 100644 packages/replay-internal/src/coreHandlers/util/addTraceIdToContext.ts rename packages/replay-internal/test/integration/coreHandlers/{handleAfterSegmentSpanEnd.test.ts => handleProcessSegmentSpan.test.ts} (51%) diff --git a/packages/core/src/types/replay.ts b/packages/core/src/types/replay.ts index a23f548aa357..862be2ff07ef 100644 --- a/packages/core/src/types/replay.ts +++ b/packages/core/src/types/replay.ts @@ -9,6 +9,7 @@ export interface ReplayEvent extends Event { replay_start_timestamp?: number; error_ids: string[]; trace_ids: string[]; + segment_names: string[]; replay_id: string; segment_id: number; replay_type: ReplayRecordingMode; diff --git a/packages/replay-internal/src/coreHandlers/handleAfterSegmentSpanEnd.ts b/packages/replay-internal/src/coreHandlers/handleAfterSegmentSpanEnd.ts deleted file mode 100644 index 6d98b94559ec..000000000000 --- a/packages/replay-internal/src/coreHandlers/handleAfterSegmentSpanEnd.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 3e78959580f0..d7247dd47d92 100644 --- a/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts @@ -2,7 +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'; +import { addSegmentDetailsToContext } from './util/addSegmentDetailsToContext'; type AfterSendEventCallback = (event: Event, sendResponse: TransportMakeRequestResponse) => void; @@ -34,8 +34,8 @@ export function handleAfterSendEvent(replay: ReplayContainer): AfterSendEventCal function handleTransactionEvent(replay: ReplayContainer, event: TransactionEvent): void { const traceId = event.contexts?.trace?.trace_id; - if (traceId) { - addTraceIdToContext(replay, traceId); + if (traceId && event.transaction) { + addSegmentDetailsToContext(replay, traceId, event.transaction); } } diff --git a/packages/replay-internal/src/coreHandlers/handleProcessSegmentSpan.ts b/packages/replay-internal/src/coreHandlers/handleProcessSegmentSpan.ts new file mode 100644 index 000000000000..f6ad31b8c5f2 --- /dev/null +++ b/packages/replay-internal/src/coreHandlers/handleProcessSegmentSpan.ts @@ -0,0 +1,19 @@ +import type { StreamedSpanJSON } from '@sentry/core'; +import type { ReplayContainer } from '../types'; +import { addSegmentDetailsToContext } from './util/addSegmentDetailsToContext'; + +type ProcessSegmentSpanCallback = (spanJSON: StreamedSpanJSON) => void; + +export function handleProcessSegmentSpan(replay: ReplayContainer): ProcessSegmentSpanCallback { + return (spanJSON: StreamedSpanJSON) => { + if (!replay.isEnabled()) { + return; + } + + const traceId = spanJSON.trace_id; + const segmentName = spanJSON.name; + if (traceId && segmentName) { + addSegmentDetailsToContext(replay, traceId, segmentName); + } + }; +} diff --git a/packages/replay-internal/src/coreHandlers/util/addSegmentDetailsToContext.ts b/packages/replay-internal/src/coreHandlers/util/addSegmentDetailsToContext.ts new file mode 100644 index 000000000000..cb5b1a3f2d0e --- /dev/null +++ b/packages/replay-internal/src/coreHandlers/util/addSegmentDetailsToContext.ts @@ -0,0 +1,13 @@ +import type { ReplayContainer } from '../../types'; + +const MAX_CONTEXT_VALUES = 100; + +export function addSegmentDetailsToContext(replay: ReplayContainer, traceId: string, segmentName: string): void { + const replayContext = replay.getContext(); + if (replayContext.traceIds.size < MAX_CONTEXT_VALUES) { + replayContext.traceIds.add(traceId); + } + if (replayContext.segmentNames.size < MAX_CONTEXT_VALUES) { + replayContext.segmentNames.add(segmentName); + } +} diff --git a/packages/replay-internal/src/coreHandlers/util/addTraceIdToContext.ts b/packages/replay-internal/src/coreHandlers/util/addTraceIdToContext.ts deleted file mode 100644 index a3302c1ba661..000000000000 --- a/packages/replay-internal/src/coreHandlers/util/addTraceIdToContext.ts +++ /dev/null @@ -1,10 +0,0 @@ -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/replay.ts b/packages/replay-internal/src/replay.ts index 947d0485ed3d..439ae124f4a5 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -195,6 +195,7 @@ export class ReplayContainer implements ReplayContainerInterface { this._context = { errorIds: new Set(), traceIds: new Set(), + segmentNames: new Set(), urls: [], initialTimestamp: Date.now(), initialUrl: '', @@ -1122,6 +1123,7 @@ export class ReplayContainer implements ReplayContainerInterface { // XXX: `initialTimestamp` and `initialUrl` do not get cleared this._context.errorIds.clear(); this._context.traceIds.clear(); + this._context.segmentNames.clear(); this._context.urls = []; } @@ -1154,6 +1156,7 @@ export class ReplayContainer implements ReplayContainerInterface { initialUrl: this._context.initialUrl, errorIds: Array.from(this._context.errorIds), traceIds: Array.from(this._context.traceIds), + segmentNames: Array.from(this._context.segmentNames), urls: this._context.urls, }; diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 95cfbdd849bf..955393c2f225 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -360,6 +360,11 @@ export interface PopEventContext extends CommonEventContext { * List of Sentry trace ids that have occurred during a replay segment */ traceIds: Array; + + /** + * List of Sentry segment names that have occurred during a replay segment + */ + segmentNames: Array; } /** @@ -375,6 +380,11 @@ export interface InternalEventContext extends CommonEventContext { * Set of Sentry trace ids that have occurred during a replay segment */ traceIds: Set; + + /** + * Set of Sentry segment names that have occurred during a replay segment + */ + segmentNames: Set; } export type Sampled = false | 'session' | 'buffer'; diff --git a/packages/replay-internal/src/util/addGlobalListeners.ts b/packages/replay-internal/src/util/addGlobalListeners.ts index 7ca4fd91f4f1..ac4d228f8976 100644 --- a/packages/replay-internal/src/util/addGlobalListeners.ts +++ b/packages/replay-internal/src/util/addGlobalListeners.ts @@ -1,7 +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 { handleProcessSegmentSpan } from '../coreHandlers/handleProcessSegmentSpan'; import { handleAfterSendEvent } from '../coreHandlers/handleAfterSendEvent'; import { handleBeforeSendEvent } from '../coreHandlers/handleBeforeSendEvent'; import { handleBreadcrumbs } from '../coreHandlers/handleBreadcrumbs'; @@ -44,7 +44,7 @@ export function addGlobalListeners(replay: ReplayContainer): void { } }); - client.on('afterSegmentSpanEnd', handleAfterSegmentSpanEnd(replay)); + client.on('processSegmentSpan', handleProcessSegmentSpan(replay)); client.on('spanStart', span => { replay.lastActiveSpan = span; diff --git a/packages/replay-internal/src/util/sendReplayRequest.ts b/packages/replay-internal/src/util/sendReplayRequest.ts index 777b3f970712..6dea2a9821eb 100644 --- a/packages/replay-internal/src/util/sendReplayRequest.ts +++ b/packages/replay-internal/src/util/sendReplayRequest.ts @@ -26,7 +26,7 @@ export async function sendReplayRequest({ }, }); - const { urls, errorIds, traceIds, initialTimestamp } = eventContext; + const { urls, errorIds, traceIds, segmentNames, initialTimestamp } = eventContext; const client = getClient(); const scope = getCurrentScope(); @@ -43,6 +43,7 @@ export async function sendReplayRequest({ timestamp: timestamp / 1000, error_ids: errorIds, trace_ids: traceIds, + segment_names: segmentNames, urls, replay_id: replayId, segment_id, diff --git a/packages/replay-internal/test/integration/coreHandlers/handleAfterSegmentSpanEnd.test.ts b/packages/replay-internal/test/integration/coreHandlers/handleProcessSegmentSpan.test.ts similarity index 51% rename from packages/replay-internal/test/integration/coreHandlers/handleAfterSegmentSpanEnd.test.ts rename to packages/replay-internal/test/integration/coreHandlers/handleProcessSegmentSpan.test.ts index 45c754ce153a..88108784c961 100644 --- a/packages/replay-internal/test/integration/coreHandlers/handleAfterSegmentSpanEnd.test.ts +++ b/packages/replay-internal/test/integration/coreHandlers/handleProcessSegmentSpan.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ -import type { Span } from '@sentry/core'; +import type { StreamedSpanJSON } from '@sentry/core'; import { getClient } from '@sentry/core'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import type { ReplayContainer } from '../../../src/replay'; @@ -10,7 +10,7 @@ import { resetSdkMock } from '../../mocks/resetSdkMock'; let replay: ReplayContainer; -describe('Integration | coreHandlers | handleAfterSegmentSpanEnd', () => { +describe('Integration | coreHandlers | handleProcessSegmentSpan', () => { beforeAll(() => { vi.useFakeTimers(); }); @@ -19,7 +19,7 @@ describe('Integration | coreHandlers | handleAfterSegmentSpanEnd', () => { replay.stop(); }); - it('records traceIds from afterSegmentSpanEnd', async () => { + it('records traceIds and segment names from processSegmentSpan', async () => { ({ replay } = await resetSdkMock({ replayOptions: { stickySession: false, @@ -32,18 +32,31 @@ describe('Integration | coreHandlers | handleAfterSegmentSpanEnd', () => { 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); + client.emit('processSegmentSpan', { + trace_id: 'trace-stream-1', + span_id: 'span1', + name: 'GET /api/users', + is_segment: true, + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + } as StreamedSpanJSON); + + client.emit('processSegmentSpan', { + trace_id: 'trace-stream-2', + span_id: 'span2', + name: 'POST /api/items', + is_segment: true, + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + } as StreamedSpanJSON); expect(Array.from(replay.getContext().traceIds)).toEqual(['trace-stream-1', 'trace-stream-2']); + expect(Array.from(replay.getContext().segmentNames)).toEqual(['GET /api/users', 'POST /api/items']); }); - it('limits traceIds from afterSegmentSpanEnd to max. 100', async () => { + it('limits traceIds from processSegmentSpan to max. 100', async () => { ({ replay } = await resetSdkMock({ replayOptions: { stickySession: false, @@ -57,9 +70,15 @@ describe('Integration | coreHandlers | handleAfterSegmentSpanEnd', () => { 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); + client.emit('processSegmentSpan', { + trace_id: `tr-${i}`, + span_id: `sp-${i}`, + name: `segment-${i}`, + is_segment: true, + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + } as StreamedSpanJSON); } expect(replay.getContext().traceIds.size).toBe(100); @@ -70,7 +89,7 @@ describe('Integration | coreHandlers | handleAfterSegmentSpanEnd', () => { ); }); - it('does not record traceIds from afterSegmentSpanEnd when replay is disabled', async () => { + it('does not record traceIds from processSegmentSpan when replay is disabled', async () => { ({ replay } = await resetSdkMock({ replayOptions: { stickySession: false, @@ -85,14 +104,20 @@ describe('Integration | coreHandlers | handleAfterSegmentSpanEnd', () => { replay['_isEnabled'] = false; - client.emit('afterSegmentSpanEnd', { - spanContext: () => ({ traceId: 'trace-stream-1', spanId: 'span1', traceFlags: 1 }), - } as unknown as Span); + client.emit('processSegmentSpan', { + trace_id: 'trace-stream-1', + span_id: 'span1', + name: 'GET /api/users', + is_segment: true, + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + } as StreamedSpanJSON); expect(Array.from(replay.getContext().traceIds)).toEqual([]); }); - it('does not record traceIds for unsampled spans', async () => { + it('does not record segment spans with empty names', async () => { ({ replay } = await resetSdkMock({ replayOptions: { stickySession: false, @@ -105,10 +130,17 @@ describe('Integration | coreHandlers | handleAfterSegmentSpanEnd', () => { const client = getClient()!; - client.emit('afterSegmentSpanEnd', { - spanContext: () => ({ traceId: 'trace-unsampled', spanId: 'span1', traceFlags: 0 }), - } as unknown as Span); + client.emit('processSegmentSpan', { + trace_id: 'trace-stream-1', + span_id: 'span1', + name: '', + is_segment: true, + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + } as StreamedSpanJSON); expect(Array.from(replay.getContext().traceIds)).toEqual([]); + expect(Array.from(replay.getContext().segmentNames)).toEqual([]); }); }); diff --git a/packages/replay-internal/test/integration/sampling.test.ts b/packages/replay-internal/test/integration/sampling.test.ts index 9ffa00c349c6..a86bf54b924a 100644 --- a/packages/replay-internal/test/integration/sampling.test.ts +++ b/packages/replay-internal/test/integration/sampling.test.ts @@ -38,6 +38,7 @@ describe('Integration | sampling', () => { expect(replay.getContext()).toEqual({ errorIds: new Set(), traceIds: new Set(), + segmentNames: new Set(), urls: [], initialTimestamp: expect.any(Number), initialUrl: '', @@ -79,6 +80,7 @@ describe('Integration | sampling', () => { initialTimestamp: expect.any(Number), initialUrl: 'http://localhost:3000/', traceIds: new Set(), + segmentNames: new Set(), urls: ['http://localhost:3000/'], }); expect(replay.recordingMode).toBe('buffer'); diff --git a/packages/replay-internal/test/integration/session.test.ts b/packages/replay-internal/test/integration/session.test.ts index 1c4b49bb1fad..31322dc4a3f8 100644 --- a/packages/replay-internal/test/integration/session.test.ts +++ b/packages/replay-internal/test/integration/session.test.ts @@ -253,6 +253,7 @@ describe('Integration | session', () => { urls: [], errorIds: new Set(), traceIds: new Set(), + segmentNames: new Set(), }); }); From 8f45b6360a8bc27ec4bcb76fc9a2a1291ad96dc9 Mon Sep 17 00:00:00 2001 From: Matt Quinn Date: Mon, 29 Jun 2026 14:44:03 -0400 Subject: [PATCH 2/2] fix integration tests --- .../suites/replay/captureReplay/test.ts | 2 ++ .../suites/replay/captureReplayFromReplayPackage/test.ts | 2 ++ .../browser-integration-tests/utils/replayEventTemplates.ts | 1 + 3 files changed, 5 insertions(+) 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..92634cf92b5a 100644 --- a/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/captureReplay/test.ts @@ -29,6 +29,7 @@ sentryTest('should capture replays (@sentry/browser export)', async ({ getLocalT replay_id: expect.stringMatching(/\w{32}/), replay_start_timestamp: expect.any(Number), segment_id: 0, + segment_names: [], replay_type: 'session', event_id: expect.stringMatching(/\w{32}/), environment: 'production', @@ -77,6 +78,7 @@ sentryTest('should capture replays (@sentry/browser export)', async ({ getLocalT replay_id: expect.stringMatching(/\w{32}/), replay_start_timestamp: expect.any(Number), segment_id: 1, + segment_names: [], replay_type: 'session', event_id: expect.stringMatching(/\w{32}/), environment: 'production', 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 3a312bdb0e2a..05b419bf9636 100644 --- a/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts @@ -29,6 +29,7 @@ sentryTest('should capture replays (@sentry/replay export)', async ({ getLocalTe replay_id: expect.stringMatching(/\w{32}/), replay_start_timestamp: expect.any(Number), segment_id: 0, + segment_names: [], replay_type: 'session', event_id: expect.stringMatching(/\w{32}/), environment: 'production', @@ -77,6 +78,7 @@ sentryTest('should capture replays (@sentry/replay export)', async ({ getLocalTe replay_id: expect.stringMatching(/\w{32}/), replay_start_timestamp: expect.any(Number), segment_id: 1, + segment_names: [], replay_type: 'session', event_id: expect.stringMatching(/\w{32}/), environment: 'production', diff --git a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts index b84818738d74..6f6ba2136ea8 100644 --- a/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/dev-packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -12,6 +12,7 @@ const DEFAULT_REPLAY_EVENT = { replay_id: expect.stringMatching(/\w{32}/), replay_start_timestamp: expect.any(Number), segment_id: 0, + segment_names: [], replay_type: 'session', event_id: expect.stringMatching(/\w{32}/), environment: 'production',