-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Description
Description
Package + Version
@sentry/cloudflare@10.42.0wrangler@4.71.0- Tested with both raw
DurableObjectandagentsframework (Agentbase class)
Description
When a Durable Object instrumented with instrumentDurableObjectWithSentry creates child spans (via Sentry.startSpan) during request handling, the transaction is delivered to Sentry but only the root transaction span is indexed. The child spans embedded in the transaction's spans array are silently dropped.
This was confirmed using a beforeSendTransaction hook which shows the correct span count (e.g., 51 spans) before the transaction is sent. The transaction arrives in Sentry (visible in the traces explorer), but only the root span appears — the 50 child spans are missing.
Evidence
With the agents framework (Agent base class):
beforeSendTransactionfires:tx=GET /agents/stream-with-spans spans=51- Sentry shows: 1 span in trace (just the root
http.serverspan)
With a plain DurableObject:
- Each
Sentry.startSpancall creates its own transaction (not a child span) beforeSendTransactionfires 50 times, each withspans=0- Sentry shows: 50+ spans in trace (because each is a separate transaction/root span)
This suggests that child spans embedded inside a transaction envelope are not being extracted/indexed by Sentry's span ingestion pipeline, at least for transactions originating from @sentry/cloudflare Durable Objects.
Impact
This blocks AI monitoring for Cloudflare DO-based applications. The Vercel AI SDK integration (vercelAIIntegration) correctly enriches spans with gen_ai.* attributes, and beforeSendTransaction confirms the spans are present in the transaction. But since child spans aren't indexed, they never appear in Sentry's traces explorer or AI monitoring views.
Minimal Reproduction
Files
wrangler.toml:
name = "sentry-do-streaming-repro"
main = "worker.ts"
compatibility_date = "2026-03-01"
compatibility_flags = ["nodejs_compat"]
[durable_objects]
bindings = [
{ name = "PLAIN_DO", class_name = "PlainDO" },
{ name = "AGENT_DO", class_name = "AgentDO" }
]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["PlainDO", "AgentDO"]worker.ts:
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';
import { Agent, getAgentByName } from 'agents';
function makeStream(): ReadableStream {
return new ReadableStream({
start(controller) {
let i = 0;
const interval = setInterval(() => {
controller.enqueue(new TextEncoder().encode(`chunk ${i}\n`));
i++;
if (i >= 5) {
clearInterval(interval);
controller.close();
}
}, 200);
},
});
}
function handlePath(path: string): Response {
if (path.endsWith('/no-stream')) {
return new Response('ok');
}
if (path.endsWith('/stream-with-spans')) {
// Create 50 child spans — these should appear in the trace but don't
for (let i = 0; i < 50; i++) {
Sentry.startSpan({ name: `work-${i}`, op: 'db' }, () => {});
}
return new Response(makeStream(), {
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
});
}
return new Response('not found', { status: 404 });
}
// ─── Plain DurableObject ───
class PlainDOBase extends DurableObject<Env> {
async fetch(request: Request): Promise<Response> {
return handlePath(new URL(request.url).pathname);
}
}
export const PlainDO = Sentry.instrumentDurableObjectWithSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN || 'https://example@sentry.io/0',
tracesSampleRate: 1.0,
enabled: !!env.SENTRY_DSN,
beforeSendTransaction(event: any) {
console.log(`[plain] tx=${event.transaction} spans=${(event.spans ?? []).length}`);
return event;
},
}),
PlainDOBase,
);
// ─── Agent-based DO ───
class AgentDOBase extends Agent<Env> {
async onRequest(request: Request): Promise<Response> {
return handlePath(new URL(request.url).pathname);
}
}
export const AgentDO = Sentry.instrumentDurableObjectWithSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN || 'https://example@sentry.io/0',
tracesSampleRate: 1.0,
enabled: !!env.SENTRY_DSN,
beforeSendTransaction(event: any) {
console.log(`[agent] tx=${event.transaction} spans=${(event.spans ?? []).length}`);
return event;
},
}),
AgentDOBase,
);
// ─── Worker ───
interface Env {
SENTRY_DSN: string;
PLAIN_DO: DurableObjectNamespace;
AGENT_DO: DurableObjectNamespace;
}
const worker = {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname.startsWith('/agents/')) {
const agent = await getAgentByName(env.AGENT_DO, 'test');
return agent.fetch(request);
}
const id = env.PLAIN_DO.idFromName('test');
const stub = env.PLAIN_DO.get(id);
return stub.fetch(request);
},
};
export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN || 'https://example@sentry.io/0',
tracesSampleRate: 1.0,
enabled: !!env.SENTRY_DSN,
}),
worker,
);Steps to Reproduce
npm init -y && npm install @sentry/cloudflare agents- Deploy:
npx wrangler deploy - Set secret:
npx wrangler secret put SENTRY_DSN - Monitor logs:
npx wrangler tail
Test the agents framework path:
curl https://<your-url>/agents/stream-with-spansObserve in wrangler tail:
[agent] tx=GET /agents/stream-with-spans spans=51
→ 51 spans in beforeSendTransaction
Check Sentry traces explorer:
→ Transaction appears with 1 span (root only). The 50 child work-* spans are missing.
Compare with plain DO path:
curl https://<your-url>/stream-with-spansObserve in wrangler tail:
[plain] tx=work-0 spans=0
[plain] tx=work-1 spans=0
... (50 separate transactions)
→ Each span becomes its own transaction (not a child span). All 50 appear in Sentry individually.
Expected Behavior
The 50 child spans created inside Sentry.startSpan should appear as children of the request transaction in Sentry's traces explorer, the same as they would in a Node.js application using @sentry/node.
Actual Behavior
- The transaction is delivered to Sentry (root span visible)
beforeSendTransactionconfirms 51 spans are in the transaction envelope- Only the root span is indexed; 50 child spans are silently dropped
Additional Context
In a plain DurableObject (not using the agents framework), Sentry.startSpan creates separate transactions instead of child spans. This suggests instrumentDurableObjectWithSentry may not be correctly establishing a parent span context for the DO's fetch handler, so each startSpan call becomes a root span/transaction.
The agents framework DOES correctly parent spans (the beforeSendTransaction shows 51 spans under one transaction), but the child spans aren't indexed by Sentry after delivery.
Related Issues
- [cloudflare] expose async.setAsyncLocalStorageAsyncContextStrategy() #15342 — Original issue about DO support (led to
instrumentDurableObjectWithSentry) - Cloudflare Websocket with Durable Objects not working #15975 — WebSocket DO events not captured
- Sentry does not capture errors or spans from Cloudflare Workers context.waitUntil() #17476 —
waitUntilspans/errors lost in Workers
Metadata
Metadata
Assignees
Fields
Give feedbackProjects
Status