11import { 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 - f 0 - 9 ] { 16 } / ) ,
34- span_id : expect . stringMatching ( / [ a - f 0 - 9 ] { 16 } / ) ,
35- trace_id : expect . stringMatching ( / [ a - f 0 - 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 - f 0 - 9 ] { 32 } / ) ,
47+ span_id : expect . stringMatching ( / [ a - f 0 - 9 ] { 16 } / ) ,
48+ parent_span_id : expect . stringMatching ( / [ a - f 0 - 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 - f 0 - 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 - f 0 - 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 - f 0 - 9 ] { 16 } / ) ,
9693 start_timestamp : expect . any ( Number ) ,
97- timestamp : expect . any ( Number ) ,
98- trace_id : expect . stringMatching ( / [ a - f 0 - 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 - f 0 - 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 - f 0 - 9 ] { 16 } / ) ,
112102 start_timestamp : expect . any ( Number ) ,
113- timestamp : expect . any ( Number ) ,
114- trace_id : expect . stringMatching ( / [ a - f 0 - 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 - f 0 - 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 - f 0 - 9 ] { 16 } / ) ,
128111 start_timestamp : expect . any ( Number ) ,
129- timestamp : expect . any ( Number ) ,
130- trace_id : expect . stringMatching ( / [ a - f 0 - 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' && / \b c f _ / . 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+ / \b c f _ / . test ( String ( span . attributes [ 'db.query.summary' ] ?. value ?? '' ) ) ,
158140 ) ;
159141
160142 expect ( internalTableSpans ) . toEqual ( [ ] ) ;
161143} ) ;
162144
163145test ( '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