Tip: LLM-generated article
For the most part, this article was LLM generated, especially the snippets and examples.
I’m not sure if anyone will find this interesting but…
You can teach lnav about Claude Code session stored on your machines and then have it properly parse them and make them queryable.
Here’s the format file:
Drop that in ~/.config/lnav/formats/claude-code/format.json
and open a session:
Replace <project>
with a specific project or projects you work on or use *
to index all sessions across all of your projects.
Querying without the TUI #
Section titled Querying without the TUI The interactive viewer is nice, but the real fun is that lnav puts every log file behind SQLite. You can run a query and print the result straight to your terminal:
-n
is headless mode, -q
silences the startup chatter, the ;
prefix marks a SQL command and :write-table-to -
dumps the result set to stdout. There’s also write-csv-to
, `write-json-to`
, `write-jsonlines-to`
and a few others if you want to pipe it somewhere. You can pass as many -c
pairs as you like and they’ll all run against a single index, or put them in a file and use lnav -f queries.lnav
.
Tip: use -r
Subagent transcripts live in a subagents/
subdirectory next to the session
file. Point lnav at the project directory with -r
and it will pick up everything recursively. Without it, every query below silently excludes your subagents.
Everything below queries claude_code_session
, the table lnav names after the format. I’ve left my own output out — point these at your transcripts instead, the shape of the answers is the interesting part.
So what’s in there? #
Section titled So what’s in there?
A transcript is more than the conversation. Alongside user
and assistant
you’ll find attachment
entries (hook output, diagnostics, task reminders), file-history-delta
records backing undo, queue-operation
entries for messages typed while it was busy, and the occasional pr-link
. It’s worth running this one first just to see how much of a transcript isn’t the conversation.
Where do the turns actually go? #
Section titled Where do the turns actually go? Each assistant entry holds exactly one content block, so you can count what the model spends its turns doing:
Three block types come back: tool_use
, thinking
and text
. Only the last one is prose written for you to read, and the ratio between the three is a surprisingly good summary of how a session went.
Which tools fail? #
Section titled Which tools fail?
This one needs a join: a tool_use
block in an assistant entry, matched to the tool_result
block in the user entry that answers it.
is_error
on the result block is what turns this from a count into a rate. MCP tools and Bash
tend to sit at the top; the HAVING
clause keeps one-off tools from dominating with a 100% failure rate on a single call.
Cache economics #
Section titled Cache economics
Token usage is recorded per message, so the whole cost picture is one GROUP BY
:
Grouping by model matters because a session can switch models partway through, and the <%
filter drops the <synthetic>
messages Claude Code writes for things like interrupts. The column to look at is cache_pct
— I was not expecting prompt caching to be doing quite as much of the work as it is.
The slowest tool calls are me #
Section titled The slowest tool calls are me Same join as before, but subtracting the two timestamps instead of counting errors:
Run this and the top of the list will almost certainly be ExitPlanMode
and AskUserQuestion
— the tools whose implementation is a human deciding something, sometimes the next morning. Add a WHERE tool NOT IN (...)
if you want actual tool latency rather than a measure of your own response time.
Odds and ends #
Section titled Odds and ends The first word of every shell command it ran, which is a decent picture of how it explores a repo:
How much work gets delegated to subagents (this is the query that needs -r
):
attributionAgent
gives you the agent type and agentId
is unique per run, so count(DISTINCT agentId)
tells you how many times each one was spawned. The main thread has no agentId
, so it lands in the (main thread)
bucket with a run count of zero.
And a few more that need no explanation:
log_time
, log_idle_msecs
and log_opid
come free with every lnav format. The last one is wired to sessionId
in the format file above, so a merged view of many files can still be grouped back into individual sessions.
Assorted notes and findings #
Section titled Assorted notes and findings
lnav can’t index into JSON arrays. Nested objects flatten into /
-joined column names, which is why "message/usage/output_tokens"
works (and why it needs the double quotes). Arrays don’t get that treatment: there is no message/content/0/type
column. Declare the array as "kind": "json"
and reach into it with jget(col, '/0/name')
. Every query above is shaped by that one limitation.
** AS MATERIALIZED isn’t optional.** Both halves of the tool-call join work fine on their own and fail the moment you join them, with
invalid char in json text
and no clue which row caused it. SQLite flattens the subqueries and ends up evaluating jget
against rows that the other side’s WHERE
clause would have excluded, including the user entries whose message.content
is a plain string rather than an array. MATERIALIZED
forces the boundary and json_valid()
handles the rest. ** lnav -f script.lnav stops at the first failing query** and still exits 0. I lost ten queries to a typo in query three and didn’t notice.
** --anonymize is partial.** Every
`write-*-to`
command accepts it, and it consistently pseudonymises usernames and path prefixes — a home directory comes out as the same made-up word in every row. But it left repository names and filenames alone when I tried it. Transcripts pick up whatever you happened to be working on, so read your output before you publish it rather than trusting the flag.