Skip to content

feat: Cloudflare-native AI tracing (agents/observability/ai)#1860

Open
mattzcarey wants to merge 26 commits into
mainfrom
feat/agent-tracing
Open

feat: Cloudflare-native AI tracing (agents/observability/ai)#1860
mattzcarey wants to merge 26 commits into
mainfrom
feat/agent-tracing

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Cloudflare-native tracing for the repository's installed AI SDK v6 using the Workers runtime tracing API. 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:

import * as ai from "ai";
import { wrapAISDK } from "agents/observability/ai";

const { generateText, streamText } = wrapAISDK(ai);

await streamText({
  model,
  prompt: "book a table for two",
  tools: { searchRestaurants, reserve },
  experimental_telemetry: {
    functionId: "booking-agent", // AI SDK canonical mapping: gen_ai.agent.name
    metadata: { conversationId: "conv-42" }
  }
});

The wrapper instruments generateText, streamText, generateObject, and streamObject:

invoke_agent booking-agent
├── chat gpt-5.5
├── execute_tool searchRestaurants
└── chat gpt-5.5

Model objects are instrumented through wrapLanguageModel, so provider work and its automatic subrequests run under chat {model}. Tool execution runs under execute_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 with wrapAISDK(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:

gen_ai.agent.name      = this.constructor.name
gen_ai.agent.id        = this.name
gen_ai.conversation.id = this.ctx.id.toString()

These are defaults, not restrictions: beforeTurn may override functionId or identity metadata for applications with another identity model. Think also adds exact cloudflare.agents.turn.* metadata for request ID, trigger, admission, channel, continuation, and generation. Ordinary customer keys such as requestId remain 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, and gen_ai.response.time_to_first_chunk in seconds.
  • execute_tool: tool name/type/call ID and real execution duration via span duration.
  • Original SDK operation fields use cloudflare.agents.* where no standard attribute exists.

Semantic corrections included in the final audit:

  • functionId → gen_ai.agent.name is retained because it is the AI SDK's canonical OpenTelemetry projection.
  • gen_ai.response.finish_reasons and gen_ai.request.stop_sequences are omitted: OTel defines arrays, while Workers Span.setAttribute currently accepts only scalar values. A single finish reason is exposed accurately as cloudflare.agents.response.finish_reason instead of JSON text under an array-typed key.
  • Response ID/model and first-chunk timing are placed on model-call spans rather than duplicated onto the enclosing operation when a model child exists.
  • Requested modelId is never presented as the served gen_ai.response.model.
  • cloudflare.agents.tool.count and cloudflare.agents.usage.total_tokens remain as useful precomputed dashboard dimensions; only low-value output-presence flags were removed.
  • Span names retain the Workers Observability 64 UTF-8-byte guard, falling back to the bare operation while preserving the full target in attributes.
  • Provider aliases match the AI SDK/OpenTelemetry mapping (azure vs azure-openai, google-vertex, bedrock, etc.).
  • Failures emit error.type; no fake otel.status_code attribute is created because OTel status is span state and Workers exposes no custom-span status setter. Cancellations remain non-errors with cloudflare.agents.canceled: true.
  • Payload storage is explicit and off by default: storeMessages writes full input/output messages to chat; storeTools writes tool arguments/results to execute_tool. Schemas, request headers, provider options, the dedicated system parameter, and raw error messages remain excluded.

Payload storage

Two independent flags control payload attributes:

  • storeMessages writes gen_ai.input.messages and gen_ai.output.messages on chat, including tool-call parts. Values over budget drop the oldest unprotected message (index 2) repeatedly, preserving the first two messages and newest tail.
  • storeTools writes gen_ai.tool.call.arguments and gen_ai.tool.call.result on execute_tool.

Both default to false. Think exposes the same fields; agent-think opts into both. The flags configure the wrapper directly and are never copied into telemetry metadata or span attributes.

AI Gateway log references

When an actual gateway-backed response exposes a log ID, its chat span includes cloudflare.ai_gateway.log.id. The adapter reads only explicit response headers, provider metadata, gateway errors, or the Workers AI binding; it omits absent, stale, untrusted, and oversized values. The enclosing invoke_agent span does not receive the attribute, and the adapter never makes an extra request or guesses an ID.

Live verification used the Wrangler-authenticated account against the default AI Gateway:

  • unified REST requests succeeded but did not expose cf-aig-log-id, so that path remains omitted;
  • a real Workers AI binding request exposed env.AI.aiGatewayLogId;
  • env.AI.gateway("default").getLog(id) returned the same log;
  • the temporary probe Worker was deleted after verification.

Storage span grouping

SDK-managed Durable Object storage spans are grouped beneath semantic lifecycle phases instead of flooding the same level as inference. Named buckets cover agent/chat initialization, Think startup and hydration, chat request persistence, turn preparation/result persistence, recovery/fibers, alarms, and durable submission acceptance/execution. Raw SQLite spans remain available beneath those collapsible groups.

Every grouping span carries agent identity plus cloudflare.agents.storage.grouped = true, cloudflare.agents.storage.system = "durable_object", and a phase. This keeps invoke_agent, chat, and execute_tool prominent inside chat_turn. Contributed by @Ankcorn in Ankcorn/agents#2.

Approval and durable-submission lifetimes

AI SDK v6 tool approvals are represented by bounded requested, approved, and denied segments under execute_tool, correlated with gen_ai.tool.call.id. No approval span remains open while waiting for a human across invocations.

Think durable submissions are alarm-owned: acceptance only persists and schedules work, while the awaited alarm invocation owns model execution. This prevents model/chat spans from outliving the short acceptance RPC and being force-closed by the Durable Object invocation boundary.

Public surface

agents/observability/ai exports only:

  • wrapAISDK (AI SDK v6) and its wrapper option type
  • createAISDKTelemetry (AI SDK v7) and its telemetry type

The Workers tracing adapter, tracer/span handles, builders, and runtime seams remain internal. agents/observability continues to expose diagnostics-channel events only.

AI SDK v7

AI SDK v7 (now GA) ships a first-class telemetry lifecycle, so v7 is instrumented through that lifecycle rather than by wrapping the SDK. createAISDKTelemetry() returns an integration you register once, or pass per call:

import { registerTelemetry } from "ai";
import { createAISDKTelemetry } from "agents/observability/ai";

registerTelemetry(createAISDKTelemetry());
// or: generateText({ ..., experimental_telemetry: { integrations: [createAISDKTelemetry()] } })

The v7 integration projects into the same invoke_agent / chat / execute_tool span schema and the same scalar attribute contract as v6, so both SDK versions are dashboard-compatible. One topology difference follows from v7's event-driven telemetry model (vs. v6's operation-wrapping): the operation, model, and tool spans are correlated by a shared cloudflare.agents.call.id rather than parented under one another. The executeLanguageModelCall / executeTool execution hooks still nest real work — provider subrequests run under chat, and a tool's own nested inference runs under execute_tool.

