CLI reference¶
crowdsim <subcommand> [flags]. Everything the tool does is a subcommand of one bash script,
bin/crowdsim, whose comment header is also its --help.
crowdsim next # where you are, and the one command to run next
crowdsim doctor # what is missing on this machine
crowdsim discover --profile p.json --limit 400 # build a URL pool from the sitemap
crowdsim probe --profile p.json --target edge # reachability + cache headers hop by hop
crowdsim load --profile p.json --target edge --peak 60
crowdsim cache-ab --profile p.json --ttl 10 # two proxy legs, one origin
crowdsim validate p.json # every rule at once, before anything runs
crowdsim history --last 10 # one line per run: does the knee move?
crowdsim compare <run-a> <run-b> # the delta, or a refusal if they differ
crowdsim record session.har # a browser HAR export → a journey file
crowdsim weights access.log --profile p.json # the class mix, counted on your own log
crowdsim init # a first profile, drafted from what was measured
crowdsim report <run-id> # one run as markdown you can paste in a ticket
crowdsim report <run-id> --html # the same run drawn: the ramp, the knee, per class
crowdsim serve # the GUI, on loopback
Asking about one subcommand¶
crowdsim --help is the synopsis of everything. crowdsim <subcommand> --help is that subcommand's own
synopsis, its own flags and one copy-pasteable example — and it exits 0, because asking for help is
not a usage error:
crowdsim load --help # twenty flags, without reading the other twelve subcommands first
crowdsim report --help
crowdsim cache-ab --help
Both come from the same comment header in bin/crowdsim, extracted by structure: the global help stops
at a marker line, and each subcommand's text is the block below it. Two sources would disagree once, and a
help page that contradicts the tool is worse than a long one. tests/cli/help.bats asserts that every flag
the argument parser accepts appears in at least one block, so a flag added and never documented fails the
suite rather than shipping invisible.
That is also why the shell completions can read the header instead of carrying their own copy of the flag list.
latest and previous¶
Anywhere a run id is accepted — report, compare, report --compare — latest and previous resolve
to one, through a single resolver:
crowdsim report latest
crowdsim compare previous latest
crowdsim report latest --compare previous --html
The resolution is always printed, on stderr so it never lands in a redirected report:
A convenience that silently picks a run is how a result gets attributed to the wrong experiment — and
20260901T123654Z and 20260901T123645Z are one glance apart, which is the other half of why retyping
them by hand was the problem.
latest skips nothing. The newest run resolves even when it is a discard (generator_ok: false), and
is then reported as the discard it is. Quietly stepping back to the previous run would hand over a
valid-looking result for a run nobody asked about.
With no runs at all, or previous when there is only one, it exits 2 and names crowdsim history.
Every refusal compare already has still applies to a resolved id: matching pool, matching profile, and a
generator that held the rate.
--version (and -V) answers the question that gets asked while something is going wrong. Inside the image
the answer is baked in at build time, because there is no package.json there to read: without it the CLI
had no answer and the GUI reported null, so the only source was whatever somebody typed into docker run
minutes earlier. docker inspect answers it too, from the OCI version label.
Subcommands¶
doctor¶
Checks prerequisites and prints what is missing. With --profile, also resolves the profile (pools inlined,
referenced files checked) and runs the full validation — the cheapest way to find out a profile is
broken. Always exits 0, even when it found errors: it is a report, not a gate, and a report that exits
non-zero gets wrapped in || true by the first person who scripts it. Use validate when you want a gate.
doctor --bench — measure the generator instead of declaring it¶
load warns you before a run the generator cannot sustain by comparing the bandwidth a peak implies against
safety.generator_mbps — a number typed into a profile by hand, usually once, usually copied to the next
profile. So the check that exists to stop you burning a window on an unmeasurable run rested on a guess.
--bench measures it: a throwaway HTTP server on loopback, k6 against it in a closed model, and the result
in out/bench-<run>.json, which the estimate reads when the profile declares nothing.
▶ measuring what this machine can generate (loopback, 10s, 40 VUs)
nothing is sent to any target: the server below is started here and thrown away.
✅ this generator: 45068 req/s of 45 KB → 2080.0 MB/s (16640 Mbit/s)
⚠️ loopback: this is the CEILING of this machine, not a prediction. Every real path is
narrower — a declared safety.generator_mbps you trust still wins over this number.
Run it on the host that will generate the load. Inside a container on a macOS or Windows host it measures
loopback inside the VM — 16 Gbit/s on the machine this was written on — which describes the VM's own
network and says nothing about the path to a target, the very layer that throttles such a run. The artefact
records where it was taken (in_container, kernel, virtualised), the run warns while measuring, and the
bandwidth estimate refuses to use a virtualised measurement as a ceiling: silence bought with that number
would be silence in the one place the warning matters.
Read the caveat as part of the number. Loopback is the best network this generator will ever see; the path
to a real target is narrower, always. What the measurement is genuinely good for is the req/s ceiling —
the limit that produces dropped iterations and generator_ok: false — and for telling a laptop apart from a
runner without anybody guessing.
Three properties, deliberate:
- A declared
safety.generator_mbpsalways wins. Somebody who knows the uplink is 100 Mbit/s is right, and this measurement is not evidence about their network. When the fallback is used, every line says so. - Plain
doctornever benchmarks. A report that quietly starts generating traffic is not a report. - It stays a warning, never a gate, like the estimate it feeds. A wrong number must not be able to stop a run somebody needs.
It needs k6 (exit 5 without it) and node, which serves the local endpoint: Python's http.server folds at a
few hundred req/s on loopback, so it would have reported the toy server's ceiling while calling it the
generator's.
discover¶
Fetches discover.sitemap from the profile, extracts <loc> entries, strips
discover.strip_prefix_regex (locale prefixes the site would redirect), de-duplicates, truncates to
--limit, and writes out/pool-<run>.json. Point a pool at it with "pages": "@pool-<run>.json".
With --verify it then requests each path and keeps only those answering 2xx, reporting what it dropped
and why:
▶ verifying 400 paths render (sequential, 0.05s apart — this is not a load test)
⚠️ 383 of 400 render — 17 dropped (why, per path: out/pool-<run>.report.txt)
status 404 /news/2019-archive
redirect 301 /es/teams
Use it. A 404 is cheap for the app tier — or is itself rendered — and a 307 measures a redirect: a pool of either yields a flattering capacity number for a load that never reached the renderer. The alternative was "verify them by hand", which for 400 URLs means nobody does.
Verification is sequential with a pause between requests (CROWDSIM_VERIFY_DELAY, default 0.05s): building
a pool must not itself be a load test. It goes through the same allowlist gate as everything else, and the
report records when it was verified — regenerate after every deploy, since static-asset pools contain build
hashes.
If the document has no <loc> entries at all, discover exits 4 and says so, rather than writing an empty
pool that surfaces much later as "every class was dropped for want of a non-empty pool".
The same result is written as data, next to the pool, in out/discover-<run>.json: what the sitemap offered,
what survived the limit, whether verification ran, and every dropped path with its reason and status. It is
what the GUI reads, and it makes verified: false impossible to miss — an unverified pool is a pool nobody
has asked whether it renders.
probe¶
Preflight against one target: status, TTFB, page size, and every cache-relevant response header, saved to
out/probe-<run>.log — and, machine-readably, to out/probe-<run>.json. Run it before every load test.
Exits 4 if the target answers ≥400 — a load test against something that does not serve is not a capacity
measurement.
The JSON is what makes load able to tell you the bandwidth a peak implies (below). Prose in a log is for
whoever reads this run; the number is for the run somebody starts next week.
It also states a verdict for every layer the profile declares in cache_headers, which is the part worth
reading twice:
── the layers this profile declares ──
proxy X-Proxy-Cache: HIT → HIT (matches /HIT|STALE|UPDATING/i)
souin Cache-Status: souin; fwd=miss → MISS (matches /hit/i)
cdn X-Cache: NOT PRESENT — nothing to classify
⚠️ 1 declared header(s) never appeared. That is usually the wrong header NAME in the
profile rather than a cold cache — and a layer that never speaks is reported as unknown,
never as a miss, so it cannot quietly drag a hit ratio to zero.
Three answers, not two: hit, miss, and never spoke. The third is the one that matters, because a header
name that is wrong in the profile looks exactly like a cache that is not working — and reporting it as a miss
would put a confident 0% hit ratio next to a layer the request never crossed. Same rule as the load
generator's own classification (k6/lib/classify.js), so the two cannot disagree.
Only cache-relevant headers are stored in the JSON. A probe against a real site can come back with
Set-Cookie, and a run archive is not the place for somebody's session.
The premise of an authed class¶
If the profile has an authed class, probe sends one request per class, without the token, and says
what came back. A 401 or a 403 is the only thing that proves the class measures an authenticated read:
── the premise of every authed class (one request, sent without the token) ──
✅ authed_api /api/me
the endpoint refused the request without a token (401)
so what this class measures is an authenticated read, not a public one.
Every authed class is pointed at an endpoint that requires the token.
An endpoint that answers 200 without the header is refused, and probe exits 4:
⛔ whoami /api/auth/whoami
this endpoint does not require the token (200 without one)
this class would send an anonymous GET wearing a bearer token, and report the result as an
authenticated read. Point it at a path that answers 401 without a token, or drop the class:
the numbers it produces describe the public path.
That is not a hypothetical. The first authenticated smoke against a real target used /api/auth/whoami,
which returns the same body with or without an Authorization header: the login was genuinely proven — a
real token, no token: 0 over 29 iterations — and the read was an anonymous GET reported as p50 63 ms of
authenticated traffic. The run was green and measured nothing, which is the exact failure this tool exists
to avoid. cs_denied cannot catch it: that counter is for a class being refused under load, and here
the anonymous request succeeds.
The other answers, and why they are not merged into two:
| Without the token | Verdict | probe |
|---|---|---|
| 401 / 403 | verified — the premise holds | continues |
| 2xx | the endpoint does not require the token | exits 4 |
| 404 / 410 | the pool names a path this target does not serve | exits 4 |
| 3xx | unknown: a redirect may be a login wall or a public canonical URL | warns, continues |
| 5xx, no answer | unknown: the check itself did not run | warns, continues |
A redirect is deliberately not counted as a refusal. From here a 302 to a login page and a 302 to a canonical URL that is then served publicly look identical, and picking one would be the confident wrong answer the rest of this is written against.
The half that needs no target is checked earlier, by validate and by load at startup: an authed
class that names no pool, or an empty one, is refused before anything runs. A class with no URLs sends
nothing — and a class that sends nothing is absent from every table in the summary rather than reported
as broken.
Without node the premise cannot be checked (the verdicts live in lib/premise.mjs). probe says so
rather than staying quiet, and does not present the class as usable.
load¶
The load test. Validates the profile, resolves the target, passes both gates, then runs k6 with one scenario
per class. Exits 0 whenever it executed — including when the brake tripped, because finding the knee is
the intended outcome. Writes out/summary-<run>.json, out/load-<run>.log, and appends to
out/history.tsv.
It also refuses to stay quiet about a generator that cannot win. A load run started from a container inside a VM — Docker Desktop on macOS or Windows, or WSL2 — says so before generating anything:
⚠️ THIS GENERATOR IS IN A CONTAINER INSIDE A VM (kernel 6.3.13-linuxkit).
→ run k6 natively on this machine, or put this container on a Linux host near the target.
Measured, repeatedly: the Docker network layer saturates before the target does, the iterations get dropped,
and the summary comes back generator_ok: false after the window is gone. It is a warning, not a gate:
the detection (a container marker plus a linuxkit/WSL kernel) misses runtimes that do not brand their
kernel, and refusing on a signal with false negatives buys nothing. The GUI in a container is unaffected —
it is a page, not a generator.
Before starting it states the bandwidth the requested peak implies, from the newest probe of that target:
ℹ️ bandwidth: 380 req/s × 45 KB ≈ 17.6 MB/s (141 Mbit/s) sustained, from probe 20260805T120000Z
⚠️ THAT IS MORE THAN THE 100 Mbit/s THIS GENERATOR IS DECLARED TO SUSTAIN.
Expect generator_ok: false. Move the generator closer to the target, or lower the peak —
do not lower the SLO.
generator_ok: false is otherwise diagnosed after the window was agreed and the run burned, and most of
those runs were predictable beforehand. Declare safety.generator_mbps to have the comparison made; without
it the estimate is still printed. It is a warning and never a gate: the estimate assumes every request
weighs what that one page weighed, which is wrong in both directions, and a wrong estimate must never stop a
run somebody needs. The one thing it must not do is stay silent.
cache-ab¶
Brings up two nginx legs against the same origin, one as-is and one with your candidate config, so you can
load both with the same pool in the same window and get a hit ratio and offload factor you can defend.
Needs docker (exit 5 without it), so it does not work from inside the container image. See
cache-ab/README.md.
A third leg — normally the narrow subset of the fix you can actually ship this week, measured in the same window as the full change — needs no compose editing:
crowdsim cache-ab --new-leg narrow-fix.conf.template # a copy of the candidate, renamed
crowdsim cache-ab --profile p.json --third narrow-fix.conf.template
It refuses (exit 2) a leg template that does not carry the candidate's warning about ignoring the origin's
Cache-Control — a third leg is a copy, and a copy is where that paragraph goes missing — and a leg still
identifying itself as candidate in X-AB-Leg, because two legs answering with the same name cannot be told
apart in the results. --new-leg satisfies both by construction and never overwrites an existing file.
--run goes the last step: it loads each leg with the same profile at the same peak, one at a time, and then
prints the delta between them.
Sequential on purpose — two generators at once on one host measure the host — so "same window" means the same
session, not the same second, and the output says so. The comparison is crowdsim compare, refusals
included. The legs live on 127.0.0.1, and --run does not grant itself that allowlist: it is checked
before a container starts, because a subcommand that can authorise a host on your behalf turns the gate into
a suggestion.
validate¶
Checks the profile against every rule at once and exits 2 if any of them is an error. Generates nothing.
It reports errors (the profile would fail, or produce a meaningless run) separately from warnings (it will run, but not necessarily mean what the author thinks):
▶ validating my-site.json
❌ classes[0].pool unknown pool "nowhere"
❌ slo.brake_class "gone" is not a class in this profile: nothing would abort the run
⚠️ pools.static pool is empty: every class using it will be dropped from the mix
2 errors · 1 warning — errors must be fixed before a run means anything
Everything at once, and errors first: a validator that stops at the first problem turns one fix into a sequence of round trips.
One implementation, two entry points. The rules live in lib/validate.mjs and the GUI's editor applies
exactly these, so validation cannot drift from what a run requires. load runs them before the safety
gates and refuses on errors; doctor --profile runs them and reports without failing.
Reaching them from bash means node, which the CLI otherwise does not need. Its absence is stated, not
hidden: validate exits 5 saying so, and load prints "full profile validation needs node — only the
structural checks ran" and carries on with what resolve_profile checks by itself (pool references,
missing pool files, empty pools). What it cannot catch that way is exactly the interesting half — a brake
class that does not exist, an allowlist of *, a read timeout below the p95 SLO.
weights¶
crowdsim weights /var/log/nginx/access.log --profile my-site.json
ssh edge 'zcat /var/log/nginx/access.log.*.gz' | crowdsim weights - --profile my-site.json
The class mix, counted on your own access log. This is the one input the tool insists must be measured —
every page here says the weights come from your edge log, and init writes them as a TODO for exactly
that reason — and until now nothing in the tool would read one, so the most important number in a profile
was left to somebody counting lines by hand.
A login or signup class is not counted here, and says so. They POST, this command counts GETs, so
no access log can ever produce a weight for them: they are reported as not countable here instead of as
0%, which are different findings with different fixes. Until 1.20.5 they were treated as plain classes,
and a login declared on a page pool matched every document GET in the log — a window with three page
views and one sign-in produced a mix of 100% login. An authed class is counted: it is a GET, and
whether your log can tell it apart from an anonymous one is yours to declare with log_match.
It does not go and fetch the log. That would mean privileged access to a production edge, which this project deliberately does not want: the log arrives as a file you hand over, or on stdin.
▶ 12 lines · 12 GET requests counted · 11 classified
1 non-GET excluded: this tool sends GETs only
class kind requests share weight
rsc_page rsc 6 54.5% 54.5
html plain 4 36.4% 36.4
static plain 1 9.1% 9.1
unclassified 1 8.3% of the counted requests
What nothing matched — the interesting part, because the mix above describes
91.7% of the traffic and not all of it:
1 /favicon.ico
Decide which class each of those belongs to, then declare it — crowdsim will not guess:
{ "name": "…", "log_match": ["/*"], … }
Paste into the profile (weights are relative; the generator renormalises them):
"classes": [
{ "name": "rsc_page", "weight": 54.5, "kind": "rsc", … },
{ "name": "html", "weight": 36.4, … },
{ "name": "static", "weight": 9.1, … }
]
⚠️ This is your traffic between 2026-09-01T12:01:00 and 2026-09-01T12:22:00, by the log's own
timestamps. A mix measured in a quiet hour does not reproduce a spike: the classes that
grow under load are exactly the ones a quiet window under-weights.
Nothing was written: no profile touched, no artefact in out/. An access log is not this
tool's data to keep.
How a class is recognised. By what the profile declares, in this order — nothing is inferred from the shape of a URL:
kindis a hard filter, not a score. Anrscclass only ever matches a request carrying the navigation parameter (rsc.param, usually_rsc), and aplainclass only ever matches one without it. The same path is two different classes with and without it, which is the whole reason they are two classes.log_match— the path globs the class declares (see Profile reference).path_prefix— already declared, already unambiguous.- the class's own pool — the paths crowdsim would actually request for it.
First match in profile order wins, so a specific class declared before a broad one keeps its traffic.
A class that POSTs cannot be counted from a GET log, and says so. login and signup are writes, and
this command counts GETs only — the same reason a write is excluded from the mix. Those classes are listed
apart, with a dash rather than a zero, and the paste block asks for the rate you measured yourself (logins
per second, from your identity provider or your application logs). Reporting them as 0 would send
somebody looking for a login in a file that cannot contain one; and until 1.20.4 it was worse — a login
class declared before the document class, on the same pool, matched every document GET in the log and
took them from the class that had served them, so a log with three page views and one login produced a mix
of 100% login. An authed class is a GET and stays countable: whether your log can tell an authenticated
read from an anonymous one is what log_match is for.
What it will not do:
- Guess.
/favicon.icois obviously an asset and the command still refuses to file it understatic, because a guessed class is a made-up mix — the thing this command exists to replace. Give the class alog_matchand it is counted. - Hide the gap. Unclassified requests are a share of the counted requests, never folded into a class and never dropped. A mix computed from 40% of a log is a mix of something else, and past 10% unclassified the output says so in as many words.
- Compute a mix from a log it mostly could not read. Over half the lines unparsed is exit 2, with the
failing lines quoted as they were read and a pointer to
--format— not a confident mix built from whatever happened to fit. - Write anything. Not the profile, not an artefact in
out/, not a temp copy. An access log holds URLs, addresses and user agents;out/is a directory people copy from and a repository is one they commit from.
Excluded from the mix, and said so: non-GET requests (this tool sends GETs and nothing else, so a write
in the mix is a weight for load that will never be generated) and non-2xx/3xx (a 404 in the mix is a weight
for requesting URLs that do not exist — the same reasoning record applies to a browser recording).
A log that is not the combined format: describe its columns.
Known fields: request (a "GET /x HTTP/1.1" token), path, method, status, time, and - for a
column to skip. A field this command does not know is exit 2 rather than a column read by guesswork. With no
--format the request is found by shape — the token that reads like a request line — so a proxy that
logs an extra field in front still parses.
| Exit | Means |
|---|---|
| 0 | a mix was printed |
| 2 | usage, an unreadable profile, or a log this command could not parse |
| 4 | the log parsed and nothing in it matched a class: the profile does not describe this traffic |
Feeding it straight into a first profile: crowdsim init --access-log <file> drafts the profile and then
measures the weights through these same rules, in one step. See init.
init¶
crowdsim init --out my-site.json
crowdsim init --out my-site.json --access-log /var/log/nginx/access.log
Drafts a profile from the artefacts already in out/, and says which run each part came from:
✅ drafted my-site.json
from probe 20260820T101500Z: target, page weight 46231 B, 2 declared cache layer(s)
from discover 20260820T102000Z: pool pool-20260820T102000Z.json, 383 of 400 verified to render
It will NOT run yet, by design:
· safety.allow_hosts and safety.safe_peak_rps are empty — fill them in;
· the class weights are a starting point, not your traffic mix — `crowdsim init
--access-log <file>` measures them from your own log instead;
· slo.max_p95_ms and slo.guillotine_ms are TODO, and the second must be your proxy's read
timeout, or the 504s a run produces will be invisible to it.
Then: crowdsim validate my-site.json
Writing the first profile is the highest step in this tool, and most of it has already been measured by the
time somebody sits down to write it: probe knows the page weight and which cache layers answered,
discover --verify has a pool of URLs that render, record has a real fan-out. Those files were sitting in
out/ and nothing assembled them.
What it refuses to do is the point. It never fills in safety.allow_hosts — that would be crowdsim
authorising a host on your behalf — and never fills in safety.safe_peak_rps, which is a decision about how
far somebody's production may be bent. Both are left empty, the file says why, and validate refuses the
draft until a human fills them in:
▶ validating my-site.json
❌ safety.allow_hosts declared and empty is not an allowlist: fill in the hosts this profile may
generate load against, or remove the key and pass CROWDSIM_ALLOW_TARGETS instead
❌ safety.safe_peak_rps the safe ceiling has to be a positive number of req/s: the rate past which a run
needs --i-know-this-breaks-production on the command line
❌ slo.max_p95_ms max_p95_ms has to be a positive number of milliseconds — this one is still the
TODO a drafted profile carries
❌ slo.guillotine_ms guillotine_ms has to be a positive number of milliseconds — this one is still the
TODO a drafted profile carries
❌ name still a TODO from a drafted profile: this is a value only you can decide
❌ pools.assets[0] still a TODO from a drafted profile: this is a value only you can decide
6 errors · 0 warnings — errors must be fixed before a run means anything
Everything else it cannot measure is a TODO rather than a plausible value, for the same reason.
The class weights, measured. Hand over an access log and the draft's placeholder weights are replaced by
counted ones, through exactly the rules weights uses — one command instead of drafting, running
weights, and pasting:
✅ drafted my-site.json
from probe 20260901T100000Z: target, page weight 46231 B, 0 declared cache layer(s)
from discover 20260901T101000Z: pool out/pool-20260901T101000Z.json, 2 of 2 verified to render
▶ measuring the mix on /var/log/nginx/access.log
✅ mix measured: 11 of 12 GET requests classified, from 13 lines
html 36.4
rsc_page 54.5
static 9.1
⚠️ 8.3% unclassified: these weights describe 91.7% of the traffic, not all of it.
See what did not match: crowdsim weights <log> --profile my-site.json
window: 2026-09-01T12:01:00 → 2026-09-01T12:22:00
It will NOT run yet, by design:
· safety.allow_hosts and safety.safe_peak_rps are empty — fill them in;
· slo.max_p95_ms and slo.guillotine_ms are TODO, and the second must be your proxy's read
timeout, or the 504s a run produces will be invisible to it.
Then: crowdsim validate my-site.json
The measurement travels into the profile, not only to the terminal: _classes_comment records how many
requests were classified, what share was not, and the window they came from, and _provenance gains a line
for the log. Each measured class says so in its own comment. What the log never showed keeps its placeholder
weight and gains a TODO: NOT ONCE in the log that was measured — a class is not deleted because one window
did not contain it, which is how a mix loses its long tail.
Nothing from the log reaches out/ on this path either: the draft receives counts, shares and the window,
never a URL. If the log cannot be parsed, the draft is kept with its placeholder weights (it is right;
only the mix is still a guess) and the command exits with the parser's own code.
- With no
probeand nodiscoverartefact it exits 4 and names the two commands to run first, instead of writing a hollow file. - It never overwrites an existing file (exit 2), and refuses to write into the profile directory (exit 2) — that is the one that gets committed, and a profile names real routes on a real host.
- An unverified pool is carried across with the warning attached, in the file.
report¶
crowdsim report 20260820T125256Z # → out/report-<run-id>.md
crowdsim report 20260820T125256Z --compare 20260819T171100Z
crowdsim report 20260820T125256Z --out ticket.md
crowdsim report 20260820T125256Z --html # → out/report-<run-id>.html, with charts
One run as markdown, written for the place the result actually goes: a ticket, a PR, an incident timeline. The caveats travel with the numbers, because the caveats are what does not survive retyping — somebody pastes a p95 into a channel and it becomes a capacity figure by Tuesday.
The order is the order in which a run has to be read: is it valid, what happened, then the numbers.
## Is this run valid?
Yes. The generator held the requested rate (0 dropped iterations of 11 requests), and the target answered.
## What happened
The brake aborted the run: **you found the knee**, which is what this tool is for. It is not a failure of
the run or of the tool.
Stopped by class **rsc_page** — `p(95)<300`, reached 465.
**Past the 1000 ms read timeout:** 0.00% of requests. That share, not the average latency, is the margin you
actually have — past it a real visitor gets a 504.
It leads with the knee — the highest rate the run measured the system surviving and the rate at which it stopped — because that is the sentence the document is being written around. A run that cannot support a knee gets the refusal instead, with what to change: an omitted section would read as no knee found, and then the requested peak gets quoted.
A run with generator_ok: false produces a report that says DISCARD THIS RUN and prints no latency
table at all: there is nothing in it to quote. A layer whose header never appeared is reported as n/a — the
declared header never appeared in any response, never as 0%. --compare delegates to
compare, refusal included: if the two runs are not comparable the report says so instead of
carrying a percentage.
The report describes your infrastructure — it names your hosts. It says so in its own footer, and out/ is
gitignored for the same reason.
--html: the same run, drawn¶
crowdsim report 20260820T125256Z --html # → out/report-<run-id>.html
crowdsim report 20260820T125256Z --html --out incident.html
A ramp is a curve, and a table of eight rows asks the reader to draw it in their head. The GUI has plotted it since 1.17.0 — and only the GUI, so the moment a result left the page it went back to being a table. This is the same run as one self-contained page: the ramp with the SLO and the read timeout on it, the knee as a band between the last clean rate and the first crossed one, p95 per class against the limit each class is actually held to, and the cache per layer.

