DEV Community

Cover image for anthropic-sdk-go 1.81 traces your Claude calls and forwards your OTel baggage
ImportStatic
ImportStatic

Posted on Originally published at importstatic.com

anthropic-sdk-go 1.81 traces your Claude calls and forwards your OTel baggage

Originally published on ImportStatic.

If your Go service registers a global OpenTelemetry tracer provider and propagator, upgrading to anthropic-sdk-go v1.81.0 (released October 14) changes two things with no code change: every Claude call becomes a span, and every request to the API carries the headers your propagator writes. In most Go services those are traceparent and baggage, since the OpenTelemetry Go getting-started guide registers both. Whatever your services keep in baggage now goes to Anthropic.

To see what goes over the wire, we sent the same request from v1.80.0 and v1.81.0 to an httptest server that returns canned Messages API responses. The request ran inside a handler span whose context held one baggage member, user.email=jane@example.com:

v1.80.0:  traceparent=""  baggage=""
          spans: "POST /tickets/summary"

v1.81.0:  traceparent="00-9cdfdc78baf5c4413a4de1c242b1b145-f6538546ab8336d9-01"
          baggage="user.email=jane@example.com"
          spans: "anthropic.messages.create" (client), "POST /tickets/summary"
Enter fullscreen mode Exit fullscreen mode

No API key was used and nothing reached Anthropic, so this shows what the SDK sends and nothing about how the real API handles it.

What v1.81.0 changes

According to the release note, if the app registers a global tracer provider, "the SDK will create spans with it after upgrading", and "requests will also get trace context headers from the global propagator". The Claude Platform release notes for the same day list this under a beta of OpenTelemetry export, which also lets the API send its own server-side spans to your collector once an admin adds a destination in the Console.

The SDK side needs no setup with Anthropic. It reads otel.GetTracerProvider() and otel.GetTextMapPropagator(), so any service that calls otel.SetTracerProvider at startup is opted in.

Services that pass a provider explicitly to their instrumentation and never set the global one are only partly opted out. In our run of that setup (handler span from a non-global provider, global propagator registered), the SDK recorded no span of its own, but the request still went out with traceparent, carrying the handler span's ID, and the baggage. A comment in StartAPICallSpan covers this case: a non-recording span is still used when its trace context "goes on" in the headers. A process sends nothing only if it has neither a span in the context nor a registered propagator.

The feature is in beta. The SDK tracing guide warns that span names, attributes, options and environment variables can change between versions and tells you to pin the SDK version, which matters if you build dashboards on these attribute names.

The baggage header

The SDK guide mentions it once: "If that propagator also writes baggage, the SDK sends your baggage too." OpenTelemetry's baggage page warns that "sensitive Baggage items can be shared with unintended resources, like third-party APIs", and api.anthropic.com is now one of them.

Before this release, a Claude request only carried that header if you'd wrapped the SDK's HTTP client in otelhttp or set it yourself. With v1.81.0, any service that registered the composite propagator from the getting-started guide sends it:

otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
    propagation.TraceContext{}, propagation.Baggage{}))
Enter fullscreen mode Exit fullscreen mode

To know what leaves, check baggage extracted from incoming requests as well as what your own code sets. Upstream services add members too, so grepping for baggage.NewMember only finds part of it.

We tested three ways to keep traceparent and drop baggage:

Change traceparent sent baggage sent Side effects
Register only propagation.TraceContext{} yes no Global: every other client in the process stops sending baggage too
option.WithOpenTelemetryPropagation(false) no no API-side spans can't join your trace
SDK middleware that deletes the header yes no Only this client is affected; no help if the client uses otelhttp

The SDK injects trace headers before it runs your middleware chain, so a middleware can remove them. For a service whose other outbound calls need baggage, this is the one we'd use:

client := anthropic.NewClient(option.WithMiddleware(
    func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) {
        req.Header.Del("baggage")
        return next(req)
    }))
Enter fullscreen mode Exit fullscreen mode

If nothing else in the process reads baggage, registering only TraceContext{} is simpler, and it's the setup the SDK guide recommends.

What the span records