The adapter defines the v7 telemetry shapes structurally (no ai import), so it compiles and unit-tests in this v6 repository, and it is a structural match for AI SDK v7's exported Telemetry interface (a compile-time satisfies Telemetry check against ai@7.0.22 passes with no loosening).

Production validation. Deployed a Worker on ai@7.0.22 (@ai-sdk/provider@4.0.3) with observability.traces.enabled, registered the v7 integration, and ran a two-step tool loop. Workers Observability recorded the expected spans (invoke_agent, two chat, execute_tool) with correct gen_ai.* / cloudflare.agents.* attributes and no raw prompt/message/tool content; the provider subrequest's persisted parentSpanId confirmed it nested under its chat span via executeLanguageModelCall.

Testing

  • focused AI observability coverage: 3 files / 74 tests
  • full agents suite: 118 files / 2357 tests
  • full ai-chat suite: 49 files / 728 tests
  • Think Workers suite: 43 files / 910 tests
  • pnpm check: exports, formatting, lint, and all 116 TypeScript projects
  • agents and @cloudflare/think package builds pass
  • generated declarations expose only the focused AI tracing API and the two storage options

Production validation

Compared raw Workers Observability events for the same agent-think smoke shape:

  • baseline: de7395981c140629b3219f5f41cb9d29
  • audited build: 6f3737d3788029ae4b23dff165ed3383
  • topology unchanged: 1 invoke_agent ThinkAgent, 4 chat gpt-5.5, 3 execute_tool; all children correctly parented
  • model TTFC corrected from four 0 values to 9.462s, 6.144s, 4.356s, 1.011s
  • invalid JSON-string gen_ai.response.finish_reasons removed
  • scalar application metadata (repository, issueNumber, requestedBy) visible on the root
  • turn reached done, added no tool errors, and terminal cleanup left exactly one warm container on container-application image version 7

Evidence: #1883 (comment)

Provenance

Ported from @msmps's feat/ai-tracing branch with Co-authored-by credit, then folded into agents, aligned with current GenAI semantics, and integrated into Think.

@changeset-bot

changeset-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 15b7c49

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
agents Minor
@cloudflare/ai-chat Patch
@cloudflare/think Minor
@cloudflare/agent-think Patch

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1860

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1860

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1860

create-think

npm i https://pkg.pr.new/create-think@1860

hono-agents

npm i https://pkg.pr.new/hono-agents@1860

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1860

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1860

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1860

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1860

commit: 15b7c49

@mattzcarey mattzcarey changed the title feat: add @cloudflare/ai-tracing — Cloudflare-native tracing for the AI SDK feat: Cloudflare-native AI tracing via agents/observability Jul 2, 2026
@mattzcarey mattzcarey changed the title feat: Cloudflare-native AI tracing via agents/observability feat: Cloudflare-native AI tracing (agents/observability + agents/observability/ai) Jul 3, 2026
@abhagsain

Copy link
Copy Markdown

@mattzcarey Would it show a tracing UI like langsmith/braintrust and also show sub-agent calls separately?

