Skip to content

Anthropic

Spanlight can record every Anthropic call your application makes: the prompt, the answer, the tokens, the latency and the cost. There are three ways to do it. Ways to send data compares them in detail.

Way Languages What you change
Python SDK Python Wrap your Anthropic client with one line.
Gateway Any Point your Anthropic client at Spanlight with a new base URL and key.
OpenTelemetry Any Export spans from your own instrumentation.

Before you start

  • A running Spanlight instance that your application can reach
  • An Anthropic API key
  • Python SDK: Python 3.10 or newer, and a project API key with the ingest:write scope (the default). Creating one needs the member, admin or owner role
  • Gateway: an organization owner has stored an Anthropic credential, and an owner or admin has created a route that uses it and a gateway key
  • JavaScript and TypeScript: Node.js 24 (the version this page was tested with)

The wrap_anthropic function instruments one Anthropic client in place. Every messages.create call and every messages.stream block made with it becomes an llm span. Your code, the answer and the exceptions you get from Anthropic do not change.

Install the SDK with its Anthropic extra and the Anthropic package:

Terminal window
pip install "spanlight[anthropic]==0.1.0" "anthropic==1.9.0"

Give the SDK your Spanlight address and project API key, and Anthropic your Anthropic key:

Terminal window
export SPANLIGHT_HOST=http://localhost:8080
export SPANLIGHT_API_KEY=spl_live_...
export ANTHROPIC_API_KEY=sk-ant-...

SPANLIGHT_HOST is the address of the Spanlight web service. It defaults to http://localhost:8000, which is right only when you run the API directly for development.

import spanlight
from anthropic import Anthropic
spanlight.init(environment="development")
client = spanlight.wrap_anthropic(Anthropic())
message = client.messages.create(
model="claude-haiku-4-5",
max_tokens=256,
messages=[{"role": "user", "content": "Say hello."}],
)
print(message.content[0].text)
spanlight.flush()

The SDK queues spans and sends them from a background thread. spanlight.flush() waits until they are delivered. A long-running server does not need it. A short script does, or its last spans are lost.

import spanlight
from anthropic import Anthropic
spanlight.init(environment="development")
client = spanlight.wrap_anthropic(Anthropic())
with client.messages.stream(
model="claude-haiku-4-5",
max_tokens=256,
messages=[{"role": "user", "content": "Say hello."}],
) as stream:
for text in stream.text_stream:
print(text, end="")
print()
spanlight.flush()

You use the stream as before, including text_stream and get_final_message(). The wrapper observes each event as you read it. The span starts when you enter the with block and ends when you leave it. It also records the time to first token.

Streaming with messages.create(..., stream=True) is traced the same way. Anthropic sends usage inside the stream events, so you need no extra option. If the stream ends before Anthropic’s final usage event, because you closed it early or it failed, the span keeps the input counts from the first event, leaves the output count and the cost unknown, and says why in usage.unreported_reason (client_disconnected, stream_error or no_final_usage).

Results of the Message Batches API that you read through a wrapped client are recorded too, one llm span per request, in a trace named batch <batch_id>:

for result in client.messages.batches.results("msgbatch_..."):
print(result.custom_id, result.result.type)

Each succeeded result is priced at the batch rate. The result carries no request, so the span has no input, and its duration is 0. Batch results describes the spans in full. Batch results need SDK 0.2.0 or newer.

The gateway is an HTTP proxy that speaks the Anthropic Messages API. Your application keeps its Anthropic client and changes two things: the base URL, which becomes <host>/gw, and the API key, which becomes a Spanlight gateway key (spl_gw_…). The gateway forwards the call to Anthropic with the key you stored and records it as a trace. No Spanlight package is installed in your application.

The base URL ends in /gw, not /gw/v1, because the Anthropic SDKs add /v1/messages themselves.

Do the one-time setup first: store the Anthropic key as a credential, create a route that uses it and create a gateway key. Gateway quickstart walks through it. A route must contain an Anthropic credential to serve messages calls.

Give your application the address and the gateway key:

Terminal window
export SPANLIGHT_HOST=http://localhost:8080
export SPANLIGHT_GATEWAY_KEY=spl_gw_...
Terminal window
pip install "anthropic==1.9.0"
import os
from anthropic import Anthropic
client = Anthropic(
base_url=f"{os.environ['SPANLIGHT_HOST']}/gw",
api_key=os.environ["SPANLIGHT_GATEWAY_KEY"], # a gateway key, not an Anthropic key
)
message = client.messages.create(
model="claude-haiku-4-5",
max_tokens=256,
messages=[{"role": "user", "content": "Say hello."}],
)
print(message.content[0].text)

Streaming needs no change: pass stream: true (or stream=True in Python) and read the stream as usual. The span is written when the stream ends.

The gateway records the provider of the credential that answered, which for an Anthropic credential is anthropic. The cost comes from the price table, which includes claude-haiku-4-5. A model that is not in the table has no cost.

To join a call to a trace, session or user you already track, send x-spanlight-* headers; see Join a trace you already started.

If your application already uses OpenTelemetry instrumentation for Anthropic, you can send its spans to Spanlight over OTLP/HTTP instead. Spanlight reads the gen_ai.* attributes. See OTLP ingestion for the endpoint and the exporter settings, and OTLP attribute mapping for which attribute fills which field.

Open Traces in the dashboard and pick the project that owns your key.

  • With the Python SDK you see a trace named chat claude-haiku-4-5 that holds one llm span of the same name.
  • Through the gateway you see a trace with one llm span named messages claude-haiku-4-5.

Open the span. It shows the messages you sent, the answer, the token counts and the cost.

