Tutorials · Claude Agent SDK · examples / python / 18_otel_capture.pyExample code

Example code · python

18_otel_capture.py

Observability. Run a tiny OTLP/HTTP receiver on localhost, point the agent at it,

Raw fileDownload all examples (zip)See its output →

Used in Part 3 §4 · Observability with OpenTelemetry

"""18: Observability. Run a tiny OTLP/HTTP receiver on localhost, point the agent at it,
and print the spans, metrics and log events the Claude Code binary exported."""

import asyncio
import json
import threading
from collections import Counter
from http.server import BaseHTTPRequestHandler, HTTPServer

from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
from demo_util import PROJECT

received = {"traces": [], "metrics": [], "logs": []}


class Collector(BaseHTTPRequestHandler):
    """Accepts OTLP over HTTP with JSON encoding and stores the payloads."""

    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        signal = self.path.rsplit("/", 1)[-1]          # /v1/traces -> traces
        if signal in received:
            received[signal].append(json.loads(body))
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b"{}")

    def log_message(self, *args):                    # silence per-request logging
        pass


def start_collector(port=4318):
    server = HTTPServer(("127.0.0.1", port), Collector)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    return server


OTEL_ENV = {
    "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
    "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA": "1",             # traces are beta
    "OTEL_TRACES_EXPORTER": "otlp",
    "OTEL_METRICS_EXPORTER": "otlp",
    "OTEL_LOGS_EXPORTER": "otlp",
    "OTEL_EXPORTER_OTLP_PROTOCOL": "http/json",             # JSON so this demo can read it without protobuf
    "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:4318",
    "OTEL_SERVICE_NAME": "tutorial-agent",
    "OTEL_RESOURCE_ATTRIBUTES": "deployment.environment=demo,tenant.id=acme",
    "OTEL_METRIC_EXPORT_INTERVAL": "1000",                  # flush fast: this run lasts seconds
    "OTEL_LOGS_EXPORT_INTERVAL": "1000",
    "OTEL_TRACES_EXPORT_INTERVAL": "1000",
    "CLAUDE_CODE_OTEL_DIAG_STDERR": "1",                    # surface exporter errors on stderr
    "NO_PROXY": "127.0.0.1,localhost",                      # don't send localhost traffic through a corporate proxy
}

stderr_lines = []


async def main():
    server = start_collector()
    try:
        async for message in query(
            prompt="Read README.md and reply with its first heading only.",
            options=ClaudeAgentOptions(cwd=PROJECT, allowed_tools=["Read"], max_turns=3,
                                       env=OTEL_ENV, stderr=stderr_lines.append),
        ):
            if isinstance(message, ResultMessage):
                print(f"[result] {message.subtype}: {message.result!r} cost=${message.total_cost_usd:.4f}")
        await asyncio.sleep(8)                               # let the final export intervals fire
    finally:
        server.shutdown()

    # ---- what arrived --------------------------------------------------------------------
    spans = [s for p in received["traces"] for rs in p.get("resourceSpans", [])
             for ss in rs.get("scopeSpans", []) for s in ss.get("spans", [])]
    metrics = [m["name"] for p in received["metrics"] for rm in p.get("resourceMetrics", [])
               for sm in rm.get("scopeMetrics", []) for m in sm.get("metrics", [])]
    logs = [r for p in received["logs"] for rl in p.get("resourceLogs", [])
            for sl in rl.get("scopeLogs", []) for r in sl.get("logRecords", [])]

    def attrs(obj):
        return {a["key"]: list(a["value"].values())[0] for a in obj.get("attributes", [])}

    print(f"\nreceived: {len(received['traces'])} trace batches, {len(received['metrics'])} metric batches, {len(received['logs'])} log batches")
    if received["traces"]:
        res = attrs(received["traces"][0]["resourceSpans"][0]["resource"])
        print("resource attributes:", {k: res[k] for k in res if k in ("service.name", "deployment.environment", "tenant.id")})
    print("\nspans (name -> count):", dict(Counter(s["name"] for s in spans)))
    for s in spans[:6]:
        a = attrs(s)
        extra = {k: a[k] for k in a if k in ("tool.name", "model", "gen_ai.request.model", "session.id")}
        dur_ms = (int(s["endTimeUnixNano"]) - int(s["startTimeUnixNano"])) / 1e6
        print(f"  {s['name']:<32} {dur_ms:8.0f} ms  parent={'yes' if s.get('parentSpanId') else 'root'} {extra}")
    print("\nmetrics:", sorted(set(metrics)))
    print("\nlog events (name -> count):", dict(Counter(attrs(r).get("event.name", r.get('body', {}).get('stringValue', '?')) for r in logs)))
    if stderr_lines:
        print("\nstderr diagnostics (first 3):", stderr_lines[:3])


asyncio.run(main())

101 lines · Back to the folder