Reading the report
-html report.html writes one self-contained file: no server, no CDN, no internet. Open it in six months and it still draws. This is the order to read it in, and what each part is for.
1. The bar, and what kind of test this was
Pinned to the top: the scenario, the kind of test, the host, the verdict, peak users, workers, throughput and failure rate. A ? beside the kind means surge inferred it from the shape of the load rather than being told — declare type: and the caveat goes away.
The kind decides what a verdict means before any number does. FAIL on a stress run is the result, not a regression. A soak that passed a whole-run p95 has proved nothing about the last hour, because the average of a healthy first hour and a sick last one is fine. The Run tab spells out what the run was judged on.
2. The verdict and its reasons
FAIL 2 of 3 thresholds breached · 4,126 requests · 83 failed (2.01%)
✗ threshold error_rate < 1% — actual 2.01%
⚠ feeder "accounts" has 80 rows for 110 virtual users3. The ⚠ caveats, before any number below them
They do not change the verdict. They decide whether it is worth anything.
| Caveat | What it means for your conclusion |
|---|---|
| no thresholds were set | the run could not fail. A pass means it finished |
| stopped early | the numbers describe the part that ran, not the plan |
| N arrivals could not be started | the rate on the chart was never applied |
| generator CPU peaked at… | you measured this machine, not the target |
| N extracts failed | some users stopped mid-journey; later steps are under-represented |
| feeder has fewer rows than VUs | several users shared an identity; you may be measuring row contention |
If a caveat invalidates the run, say so first and do not interpret the numbers underneath. A confident reading of a bad run is worse than no reading.
4. Failures, with the evidence
Each distinct failure keeps one real example — the role, the URL, the status, which checks gave way, and what the server replied:
POST api/checkout (buyer) check failed ×113
check: json status = confirmed
replied: {"order_id":"o-10826","status":"pending","total":30.5}That one is invisible to the error rate: it answered 200 and answered wrongly. Quote the body. "113 checkout failures" is a statistic; the body is evidence.
Failures are grouped by signature and counted, so a million of them cost the memory of fifty.
5. Checks, by name
check_rate 97.1% is not a place to look. json status = confirmed — 113 of 799 failed is.
6. User actions
If the scenario used group:, this is the table the business asked for:
user actions (end to end, think time excluded)
action ran failed avg p95 p99
checkout 842 40 60.4 82.9 87.0It is not part of the totals — every request inside an action is already counted there — so do not add it to the throughput, and do not compare its p95 with a request's. An action fails when any step in it failed, so "40 failed" can be forty different steps.
7. Who did the work
Planned share against the share that actually ran:
| role | planned | actual | iterations |
|---|---|---|---|
| shopper | 64.2% | 52.9% | 1,044 |
| buyer | 32.1% | 42.6% | 842 |
| back-office | 3.6% | 4.5% | 89 |
A gap like that is arithmetic, not a bug: weight decides how many users pick a role, not how fast they iterate. A role that thinks for a second completes fewer iterations than one that does not, whatever its weight. Every average in the report is weighted by the actual column. If the mix you measured is not the mix you meant, the overall p95 describes a system nobody runs — change the think time or the weights, not the conclusion.
8. The log
Timestamped: feeder warnings, generator saturation, an abort rule firing, a node joining late. Line it up against the charts — "p95 climbed at 00:42" and "the generator saturated at 00:41" is one finding, not two. On a merged fleet run each line says which node it came from, or each node: when they all said it.
9. Where the time went
With -timings, the dominant phase names the suspect:
| Phase | What it means |
|---|---|
wait | the server thinking. The application's own time — the normal answer |
connect / tls | connections are not being reused: keep-alive, a proxy, or a pool too small |
blocked | your own client pool is the limit, not the server |
dns | resolution per request. Almost always a client bug |
Read reuse% too. Below 90% the setup cost is inflating every latency above it.
10. What the target said about itself
With -monitor, the server-side series ranked by how closely each moved with the latency. Correlation ranks where to look; it is not cause. Under load almost everything moves together.
11. Throughput against latency over time
The knee — throughput flat while latency climbs — is the capacity limit. Past it, adding users adds queueing, not work. The three charts share one time axis so the knee is visible rather than inferred.
Long lists
Every long table — failures, checks, exceptions, agents, server-side metrics, requests — has a search box and a show all. A report of two thousand endpoints is still one file you can find one endpoint in.
The other files
| Flag | File | For |
|---|---|---|
-json | summary.json | the same structure the library's Run returns; what compare, analyze and baseline save read |
-csv | timeline.csv | one row a second: sec,reqs,errs,avg,p50,p95,max,vus |
-failures | failures.csv | one row per distinct failure, with its example. For grepping and diffing |
-snapshot | raw.json | mergeable histogram buckets, for surge merge |
-log-requests | requests.csv | one line per request, for outlier forensics |
-prometheus -influx -webhook | — | the same run, in whatever your dashboards already read |
Upload the HTML report on failure too. The report is the thing that explains the failure; a pipeline that throws it away on red has kept the one artefact nobody needs.