---
title: Claude Agent SDK Observability With OpenTelemetry (2026)
description: 'Instrument a Claude Agent SDK loop with OpenTelemetry in TypeScript: enable trace export, read the claude_code span tree, and ship real spans to a backend.'
url: https://nerdleveltech.com/claude-agent-sdk-opentelemetry-typescript-tutorial
language: en
date: '2026-07-15'
category: ai-ml
tags:
  - claude agent sdk
  - opentelemetry
  - observability
  - ai agents
  - typescript
  - tracing
  - agent evaluation
  - langfuse
markdown_url: https://nerdleveltech.com/claude-agent-sdk-opentelemetry-typescript-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/claude-agent-sdk-opentelemetry-typescript-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Claude Agent SDK Observability With OpenTelemetry (2026)

The Claude Agent SDK can export every model request, tool call, and hook execution in an agent run as OpenTelemetry traces — but tracing is off by default and gated behind a beta flag, and the span tree it produces has its own naming scheme that only partially overlaps with the OpenTelemetry GenAI semantic conventions[^1][^2]. This tutorial turns tracing on in TypeScript, reads the real span hierarchy, and wires it to a backend you can actually look at.

## TL;DR

Set `CLAUDE_CODE_ENABLE_TELEMETRY=1` and `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1`, point `OTEL_TRACES_EXPORTER=otlp` at a collector endpoint, and every agent turn becomes a `claude_code.interaction` root span with child spans for each model request (`claude_code.llm_request`), each tool call (`claude_code.tool`, with `.blocked_on_user` and `.execution` sub-spans), and each hook (`claude_code.hook`, beta-gated separately)[^1][^2]. Five attributes on those spans — `gen_ai.system`, `gen_ai.request.model`, `gen_ai.response.id`, `gen_ai.response.finish_reasons`, and `gen_ai.tool.call.id` — are explicitly documented as OpenTelemetry GenAI semantic convention attributes; everything else, including the span names themselves, is Anthropic's own `claude_code.*` namespace[^2]. Runtime: Node 18+ (built and type-checked against Node 22.22.3). SDK: `@anthropic-ai/claude-agent-sdk@0.3.210`[^3]. Build time: 25-30 minutes.

## What you'll learn

- Which environment variables turn on traces, metrics, and log events independently, and why traces need one flag the other two signals don't[^1]
- The exact span hierarchy the Agent SDK emits, down to the attribute level, and which attributes only appear when you opt in to capturing content[^2]
- How to build a minimal tool-calling agent worth tracing, and point its telemetry at a local receiver you can actually read
- How to make the Claude Agent SDK's `claude_code.interaction` span show up as a child of your own application's trace, instead of a disconnected root
- Whether Claude Agent SDK tracing actually follows the OpenTelemetry GenAI semantic conventions, and what that distinction means for your dashboards
- How to get per-request cost and latency two different ways — one that needs no OpenTelemetry at all — and when to reach for each

## Prerequisites

