Skip to content

Commit ac0d888

Browse files
Lms24JPeer264
andauthored
feat(cloudflare,deno,vercel-edge): Add span streaming support (#20127)
This PR adds span streaming support to cloudflare, deno and vercel edge SDKs. Similarly to Node, we check for the `traceLifecycle` option and add `spanStreamingIntegration` based on the option. Also added unit, integration and e2e tests to ensure that the SDKs actually send spans. Similarly to Node and Browser, we'll likely still miss data from event processors but we can follow up with this later. h/t @JPeer264 for the e2e test helpers! I cherry-picked them from #17852 and adjusted them for naming. ref [JS-1010](https://linear.app/getsentry/issue/JS-1010/span-streaming-implementation) #17836 --------- Co-authored-by: Jan Peer Stöcklmair <jan.peer@sentry.io>
1 parent 4fccad5 commit ac0d888

File tree

22 files changed

+1039
-5
lines changed

22 files changed

+1039
-5
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -910,7 +910,7 @@ jobs:
910910
use-installer: true
911911
token: ${{ secrets.GITHUB_TOKEN }}
912912
- name: Set up Deno
913-
if: matrix.test-application == 'deno'
913+
if: matrix.test-application == 'deno' || matrix.test-application == 'deno-streamed'
914914
uses: denoland/setup-deno@v2.0.3
915915
with:
916916
deno-version: v2.1.5
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+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"imports": {
3+
"@sentry/deno": "npm:@sentry/deno",
4+
"@sentry/core": "npm:@sentry/core",
5+
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0",
6+
"ai": "npm:ai@^3.0.0",
7+
"ai/test": "npm:ai@^3.0.0/test",
8+
"zod": "npm:zod@^3.22.4"
9+
},
10+
"nodeModulesDir": "manual"
11+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "deno-streamed-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"start": "deno run --allow-net --allow-env --allow-read src/app.ts",
7+
"test": "playwright test",
8+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
9+
"test:build": "pnpm install",
10+
"test:assert": "pnpm test"
11+
},
12+
"dependencies": {
13+
"@sentry/deno": "latest || *",
14+
"@opentelemetry/api": "^1.9.0",
15+
"ai": "^3.0.0",
16+
"zod": "^3.22.4"
17+
},
18+
"devDependencies": {
19+
"@playwright/test": "~1.56.0",
20+
"@sentry-internal/test-utils": "link:../../../test-utils"
21+
},
22+
"volta": {
23+
"extends": "../../package.json"
24+
}
25+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { getPlaywrightConfig } from '@sentry-internal/test-utils';
2+
3+
const config = getPlaywrightConfig({
4+
startCommand: `pnpm start`,
5+
port: 3030,
6+
});
7+
8+
export default config;

0 commit comments

Comments
 (0)