The terminal as the instrument

TL;DR. You do not need an observability stack to benchmark Claude Code: the platform is its own instrument. It exposes three layers of telemetry, from glanceable to industrial: the in-session gauges (/context, /usage, the statusline, claude -p --output-format json), the transcript files (a JSONL line per event under ~/.claude/projects/, each assistant turn carrying the exact API usage block from Chapter 23), and an opt-in OpenTelemetry exporter for fleet-level dashboards. The lab in this chapter parses this machine's real transcripts into a ledger: 273 sessions, 24,820 billed turns, 7.0 billion prompt tokens, a 96.8 percent cache hit rate, and $32,266 that prompt caching saved against a $39,118 uncached baseline. All of that was already on disk; the script just added it up.

Contents

Chapter 21 read the gauges; Chapter 23 read one receipt; Chapter 24 showed which knob moves which field. This chapter closes the loop with the part most people never discover: Claude Code records every one of those receipts, locally, for every session, and hands you three progressively deeper ways to read them back. The theme of the whole measurement part lands here: the platform itself is the lab bench. You open a terminal, you run a session, and the evidence of what it cost, where the tokens went, and whether the cache held is already written down before you think to ask.

Three layers of instrumentation

LayerInstrumentGranularitySetup
Live/context, /usage, statusline, claude -p --output-format jsonthis session, this momentnone
Historicaltranscript JSONL under ~/.claude/projects/every turn of every session, per usage fieldnone
FleetOpenTelemetry metrics and eventsaggregated across users and machinesenv vars + a collector

The right habit is bottom-up: glance at the gauges while working, mine the transcripts when a question needs history ("what did last week actually cost?", "is my cache hit rate degrading?"), and stand up OTel only when the question is about a team rather than a terminal.

Layer 1: the live gauges

The gauges themselves were dissected in Chapter 21; what belongs here is the part relevant to instrumentation: where their numbers come from and how to keep them in view.

The statusline is the gauge you do not have to ask for. Configure it once (/statusline, or a statusLine command in settings.json) and Claude Code pipes a JSON object to your script after every assistant message. That object carries the same four usage fields this part keeps returning to, live:

context_window.current_usage.input_tokens
context_window.current_usage.cache_creation_input_tokens
context_window.current_usage.cache_read_input_tokens
context_window.current_usage.output_tokens
context_window.context_window_size, used_percentage, remaining_percentage
cost.total_cost_usd, model.id, rate_limits.five_hour.used_percentage, ...

A ten-line shell script can render "cache 96% | ctx 41% | $3.20" in your prompt permanently, which is the cheapest possible defense against the silent misses of Chapter 24: if the cache percentage ever collapses after you edit your setup, you see it on the very next turn.

For scripted runs, claude -p "..." --output-format json prints a structured result whose fields include the session id, per-request usage, and a total_cost_usd estimate, so a CI job or a benchmark harness can meter itself without touching transcripts at all. This is the supported, stable interface for automation; the transcript format below is explicitly not.

Layer 2: the transcripts

Every session is appended, event by event, to a JSON Lines file:

~/.claude/projects/<munged-cwd>/<session-id>.jsonl

The directory name is your working directory with separators replaced by dashes (/Users/you/src/app becomes -Users-you-src-app), one file per session, plus a <session-id>/subagents/ directory holding a separate transcript per spawned subagent, which is how subagent spend stays out of the main window but still on the record. Files are kept 30 days by default (cleanupPeriodDays in settings).

Each line is one event: user lines for your prompts and tool results, assistant lines for model turns, plus housekeeping records. The assistant lines are the paydirt. Here is one from this machine, trimmed to the fields that matter:

{"type":"assistant",
 "message":{"model":"claude-opus-4-8",
            "usage":{"input_tokens":6199,
                     "cache_creation_input_tokens":6052,
                     "cache_read_input_tokens":15853,
                     "output_tokens":318,
                     "cache_creation":{"ephemeral_1h_input_tokens":6052,
                                       "ephemeral_5m_input_tokens":0},
                     "service_tier":"standard","speed":"standard"}},
 "requestId":"req_...","timestamp":"2026-06-29T01:29:52.804Z",
 "sessionId":"c778f680-...","isSidechain":false,"gitBranch":"main","version":"2.1.195"}

That usage object is byte-for-byte the API receipt from Chapter 23, stamped with the model, the time, the git branch, and whether the turn belonged to a subagent (isSidechain). Multiply by every turn of every session and the transcript directory is a complete, local, per-turn billing record of everything Claude Code has ever done on the machine. No key, no exporter, no vendor: it is already there.

