Skip to content

Commit 1693d50

Browse files
JPeer264claude
andauthored
test(cloudflare): Port the binding suites to span streaming (#24190)
Ports the binding suites off the `traceLifecycle: 'static'` pin: `d1`, `r2`, `queue`, `prisma`, `durableobject/error`, `workflows/step-context` and `vite/diagnostics-channel/vercelai-6`. `durableobject/error` and `workflows/step-context` only assert on error events, so the pin is all that goes. `cache-client` and `durableobject-scope` were already unpinned and keep ignoring spans, so they are untouched. Most binding spans keep the shape they had and only the encoding changes, so the r2 and queue suites keep one envelope expectation per request. Two things do change. D1 and Prisma spans carry the `db.query` op, so streaming names them after `db.query.summary`: `SELECT * FROM users WHERE id = ?` becomes `SELECT users`. The Prisma suite loses the description-based split between its two `SELECT` spans, which now share the name `SELECT main.User`. The D1 one is picked by its op instead, and the traceparent comment that used to be matched on the description is asserted on `db.query.text`. `prisma` and `vercelai-6` switch to `collectStreamedSpansUntilSegment`. Both assert on the complete child set of one request and the span buffer flushes on a timer, so reading a single envelope would be a race. `vercelai-6` also drops the separate span container it used to read next to the transaction item: a streamed gen_ai span is an ordinary item of the one span envelope. `/` keeps its `GET /` name under streaming (the source is `route`, not `url`), so `vercelai-6` waits on that rather than the bare method the raw-URL suites see. Part of #24148 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ce8aead commit 1693d50

14 files changed

Lines changed: 346 additions & 486 deletions

File tree

‎dev-packages/cloudflare-integration-tests/suites/cache-client/test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ it('cacheClient: true - detached work events ARE captured', async ({ signal }) =
110110
});
111111

