sanity-logs
v0.8.0
Published
Analyze Sanity request-log exports locally with DuckDB and a browser dashboard. Nothing is uploaded.
Maintainers
Readme
sanity-logs
Analyses a Sanity CMS request-log export from the terminal, then serves an interactive dashboard for it. Nothing is uploaded and nothing leaves the machine: the export is parsed by native DuckDB in this process, and the dashboard is served on loopback.
Running it
Nothing to install:
npx sanity-logs analyze export.ndjson.gzOr install it, if you run it often enough to care about the download:
npm i -g sanity-logsNeeds Node 22 or 24. The dashboard ships inside the package, so the analysis and the UI are always the same build.
Check the name of the file you downloaded before you type it. Sanity names an export
<projectId>-<from>-<to>.ndjson.gz, so a space in it is not Sanity's doing — it comes from
expanding the .gz, where macOS's Archive Utility appends 2 if that name is already taken. The
space is nothing to this tool, which reads the path as a string. It is the shell that splits on it
first, so only the part before the space reaches the CLI, and the error quotes that fragment rather
than the file you meant:
$ sanity-logs analyze demo1234-2026-08-10-2026-08-17 2.ndjson
Failed to read "demo1234-2026-08-10-2026-08-17": IO Error: No files found that match the pattern "demo1234-2026-08-10-2026-08-17"
LINE 17: FROM read_json('demo1234-2026-08-10-2026-08-17', format = 'newli...
^Quote it or rename it — either works, and nothing else needs installing:
sanity-logs analyze "demo1234-2026-08-10-2026-08-17 2.ndjson"Better still, do not expand the download at all: see Gzipped exports.
From a checkout
bun run cli analyze <export.ndjson|export.ndjson.gz> [options]That compiles sanity-logs and its dependencies first, so the dashboard it serves can never be a
stale build. Once the build is current, the binary works on its own:
sanity-logs analyze export.ndjsonanalyze is the only subcommand. With no subcommand or no path it prints the usage and exits 1;
--help prints the same and exits 0.
What it prints
The report goes to stdout, then the dashboard opens. Every table is capped by --top (default 10).
rows 4,000 parsed in 0.0s
window Jul 20, 2026, 12:00 AM – Jul 26, 2026, 11:44 PM
requests 4,000 streaming connections 727
error rate 19.0% (all segments)
latency p50 919ms p95 1.74s p99 4.21s
bandwidth 82.4 MB studio share 8.5%
findings
1 critical and 2 warning issues across errors. Biggest: Server errors are 3.9% of all requests.
critical Server errors are 3.9% of all requests
156 of 4,000 requests (3.9%) returned a 5xx.
fix: Check upstream service health — a sustained 5xx rate at this share points at the origin, not the client.
warning Client errors are 3.9% of all requests
156 of 4,000 requests (3.9%) returned a 4xx other than auth or rate-limit codes.
fix: Look for a client sending malformed queries or stale mutations — 4xx at this share is usually a bug, not user error.
warning The client is being rate-limited
155 requests returned 429.
fix: Add backoff and reduce request concurrency — 429s at this volume mean the client is running above its plan quota.
13 of 16 checks passed
status
200 3,066 68.1 MB
304 312 1.2 MB
400 156 312.8 kB
429 155 100.9 kB
500 156 198.4 kB
method
GET 3,065 79.9 MB
PUT 468 1.6 MB
POST 467 904 kB
domain
apicdn 2,667 74.3 MB
api 1,333 8.1 MB
top endpoints
query 1,819 61.2 MB
doc 727 9.8 MB
live 364 6.4 MB
mutate 364 3.1 MB
assets 363 1.9 MB
latency distribution
<100ms 164
100–500ms 720
500ms–1s 898
1–5s 1,476
>5s 15
streaming connections
live 364
listen 363
slowest requests
5.94s GET 200 https://demo1234.apicdn.sanity.io/v2024-10-28/data/query/production?query=…
5.92s POST 502 https://demo1234.apicdn.sanity.io/v2024-10-28/data/query/production?query=…requests is count(*) under the active filters, so with no --segment it counts every row in
the export. The streaming connections beside it are a subset of that total, not a separate
population — in the run above, 727 of those 4,000 rows are streaming.
The latency percentiles read request traffic only. listen, live and socket
are long-lived subscriptions whose duration is a connection lifetime rather than a request
latency, so blending them in produces an average measured in seconds that describes nothing. They
get their own line and their own table.
error rate deliberately does not share that restriction — it counts every segment. A status
code means the same thing on a streaming row as on a request row, and scoping it the way the
percentiles are scoped hides real failures: on a reference export, 783 of 807 5xx responses were
502s on listen, so the scoped version reported a 0.0% error rate against 807 server errors.
401 stays excluded as ordinary token-refresh noise.
bandwidth is sum(responseSize) in decimal units — kB/MB/GB at 10³, matching how Sanity
bills, so this figure lines up with the invoice rather than understating it. studio share is the
fraction of requests (not bytes) that come from Sanity Studio itself.
findings is a rule-based read of the same export — cost, error, query and traffic checks that
flag what's worth investigating — printed ahead of the tables because it's the answer to "why did
my bill spike," not a footnote. Every check renders; the ones that passed are tallied on the
closing line rather than printed individually, so coverage stays visible without the noise. A
docs: line appears under a verdict when Sanity has its own guidance to link.
Empty tables are omitted rather than printed as headings with nothing under them.
Options
| Flag | Effect |
|------|--------|
| --json | The whole report as JSON instead of tables. Implies --no-open |
| --no-open | Print the report and exit, without the dashboard |
| --no-browser | Serve the dashboard but do not open a browser at it |
| --segment <s> | request, streaming or all (default all) |
| --studio <bool> | true or false — Studio vs application traffic |
| --status <code> | Repeatable: --status 500 --status 502 |
| --endpoint <name> | Repeatable: --endpoint query |
| --method <verb> | Repeatable: --method POST |
| --domain <name> | Repeatable: --domain api |
| --ip <addr> | Repeatable: --ip 203.0.113.5 |
| --referer <url> | Repeatable, prefix match: --referer https://example.com |
| --search <text> | Repeatable and ANDed, unlike the rest: --search a --search b requires both. Substring match on the URL or the GROQ query id |
| --from <iso> | Inclusive lower bound on timestamp |
| --to <iso> | Inclusive upper bound on timestamp |
| --top <n> | Rows per table (default 10) |
| --port <n> | Dashboard port (default: ephemeral) |
| -h, --help | Print the usage |
These are the dashboard's filters, under the same names. Whatever you pass is encoded into the URL the browser opens, so the dashboard arrives already filtered to what you asked the terminal about.
bun run cli analyze export.ndjson.gz --segment request --status 500 --status 502
bun run cli analyze export.ndjson --search /query --from 2026-07-22 --top 25
bun run cli analyze export.ndjson --studio false --ip 203.0.113.5 --referer https://example.comA filter value that can't be used fails the command instead of being dropped. Dropping the only
--status or --segment value would remove the filter entirely, widening the report to every row
while it still claimed to be filtered — a silently wrong answer is worse than no answer. --segment
must be request, streaming or all; --studio must be true or false; --status must be an
integer, and a blank one is rejected before the numeric conversion, because Number('') is 0 and
0 is a status that genuinely appears in Sanity exports; --ip and --referer reject a blank value
for the same reason — an empty one would silently compile to a real predicate rather than dropping
out, IN ('') for --ip and LIKE '%' for --referer (which matches every row with a referer at
all, not zero); --from/--to must parse as timestamps. The remaining filters (--endpoint,
--method, --domain, --search) are free text matched against the export, so there is nothing to
validate — a value that matches nothing yields an empty result, not a wrong one.
--search is the one repeatable flag that ANDs instead of ORs: --search cacheMode=noStale --search
tag=next-loader requires both terms on the same request, which is what lets it express a filter a
single substring can't. A lone --search behaves exactly as before.
Filters, --top and --port are all validated up front and exit 1 with the offending value quoted,
as does an export that can't be read:
--segment must be request, streaming, or all, got "stream"
--studio must be true or false, got "yes"
--status must be an integer, got "abc"
--ip must not be blank
--referer must not be blank
--from must be an ISO timestamp, got "garbage"
--top must be a positive integer, got "0"
--port must be a port number, got "70000"
Failed to read "missing.ndjson": IO Error: No files found that match the pattern "missing.ndjson"DuckDB's own message is passed through rather than replaced, so a parse failure keeps whatever SQL context it came with.
The dashboard
Serving is the default. The CLI prints the report, starts the SPA plus a POST /query endpoint on
127.0.0.1, opens your browser, and stays up until Ctrl-C:
Dashboard: http://127.0.0.1:53219/?segment=request
Ctrl-C to stop.The browser gets a Dataset whose query is a fetch. It holds no SQL and no row shaping of its
own, so the thing actually answering is this process — the dashboard and the terminal report cannot
drift apart, because they are the same queries against the same parsed table.
--no-open prints and exits. --json implies it: that flag exists to be piped, and a command that
never exits is not pipeable, so sanity-logs … --json | jq would otherwise hang on a server nobody
asked for.
Why the endpoint is locked down
/query runs arbitrary SQL against an engine that can read and write local files. Five independent
defenses, each covered by a test in serve.test.ts:
- Bound to
127.0.0.1, never0.0.0.0. Hostpinned to the loopback authority. This is the DNS-rebinding defense: a rebound request arrives same-origin, so no CORS check ever runs on it, but theHostheader still carries the attacker's domain.- A per-run token, injected into the served
index.htmland required asx-roboto-token. application/jsonrequired. Together with the token this disqualifies the request from being CORS-simple, forcing a preflight that is never granted — no CORS headers are sent, ever.enable_external_access = falseandlock_configuration = true, set before the port opens. Screening SQL by shape would be theatre, sinceSELECT * FROM read_text('~/.ssh/id_rsa')is an ordinary SELECT. The capability is revoked at the engine instead, and the lock stops the same endpoint switching it back on.
--json
bun run cli analyze export.ndjson --status 500 --json | jq '.kpis'rows number
parseSeconds number
kpis { errorRate, latencyP50, latencyP95, latencyP99, studioShare, totalBytes, totalRequests, windowEnd, windowStart }
distributions { byDomain, byEndpoint, byMethod, byStatus }
latency array of { bucket, count, order }
slowest array of { durationMs, endpoint, method, status, timestamp, url }
streaming { byEndpoint, totalConnections }
findings { headline, totalBytes, totalRequests, verdicts }Every row shape in distributions (byStatus, byMethod, byDomain, byEndpoint) now also
carries a bytes figure, alongside whatever it already grouped by.
--top shapes the tables, not the JSON: slowest always carries the 20 rows the query returns,
and findings.verdicts always carries all 16 checks, passed included.
The root cli script sends turbo's build log to stderr, because turbo prints "Packages in scope…"
to stdout and that lands in the middle of the JSON.
Gzipped exports
bun run cli analyze export.ndjson.gzread_json decompresses from the file extension, so a .gz needs no separate unpack step. A
109 MB .gz (2.08 GB of JSON, 1.29M rows) parses here in about 1.5s, and the dashboard that follows
is fully interactive.
So the download never needs expanding — and expanding it is the step that renames it. On macOS
that is rarely a decision anyone makes: Safari's "Open safe files after downloading" hands the
.gz to Archive Utility as it lands, which is where the 2 in … 2.ndjson comes from. Turn
that setting off, or just point the CLI at the .gz Sanity gave you.
Notes
The report's queries run one after another, never Promise.all. DuckDB stacks a hash/sort buffer
per concurrent query, and on a multi-GB export that is what pushes it over. The dashboard server
serializes the browser's own widget queries through the same pattern once it's up, for the
identical reason.
The parsed table lives in memory for the life of the process, which is why the dashboard has to keep it alive rather than exiting after the report.
See CLAUDE.md for the architecture and docs/glossary.md
for the domain vocabulary.
