Skip to content

Commit c237b43

Browse files
JPeer264claude
andcommitted
test(e2e): Port cloudflare-agent to span streaming
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6c19779 commit c237b43

5 files changed

Lines changed: 175 additions & 164 deletions

File tree

dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/ai-streaming.test.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import { getSpanOp, waitForStreamedSpan, waitForTransaction } from '@sentry-internal/test-utils';
2+
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
33
import type { SerializedStreamedSpan } from '@sentry/core';
44

55
// Drives Workers AI through the real Cloudflare Agents SDK + Vercel AI SDK + `workers-ai-provider`
@@ -26,32 +26,41 @@ function assertGenAiStreamingSpan(span: SerializedStreamedSpan): void {
2626
expect(span.attributes['gen_ai.conversation.id']?.value).toMatch(/^[0-9a-f]{32}$/);
2727
}
2828

29+
// With span streaming, URL-sourced `http.server` spans are named by method only, so the request
30+
// segments below are identified by their `url.path` attribute.
31+
2932
test('captures Workers AI streaming output when driven via an Agent', async ({ request, baseURL }) => {
3033
const spanPromise = waitForStreamedSpan('cloudflare-agent', span => getSpanOp(span) === 'gen_ai.chat');
31-
const transactionPromise = waitForTransaction(
34+
const requestSpanPromise = waitForStreamedSpan(
3235
'cloudflare-agent',
33-
transactionEvent => transactionEvent.transaction === 'GET /agents/my-agent/test',
36+
span =>
37+
getSpanOp(span) === 'http.server' &&
38+
span.is_segment &&
39+
span.attributes['url.path']?.value === '/agents/my-agent/test',
3440
);
3541

3642
const response = await request.get(`${baseURL}/agents/my-agent/test`);
3743
expect(response.ok()).toBe(true);
3844

39-
const [span, transaction] = await Promise.all([spanPromise, transactionPromise]);
40-
expect(span.trace_id).toBe(transaction.contexts?.trace?.trace_id);
45+
const [span, requestSpan] = await Promise.all([spanPromise, requestSpanPromise]);
46+
expect(span.trace_id).toBe(requestSpan.trace_id);
4147
assertGenAiStreamingSpan(span);
4248
});
4349

4450
test('captures Workers AI streaming output when driven via an AIChatAgent', async ({ request, baseURL }) => {
4551
const spanPromise = waitForStreamedSpan('cloudflare-agent', span => getSpanOp(span) === 'gen_ai.chat');
46-
const transactionPromise = waitForTransaction(
52+
const requestSpanPromise = waitForStreamedSpan(
4753
'cloudflare-agent',
48-
transactionEvent => transactionEvent.transaction === 'GET /agents/my-chat-agent/test',
54+
span =>
55+
getSpanOp(span) === 'http.server' &&
56+
span.is_segment &&
57+
span.attributes['url.path']?.value === '/agents/my-chat-agent/test',
4958
);
5059

5160
const response = await request.get(`${baseURL}/agents/my-chat-agent/test`);
5261
expect(response.ok()).toBe(true);
5362

54-
const [span, transaction] = await Promise.all([spanPromise, transactionPromise]);
55-
expect(span.trace_id).toBe(transaction.contexts?.trace?.trace_id);
63+
const [span, requestSpan] = await Promise.all([spanPromise, requestSpanPromise]);
64+
expect(span.trace_id).toBe(requestSpan.trace_id);
5665
assertGenAiStreamingSpan(span);
5766
});
Lines changed: 125 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
import { expect, test } from '@playwright/test';
2-
import { waitForTransaction } from '@sentry-internal/test-utils';
2+
import { collectStreamedSpans, getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
3+
4+
// The agent request segment is the Durable Object's `http.server` span. It has a parent because
5+
// the worker propagates its trace over the RPC binding; the worker's own segment for the same URL
6+
// does not. With span streaming the name is the method only, so the segment is picked by `url.path`.
37

48
test('@callable() methods work correctly with Sentry instrumentAgentWithSentry', async ({ page, baseURL }) => {
5-
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
6-
return (
7-
transactionEvent.transaction === 'GET /agents/my-agent/user-123' &&
8-
transactionEvent.contexts?.trace?.parent_span_id !== undefined
9-
);
10-
});
9+
const requestSpanPromise = waitForStreamedSpan(
10+
'cloudflare-agent',
11+
span =>
12+
getSpanOp(span) === 'http.server' &&
13+
span.is_segment &&
14+
span.attributes['url.path']?.value === '/agents/my-agent/user-123' &&
15+
span.parent_span_id !== undefined,
16+
);
1117

12-
// The greet() call goes over the websocket, so its storage spans land in a webSocketMessage
13-
// transaction. Filter for the one carrying our put span — control messages produce their own
14-
// webSocketMessage transactions without storage spans.
15-
const storageTransactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
18+
// The greet() call goes over the websocket, so its storage spans are children of the `greet` rpc
19+
// span inside a webSocketMessage segment. Control messages produce their own webSocketMessage
20+
// segments without storage spans. Streamed children arrive before their segment, so collect until
21+
// the segment that closes the trace of a `greet` call has arrived.
22+
const storageSpansPromise = collectStreamedSpans('cloudflare-agent', spans => {
23+
const rpcSpan = spans.find(span => getSpanOp(span) === 'rpc' && span.name === 'greet');
1624
return (
17-
transactionEvent.transaction === 'webSocketMessage' &&
18-
(transactionEvent.spans ?? []).some(span => span.description === 'durable_object_storage_put')
25+
!!rpcSpan &&
26+
spans.some(span => span.is_segment && span.name === 'webSocketMessage' && span.trace_id === rpcSpan.trace_id)
1927
);
2028
});
2129

@@ -25,146 +33,141 @@ test('@callable() methods work correctly with Sentry instrumentAgentWithSentry',
2533
await page.getByRole('button', { name: 'Call Agent' }).click();
2634
await expect(page.getByText('Hello, World!')).toBeVisible();
2735

28-
const transaction = await transactionPromise;
29-
30-
expect(transaction).toEqual({
31-
contexts: {
32-
trace: {
33-
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
34-
span_id: expect.stringMatching(/[a-f0-9]{16}/),
35-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
36-
data: expect.any(Object),
37-
op: 'http.server',
38-
status: 'ok',
39-
origin: 'auto.http.cloudflare',
40-
},
41-
cloud_resource: { 'cloud.provider': 'cloudflare' },
42-
culture: { timezone: expect.any(String) },
43-
runtime: { name: 'cloudflare' },
44-
},
45-
spans: [],
36+
const requestSpan = await requestSpanPromise;
37+
38+
expect(requestSpan).toEqual({
39+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
40+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
41+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
42+
name: 'GET',
4643
start_timestamp: expect.any(Number),
47-
timestamp: expect.any(Number),
48-
transaction: 'GET /agents/my-agent/user-123',
49-
type: 'transaction',
50-
request: {
51-
cookies: {},
52-
headers: expect.any(Object),
53-
method: 'GET',
54-
url: expect.stringContaining('/agents/my-agent/user-123'),
55-
query_string: expect.any(String),
56-
},
57-
transaction_info: { source: 'url' },
58-
platform: 'javascript',
59-
event_id: expect.stringMatching(/[a-f0-9]{32}/),
60-
environment: expect.any(String),
61-
user: {
62-
ip_address: '127.0.0.1',
63-
},
64-
release: expect.any(String),
65-
sdk: {
66-
integrations: expect.any(Array),
67-
name: 'sentry.javascript.cloudflare',
68-
version: expect.any(String),
69-
packages: expect.any(Array),
70-
},
44+
end_timestamp: expect.any(Number),
45+
status: 'ok',
46+
is_segment: true,
47+
attributes: expect.objectContaining({
48+
'sentry.op': { value: 'http.server', type: 'string' },
49+
'sentry.origin': { value: 'auto.http.cloudflare', type: 'string' },
50+
'sentry.segment.name.source': { value: 'url', type: 'string' },
51+
'http.request.method': { value: 'GET', type: 'string' },
52+
'url.path': { value: '/agents/my-agent/user-123', type: 'string' },
53+
'sentry.environment': { value: expect.any(String), type: 'string' },
54+
}),
7155
});
7256

7357
// greet() touches 6 storage keys: 2 user ops + 3 framework-internal keys (cf_, __ps_, /) that
7458
// must be filtered + 1 allowlisted cf_ key. Spans carry no key attribute, so filtering can only
7559
// be verified by count — exactly these 3 storage spans (in execution order) should survive, and
7660
// any framework-internal span leaking through shows up as an extra entry here.
77-
const storageTransaction = await storageTransactionPromise;
61+
const spans = await storageSpansPromise;
62+
const rpcSpan = spans.find(span => getSpanOp(span) === 'rpc' && span.name === 'greet')!;
7863

79-
const storageSpans = (storageTransaction.spans ?? []).filter(
80-
span => span.origin === 'auto.db.cloudflare.durable_object',
81-
);
64+
const storageSpans = spans
65+
.filter(
66+
span =>
67+
span.parent_span_id === rpcSpan.span_id &&
68+
span.attributes['sentry.origin']?.value === 'auto.db.cloudflare.durable_object',
69+
)
70+
.sort((a, b) => a.start_timestamp - b.start_timestamp);
8271

8372
expect(storageSpans).toEqual([
84-
expect.objectContaining({
85-
data: {
86-
'db.operation.name': 'put',
87-
'db.system.name': 'cloudflare.durable_object.storage',
88-
'sentry.op': 'db',
89-
'sentry.origin': 'auto.db.cloudflare.durable_object',
90-
},
91-
description: 'durable_object_storage_put',
92-
op: 'db',
93-
origin: 'auto.db.cloudflare.durable_object',
94-
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
95-
span_id: expect.stringMatching(/[a-f0-9]{16}/),
73+
{
74+
name: 'durable_object_storage_put',
75+
attributes: expect.objectContaining({
76+
'db.operation.name': { value: 'put', type: 'string' },
77+
'db.system.name': { value: 'cloudflare.durable_object.storage', type: 'string' },
78+
'sentry.op': { value: 'db', type: 'string' },
79+
'sentry.origin': { value: 'auto.db.cloudflare.durable_object', type: 'string' },
80+
}),
81+
parent_span_id: rpcSpan.span_id,
82+
span_id: expect.stringMatching(/^[a-f0-9]{16}$/),
9683
start_timestamp: expect.any(Number),
97-
timestamp: expect.any(Number),
98-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
99-
}),
100-
expect.objectContaining({
101-
data: {
102-
'db.operation.name': 'get',
103-
'db.system.name': 'cloudflare.durable_object.storage',
104-
'sentry.op': 'db',
105-
'sentry.origin': 'auto.db.cloudflare.durable_object',
106-
},
107-
description: 'durable_object_storage_get',
108-
op: 'db',
109-
origin: 'auto.db.cloudflare.durable_object',
110-
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
111-
span_id: expect.stringMatching(/[a-f0-9]{16}/),
84+
end_timestamp: expect.any(Number),
85+
status: 'ok',
86+
is_segment: false,
87+
trace_id: rpcSpan.trace_id,
88+
},
89+
{
90+
name: 'durable_object_storage_get',
91+
attributes: expect.objectContaining({
92+
'db.operation.name': { value: 'get', type: 'string' },
93+
'db.system.name': { value: 'cloudflare.durable_object.storage', type: 'string' },
94+
'sentry.op': { value: 'db', type: 'string' },
95+
'sentry.origin': { value: 'auto.db.cloudflare.durable_object', type: 'string' },
96+
}),
97+
parent_span_id: rpcSpan.span_id,
98+
span_id: expect.stringMatching(/^[a-f0-9]{16}$/),
11299
start_timestamp: expect.any(Number),
113-
timestamp: expect.any(Number),
114-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
115-
}),
116-
expect.objectContaining({
117-
data: {
118-
'db.operation.name': 'get',
119-
'db.system.name': 'cloudflare.durable_object.storage',
120-
'sentry.op': 'db',
121-
'sentry.origin': 'auto.db.cloudflare.durable_object',
122-
},
123-
description: 'durable_object_storage_get',
124-
op: 'db',
125-
origin: 'auto.db.cloudflare.durable_object',
126-
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
127-
span_id: expect.stringMatching(/[a-f0-9]{16}/),
100+
end_timestamp: expect.any(Number),
101+
status: 'ok',
102+
is_segment: false,
103+
trace_id: rpcSpan.trace_id,
104+
},
105+
{
106+
name: 'durable_object_storage_get',
107+
attributes: expect.objectContaining({
108+
'db.operation.name': { value: 'get', type: 'string' },
109+
'db.system.name': { value: 'cloudflare.durable_object.storage', type: 'string' },
110+
'sentry.op': { value: 'db', type: 'string' },
111+
'sentry.origin': { value: 'auto.db.cloudflare.durable_object', type: 'string' },
112+
}),
113+
parent_span_id: rpcSpan.span_id,
114+
span_id: expect.stringMatching(/^[a-f0-9]{16}$/),
128115
start_timestamp: expect.any(Number),
129-
timestamp: expect.any(Number),
130-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
131-
}),
116+
end_timestamp: expect.any(Number),
117+
status: 'ok',
118+
is_segment: false,
119+
trace_id: rpcSpan.trace_id,
120+
},
132121
]);
133122
});
134123

135124
test('does not emit db.query spans for the agents runtime `cf_`-prefixed internal tables', async ({
136125
page,
137126
baseURL,
138127
}) => {
139-
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
140-
return (
141-
transactionEvent.transaction === 'GET /agents/my-agent/user-123' &&
142-
transactionEvent.contexts?.trace?.parent_span_id !== undefined
143-
);
144-
});
128+
const spansPromise = collectStreamedSpans('cloudflare-agent', spans =>
129+
spans.some(
130+
span =>
131+
getSpanOp(span) === 'http.server' &&
132+
span.is_segment &&
133+
span.attributes['url.path']?.value === '/agents/my-agent/user-123' &&
134+
span.parent_span_id !== undefined,
135+
),
136+
);
145137

146138
await page.goto(baseURL!);
147139

148140
await expect(page.getByText('Connected')).toBeVisible();
149141
await page.getByRole('button', { name: 'Call Agent' }).click();
150142
await expect(page.getByText('Hello, World!')).toBeVisible();
151143

152-
const transaction = await transactionPromise;
144+
const spans = await spansPromise;
145+
const requestSpan = spans.find(
146+
span =>
147+
getSpanOp(span) === 'http.server' &&
148+
span.is_segment &&
149+
span.attributes['url.path']?.value === '/agents/my-agent/user-123' &&
150+
span.parent_span_id !== undefined,
151+
)!;
153152

154153
// The agents runtime constantly queries its own `cf_agents_*` / `cf_agent_*` bookkeeping tables.
155154
// These are framework internals and are filtered out by default, so no such span should leak.
156-
const internalTableSpans = (transaction.spans ?? []).filter(
157-
span => span.op === 'db.query' && /\bcf_/.test((span.data?.['db.query.summary'] as string) ?? ''),
155+
const internalTableSpans = spans.filter(
156+
span =>
157+
span.trace_id === requestSpan.trace_id &&
158+
getSpanOp(span) === 'db.query' &&
159+
/\bcf_/.test(String(span.attributes['db.query.summary']?.value ?? '')),
158160
);
159161

160162
expect(internalTableSpans).toEqual([]);
161163
});
162164

163165
test('creates an rpc span named after the @callable() method', async ({ page, baseURL }) => {
164-
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
166+
const spansPromise = collectStreamedSpans('cloudflare-agent', spans => {
167+
const rpcSpan = spans.find(span => getSpanOp(span) === 'rpc' && span.name === 'greet');
165168
return (
166-
transactionEvent.transaction === 'webSocketMessage' &&
167-
(transactionEvent.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
169+
!!rpcSpan &&
170+
spans.some(span => span.is_segment && span.name === 'webSocketMessage' && span.trace_id === rpcSpan.trace_id)
168171
);
169172
});
170173

@@ -174,19 +177,13 @@ test('creates an rpc span named after the @callable() method', async ({ page, ba
174177
await page.getByRole('button', { name: 'Call Agent' }).click();
175178
await expect(page.getByText('Hello, World!')).toBeVisible();
176179

177-
const transaction = await transactionPromise;
180+
const spans = await spansPromise;
181+
const rpcSpan = spans.find(span => getSpanOp(span) === 'rpc' && span.name === 'greet')!;
178182

179-
const rpcSpans = (transaction.spans ?? []).filter(span => span.op === 'rpc');
183+
const rpcSpans = spans.filter(span => getSpanOp(span) === 'rpc' && span.trace_id === rpcSpan.trace_id);
180184
expect(rpcSpans).toHaveLength(1);
181185

182-
expect(rpcSpans[0]).toEqual(
183-
expect.objectContaining({
184-
op: 'rpc',
185-
description: 'greet',
186-
origin: 'auto.faas.cloudflare.agents',
187-
data: expect.objectContaining({
188-
'gen_ai.agent.name': 'MyBaseAgent',
189-
}),
190-
}),
191-
);
186+
expect(rpcSpan.attributes['sentry.op']?.value).toBe('rpc');
187+
expect(rpcSpan.attributes['sentry.origin']?.value).toBe('auto.faas.cloudflare.agents');
188+
expect(rpcSpan.attributes['gen_ai.agent.name']?.value).toBe('MyBaseAgent');
192189
});

0 commit comments

Comments
 (0)