Skip to content
Open
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 `agents/observability/ai` with `wrapAISDK` for the repository's installed AI SDK v6. Instrumented operations produce Cloudflare-native spans using the scalar OpenTelemetry GenAI attributes supported by Workers: `invoke_agent {agent}`, `chat {model}`, and `execute_tool {tool}` spans with request settings, token usage, dashboard-ready total/tool counts, scalar finish reasons, tool-call IDs, and model-call time to first chunk. Failures and cancellations are classified without recording prompts, messages, tool content, schemas, headers, provider options, raw outputs, or raw error messages. The underlying Workers tracing adapter remains internal.
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 with `chat {model}` and `execute_tool {tool}` children in Workers Observability. By default Think emits the class name as `gen_ai.agent.name`, the named instance as `gen_ai.agent.id`, and the opaque Durable Object ID as `gen_ai.conversation.id`, plus `cloudflare.agents.turn.*` attributes (request id, trigger, admission, channel, continuation, generation). Caller telemetry remains authoritative and can override these defaults. 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.
145 changes: 145 additions & 0 deletions docs/agents/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,148 @@ 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 |

## AI SDK tracing

`agents/observability/ai` instruments the Vercel AI SDK with Workers' native
custom spans. It projects the scalar subset supported by the Workers `Span` API
onto the current
[OpenTelemetry GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai).
Spans flow to Workers Observability and configured OTLP destinations. The
integration is a no-op when the runtime has no native tracing capability.

**Think agents are traced out of the box.** Enable
`observability.traces.enabled` in `wrangler.jsonc`; no Think option is required.
A turn gets an `invoke_agent {agent class}` operation span, `chat {model}` model
spans, and `execute_tool {tool}` spans. Think always supplies its durable
identity: `gen_ai.agent.name` is the class name, `gen_ai.agent.id` is the named
instance, and `gen_ai.conversation.id` is the opaque Durable Object ID. These
are defaults; `beforeTurn` can override `functionId` or the corresponding
metadata fields for applications with a different identity model.

### AI SDK v6

Wrap the SDK namespace:

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

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

`wrapAISDK` instruments `generateText`, `streamText`, `generateObject`, and
`streamObject`. Span names use `{operation} {target}` and fall back to the bare
operation past 64 UTF-8 bytes; the full target remains on its semantic
attribute. A model object is wrapped with the SDK's `wrapLanguageModel` helper,
so provider work is a `chat {model}` child of the operation span. Tool
execution is wrapped as `execute_tool {tool}`. Stream spans close on completion,
cancellation, an in-band error, or early consumer return. Async-generator tools
stay open until iteration ends.

### Identity

The AI SDK's canonical OpenTelemetry integration maps `functionId` to
`gen_ai.agent.name`. For direct `wrapAISDK` calls, `functionId` should therefore
be a low-cardinality logical agent name, not a request, user, session, or
Durable Object identifier. The other identity values are read from
`experimental_telemetry.metadata`; an explicit `agentName` takes precedence
over `functionId`.

Think sets all three fields automatically on every inference:

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

For direct v6 wrapper calls, supply identity explicitly:

```ts
await generateText({
model,
prompt: "...",
experimental_telemetry: {
functionId: "booking-agent",
metadata: {
// Stable agent resource/instance identifier.
agentId: "asst_123",
agentVersion: "2026-07-01",
conversationId: "conversation-123"
}
}
});
```

### Emitted data

