Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

The decision ledger on disk

One JSON line per request, appended forever. How fast it grows, when it rotates, what gets deleted, and what a reader sees afterwards.

ExplanationLast updated

On this page

Every request TokenTriage sees becomes one JSON line on a disk you own. That is the whole storage model, and it raises the first question any operator asks: how big does it get, and what stops it.

The answer is three mechanisms that do three different jobs. Rotation bounds the file you are writing to. Retention bounds how long the rotated copies survive. The writer queue bounds how far behind the disk is allowed to fall before rows start being discarded rather than delaying your traffic.

This page measures each one on real output so you can size a volume and set a policy. If you want the mechanics of the drop counter and the other telemetry, what a drop means owns that; here we cover the file itself.

How fast it grows

A row’s size depends on what the request carried. Every row holds the same fixed skeleton — identifiers, the routing decision and its explain path, usage, cost with its pricing snapshot, latency. On top of that sits whatever varies with your traffic: the model name, the number of messages, tool counts, and any business dimensions you tag.

A concrete figure to start from. Twenty single-message Anthropic completions through a local mock produced this:

$ wc -c -l data/decisions.jsonl
   20 34620 data/decisions.jsonl

That is 1,731 bytes per row, so roughly 1.7 KB per request for traffic of that shape. Your rows will be larger if you send long tool lists or many dimensions, and larger again in shadow mode, which adds the would-route decision to every row. Measure yours the same way after a day of real traffic rather than adopting the figure above.

The default chart PVC is 10Gi, which holds roughly six million rows at that size. The compressed history costs far less, as the next section shows.

Rotation bounds the active file

ledger.decision_log.rotate_mb sets the size at which the writer closes the active file, gzips it under a timestamped name, and starts a fresh one. The default is 256 MiB; a value of 0 or less turns size-based rotation off entirely and lets one file grow without bound.

ledger:
  decision_log:
    path: /data/.tokentriage/decisions.jsonl
    rotate_mb: 256 # rotate the active file past this size; 0 disables rotation
    queue: 8192 # bounded writer queue; overflow drops and counts

Rotation is checked after each append, so the active file can exceed the threshold by at most one row. Driving 2,500 requests through an instance configured with rotate_mb: 1 left this on disk:

$ ls -la data/
-rw-r--r-- 1  19668 decisions-20260810T125338-1.jsonl.gz
-rw-r--r-- 1  19588 decisions-20260810T125338-2.jsonl.gz
-rw-r--r-- 1  19635 decisions-20260810T125338-3.jsonl.gz
-rw-r--r-- 1  19647 decisions-20260810T125338.jsonl.gz
-rw-r--r-- 1 159192 decisions.jsonl

Two things in that listing are worth reading closely.

The compression is dramatic. Each companion holds a full 1 MiB of rows in about 19.6 KB — a ratio near 53:1. Decision rows are highly repetitive JSON, so they compress far better than general data. That is why the retention size cap in the next section is a cap on compressed bytes and why history is cheap to keep.

The timestamp is second-precision. All four rotations happened inside one second, so the first took the plain stamp and the rest took -1, -2, -3 suffixes. A collision suffix in your directory means rotation fired more than once that second, not that anything went wrong.

Where each bound acts on the decision ledgerA request hands its row to a bounded writer queue; when that queue is full the row is discarded and counted rather than delaying the request. Rows that get through are appended to the active decision log. When the active file passes rotate_mb it is gzipped into a timestamped companion and a fresh active file is opened. Retention deletes companions once they exceed max_age or the max_total_mb cap, and can never select the active file. Readers such as the CLI join the companions and the active file together.Requestone rowWriter queuequeue: 8192Dropped, counteddeclog_dropped_totalfullActive filedecisions.jsonlappendCompressed companions-<ts>.jsonl.gzrotate_mbDeleted, auditedmax_age · max_total_mbretention never selects this fileReaders join companions + active filetokentriage top · report · query

Read the figure left to right. The active file is the only thing being written; a rotation turns it into a compressed companion and opens a fresh one. Retention only ever reaches the companions, and the dashed boundary around the active file is the line the sweeper cannot cross.

Retention bounds the history

The top-level retention: block deletes rotated companions. Both limits default to zero, and zero means keep forever.

retention:
  max_age: 720h # delete rotated files older than this; 0 = keep forever
  max_total_mb: 512 # cap the total size of all rotated files; 0 = unbounded
Key Default What it does
max_age 0 (keep forever) deletes a rotated file whose rotation timestamp is older than the duration
max_total_mb 0 (unbounded) after the age pass, evicts the oldest survivors until the compressed total is under the cap

The sweeper runs hourly, and the first sweep happens one interval after start rather than at boot, so a fresh process does not prune immediately. Each deletion writes an audit row. Here is a sweep over a directory holding four companions dated across five weeks, with max_age: 720h:

# before:
  decisions-20260701T031500.jsonl.gz                19647 bytes
  decisions-20260715T031500.jsonl.gz                19647 bytes
  decisions-20260801T031500.jsonl.gz                19647 bytes
  decisions-20260809T031500.jsonl.gz                19647 bytes
  decisions.jsonl                                  165342 bytes

# plan: 1 file(s) selected
  decisions-20260701T031500.jsonl.gz                19647 bytes  max_age

