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.
Search across all documentation pages
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.
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.
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:
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:
log_entry function enforcing one schema, called from both the success and error paths.None fields keeps error log lines from carrying misleading empty response/token fields.request_id generated before the call, so it exists even when the call raises.cache_read_input_tokens captured with getattr since it's only present once prompt caching applies.turn_count field, showing the schema extends rather than gets replaced per call type.python-json-logger, structlog) removes the manual json.dumps boilerplate at scale.log_entry) is what actually enforces the schema; if every call site builds its own dict inline, fields drift apart over time.logging.Logger (not print) 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.status distinguishing them, so a single query like status:error or an aggregation over input_tokens works regardless of outcome.| 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 |
logging.Logger over print even for scripts; it costs nothing extra and means the same code works unmodified once you add handlers for a real log aggregator.logger.info(json.dumps(...)) for a minimal setup; for higher-throughput services, a logging.Formatter subclass that JSON-encodes the whole LogRecord avoids double-serialization and keeps stack traces intact on errors.prompt/response fields 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.log_entry function, 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.usage fields before checking for an exception. If the call raises, response never gets assigned, and referencing response.usage in a finally block crashes with NameError. Fix: compute token fields only inside the success branch, and log None for them on the error path.cache_read_input_tokens breaking 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 with getattr(response.usage, "cache_read_input_tokens", None) rather than direct attribute access.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 through logging.Logger, even for a quick script.| 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 |
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.
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.
request_id, model, prompt, response, input_tokens, output_tokens, latency_ms, and status.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.
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.
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.
uuid.uuid4() call.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.
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.
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.
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.
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.
Reviewed by Chris St. John·Last updated Jul 13, 2026