From 1545126a5e7095aafee8b7ea6c6e01898bf55c16 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 15 May 2026 08:18:30 +0200 Subject: [PATCH] feat(replay): Set `sentry.replay_id` attribute on streamed spans Co-Authored-By: Claude Opus 4.6 (1M context) --- .../suites/replay/span-streaming/init.js | 18 ++++++ .../suites/replay/span-streaming/test.ts | 48 +++++++++++++++ packages/replay-internal/src/integration.ts | 18 +++++- .../test/unit/processSpan.test.ts | 58 +++++++++++++++++++ 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/replay/span-streaming/init.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/span-streaming/test.ts create mode 100644 packages/replay-internal/test/unit/processSpan.test.ts diff --git a/dev-packages/browser-integration-tests/suites/replay/span-streaming/init.js b/dev-packages/browser-integration-tests/suites/replay/span-streaming/init.js new file mode 100644 index 000000000000..63e54914842d --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/span-streaming/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + useCompression: false, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration(), window.Replay], + environment: 'production', + tracesSampleRate: 1, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/span-streaming/test.ts b/dev-packages/browser-integration-tests/suites/replay/span-streaming/test.ts new file mode 100644 index 000000000000..65a459997cae --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/span-streaming/test.ts @@ -0,0 +1,48 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { shouldSkipTracingTest } from '../../../utils/helpers'; +import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRunning } from '../../../utils/replayHelpers'; +import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../utils/spanUtils'; + +sentryTest( + 'should set correct replay data on streamed spans when replay is active', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || shouldSkipTracingTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const envelopePromise = waitForStreamedSpanEnvelope(page, envelope => { + const spans = envelope[1][0][1].items; + return spans.some(s => getSpanOp(s) === 'pageload'); + }); + + await page.goto(url); + await waitForReplayRunning(page); + + const envelope = await envelopePromise; + const replay = await getReplaySnapshot(page); + + expect(replay.session?.id).toBeDefined(); + + const spans = envelope[1][0][1].items; + const dsc = envelope[0].trace; + const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload'); + + expect(pageloadSpan).toBeDefined(); + + // Span attribute: sentry.replay_id + expect(pageloadSpan!.attributes?.['sentry.replay_id']).toEqual({ + type: 'string', + value: replay.session?.id, + }); + + // DSC envelope header: replay_id + expect(dsc).toEqual( + expect.objectContaining({ + replay_id: replay.session?.id, + }), + ); + }, +); diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index ec762eacd8dd..f4b1884f3b83 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -1,5 +1,12 @@ -import type { BrowserClientReplayOptions, Client, Integration, IntegrationFn, ReplayRecordingMode } from '@sentry/core'; -import { consoleSandbox, GLOBAL_OBJ, isBrowser, parseSampleRate } from '@sentry/core'; +import type { + BrowserClientReplayOptions, + Client, + Integration, + IntegrationFn, + ReplayRecordingMode, + StreamedSpanJSON, +} from '@sentry/core'; +import { consoleSandbox, GLOBAL_OBJ, isBrowser, parseSampleRate, safeSetSpanJSONAttributes } from '@sentry/core'; import { DEFAULT_FLUSH_MAX_DELAY, DEFAULT_FLUSH_MIN_DELAY, @@ -352,6 +359,13 @@ export class Replay implements Integration { return this._replay.recordingMode; } + public processSpan(span: StreamedSpanJSON): void { + const replayId = this.getReplayId(true); + if (replayId) { + safeSetSpanJSONAttributes(span, { 'sentry.replay_id': replayId }); + } + } + /** * Initializes replay. */ diff --git a/packages/replay-internal/test/unit/processSpan.test.ts b/packages/replay-internal/test/unit/processSpan.test.ts new file mode 100644 index 000000000000..c4f2264dd0da --- /dev/null +++ b/packages/replay-internal/test/unit/processSpan.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment jsdom + */ + +import type { StreamedSpanJSON } from '@sentry/core'; +import { describe, expect, it, vi } from 'vitest'; +import { Replay } from '../../src/integration'; + +function makeSpanJSON(overrides: Partial = {}): StreamedSpanJSON { + return { + name: 'test-span', + span_id: 'abc123', + trace_id: 'def456', + start_timestamp: 0, + end_timestamp: 1, + status: 'ok', + is_segment: false, + attributes: {}, + ...overrides, + }; +} + +const replay = new Replay(); + +describe('Replay.processSpan', () => { + it('sets sentry.replay_id when replay is active', () => { + vi.spyOn(replay, 'getReplayId').mockReturnValue('abc123sessionid'); + + const span = makeSpanJSON(); + replay.processSpan(span); + + expect(span.attributes).toEqual(expect.objectContaining({ 'sentry.replay_id': 'abc123sessionid' })); + + vi.restoreAllMocks(); + }); + + it('does not set sentry.replay_id when replay is not active', () => { + vi.spyOn(replay, 'getReplayId').mockReturnValue(undefined); + + const span = makeSpanJSON(); + replay.processSpan(span); + + expect(span.attributes).not.toHaveProperty('sentry.replay_id'); + + vi.restoreAllMocks(); + }); + + it('does not overwrite an existing sentry.replay_id attribute', () => { + vi.spyOn(replay, 'getReplayId').mockReturnValue('new-id'); + + const span = makeSpanJSON({ attributes: { 'sentry.replay_id': 'existing-id' } }); + replay.processSpan(span); + + expect(span.attributes!['sentry.replay_id']).toBe('existing-id'); + + vi.restoreAllMocks(); + }); +});