# after:
  audit.jsonl                                         266 bytes
  decisions-20260715T031500.jsonl.gz                19647 bytes
  decisions-20260801T031500.jsonl.gz                19647 bytes
  decisions-20260809T031500.jsonl.gz                19647 bytes
  decisions.jsonl                                  165342 bytes

And the row it wrote:

{"v":1,"ts":"2026-08-10T12:55:42.0590646Z","actor":"system","role":"","origin":"cli","action":"retention.delete","resource":"decisions-20260701T031500.jsonl.gz","summary":"deleted rotated log decisions-20260701T031500.jsonl.gz (19647 bytes, max_age)","result":"ok"}

Those blocks come from a harness that calls the shipped sweeper and the shipped audit writer directly, because the hourly cadence is not something a page can wait for. The selection, the deletion and the audit row are the product’s own code paths; only the clock is supplied.

The size cap works the same way over whatever survives the age pass. Staging 84 companions totalling 1,650,348 bytes and setting max_total_mb: 1 selected the 31 oldest and left 53 files totalling 1,041,291 bytes, under the 1,048,576-byte cap, oldest evicted first.

Why the active file can never be deleted by retention, whatever you configure

The sweeper’s candidate test is strict, and it is strict because the operation is destructive. A file is a candidate only if its name matches <stem>-<timestamp>.jsonl.gz and the timestamp parses. The active <stem>.jsonl matches neither condition, being neither gzipped nor timestamp-suffixed, so no configuration can select it.

The same strictness protects your own files. A decisions-backup.jsonl.gz you copied into the directory has a leading segment that is not a timestamp, so it is not a candidate and stays where you put it. A sibling log’s companion, such as decisions-audit-20260701T031500.jsonl.gz belonging to a stem of decisions-audit, is excluded for the same reason.

The trade is deliberate: a genuinely corrupt timestamp means a file is retained rather than pruned. Retention errs towards keeping data.

The writer queue bounds the lag

The ledger writer is asynchronous. A request hands its row to a bounded channel and returns immediately; a single goroutine drains the channel, marshals each row and appends it. When the channel is full the row is discarded and counted rather than making the request wait. ledger.decision_log.queue sets the depth, and the default is 8,192.

That choice has an operational consequence worth seeing rather than reading about. An instance configured with queue: 1 and driven with 5,000 requests produced this:

$ curl -s localhost:9091/metrics | grep declog_dropped
tokentriage_declog_dropped_total 1539

$ curl -s localhost:9091/metrics | grep requests_total
tokentriage_requests_total{dialect="anthropic",mode="ledger",path="/v1/messages",status="200"} 5000

$ wc -l data/decisions.jsonl
3461 data/decisions.jsonl

Every one of the 5,000 requests was served with a 200. 3,461 rows reached the file and 1,539 were dropped, and the two add up to exactly the request count. The traffic was fine; the record of it was not.

The cost lands on anything computed from the file:

$ tokentriage top --log ./data/decisions.jsonl
MODEL             REQS  INPUT    OUTPUT   COST
claude-haiku-4-5  3461  4153200  1211350  $10.209950
TOTAL             3461  4153200  1211350  $10.209950

The instance actually served 5,000 requests worth $14.75 at the same per-request price. The rollup is short by $4.54 and cannot say so, because the rows that would have carried the disclosure are the rows that were never written. The counter on the metrics endpoint is the only place the shortfall exists. That asymmetry, and what to do about each of the four drop counters, is covered in what a drop means.

A queue depth of 1 is not a realistic setting; it was chosen to make the drop happen on demand. At the default of 8,192 the writer has to fall more than eight thousand rows behind, which in practice means a stalled disk, a full filesystem, or a burst far beyond what the volume can absorb.

What a reader sees across a rotation

Rotation splits your history across several files, and the surfaces that read it do not all treat that the same way. This is the part that surprises people after a restore.

The CLI joins. Given a directory holding one active file of 97 rows and four companions, top reports the whole corpus whether you point it at the file or the directory:

$ wc -l < decisions.jsonl
97

$ tokentriage top --log ./rotdemo/decisions.jsonl
MODEL             REQS  INPUT    OUTPUT  COST
claude-haiku-4-5  2520  3024000  882000  $7.434000
TOTAL             2525  3024000  882000  $7.434000

The dashboard’s SQLite read-model does not, when it is rebuilt from a file path:

$ tokentriage index rebuild --index ./rotdemo/idx.sqlite --log ./rotdemo/decisions.jsonl
rebuilt index ./rotdemo/idx.sqlite from ./rotdemo/decisions.jsonl
  rows indexed:    97

$ tokentriage index rebuild --index ./rotdemo/idx.sqlite --log ./rotdemo
rebuilt index ./rotdemo/idx.sqlite from ./rotdemo
  rows indexed:    2525

During normal operation the gap does not appear, because the indexer tails the active file continuously and has already recorded each row before it rotates. It appears when the index is rebuilt: after a restore, after a schema bump self-heals, or when you run the command yourself.

What this does not cover

Nothing here changes what a row contains; the decision ledger fields own that. Sizing the rest of a deployment, including what the chart suggests for CPU and memory, is on sizing and capacity. In a multi-replica fleet each replica keeps its own ledger on its own volume and everything above applies per replica; multi-replica & scale-out covers the read-time merge over them.