Module 7: Analyzing Results And Ci
2. Reading the summary and the trend metric
Overview
When a load test finishes, it spits out a summary: a block of numbers that condenses thousands of requests into a few lines. Knowing how to read it is the first skill of this module, because a badly read summary leads to false conclusions —"the average was 10 ms, it flies!"— that hide a real problem in the tail. In this lesson you learn to read the summary with the three questions that matter (is the p95 within the SLO? did the error rate cross the limit? is the RPS it held enough for the expected traffic?) and you meet the type of metric that makes that summary possible: the trend metric (Trend), which takes an entire series of values —one latency per request— and summarizes it into percentiles (avg, min, med, max, p90, p95). On the executable side, a small analyzer in Python reads the metrics of a real run and judges them against an SLO, showing in practice what "reading the summary with judgment" means.
Connection to the module: this is the first piece of analyzing. It uses the metrics you learned to measure in module 3 (p95, RPS, error rate) and now reads and interprets them against a standard. It doesn't re-explain what a percentile is —that was M3—; it teaches how to read the portrait they form all together. What comes next is exporting that summary to a file (lesson 3) to be able to analyze it elsewhere and compare it with other runs. The trend metric presented here is the one you'll later export and compare.
The report card, not the list of all the assignments
Imagine that at the end of the semester they handed you, instead of a report card, the complete list of the 4,000 answers you gave in all the year's exams. Technically all the information is there, but it's useless: no one can read 4,000 answers and draw a conclusion. That's why the report card exists: it summarizes those 4,000 answers into a few meaningful figures —the average, the lowest grade, the highest, what percentile you ended up in—. The report card doesn't give you less useful information than the raw list; it gives you more, because it makes it legible.
A load test's summary is that report card. Behind it are thousands of individual latencies —one for each request—, impossible to read one by one. The trend metric is the one that makes the report card: it grabs the complete series of latencies and summarizes it into avg/min/med/max/p90/p95. And as on a school report card, the figure that matters most isn't always the average: a student with a 8 average but who failed the final exam (their "p95") has a problem the average hides. Reading the summary is knowing which figure to look at.
What a trend metric is (Trend)
k6 classifies its metrics into four types, and it's worth knowing them because each answers a different question:
Trend: summarizes a series of values into statistics —avg, min, med, max, and percentiles like p(90), p(95), p(99)—. It's the type ofhttp_req_duration(the latency). Each request contributes a value to the series; theTrendsummarizes them. It's the type you care about for latency.Counter: sums. Counts how many times something happened. It's the type ofhttp_reqs(total requests).Rate: the percentage of times something was true. It's the type ofhttp_req_failed(fraction of requests that failed) and ofchecks(fraction of checks that passed).Gauge: the last value, or the minimum/maximum. It serves for things that rise and fall, likevus(virtual users active right now).
The star for analyzing performance is the Trend, because the latency is a distribution, not a number: a thousand requests give a thousand different latencies, and you need the percentiles to describe that distribution (why the average lies and the p95 doesn't, you saw in module 3). k6's summary shows http_req_duration as a Trend precisely for that.
k6 also lets you create a custom Trend: your own series that you feed, to measure something http_req_duration doesn't separate —for example, the latency of only the "quote" step, isolated from the "book" step—. This is how it's declared (this is k6 content, labeled; it isn't run here):
// CONTENT (not run here): a custom Trend in k6. See grafana.com/docs/k6.
import http from "k6/http";
import { Trend } from "k6/metrics";
// Your own trend metric: the latency of only the "quote" step.
const quoteLatency = new Trend("quote_latency", true); // true = in milliseconds
export const options = { vus: 50, duration: "30s" };
export default function () {
const url = "http://127.0.0.1:8000/quote";
const payload = JSON.stringify({ room: "Focus", tier: "basic", hours: 3 });
const params = { headers: { "Content-Type": "application/json" } };
const res = http.post(url, payload, params);
quoteLatency.add(res.timings.duration); // feeds the series with this latency
}
In the summary, that custom Trend appears in the same shape as http_req_duration: avg/min/med/max/p(90)/p(95). It's the tool for answering "which of my steps is the slow one?" when a flow has several (we'll see it when hunting the bottleneck, lesson 5).
Reading the summary with three questions
This is what k6's end-of-test summary looks like (labeled content, faithful to k6's documentation; the numbers are consistent with what the Python generator actually measures). It's not read top to bottom like text; it's read looking for three answers:
// CONTENT (not run here): shape of the k6 run summary. See grafana.com/docs/k6
✓ status is 200
✓ price_cents is 7500
checks.........................: 100.00% ✓ 30000 ✗ 0
data_received..................: 4.2 MB 140 kB/s
data_sent......................: 3.6 MB 120 kB/s
http_req_duration..............: avg=5.3ms min=2.7ms med=4.7ms max=22.6ms p(90)=6.1ms p(95)=6.7ms
http_req_failed................: 0.00% ✓ 0 ✗ 30000
http_reqs......................: 30000 1000/s
iteration_duration.............: avg=5.8ms min=2.9ms max=24.1ms
iterations.....................: 30000 1000/s
vus............................: 30 min=30 max=30
The three questions, with the line that answers each:
- Is the latency within the SLO? → look at
http_req_duration, and inside it the percentile your SLO sets (almost alwaysp(95)). Herep(95)=6.7ms. If your SLO isp(95) < 200 ms, you have tons of room. Ignore theavgfor judging the SLO: the average hides the tail. The p95 is your report card. - Did the error rate cross the limit? → look at
http_req_failed. Here0.00%. If your reliability SLO is< 1%, perfect. A high error rate invalidates everything else: a beautiful p95 is worth nothing if 20% of the requests failed (you measured the latency of the ones that did respond, a dangerous bias). - Is the throughput enough? → look at
http_reqs(the total and the/s). Here1000/s. The question is whether that sustained RPS covers the traffic you expect in production. If your real peak is 300 req/s and it held 1000, plenty; if you expect 3000, this system doesn't reach it.
With those three answers —p95 within the SLO, error below the limit, RPS enough— you have a verdict. Without the three, you have loose numbers. Notice too checks: 100.00%: the correctness under load (M6) also lives in the summary, as a Rate.
The executable side: an analyzer that reads and judges
Now the one that does run. The k6 summary is content, but the Python generator produces the same metrics for real, and we can write a small analyzer that reads and judges them against an SLO —exactly the "reading the summary with judgment" we just described, made code—. The analyzer takes an exported results.json (lesson 3 produces it) and answers the three questions:
"""Analyzes an exported results.json: interprets it against an SLO.
Reading the JSON outside the run is the point of exporting: it can be analyzed,
compared, and judged later, without launching load again. Here we evaluate the p95 and the
error rate against an SLO and emit a legible verdict.
Usage: python3.14 analyze_results.py results.json P95_SLO_MS ERROR_SLO_PCT
"""
import json
import sys
with open(sys.argv[1]) as f:
r = json.load(f)
p95_slo = float(sys.argv[2])
err_slo = float(sys.argv[3])
p95 = r["latency_ms"]["p95"]
err_pct = r["error_rate"] * 100
print(f"Run '{r['label']}' ({r['endpoint']}, {r['requests']} req)")
print(f" sustained RPS : {r['rps']}")
print(f" p50 / p95 / p99 : {r['latency_ms']['p50']} / {p95} / "
f"{r['latency_ms']['p99']} ms")
p95_ok = p95 < p95_slo
err_ok = err_pct < err_slo
print(f" p95 {p95} ms vs SLO < {p95_slo} ms -> "
f"{'WITHIN the SLO' if p95_ok else 'OUTSIDE the SLO'}")
print(f" error {err_pct:.2f}% vs SLO < {err_slo}% -> "
f"{'WITHIN the SLO' if err_ok else 'OUTSIDE the SLO'}")
print(f" VERDICT: {'PASS' if (p95_ok and err_ok) else 'FAIL'}")
We run it against two real exported runs: the baseline (fast endpoint /quote) and the degraded one (slow endpoint /quote_slow), both against an SLO of p95 < 200 ms and error < 1%. All of this is real output.
What to expect — the baseline has a p95 of milliseconds: within the SLO, verdict PASS:
$ python3.14 analyze_results.py results_baseline.json 200 1
Run 'baseline' (/quote, 600 req)
sustained RPS : 5449.6
p50 / p95 / p99 : 4.7 / 6.66 / 19.91 ms
p95 6.66 ms vs SLO < 200.0 ms -> WITHIN the SLO
error 0.00% vs SLO < 1.0% -> WITHIN the SLO
VERDICT: PASS
What to expect — the degraded one has a much higher p95 (61 ms), but still within the 200 ms SLO: verdict PASS too:
$ python3.14 analyze_results.py results_actual.json 200 1
Run 'actual' (/quote_slow, 600 req)
sustained RPS : 538.1
p50 / p95 / p99 : 54.74 / 61.27 / 73.62 ms
p95 61.27 ms vs SLO < 200.0 ms -> WITHIN the SLO
error 0.00% vs SLO < 1.0% -> WITHIN the SLO
VERDICT: PASS
Stop at this, because it's a lesson within the lesson. Both runs pass the SLO, even though the degraded one is almost ten times slower (61 ms vs 6.66 ms). Reading the summary against an absolute SLO tells you "both are fine" —and by today's SLO, they are—. But your instinct screams that something changed: the p95 multiplied by nine. That's the limitation of analyzing an isolated run: you see whether it meets the standard, but you don't see whether it got worse. For that you need to compare with a previous run, which is exactly lesson 4's regression. Reading the summary is the first step; comparing summaries is the next.
Common mistakes
Judging the SLO by the average. What happens: someone looks at avg=5.3ms, concludes "very fast," and doesn't see that the p99 is 20 ms or that there's a tail. Why it happens: the average is the most visible and most intuitive figure. How to detect it: if your verdict is based on the avg and not the percentile of your SLO, you're reading it wrong. How to fix it: judge the SLO by the percentile you set (p95 or p99); the average is context, not verdict (module 3).
Reading the latency without looking at the error rate. What happens: a low p95 is celebrated without noticing that http_req_failed is 20%. Why it happens: latency is the first thing looked at. How to detect it: a suspiciously good p95 next to a high error rate —you measured the latency of only the requests that responded, ignoring the ones that failed—. How to fix it: always read the three figures together; a high error rate invalidates the rest of the summary.
Confusing the Trend with a Counter or a Rate. What happens: someone looks for the percentile in http_reqs (which is a total, a Counter) or the total in http_req_duration (which is a Trend). Why it happens: the metric type isn't distinguished. How to detect it: if you look for a p95 and the metric only has a total and a /s, it's a Counter, not a Trend. How to fix it: remember the types —latency = Trend (has percentiles), total = Counter (sum), fraction = Rate (percentage), current value = Gauge—.
Exercises
Exercise 1 — Classify the metric. For each one, say what type it is (Trend, Counter, Rate, or Gauge) and what question it answers. (a) http_req_duration. (b) http_reqs. (c) http_req_failed. (d) vus. (e) checks.
See solution
- (a)
Trend— summarizes the series of latencies into percentiles. Answers "how long does it take?". - (b)
Counter— sums the total of requests (and its/s). Answers "how much throughput?". - (c)
Rate— the fraction of requests that failed. Answers "how reliable?". - (d)
Gauge— the virtual users active right now (min/max). Answers "how much load was there?". - (e)
Rate— the fraction of checks that passed. Answers "was the response correct under load?".
Exercise 2 — Read this summary. SLO: p(95) < 300 ms, error < 1%, and you need at least 500 req/s. The summary says: http_req_duration: avg=80ms med=70ms p(95)=280ms; http_req_failed: 0.30%; http_reqs: ... 640/s. Does it pass the SLO? Justify the three figures.
See solution
It passes all three. (i) Latency: p(95)=280ms < 300ms → within the SLO (though by a small margin; worth watching). (ii) Error: 0.30% < 1% → within. (iii) Throughput: 640/s ≥ 500/s → enough. Note: the avg=80ms is much lower than the p(95)=280ms, a sign of a long tail —the typical user sees 70 ms, but the worst 5% see 280—; the SLO is met, but that p50↔p95 gap is worth keeping on the radar.
Exercise 3 — Why a custom Trend? A k6 flow makes two requests per iteration: POST /quote and then POST /book. The summary's http_req_duration mixes the latencies of the two. (a) What problem does that have if you want to know which of the two steps is the slow one? (b) How does a custom Trend solve it?
See solution
- (a)
http_req_durationaggregates all the run's HTTP requests into a single series. If/quotetakes 5 ms and/booktakes 100 ms, the combined p95 tells you "something takes long," but not which: you can't separate the fast step from the slow one by looking at that aggregate metric. - (b) You declare two custom
Trends —quoteLatencyandbookLatency— and in the code feed each with its step's latency (quoteLatency.add(resQuote.timings.duration)and the same for book). In the summary they appear separately, each with its p95, and there you immediately see that/bookis the bottleneck. It's the tool for locating the slow step within a flow (lesson 5).
Summary and next step
In this lesson you learned to read the summary of a load test like a report card, not like a raw list: looking for three answers —is the p95 within the SLO? did the error rate cross the limit? is the RPS enough for the expected traffic?— and looking at the right figure for each (your SLO's percentile for the latency, not the average). You met k6's four metric types and why the latency is a Trend (a series summarized into percentiles), including the custom Trend for isolating the latency of a specific step. And with the executable analyzer you saw something key: a run read in isolation tells you whether it meets the standard, but not whether it got worse —both runs passed the 200 ms SLO even though one was 9x slower—.
Before moving on you should be able to: name the three questions for reading a summary and the line that answers each; distinguish Trend/Counter/Rate/Gauge; and explain why analyzing an isolated run doesn't catch a regression. What comes next, in lesson 3, is exporting that summary to a file —exactly what the analyzer read— to be able to analyze it elsewhere and, above all, save it as the baseline against which to compare future runs.
Resources
- k6 — Metric types — the official reference for the four metric types (
Trend,Counter,Rate,Gauge) and what each reports in the summary. The source of this lesson's content. - k6 — End-of-test summary — how the end-of-test summary you learned to read here is structured, line by line.
- k6 —
Trend(custom metric) — how a customTrendis declared and fed to isolate the latency of a specific step of a flow. - Google SRE Book — Service Level Objectives — the criterion for judging the summary: why the p95/p99 (and not the average) is what's compared against an SLO.