Skip to content
Closed
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-spans-opentelemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agents": minor
---

Agents SDK spans (agent lifecycle, `wrapAISDK`, Think) are now recorded through the OpenTelemetry API and register the Workers tracing bridge automatically; names and attributes are unchanged, and failures also set span status `ERROR`. See [Observability](https://github.com/cloudflare/agents/blob/main/docs/agents/observability.md#opentelemetry).
47 changes: 22 additions & 25 deletions design/rfc-opentelemetry-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,31 +132,28 @@ throws is swallowed and reported through `diag`, as in `AgentTracer`.

## One path for Agents SDK spans

Today `AgentTracer` (`src/observability/tracing/`) writes native spans directly
for agent lifecycle spans, the AI SDK wrapper, Think, and the GenAI spans. Those
spans nest correctly with bridged OTel spans in both directions, because both
sit on native async context (covered by the `agent-spans` case of
`src/otel-tests`).

Moving them onto the OTel API is mostly mechanical: `AgentTracer` only needs a
`SpanRuntime` (`startActiveSpan(name, run)` with `isTraced`, `setAttribute`,
`end`), which a few lines can implement over any OTel `Tracer`. This PR does not
do it, because the switch is not behavior-neutral:

- With no OTel registration, agent spans must keep recording natively. The
runtime would use `trace.getTracer("agents")` when a provider is registered
and the bridge's tracer (unregistered) otherwise.
- With a user's own OTel SDK registered, agent spans would move from Workers
tracing to that SDK. That is the goal of "one mechanism", but it is a
user-visible change.
- The bridge refuses spans under remote or inactive parents; native spans
never are. Agent spans opened inside such a context would stop recording.
- `ManagedSpan.fail()` should then call `setStatus(ERROR)` (the reason
`error.type` stands in for status today), and Think/AI-wrapper tests that use
`RecordingTracer` keep working because they inject a `SpanRuntime`.

Once Workers registers the OTel API natively, the bridge steps aside and the
same code paths go to the native implementation.
The bridge is the only code that touches Workers native tracing. `AgentTracer`
(`src/observability/tracing/`), which carries agent lifecycle spans, the AI SDK
wrapper, Think, and the GenAI spans, takes an OTel `Tracer`: the global
`trace.getTracer("agents")`. Deleting the bridge later removes one directory.

- The first Agents SDK span registers the bridge. Agents SDK spans start in
handlers and Durable Object constructors, after module evaluation, so an
application's own module-scope OTel setup registers first; the bridge then
steps aside and Agents SDK spans go to that SDK.
- Span names and attributes are unchanged. Workers Observability's agent views
key on span names (`invoke_agent ...`, `chat ...`), `gen_ai.*` attributes,
and an error derived from exception events or `error.type`. The bridge writes
the same native span-open, attribute, and span-close events as before.
- Failures add span status `ERROR` with no description; no exception event is
recorded, which would change the derived error to the raw message.
- `chat {model}` spans are `CLIENT` (GenAI semantic conventions). Workers spans
have no kind, so this shows only under another OTel SDK.
- With no registered provider (a runtime without `tracing.getActiveSpan()`),
Agents SDK spans are no-ops rather than falling back to a second native path.
- The bridge refuses spans under remote or inactive parents. An Agents SDK span
opened inside such an OTel context is not recorded, where a direct native
span would have been.

## The decision

Expand Down
31 changes: 22 additions & 9 deletions docs/agents/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,16 @@ After that, OpenTelemetry spans from these sources become Workers spans:
- AI SDK v6 `experimental_telemetry`.
- Your own spans from `trace.getTracer(...)`.

The Agents SDK records its own spans (agent lifecycle, `wrapAISDK`, Think)
through the OpenTelemetry API too, and registers the bridge automatically the
first time it records a span. A Worker that runs an Agent, Think,
or `wrapAISDK` therefore needs no `registerOpenTelemetry()` call. Call it
yourself to record OpenTelemetry spans that can start before any Agents SDK
span, for example in a Worker that only serves `createMcpHandler`.

If you register your own OpenTelemetry SDK at module scope, the Agents SDK's
spans go to that SDK instead, like any other OpenTelemetry instrumentation.
Comment on lines +302 to +310

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.

🔍 Synchronize the published observability guide

The documentation rules require matching changes in cloudflare-docs. Confirm the updated OpenTelemetry instructions reach the published guide.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.


They sit in the same trace as the Agents SDK's spans and the automatic fetch,
KV, D1, and Durable Object spans, and they export through
`observability.traces.destinations` like every other Workers span. An MCP tool
Expand Down Expand Up @@ -377,17 +387,20 @@ this one stable parent instead of appearing as top-level clutter. The span
carries `cloudflare.agents.agent.name` (the agent class),
`cloudflare.agents.agent.id` (the named instance, omitted when the name is not
yet readable during construction), and `cloudflare.agents.operation.name`
(`agent_initialization`). Like the rest of the tracing in this package, it is a
no-op when the runtime has no native tracing capability.
(`agent_initialization`). Like the rest of the tracing in this package, it is
recorded through the OpenTelemetry API (see [OpenTelemetry](#opentelemetry)) and
is a no-op when no tracer is available.

## 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
`agents/observability/ai` instruments the Vercel AI SDK through the
OpenTelemetry API, which records Workers spans (see
[OpenTelemetry](#opentelemetry)). 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.
integration is a no-op when no tracer is available. `chat {model}` spans have
span kind `CLIENT`; the others are `INTERNAL`.

**Think agents are traced out of the box.** Enable
`observability.traces.enabled` in `wrangler.jsonc`; no Think option is required.
Expand Down Expand Up @@ -546,9 +559,9 @@ exists:
`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 wrapper 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 wrapper does not invent an
`otel.status_code` attribute when Workers exposes no custom-span status setter.
JSON text under an array-typed key. Failures set span status `ERROR` without a
description and emit `error.type`; the raw error message is never recorded.
Cancellations leave the status unset.

### Context and safety

Expand Down
4 changes: 2 additions & 2 deletions packages/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ src/
base.ts
agent.ts # Agent-level events
mcp.ts # MCP-level events
tracing/ # Native Workers tracing: AgentTracer, native span types
otel/ # registerOpenTelemetry(): OpenTelemetry API on native tracing
tracing/ # AgentTracer over the OpenTelemetry API (Agents SDK spans)
otel/ # The only code on Workers native tracing: the OTel bridge, registerOpenTelemetry()

codemode/ # Experimental code generation
ai.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 @@ -156,7 +156,7 @@ import {
type ObservabilityEvent
} from "./observability/emitter";
import { agentSpanAttributes } from "./observability/agent-span-attributes";
import { tracer } from "./observability/tracing/cloudflare";
import { tracer } from "./observability/tracing/agents";
import {
withInvocationScope,
writeSpanAttributes,
Expand Down
31 changes: 0 additions & 31 deletions packages/agents/src/node-tests/observability-bundle.test.ts

This file was deleted.

176 changes: 176 additions & 0 deletions packages/agents/src/node-tests/otel/agents-tracer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import {
context,
ProxyTracerProvider,
ROOT_CONTEXT,
trace
} from "@opentelemetry/api";
import type { ContextManager, Span, Tracer } from "@opentelemetry/api";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { FakeTracing } from "./fake-tracing";

const REGISTRY = Symbol.for("opentelemetry.js.api.1");

function teardown() {
context.disable();
trace.disable();
}

/** A fresh copy of the Agents SDK tracer and the native tracing it sits on. */
async function load(): Promise<{
tracer: typeof import("../../observability/tracing/agents").tracer;
tracing: FakeTracing;
}> {
vi.resetModules();
const { tracer } = await import("../../observability/tracing/agents");
const { tracing } = await import("./cloudflare-workers");
return { tracer, tracing };
}

function registered(): boolean {
const registry = (globalThis as Record<symbol, { trace?: unknown }>)[
REGISTRY
];
return registry?.trace !== undefined;
}

beforeEach(teardown);
afterEach(teardown);

it("registers the bridge on the first Agents SDK span", async () => {
const { tracer, tracing } = await load();
expect(registered()).toBe(false);

tracing.invocation((root) =>
tracer.withSpan("invoke_agent weather", {}, () => {
// Application OTel spans and platform spans nest under the agent span.
trace.getTracer("app").startActiveSpan("app span", (span) => {
tracing.platform("kv_get");
span.end();
});
expect(root.children.map((span) => span.name)).toEqual([
"invoke_agent weather"
]);
})
);

expect(registered()).toBe(true);
const agent = tracing.recorded.find(
(span) => span.name === "invoke_agent weather"
);
expect(agent?.ended).toBe(true);
expect(agent?.children.map((span) => span.name)).toEqual(["app span"]);
expect(agent?.children[0]?.children.map((span) => span.name)).toEqual([
"kv_get"
]);
});

it("writes exactly the attributes it is given, and error status on failure", async () => {
const { tracer, tracing } = await load();
const attributes = {
"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.name": "weather",
"instrumentation_scope.name": "agents",
"instrumentation_scope.version": "1",
"cloudflare.agents.session.name": undefined,
"gen_ai.usage.input_tokens": 3,
"cloudflare.agents.storage.grouped": true
};

await tracing.invocation(async () => {
await expect(
tracer.withSpan("invoke_agent weather", attributes, async (span) => {
span.finish({ "gen_ai.usage.output_tokens": 1 });
throw new TypeError("secret detail");
})
).rejects.toThrow("secret detail");
});

const agent = tracing.recorded.find(
(span) => span.name === "invoke_agent weather"
);
expect(agent?.attributes).toEqual({
"gen_ai.operation.name": "invoke_agent",
"gen_ai.agent.name": "weather",
"instrumentation_scope.name": "agents",
"instrumentation_scope.version": "1",
"gen_ai.usage.input_tokens": 3,
"cloudflare.agents.storage.grouped": true,
"gen_ai.usage.output_tokens": 1
});
// finish() closed the span before the throw, so nothing else is written.
expect(agent?.status).toEqual({ code: "unset" });
expect(agent?.exceptions).toEqual([]);

await tracing.invocation(async () => {
await expect(
tracer.withSpan("chat model", {}, async () => {
throw new TypeError("secret detail");
})
).rejects.toThrow();
});
const chat = tracing.recorded.find((span) => span.name === "chat model");
expect(chat?.attributes).toEqual({ "error.type": "TypeError" });
expect(chat?.status).toEqual({ code: "error" });
expect(chat?.exceptions).toEqual([]);
});

it("records Agents SDK spans in a Durable Object constructor", async () => {
const { tracer, tracing } = await load();

tracing.invocation((root) =>
tracing.constructing(() => {
// The runtime reports no active span here.
expect(tracing.getActiveSpan()).toBeUndefined();
tracer.withSpan("agent_initialization", {}, () =>
Comment on lines +120 to +124

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.

🔍 Constructor test models hidden parentage

constructing() hides getActiveSpan() but retains the request root for startActiveSpan(). A real Durable Object runtime test can establish whether constructor spans attach the same way.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

tracer.withSpan("initialize_agent_storage", {}, () => undefined)
);
expect(root.children.map((span) => span.name)).toEqual([
"agent_initialization"
]);
expect(root.children[0]?.children.map((span) => span.name)).toEqual([
"initialize_agent_storage"
]);
})
);
expect(registered()).toBe(true);
});

it("steps aside for an application's own OpenTelemetry SDK", async () => {
const { tracer, tracing } = await load();
const started: string[] = [];
const appTracer: Tracer = {
startSpan: () =>
trace.wrapSpanContext({ traceId: "", spanId: "", traceFlags: 0 }),
startActiveSpan<F extends (span: Span) => unknown>(
name: string,
...rest: unknown[]
) {
started.push(name);
const fn = rest.at(-1) as F;
return fn(
trace.wrapSpanContext({ traceId: "", spanId: "", traceFlags: 0 })
) as ReturnType<F>;
}
};
const provider = new ProxyTracerProvider();
provider.setDelegate({ getTracer: () => appTracer });
const manager: ContextManager = {
active: () => ROOT_CONTEXT,
with: (_context, fn, thisArg, ...args) => fn.apply(thisArg, args),
bind: (_context, target) => target,
enable() {
return this;
},
disable() {
return this;
}
};
expect(context.setGlobalContextManager(manager)).toBe(true);
expect(trace.setGlobalTracerProvider(provider)).toBe(true);

tracing.invocation((root) => {
tracer.withSpan("invoke_agent weather", {}, () => undefined);
expect(root.children).toEqual([]);
});
expect(started).toEqual(["invoke_agent weather"]);
});
4 changes: 4 additions & 0 deletions packages/agents/src/node-tests/otel/cloudflare-workers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** `cloudflare:workers` for the node project: native tracing is the model in fake-tracing.ts. */
import { createFakeTracing } from "./fake-tracing";

export const tracing = createFakeTracing();
2 changes: 1 addition & 1 deletion packages/agents/src/node-tests/otel/fake-tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
NativeException,
NativeSpan,
NativeSpanStatus
} from "../../observability/tracing/native";
} from "../../observability/otel/native";

let nextId = 0;

Expand Down
8 changes: 8 additions & 0 deletions packages/agents/src/node-tests/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
alias: {
"cloudflare:workers": fileURLToPath(
new URL("./otel/cloudflare-workers.ts", import.meta.url)
)
}
},
test: {
name: "node",
retry: 3,
Expand Down
2 changes: 1 addition & 1 deletion packages/agents/src/observability/ai/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AISDKInstrumentationOptions } from "./options";
import { createAISDKWrapper } from "./wrapper/wrap";
import { tracer } from "../tracing/cloudflare";
import { tracer } from "../tracing/agents";

/**
* Wraps an AI SDK namespace with tracing.
Expand Down
Loading
Loading