Skip to content

Commit 1545126

Browse files
nicohrubecclaude
andcommitted
feat(replay): Set sentry.replay_id attribute on streamed spans
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7f2aa3d commit 1545126

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)