Skip to content

Commit 110feb6

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

5 files changed

Lines changed: 163 additions & 166 deletions

File tree

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

Lines changed: 15 additions & 11 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,36 @@ 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+
// segment is identified by its `url.path` attribute.
31+
function isRequestSegment(span: SerializedStreamedSpan, urlPath: string): boolean {
32+
return getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === urlPath;
33+
}
34+
2935
test('captures Workers AI streaming output when driven via an Agent', async ({ request, baseURL }) => {
3036
const spanPromise = waitForStreamedSpan('cloudflare-agent', span => getSpanOp(span) === 'gen_ai.chat');
31-
const transactionPromise = waitForTransaction(
32-
'cloudflare-agent',
33-
transactionEvent => transactionEvent.transaction === 'GET /agents/my-agent/test',
37+
const requestSpanPromise = waitForStreamedSpan('cloudflare-agent', span =>
38+
isRequestSegment(span, '/agents/my-agent/test'),
3439
);
3540

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

39-
const [span, transaction] = await Promise.all([spanPromise, transactionPromise]);
40-
expect(span.trace_id).toBe(transaction.contexts?.trace?.trace_id);
44+
const [span, requestSpan] = await Promise.all([spanPromise, requestSpanPromise]);
45+
expect(span.trace_id).toBe(requestSpan.trace_id);
4146
assertGenAiStreamingSpan(span);
4247
});
4348

4449
test('captures Workers AI streaming output when driven via an AIChatAgent', async ({ request, baseURL }) => {
4550
const spanPromise = waitForStreamedSpan('cloudflare-agent', span => getSpanOp(span) === 'gen_ai.chat');
46-
const transactionPromise = waitForTransaction(
47-
'cloudflare-agent',
48-
transactionEvent => transactionEvent.transaction === 'GET /agents/my-chat-agent/test',
51+
const requestSpanPromise = waitForStreamedSpan('cloudflare-agent', span =>
52+
isRequestSegment(span, '/agents/my-chat-agent/test'),
4953
);
5054

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

54-
const [span, transaction] = await Promise.all([spanPromise, transactionPromise]);
55-
expect(span.trace_id).toBe(transaction.contexts?.trace?.trace_id);
58+
const [span, requestSpan] = await Promise.all([spanPromise, requestSpanPromise]);
59+
expect(span.trace_id).toBe(requestSpan.trace_id);
5660
assertGenAiStreamingSpan(span);
5761
});
Lines changed: 101 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,37 @@
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+
import type { SerializedStreamedSpan } from '@sentry/core';
4+
5+
// The agent request segment is the Durable Object's `http.server` span. It has a parent because
6+
// the worker propagates its trace over the RPC binding; the worker's own segment for the same URL
7+
// does not. With span streaming the name is the method only, so the segment is picked by `url.path`.
8+
function isAgentRequestSegment(span: SerializedStreamedSpan): boolean {
9+
return (
10+
getSpanOp(span) === 'http.server' &&
11+
span.is_segment &&
12+
span.attributes['url.path']?.value === '/agents/my-agent/user-123' &&
13+
span.parent_span_id !== undefined
14+
);
15+
}
316

4-
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-
});
17+
function isGreetRpcSpan(span: SerializedStreamedSpan): boolean {
18+
return getSpanOp(span) === 'rpc' && span.name === 'greet';
19+
}
20+
21+
function hasWebSocketMessageSegment(spans: SerializedStreamedSpan[], traceId: string): boolean {
22+
return spans.some(span => span.is_segment && span.name === 'webSocketMessage' && span.trace_id === traceId);
23+
}
1124

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 => {
16-
return (
17-
transactionEvent.transaction === 'webSocketMessage' &&
18-
(transactionEvent.spans ?? []).some(span => span.description === 'durable_object_storage_put')
19-
);
25+
test('@callable() methods work correctly with Sentry instrumentAgentWithSentry', async ({ page, baseURL }) => {
26+
const requestSpanPromise = waitForStreamedSpan('cloudflare-agent', isAgentRequestSegment);
27+
28+
// The greet() call goes over the websocket, so its storage spans are children of the `greet` rpc
29+
// span inside a webSocketMessage segment. Control messages produce their own webSocketMessage
30+
// segments without storage spans. Streamed children arrive before their segment, so collect until
31+
// the segment that closes the trace of a `greet` call has arrived.
32+
const storageSpansPromise = collectStreamedSpans('cloudflare-agent', spans => {
33+
const rpcSpan = spans.find(isGreetRpcSpan);
34+
return !!rpcSpan && hasWebSocketMessageSegment(spans, rpcSpan.trace_id);
2035
});
2136

2237
await page.goto(baseURL!);
@@ -25,109 +40,77 @@ test('@callable() methods work correctly with Sentry instrumentAgentWithSentry',
2540
await page.getByRole('button', { name: 'Call Agent' }).click();
2641
await expect(page.getByText('Hello, World!')).toBeVisible();
2742

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: [],
43+
const requestSpan = await requestSpanPromise;
44+
45+
expect(requestSpan).toEqual({
46+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
47+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
48+
parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
49+
name: 'GET',
4650
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-
},
51+
end_timestamp: expect.any(Number),
52+
status: 'ok',
53+
is_segment: true,
54+
attributes: expect.objectContaining({
55+
'sentry.op': { value: 'http.server', type: 'string' },
56+
'sentry.origin': { value: 'auto.http.cloudflare', type: 'string' },
57+
'sentry.segment.name.source': { value: 'url', type: 'string' },
58+
'http.request.method': { value: 'GET', type: 'string' },
59+
'url.path': { value: '/agents/my-agent/user-123', type: 'string' },
60+
'sentry.environment': { value: expect.any(String), type: 'string' },
61+
}),
7162
});
7263

7364
// greet() touches 6 storage keys: 2 user ops + 3 framework-internal keys (cf_, __ps_, /) that
7465
// must be filtered + 1 allowlisted cf_ key. Spans carry no key attribute, so filtering can only
7566
// be verified by count — exactly these 3 storage spans (in execution order) should survive, and
7667
// any framework-internal span leaking through shows up as an extra entry here.
77-
const storageTransaction = await storageTransactionPromise;
78-
79-
const storageSpans = (storageTransaction.spans ?? []).filter(
80-
span => span.origin === 'auto.db.cloudflare.durable_object',
81-
);
68+
const spans = await storageSpansPromise;
69+
const rpcSpan = spans.find(isGreetRpcSpan)!;
70+
71+
const storageSpans = spans
72+
.filter(
73+
span =>
74+
span.parent_span_id === rpcSpan.span_id &&
75+
span.attributes['sentry.origin']?.value === 'auto.db.cloudflare.durable_object',
76+
)
77+
.sort((a, b) => a.start_timestamp - b.start_timestamp);
78+
79+
const storageSpanAttributes = (operation: string) =>
80+
expect.objectContaining({
81+
'db.operation.name': { value: operation, type: 'string' },
82+
'db.system.name': { value: 'cloudflare.durable_object.storage', type: 'string' },
83+
'sentry.op': { value: 'db', type: 'string' },
84+
'sentry.origin': { value: 'auto.db.cloudflare.durable_object', type: 'string' },
85+
});
8286

8387
expect(storageSpans).toEqual([
8488
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}/),
89+
name: 'durable_object_storage_put',
90+
attributes: storageSpanAttributes('put'),
91+
parent_span_id: rpcSpan.span_id,
9592
span_id: expect.stringMatching(/[a-f0-9]{16}/),
9693
start_timestamp: expect.any(Number),
97-
timestamp: expect.any(Number),
98-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
94+
end_timestamp: expect.any(Number),
95+
trace_id: rpcSpan.trace_id,
9996
}),
10097
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}/),
98+
name: 'durable_object_storage_get',
99+
attributes: storageSpanAttributes('get'),
100+
parent_span_id: rpcSpan.span_id,
111101
span_id: expect.stringMatching(/[a-f0-9]{16}/),
112102
start_timestamp: expect.any(Number),
113-
timestamp: expect.any(Number),
114-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
103+
end_timestamp: expect.any(Number),
104+
trace_id: rpcSpan.trace_id,
115105
}),
116106
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}/),
107+
name: 'durable_object_storage_get',
108+
attributes: storageSpanAttributes('get'),
109+
parent_span_id: rpcSpan.span_id,
127110
span_id: expect.stringMatching(/[a-f0-9]{16}/),
128111
start_timestamp: expect.any(Number),
129-
timestamp: expect.any(Number),
130-
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
112+
end_timestamp: expect.any(Number),
113+
trace_id: rpcSpan.trace_id,
131114
}),
132115
]);
133116
});
@@ -136,36 +119,33 @@ test('does not emit db.query spans for the agents runtime `cf_`-prefixed interna
136119
page,
137120
baseURL,
138121
}) => {
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-
});
122+
const spansPromise = collectStreamedSpans('cloudflare-agent', spans => spans.some(isAgentRequestSegment));
145123

