Skip to content

@sentry/cloudflare: Child spans inside transactions from Durable Objects are not indexed by Sentry #20030

@jameskranz

Description

@jameskranz

Description

Package + Version

  • @sentry/cloudflare@10.42.0
  • wrangler@4.71.0
  • Tested with both raw DurableObject and agents framework (Agent base 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):

  • beforeSendTransaction fires: tx=GET /agents/stream-with-spans spans=51
  • Sentry shows: 1 span in trace (just the root http.server span)

With a plain DurableObject:

  • Each Sentry.startSpan call creates its own transaction (not a child span)
  • beforeSendTransaction fires 50 times, each with spans=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

  1. npm init -y && npm install @sentry/cloudflare agents
  2. Deploy: npx wrangler deploy
  3. Set secret: npx wrangler secret put SENTRY_DSN
  4. Monitor logs: npx wrangler tail

Test the agents framework path:

curl https://<your-url>/agents/stream-with-spans

Observe 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-spans

Observe 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)
  • beforeSendTransaction confirms 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

Metadata

Metadata

Assignees

No one assigned
    No fields configured for issues without a type.

    Projects

    Status

    Waiting for: Product Owner

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions