Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agents-observability-ai-tracing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": minor
---

Add Cloudflare-native AI tracing to observability, aligned with the OpenTelemetry GenAI semantic conventions. `agents/observability` now exports `tracer` — an `AgentTracer` built on the Workers runtime `tracing` API (no-op on runtimes without it) with `withSpan` (tracer-owned lifetime) and `openSpan` (caller-owned, for streams) — plus the `AgentSpan`, `TraceAttributes`, and `TraceAttributeValue` types. The new `agents/observability/ai` entry exports `wrapAISDK` (AI SDK v6 wrapper) and `createAISDKTelemetry` (AI SDK v7 telemetry lifecycle adapter). Instrumented operations produce semconv-named spans — `invoke_agent {agent}` roots with `chat {model}` and `execute_tool {tool}` children (bare-operation fallback past 64 bytes) — carrying `gen_ai.*` attributes (request params, token usage including cache and reasoning tokens, finish reasons, `gen_ai.tool.call.id`, `gen_ai.response.time_to_first_chunk`) with vendor extensions under `cloudflare.agents.*`. Failures record `otel.status_code: "ERROR"` + `error.type`; cancellations (including AI SDK v6 in-band abort chunks) record `cloudflare.agents.canceled: true` and are not errors. Only scalar, non-sensitive attributes are emitted — never prompt, message, or tool content.
5 changes: 5 additions & 0 deletions .changeset/think-out-of-box-tracing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/think": minor
---

Think agents emit Cloudflare-native traces out of the box — zero configuration, no new API surface. Every turn's inference call is routed through `agents/observability/ai`, producing an `invoke_agent {agent class}` root span (carrying agent/conversation identity and `cloudflare.agents.turn.*` attributes: request id, trigger, admission, channel, continuation, generation) with `chat {model}` and `execute_tool {tool}` children in Workers Observability. Caller-provided `experimental_telemetry` merges over the injected metadata and still flows to the AI SDK's own telemetry when enabled. Drain loops now finalize the underlying model stream on early exit (in-stream error, stall abort, user abort) so operation spans close instead of leaking. On runtimes without the `tracing` API the tracer is a no-op.
85 changes: 85 additions & 0 deletions docs/agents/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,88 @@ These events are emitted by `AIChatAgent` from `@cloudflare/ai-chat`. They track
| --------------- | ------------------------ | --------------------- |
| `email:receive` | `{ from, to, subject? }` | An email is received |
| `email:reply` | `{ from, to, subject? }` | A reply email is sent |

## Tracing

Alongside diagnostics-channel events, `agents/observability` exports a tracer built on the Workers runtime's native `tracing` API (`cloudflare:workers`). Spans follow the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) and flow to your tail worker or observability pipeline like any other Workers spans. On runtimes without the `tracing` API, the tracer is a no-op.

```ts
import { tracer } from "agents/observability";

const result = tracer.withSpan("my-operation", { "app.step": "ingest" }, () =>
doWork()
);
```

`tracer.withSpan` owns the span lifetime — it finishes when the callback returns or its promise settles. For work that outlives a callback (streams, event-driven telemetry), `tracer.openSpan` hands you the span and you must call `span.finish()` or `span.fail(cause)`.

Failed spans carry `otel.status_code: "ERROR"` and `error.type` (the OpenTelemetry status encoding for status-less backends). Cancellations (an `AbortError`) are not errors: they carry `cloudflare.agents.canceled: true` and leave the status untouched, matching OTel semantics.

### AI SDK tracing

`agents/observability/ai` instruments the Vercel AI SDK's text/object generation path.

**Think agents are traced out of the box** — no configuration. Every turn's inference call becomes an `invoke_agent {agent class}` root span carrying the agent/conversation identity plus turn attributes (`cloudflare.agents.turn.request_id`, `.trigger`, `.admission`, `.channel`, …), with `chat {model}` and `execute_tool {tool}` children. Enable `observability: { traces: { enabled: true } }` in `wrangler.jsonc` to see them. Per-call `experimental_telemetry.metadata` from `beforeTurn` is merged in (caller values win): reserved keys map to their dedicated attributes, `userId` maps to `user.id`, and any other scalar entry lands as `cloudflare.agents.metadata.{key}`.

For AI SDK v6, wrap the SDK namespace:

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

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

The wrapper instruments `generateText`, `streamText`, `generateObject`, and `streamObject`. Span names follow the GenAI semconv formula, falling back to the bare operation when the combined name would exceed 64 bytes: each operation gets a root `invoke_agent {agent name}` span (`gen_ai.operation.name: "invoke_agent"` — query on the attribute, not the name); when `wrapLanguageModel` is available, provider `doGenerate` / `doStream` calls get child `chat {model}` spans, and `tools.*.execute` calls get `execute_tool {tool}` spans carrying `gen_ai.tool.call.id`. Stream spans stay open until the returned stream is consumed, cancelled, errors, or is returned early, and record `gen_ai.response.time_to_first_chunk` (seconds). Streaming tools (async-generator `execute`) keep their tool span open until the iterable is consumed.

For AI SDK v7, register the telemetry lifecycle adapter instead:

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

registerTelemetry(createAISDKTelemetry());
```

The v7 adapter creates the same operation, language-model, and tool-execution spans through AI SDK telemetry callbacks, correlated with `cloudflare.agents.call.id` / `gen_ai.tool.call.id` attributes.

### Agent and conversation attributes

`gen_ai.agent.id`, `gen_ai.agent.name`, `gen_ai.agent.version`, and `gen_ai.conversation.id` are read from the AI SDK's own `experimental_telemetry` option, per call:

```ts
await generateText({
model,
prompt: "...",
experimental_telemetry: {
// Falls back to gen_ai.agent.name when metadata.agentName is absent.
functionId: "support-agent",
metadata: {
agentId: "agent-123",
agentVersion: "2026-07-01",
conversationId: "conversation-123"
}
}
});
```

### Safety defaults

The adapters do not emit prompts, messages, system instructions, tool inputs, tool outputs, schemas, headers, provider options, raw model outputs, or raw error messages. Only scalar attributes are emitted.

Runtime/tool context attributes are opt-in. For v6, pass allowlists to `wrapAISDK`:

```ts
const traced = wrapAISDK(ai, {
includeRuntimeContext: ["requestId"],
includeToolsContext: {
weather: ["defaultUnit"]
}
});
```

For v7, use the AI SDK's per-call `telemetry.includeRuntimeContext` and `telemetry.includeToolsContext` options instead — the SDK filters `runtimeContext` / `toolsContext` before telemetry integrations receive events, and the adapter emits the scalar fields the SDK includes.

### Not instrumented

`embed` / `embedMany`, `rerank`, `Agent` / `ToolLoopAgent`, automatic instrumentation or loader hooks, and prompt/message/tool-definition content capture are intentionally out of scope. Use the adapters as explicit compatibility wrappers.
5 changes: 5 additions & 0 deletions packages/agents/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@
"import": "./dist/observability/index.js",
"require": "./dist/observability/index.js"
},
"./observability/ai": {
"types": "./dist/observability/ai/index.d.ts",
"import": "./dist/observability/ai/index.js",
"require": "./dist/observability/ai/index.js"
},
"./react": {
"types": "./dist/react.d.ts",
"import": "./dist/react.js",
Expand Down
1 change: 1 addition & 0 deletions packages/agents/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const entries = [
"src/mcp/do-oauth-client-provider.ts",
"src/mcp/x402.ts",
"src/observability/index.ts",
"src/observability/ai/index.ts",
"src/codemode/ai.ts",
"src/experimental/memory/session/index.ts",
"src/experimental/memory/utils/index.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ import {
genericObservability,
type Observability,
type ObservabilityEvent
} from "./observability";
} from "./observability/events";
import { DisposableStore } from "./core/events";
import { MessageType } from "./types";
import { RPC_DO_PREFIX } from "./mcp/rpc";
Expand Down
34 changes: 34 additions & 0 deletions packages/agents/src/observability/ai/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { AISDKInstrumentationOptions } from "./options";
import { createAISDKV6Wrapper } from "./v6/wrap";
import { createAISDKV7Telemetry } from "./v7/telemetry";
import type { AISDKV7Telemetry } from "./v7/types";
import { tracer } from "../tracing/cloudflare";

/**
* Wraps an AI SDK namespace with tracing.
*/
export function wrapAISDK<T extends Record<string, unknown>>(
ai: T,
options: AISDKInstrumentationOptions = {}
): T {
return createAISDKV6Wrapper(ai, {
options,
tracer
});
}

/**
* Creates an AI SDK v7 telemetry adapter for use with `registerTelemetry` or
* per-call telemetry configuration.
*/
export function createAISDKTelemetry(
options: AISDKInstrumentationOptions = {}
): AISDKV7Telemetry {
return createAISDKV7Telemetry({
options,
tracer
});
}

export type { AISDKInstrumentationOptions } from "./options";
export type { AISDKV7Telemetry } from "./v7/types";
5 changes: 5 additions & 0 deletions packages/agents/src/observability/ai/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** Instrumentation options for the AI SDK adapter. */
export type AISDKInstrumentationOptions = {
readonly includeRuntimeContext?: readonly string[];
readonly includeToolsContext?: Readonly<Record<string, readonly string[]>>;
};
38 changes: 38 additions & 0 deletions packages/agents/src/observability/ai/read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/** Narrows an unknown value to a string. */
export function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

/** Narrows an unknown value to a number. */
export function readNumber(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}

/** AI SDK token counts are either a plain number or `{ total?: number, ... }`. */
export function readTokenCount(value: unknown): number | undefined {
if (typeof value === "number") {
return value;
}

if (typeof value === "object" && value !== null) {
// SAFETY: AI SDK nested token count has a numeric total field.
const total = (value as Record<string, unknown>).total;
return typeof total === "number" ? total : undefined;
}

return undefined;
}

/** Reads a numeric sub-field from a nested AI SDK token count object. */
export function readNestedTokenField(
value: unknown,
key: string
): number | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}

// SAFETY: AI SDK nested token count has known numeric sub-fields.
const nested = (value as Record<string, unknown>)[key];
return typeof nested === "number" ? nested : undefined;
}
Loading
Loading