- Node.js 18.0.0 or later — the SDK's `package.json` still sets `"engines": {"node": ">=18.0.0"}` at the current version; this tutorial was built and type-checked against Node 22.22.3[^3]
- `@anthropic-ai/claude-agent-sdk@0.3.210` (the registry-confirmed `latest` as of this writing, published 2026-07-14), plus peer dependencies `zod@^4.0.0`, `@anthropic-ai/sdk@>=0.93.0`, and `@modelcontextprotocol/sdk@^1.29.0`[^3]
- TypeScript 5.x or later with `strict` enabled (this tutorial's code was type-checked with TypeScript 7.0.2, the `latest` tag on npm as of this writing)
- An `ANTHROPIC_API_KEY` if you want to run the agent loop end to end and see real spans arrive at a collector — the code below is type-checked against the real SDK, but this automated writing pipeline doesn't hold a paid API key, so live model calls were not executed; see Verification for exactly what was and wasn't run

## Why doesn't `total_cost_usd` already solve this?

The Agent SDK's response stream already includes a `result` message with `total_cost_usd`, `duration_ms`, and token usage for the whole `query()` call[^4]. That's enough to log "this run cost $0.03." It is not enough to answer "which single tool call in that run accounted for most of the latency," "did the model have to retry before a particular tool call succeeded," or "is one specific MCP server's tool consistently the slow part of every run." Those questions need a span per step, not one aggregate number per run — which is exactly what OpenTelemetry tracing adds on top of the result message, not instead of it.

## Step 1: Set up the project

```bash
mkdir agent-observability && cd agent-observability
npm init -y
npm install @anthropic-ai/claude-agent-sdk@0.3.210 zod@4
npm install -D typescript@7 @types/node@26
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --strict
```

Add `"type": "module"` to `package.json` — the SDK itself ships as `"type": "module"`, and `tsc --moduleResolution NodeNext` expects the consuming project to match[^3].

## Step 2: Turn on the three telemetry signals

Metrics, log events, and traces are three independent signals, each with its own exporter switch, and all three are off until you flip the master switch[^1]:

```typescript
// otel-env.ts
export const otelEnv = {
  ...process.env,
  // Master switch — nothing exports without this
  CLAUDE_CODE_ENABLE_TELEMETRY: "1",
  // Traces specifically are beta and need this second flag;
  // metrics and log events do not.
  CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: "1",
  OTEL_TRACES_EXPORTER: "otlp",
  OTEL_METRICS_EXPORTER: "otlp",
  OTEL_LOGS_EXPORTER: "otlp",
  OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
  OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318",
  OTEL_SERVICE_NAME: "weather-agent-demo",
};
```

Two things that are easy to get wrong here. First, never set `console` as an exporter value when running through the SDK — the SDK uses stdout as its own message channel, so a console exporter's output gets interleaved with (and can corrupt) the protocol it's reading[^1]. Point `OTEL_EXPORTER_OTLP_ENDPOINT` at a real local receiver instead, which is exactly what Step 5 builds.

Second, in TypeScript, whatever you pass as `options.env` **replaces** the subprocess's environment entirely rather than merging with it — the installed SDK's own type definition spells this out verbatim: "this value REPLACES the subprocess environment entirely — it is not merged with `process.env`"[^4]. That's why `otelEnv` above spreads `...process.env` first; skip that and the child process loses `PATH`, `HOME`, and `ANTHROPIC_API_KEY`. Python's `ClaudeAgentOptions.env` behaves differently — it merges on top of the inherited environment instead of replacing it[^1].

## Step 3: Give the agent a tool worth tracing

A single-turn "hello world" query only produces one `llm_request` span. To see the full hierarchy — including tool spans — the agent needs at least one tool call. This is a minimal weather tool built with the SDK's own `tool()` and `createSdkMcpServer()` helpers:

```typescript
// weather-tool.ts
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

const getWeather = tool(
  "get_weather",
  "Gets the current weather for a given city",
  { city: z.string() },
  async (args) => {
    const weatherData: Record<string, string> = {
      Berlin: "Cloudy, 15°C",
      "New York": "Sunny, 24°C",
    };
    const weather = weatherData[args.city] ?? "Weather data not available";
    return {
      content: [{ type: "text", text: `Weather in ${args.city}: ${weather}` }],
    };
  }
);

export const weatherServer = createSdkMcpServer({
  name: "weather",
  version: "1.0.0",
  tools: [getWeather],
});
```

Wire it into a `query()` call alongside the telemetry environment from Step 2:

```typescript
// index.ts
import { query } from "@anthropic-ai/claude-agent-sdk";
import { otelEnv } from "./otel-env.js";
import { weatherServer } from "./weather-tool.js";

async function main() {
  for await (const message of query({
    prompt: "What's the weather like in Berlin?",
    options: {
      model: "claude-opus-4-8",
      mcpServers: { weather: weatherServer },
      allowedTools: ["mcp__weather__get_weather"],
      env: otelEnv,
    },
  })) {
    if (message.type === "assistant") {
      console.log(message.message.content);
    }
    if (message.type === "result" && message.subtype === "success") {
      console.log("total_cost_usd:", message.total_cost_usd);
      console.log("duration_ms:", message.duration_ms);
    }
  }
}

main();
```

This runs on Claude Opus 4.8 (`claude-opus-4-8`) — Anthropic's own guidance says to "start with Claude Opus 4.8 for complex agentic coding and enterprise work" if you're unsure which model to use[^5].

## Step 4: Read the span tree

With tracing on, one user turn produces this hierarchy — this is the documented tree, reproduced verbatim from the monitoring reference[^2]:

```text
claude_code.interaction
├── claude_code.llm_request
├── claude_code.hook                    (requires detailed beta tracing)
└── claude_code.tool
    ├── claude_code.tool.blocked_on_user
    ├── claude_code.tool.execution
    └── (Agent tool) subagent claude_code.llm_request / claude_code.tool spans
```

`claude_code.interaction` is the root: one per user turn, carrying `user_prompt_length`, `interaction.sequence`, and `interaction.duration_ms`. `claude_code.llm_request` wraps each call to the Claude API and is where cost- and latency-relevant attributes live: `model`, `duration_ms`, `ttft_ms` (time to first token), `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens`, `stop_reason`, and `success`[^2].

`claude_code.tool` wraps each tool invocation with `tool_name`, `duration_ms`, and `result_tokens`, and splits into two children: `claude_code.tool.blocked_on_user` (time spent waiting on a permission decision, with `decision: accept|reject`) and `claude_code.tool.execution` (the actual run, with `success`/`error`)[^2]. Only three span types ever carry an `ERROR` status — `llm_request`, `tool.execution`, and `hook` — everything else always ends `UNSET` even on failure, because failure for those spans is expressed through their own child spans instead[^2].

`claude_code.hook` is a special case: it needs `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` plus two additional variables, `ENABLE_BETA_TRACING_DETAILED=1` and `BETA_TRACING_ENDPOINT`. In interactive CLI sessions it also requires your organization to be allowlisted for the feature — but Agent SDK and non-interactive `-p` sessions are explicitly **not** gated by that allowlist requirement[^2], so hook spans are available to SDK consumers today even where they aren't yet to everyone using the CLI directly.

## Do these spans follow the OpenTelemetry GenAI semantic conventions?

Partially, and it's worth being precise about which parts. The span *names* — `claude_code.interaction`, `claude_code.llm_request`, `claude_code.tool` — are Anthropic's own namespace, not the GenAI conventions' own naming scheme; the conventions themselves define operation-based span names instead, such as `invoke_agent` for an agent run, `chat` for an LLM call, and `execute_tool` for a tool invocation[^7]. Those conventions remain in experimental/development status as of mid-2026 and have moved into their own dedicated `semantic-conventions-genai` repository, separate from the main OpenTelemetry spec[^7]. But a specific, named subset of *attributes* on Claude Agent SDK's `claude_code.*` spans is explicitly documented as implementing the GenAI convention regardless of the span-name mismatch:

| Span | Anthropic-specific attributes (examples) | OpenTelemetry GenAI semantic convention attributes |
| --- | --- | --- |
| `claude_code.llm_request` | `model`, `duration_ms`, `ttft_ms`, `input_tokens`, `output_tokens`, `stop_reason`, `query_source` | `gen_ai.system` (always `"anthropic"`), `gen_ai.request.model`, `gen_ai.response.id`, `gen_ai.response.finish_reasons` |
| `claude_code.tool` / `.execution` | `tool_name`, `result_tokens`, `file_path`, `full_command` | `gen_ai.tool.call.id` |

Source: the monitoring reference's attribute tables, which label each of these five attributes "OpenTelemetry GenAI semantic convention" individually[^2]. The practical consequence: a backend that dashboards purely by GenAI-convention span *names* won't recognize `claude_code.llm_request` as a GenAI operation out of the box, since the name itself isn't part of that convention — but it will find the five `gen_ai.*` attributes if it queries by attribute instead of by span name. Don't assume full compliance, and don't assume zero relation either.

One of those five is worth its own caveat: `gen_ai.system` does not appear anywhere in the current OpenTelemetry GenAI attribute registry — the spec has since renamed it to `gen_ai.provider.name` for the same "which provider handled this call" purpose[^7].

Anthropic's monitoring documentation still labels `gen_ai.system` as the GenAI-convention attribute Claude Code emits, which means either the CLI's span still literally carries that key, or Anthropic's own docs haven't been updated to the current attribute name — this post reports what Anthropic's documentation and the SDK actually say today, not what the newest spec revision calls for, so don't assume `gen_ai.system` is the key name a spec-current GenAI backend or dashboard expects.

If you'd rather instrument a hand-rolled tool-calling loop directly against the current GenAI conventions — `invoke_agent`/`chat`/`execute_tool` spans and `gen_ai.provider.name` rather than the Agent SDK's `claude_code.*` export — [the OpenTelemetry GenAI semantic conventions tracing tutorial](https://nerdleveltech.com/opentelemetry-claude-agent-tracing-typescript-tutorial) builds exactly that, from the raw Messages API rather than the Agent SDK.

## Step 5: See real spans without paying for a backend

You don't need Jaeger or a commercial vendor to inspect what the CLI actually sends — a plain HTTP server that logs the request body works, as long as you use the `http/json` protocol (readable JSON) instead of `http/protobuf`:

```typescript
// receiver.ts
import http from "node:http";

const server = http.createServer((req, res) => {
  if (req.method === "POST" && req.url === "/v1/traces") {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", () => {
      const parsed = JSON.parse(body);
      for (const rs of parsed.resourceSpans ?? []) {
        for (const ss of rs.scopeSpans ?? []) {
          for (const span of ss.spans ?? []) {
            console.log(`${span.name}`);
          }
        }
      }
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end("{}");
    });
  } else {
    res.writeHead(404);
    res.end();
  }
});

server.listen(4318, () => {
  console.log("Listening on http://localhost:4318/v1/traces");
});
```

Run it directly with `node receiver.ts` — Node 22.18.0 and later run TypeScript files with only erasable syntax like this one natively, with no flag or compile step. On Node 22.6.0 through 22.17.x, add the flag explicitly: `node --experimental-strip-types receiver.ts`. Earlier releases, including all of Node 18.x and 20.x, don't support running `.ts` files natively at all — use `npx tsx receiver.ts` or compile with `tsc` first[^8].

Then run `index.ts` from Step 3 against it. For production use, swap this for an actual collector or an all-in-one Jaeger container — the official guidance is explicit that this kind of minimal listener is for local inspection, not for running a real pipeline[^1].

## Step 6: Link the trace to your own application span

By default, an agent run started from a script shows up in your backend as an isolated root trace. If your application already has its own OpenTelemetry instrumentation, wrap the `query()` call in an active span from `@opentelemetry/api`, and the SDK will inject `TRACEPARENT`/`TRACESTATE` into the CLI subprocess's environment automatically, making `claude_code.interaction` a child of your span instead of a new root[^1]:

```typescript
// traced-request.ts
import { trace } from "@opentelemetry/api";
import { query } from "@anthropic-ai/claude-agent-sdk";
import { otelEnv } from "./otel-env.js";

const tracer = trace.getTracer("weather-agent-demo");

export async function handleUserRequest(prompt: string) {
  return tracer.startActiveSpan("handle-user-request", async (span) => {
    try {
      for await (const message of query({
        prompt,
        options: { model: "claude-opus-4-8", env: otelEnv },
      })) {
        if (message.type === "result" && message.subtype === "success") {
          span.setAttribute("total_cost_usd", message.total_cost_usd);
        }
      }
    } finally {
      span.end();
    }
  });
}
```

Auto-injection is skipped if you set `TRACEPARENT` explicitly in `options.env` yourself — useful if you need to pin a specific parent context rather than whatever span happens to be active. Interactive CLI sessions ignore inbound `TRACEPARENT` entirely to avoid picking up ambient values from CI or containers; only Agent SDK and `claude -p` runs honor it[^1][^2]. One more scope limit worth knowing: the `traceparent` header is only sent to the Anthropic API directly by default. If you're routing through a custom `ANTHROPIC_BASE_URL` proxy, propagation is off unless you also set `CLAUDE_CODE_PROPAGATE_TRACEPARENT=1`, since some proxies reject unrecognized headers[^2].

## Step 7: Tag traces by service, user, and tenant

The default `service.name` on every span is `claude-code`[^1]. If more than one agent in your system exports to the same collector, override it and attach whatever metadata you need to filter by later:

```typescript
const options = {
  env: {
    ...otelEnv,
    OTEL_SERVICE_NAME: "support-triage-agent",
    OTEL_RESOURCE_ATTRIBUTES:
      `service.version=1.4.0,deployment.environment=production,enduser.id=${encodeURIComponent(userId)},tenant.id=${encodeURIComponent(tenantId)}`,
  },
};
```

Percent-encode attribute values before interpolating them — `OTEL_RESOURCE_ATTRIBUTES` reserves commas, spaces, and equals signs as its own delimiters[^1]. With `enduser.id`/`tenant.id` attached, the `tool_decision`, `tool_result`, `mcp_server_connection`, and `permission_mode_changed` log events become a per-user audit trail you can forward to a SIEM, instead of just attributing every action to whichever credential the process runs under[^1].

## Controlling what gets captured

Spans are structural by default — durations, model names, and tool names are always recorded, but token counts are only recorded when the underlying API call actually returns usage data (so a failed or aborted request's span may simply omit them), and the *content* your agent reads and writes is never captured unless you opt in[^1][^2]:

| Variable | What it adds |
| --- | --- |
| `OTEL_LOG_USER_PROMPTS=1` | Prompt text on the `claude_code.interaction` span's `user_prompt` attribute |
| `OTEL_LOG_TOOL_DETAILS=1` | Tool input arguments — file paths, shell commands, search patterns — on `claude_code.tool` |
| `OTEL_LOG_TOOL_CONTENT=1` | Full tool input/output bodies as a span event, truncated at 60 KB; requires tracing to already be on |
| `OTEL_LOG_RAW_API_BODIES` | Full Messages API request/response JSON; `1` truncates inline at 60 KB, `file:<dir>` writes untruncated bodies to disk |

Leave all four unset unless your observability pipeline is specifically approved to store what your agent handles — enabling the raw-bodies variable implies consent to everything the other three would separately reveal[^1].

## Shipping traces to a real backend

Two genuinely different routes get you to a dashboard, and they instrument different things:

| | Native CLI export (this tutorial) | OpenInference instrumentation |
| --- | --- | --- |
| What's actually instrumented | The Claude Code CLI subprocess the SDK spawns | Your own process's SDK calls, patched by a JS/Python instrumentation library |
| Span names you get | `claude_code.*` | OpenInference's own span shape |
| Extra packages needed | None — just environment variables | `@arizeai/openinference-instrumentation-claude-agent-sdk`, `@langfuse/otel`, `@opentelemetry/sdk-node`[^6] |
| Where it can go | Any OTLP-compatible backend: Honeycomb, Datadog, Grafana, a self-hosted collector, or Langfuse[^1] | Whichever exporter you wire the `NodeSDK` to — Langfuse's own docs demonstrate this route specifically[^6] |

The OpenInference route (used by Langfuse's official JS/TS integration) is a separate community instrumentation layer, not an alternative configuration of the same CLI-level export this tutorial builds — the two can coexist, but don't expect them to produce identical span trees[^6]. If your goal is prompt/response review, scoring, and dataset curation, the OpenInference-to-Langfuse route is built for that. If your goal is ops-style dashboards, cost attribution per tenant, or a SIEM-forwardable audit trail, the native CLI export this tutorial covers is the more direct path, since it doesn't require adding an instrumentation dependency to your own process at all.

## Cost and latency without any of this

If all you need is one number per run, you don't need OpenTelemetry at all. The `result` message on the SDK's own response stream already carries `total_cost_usd`, `duration_ms`, `num_turns`, and a full `usage`/`modelUsage` breakdown for the whole `query()` call, with no exporter configuration required[^4]. Tracing is for when that one aggregate number isn't enough to answer *which step* was expensive — the two are complementary, not competing, and Anthropic's own observability guide links out to the cost-tracking doc for exactly this reason[^1].

## Verification

**What was actually run:** every code sample above was installed against the real, currently-published `@anthropic-ai/claude-agent-sdk@0.3.210` (npm registry, confirmed 2026-07-15) plus `zod@4.4.3`, `@types/node@26.1.1`, `typescript@7.0.2`, and `@opentelemetry/api@1.9.1`, and type-checked with `tsc --strict --target es2022 --module nodenext --moduleResolution nodenext`. All five TypeScript files — `otel-env.ts`, `weather-tool.ts`, `index.ts`, `traced-request.ts`, and `receiver.ts` — compiled together with zero errors. The `receiver.ts` server was actually started (`node`, listening on port 4318) and sent a hand-constructed OTLP/JSON payload via `curl` containing the real span names from Step 4; the receiver correctly parsed it and printed all three span names, confirming the parsing logic works against the documented payload shape.

**What was not run:** a live `query()` call against the real Claude API. That requires a paid `ANTHROPIC_API_KEY`, which this automated writing pipeline doesn't have access to, so no genuine `claude_code.*` spans were captured from an actual agent run — the span tree and attribute names in this post are transcribed directly from Anthropic's own documented reference, not from an independent capture[^2]. If you have a key, run:

```bash
export ANTHROPIC_API_KEY=sk-ant-...
npx tsx index.ts
```

You should see the weather answer printed, followed by `total_cost_usd` and `duration_ms` from the `result` message, and — if `receiver.ts` is running and `OTEL_EXPORTER_OTLP_ENDPOINT` points at it — a `claude_code.interaction` line followed by `claude_code.llm_request` and `claude_code.tool` lines in the receiver's own console.

## Troubleshooting

**No spans arrive anywhere.** Confirm both `CLAUDE_CODE_ENABLE_TELEMETRY=1` and `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1` are set — traces need both, unlike metrics and logs, which only need the first[^1][^2].

**Spans arrive but every attribute you expected is missing.** Content capture is opt-in. If `user_prompt`, tool inputs, or tool bodies are missing, that's the default redaction behavior, not a bug — set the relevant `OTEL_LOG_*` variable from the table above[^1].

**Nothing shows up in `stdout`, or the process seems to hang oddly.** Check you haven't set `console` as an exporter value — the SDK already uses stdout as its message channel, and the two will collide[^1].

**Traces don't nest under my application's span.** Confirm your own span is genuinely *active* (via `startActiveSpan`, not just created) at the moment `query()` is called, and that you haven't set `TRACEPARENT` explicitly in `options.env`, which skips auto-injection entirely[^1].

**Everything above works locally but nothing arrives when routed through a custom base URL.** If `ANTHROPIC_BASE_URL` points at anything other than the Anthropic API directly, set `CLAUDE_CODE_PROPAGATE_TRACEPARENT=1` — propagation to non-Anthropic endpoints is off by default[^2].

## FAQ

### Is Claude Agent SDK tracing stable?

No. It's explicitly documented as beta — span names and attributes may change between SDK releases, which is also why it needs its own `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA` flag separate from metrics and logs[^1][^2].

### Do Claude Agent SDK traces use the OpenTelemetry GenAI semantic conventions?

Partially. Five specific attributes (`gen_ai.system`, `gen_ai.request.model`, `gen_ai.response.id`, `gen_ai.response.finish_reasons`, `gen_ai.tool.call.id`) are documented as implementing the convention. Span names and most other attributes use Anthropic's own `claude_code.*` namespace instead, and the GenAI conventions themselves remain in experimental status as of mid-2026[^2][^7].

### Can I see traces without paying for a commercial observability vendor?

Yes. Point `OTEL_EXPORTER_OTLP_ENDPOINT` at a free local Jaeger container, at the minimal custom receiver built in Step 5, or at Langfuse — its Hobby cloud tier is free with no credit card required, and its self-hosted Community edition is also free and MIT-licensed, with the core observability, evaluation, and prompt-management features included[^1][^6][^9].

### Does tracing capture my prompts and tool inputs by default?

No. Durations, model names, and tool names are recorded by default; actual prompt text and tool input/output content require explicitly setting `OTEL_LOG_USER_PROMPTS`, `OTEL_LOG_TOOL_DETAILS`, or `OTEL_LOG_TOOL_CONTENT`[^1][^2].

### How is this different from the SDK's built-in cost tracking?

The `result` message gives you one `total_cost_usd` figure per `query()` call, with no setup. Tracing breaks that same cost and latency down per model request and per tool call, so you can see which specific step in a multi-step run was expensive[^4].

### Does any of this work the same way in Python?

Mostly yes — the same environment variables and the same `claude_code.*` span tree apply, since both language SDKs run the same underlying Claude Code CLI. The one concrete difference: Python's `ClaudeAgentOptions.env` merges on top of the inherited environment, while TypeScript's `options.env` replaces it entirely[^1].

## Next steps and further reading

This tutorial traces built-in and SDK-defined tools; the same `claude_code.tool` spans apply to tools exposed over MCP, which is worth combining with [the production MCP server tutorial with OAuth and streamable HTTP](https://nerdleveltech.com/mcp-server-typescript-oauth-streamable-http-production-tutorial) if your agent's tools live behind an authenticated MCP endpoint.

If you're also adding an approval layer in front of tool calls, [the human-in-the-loop Claude Agent SDK tutorial](https://nerdleveltech.com/human-in-the-loop-claude-agent-sdk-typescript-tutorial) covers the `canUseTool` callback and `PreToolUse`/`PostToolUse` hooks for building your own hand-rolled audit log — a different mechanism from the `tool_decision` log event this post's audit-trail section covers, since that one is emitted automatically once `OTEL_LOGS_EXPORTER` is on rather than written by a hook you author yourself, but the two are complementary places to look for the same kind of "what did the agent actually do" record.

And if your agent needs to remember things across sessions rather than just be observable within one, [the Claude memory tool and context editing tutorial](https://nerdleveltech.com/claude-memory-tool-context-editing-typescript-tutorial) covers the SDK's persistent-memory feature, which is a different package concern entirely from the tracing setup here.

For the current status of the attribute names themselves, [OpenTelemetry gen_ai attributes: 60 deprecated in 2026](https://nerdleveltech.com/opentelemetry-gen-ai-attributes-deprecated) audits every constant in the semantic-conventions package and separates the eight real renames from the fifty that only changed repository.

[^1]: Anthropic, "Observability with OpenTelemetry" — https://code.claude.com/docs/en/agent-sdk/observability (three-signal model, `CLAUDE_CODE_ENABLE_TELEMETRY`/`CLAUDE_CODE_ENHANCED_TELEMETRY_BETA` flags, TypeScript `env` replace-vs-merge behavior, W3C trace-context propagation, `OTEL_SERVICE_NAME`/`OTEL_RESOURCE_ATTRIBUTES`, content-capture opt-in variables; fetched 2026-07-15)
[^2]: Anthropic, "Monitoring" — https://code.claude.com/docs/en/monitoring-usage, "Traces (beta)" section (span hierarchy diagram, per-span attribute tables, `gen_ai.*` attributes individually labeled "OpenTelemetry GenAI semantic convention," `claude_code.hook` allowlist scope, `CLAUDE_CODE_PROPAGATE_TRACEPARENT`, OTel status semantics; fetched 2026-07-15)
[^3]: npm registry, `@anthropic-ai/claude-agent-sdk` — https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk (version 0.3.210, published 2026-07-14T19:39:19.351Z per this version's own registry `time` entry, `engines.node >=18.0.0`, bundled `claudeCodeVersion` 2.1.210, peerDependencies `zod ^4.0.0`/`@anthropic-ai/sdk >=0.93.0`/`@modelcontextprotocol/sdk ^1.29.0`; fetched directly from the registry API, 2026-07-15)
[^4]: `@anthropic-ai/claude-agent-sdk@0.3.210` compiled type definitions, `sdk.d.ts` — installed from npm and inspected directly, 2026-07-15 (`Options.env` replace-semantics comment, `query()`/`tool()`/`createSdkMcpServer()` signatures, `SDKResultSuccess` fields including `total_cost_usd`/`duration_ms`/`usage`/`modelUsage`)
[^5]: Anthropic, "Models overview" — https://platform.claude.com/docs/en/about-claude/models/overview (current model IDs and pricing: Claude Opus 4.8 `claude-opus-4-8` at $5/$25 per MTok; fetched 2026-07-15)
[^6]: Langfuse, "Observability for Claude Agent SDK JS/TS with Langfuse" — https://langfuse.com/integrations/frameworks/claude-agent-sdk-js (install command listing `@anthropic-ai/claude-agent-sdk`, `@arizeai/openinference-instrumentation-claude-agent-sdk`, `@langfuse/otel`, and `@opentelemetry/sdk-node` together; `NodeSDK`/`LangfuseSpanProcessor` setup; the page's own runnable example is a tool-free prompt on `claude-sonnet-4-5`, not a weather tool; fetched 2026-07-15)
[^7]: OpenTelemetry, "Generative AI semantic conventions" — https://opentelemetry.io/docs/specs/semconv/gen-ai/ (page explicitly states GenAI semantic conventions have moved to a dedicated `semantic-conventions-genai` repository and are no longer maintained in the main spec repo; fetched 2026-07-15). Development/experimental status as of this writing confirmed separately via OpenTelemetry, "GenAI observability" blog post, May 14, 2026 — https://opentelemetry.io/blog/2026/genai-observability/ ("under active development"), and via the `invoke_agent`/`chat`/`execute_tool` span names that blog post documents as the GenAI conventions' own naming scheme — distinct from `claude_code.*`. That same registry page's full attribute table (fetched directly, 2026-07-15) lists `gen_ai.provider.name` but contains no entry for a bare `gen_ai.system` anywhere, alphabetically or otherwise — only the distinct `gen_ai.system_instructions` attribute appears — consistent with `gen_ai.system` having been renamed to `gen_ai.provider.name` in the current spec.
[^8]: Node.js, "Running TypeScript Natively" — https://nodejs.org/learn/typescript/run-natively (v22.18.0+ runs erasable-syntax `.ts` files with no flag; earlier versions need `--experimental-strip-types`; type stripping does not type-check, `enum`/namespaces/parameter properties aren't supported; fetched 2026-07-15)
[^9]: Langfuse, "Pricing" — https://langfuse.com/pricing ("Hobby plan is completely free and does not require a credit card," 100k units/month included) and Langfuse, "Self-Hosted Pricing" — https://langfuse.com/pricing-self-host (Community tier listed as "Free," MIT License, "All core platform features and APIs"; both fetched directly, 2026-07-15)