Don't be confused. The transcript format is internal to Claude Code and changes between releases; the docs say so explicitly. Two consequences. First, parse defensively: read only what you need (the message.usage shape is the API's own and the most stable part), skip lines you do not recognize, and expect new fields. Second, for anything that must not break, prefer the supported surfaces: --output-format json for automation and OTel for pipelines. The ledger below is a diagnostic tool you rerun and adjust, not a billing system you ship.

The lab: a ledger from your own transcripts

The script walks the projects directory, pulls message.usage from every assistant line, and aggregates: tokens per model split into the four fields, the realized cache hit rate, cost at API prices (with the TTL-correct write multipliers), the counterfactual cost with caching off, and the most expensive sessions.

"""A usage ledger built from Claude Code's own transcripts. Real data.

Claude Code writes every session to disk as JSON Lines:

    ~/.claude/projects/<munged-cwd>/<session-id>.jsonl

Each assistant line in that file carries the exact `message.usage` block the
API returned for that turn: input_tokens, cache_creation_input_tokens (split
by TTL under `cache_creation`), cache_read_input_tokens, output_tokens. That
makes the transcript directory a complete, local, per-turn billing record of
everything Claude Code has done on this machine. No telemetry setup, no API
key: the data is already there.

This script walks the directory and answers the questions the gauges cannot:

  - how many tokens, per model, split into the four usage fields
  - the realized cache hit rate (read / total prompt tokens)
  - what the work cost at API prices, and what caching saved
  - the most expensive sessions, so you know where the tokens went

Caveat printed up front: the transcript format is internal to Claude Code and
can change between versions. This parser reads only `message.usage`, the most
stable part (it is the API's own response shape), and skips anything it does
not recognize.

Run with the standard library only:  python3 usage_ledger.py [projects_dir]
"""

import json
import sys
from collections import defaultdict
from pathlib import Path

# $/1M tokens: (input, output). Cache read is 0.1x input; cache writes are
# 1.25x (5m TTL) and 2x (1h TTL) of input.
PRICES = {
    "claude-opus-4-8": (5.00, 25.00),
    "claude-opus-4-7": (5.00, 25.00),
    "claude-opus-4-6": (5.00, 25.00),
    "claude-sonnet-4-6": (3.00, 15.00),
    "claude-sonnet-5": (3.00, 15.00),
    "claude-haiku-4-5": (1.00, 5.00),
    "claude-fable-5": (10.00, 50.00),
}
DEFAULT_PRICE = (5.00, 25.00)  # unknown model ids fall back to opus rates


def price(model):
    for known, p in PRICES.items():
        if model.startswith(known):
            return p
    return DEFAULT_PRICE


def cost_usd(model, u):
    inp, out = price(model)
    inp /= 1e6
    out /= 1e6
    w = u.get("cache_creation") or {}
    w5 = w.get("ephemeral_5m_input_tokens", 0)
    w1 = w.get("ephemeral_1h_input_tokens", 0)
    # Older lines may lack the TTL split; treat the lump sum as 5m writes.
    if w5 == 0 and w1 == 0:
        w5 = u.get("cache_creation_input_tokens", 0)
    return (u.get("input_tokens", 0) * inp
            + w5 * inp * 1.25 + w1 * inp * 2.00
            + u.get("cache_read_input_tokens", 0) * inp * 0.10
            + u.get("output_tokens", 0) * out)


def uncached_cost_usd(model, u):
    """What the same turn would have cost with no prompt cache at all."""
    inp, out = price(model)
    prompt = (u.get("input_tokens", 0)
              + u.get("cache_creation_input_tokens", 0)
              + u.get("cache_read_input_tokens", 0))
    return prompt * inp / 1e6 + u.get("output_tokens", 0) * out / 1e6


