Repository navigation
Releases: HiWay-Media/crowdsim
Release list
crowdsim 1.41.0
Four feature milestones shipped together: server-side metrics collection, GUI archive filtering and export, CLI batch mode for multi-test campaigns, and trend analysis with SLO tracking. The infrastructure now answers "why did capacity fail" (server metrics), not just "what did we see" (client-side symptoms), and makes it practical to run sequences of tests and compare their results.
Added
- Server-side metrics collection (#95):
k6/lib/server-metrics.jsparses Prometheus and custom metric formats. Handed to a run via--server-metrics <file> --server-metrics-label <name>, correlated with run steps and written toout/server-side-<run_id>.json. Enables root-cause diagnosis: differentiates CPU throttling from latency under load. - GUI archive filtering and export (#96): HistoryPanel now filters runs by status (clean/knee/invalid) and peak rate range. Export button produces valid CSV (RFC 4180) and JSON with metadata. Closes the loop between individual run analysis and campaign review.
- CLI batch mode (#97):
crowdsim batch <config.json>runs multiple load tests sequentially from a JSON config file. Each job specifies profile, target, peak rate. Exit 0 on success, 1 on failure. Enables CI/CD automation and multi-stage test campaigns. - Trend analysis and SLO tracking (#98):
lib/trend.mjscalculates linear regression across runs (improving/degrading performance), checks SLO compliance (pass/fail against declared thresholds), and forecasts next run's p95 latency. Foundation for multi-run trend reports.
Changed
- Branch protection: removed PR review requirement (CI checks remain mandatory). Procedural gates can now adapt when explicitly authorized; critical gates (allowlist, safe-peak, exit code contract) remain immutable.
Fixed
[Unreleased]
Added
- A local MCP server for VS Code/Copilot, wired to the same
crowdsimCLI used by the project itself.
The workspace config in.vscode/mcp.jsonstartsnode ./scripts/crowdsim-mcp.jsand exposes the
help, validate, probe, load and history tools without re-implementing the safety gates.
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.41.0
Install and usage: docs/docker.md
crowdsim 1.40.2
Three defects in probe, in one neighbourhood, found by asking why the ci workflow went red on a tree
where make test was green (#94).
Fixed
-
A preflight against a target that does not answer cost 2 minutes 7 seconds. 1.33.0 made
probe
sample five URLs per pool instead of checking one — paced and bounded in count, and nothing bounded
the time. Each sample used the preflight's own curl options:--max-time 10 --retry 1 --retry-connrefused. The CLI suite makes thirteen probe calls, so a 90-second job became a 46-minute
one and the runner killed it. It never showed locally, because a resolver that answers NXDOMAIN
instantly makes every one of those requests free.Now: nothing is sampled when the target did not answer the first request — that request already
settled reachability, and asking about every pool sends thirty more to learn the same thing. A sample
request is bounded byCROWDSIM_SAMPLE_MAX_TIME(default 3s) and never retried: it answers does
this path exist, and a path that did not answer inside the budget has answered that. The first request
keeps the longer timeout and the retry, because reachability is the one thing worth waiting for.
Measured on the same blackholed address: 2m07s → 21s, and the 21 seconds are that first request. -
A target that never answered exited 0.
probedocuments exit 4 if the target answers ≥400 and the
exit-code contract says4 = target not reachable. A failed curl leaves the status as000, and
"000" -ge 400is false, so an address nothing listens on reported a preflight that passed — to a
person, and to a scheduler reading the exit code. -
The documented exit 4 was dead code. Everything that reads the status runs inside
{ … } | tee "$log", which is a subshell, so the variable the parent tested was always empty. A 404 on
the first pool entry printed target does not answer 2xx/3xx and exited 0 — true for as long as the
log has been teed. The only exit-4 paths that worked were the marker files added later for the pool and
premise checks, and the status now crosses the same boundary the same way.The two shapes are told apart in words, because they send you to different places: the target never
answered is connectivity, and it answered 404 on the first pool entry is a pool or a route.
Added
- Tests asserting each exit code against a real socket rather than trusting the variable: a refused port,
and a first pool entry that answers 404.
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.40.2
Install and usage: docs/docker.md
crowdsim 1.40.1
Fixed
-
The docs index listed one drawn page and the tool draws three
(#93).history --htmlandcompare a b --html
shipped in 1.38.0, documented incli.mdand listed in the README, and were absent from
docs/index.md— which is the entry point, and where this repository's own rule says a page has to
appear or it does not exist.§Start here now answers the three questions those commands exist for, one row each: hand one run
over, ask whether the knee moves over time, ask what a change actually bought. With a sentence
tying them together, because the useful thing is knowing there are three and which is which. -
make check-docscovers it now, the way it already covered documented flags:check-doc-commands.sh
asksbin/crowdsimwhich subcommands declare--htmland fails when one of them is missing from the
index. A checklist nobody checks is how this happened, so it is not a checklist any more. Verified by
deleting a row and watching it name the missing page.
Changed
- The index rows spell the commands out (
crowdsim history --html) rather than naming a flag, so they can
be copied.
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.40.1
Install and usage: docs/docker.md
crowdsim 1.40.0
Two places let a browser name a file on the server's filesystem, and they were checked to different
standards (#91).
gui/server/lib/profiles.js has resolved and contained a browser-supplied name since the beginning:
realpathSync the base, resolve the name against it, refuse if it landed elsewhere. The
--server-metrics field, added later, matched a regex instead. The regex refuses an absolute path and any
.. segment — the traversal that matters most — and cannot see a symlink: a name with no .. in it
that resolves outside anyway. The driver then read whatever it pointed at and rendered numbers out of it
into a page.
Nothing here was exploitable by a stranger: the GUI binds loopback and demands a token for anything else,
and only numbers survive the parse. It was the weaker of two checks guarding the same kind of thing, in
the newer code, which is the direction this repository is usually careful about.
Changed
- The series path is resolved and contained, the same treatment as a profile name:
realpaththe
allowed directory,realpaththe candidate, refuse anything that does not land inside. Nesting is
allowed — a series directory is naturally organised by date — because containment is what matters, not
depth. The resolved path is what reaches the driver, so it reads the file that was checked rather
than a name re-resolved against wherever it happens to run. CROWDSIM_SERIES_DIRsays where series may be read from, and has no default. Unset means the page
reads none. Falling back to the server's working directory is what made this weak in the first place, so
the fallback is gone rather than narrowed. In the container: mount it read-only and point the variable
at it.- The refusals are kept apart because they send you to different places: not a relative path, the
directory is not configured, the file is not there, and it resolves outside the series directory.
A missing label is still reported as the label — checked first, because it is the cheaper mistake
and the simpler fix, and reporting the path would send somebody to look at their filesystem.
Added
- Tests over the case the pattern could not see: a symlink inside the allowed directory pointing outside
it, alongside the absolute path, the..and a legitimate nested path.
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.40.0
Install and usage: docs/docker.md
crowdsim 1.39.0
The page could hand over one run drawn, and neither the trend nor the delta
(#90). history --html and compare a b --html
shipped in 1.38.0; gui/server/lib/app.js built exactly one drawn-page argv, ['report', runId, '--html']. So the GUI plotted the archive, knew which runs were comparable, and could hand over neither
of the two pages that say what the archive means.
That was the fourth recurrence of one shape: #53 was two flags the CLI had and the page did not, #78 was
six summary blocks, #79 was six flags. Every one of them was the page tracking the CLI by hand, and the
two that stopped recurring are the two that got a test.
Added
- The archive hands over the trend:
GET /api/history/trend, and a Trend over time (drawn) button
above the run list. The filtershistoryitself accepts (last,target,profile) are forwarded, so
the page and the table cannot become two answers to one question. - A comparison hands over the delta:
GET /api/compare/page?a=&b=, and a Delta, drawn button. When
comparerefuses the pair the endpoint answers 422 withcompare's own text rather than drawing two
different experiments on one pair of axes. - Both are spawned, not re-rendered — the CLI draws the page and the server sends the bytes, exactly
as the run report already worked. A renderer here would be a second opinion about what a run means, and
the first time the two disagreed the wrong one would be on screen while somebody decided something. - Both open in a new tab rather than downloading. The run report is a file because it goes into a
ticket; does the knee move is a question somebody asks on screen while deciding. - Neither is offered when there is nothing to draw, and the page says why rather than hiding a control
(gui/ui/src/lib/drawn.js): one run is not a trend — through a single point it is a straight line,
which is the knee's own refusal from one completed step — and a comparisoncomparerefused gets no
drawing. - A fourth drawn page cannot appear unnoticed.
gui/server/lib/drawn-pages.jsdeclares the set
exhaustively, with a reason for anything deliberately not offered, and
tests/gui/drawn-pages.test.jsasksbin/crowdsimwhich subcommands declare--html. A page in
neither table fails the suite; so does a declared route the server does not serve, and a declared page
the CLI no longer draws.
Changed
- The two argv builders live in
gui/server/lib/args.jswith the others, because the filters arrive from
a URL and end up in an argv:lastis an integer in range,targetandprofileare matched against a
charset and refused if they start with a dash — a value that starts with one becomes a flag rather than
an argument.latestandpreviousare refused for the delta: the page is showing the archive and
already knows which two runs it means, so a selector there could let the page and the file it hands over
name different runs. tests/cli/flags.batsattributes each flag inargs.jsto the subcommand whose builder emits it.
It used to assume they all belonged toload, with--limithand-excepted as discover's — a claim
about the code's shape that held whileargs.jsbuilt one argv and broke the moment it built five. It
now reads the subcommand each builder starts its array with, and the hand-coded exception is gone.
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.39.0
Install and usage: docs/docker.md
crowdsim 1.38.1
Added
-
Tagging a release now requires that the image has built (#89).
1.36.0 and 1.37.0 were tagged with a Dockerfile that could not build — theuistage did not copy a
file the UI imports — and published no image at all.make lint,make testandmake test-e2e
were green, because none of them builds the image, andnew-release.sh tagdid not ask. The only gate
was rememberingmake image-smoke, which this repository requires and which was skipped.make image-smokenow records a receipt in.git/crowdsim-image-smoke, fingerprinting the files that
end up in the image, andtagrefuses unless the receipt matches the tree it is about to tag:❌ the image has not been smoke-tested for this tree. Run it, then tag: make image-smokeThree deliberate properties:
- Nothing is built at tag time. It verifies a run that already happened, so it costs a second and
not five minutes — which is why the full smoke test can be the gate rather than a baredocker build. - What counts as image-relevant is not written down twice.
scripts/image-fingerprint.shreads the
paths:filters in.github/workflows/image.yml, the list that already decides whether CI builds.
A second copy would leave the gate blind to a path added to the workflow — the shape of the bug it
exists to catch. - The receipt lives in
.git/. At the repo root it made the tree dirty, andtagrefuses a dirty
tree, so recording a receipt blocked the command it exists to unblock. In.git/it needs no
.gitignoreentry and cannot reach a clone.
Prose does not invalidate it;
preparedoes, because the version is baked into the image and the smoke
test asserts the image reports it. So the honest order is prepare → CHANGELOG → commit → image-smoke
→ tag, anddocs/development.mdnow has a table of which gate runs when — the previous answer being
the ones you remember.tag --no-imagecuts a release without the gate on a machine that cannot build the image, and says so
every time. A command-line flag and never an environment variable, the same shape as the safe-peak
override: a gate somebody forgot they disabled is worse than none. When docker is missing the refusal
names the flag itself. - Nothing is built at tag time. It verifies a run that already happened, so it costs a second and
Fixed
- Two defects in the fingerprint, both found by running it rather than reading it, and both of the kind
that fail open:- it hashed nothing.
python3 - <<'PY'takes its program from stdin, so piping the file list into
that heredoc delivered an empty stream and the fingerprint came out as the sha256 of the empty
string — a value that matches every tree, i.e. a gate that always passes. This repository had already
been caught by that heredoc once, incrowdsim init. - it depended on the current directory. The file names are repo-relative and were opened as such,
so running it from anywhere but the repo root hashed a different repository's files. Now joined to
the repo root, and asserted identical from three directories.
- it hashed nothing.
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.38.1
Install and usage: docs/docker.md
crowdsim 1.38.0
Milestone v1.17.0, closed. The two commands that read the archive rather than one run had no drawn
form, so the two claims this tool is most confident about could not be attached to anything.
Added
-
crowdsim history --html(#87): the knee over
time. Does the knee move is the only question this subcommand exists to answer, and it answered it as
a table — the GUI plotted it,report --htmldraws exactly one run, and there was nothing to hand over.
The trend is the only claim here that survives its own caveat about absolutes being optimistic, and it
was the one thing that could not go in a ticket.One line per experiment. Runs at a different profile, target, shape or fan-out are separated rather
than averaged, because they are not points on one line; the fan-out rule iscomparableFanOutfrom
k6/lib/delivery.jsand not a second copy. A discard never joins a line and is listed under Left out,
and why, with its run id. A run whose knee was refused is a gap, not a zero — a knee of 0 req/s
would be a claim about the system. And one run is not a trend: the page says so instead of drawing a
line through a single point, which is the same refusal that stops a knee being read off one step.The records go through the same
--jsonview the table is built from, so the page and the terminal
cannot disagree about what a run was. -
crowdsim compare a b --html(#88): the delta,
drawn as a delta — the bar is the change, centred on zero, left better and right worse. Two absolute
bars would leave the reader subtracting by eye, and that subtraction is the one number this tool is
confident about: the absolutes come from a synthetic pool of cold URLs and travel badly.It is drawn from what
compare --jsonprints, which is what makes it allowable at all.report --html
declines to draw two runs and is right for the reason it gives — two runs on one pair of axes without
compare's refusals is a picture of two different experiments — and this page has those refusals
because it has no second opinion to have: a refusal stops the drawing, and adding one tocompare
cannot leave the picture behind. That refusal message now points here instead of implying no drawn
comparison exists. -
Both pages reuse the run report's shell, stylesheet and geometry (
page(),W/H/PAD, now
exported), so a second page cannot drift from the first on what self-contained means. And
tests/image/smoke.shasserts both load inside the image: they import across directories, which is the
shape that broke 1.20.0.
Fixed
-
No image published for 1.36.0 or 1.37.0: the UI build stage lacked the one file the UI imports from
outside itself.gui/ui/src/lib/summary-blocks.jsimportsoutcomeBandsfromk6/lib/failure.js,
so the page and the drawn report share one band arithmetic rather than two that can disagree — the
right call, and it costs a line in the Dockerfile that was not there. Theuistage copied only
gui/ui/, sovite buildinside the image failed with:Could not resolve "../../../../k6/lib/failure.js" from "src/lib/summary-blocks.js"A checkout has the whole repository, so
make lint,make testand the UI tests all passed. The only
thing that could catch it was building the image — and the image build is not part ofmake test, so
the image workflow went red on a release, twice, while everything local was green. -
A guard inside
make test:tests/gui/ui-build-inputs.test.jsreads the UI's imports that resolve
outsidegui/ui/and the paths the Dockerfile'suistage copies, and asserts they agree. It cannot
prove the image builds —make image-smokedoes that, and it is the gate this change skipped — but it
fails in a second on the mistake that actually happened. A second assertion keeps the list of escaping
imports explicit, so reaching out of the UI stays a deliberate decision.The same class of mistake on the server side is already covered:
gui/serverimports
lib/validate.mjs, the runtime stage copieslib/, and the smoke test asserts it loads. The
difference is that the UI is bundled at build time and the server runs from source at runtime, so
they need their imports in two different stages.
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.38.0
Install and usage: docs/docker.md
crowdsim 1.37.0
A server-side series was a table, and the overlay it exists for was done by eye. --server-metrics
(1.31.0) aligns a handed-in series to the run's own steps — reading it against latency is the entire
reason for aligning it — and reported it as mean and max per step, so doing that meant putting two tables
side by side and comparing rows.
Added
-
seriesChart()(#86): the series on the
ramp's own step axis, with p95 beside it, inreport --html. The artefact is read from beside the
summary rather than passed as a flag — the driver already wrote it there, and a report should not need
to be told what its own run produced.Four things it must not do, and does not:
- Two scales, both named. The series keeps its own units and its own axis; one shared axis would
make two different quantities look like one. - No trend line through both, and no smoothing. The shape is the shape that was recorded.
- A step with no samples is a gap — the line breaks into segments rather than spanning a window
nobody recorded, which would draw data that does not exist. Asserted by counting the segments. - The caveat is on the chart, not only in the prose above it: a chart travels further than the
paragraph next to it, and the word is correlation. A unit test asserts the SVG contains none of
caused, because, explains or due to.
On an invalid run the series is drawn without the latency line, for the same reason the failure mode
is still shown there: a counter that rose is not a latency claim, while latency from a run that did not
deliver its rate describes the generator or the target rather than the system. - Two scales, both named. The series keeps its own units and its own axis; one shared axis would
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.37.0
Install and usage: docs/docker.md
crowdsim 1.36.0
«Concentrated on 2 of 5 classes» was prose next to a chart of something else. That claim is the whole
distinction the failure-mode line makes — the same code on some classes and not others points at a pool or
a route, spread evenly it points at the system — and it is exactly what a chart is for. classChart drew
p95, so a run serving 474 × 404 on two classes drew a perfectly healthy set of bars.
Added
-
outcomeChart(): one stacked bar per class, split by outcome, inreport --html
(#85) — and the same rows on the GUI's result
card. Three rules that are not decoration:- A class with no requests is absent, not a band of width zero. A zero bar reads as a class that
was fine; a class that never ran is a different statement, and this project has been caught by that
conversion more than once. cs_5xxcounts the 502s and 504s too, so the bands are 504, 502 and other 5xx. Stacking the
raw counters would draw more failures than the class had, and a stack past its own total is a chart
nobody can read.- Counts, never shares recomputed at the drawing site.
failure_mode.shareand these bands come
from the same numbers, or they disagree on one page.
A run where nothing failed gets no chart at all: six full-width bars say nothing the p95 chart does not
already say better. Every band carries a CSS class and a<title>, and the failure bands are hatched
as well as coloured — the page's whole point is that it travels, and it gets printed. - A class with no requests is absent, not a band of width zero. A zero bar reads as a class that
-
outcomeBands()lives ink6/lib/failure.js, next to the code list, and both renderers import
it — the drawn report and the browser bundle. Two copies of "cs_5xx is the superset" is the duplication
this project keeps paying for, and the page and the report end up side by side in the same
conversation. -
tests/image/smoke.shassertslib/report-html.mjsloads inside the image. That file now imports
../k6/lib/failure.js— a cross-directory import in a file the image ships, which is exactly the shape
that broke 1.20.0 (lib/validate.mjs→k6/lib/auth.js, ESM in a checkout and CommonJS in the
container). Asserted by name rather than assumed.
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.36.0
Install and usage: docs/docker.md
crowdsim 1.35.0
The first two of milestone v1.17.0, both the same shape: a chart is a worse place to be wrong than a
sentence. A wrong scale does not throw — it draws something convincing.
Fixed
-
The knee plot's axis now says which rate it is drawing
(#83). This was the unmet half of
#78, which was closed with it unmet: that issue's
acceptance asked for it and 1.34.0 delivered the banners without touching the plot.stepCurve()mapped
requested_rpsonto an unlabelled axis, so on a mix with a fan-out of 1.25 the page showed a knee at 60
while the target was taking 76 — the wrong answer 1.29.0 removed from the text, moved onto the chart.rateAxis()names the rate and, in the axis title, gives the pair and the fan-out. The curve carries
both rates per point, so a step's tooltip reads 20 req/s requested → 25 delivered. A run whose
delivered rate was refused says so rather than falling back to the requested rate wearing the
delivered label, and the knee badge's title carries the delivered pair when the run has one — derived
from the knee, not read out of the summary sentence, which is what an earlier version of the test
accidentally asserted. -
The drawn report says which kind of invalid a run is
(#84).report --htmlrendered no
drop_diagnosis, so since 1.32.0 the artefact most likely to be attached to a ticket still opened with
DISCARD THIS RUN for a target that had simply saturated — the advice 1.32.0 exists to correct. A
saturated target is now drawn as a finding, a starved generator as a discard, and the
no-latency-charts rule is unchanged either way: a rate that was not delivered was not measured. -
What answered, and with what: the status codes per class, and across the run. p95 per class was
the only thing a class did on that page, so a run serving 474 × 404 on two classes drew a perfectly
healthy set of bars. Shown on an invalid run too — a 404 does not become untrue because the rate was
not delivered. A counter of zero is drawn as0, not as n/a: unlike a latency, zero is a real answer
for a counter.
Added
- A drift guard for the drawn report, symmetric with the GUI's.
lib/report-html.mjsexportsDRAWN
andDELIBERATELY_NOT_DRAWN, each omission with its reason, and a unit test asksbuildSummary
itself for its keys and fails on a block belonging to neither. The GUI accumulated six unrendered
blocks because nothing noticed and got a guard in 1.34.0; this page was left without one and was
already missingdrop_diagnosisby then. Seven blocks stay off it on purpose —concurrencyand
think_time(journey-only numbers, not shapes),allocationandmix_target(one arithmetic, one
picture),signupandauth(they name real accounts),server_side(its own chart, in #86), and
rsc_mode(an input, not a measurement).
Container image
docker pull ghcr.io/hiway-media/crowdsim:1.35.0
Install and usage: docs/docker.md