| Standard attributes | Source and scope |
| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `gen_ai.operation.name` | `invoke_agent`, `chat`, or `execute_tool` on every span |
| `gen_ai.agent.id`, `gen_ai.agent.name`, `gen_ai.agent.version` | Explicit operation identity; Think defaults ID and name, but not version |
| `gen_ai.conversation.id` | Explicit operation conversation; Think defaults to its opaque Durable Object ID |
| `gen_ai.provider.name` | Normalized provider on operation/model spans when known |
| `gen_ai.output.type` | Requested `text` or `json` output on operation/model spans |
| `gen_ai.request.model` | Requested model when known |
| `gen_ai.request.frequency_penalty`, `gen_ai.request.presence_penalty` | Numeric request settings when supplied |
| `gen_ai.request.max_tokens`, `gen_ai.request.seed`, `gen_ai.request.top_k` | Integer request settings when supplied |
| `gen_ai.request.stream` | `true` on streaming operations; omitted otherwise |
| `gen_ai.request.temperature`, `gen_ai.request.top_p` | Numeric request settings when supplied |
| `gen_ai.response.id`, `gen_ai.response.model` | Model-call response metadata when actually reported; never inferred from the requested model |
| `gen_ai.response.time_to_first_chunk` | Model-call streaming latency in seconds |
| `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens` | Aggregate usage on operations and per-call usage on model spans |
| `gen_ai.usage.cache_creation.input_tokens`, `gen_ai.usage.cache_read.input_tokens` | Provider cache usage when reported |
| `gen_ai.usage.reasoning.output_tokens` | Reasoning output usage when reported |
| `gen_ai.tool.name`, `gen_ai.tool.type`, `gen_ai.tool.call.id` | Tool identity; call ID only when available |
| `user.id` | Explicit metadata key `user.id` |
| `error.type` | Low-cardinality error class; raw error messages are never recorded |

The adapter also emits a small vendor namespace where no standard equivalent
exists:

| Attribute | Meaning |
| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `cloudflare.agents.integration.name` | Instrumentation source (`ai-sdk`) |
| `cloudflare.agents.operation.name` | Original SDK operation (`streamText`, `doStream`, `tool.execute`, etc.) |
| `cloudflare.agents.response.finish_reason` | One finish reason as a scalar |
| `cloudflare.agents.tool.count` | Precomputed tool-call count for dashboards |
| `cloudflare.agents.usage.total_tokens` | Provider total, or input plus output when both are known |
| `cloudflare.agents.runtime_context.{key}` | Explicitly included scalar runtime context |
| `cloudflare.agents.metadata.{key}` | Other scalar telemetry metadata |
| `cloudflare.agents.turn.{request_id,trigger,admission,channel,continuation,generation}` | Think turn context |
| `cloudflare.agents.canceled` | Recognized cancellation, not a failure |

`gen_ai.response.finish_reasons` and `gen_ai.request.stop_sequences` are arrays
in OTel. Workers' custom `Span.setAttribute` currently accepts only a string,
number, or boolean, so the adapter omits those attributes rather than placing
JSON text under an array-typed key. Similarly, span status is state rather than
an attribute: failures emit `error.type`, but the adapter does not invent an
`otel.status_code` attribute when Workers exposes no custom-span status setter.

### Context and safety

The wrapper never emits prompts, messages, system instructions, tool inputs,
tool outputs, schemas, headers, provider options, raw model output, or raw error
messages. Metadata and context values must be scalar; objects and arrays are
dropped.

AI SDK v6 exposes `experimental_context`. Configure its allowlist on the wrapper:

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

await traced.generateText({
model,
prompt: "Will I need an umbrella?",
experimental_context: {
requestId: "req-123",
tenantId: "tenant-42"
}
});
```

Do not include tokens, credentials, user input, or other secrets. Context
filtering reduces accidental exposure; it is not a security boundary.
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
18 changes: 18 additions & 0 deletions packages/agents/src/observability/ai/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { AISDKInstrumentationOptions } from "./options";
import { createAISDKV6Wrapper } from "./v6/wrap";
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
});
}

export type { AISDKInstrumentationOptions } from "./options";
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 v6 adapter. */
export type AISDKInstrumentationOptions = {
/** AI SDK v6 `experimental_context` keys to emit as scalar attributes. */
readonly includeRuntimeContext?: 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