OTel middleware that traces calls made through anthropics/anthropic-sdk-go with OpenInference LLM spans.
go get github.com/Arize-ai/openinference/go/openinference-instrumentation-anthropic-sdk-go
import (
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"go.opentelemetry.io/otel"
anthropicotel "github.com/Arize-ai/openinference/go/openinference-instrumentation-anthropic-sdk-go"
)
client := anthropic.NewClient(
option.WithMiddleware(anthropicotel.Middleware(otel.Tracer("my-app"))),
)
resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
Model: "claude-3-5-sonnet-latest",
MaxTokens: 100,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("hi")),
},
})
Every /v1/messages call now emits an LLM-kind span with the following attributes:
| Attribute | Source |
|---|---|
openinference.span.kind |
LLM |
llm.system / llm.provider |
anthropic |
llm.model_name |
request model field, then overwritten by the canonical model in the response |
llm.invocation_parameters |
JSON with max_tokens, temperature, top_p, top_k |
llm.input_messages.{i}.message.role / .content |
system prompt (if any) + each message; tool-use blocks are skipped |
input.value |
last user message text |
output.value |
concatenated content[].text from the response |
llm.output_messages.0.message.role / .content |
assistant + response text |
llm.finish_reason |
response stop_reason |
llm.token_count.prompt / .completion / .total |
from response usage |
llm.token_count.prompt_details.cache_read / .cache_write |
when present |
Streaming responses (SSE text/event-stream) pass through unchanged so the caller’s stream consumer keeps working. The middleware wraps the response body in a small adapter so the span’s End() fires when the caller closes (or fully reads) the body — the span’s duration reflects the actual time-to-last-token, not just the HTTP handshake. Output attributes (output.value, llm.token_count.*) are not populated for streaming spans today; future versions may parse the SSE delta stream to fill them in.
The middleware ignores tool_use content blocks in the request and response — wrap those in dedicated TOOL spans yourself (see openinference docs). Auto-instrumenting tool execution is out of scope because the tool runs in the caller’s process, not in the SDK.
The sibling openinference/go/openinference-instrumentation package gives customers control over what shows up on LLM spans without setting attributes manually on each one:
import "github.com/Arize-ai/openinference/go/openinference-instrumentation"
// Suppression: evaluator/grader code that itself calls an LLM but
// should not appear in the customer's product trace.
ctx := instrumentation.WithSuppression(ctx)
resp, _ := client.Messages.New(ctx, params) // no span emitted
// Context attributes propagate from ctx to every LLM span descended
// from it, even when the call is several layers deep.
ctx = instrumentation.WithSession(ctx, "session-abc")
ctx = instrumentation.WithUser(ctx, "user-xyz")
ctx = instrumentation.WithMetadata(ctx, `{"team":"platform"}`)
ctx = instrumentation.WithTags(ctx, "prod", "canary") // typed []string, matching the spec
resp, _ := client.Messages.New(ctx, params) // span has session.id, user.id, …
These ride the standard context.Context (via unexported keys, not OTel baggage) so they flow through your call graph in-process but never leak out as baggage HTTP headers on downstream requests.
The middleware honors the canonical OpenInference OPENINFERENCE_HIDE_* environment variables for PII / sensitive-data protection. Set any of these to true to redact the corresponding attribute family:
| Env var | What it does |
|---|---|
OPENINFERENCE_HIDE_INPUTS |
Replaces input.value with __REDACTED__ AND drops llm.input_messages.* entirely. Strongest input-side flag. |
OPENINFERENCE_HIDE_OUTPUTS |
Replaces output.value with __REDACTED__ AND drops llm.output_messages.* entirely. Strongest output-side flag. |
OPENINFERENCE_HIDE_INPUT_MESSAGES |
Drops llm.input_messages.* entirely; input.value still set. |
OPENINFERENCE_HIDE_OUTPUT_MESSAGES |
Drops llm.output_messages.* entirely; output.value still set. |
OPENINFERENCE_HIDE_INPUT_TEXT / _OUTPUT_TEXT |
Keeps message structure (role, indices) and redacts only the .content field with __REDACTED__. |
OPENINFERENCE_HIDE_LLM_INVOCATION_PARAMETERS |
Omits llm.invocation_parameters. |
OPENINFERENCE_HIDE_LLM_TOOLS |
Omits the llm.tools.* advertised-tools list. (Implied by HIDE_INPUTS.) |
Top-level values (input.value / output.value) are replaced with the __REDACTED__ sentinel rather than omitted, so downstream consumers can distinguish “hidden” from “never recorded”. Structural attribute families (llm.input_messages.*, llm.output_messages.*, llm.tools.*) are dropped wholesale — the wire-format keys do not appear on the span at all. Token counts, model name, llm.finish_reason, and timing are never affected.
To override the env-driven config programmatically:
import "github.com/Arize-ai/openinference/go/openinference-instrumentation"
mw := anthropicotel.Middleware(
otel.Tracer("my-app"),
anthropicotel.WithTraceConfig(instrumentation.TraceConfig{
HideInputs: true,
HideOutputs: false,
}),
)
WithTraceConfig fully replaces the env-derived config — set it once at construction. Matches the Python TraceConfig and JS generateTraceConfig patterns.
/v1/messages is instrumented; the models endpoints and the Beta surfaces fall through untouched.tool_use content blocks in messages are not yet captured as message.tool_calls attributes; only text blocks make it onto the span.Read-to-EOF or Close.