Each call is one llm span. Where a column says “empty”, Spanlight stores nothing and the dashboard shows “—”.

Field Python SDK Gateway
Span name chat <model> messages <model>
Trace name The name of the root span, for example chat claude-haiku-4-5 Empty. The gateway does not name traces
Provider anthropic The provider of the credential that answered: anthropic
Model The model Anthropic reports in its answer. If the answer has none, the model you asked for The same
Input The request parameters as JSON: model, max_tokens, messages, system, stream and any others you pass The request body as you sent it
Output The assistant message: its list of content blocks The assistant message
Input tokens Uncached input plus cache reads plus cache writes The same. On a response cache hit the tokens are recorded as 0, because nothing was billed
Output tokens From Anthropic’s usage The same
Cached tokens Cache reads, when Anthropic reports them The same. On a response cache hit it is recorded as 0
Cache writes Cache writes (cache_creation_input_tokens), and the part of them with a 1-hour lifetime (cache_creation.ephemeral_1h_input_tokens), when Anthropic reports them The same. On a response cache hit they are recorded as 0
Service tier usage.service_tier: standard, batch, or priority for Anthropic’s priority_on_demand. A batch result is always batch usage.service_tier of the answer or stream
Web searches usage.server_tool_use.web_search_requests, when the model used the web search tool The same
Cost From the price table and your price overrides, times the tier factor, plus $10.00 per 1,000 web searches. Empty for a model with no price, and for a stream cut before its usage arrived The same
Finish reason stop, length, tool_calls, content_filter or other. Anthropic’s end_turn is stored as stop The same
Time to first token For streamed calls For streamed calls
Status ok, or error with the exception type (error.type), plus http.status_code and provider.request_id when the exception carries them ok, or error with a status message
Environment The environment you pass to spanlight.init The environment of the gateway key
User and session Set with spanlight.update_trace Set with the x-spanlight-user and x-spanlight-session headers

Spanlight counts input tokens the same way for every provider: input_tokens includes cache reads and cache writes. Anthropic reports uncached input, cache reads and cache writes apart, so Spanlight adds them up for input_tokens and keeps the reads in cached_tokens and the writes in cache_write_tokens, with the 1-hour part in cache_write_1h_tokens (the SDK reports the writes since 0.1.2; the gateway always has). Anthropic bills a cache write above the input rate (1.25 times for a 5-minute cache, 2 times for 1 hour), and Spanlight prices it that way; see How cost is calculated. The trace page shows the writes in the Usage card as Cache writes, with the 1-hour part after it when there is one.

Anthropic bills Priority Tier as a capacity commitment, so a priority call is priced at the standard token rate and its cost explanation marks it as commitment billed. A call that passes inference_geo (other than global) or speed: "fast" is priced at the standard rate too, and the SDK and the gateway set usage.pricing_modifier_unknown on its span (inference_geo_us or fast_mode), because Spanlight does not price those options yet. The SDK reads these options from the call’s keywords or from extra_body.

The SDK and the gateway also store attributes. The SDK adds response.id, response.stop_reason (Anthropic’s own value, such as end_turn) and finish_reason. A streamed call adds request.stream. The gateway adds spanlight.gateway.* attributes (the key, route, target, attempts, overhead and upstream status) and spanlight.cache. Gateway quickstart lists them. How cost is calculated explains when a cost is unknown.

  • Your keys and headers. The wrapper never records api_key, extra_headers, extra_query, extra_body, timeout or http_client. The gateway does not forward your gateway key, cookies or any header outside a short allowlist to Anthropic.
  • Prompts and completions, when the project turns capture off. Spanlight then keeps the model, tokens, cost and timing and drops the text. See Privacy. The server also redacts common secret patterns from payloads and truncates very large ones.
  • Output of a call that fails before an answer arrives. The span has the error and the request, and no output. A stream that fails midway keeps the part of the answer that arrived, and is marked error; its output token count and cost are unknown.
  • Calls the wrapper does not see. It instruments the client you wrap and the methods messages.create, messages.stream and messages.batches.results. Other endpoints, such as token counting or creating a batch, are not traced, and a batch’s requests appear only when you read its results through the wrapped client. A call made through with_raw_response or with_streaming_response is passed through untraced.
  • A stream you abandon. The span of a messages.create(..., stream=True) stream ends when you read it to the end or close it. If you drop it without either, its span is never sent.
  • Anything that bypasses the gateway. Calls that do not use the gateway base URL do not appear.
  • No trace arrives. Work through No trace arrives. Two causes are common with the SDK. If SPANLIGHT_API_KEY is missing, the SDK logs Spanlight: no API key configured once, and your Anthropic call still works and returns its answer; see The SDK has no API key. If a script ends right after the call, the last spans are lost unless you call spanlight.flush(); see A short script exits before the spans are sent.
  • The gateway answers 400 NO_COMPATIBLE_TARGET with the message “Route ‘…’ has no target that serves messages.” The route has no Anthropic credential. The gateway does not translate between providers, so an OpenAI or OpenAI-compatible credential cannot serve a messages call. Add an Anthropic credential to the route. The error comes back in Anthropic’s error format, so the SDK raises its normal BadRequestError. Gateway error responses lists every code.
  • The gateway answers 401 UNAUTHORIZED. The key is missing, unknown or revoked, or you sent an Anthropic key or a project API key instead of a gateway key. Use the spl_gw_… key. If you send both Authorization and x-api-key, they must carry the same key. Gateway error responses lists every code.
  • The gateway cost is empty. The model has no price, and you have not set a price override. Check the model on the span, then see When a cost is unknown.
  • The trace has no prompt or answer. The project has prompt and completion capture turned off. Privacy shows where.