112112
it('cacheClient: false - repro #22545: detached work events are silently dropped', async ({ signal }) => {
113-
const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal);
113+
const runner = createRunner(__dirname).ignore('span').start(signal);
114114

115115
// Make the request that spawns detached work
116116
await runner.makeRequest('get', '/no-cache/detached?id=repro-1');
@@ -135,7 +135,7 @@ it('cacheClient: true - dedupe drops the same error across invocations', async (
135135
// A shared client shares its dedupe state, so the same error captured by two separate
136136
// invocations is reported only once — the second is dropped as a duplicate.
137137
const runner = createRunner(__dirname)
138-
.ignore('transaction', 'span')
138+
.ignore('span')
139139
.unordered()
140140
.failOnUnexpected()
141141
.expect(errorEventExpectation('Same error', CAPTURE_MECHANISM))
@@ -159,7 +159,7 @@ it('cacheClient: true - dedupe drops the same error across invocations', async (
159159
it('cacheClient: false - dedupe does not persist across invocations', async ({ signal }) => {
160160
// A fresh client per invocation means fresh dedupe state, so each invocation reports
161161
// the same error independently.
162-
const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal);
162+
const runner = createRunner(__dirname).ignore('span').start(signal);
163163

164164
for (let i = 0; i < 3; i++) {
165165
await runner.makeRequestAndWaitForEnvelope(
@@ -174,7 +174,7 @@ it('cacheClient: false - dedupe does not persist across invocations', async ({ s
174174
// also start reusing the isolation scope `setTag`/`setUser` write to. The uncached counterpart of
175175
// this test lives in the `durable-object-scope` suite.
176176
it('cacheClient: true - two consecutive invocations get different isolation scopes', async ({ signal }) => {
177-
const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal);
177+
const runner = createRunner(__dirname).ignore('span').start(signal);
178178

179179
await runner.makeRequestAndWaitForEnvelope('get', '/cache/scope?id=scope-shared&seed=1', (envelope: Envelope) => {
180180
const event = envelope[1]?.[0]?.[1] as Event;

‎dev-packages/cloudflare-integration-tests/suites/d1/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ interface Env {
99
export default Sentry.withSentry(
1010
(env: Env) => ({
1111
dsn: env.SENTRY_DSN,
12-
traceLifecycle: 'static',
1312
tracesSampleRate: 1,
1413
}),
1514
{

‎dev-packages/cloudflare-integration-tests/suites/d1/test.ts‎

Lines changed: 89 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,37 @@
1-
import type { Envelope } from '@sentry/core';
1+
import type { Envelope, SerializedStreamedSpan } from '@sentry/core';
22
import { expect, it } from 'vitest';
33
import { createRunner } from '../../runner';
4+
import { getSpansFromEnvelope } from '../../spanUtils';
45

5-
function envelopeItemType(envelope: Envelope): string | undefined {
6-
return envelope[1][0]?.[0]?.type as string | undefined;
7-
}
8-
9-
function envelopeItem(envelope: Envelope): Record<string, unknown> {
10-
return envelope[1][0]![1] as Record<string, unknown>;
11-
}
12-
13-
function findD1Spans(envelope: Envelope): Array<Record<string, unknown>> {
14-
if (envelopeItemType(envelope) !== 'transaction') return [];
15-
const tx = envelopeItem(envelope);
16-
const spans = (tx.spans as Array<Record<string, unknown>>) || [];
17-
return spans.filter(s => (s.op as string) === 'db.query');
18-
}
6+
// `cloudflare.d1.duration` is only an integer when the query happens to take a whole number of
7+
// milliseconds, so the type can't be pinned down.
8+
const NUMBER_ATTRIBUTE = { type: expect.stringMatching(/^(?:integer|double)$/), value: expect.any(Number) };
199

2010
it('instruments D1 prepare().all() automatically via env', async ({ signal }) => {
2111
const runner = createRunner(__dirname)
2212
.ignore('event')
2313
.expect((envelope: Envelope) => {
24-
expect(envelopeItemType(envelope)).toBe('transaction');
25-
const d1Spans = findD1Spans(envelope);
26-
expect(d1Spans.length).toBeGreaterThanOrEqual(1);
27-
28-
const querySpan = d1Spans.find(s => s.description === 'SELECT * FROM users WHERE id = ?');
29-
expect(querySpan).toBeDefined();
30-
expect(querySpan).toEqual({
31-
data: {
32-
'db.system.name': 'cloudflare-d1',
33-
'db.operation.name': 'all',
34-
'db.query.text': 'SELECT * FROM users WHERE id = ?',
35-
'db.query.summary': 'SELECT users',
36-
'cloudflare.d1.duration': expect.any(Number),
37-
'cloudflare.d1.rows_read': expect.any(Number),
38-
'cloudflare.d1.rows_written': expect.any(Number),
39-
'sentry.op': 'db.query',
40-
'sentry.origin': 'auto.db.cloudflare.d1',
41-
},
42-
description: 'SELECT * FROM users WHERE id = ?',
43-
op: 'db.query',
44-
origin: 'auto.db.cloudflare.d1',
45-
status: 'ok',
46-
parent_span_id: expect.any(String),
47-
span_id: expect.any(String),
48-
start_timestamp: expect.any(Number),
49-
timestamp: expect.any(Number),
50-
trace_id: expect.any(String),
51-
});
14+
const spans = getSpansFromEnvelope(envelope);
15+
const segmentSpan = spans.find(span => span.is_segment);
16+
17+
// The D1 span is named after its query summary rather than the full query text.
18+
const querySpan = spans.find(span => span.attributes['db.operation.name']?.value === 'all');
19+
expect(querySpan?.name).toBe('SELECT users');
20+
expect(querySpan?.parent_span_id).toBe(segmentSpan?.span_id);
21+
expect(querySpan?.status).toBe('ok');
22+
expect(querySpan?.attributes).toEqual(
23+
expect.objectContaining({
24+
'sentry.op': { type: 'string', value: 'db.query' },
25+
'sentry.origin': { type: 'string', value: 'auto.db.cloudflare.d1' },
26+
'db.system.name': { type: 'string', value: 'cloudflare-d1' },
27+
'db.operation.name': { type: 'string', value: 'all' },
28+
'db.query.text': { type: 'string', value: 'SELECT * FROM users WHERE id = ?' },
29+
'db.query.summary': { type: 'string', value: 'SELECT users' },
30+
'cloudflare.d1.duration': NUMBER_ATTRIBUTE,
31+
'cloudflare.d1.rows_read': NUMBER_ATTRIBUTE,
32+
'cloudflare.d1.rows_written': NUMBER_ATTRIBUTE,
33+
}),
34+
);
5235
})
5336
.start(signal);
5437

@@ -58,10 +41,10 @@ it('instruments D1 prepare().all() automatically via env', async ({ signal }) =>
5841

5942
it('captures error event when a D1 query references a non-existent table', async ({ signal }) => {
6043
const runner = createRunner(__dirname)
61-
.ignore('transaction')
44+
.ignore('span')
6245
.expect((envelope: Envelope) => {
63-
expect(envelopeItemType(envelope)).toBe('event');
64-
const event = envelopeItem(envelope);
46+
expect(envelope[1][0]?.[0]?.type).toBe('event');
47+
const event = envelope[1][0]![1] as Record<string, unknown>;
6548
expect(event.level).toBe('error');
6649

6750
const values = (event.exception as { values: Array<Record<string, unknown>> })?.values;
@@ -102,32 +85,26 @@ it('instruments D1 exec() automatically via env', async ({ signal }) => {
10285
const runner = createRunner(__dirname)
10386
.ignore('event')
10487
.expect((envelope: Envelope) => {
105-
expect(envelopeItemType(envelope)).toBe('transaction');
106-
const d1Spans = findD1Spans(envelope);
107-
108-
const execSpan = d1Spans.find(
109-
s => s.description === 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)',
88+
const spans = getSpansFromEnvelope(envelope);
89+
const segmentSpan = spans.find(span => span.is_segment);
90+
91+
const execSpan = spans.find(span => span.attributes['db.operation.name']?.value === 'exec');
92+
expect(execSpan?.name).toBe('CREATE TABLE users');
93+
expect(execSpan?.parent_span_id).toBe(segmentSpan?.span_id);
94+
expect(execSpan?.status).toBe('ok');
95+
expect(execSpan?.attributes).toEqual(
96+
expect.objectContaining({
97+
'sentry.op': { type: 'string', value: 'db.query' },
98+
'sentry.origin': { type: 'string', value: 'auto.db.cloudflare.d1' },
99+
'db.system.name': { type: 'string', value: 'cloudflare-d1' },
100+
'db.operation.name': { type: 'string', value: 'exec' },
101+
'db.query.text': {
102+
type: 'string',
103+
value: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)',
104+
},
105+
'db.query.summary': { type: 'string', value: 'CREATE TABLE users' },
106+
}),
110107
);
111-
expect(execSpan).toBeDefined();
112-
expect(execSpan).toEqual({
113-
data: {
114-
'db.system.name': 'cloudflare-d1',
115-
'db.operation.name': 'exec',
116-
'db.query.text': 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)',
117-
'db.query.summary': 'CREATE TABLE users',
118-
'sentry.op': 'db.query',
119-
'sentry.origin': 'auto.db.cloudflare.d1',
120-
},
121-
description: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)',
122-
op: 'db.query',
123-
origin: 'auto.db.cloudflare.d1',
124-
status: 'ok',
125-
parent_span_id: expect.any(String),
126-
span_id: expect.any(String),
127-
start_timestamp: expect.any(Number),
128-
timestamp: expect.any(Number),
129-
trace_id: expect.any(String),
130-
});
131108
})
132109
.start(signal);
133110

@@ -136,20 +113,30 @@ it('instruments D1 exec() automatically via env', async ({ signal }) => {
136113
});
137114

138115
it('instruments D1 withSession().batch() identically to db.batch()', async ({ signal }) => {
139-
let directBatchSpan: Record<string, unknown> | undefined;
140-
let sessionBatchSpan: Record<string, unknown> | undefined;
116+
let directBatchSpan: SerializedStreamedSpan | undefined;
117+
let sessionBatchSpan: SerializedStreamedSpan | undefined;
141118

142119
const runner = createRunner(__dirname)
143120
.ignore('event')
144121
.expect((envelope: Envelope) => {
145-
expect(envelopeItem(envelope).transaction).toBe('GET /batch');
122+
const spans = getSpansFromEnvelope(envelope);
123+
// Both routes are raw URLs, so the streamed segment name keeps the method only and the
124+
// request is identified through `url.path`.
125+
expect(spans.find(span => span.is_segment)?.attributes['url.path']).toEqual({
126+
type: 'string',
127+
value: '/batch',
128+
});
146129

147-
directBatchSpan = findD1Spans(envelope).find(s => s.description === 'D1 batch');
130+
directBatchSpan = spans.find(span => span.name === 'D1 batch');
148131
})
149132
.expect((envelope: Envelope) => {
150-
expect(envelopeItem(envelope).transaction).toBe('GET /with-session/batch');
133+
const spans = getSpansFromEnvelope(envelope);
134+
expect(spans.find(span => span.is_segment)?.attributes['url.path']).toEqual({
135+
type: 'string',
136+
value: '/with-session/batch',
137+
});
151138

152-
sessionBatchSpan = findD1Spans(envelope).find(s => s.description === 'D1 batch');
139+
sessionBatchSpan = spans.find(span => span.name === 'D1 batch');
153140
})
154141
.unordered()
155142
.start(signal);
@@ -161,16 +148,19 @@ it('instruments D1 withSession().batch() identically to db.batch()', async ({ si
161148
expect(directBatchSpan).toBeDefined();
162149
expect(sessionBatchSpan).toBeDefined();
163150

164-
const normalize = (span: Record<string, unknown>): Record<string, unknown> => {
151+
// Ids and timestamps differ between the two requests, everything else must match.
152+
const normalize = (span: SerializedStreamedSpan): Record<string, unknown> => {
165153
const {
166154
span_id: _spanId,
167155
parent_span_id: _parentSpanId,
168156
start_timestamp: _start,
169-
timestamp: _end,
157+
end_timestamp: _end,
170158
trace_id: _traceId,
159+
attributes,
171160
...rest
172161
} = span;
173-
return rest;
162+
const { 'sentry.segment.id': _segmentId, ...restAttributes } = attributes;
163+
return { ...rest, attributes: restAttributes };
174164
};
175165

176166
expect(normalize(sessionBatchSpan!)).toEqual(normalize(directBatchSpan!));
@@ -180,30 +170,25 @@ it('instruments D1 batch() automatically via env', async ({ signal }) => {
180170
const runner = createRunner(__dirname)
181171
.ignore('event')
182172
.expect((envelope: Envelope) => {
183-
expect(envelopeItemType(envelope)).toBe('transaction');
184-
const d1Spans = findD1Spans(envelope);
185-
186-
const batchSpan = d1Spans.find(s => s.description === 'D1 batch');
187-
expect(batchSpan).toBeDefined();
188-
expect(batchSpan).toEqual({
189-
data: {
190-
'db.system.name': 'cloudflare-d1',
191-
'db.operation.name': 'batch',
192-
'db.query.text': 'INSERT INTO users (name) VALUES (?)\nINSERT INTO users (name) VALUES (?)',
193-
'db.operation.batch.size': 2,
194-
'sentry.op': 'db.query',
195-
'sentry.origin': 'auto.db.cloudflare.d1',
196-
},
197-
description: 'D1 batch',
198-
op: 'db.query',
199-
origin: 'auto.db.cloudflare.d1',
200-
status: 'ok',
201-
parent_span_id: expect.any(String),
202-
span_id: expect.any(String),
203-
start_timestamp: expect.any(Number),
204-
timestamp: expect.any(Number),
205-
trace_id: expect.any(String),
206-
});
173+
const spans = getSpansFromEnvelope(envelope);
174+
const segmentSpan = spans.find(span => span.is_segment);
175+
176+
const batchSpan = spans.find(span => span.name === 'D1 batch');
177+
expect(batchSpan?.parent_span_id).toBe(segmentSpan?.span_id);
178+
expect(batchSpan?.status).toBe('ok');
179+
expect(batchSpan?.attributes).toEqual(
180+
expect.objectContaining({
181+
'sentry.op': { type: 'string', value: 'db.query' },
182+
'sentry.origin': { type: 'string', value: 'auto.db.cloudflare.d1' },
183+
'db.system.name': { type: 'string', value: 'cloudflare-d1' },
184+
'db.operation.name': { type: 'string', value: 'batch' },
185+
'db.query.text': {
186+
type: 'string',
187+
value: 'INSERT INTO users (name) VALUES (?)\nINSERT INTO users (name) VALUES (?)',
188+
},
189+
'db.operation.batch.size': { type: 'integer', value: 2 },
190+
}),
191+
);
207192
})
208193
.start(signal);
209194

‎dev-packages/cloudflare-integration-tests/suites/durableobject-scope/test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { expect, it } from 'vitest';
33
import { createRunner } from '../../runner';
44

55
it('two consecutive invocations get different isolation scopes', async ({ signal }) => {
6-
const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal);
6+
const runner = createRunner(__dirname).ignore('span').start(signal);
77

88
await runner.makeRequestAndWaitForEnvelope('get', '/scope?seed=1', (envelope: Envelope) => {
99
const event = envelope[1]?.[0]?.[1] as Event;
@@ -23,7 +23,7 @@ it('two consecutive invocations get different isolation scopes', async ({ signal
2323
});
2424

2525
it('a nested direct call within one invocation shares the same isolation scope', async ({ signal }) => {
26-
const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal);
26+
const runner = createRunner(__dirname).ignore('span').start(signal);
2727

2828
await runner.makeRequestAndWaitForEnvelope('get', '/nested', (envelope: Envelope) => {
2929
const event = envelope[1]?.[0]?.[1] as Event;
@@ -51,7 +51,7 @@ it('a nested direct call within one invocation shares the same isolation scope',
5151
});
5252

5353
it('a nested call into another instrumented handler shares the same isolation scope', async ({ signal }) => {
54-
const runner = createRunner(__dirname).ignore('transaction', 'span').start(signal);
54+
const runner = createRunner(__dirname).ignore('span').start(signal);
5555

5656
await runner.makeRequestAndWaitForEnvelope('get', '/reentrant', (envelope: Envelope) => {
5757
const event = envelope[1]?.[0]?.[1] as Event;

‎dev-packages/cloudflare-integration-tests/suites/durableobject/error/index.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ class TestDurableObjectBase extends DurableObject<Env> {
1919
export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
2020
(env: Env) => ({
2121
dsn: env.SENTRY_DSN,
22-
traceLifecycle: 'static',
2322
tracesSampleRate: 1.0,
2423
}),
2524
TestDurableObjectBase,
@@ -28,7 +27,6 @@ export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
2827
export default Sentry.withSentry(
2928
(env: Env) => ({
3029
dsn: env.SENTRY_DSN,
31-
traceLifecycle: 'static',
3230
tracesSampleRate: 1.0,
3331
}),
3432
{

‎dev-packages/cloudflare-integration-tests/suites/prisma/index.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ interface Env {
1111
export default Sentry.withSentry(
1212
(env: Env) => ({
1313
dsn: env.SENTRY_DSN,
14-
traceLifecycle: 'static',
1514
tracesSampleRate: 1,
1615
integrations: [Sentry.prismaIntegration()],
1716
}),

0 commit comments

Comments
 (0)