feat: Cloudflare-native AI tracing (agents/observability/ai)#1860
feat: Cloudflare-native AI tracing (agents/observability/ai)#1860mattzcarey wants to merge 15 commits into
Conversation
🦋 Changeset detectedLatest commit: 05ef7ce The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
agents
@cloudflare/ai-chat
@cloudflare/codemode
create-think
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
|
@mattzcarey Would it show a tracing UI like langsmith/braintrust and also show sub-agent calls separately? |
2fcf743 to
d627bc2
Compare
d627bc2 to
96150cf
Compare
Co-authored-by: msmps <7691252+msmps@users.noreply.github.com>
Co-authored-by: msmps <7691252+msmps@users.noreply.github.com>
- match workspace devDependency versions (sherif) - oxfmt formatting, remove unused type imports (oxlint) - bundler moduleResolution with extensionless relative imports - build with tsdown like sibling packages (cloudflare:workers kept external) - explicit types field for TS 6 (no automatic @types inclusion) - start at version 0.0.0 with an initial-release changeset - update pnpm lockfile
Move the ai-tracing package into the agents package: the tracer core (createTracer, the cloudflare:workers-bound tracer, span types) is exported from agents/observability and the AI SDK v6/v7 adapters from the new agents/observability/ai entry. The cloudflare:workers 'tracing' export is accessed via the module namespace with a no-op fallback so runtimes that predate it degrade gracefully instead of failing at module-link time (the observability module loads with the main agents entry). The hand-rolled cloudflare:workers type shim is dropped in favor of @cloudflare/workers-types. Tests run in the agents workers pool. Co-authored-by: msmps <7691252+msmps@users.noreply.github.com>
…face
Schema (per semconv research; nothing shipped, renames free):
- span names follow the semconv formula with a 64-byte bare-op fallback:
'invoke_agent {agent}', 'chat {model}', 'execute_tool {tool}' — the stable
query key is gen_ai.operation.name, never the span name
- vendor keys move to cloudflare.agents.* (ai.* is the Vercel AI SDK's
de-facto namespace); ai.tool.call_id becomes semconv gen_ai.tool.call.id
- failures record otel.status_code: ERROR + error.type (the spec-defined
status encoding for status-less backends) instead of a bare error boolean;
cancellations record cloudflare.agents.canceled and are not errors
- gen_ai.provider.name normalized to the semconv enum; gen_ai.request.stream
emitted only when true; gen_ai.response.time_to_first_chunk and response
id/model captured on the stream path
Wrapper fixes surfaced by the trace-content audit:
- AI SDK v6 signals aborts as in-band {type:'abort'} chunks and never rejects
with AbortError — recognize them so aborted streams close as canceled
instead of false successes
- streaming tools (async-generator execute) keep their execute_tool span open
until the iterable is consumed instead of finishing at ~0ms
- tool spans carry gen_ai.tool.call.id from the execute options
Public surface hardening (runtime will gain native OTel support later):
- types renamed to avoid @opentelemetry/api collisions: AgentTracer,
AgentSpan, TraceAttributes, TraceAttributeValue; startSpan renamed openSpan
(OTel's startSpan means create-without-activating — a semantic inversion)
- createTracer, SpanRuntime, SpanWriter, MaybePromise are private:
SpanRuntime is the OTel-convergence seam and must stay free to change
Zero new public surface. Think's streamText call routes through the
always-on agents/observability/ai wrapper, so every turn emits an
'invoke_agent {agent class}' root span with 'chat {model}' and
'execute_tool {tool}' children in Workers Observability.
- the admittedTurnContext ALS internally carries trigger/admission/channel/
continuation/generation; _turnTelemetry() injects agent identity and turn
metadata into experimental_telemetry.metadata (caller values win; inert
for the AI SDK's own telemetry unless enabled)
- agents adapters (v6 + v7) project telemetry metadata onto root-span
attributes: reserved keys -> cloudflare.agents.turn.*, userId -> user.id,
other scalars -> cloudflare.agents.metadata.{key}, objects dropped
- drain loops finalize the underlying model stream on early exit (in-stream
error break, stall abort, user abort) via a WeakMap finalizer calling
consumeStream — the SDK tees its base stream, so an abandoned tee branch
would otherwise leave the operation span open forever
- wrapModel skips middleware for gateway-style string model ids (the root
span still carries the model)
Verified against the pinned ai@6.0.208 and fixed:
- stream observation now unwraps the SDK's {part} baseStream envelope —
previously real spans missed usage, finish reasons, errors, and aborts
(only look-alike test fixtures passed); added real-SDK integration tests
(actual streamText + MockLanguageModelV3) covering envelope unwrapping,
in-band error/abort parts, tool call ids, and time-to-first-chunk
- removed the eager result-getter 'safeguard': steps/totalUsage/finishReason
getters call consumeStream(), so touching them started hidden stream
consumption at wrap time; added a laziness regression test
- untraced fast path: when an invocation is not traced the wrapper calls the
original operation with the original params — no tool wrapping, no model
middleware, no stream patching (AgentSpan gains readonly isTraced)
- main agents entry no longer initializes tracing: diagnostics-channel events
moved to observability/events.ts; the public barrel composes events+tracing
- provider doStream now runs inside the chat span's activation so provider
work nests under it; stream patching fails open on unknown result shapes
- extractors read the public result shapes (inputTokenDetails/
outputTokenDetails, response.modelId, deprecated flat fields) and string
gateway model ids
- think: agents peer floor raised to >=0.18.0; the early-exit stream drain is
idempotent (deleted before invocation) and rides ctx.waitUntil
- v7 tool spans keyed by callId:toolCallId (concurrent id reuse); operation
wrappers cached for stable identity; tracer attribute writes fail-safe;
cloudflare.agents.operation.id renamed to .operation.name (values are names)
- untraced calls no longer compute the span spec: roots open with only the semconv name (agent name via direct property reads) and empty attributes; the full spec — metadata enumeration, request fields, context allowlists — is computed after the isTraced check and written through an internal writeSpanAttributes seam, so caller getters/proxies are never enumerated on untraced calls - think drains the model stream only on early exits (break or throw), via a natural-exhaustion flag — consumeStream is not a no-op (it tees baseStream and traverses the buffered branch), so draining every call was per-inference overhead; a thrown exit (stall watchdog) still drains - the finalizer runs exactly once: the drain promise is created before ctx.waitUntil, so a missing/throwing waitUntil cannot start a second tee consumer - async-generator tool bodies are re-entered into the tool span's async context via AsyncLocalStorage.snapshot() on every pull, so spans created inside the body parent under execute_tool (verified in workerd) - extractors: provider response-metadata stream parts populate response id/model on chat spans; v7 reads public usage detail shapes (inputTokenDetails/outputTokenDetails + deprecated flat fields) and prefers the served response.modelId over the requested event.modelId
The round-2 manual iterator.next() loop dropped for-await's automatic return() forwarding: a consumer breaking while the wrapper was suspended at yield closed the span but never ran the tool generator's own finally blocks. The wrapper now tracks exhaustion and, on early termination, forwards iterator.return() inside the tool span's context before finishing the span. Regression test: consumer breaks after the first yield; the tool generator's cleanup runs (and a span opened in that cleanup parents under execute_tool).
96150cf to
fbc5453
Compare
Production trace comparisonFinal v6-only build deployed to agent-think Worker version Raw Workers Observability comparison:
The untestable AI SDK v7 adapter was removed from the package and preserved as an explicitly unverified design gist: https://gist.github.com/mattzcarey/cc7265966dd8eb58e7b230688a3f4140 Validation evidence: #1883 (comment) |
Cloudflare-native tracing for the repository's installed AI SDK v6 using the Workers runtime
tracingAPI. Spans use the scalar subset of the current OpenTelemetry GenAI semantic conventions that Workers custom spans can represent and flow directly to Workers Observability and configured OTLP destinations. No OTel SDK, exporter, or collector dependency is added.AI SDK v6 instrumentation
Enable Worker traces:
{ "observability": { "traces": { "enabled": true } } }Wrap the namespace once and use it normally:
The wrapper instruments
generateText,streamText,generateObject, andstreamObject:Model objects are instrumented through
wrapLanguageModel, so provider work and its automatic subrequests run underchat {model}. Tool execution runs underexecute_tool {tool}. Streams close on completion, cancellation, an in-band error, or early consumer return. Async-generator tools stay open until iteration and cleanup finish.AI SDK v6 exposes
experimental_context; selected scalar keys can be emitted withwrapAISDK(ai, { includeRuntimeContext: [...] }).Think: traced out of the box
Think routes every turn through the wrapper with no new customer-facing option. Its default identity is:
These are defaults, not restrictions:
beforeTurnmay overridefunctionIdor identity metadata for applications with another identity model. Think also adds exactcloudflare.agents.turn.*metadata for request ID, trigger, admission, channel, continuation, and generation. Ordinary customer keys such asrequestIdremain ordinary metadata and are not silently reclassified as Think turn fields.Drain loops consume the abandoned AI SDK tee after early exit (in-stream error, stall abort, or user abort), preventing open operation spans.
Attribute contract
invoke_agent: agent/conversation identity, output type, request settings, aggregate standard token usage, dashboard-ready total/tool counts, and scalar finish reason.chat: provider, requested model/settings, actual response ID/model when reported, model-call usage and total/tool counts, andgen_ai.response.time_to_first_chunkin seconds.execute_tool: tool name/type/call ID and real execution duration via span duration.cloudflare.agents.*where no standard attribute exists.Semantic corrections included in the final audit:
functionId → gen_ai.agent.nameis retained because it is the AI SDK's canonical OpenTelemetry projection.gen_ai.response.finish_reasonsandgen_ai.request.stop_sequencesare omitted: OTel defines arrays, while WorkersSpan.setAttributecurrently accepts only scalar values. A single finish reason is exposed accurately ascloudflare.agents.response.finish_reasoninstead of JSON text under an array-typed key.modelIdis never presented as the servedgen_ai.response.model.cloudflare.agents.tool.countandcloudflare.agents.usage.total_tokensremain as useful precomputed dashboard dimensions; only low-value output-presence flags were removed.azurevsazure-openai,google-vertex,bedrock, etc.).error.type; no fakeotel.status_codeattribute is created because OTel status is span state and Workers exposes no custom-span status setter. Cancellations remain non-errors withcloudflare.agents.canceled: true.Public surface
agents/observability/aiexports only:wrapAISDKThe Workers tracing adapter, tracer/span handles, builders, and runtime seams remain internal.
agents/observabilitycontinues to expose diagnostics-channel events only.The AI SDK v7 prototype was removed because this v6 repository cannot integration-test it. It remains available as an explicitly unverified public design artifact: https://gist.github.com/mattzcarey/cc7265966dd8eb58e7b230688a3f4140
Testing
pnpm check: exports, formatting, lint, and all 116 TypeScript projectsagentsand@cloudflare/thinkpackage builds passProduction validation
Compared raw Workers Observability events for the same agent-think smoke shape:
de7395981c140629b3219f5f41cb9d296f3737d3788029ae4b23dff165ed3383invoke_agent ThinkAgent, 4chat gpt-5.5, 3execute_tool; all children correctly parented0values to9.462s,6.144s,4.356s,1.011sgen_ai.response.finish_reasonsremovedrepository,issueNumber,requestedBy) visible on the rootdone, added no tool errors, and terminal cleanup left exactly one warm container on container-application image version 7Evidence: #1883 (comment)
Provenance
Ported from @msmps's
feat/ai-tracingbranch withCo-authored-bycredit, then folded intoagents, aligned with current GenAI semantics, and integrated into Think.