Skip to main content

Reporting

Table of Contents

Why the report is tool-neutral​

A stakeholder reading a performance test report doesn't care whether it ran in k6 or JMeter — they care whether the system met its NFRs, and if not, where it broke. docs/jmeter/10-reporting.md already covers that report structure in full (summary, test scope, load model, NFR table, per-transaction breakdown, observations, recommendation) — this page doesn't repeat it, it's the k6-specific plumbing for filling that same structure in: where each number in the NFR table comes from, and what to attach.

How: what the report needs​

Same sections as the JMeter page, filled from a k6 run instead of a .jtl:

Report sectionk6 source
Load modelThe PROFILE/EXECUTOR/USERS/RATE the run was invoked with — see Load Model
EnvironmentBASE_URL, and anything about the target worth noting (infra size, for a scalability comparison)
NFR vs. actualhandleSummary's JSON, one jq query per NFR row (below)
Per-endpoint p95/avg/error rateThe --out json/csv event stream filtered by name, not the summary — see Execute and Analyze
ObservationsAnything that needed the raw data to see — the smoke-vs-load p95 gap, the Insufficient VUs warning, a threshold that passed but was close
RecommendationSame judgment call as the JMeter page — pass with headroom noted, or fail with the specific metric and load level

How: pulling each NFR row from the summary JSON​

handleSummary (below) writes results/summary-<PROFILE>-<timestamp>.json — the same data k6 uses to build the on-screen summary, as JSON. Each NFR row in the report table is one jq query against it:

$ F=results/summary-smoke-202609280626.json
$ jq '.metrics.http_req_duration.values.avg' "$F"
24.438333333333336
$ jq '.metrics.http_req_duration.values["p(95)"]' "$F"
114.289
$ jq '.metrics.http_req_failed.values.rate' "$F"
0
$ jq '.metrics.checks.values.rate' "$F"
1

(Numbers above are from a real SCALE=0.1 smoke run — the shape of the query doesn't change for a full-length one.) The first two rows are 05-load-model.js's actual thresholds, so the NFR target column comes straight from the script; p95 and checks are reported alongside them even though this script has no explicit threshold on either (a real NFR would add one — http_req_duration: ['p(95)<500', 'avg<3000'] — the same way 06-checks-thresholds.md does it per endpoint):

MetricNFR TargetActual ResultStatus
Avg response time< 3000ms (script threshold)24.4msPASS
Error rate< 0.5% (script threshold)0%PASS
p95 response time— (no threshold set; report for context)114.3ms—
Checks pass rate— (no threshold set; report for context)100%—

For the per-endpoint breakdown row (POST /orders p95 specifically), the summary JSON doesn't have it — that's the --out json + jq query from the previous page, not this file.

How: handleSummary​

05-load-model.js ends with this — it fully replaces k6's default end-of-test output, so what prints to the terminal and what gets written to disk are both explicit choices, not defaults:

import { textSummary } from 'https://jslib.k6.io/k6-summary/0.1.0/index.js';

// Replaces the default end-of-test output: keep it on screen and save JSON for the report.
export function handleSummary(data) {
const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '');
return {
stdout: textSummary(data, { indent: ' ', enableColors: true }),
[`results/summary-${PROFILE}-${stamp}.json`]: JSON.stringify(data, null, 2),
};
}

jslib.k6.io/k6-summary/0.1.0 still loads and works on k6 v2.2.0 (verified — it's the only text-summary helper needed here; v2.2 has no built-in replacement for it). textSummary(data, ...) does not reproduce k6 v2.2's own boxed summary (the █ THRESHOLDS / █ TOTAL RESULTS layout seen on Install Tools and Debug) — jslib 0.1.0 hasn't been updated to match, so it renders the older flat layout instead (checks...: ✓ N ✗ M, one line per metric, as seen on the previous page). Same numbers, older look. That's also why the compact-vs-full comparison on the previous page had to use a script without handleSummary — only k6's own renderer honors --summary-mode. The JSON file is the one jq reads from above; the filename embeds PROFILE and a timestamp so consecutive runs (baseline, after a fix, after a scale-up) don't overwrite each other — exactly the "version your reports" tip from the JMeter page, but automatic.

How: the web dashboard export as the attachment​

K6_WEB_DASHBOARD_EXPORT (see Execute and Analyze) produces a self-contained HTML file with the same charts JMeter's HTML report gives — response time and throughput over time — which is the "include relevant charts" tip from the JMeter reporting page, satisfied without a separate reporting tool:

K6_WEB_DASHBOARD=true K6_WEB_DASHBOARD_EXPORT=results/report.html k6 run -e PROFILE=load -e USERS=<NFR peak> 05-load-model.js

That report.html is the attachment: drop it alongside the NFR table and the summary JSON when the report goes out, the same way a .jtl and its generated HTML report travel together in the JMeter track.

Tips​

  • Save the JSON, not just the terminal output. handleSummary's file (or --out json) is what makes "Run 2 — after DB optimization" an actual diff against "Run 1 — Baseline" instead of two screenshots someone has to eyeball.
  • Lead with pass/fail, same as the JMeter page says. The NFR table above is the whole story for a management audience; keep the per-endpoint breakdown and the raw results/ files for the technical follow-up.
  • The playground's teardown line (active orders: N, highest order number: M) is worth quoting in the observations section — it's independent proof the load actually landed, not just that k6 reported success.