Skip to content

Commit 410b4ee

Browse files
nicohrubecclaude
andauthored
fix(replay): Set sentry.replay_id attribute on streamed spans (#20897)
Sets `sentry.replay_id` as a span attribute on streamed spans via a `processSpan` hook on the Replay integration. This is expected as per https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#common-attribute-keys and wasn't previously set, so replays could not be connected to their traces in the UI when span streaming is active. Fix lives in the replay integration to not impact bundle size for users who don't use session replays. Currently this still doesn't work yet in the product. It seems that the UI relies on `replayId` instead of `sentry.replay_id`, but`sentry.replay_id` is the attribute we defined for SDKs ([conventions](https://getsentry.github.io/sentry-conventions/attributes/sentry/#sentry-replay_id), [dev docs](https://develop.sentry.dev/sdk/telemetry/spans/span-protocol/#common-attribute-keys)). Closes #20880 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 842d167 commit 410b4ee

4 files changed

Lines changed: 140 additions & 2 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = Sentry.replayIntegration({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
useCompression: false,
9+
});
10+
11+
Sentry.init({
12+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
13+
integrations: [Sentry.browserTracingIntegration(), Sentry.spanStreamingIntegration(), window.Replay],
14+
environment: 'production',
15+
tracesSampleRate: 1,
16+
replaysSessionSampleRate: 1.0,
17+
replaysOnErrorSampleRate: 0.0,
18+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect } from '@playwright/test';
2+
import { sentryTest } from '../../../utils/fixtures';
3+
import { shouldSkipTracingTest } from '../../../utils/helpers';
4+
import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRunning } from '../../../utils/replayHelpers';
5+
import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../utils/spanUtils';
6+
7+
sentryTest(
8+
'should set correct replay data on streamed spans when replay is active',
9+
async ({ getLocalTestUrl, page, browserName }) => {
10+
if (shouldSkipReplayTest() || shouldSkipTracingTest() || browserName === 'webkit') {
11+
sentryTest.skip();
12+
}
13+
14+
const url = await getLocalTestUrl({ testDir: __dirname });
15+
16+
const envelopePromise = waitForStreamedSpanEnvelope(page, envelope => {
17+
const spans = envelope[1][0][1].items;
18+
return spans.some(s => getSpanOp(s) === 'pageload');
19+
});
20+
21+
await page.goto(url);
22+
await waitForReplayRunning(page);
23+
24+
const envelope = await envelopePromise;
25+
const replay = await getReplaySnapshot(page);
26+
27+
expect(replay.session?.id).toBeDefined();
28+
29+
const spans = envelope[1][0][1].items;
30+
const dsc = envelope[0].trace;
31+
const pageloadSpan = spans.find(s => getSpanOp(s) === 'pageload');
32+
33+
expect(pageloadSpan).toBeDefined();
34+
35+
// Span attribute: sentry.replay_id
36+
expect(pageloadSpan!.attributes?.['sentry.replay_id']).toEqual({
37+
type: 'string',
38+
value: replay.session?.id,
39+
});
40+
41+
// DSC envelope header: replay_id
42+
expect(dsc).toEqual(
43+
expect.objectContaining({
44+
replay_id: replay.session?.id,
45+
}),
46+
);
47+
},
48+
);

packages/replay-internal/src/integration.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import type { BrowserClientReplayOptions, Client, Integration, IntegrationFn, ReplayRecordingMode } from '@sentry/core';
2-
import { consoleSandbox, GLOBAL_OBJ, isBrowser, parseSampleRate } from '@sentry/core';
1+
import type {
2+
BrowserClientReplayOptions,
3+
Client,
4+
Integration,
5+
IntegrationFn,
6+
ReplayRecordingMode,
7+
StreamedSpanJSON,
8+
} from '@sentry/core';
9+
import { consoleSandbox, GLOBAL_OBJ, isBrowser, parseSampleRate, safeSetSpanJSONAttributes } from '@sentry/core';
310
import {
411
DEFAULT_FLUSH_MAX_DELAY,
512
DEFAULT_FLUSH_MIN_DELAY,
@@ -352,6 +359,13 @@ export class Replay implements Integration {
352359
return this._replay.recordingMode;
353360
}
354361

362+
public processSpan(span: StreamedSpanJSON): void {
363+
const replayId = this.getReplayId(true);
364+
if (replayId) {
365+
safeSetSpanJSONAttributes(span, { 'sentry.replay_id': replayId });
366+
}
367+
}
368+
355369
/**
356370
* Initializes replay.
357371
*/
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import type { StreamedSpanJSON } from '@sentry/core';
6+
import { describe, expect, it, vi } from 'vitest';
7+
import { Replay } from '../../src/integration';
8+
9+
function makeSpanJSON(overrides: Partial<StreamedSpanJSON> = {}): StreamedSpanJSON {
10+
return {
11+
name: 'test-span',
12+
span_id: 'abc123',
13+
trace_id: 'def456',
14+
start_timestamp: 0,
15+
end_timestamp: 1,
16+
status: 'ok',
17+
is_segment: false,
18+
attributes: {},
19+
...overrides,
20+
};
21+
}
22+
23+
const replay = new Replay();
24+
25+
describe('Replay.processSpan', () => {
26+
it('sets sentry.replay_id when replay is active', () => {
27+
vi.spyOn(replay, 'getReplayId').mockReturnValue('abc123sessionid');
28+
29+
const span = makeSpanJSON();
30+
replay.processSpan(span);
31+
32+
expect(span.attributes).toEqual(expect.objectContaining({ 'sentry.replay_id': 'abc123sessionid' }));
33+
34+
vi.restoreAllMocks();
35+
});
36+
37+
it('does not set sentry.replay_id when replay is not active', () => {
38+
vi.spyOn(replay, 'getReplayId').mockReturnValue(undefined);
39+
40+
const span = makeSpanJSON();
41+
replay.processSpan(span);
42+
43+
expect(span.attributes).not.toHaveProperty('sentry.replay_id');
44+
45+
vi.restoreAllMocks();
46+
});
47+
48+
it('does not overwrite an existing sentry.replay_id attribute', () => {
49+
vi.spyOn(replay, 'getReplayId').mockReturnValue('new-id');
50+
51+
const span = makeSpanJSON({ attributes: { 'sentry.replay_id': 'existing-id' } });
52+
replay.processSpan(span);
53+
54+
expect(span.attributes!['sentry.replay_id']).toBe('existing-id');
55+
56+
vi.restoreAllMocks();
57+
});
58+
});

0 commit comments

Comments
 (0)