Structured Logging of Prompts, Responses, and Token Counts
A structured log is a consistent, machine-parseable record of one Claude API call: the prompt, the response, the token counts, the model, the latency, and a request ID.
Without that consistency, every call site logs something slightly different, and answering "how many tokens did we use yesterday" turns into grepping through inconsistent text instead of running a query.
Summary
The goal of structured logging is a fixed schema applied to every call, not a best-effort log message.
Each field in the schema earns its place: the prompt and response for debugging quality issues, token counts for cost, latency for performance, model name for comparing across model versions, and a request ID for correlating with traces or support tickets.
A Python logging.Logger with a JSON formatter, or a dedicated logging adapter function, is enough to enforce this without adopting a new framework.
The pattern generalizes past a single messages.create call: streaming responses, multi-turn conversations, and tool-using agent loops all need the same schema applied per call, not per conversation.
This page builds a reusable logging adapter, then extends it to multi-turn and streaming cases.
Recipe
import json
import logging
import time
import uuid
logger = logging.getLogger("claude.calls")
def log_call(*, request_id, model, prompt, response_text, usage, latency_ms, status="ok"):
logger.info(json.dumps({
"request_id": request_id,
"model": model,
"prompt": prompt,
"response": response_text,
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"latency_ms": latency_ms,
"status": status,
}))When to reach for this:
- You have more than one call site making Claude API calls and want a consistent, queryable log format.
- You need to answer "how many tokens did this feature use last week" from logs, not by re-running requests.
- You are about to add tracing or cost dashboards, both of which depend on this schema existing first.
- You want error and success logs in the same shape so one query covers both.
Working Example
import json
import logging
import time
import uuid
import anthropic
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger("claude.calls")
client = anthropic.Anthropic()
def log_entry(**fields) -> None:
"""Emit one structured JSON log line, dropping any field left as None."""
logger.info(json.dumps({k: v for k, v in fields.items() if v is not None}))
def call_claude(prompt: str, model: str = "claude-sonnet-5") -> str:
"""Make one Claude call and log a structured entry for it, success or failure."""
request_id = str(uuid.uuid4())
start = time.monotonic()
try:
response = client.messages.create(
model=model,
max_tokens=500,
messages=[{"role": "user", "content": prompt}],
)
except anthropic.APIStatusError as exc:
log_entry(
request_id=request_id,
model=model,
prompt=prompt,
response=None,
input_tokens=None,
output_tokens=None,
latency_ms=round((time.monotonic() - start) * 1000, 1),
status="error",
error_type=exc.__class__.__name__,
status_code=exc.status_code,
)
raise
response_text = response.content[0].text
log_entry(
request_id=request_id,
model=response.model,
prompt=prompt,
response=response_text,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
cache_read_input_tokens=getattr(response.usage, "cache_read_input_tokens", None),
latency_ms=round((time.monotonic() - start) * 1000, 1),
status="ok",
)
return response_text
def call_claude_multi_turn(messages: list[dict], model: str = "claude-sonnet-5") -> dict:
"""Same schema, applied to one turn of a multi-turn conversation."""
request_id = str(uuid.uuid4())
start = time.monotonic()
response = client.messages.create(
model=model,
max_tokens=500,
messages=messages,
)
log_entry(
request_id=request_id,
model=response.model,
prompt=messages[-1]["content"],
response=response.content[0].text if response.content else "",
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
latency_ms=round((time.monotonic() - start) * 1000, 1),
status="ok",
turn_count=len(messages),
)
return {"request_id": request_id, "response": response}
if __name__ == "__main__":
call_claude("Summarize the tradeoffs of microservices in two sentences.")What this demonstrates:
- A single
log_entryfunction enforcing one schema, called from both the success and error paths. - Dropping
Nonefields keeps error log lines from carrying misleading emptyresponse/token fields. request_idgenerated before the call, so it exists even when the call raises.cache_read_input_tokenscaptured withgetattrsince it's only present once prompt caching applies.- The same schema reused for a multi-turn call, with an added
turn_countfield, showing the schema extends rather than gets replaced per call type.
Deep Dive
How It Works
- The schema is just a Python dict serialized to JSON; there's no special library required, though a dedicated JSON logging library (
python-json-logger,structlog) removes the manualjson.dumpsboilerplate at scale. - Centralizing the log call in one function (
log_entry) is what actually enforces the schema; if every call site builds its own dict inline, fields drift apart over time. logging.Logger(notprint) is used because it integrates with log level filtering, handlers, and most log aggregation agents (Datadog Agent, Fluent Bit, CloudWatch Logs agent) out of the box.- Error and success paths use the same field set with
statusdistinguishing them, so a single query likestatus:erroror an aggregation overinput_tokensworks regardless of outcome.
Schema Reference
| Field | Type | Always Present | Notes |
|---|---|---|---|
request_id | string | yes | Generated before the call, joins to trace spans |
model | string | yes | The model name used for the request |
prompt | string | yes | Full prompt text sent |
response | string | only on success | Full response text |
input_tokens / output_tokens | int | only on success | From response.usage |
cache_read_input_tokens | int | only when caching applies | Present once prompt caching is in use |
latency_ms | float | yes | Wall-clock time for the call |
status | string | yes | "ok" or "error" |
error_type / status_code | string / int | only on error | From the caught exception |
Python Notes
- Prefer
logging.Loggeroverprinteven for scripts; it costs nothing extra and means the same code works unmodified once you add handlers for a real log aggregator. - Use
logger.info(json.dumps(...))for a minimal setup; for higher-throughput services, alogging.Formattersubclass that JSON-encodes the wholeLogRecordavoids double-serialization and keeps stack traces intact on errors. - Redact or truncate
prompt/responsefields before logging if your application handles regulated or sensitive user data; a structured schema makes this easier since redaction becomes one function, not a per-call-site decision.
Gotchas
- Building the log dict inline at every call site. Without a shared
log_entryfunction, one call site adds a field and another doesn't, and your schema silently drifts. Fix: centralize log construction in one function or a small logging adapter class. - Logging
usagefields before checking for an exception. If the call raises,responsenever gets assigned, and referencingresponse.usagein afinallyblock crashes withNameError. Fix: compute token fields only inside the success branch, and logNonefor them on the error path. - Missing
cache_read_input_tokensbreaking cost math downstream. Cache-related usage fields aren't always present on the response object depending on SDK version and whether caching applied. Fix: access them withgetattr(response.usage, "cache_read_input_tokens", None)rather than direct attribute access. - Logging full prompt/response text at a very high log level in production. This can balloon log storage costs and, if prompts contain user PII, creates a compliance surface you didn't intend. Fix: apply redaction/truncation before logging, and confirm your log retention policy for this stream matches your data handling requirements.
- Reusing
print(json.dumps(...))instead of the logging module. This bypasses log level filtering and most log aggregation agents, which expect log lines on stdout/stderr via a logging framework or expect a specific file format. Fix: always route throughlogging.Logger, even for a quick script.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Ad hoc print statements | A one-off debugging script you'll delete in an hour | Any code that will run more than once or be read by a teammate |
A dedicated structured logging library (structlog) | You want automatic context binding and less JSON boilerplate at scale | A small script where the added dependency isn't worth it |
| OpenTelemetry span attributes instead of logs | You only need short, queryable metadata (tokens, model, latency) | You need the full prompt/response text preserved, which spans aren't meant to carry |
| A managed LLM observability platform's SDK | You want prompt/response capture with a ready-made UI and minimal setup | You need full control over the schema or must avoid sending prompt content to a third party |
FAQs
Why not just use `print()` for logging Claude calls during development?
print output isn't structured, doesn't integrate with log level filtering, and most log aggregation tools (Datadog Agent, CloudWatch Logs, Fluent Bit) expect output through a logging framework to parse it reliably. Using logging.Logger from the start costs nothing extra and means the same code works once you add real handlers.
Should I log the full prompt and response text, or just a summary?
Log the full text when you can, since truncated logs make debugging quality issues much harder. If your prompts or responses contain sensitive user data, add redaction before logging rather than omitting the fields entirely, so you keep debuggability without the compliance risk.
What is the minimum set of fields a structured log entry needs?
request_id,model,prompt,response,input_tokens,output_tokens,latency_ms, andstatus.- Everything else (cache tokens, error details, turn count) is additive on top of that base schema.
How do I keep the schema consistent across many call sites in a large codebase?
Centralize logging in one function or a small adapter class that every call site uses, rather than letting each call site build its own log dict. This is the single biggest lever for schema consistency; without it, fields drift within weeks.
What happens to `response.usage` if the API call raises an exception?
try:
response = client.messages.create(...)
except anthropic.APIStatusError as exc:
# response was never assigned; log None for usage fields instead
log_entry(status="error", error_type=exc.__class__.__name__, input_tokens=None)
raiseThere is no response object to read usage from, so the error branch must log None (or omit) the usage fields rather than trying to reference an unassigned variable.
Do streaming responses need a different logging approach?
The schema stays the same, but you log once after the stream completes, using stream.get_final_message() for the final usage numbers, rather than logging per chunk. Logging inside the chunk loop would produce one log line per token fragment instead of one per call.
Why include a `request_id` if I'm not using tracing yet?
- It future-proofs your logs for correlation with tracing once you add it.
- It's also useful on its own for correlating a user's bug report with the exact call that produced their result.
- Generating it costs nothing and is a single
uuid.uuid4()call.
Is `cache_read_input_tokens` always present on the response?
No, it's only meaningfully populated once prompt caching is in use for that call. Access it defensively with getattr(response.usage, "cache_read_input_tokens", None) so your logging code doesn't break on calls that don't use caching.
Should error logs use the exact same schema as success logs?
Yes, with status as the field that distinguishes them and token/response fields set to None on error. Keeping the schema shared means one query (e.g. average latency, or count grouped by status) works across both outcomes without separate log formats.
How does this schema connect to a cost dashboard?
The input_tokens, output_tokens, and model fields are exactly what a cost calculation needs; a log shipper or batch job reads these structured log lines, multiplies by per-model pricing, and forwards the result to a dashboard. Without a consistent schema, that calculation has nothing reliable to parse.
What's a reasonable log level for these entries?
INFO for successful calls is typical, since they're expected, high-volume events you still want retained for cost and usage analysis. ERROR (or WARNING, depending on severity) fits the failure path, which lets you filter dashboards and alerts by level in addition to the status field.
Related
- Observability Basics - the smaller, single-function version of this pattern.
- How Observability Works for LLM Applications - where structured logging fits relative to tracing and alerting.
- Instrumenting Agent Loops with OpenTelemetry Tracing - joining these log lines to trace spans via request ID.
- Integrating Claude Usage and Cost Dashboards with Datadog - shipping this schema's token fields into a cost dashboard.
- Observability Best Practices - a consolidated checklist across this section.
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the official
anthropicPython SDK (latest 0.x release). Model names, pricing, and SDK versions move quickly - verify current specifics at platform.claude.com/docs before relying on them.