One file, no dependencies: no script, no font, no stylesheet, nothing fetched. It opens offline, attaches to a
ticket, and prints to PDF (⌘P) with the tables expanded. Needs node — the chart geometry lives in
lib/report-html.mjs, where it is tested; the markdown report needs nothing but python3, and is unchanged.
What the charts refuse to draw. A chart is the most persuasive thing this tool can produce, so each of these is a rule with a test behind it:
- An invalid run gets no latency chart at all.
generator_ok: falsemeans no step measured the rate it claims, and a curve drawn from it looks exactly like a healthy system absorbing load. What such a run does get is the one chart that shows why it is invalid — requested rate against delivered rate. Same for a target that never answered: a p95 of nearly zero is not a fast system. - A knee recorded next to a verdict that voids it is shown as not counting.
crowdsimrefuses a knee on an invalid run, but an older summary can carry both; the verdict wins, and the page says so rather than drawing the band. - A threshold line is drawn only when the run recorded the threshold.
summary.sloexists from 1.19.0; a run archived before that gets a curve with no limit line, and a sentence saying why. A line at a guessed limit moves the knee for the reader. - A threshold that does not fit the scale is named, not silently dropped. A read timeout ten times the p95 would flatten the curve into the bottom of the picture, so it is left off — and said to be left off, because a line that is simply absent reads as a limit nothing came near.
- A partial step is drawn as a partial step: hollow marker, dashed segment, and a note. The brake fires while latency is climbing, so the step it fired in is a fraction of one, biased towards its worst part.
unknownis not a miss and not 0%. A cache layer whose header never appeared has no hit ratio at all; a zero bar would read as a cache that answered and missed every time — usually a wrong header name in the profile, which is a different bug with a different fix.
Every chart carries the same numbers as a table underneath it (open in print, collapsed on screen) and describes itself in words for a screen reader — a chart nobody can quote from is a chart somebody retypes by eye.
--html reports one run. --html --compare is a usage error: a delta between two runs is
compare, and drawing two runs on one pair of axes without its refusals would put a confident
picture behind two different experiments. The markdown report embeds the comparison, refusals included.
history¶
out/history.tsv as a table: one line per run. What it is for is watching whether the knee moves after a
change — not for reading a single run, which is what the summary is for.
The default view is eight columns, not fourteen, and always keeps the run id and the knee. A run whose generator did not hold the rate is marked in the margin:
run_id profile shape peak rps p95 failed knee_clean knee_crossed
⛔ 20260901T121500Z site-a mix 80 20.0 300 0.002
20260901T111500Z site-b mix 60 55.1 5100 0.06 40 50
20260901T101500Z site-a mix 40 39.8 210 0.001 30 40
A discard carried in a column at the far right is a discard nobody reads, and a discard that reads like a result is worse than no row at all.
| Flag | What it does |
|---|---|
--last <n> |
the n newest runs |
--target <host> |
only runs whose base_url host contains this |
--profile <name> |
only runs of this profile |
--cols a,b,c |
exactly these columns, named as they are in history.tsv. run_id is never dropped |
--json |
the same records the GUI's history endpoint returns |
A filtered or truncated view says so, on its last line, with the total — showing 2 of 3 runs · --last
2. A subset of runs that looks like all of them is the same class of mistake as a p95 quoted for a rate
that never happened.
The parser stays header-keyed, so rows written before a column existed keep working and a missing cell
prints empty rather than 0. The knee columns (knee_clean, knee_crossed) are empty rather than 0
whenever a run could not support one — see the knee. A
knee of 0 req/s is a claim about the system; this run predates the knee is not the same statement.
--json emits the same record shape gui/server/lib/history.js produces, and
tests/gui/history-shape.test.js runs both against one fixture and compares them field by field. Two
shapes would mean the page and the terminal disagreeing about what a run was, while somebody is deciding
something.
Aligned in python3, not with column(1): that comes from util-linux, busybox does not have it, and this
subcommand used to exit 127 inside the published image.
next¶
Where you are. What out/ already holds from probe, discover and a completed run; which profiles
exist and which of them are still drafts; and the one command to run next, as text to copy.
▶ where you are
Measured so far (/work/out)
· probe nothing yet — reachability, page weight, cache layers
· discover nothing yet — a pool of URLs that actually render
· summary nothing yet — a completed load run
Profiles (/work/profiles)
⚠️ profiles/site.json draft: safety.allow_hosts, safety.safe_peak_rps
Next
profiles/site.json is a draft, and the two things it is missing are not things this tool
gets to decide:
safety.allow_hosts which hosts this tool may generate load against. There is no
default anywhere — without it every run exits 3.
safety.safe_peak_rps the rate above which a run needs
--i-know-this-breaks-production on the command line, every time.
Edit profiles/site.json, then:
crowdsim validate profiles/site.json
This command generated no traffic, wrote nothing and changed no profile.
Getting from a clean checkout to a run is doctor → discover → probe → init → editing the TODOs
and the two deliberately empty safety keys → validate → load. Every one of those is documented and
every one works; what was missing was any answer to where am I. doctor reports on the machine and
stops, init writes a draft and stops, and the only thing that knew a profile was still a draft was
validate — which you had to run to find out.
It fills nothing in. safety.allow_hosts and safety.safe_peak_rps are the two gates, and the next
step this command names for them is you decide these — never a suggested value. There is no wizard, no
prompt and no -y: a guided setup is exactly where an interactive confirmation would get added by
accident, and this tool has none on purpose (see Safety).
It generates no traffic, writes nothing, and never edits a profile — which is what makes it safe to run blind, on a machine you have just walked up to.
serve¶
Starts the GUI (needs node and a built UI). Binds 127.0.0.1:8787 by default and refuses any other bind
address without CROWDSIM_GUI_TOKEN. See GUI.
Flags¶
Only load uses most of them; unknown flags are an error (exit 2) rather than being ignored.
| Flag | Default | Applies to | What it does |
|---|---|---|---|
--profile <file> |
— | all but doctor/history/serve |
The profile. Required. |
--target <name> |
targets.default |
load, probe, discover, cache-ab | A named target from the profile. |
--base-url <url> |
— | load | Bypasses target resolution entirely. Still subject to the allowlist. |
--shape mix\|journey |
mix |
load | mix = one scenario per class; journey = visitor sessions from a recorded journey file. |
--peak <n> |
60 |
load, cache-ab | Total user requests/s at peak, split across classes by weight. |
--start <n> |
15 |
load | Rate of the first step. |
--steps <n> |
4 |
load | Number of linear steps from start to peak. |
--step-dur <dur> |
60s |
load | Duration of each step. |
--hold <dur> |
120s |
load | Time held at peak. 0s means climb and leave. |
--rsc-mode repeat\|random |
repeat |
load | repeat replays the few distinct navigation URLs a real build produces; random measures the opposite hypothesis (a genuine cache-buster). |
--max-p95 <ms> |
slo.max_p95_ms |
load | Brake: abort when the brake class's p95 crosses this. |
--max-5xx <ratio> |
slo.max_failed_rate |
load | Brake: abort when the failed rate crosses this. |
--abort-delay <dur> |
30s |
load | Grace period before the brake is evaluated, so a cold start does not abort the run. |
--safe-peak <n> |
safety.safe_peak_rps |
load | The ceiling for this run. Can only make the gate stricter in practice — going above still needs the override. |
--i-know-this-breaks-production |
off | load | The only way past the safe peak. Command line only, every time. |
--warmup <dur> |
off | load | A separate, unmeasured run before the measured one: fills the caches, opens the pools, JITs the app. Its numbers are written to warmup-<run>.json and are not mixed into the result. |
--warmup-peak <n> |
--start |
load | Rate for the warm-up. Subject to the same safe-peak gate as anything else. |
--touch-and-go |
off | load | Preset: --steps 3 --step-dur 20s --hold 0s --abort-delay 10s. The cheapest ramp that still produces errors — not a way to make a test harmless. |
--skip-classes <a,b> |
target's skip_classes |
load | Classes to leave out (routes a given tier does not serve). |
--insecure |
target's insecure |
load, probe | Skip TLS verification, for a node addressed by IP. |
--slack |
off | load | Post a recap to CROWDSIM_SLACK_WEBHOOK. |
--dry-run |
off | load | Print the exact k6 invocation and stop. Sends nothing. |
--limit <n> |
400 |
discover | Maximum URLs in the pool. |
--verify |
off | discover | Request each discovered path and keep only those answering 2xx. Sequential, paced by CROWDSIM_VERIFY_DELAY. |
--ttl <s> |
10 |
cache-ab | Cache TTL for the candidate leg. |
--port <n> |
8787 |
serve | GUI port. |
--bind <addr> |
127.0.0.1 |
serve | GUI bind address. Anything but loopback needs a token. |
--format <fields> |
combined | weights | Column names of a log that is not the combined format: request, path, method, status, time, -. |
--top <n> |
10 |
weights | How many unclassified paths to show. |
--access-log <file> |
— | init | Measure the class weights from this log instead of drafting them as a TODO. |
--json |
off | compare, weights | Machine-readable output. For weights it carries counts, shares and the window — never a path from the log. |
--html |
off | report | The run as a self-contained page with charts instead of markdown. Needs node. |
--out <file> |
— | record, report, init | Where to write the journey file, the report, or the drafted profile. |
-h, --help |
— | all | The usage header. |
-V, --version |
— | — | The version, including inside the image where nothing else can say. |
Environment¶
| Variable | Default | What it does |
|---|---|---|
CROWDSIM_ALLOW_TARGETS |
none | Comma-separated host globs the tool may hit. Required unless the profile declares safety.allow_hosts. No default, by design. |
CROWDSIM_OUT |
./out |
Where summaries, logs, resolved profiles and history.tsv go. |
CROWDSIM_PROFILES |
./profiles |
The directory the GUI reads and writes. |
CROWDSIM_SLACK_WEBHOOK |
unset | Target for --slack. A secret: never hardcode it. |
CROWDSIM_ROOT |
parent of the script | Where k6/, gui/ and cache-ab/ live. Set to /crowdsim in the image. |
CROWDSIM_K6_SCRIPT |
$CROWDSIM_ROOT/k6/live-event.js |
The generator script. |
CROWDSIM_GUI_PORT / _BIND / _TOKEN |
8787 / 127.0.0.1 / unset |
See GUI. |
CROWDSIM_BIN |
see GUI §the driver | Which driver the GUI spawns. Set explicitly in the image. |
CROWDSIM_VERIFY_DELAY |
0.05 |
Seconds between requests during discover --verify. Building a pool must not be a load test. |
Exit codes¶
They are an API: the Nomad job, CI and the GUI all branch on them.
| Code | Meaning | Typical cause |
|---|---|---|
0 |
Executed | Also when the brake tripped — that is an outcome, not an error |
2 |
Usage | Unknown flag or subcommand, missing/unparseable profile, unknown target, --shape journey without journey.file |
3 |
A safety gate refused it | No allowlist, host not allowlisted, peak above the ceiling without the override, GUI asked to bind off-loopback without a token |
4 |
Nothing usable came out of it | probe got ≥400 or no answer, or found an authed class whose endpoint does not require the token (1.24.0) — a class that would measure the public path; record found no page in the HAR; weights classified nothing in the log; init found no artefacts to assemble — and, since 1.20.4, a load whose generator produced no summary: a run that never happened is not a success, and until then it warned on a terminal and exited 0 |
5 |
Missing or broken prerequisite | k6 absent, docker absent for cache-ab, node absent for serve, validate, record or weights — and, since 1.20.2, a profile validator that crashes instead of reaching a verdict: that is the installation, not the profile, and it used to be reported as exit 2 |
compare¶
The delta between two runs from out/: overall p50/p95/p99, failed rate, the share past the read timeout,
504s, the cache hit ratio per layer, and the same per class. An improvement and a regression are marked
differently, and the footer says what the numbers are worth:
A 20260805T090000Z profile live-event https://www.example.test shape mix peak 60
B 20260805T093000Z profile live-event https://www.example.test shape mix peak 60
── overall ─────────────────────────────────────────────────────────────────────
A B change
p95 200 ms 140 ms -60 ms (-30%) ✅
failed rate 0.00% 0.00% +0.00 pp =
── cache hit ratio per layer ───────────────────────────────────────────────────
proxy 61.00% 94.00% +33.00 pp (+54%) ✅
cdn n/a n/a header never appeared in either run
The value is in what it refuses (exit 2), because a comparison between two runs that were not the same experiment is a confident number with nothing behind it:
| It refuses when | Because |
|---|---|
either run has generator_ok: false |
that run has no numbers at all — the generator was the bottleneck |
| either run never reached its target | connectivity, not capacity |
| the URL pools differ | two different experiments; a colder pool is a harder test. Compared from the archived profile-<run>.json, which is why it is archived |
| a pool exists in only one run | same reason |
| the shapes differ | a journey and a mix are not the same load |
A different target or a different peak is a legitimate question — what does the CDN add, where is the knee — so those are allowed and stated: the report says this is a comparison between two targets, not a before/after of one.
--json prints the same thing as data — the same verdicts, the same refusals, the same exit code — which is
how the GUI shows a comparison without owning a second copy of these rules:
record¶
crowdsim record session.har # → out/journey-<run>.json
crowdsim record session.har --out ~/private/journey.json --force
Turns a browser HAR export into the journey file --shape journey needs. In DevTools: Network → Preserve
log on → reload the page → click around → the ⬇ Export HAR button. Then:
✅ 2 pages · 1 navigation requests · 4 assets
origin https://www.example.test → out/journey-20260805T174432Z.json
from 12 recorded requests
dropped 2 third-party (fonts.gstatic.com, www.google-analytics.com) — not your capacity problem,
and not yours to generate load against
dropped 1 that did not answer 2xx/3xx
dropped 1 non-GET (this tool does not send writes)
stripped per-request query params: _, _rsc
Four judgements it makes, all of them ways to end up measuring something other than your site:
- Third-party hosts are dropped. Analytics and fonts are not your capacity problem, and generating them would aim load at somebody else's infrastructure — from a tool whose premise is that you only hit hosts you explicitly allowed.
- Per-request cache-busters are stripped; per-build ones are kept. Measured, not guessed from a list of names: if a parameter's value varies between requests to the same path it is noise, and keeping it turns the recording into a pool of unique cold URLs — the pool that makes any cache look useless. A constant value is a build hash, part of the URL the cache sees, and dropping it would measure a URL that does not exist.
- The navigation parameter (
_rsc) is stripped entirely, because the generator adds it back itself, and whether it repeats or is randomised is the experiment (rsc.mode). - Failures and non-GET requests are not recorded. A 404 in a journey is a load test of your error page, and this tool does not send writes at a production system.
The origin travels inside the file: a journey recorded against staging tells you nothing about production's
fan-out. record refuses to write into the profile directory — a journey names real routes, the same
category as a URL pool — and refuses to overwrite an existing file without --force. Needs node; the rules
live in lib/har.mjs with unit tests. Exit 4 when nothing usable was recorded, with what to record instead.
Re-record after a redesign or a deploy that changes the fan-out: a journey is a snapshot of what one build made the browser fetch.
Output files¶
out/
summary-<run_id>.json the result — see Reading results
load-<run_id>.log the full run log
probe-<run_id>.log the preflight
probe-<run_id>.json the same as data: page weight + a verdict per declared cache layer
pool-<run_id>.json what discover found
pool-<run_id>.report.txt what --verify dropped, and why
discover-<run_id>.json the same as data: offered, kept, dropped, and whether it was verified
journey-<run_id>.json what record extracted from a HAR (data about your site: keep it private)
warmup-<run_id>.json the warm-up's own summary, kept apart so it can never be read as the result
warmup-<run_id>.log the warm-up's log
report-<run_id>.md what `report` wrote: the run with its caveats attached
profile-draft.json what `init` drafted, if you did not pass --out
profile-<run_id>.json the profile as resolved for that run (pools inlined)
history.tsv one appended line per run
bench-<run_id>.json what doctor --bench measured this machine doing, and where it was measured
(loopback: a ceiling — and not one at all if taken inside a VM)
gui-run.json written by `serve` only: the run in flight, so a restart can find it
<run_id> is a UTC timestamp, 20260805T093710Z. Every subcommand that writes files announces its run id in
its output, which is how anything reading afterwards — you, or the GUI — finds them. out/ is gitignored: it
names your hosts.
See also¶
- Running a test — how these commands fit together
- Profile reference — what
--target, the classes and the SLO come from - Reading results — the summary, field by field