def main():
    root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.home() / ".claude/projects"
    per_model = defaultdict(lambda: defaultdict(int))
    per_session = defaultdict(float)
    totals = defaultdict(int)
    actual = saved_baseline = 0.0
    turns = sessions = 0

    for f in sorted(root.rglob("*.jsonl")):
        seen_in_file = False
        for line in f.open(errors="replace"):
            try:
                d = json.loads(line)
            except json.JSONDecodeError:
                continue
            if d.get("type") != "assistant":
                continue
            u = (d.get("message") or {}).get("usage")
            if not u:
                continue
            model = (d.get("message") or {}).get("model", "unknown")
            m = per_model[model]
            for k in ("input_tokens", "cache_creation_input_tokens",
                      "cache_read_input_tokens", "output_tokens"):
                m[k] += u.get(k, 0)
                totals[k] += u.get(k, 0)
            c = cost_usd(model, u)
            actual += c
            saved_baseline += uncached_cost_usd(model, u)
            per_session[f.stem] += c
            turns += 1
            seen_in_file = True
        sessions += seen_in_file

    prompt = (totals["input_tokens"] + totals["cache_creation_input_tokens"]
              + totals["cache_read_input_tokens"])
    print(f"=== Usage ledger from {root} ===")
    print(f"{sessions} transcripts with usage, {turns:,} billed API turns\n")

    print(f"{'model':<26}{'input':>12}{'cache write':>14}{'cache read':>16}{'output':>12}")
    for model, m in sorted(per_model.items(),
                           key=lambda kv: -sum(kv[1].values())):
        if sum(m.values()) == 0:
            continue  # synthetic/system rows carry no usage
        print(f"{model:<26}{m['input_tokens']:>12,}"
              f"{m['cache_creation_input_tokens']:>14,}"
              f"{m['cache_read_input_tokens']:>16,}"
              f"{m['output_tokens']:>12,}")

    print(f"\nTotal prompt tokens processed: {prompt:>14,}")
    print(f"  served from cache:           {totals['cache_read_input_tokens']:>14,}"
          f"  ({totals['cache_read_input_tokens'] / max(prompt, 1) * 100:.1f}% hit rate)")
    print(f"  written to cache:            {totals['cache_creation_input_tokens']:>14,}")
    print(f"  full-price remainder:        {totals['input_tokens']:>14,}")
    print(f"Output tokens:                 {totals['output_tokens']:>14,}\n")

    print(f"Cost at API prices:            ${actual:>12,.2f}")
    print(f"Same work with caching off:    ${saved_baseline:>12,.2f}")
    if actual:
        print(f"Prompt caching saved:          ${saved_baseline - actual:>12,.2f}"
              f"  ({saved_baseline / actual:.1f}x)\n")

    print("Top 5 most expensive sessions:")
    for sid, c in sorted(per_session.items(), key=lambda kv: -kv[1])[:5]:
        print(f"  {sid}  ${c:,.2f}")


if __name__ == "__main__":
    main()

Running it on this machine, today (a snapshot; yours will differ, and this number grows while you work):

=== Usage ledger from /Users/s0x/.claude/projects ===
273 transcripts with usage, 24,820 billed API turns

model                            input   cache write      cache read      output
claude-opus-4-8              7,137,103   193,004,430   6,114,876,785  32,733,842
claude-fable-5                 360,726    17,792,182     618,454,099   3,394,316
claude-sonnet-4-6                1,165     3,805,632      46,015,436     952,623
claude-haiku-4-5-20251001       44,882     1,998,637      22,109,283     232,363

Total prompt tokens processed:  7,025,600,360
  served from cache:            6,801,455,603  (96.8% hit rate)
  written to cache:               216,600,881
  full-price remainder:             7,543,876
Output tokens:                     37,313,144

Cost at API prices:            $    6,852.61
Same work with caching off:    $   39,118.29
Prompt caching saved:          $   32,265.68  (5.7x)

Top 5 most expensive sessions:
  27c17bac-95bc-4bd3-802a-8d5fea5467df  $701.69
  478c0135-0419-498e-8c92-919cd51480df  $670.78
  219e05d3-7f6e-496d-bd82-d16dd60b4a47  $463.43
  4e4beca1-d271-41be-821c-8e38e59ce9eb  $446.99
  7c757dcc-1531-4e84-9261-a105e3fccded  $414.66

Reading the ledger