In the default metadata_only mode, a non-streaming call produced one CLIENT span named anthropic.messages.create under the handler span. Its attributes, with server.port, url.path and url.scheme left out and the port removed from url.full:

anthropic.message.stop_reason=end_turn
anthropic.request.id=req_test
gen_ai.operation.name=chat
gen_ai.provider.name=anthropic
gen_ai.request.max_tokens=256
gen_ai.request.model=claude-sonnet-5-5
gen_ai.response.id=msg_01
gen_ai.response.model=claude-sonnet-5-5
gen_ai.usage.input_tokens=12
gen_ai.usage.output_tokens=3
http.request.method=POST
http.response.status_code=200
http.route=/v1/messages
server.address=127.0.0.1
url.full=http://127.0.0.1/v1/messages
Enter fullscreen mode Exit fullscreen mode

No prompt text is recorded in this mode. Setting ANTHROPIC_OPEN_TELEMETRY_TRACES_CONTENT_MODE=content added two attributes with the prompt and the reply; our test prompt included the email address:

gen_ai.input.messages=[{"role":"user","parts":[{"type":"text","content":"Summarise ticket 44...
gen_ai.output.messages=[{"role":"assistant","parts":[{"type":"text","content":"Hi"}]}]
Enter fullscreen mode Exit fullscreen mode

The v1.81.0 source (internal/tracing/tracing.go) reads that variable on every call rather than once when the client is built, and an option passed to anthropic.NewClient overrides it. If prompts must never reach your tracing backend, pass option.WithOpenTelemetryTracesContentMode(option.OpenTelemetryContentModeMetadataOnly) explicitly so an env var in a deployment manifest can't change it.

Retries, otelhttp and streams

Retries. With the stand-in answering the first request with a 529 overloaded_error, the SDK retried using the same traceparent for both attempts and recorded one span with http.request.resend_count=1 and status Ok. The span reference documents this: one span covers the whole call, retries included, so failed attempts show up in that attribute and not in the span count.

otelhttp. With the SDK's HTTP client wrapped in otelhttp.NewTransport, we got the anthropic.messages.create span with an HTTP POST span under it, no duplicate anthropic.* span, and a single traceparent value at the server, carrying the otelhttp span's ID. The SDK guide suggests WithOpenTelemetryPropagation(false) in this setup. In our Go run the headers were identical with and without it, because otelhttp writes them last.

For the same reason the baggage middleware above has no effect here: otelhttp adds the header again after it runs. This setup already sent baggage before v1.81.0, and registering only TraceContext{} is the way to stop it. The HTTP POST span repeats the request timing; drop otelhttp from this client if you don't want it.

Streams. Messages.NewStreaming returns a stream whose span ends only when you've read it to the end or closed it:

read all events:         span ended, status Ok, gen_ai.response.time_to_first_chunk=0.0023
read 1 event, Close():   span ended, status Unset, no output token count
read 1 event, no Close:  no span exported
Enter fullscreen mode Exit fullscreen mode

If your loop can stop before the last event, defer stream.Close(), or that call's span is never exported.

Counting tokens from these spans

With an API-side destination configured as well, each request produces two spans named anthropic.messages.create, yours (CLIENT) and the API's (SERVER), and both carry gen_ai.usage.*. The OpenTelemetry overview says to sum only one source per request, preferring the API spans when both exist; summing both doubles your token counts. The API also honours the sampled flag in traceparent, so per the API guide a trace your sampler drops gets no API spans either.

Span delivery is best effort, and the guide points to the Usage API for spend tracking. Our article on LLM API spend limits covers what the vendors enforce.

Turning it off

Pass option.WithoutOpenTelemetry() to disable it for one client, or set ANTHROPIC_OPEN_TELEMETRY=false for the whole process. With either, the server got no trace headers and only the handler span was recorded. Either is reasonable if another library already traces your Claude calls, or until you've checked what's in your baggage.

If you're upgrading across several SDK versions at once, the Go type changes from earlier releases still apply; we covered the ones that bit Haiku 5.5 migrations in Content[0].Text coming back empty.


Originally published on ImportStatic. This article was drafted with AI assistance; every claim was checked against the sources linked in it and the code samples were compiled and run.

Top comments (0)