Terminal browser for spark profiler reports.
Point it at a spark.lucko.me/<code> URL, a bare code, or a local .bin/.json file. Get an interactive Ink TUI when stdout is a TTY, batch text when it isn't, and JSON when you ask for it.
$ sparkcli https://spark.lucko.me/AbCdEfGhIj --no-tui --top 10
─── spark report ───────────────────────────────────────
platform : Paper 1.21-DEV-abc1234 MC 1.21
sampler : ASYNC/EXECUTION interval=4000μs
source : https://spark-usercontent.lucko.me/AbCdEfGhIj
time : 2026-09-13T04:09:14.187Z → 2026-09-13T04:19:14.187Z
ticks : 12000 dur 600.0s players 50
TPS : 20.00 / 20.00 / 20.00 MSPT med 12.4 p95 28.1 max 110.0
CPU : process 87.8% / 70.3% system 72.4% / 72.7% (1m/15m) cores 2
memory : 87.3 GiB / 123 GiB (71.2%) uptime 2h 7m
AMD Ryzen 9 7950X3D 16-Core Processor
Windows (2 × ~1m — CPU process/system · TPS · MSPT med · players):
#1 60.1s cpu 86.2% / 73.3% tps 20.00 mspt 0.49 players 10
#2 37.9s cpu 87.4% / 72.5% tps 19.99 mspt 0.46 players 9
Top 10 self-time hot spots in "Server thread":
self% incl% frame
62.1% 62.1% libc.so.6.__pthread_cond_timedwait
4.20% 4.81% ServerLevel.tickChunk
2.95% 3.40% CollisionUtil.getCollisionsForBlocksOrWorldBorder
...
(short code, platform name, and counts above are illustrative — every spark report renders the same shape with your own data.)
On a regionised server a thread is not a unit of work: one region tick thread ticks many regions over the course of a profile. Stock spark groups samples by thread pool, which merges the whole region pool into a single node - one average across every region, with no way to see which region was slow and no per-thread split either.
Our spark fork tags every node with the region it was sampled in and the thread it ran on, at the finest granularity, so one profile can be read three ways:
sparkcli ./profile.sparkprofile --regions # which region is eating the server
sparkcli ./profile.sparkprofile --by-thread # is the pool balanced, or is one thread flat?
sparkcli ./profile.sparkprofile --region 42 # one region's hot spots, merged across threads
sparkcli ./profile.sparkprofile --region 42 --tree # ... as a call tree--- regions (3), ranked by busy time ---------------------
attribution : 602 of 39,976 samples placed in a region (39,372 had no region - idle or
non-tick work, 2 ambiguous)
busy% busy / total
62.2% 1.4s / 1.5s Region #1 (world @ -208,128 - 400 chunks, 94 entities)
36.6% 0.8s / 0.9s Region #2 (world @ 5792,6128 - 400 chunks, 28 entities)
1.2% 0.0s / 0.1s Global tick
Attribution is statistical, not exact - the stack trace and the region are not read at the same
instant - so the server counts the samples it could not place confidently and --regions prints
that count. Treat the ambiguous figure as the error bar on the split.
Reports from an unmodified spark have no region data; these flags then say so and the normal thread views still work.
Requires Node ≥ 18 (and npm, which ships with it).
npx -y github:ruleyourowngame/sparkcli https://spark.lucko.me/AbCdEfGhIjnpx downloads to its cache, runs once, and stays out of your PATH. Use this if you just want to try it.
npm install -g github:ruleyourowngame/sparkcli
sparkcli https://spark.lucko.me/AbCdEfGhIjPuts a sparkcli binary on your PATH (typically ~/.npm-global/bin/ or /usr/local/bin/, depending on your npm prefix).
Update later with the same command — npm pulls latest main. Uninstall with:
npm uninstall -g sparkcligit clone https://github.com/ruleyourowngame/sparkcli
cd sparkcli
npm install
npm link # exposes `sparkcli` globally from your checkoutOr run directly without linking:
npm start -- https://spark.lucko.me/AbCdEfGhIjsparkcli <spark-url | code | file> [flags]
| flag | what |
|---|---|
--tui |
force interactive TUI (default when stdout is a terminal) |
--no-tui |
force batch text output |
--json |
JSON output (implies --no-tui) — pipe through jq |
--top N |
rows per hot-spot section (default 25) |
--thread NAME |
target thread (substring match; default: Server thread) |
--all-threads |
rank ALL threads by busy (non-idle) time + cross-thread busy-frame rollup. Park/wait/epoll idle sinks are excluded, so 50 sleeping pool workers stop outranking the one thread doing real work. Alias: --threads |
--tree |
batch top-down call tree of the target thread |
--focus SUBSTR |
drill into frames matching SUBSTR: caller chains to them + the merged tree below them |
--depth N |
max depth for --tree / --focus subtree (default 8) |
--audit PATH |
map top frames to source files inside a repo |
--min-pct N |
audit/tree/focus/all-threads: hide rows below N% (default 0.1) |
--flags PATH |
scan a repo for -Dasp.* runtime toggles |
--color / --no-color |
force/disable ANSI color |
# Interactive TUI (auto-detected on a terminal)
sparkcli https://spark.lucko.me/AbCdEfGhIj
# Just dump a summary + top 30 frames as text
sparkcli AbCdEfGhIj --no-tui --top 30
# Machine-readable JSON for scripting
sparkcli ./report.bin --json | jq '.frames[] | select(.selfPct > 1.0)'
# Which threads actually burn CPU (idle park time excluded)?
sparkcli AbCdEfGhIj --all-threads --top 30
# Who calls this hot method, and what runs beneath it?
sparkcli AbCdEfGhIj --thread "Netty Epoll" --focus "Connection\$2.write" --depth 20
# Batch call tree of a world ticker thread
sparkcli AbCdEfGhIj --thread "universe-world-ticker" --tree --depth 10 --min-pct 1
# Audit: map hot frames to source files in a checked-out Paper fork
sparkcli https://spark.lucko.me/AbCdEfGhIj --audit ~/code/Paper --top 20
# Inventory all -Dasp.* JVM flags declared in a fork
sparkcli --flags ~/code/Paper↑↓/jk— movePgUpPgDn— pageEnter— open thread / expand tree nodes— toggle self↔inclusive sortt— toggle hot-spots ↔ tree viewe— expand 3 levels (tree)x— collapse all (tree)Esc/q— back / quitCtrl-C— exit
- Fetches from
spark-usercontent.lucko.mewith the rightAccept: application/x-spark-samplerheader. Spark's response is gzip; Node's built-infetchdecodes it. - Decodes the protobuf payload (
SamplerData) using the official spark.protoloaded at runtime via protobufjs. - Walks the flattened tree — spark serializes each thread's call tree post-order DFS into a single
children[]array;children_refs[]are indices into that array. sparkcli rebuilds the tree, computes per-frame self / inclusive time, and ranks. - Flags
--only-ticks-overprofiles. When spark's aggregator isTICKEDwith a threshold, only the slow ticks were sampled, so every percentage in the report is a share of those ticks — not of the average tick. The header addsfilter : only ticks over 25 ms — 915 of 67731 ticks kept (1.4%)and--jsoncarriestickFilter: {thresholdMs, includedTicks, totalTicks}(nullfor plain profiles).--jsonalso exposesstartTime/endTimeas ISO-8601 strings. - Surfaces CPU & system stats — process/system CPU usage (1m/15m), core count (cgroup-capped, so a 2-core cap on a 16-core host is visible), CPU model, memory, uptime, plus the per-window CPU·TPS·MSPT time series from
time_window_statistics. A report with zero thread samples (a statistics-only snapshot) no longer errors — it just shows the CPU/system block. - Audit mode walks the top-N inclusive frames and locates each one's source file inside a checked-out repo (looks under
paper-server,aspaper-server,core,pluginsource roots). Useful for "what file do I need to read to optimize this hot spot?"
The web viewer is great for one report. sparkcli is for:
- diffing two profiles via
--json - piping into other tools (grep, jq, awk)
- correlating hot frames to a specific source tree
- running headless in CI / cron / a tmux pane
- not having to wait for the React viewer to load on a thin link
- Heap dumps and health reports aren't parsed yet — only sampler reports (the common case). Adding them is a few lines of protobuf decoding; PRs welcome.
- Method-level dedup can over-count native frames that recur across many call paths. For Java frames this is rare; for
libc.so.6.*pthread*style symbols the inclusive sum can show > 100%. The fix is to dedup by stack identity instead of method, which sparkcli doesn't do yet. - No live/streaming mode — sparkcli reads a finalized report, not a live profile.
npm install
npm run typecheck
npm start -- /path/to/report.binSource layout:
src/
├── cli.tsx # entry: arg parsing + TUI vs batch dispatch
├── fetch.ts # URL / code / file → bytes
├── parse.ts # protobuf bytes → typed Report
├── analyze.ts # tree walking, self / inclusive aggregation
├── report.ts # batch text + JSON renderer
├── audit.ts # frame → source-file locator
├── flags.ts # repo scanner for -Dasp.* toggles
└── proto/ # spark's .proto files (copied from upstream)
MIT — see LICENSE.
sparkcli is an unofficial client. spark itself is © lucko.