The store
Everything Aftertrace keeps is in one DuckDB file on your machine. Four tables hold what arrived, and five views join them into what the UI, the rules and Ask read.
Point at a drawer to open it, or at a frame to see which tables that view reads. R sends the last batches again.
The file
- Where.
~/.aftertrace/aftertrace.duckdbby default.serve,import,demoandservice installall take--dbto use another path.servealso imports Claude Code session JSONL from~/.claude/projectsunless--no-history. - The WAL. Next to it is
aftertrace.duckdb.wal, DuckDB's write-ahead log. Each write is logged there before it reaches the file, which is why deleting the store means deleting both (Privacy). - One process at a time. DuckDB takes an exclusive lock on the file. A second
serve,importordemoon the same path fails withConflicting lock is held(Troubleshooting). - A sealed file. Aftertrace opens it with
enable_external_access=falseandlock_configuration=true. No query can read another file,ATTACHa database orINSTALLan extension, and noSETcan turn that back on. - Nothing is deleted for you. No code path removes rows. The file grows with what you send until you delete it.
How data goes in
The receivers turn every OTLP export into rows (Receiving), and redaction removes keys first (Redaction). Local session history is a second writer: it synthesises the same span and event rows from Claude Code JSONL, with aftertrace.source=history on the resource, and never copies prompt or response text (Connect Claude Code). Then one call writes the batch:
- It takes the store's one write lock. Only one batch is written at a time. Batches that arrive meanwhile wait their turn.
- It opens a transaction and inserts the batch's spans, span events, events and metric points.
- It commits, which makes the whole batch visible at once, and releases the lock.
Reads never take that lock. DuckDB gives every query a snapshot of the last commit, so a slow page or rule never holds up ingest, and ingest never holds up a page.
The four tables
Two tables have a primary key and are written with INSERT OR REPLACE. If a collector retries an export, or you import the same files twice, each span lands on its own key and replaces the copy already there. The other two have no key and are plain INSERTs, so a batch sent twice is stored twice. The figure's R shows both.
attrs and resource are JSON. They hold the record's attributes and its resource attributes, as they arrived minus the redacted keys.
spans
One row per span. Key: trace_id + span_id.
| Column | Type | What it holds |
|---|---|---|
trace_id, span_id |
VARCHAR | Hex ids. Together, the key. |
parent_span_id |
VARCHAR | NULL for a root span. |
name |
VARCHAR | For example claude_code.llm_request or claude_code.tool. |
start_ns, end_ns |
BIGINT | Unix nanoseconds. |
duration_ms |
DOUBLE | end_ns - start_ns, in milliseconds. |
is_error |
BOOLEAN | True when the span's status code is ERROR. |
status_message |
VARCHAR | The status message, or NULL. |
session_id |
VARCHAR | The session.id attribute, or the resource's if the span has none. |
attrs, resource |
JSON | Everything else. |
Indexes on session_id and name.
span_events
Events recorded inside a span, such as gen_ai.request.attempt, one per attempt of a model request. Key: trace_id + span_id + name + time_ns. Columns: those four, plus attrs.
events
One row per log record. Claude Code sends its events as logs. The name comes from the event.name attribute or the log body, with the claude_code. prefix removed, for example api_request, tool_decision or compaction. No key.
| Column | Type | What it holds |
|---|---|---|
ts_ns |
BIGINT | The record's time, or its observed time if it has none. |
name |
VARCHAR | The event name. |
session_id, prompt_id |
VARCHAR | From session.id and prompt.id. |
trace_id, span_id |
VARCHAR | The span the record was logged under, if any. |
request_id, tool_use_id |
VARCHAR | Pulled out of attrs so joins to spans are cheap. |
attrs, resource |
JSON | Everything else. |
Indexes on session_id, name, request_id, tool_use_id and trace_id.
metric_points
One row per data point of a sum or a gauge. Histograms and summaries are not stored. No key.
| Column | Type | What it holds |
|---|---|---|
name, unit |
VARCHAR | For example claude_code.cost.usage, USD. |
ts_ns |
BIGINT | The data point's time. |
value |
DOUBLE | Integer points are stored as doubles. |
session_id |
VARCHAR | From session.id on the point or its resource. |
attrs, resource |
JSON | The point's attributes, such as model or type. |
history_files
One row per Claude Code transcript file that serve or import --history has looked at (path, size, mtime, session_id, status). It is how a later scan skips unchanged files. Not a signal table; views and the UI do not read it. Imported signal rows themselves live in spans and events with aftertrace.source=history on resource.
prices
The price list for model calls that do not report a cost. It is rewritten from the binary every time the store opens: one row per model-id prefix for Anthropic, OpenAI and Google models, with input, cache write, cache read and output prices in USD per million tokens. The longest matching prefix wins. Claude Code reports its own cost, so this is only used for your apps (Apps and agents).
The five views
The views do the joins once, so the UI, the rules and Ask read one row per thing instead of reassembling spans and events. They also pull the attributes you query most out of JSON into columns.
| View | One row per | Reads | What it adds |
|---|---|---|---|
llm |
Claude Code model request | spans, events |
The llm_request span joined to its api_request event on request_id: model, effort, tokens and cache tokens, ttft_ms, attempt, cost_usd, and the skill, agent or MCP tool that made the request. |
tools |
Claude Code tool call | spans, events |
The tool span with its permission wait (wait_ms, decision, decision_source) and its execution (exec_ms, success). |
q_spans |
span | spans, events |
Every span, with the api_request attributes and cost_usd merged onto model requests. It is what the Query page searches. |
turn_roots |
turn | spans |
The claude_code.interaction span of each finished turn. A turn still running has no such span yet, so its root is inferred from the missing parent its spans point at, with in_progress = true. |
calls |
model call from your own apps | spans, prices |
Calls from the OpenTelemetry GenAI conventions, the Claude SDK, OpenLLMetry, OpenInference, the Vercel AI SDK, Pydantic AI and Gemini CLI, normalised to service, model, tokens and cost_usd. Uses the reported cost when there is one and prices it from prices when there isn't. |
Two helper macros come with them. a(attrs, 'key') reads a JSON attribute as text and n(attrs, 'key') reads one as a number.
Querying it yourself
While Aftertrace runs, send one read-only SELECT or WITH to its API. It returns up to 500 rows, and the transaction is always rolled back (JSON API):
curl -s localhost:7311/api/sql -H 'content-type: application/json' \
-d '{"sql": "SELECT model, count(*) AS requests, round(sum(cost_usd), 2) AS usd FROM llm GROUP BY model ORDER BY usd DESC"}'
To use the DuckDB CLI instead, stop Aftertrace first (the file is locked while it runs), then duckdb ~/.aftertrace/aftertrace.duckdb.
Some queries to start from:
-- Which tools keep you waiting at the permission prompt
SELECT tool_name, count(*) AS calls, round(avg(wait_ms)) AS avg_wait_ms
FROM tools WHERE wait_ms IS NOT NULL
GROUP BY tool_name ORDER BY avg_wait_ms DESC LIMIT 5;
-- Turns per session and how long they take
SELECT session_id, count(*) AS turns, round(avg(duration_ms) / 1000, 1) AS avg_s
FROM turn_roots GROUP BY session_id;
-- What your own apps spend, by service and model
SELECT service, model, count(*) AS calls, round(sum(cost_usd), 4) AS usd
FROM calls GROUP BY ALL;
-- An attribute straight from the JSON, with the a() macro
SELECT a(attrs, 'tool_name') AS tool, count(*) AS n
FROM spans WHERE name = 'claude_code.tool'
GROUP BY tool ORDER BY n DESC;
-- Events stored more than once (they have no key)
SELECT name, count(*) - count(DISTINCT (ts_ns, attrs::VARCHAR)) AS extra_copies
FROM events GROUP BY name HAVING extra_copies > 0;
What every attribute means, and which Claude Code version sends it, is in Schema notes.