146124
await page.goto(baseURL!);
147125

148126
await expect(page.getByText('Connected')).toBeVisible();
149127
await page.getByRole('button', { name: 'Call Agent' }).click();
150128
await expect(page.getByText('Hello, World!')).toBeVisible();
151129

152-
const transaction = await transactionPromise;
130+
const spans = await spansPromise;
131+
const requestSpan = spans.find(isAgentRequestSegment)!;
153132

154133
// The agents runtime constantly queries its own `cf_agents_*` / `cf_agent_*` bookkeeping tables.
155134
// 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) ?? ''),
135+
const internalTableSpans = spans.filter(
136+
span =>
137+
span.trace_id === requestSpan.trace_id &&
138+
getSpanOp(span) === 'db.query' &&
139+
/\bcf_/.test(String(span.attributes['db.query.summary']?.value ?? '')),
158140
);
159141

160142
expect(internalTableSpans).toEqual([]);
161143
});
162144

163145
test('creates an rpc span named after the @callable() method', async ({ page, baseURL }) => {
164-
const transactionPromise = waitForTransaction('cloudflare-agent', transactionEvent => {
165-
return (
166-
transactionEvent.transaction === 'webSocketMessage' &&
167-
(transactionEvent.spans ?? []).some(span => span.op === 'rpc' && span.description === 'greet')
168-
);
146+
const spansPromise = collectStreamedSpans('cloudflare-agent', spans => {
147+
const rpcSpan = spans.find(isGreetRpcSpan);
148+
return !!rpcSpan && hasWebSocketMessageSegment(spans, rpcSpan.trace_id);
169149
});
170150

171151
await page.goto(baseURL!);
@@ -174,19 +154,15 @@ test('creates an rpc span named after the @callable() method', async ({ page, ba
174154
await page.getByRole('button', { name: 'Call Agent' }).click();
175155
await expect(page.getByText('Hello, World!')).toBeVisible();
176156

177-
const transaction = await transactionPromise;
157+
const spans = await spansPromise;
158+
const rpcSpan = spans.find(isGreetRpcSpan)!;
178159

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

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-
);
163+
expect(rpcSpan.attributes).toMatchObject({
164+
'sentry.op': { value: 'rpc', type: 'string' },
165+
'sentry.origin': { value: 'auto.faas.cloudflare.agents', type: 'string' },
166+
'gen_ai.agent.name': { value: 'MyBaseAgent', type: 'string' },
167+
});
192168
});

0 commit comments

Comments
 (0)