Skip to content

Commit bcc15b1

Browse files
committed
feat(cloudflare,deno,vercel-edge): Add span streaming support
1 parent 53f5d9c commit bcc15b1

File tree

7 files changed

+353
-3
lines changed

7 files changed

+353
-3
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
}
6+
7+
export default Sentry.withSentry(
8+
(env: Env) => ({
9+
dsn: env.SENTRY_DSN,
10+
tracesSampleRate: 1.0,
11+
traceLifecycle: 'stream',
12+
release: '1.0.0',
13+
}),
14+
{
15+
async fetch(_request, _env, _ctx) {
16+
Sentry.startSpan({ name: 'test-span', op: 'test' }, segmentSpan => {
17+
Sentry.startSpan({ name: 'test-child-span', op: 'test-child' }, () => {
18+
// noop
19+
});
20+
21+
const inactiveSpan = Sentry.startInactiveSpan({ name: 'test-inactive-span' });
22+
inactiveSpan.addLink({
23+
context: segmentSpan.spanContext(),
24+
attributes: { 'sentry.link.type': 'some_relation' },
25+
});
26+
inactiveSpan.end();
27+
28+
Sentry.startSpanManual({ name: 'test-manual-span' }, span => {
29+
span.end();
30+
});
31+
});
32+
33+
return new Response('OK');
34+
},
35+
},
36+
);
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core';
2+
import {
3+
SDK_VERSION,
4+
SEMANTIC_ATTRIBUTE_SENTRY_OP,
5+
SEMANTIC_ATTRIBUTE_SENTRY_RELEASE,
6+
SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME,
7+
SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION,
8+
SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID,
9+
SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME,
10+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
11+
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
12+
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
13+
} from '@sentry/core';
14+
import { expect, it } from 'vitest';
15+
import { createRunner } from '../../../runner';
16+
17+
const CLOUDFLARE_SDK = 'sentry.javascript.cloudflare';
18+
19+
function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer {
20+
const spanItem = envelope[1].find(item => item[0].type === 'span');
21+
expect(spanItem).toBeDefined();
22+
return spanItem![1] as SerializedStreamedSpanContainer;
23+
}
24+
25+
it('sends a streamed span envelope with correct envelope header', async ({ signal }) => {
26+
const runner = createRunner(__dirname)
27+
.expect(envelope => {
28+
expect(getSpanContainer(envelope).items.length).toBeGreaterThan(0);
29+
30+
expect(envelope[0]).toEqual(
31+
expect.objectContaining({
32+
sent_at: expect.any(String),
33+
sdk: {
34+
name: CLOUDFLARE_SDK,
35+
version: SDK_VERSION,
36+
},
37+
trace: expect.objectContaining({
38+
public_key: 'public',
39+
sample_rate: '1',
40+
sampled: 'true',
41+
trace_id: expect.stringMatching(/^[\da-f]{32}$/),
42+
}),
43+
}),
44+
);
45+
})
46+
.start(signal);
47+
48+
await runner.makeRequest('get', '/');
49+
await runner.completed();
50+
});
51+
52+
it('sends a streamed span envelope with correct spans for a manually started span with children', async ({
53+
signal,
54+
}) => {
55+
const runner = createRunner(__dirname)
56+
.expect(envelope => {
57+
const container = getSpanContainer(envelope);
58+
const spans = container.items;
59+
60+
// Cloudflare `withSentry` wraps fetch in an http.server span (segment) around the scenario.
61+
expect(spans.length).toBe(5);
62+
63+
const segmentSpan = spans.find(s => !!s.is_segment);
64+
expect(segmentSpan).toBeDefined();
65+
66+
const segmentSpanId = segmentSpan!.span_id;
67+
const traceId = segmentSpan!.trace_id;
68+
const segmentName = segmentSpan!.name;
69+
70+
const parentTestSpan = spans.find(s => s.name === 'test-span');
71+
expect(parentTestSpan).toBeDefined();
72+
expect(parentTestSpan!.parent_span_id).toBe(segmentSpanId);
73+
74+
const childSpan = spans.find(s => s.name === 'test-child-span');
75+
expect(childSpan).toBeDefined();
76+
expect(childSpan).toEqual({
77+
attributes: {
78+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: {
79+
type: 'string',
80+
value: 'test-child',
81+
},
82+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK },
83+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION },
84+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId },
85+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName },
86+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' },
87+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' },
88+
},
89+
name: 'test-child-span',
90+
is_segment: false,
91+
parent_span_id: parentTestSpan!.span_id,
92+
trace_id: traceId,
93+
span_id: expect.stringMatching(/^[\da-f]{16}$/),
94+
start_timestamp: expect.any(Number),
95+
end_timestamp: expect.any(Number),
96+
status: 'ok',
97+
});
98+
99+
const inactiveSpan = spans.find(s => s.name === 'test-inactive-span');
100+
expect(inactiveSpan).toBeDefined();
101+
expect(inactiveSpan).toEqual({
102+
attributes: {
103+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' },
104+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK },
105+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION },
106+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId },
107+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName },
108+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' },
109+
},
110+
links: [
111+
{
112+
attributes: {
113+
'sentry.link.type': {
114+
type: 'string',
115+
value: 'some_relation',
116+
},
117+
},
118+
sampled: true,
119+
span_id: parentTestSpan!.span_id,
120+
trace_id: traceId,
121+
},
122+
],
123+
name: 'test-inactive-span',
124+
is_segment: false,
125+
parent_span_id: parentTestSpan!.span_id,
126+
trace_id: traceId,
127+
span_id: expect.stringMatching(/^[\da-f]{16}$/),
128+
start_timestamp: expect.any(Number),
129+
end_timestamp: expect.any(Number),
130+
status: 'ok',
131+
});
132+
133+
const manualSpan = spans.find(s => s.name === 'test-manual-span');
134+
expect(manualSpan).toBeDefined();
135+
expect(manualSpan).toEqual({
136+
attributes: {
137+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' },
138+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK },
139+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION },
140+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId },
141+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName },
142+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' },
143+
},
144+
name: 'test-manual-span',
145+
is_segment: false,
146+
parent_span_id: parentTestSpan!.span_id,
147+
trace_id: traceId,
148+
span_id: expect.stringMatching(/^[\da-f]{16}$/),
149+
start_timestamp: expect.any(Number),
150+
end_timestamp: expect.any(Number),
151+
status: 'ok',
152+
});
153+
154+
expect(parentTestSpan).toEqual({
155+
attributes: {
156+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'test' },
157+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK },
158+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION },
159+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId },
160+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName },
161+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' },
162+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' },
163+
},
164+
name: 'test-span',
165+
is_segment: false,
166+
parent_span_id: segmentSpanId,
167+
trace_id: traceId,
168+
span_id: parentTestSpan!.span_id,
169+
start_timestamp: expect.any(Number),
170+
end_timestamp: expect.any(Number),
171+
status: 'ok',
172+
});
173+
174+
expect(segmentSpan).toEqual({
175+
attributes: {
176+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK },
177+
[SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION },
178+
[SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' },
179+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' },
180+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId },
181+
[SEMANTIC_ATTRIBUTE_SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName },
182+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.server' },
183+
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 },
184+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'route' },
185+
'sentry.span.source': { type: 'string', value: 'route' },
186+
'server.address': {
187+
type: 'string',
188+
value: 'localhost',
189+
},
190+
'url.full': {
191+
type: 'string',
192+
value: expect.stringMatching(/^http:\/\/localhost:.+$/),
193+
},
194+
'url.path': {
195+
type: 'string',
196+
value: '/',
197+
},
198+
'url.port': {
199+
type: 'string',
200+
value: '8787',
201+
},
202+
'url.scheme': {
203+
type: 'string',
204+
value: 'http:',
205+
},
206+
'user_agent.original': {
207+
type: 'string',
208+
value: 'node',
209+
},
210+
'http.request.header.accept': {
211+
type: 'string',
212+
value: '*/*',
213+
},
214+
'http.request.header.accept_encoding': {
215+
type: 'string',
216+
value: 'br, gzip',
217+
},
218+
'http.request.header.accept_language': {
219+
type: 'string',
220+
value: '*',
221+
},
222+
'http.request.header.cf_connecting_ip': {
223+
type: 'string',
224+
value: '::1',
225+
},
226+
'http.request.header.host': {
227+
type: 'string',
228+
value: expect.stringMatching(/^localhost:.+$/),
229+
},
230+
'http.request.header.sec_fetch_mode': {
231+
type: 'string',
232+
value: 'cors',
233+
},
234+
'http.request.header.user_agent': {
235+
type: 'string',
236+
value: 'node',
237+
},
238+
'http.request.method': {
239+
type: 'string',
240+
value: 'GET',
241+
},
242+
'http.response.status_code': {
243+
type: 'integer',
244+
value: 200,
245+
},
246+
'network.protocol.name': {
247+
type: 'string',
248+
value: 'HTTP/1.1',
249+
},
250+
},
251+
is_segment: true,
252+
trace_id: traceId,
253+
span_id: segmentSpanId,
254+
start_timestamp: expect.any(Number),
255+
end_timestamp: expect.any(Number),
256+
status: 'ok',
257+
name: 'GET /',
258+
});
259+
})
260+
.start(signal);
261+
262+
await runner.makeRequest('get', '/');
263+
await runner.completed();
264+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "start-span-streamed",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_compat"],
6+
}

