npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sanity-logs

v0.8.0

Published

Analyze Sanity request-log exports locally with DuckDB and a browser dashboard. Nothing is uploaded.

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.gz

Or install it, if you run it often enough to care about the download:

npm i -g sanity-logs

Needs 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.ndjson

analyze 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.com

A 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:

  1. Bound to 127.0.0.1, never 0.0.0.0.
  2. Host pinned 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 the Host header still carries the attacker's domain.
  3. A per-run token, injected into the served index.html and required as x-roboto-token.
  4. application/json required. 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.
  5. enable_external_access = false and lock_configuration = true, set before the port opens. Screening SQL by shape would be theatre, since SELECT * 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.gz

read_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.