Every claim this book has made about agent economics is sitting in that readout, measured on real work rather than modeled:

  • The window really is re-sent every turn. Seven billion prompt tokens against 37 million output tokens is a 188:1 ratio. Nobody typed seven billion tokens; that is the same growing conversation billed again on every one of 24,820 turns, exactly the mechanism Chapter 17 described and Chapter 22 modeled.
  • The cache is what makes the loop affordable at all. 96.8 percent of those prompt tokens were cache reads at 0.1x. The counterfactual column prices the identical work uncached: $39,118 instead of $6,853. A harness with the invalidator bug from Chapter 24's experiment B would have paid the difference without a single visible error.
  • The remaining cost is output plus writes, which is where your levers are. With reads nearly free, the bill is dominated by the 5x output tokens and the 2x 1-hour cache writes (the ledger's per-TTL pricing uses the split from Chapter 23). That is the measured justification for the priority order the optimization lab found: bound the history, delegate verbose work, shorten the output; the prefix is already handled.
  • The expensive-session list is your review queue. Five sessions account for $2,698 of the total. Pull one apart (its file name is the session id) and you will usually find the expensive archetype from Chapter 21: a very long thread that should have been /cleared, or a subagent fan-out that a single window could have done.

The same script bends to any question in a few lines: group by day instead of model, by gitBranch, by isSidechain to isolate subagent spend, or filter one project's directory to price a single repo's development. That is the point of layer 2: it is not a dashboard, it is data.

If you want the dashboard without writing it yourself, this exact parsing job is what the open-source ccusage tool productizes (daily, monthly, per-session and live-block reports from the same JSONL); Chapter 26 runs it end to end.

Layer 3: OpenTelemetry

When the question outgrows one machine ("what does the team spend?", "which model mix are we running?", "alert if cost per developer doubles"), Claude Code has a supported answer: an opt-in OpenTelemetry exporter. It is off by default and never sends data to Anthropic; you point it at your own collector.

export CLAUDE_CODE_ENABLE_TELEMETRY=1      # master switch
export OTEL_METRICS_EXPORTER=otlp          # or: prometheus, console
export OTEL_LOGS_EXPORTER=otlp             # events; or: console
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317

The metrics it emits are the fleet versions of everything this part has measured by hand:

MetricWhat it counts
claude_code.token.usagetokens, dimensioned by type (input, output, cache read, cache write) and model
claude_code.cost.usageestimated USD per model
claude_code.session.countsessions started
claude_code.active_time.totalseconds of active use
claude_code.lines_of_code.count, claude_code.commit.countwhat the tokens bought

Events (claude_code.api_request, claude_code.tool_result, claude_code.tool_decision, and friends) carry per-request detail, with standard attributes (session.id, user.email, organization.id, app.version) for slicing. Opt-in flags add payloads for debugging (OTEL_LOG_USER_PROMPTS=1, OTEL_LOG_TOOL_DETAILS=1), and a beta tracing mode (CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1 with OTEL_TRACES_EXPORTER=otlp) emits spans for each interaction, LLM request, hook, and tool execution.

The zero-infrastructure way to see it work is the console exporter, which needs no collector at all:

CLAUDE_CODE_ENABLE_TELEMETRY=1 OTEL_METRICS_EXPORTER=console \
  OTEL_METRICS_EXPORT_INTERVAL=10000 claude

and token counters start printing to stderr every ten seconds (illustrative; the exact frames depend on your version). From there, pointing OTEL_EXPORTER_OTLP_ENDPOINT at a Grafana, Datadog, Honeycomb, or Langfuse collector is configuration, not code. Chapter 26 wires up the open-source options.

Remember. Match the layer to the question. "Is this session healthy?" is a statusline glance. "What did this month cost and where?" is a transcript ledger. "How is the team trending?" is OTel. Standing up the heavy layer to answer the light question is its own kind of context-engineering failure: infrastructure tokens spent where a grep would do.

Further reading

  • Claude Code, "Monitoring usage" (code.claude.com/docs/en/monitoring-usage): the authoritative OTel reference, with the full metric, event, and attribute tables.
  • Claude Code, "Sessions" and "Manage costs effectively" (code.claude.com/docs): transcript location and retention, /usage, and the plan-versus-API cost distinction.
  • Claude Code, "Statusline" (code.claude.com/docs/en/statusline): the full JSON schema your statusline script receives, including context_window.current_usage.
  • ccusage (github.com/ryoppippi/ccusage): the open-source productization of the transcript ledger; walked end to end in Chapter 26.

Takeaways

  • Claude Code instruments itself at three depths, all built in: live gauges and the statusline, per-turn transcript JSONL under ~/.claude/projects/, and an opt-in OTel exporter for fleets.
  • Every assistant line in a transcript carries the exact API usage block: the four fields, the TTL split, model, timestamp, branch, and a subagent flag. Your disk already holds a complete per-turn billing record.
  • The real ledger from this machine: 273 sessions, 24,820 turns, 7.0B prompt tokens at a 96.8 percent cache hit rate; $6,853 actual versus $39,118 uncached, so caching saved $32,266 (5.7x). The 188:1 prompt-to-output ratio is the re-sent window, measured.
  • What remains after the cache does its job is output tokens and cache writes, which is why bounding history, delegating, and shortening output outrank prefix tuning once caching is healthy.
  • Parse transcripts defensively (the format is internal and versioned); automate against claude -p --output-format json and OTel instead when stability matters.
  • Match the instrument to the question: statusline for now, ledger for history, OTel for the team.

👉 The instruments are in hand and the numbers are yours. Time to use them on a live experiment: the next chapter walks caching in Claude Code step by step, then implements one real feature twice, naive and engineered, and audits both sessions turn by turn. Continue to The guided lab.