packages/cloudflare/src/sdk.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
initAndBind,
1010
linkedErrorsIntegration,
1111
requestDataIntegration,
12+
spanStreamingIntegration,
1213
stackParserFromStackParserOptions,
1314
} from '@sentry/core';
1415
import type { CloudflareClientOptions, CloudflareOptions } from './client';
@@ -52,10 +53,15 @@ export function init(options: CloudflareOptions): CloudflareClient | undefined {
5253
const flushLock = options.ctx ? makeFlushLock(options.ctx) : undefined;
5354
delete options.ctx;
5455

56+
const resolvedIntegrations = getIntegrationsToSetup(options);
57+
if (options.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) {
58+
resolvedIntegrations.push(spanStreamingIntegration());
59+
}
60+
5561
const clientOptions: CloudflareClientOptions = {
5662
...options,
5763
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
58-
integrations: getIntegrationsToSetup(options),
64+
integrations: resolvedIntegrations,
5965
transport: options.transport || makeCloudflareTransport,
6066
flushLock,
6167
};

packages/cloudflare/test/sdk.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as SentryCore from '@sentry/core';
2+
import { getClient } from '@sentry/core';
23
import { beforeEach, describe, expect, test, vi } from 'vitest';
34
import { CloudflareClient } from '../src/client';
45
import { init } from '../src/sdk';
@@ -18,4 +19,29 @@ describe('init', () => {
1819
expect(client).toBeDefined();
1920
expect(client).toBeInstanceOf(CloudflareClient);
2021
});
22+
23+
test('installs SpanStreaming integration when traceLifecycle is "stream"', () => {
24+
init({
25+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
26+
traceLifecycle: 'stream',
27+
});
28+
const client = getClient();
29+
30+
expect(client?.getOptions()).toEqual(
31+
expect.objectContaining({
32+
integrations: expect.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]),
33+
}),
34+
);
35+
});
36+
37+
test("does not install SpanStreaming integration when traceLifecycle is not 'stream'", () => {
38+
init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' });
39+
const client = getClient();
40+
41+
expect(client?.getOptions()).toEqual(
42+
expect.objectContaining({
43+
integrations: expect.not.arrayContaining([expect.objectContaining({ name: 'SpanStreaming' })]),
44+
}),
45+
);
46+
});
2147
});

packages/deno/src/sdk.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
linkedErrorsIntegration,
1010
nodeStackLineParser,
1111
requestDataIntegration,
12+
spanStreamingIntegration,
1213
stackParserFromStackParserOptions,
1314
} from '@sentry/core';
1415
import { DenoClient } from './client';
@@ -95,10 +96,15 @@ export function init(options: DenoOptions = {}): Client {
9596
options.defaultIntegrations = getDefaultIntegrations(options);
9697
}
9798

99+
const resolvedIntegrations = getIntegrationsToSetup(options);
100+
if (options.traceLifecycle === 'stream' && !resolvedIntegrations.some(i => i.name === 'SpanStreaming')) {
101+
resolvedIntegrations.push(spanStreamingIntegration());
102+
}
103+
98104
const clientOptions: ServerRuntimeClientOptions = {
99105
...options,
100106
stackParser: stackParserFromStackParserOptions(options.stackParser || defaultStackParser),
101-
integrations: getIntegrationsToSetup(options),
107+
integrations: resolvedIntegrations,
102108
transport: options.transport || makeFetchTransport,
103109
};
104110

0 commit comments

Comments
 (0)