Skip to content

Releases: HiWay-Media/crowdsim

crowdsim 1.41.0

Choose a tag to compare

@github-actions github-actions released this 21 Sep 13:19

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.js parses Prometheus and custom metric formats. Handed to a run via --server-metrics <file> --server-metrics-label <name>, correlated with run steps and written to out/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.mjs calculates 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 crowdsim CLI used by the project itself.
    The workspace config in .vscode/mcp.json starts node ./scripts/crowdsim-mcp.js and 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

Choose a tag to compare

@Allan-Nava Allan-Nava released this 21 Sep 12:14

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 by CROWDSIM_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. probe documents exit 4 if the target answers ≥400 and the
    exit-code contract says 4 = target not reachable. A failed curl leaves the status as 000, and
    "000" -ge 400 is 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

Choose a tag to compare

@github-actions github-actions released this 09 Sep 08:10

Fixed

  • The docs index listed one drawn page and the tool draws three
    (#93). history --html and compare a b --html
    shipped in 1.38.0, documented in cli.md and 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-docs covers it now, the way it already covered documented flags: check-doc-commands.sh
    asks bin/crowdsim which subcommands declare --html and 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

Choose a tag to compare

@github-actions github-actions released this 09 Sep 08:09

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: realpath the
    allowed directory, realpath the 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_DIR says 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

Choose a tag to compare

@github-actions github-actions released this 09 Sep 07:55

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 filters history itself 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
    compare refuses the pair the endpoint answers 422 with compare'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 comparison compare refused gets no
    drawing.
  • A fourth drawn page cannot appear unnoticed. gui/server/lib/drawn-pages.js declares the set
    exhaustively, with a reason for anything deliberately not offered, and
    tests/gui/drawn-pages.test.js asks bin/crowdsim which 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.js with the others, because the filters arrive from
    a URL and end up in an argv: last is an integer in range, target and profile are 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. latest and previous are 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.bats attributes each flag in args.js to the subcommand whose builder emits it.
    It used to assume they all belonged to load, with --limit hand-excepted as discover's — a claim
    about the code's shape that held while args.js built 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

Choose a tag to compare

@github-actions github-actions released this 09 Sep 06:01

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 — the ui stage did not copy a
    file the UI imports — and published no image at all. make lint, make test and make test-e2e
    were green, because none of them builds the image, and new-release.sh tag did not ask. The only gate
    was remembering make image-smoke, which this repository requires and which was skipped.

    make image-smoke now records a receipt in .git/crowdsim-image-smoke, fingerprinting the files that
    end up in the image, and tag refuses 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-smoke
    

    Three 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 bare docker build.
    • What counts as image-relevant is not written down twice. scripts/image-fingerprint.sh reads 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, and tag refuses a dirty
      tree, so recording a receipt blocked the command it exists to unblock. In .git/ it needs no
      .gitignore entry and cannot reach a clone.

    Prose does not invalidate it; prepare does, 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
    , and docs/development.md now has a table of which gate runs when — the previous answer being
    the ones you remember.

    tag --no-image cuts 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.

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, in crowdsim 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.

Container image

docker pull ghcr.io/hiway-media/crowdsim:1.38.1

Install and usage: docs/docker.md

crowdsim 1.38.0

Choose a tag to compare

@github-actions github-actions released this 08 Sep 13:59

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 --html draws 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 is comparableFanOut from
    k6/lib/delivery.js and 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 --json view 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 --json prints, 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 to compare
    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.sh asserts 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.js imports outcomeBands from k6/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. The ui stage copied only
    gui/ui/, so vite build inside 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 test and the UI tests all passed. The only
    thing that could catch it was building the image — and the image build is not part of make 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.js reads the UI's imports that resolve
    outside gui/ui/ and the paths the Dockerfile's ui stage copies, and asserts they agree. It cannot
    prove the image builds — make image-smoke does 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/server imports
    lib/validate.mjs, the runtime stage copies lib/, 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

Choose a tag to compare

@github-actions github-actions released this 08 Sep 13:59

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, in report --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.


Container image

docker pull ghcr.io/hiway-media/crowdsim:1.37.0

Install and usage: docs/docker.md

crowdsim 1.36.0

Choose a tag to compare

@github-actions github-actions released this 08 Sep 13:28

«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, in report --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_5xx counts 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.share and 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.

  • outcomeBands() lives in k6/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.sh asserts lib/report-html.mjs loads 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

Choose a tag to compare

@github-actions github-actions released this 08 Sep 13:21

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_rps onto 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 --html rendered 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 as 0, 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.mjs exports DRAWN
    and DELIBERATELY_NOT_DRAWN, each omission with its reason, and a unit test asks buildSummary
    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 missing drop_diagnosis by then. Seven blocks stay off it on purpose — concurrency and
    think_time (journey-only numbers, not shapes), allocation and mix_target (one arithmetic, one
    picture), signup and auth (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