@mattzcarey mattzcarey marked this pull request as ready for review July 6, 2026 16:09
@mattzcarey mattzcarey force-pushed the feat/agent-tracing branch from 2fcf743 to d627bc2 Compare July 6, 2026 16:09

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread packages/think/src/think.ts Outdated
Comment thread docs/agents/observability.md Outdated
@mattzcarey mattzcarey force-pushed the feat/agent-tracing branch 2 times, most recently from 96150cf to fbc5453 Compare July 8, 2026 14:42
@mattzcarey mattzcarey changed the title feat: Cloudflare-native AI tracing (agents/observability + agents/observability/ai) feat: Cloudflare-native AI tracing (agents/observability/ai) Jul 8, 2026
@mattzcarey

mattzcarey commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

Production trace comparison

Final v6-only build deployed to agent-think Worker version a612d7b1-d349-4b9b-8601-31901ca8b93e (container application image version 7, digest sha256:1ceff71cad30f3207e4380de6ceaff1b7f89c6908505f49aeba49574344a9d3b).

Raw Workers Observability comparison:

  • baseline: de7395981c140629b3219f5f41cb9d29
  • final v6-only build: 6ca72a82c7813bfc313c765ea3e86eeb
  • topology unchanged: 1 invoke_agent ThinkAgent, 4 chat gpt-5.5, 3 execute_tool; all children correctly parented
  • real model TTFC: 8.575s, 2.093s, 4.758s, 1.452s rather than four 0 values
  • invalid JSON-string gen_ai.response.finish_reasons removed
  • response ID/model/TTFC retained on model spans rather than copied to the root
  • dashboard counts retained: root cloudflare.agents.tool.count=3, cloudflare.agents.usage.total_tokens=86336; per-model total/tool counts also present
  • scalar application metadata (repository, issueNumber, requestedBy) visible on the root
  • turn reached done, added no tool errors, and terminal cleanup left exactly one warm container

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)

mattzcarey and others added 14 commits July 16, 2026 10:38
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).
mattzcarey and others added 12 commits July 16, 2026 10:38
Group constructor-time setup (method wrapping, schema creation, MCP client
manager initialization) under one stable agent_initialization span so the UI
can collapse it instead of surfacing top-level clutter, and give init-specific
trace behaviour a hook.

The agent id attribute is read defensively: facets restore their name after
construction and idFromString()/newUniqueId() DOs are named later via
setName(), so an unreadable name leaves the attribute unset instead of
failing construction.
Restore the v7 Telemetry adapter (createAISDKTelemetry) alongside the v6
wrapAISDK, conformed to the ai@7.0.22 GA Telemetry interface. The adapter's
structural event/hook types remain independent of the "ai" package so it
still compiles in this v6-installed repo. Re-adds the cloudflare.agents.call.id
correlation attribute and the v7 docs sections removed when v7 was scoped out.

- observability/ai/v7/{types,extract,telemetry}.ts
- observability/ai/index.ts: re-export createAISDKTelemetry
- genai/attributes.ts: restore Cloudflare.CallID
- tests: ai-sdk-v7-telemetry.test.ts (structural, RecordingTracer)
- docs + changeset: v7 usage via registerTelemetry / experimental_telemetry
Add an explicit, default-off opt-in for recording chat inputs/outputs and
tool inputs/outputs on the AI SDK tracing spans. This content is potentially
PII, so it is emitted only when a record flag resolves to true; the default
projection remains content-free.

- v6: `recordInputs`/`recordOutputs` on the `wrapAISDK` options, plus per-call
  `experimental_telemetry.recordInputs`/`recordOutputs` (authoritative, mirrors
  the AI SDK's own TelemetrySettings). Chat inputs and streamed/generated
  output and tool arguments/results are serialized onto the operation and
  execute_tool spans.
- v7: `createAISDKTelemetry(options)` gains the same flags; content is read from
  the event fields the adapter already receives and emitted only when opted in.
- Shared genai builders serialize each value to a JSON string attribute
  truncated to a safe byte cap with a marker; semconv-aligned keys
  (gen_ai.input.messages / gen_ai.output.messages / gen_ai.tool.call.arguments /
  gen_ai.tool.call.result). Never emitted on error/abort beyond the flag.
- Think exposes a single `recordTraceContent` flag (off by default) that flows
  into the per-turn telemetry; agent-think opts in.

Tests assert the default records no content attribute and the opt-in records
the expected serialized (and truncated) value for v6, v7, and Think. Docs
updated with the opt-in, flagged as PII-recording and off by default.
…hink; cap content to span budget

Think exposes recordInputs/recordOutputs fields (matching the AI SDK's own
TelemetrySettings and the tracing adapter) instead of a single
recordTraceContent flag; agent-think opts into both.

Derive the content-attribute cap from workerd's 64 KiB MAX_SPAN_BYTES total-span
budget (split across the up-to-two content attributes a span can carry, with
headroom for scalar metadata) instead of a flat 4 KiB.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants