Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
breaks with no more of its own users than it held alone: it competes with the others for
something they share (a database pool, CPU, a cache). Saved runs carry a `combined` field.
`--no-combined` skips it.
- **Journeys everywhere.** The `--html` report shows each journey, the combined run and
`--expect`; the MCP tools' run summaries list each journey's verdict; and `prescale compare`
diffs each journey by name (terminal, `--json`, `--markdown`). A drop past the threshold in any
journey counts as a regression for `--fail-on-regression`, even when it isn't the weakest journey.
- **`--expect N`** on `run` says whether you'd hold N users at peak. In a journey run, N is split
across journeys by weight: each must hold its share alone, and all together must hold N.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ Interference checkout broke with 15 of its own users, but held 20 alone.
It competes with quickbuy for something they share (a database pool, CPU, a cache).
```

`prescale show`, `--html` reports and the MCP tools show journeys too, and `prescale compare` diffs each journey: a drop past the threshold in any journey is a regression (so `--fail-on-regression` catches it), even when it isn't the weakest one; it's marked confident when the confidence bands don't overlap. A journey that held everything it was tested to, below the old level, is inconclusive, not a drop. A URL run and a journey run measure different things, so comparing them is reported as not comparable.

`--no-combined` skips this pass. `--expect 500` says whether you'd hold 500 users at peak: in a journey run, each journey must hold its share of them (by weight) on its own, and all journeys together must hold 500; a journey that wasn't load-tested makes the answer unknown. It reports, it doesn't change the exit code (use `--fail-under` to gate CI).

- `{{name}}` is a value an earlier step pulled out with `extract` (`body.<field>` or `header.<name>`); `{{unique.email}}` and `{{unique.id}}` are fresh each run; `{{env.NAME}}` reads an environment variable.
Expand Down
24 changes: 23 additions & 1 deletion cli/src/prescale_cli/commands/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@

import click
from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
from rich.table import Table

from prescale_cli.bars import text_bar
from prescale_cli.compare import compare_results, resolve, to_markdown
from prescale_cli.compare import (
compare_results,
journey_change,
journey_users,
resolve,
to_markdown,
)
from prescale_cli.result import AmbiguousResultError, ResultNotFoundError

console = Console()
Expand Down Expand Up @@ -97,3 +104,18 @@ def _render(cmp: dict) -> None:
note = "" if (cmp["confident"] or not cmp["regressed"]) else \
" (confidence bands overlap — could be noise)"
console.print(Panel(cmp["summary"] + note, border_style=color))
if cmp.get("journeys"):
jt = Table(show_header=True, header_style="bold magenta", title="Per journey")
for col, justify in (("Journey", "left"), ("Old", "right"), ("New", "right"),
("Change", "right"), ("Breaks at", "left")):
jt.add_column(col, justify=justify)
for j in cmp["journeys"]:
if j["status"] != "ok":
jt.add_row(escape(j["name"]), "", "", f"[dim]{escape(j['status'])}[/dim]", "")
continue
tone = "red" if j["regressed"] else ("green" if j.get("improved") else "")
change = journey_change(j)
jt.add_row(escape(j["name"]), journey_users(j, "old"), journey_users(j, "new"),
f"[{tone}]{change}[/{tone}]" if tone else change,
escape(j.get("breaks_at") or "—"))
console.print(jt)
124 changes: 109 additions & 15 deletions cli/src/prescale_cli/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,44 @@ def compare_results(old: dict, new: dict, *, threshold: float = _DEFAULT_THRESHO
if o["mode"] != n["mode"]:
# URL capacity and journey capacity measure different things: never a regression.
return _not_comparable(o, n)
d = _diff(o, n, threshold)
cmp = {
"old": o,
"new": n,
"survives_delta": n["survives_users"] - o["survives_users"],
"survives_pct": round(d["pct"], 3),
"peak_rps_pct": (round((n["peak_rps"] - o["peak_rps"]) / o["peak_rps"], 3)
if o["peak_rps"] else 0.0),
"regressed": d["regressed"],
"improved": d["improved"],
"inconclusive": d["inconclusive"],
"confident": d["confident"],
"summary": (_summary(o, n, d["pct"], d["regressed"], d["improved"], d["inconclusive"])
+ _journey_note(o, n)),
}
if o["mode"] == "journeys":
cmp["journeys"] = compare_journeys(old, new, threshold=threshold)
worse = [j for j in cmp["journeys"] if j.get("regressed")]
if worse:
# A drop in any one journey is a regression, even if it isn't the weakest.
# Confidence only comes from drops: a confident overall gain says nothing
# about whether a journey's drop is noise.
cmp["regressed"] = True
cmp["confident"] = ((d["confident"] and d["regressed"])
or any(j["confident"] for j in worse))
if not d["regressed"]:
cmp["improved"] = False
cmp["summary"] = "Capacity regressed: " + "; ".join(
f"{j['name']} {j['old']} → {j['new']} users ({j['pct']:+.0%})"
for j in worse) + "."
if d["improved"]:
cmp["summary"] += (f" (Overall: {o['survives_users']} → "
f"{n['survives_users']} users, {d['pct']:+.0%}.)")
return cmp


def _diff(o: dict, n: dict, threshold: float) -> dict:
"""The capacity change between two sets of facts (a run, or one journey)."""
o_surv, n_surv = o["survives_users"], n["survives_users"]
pct = (n_surv - o_surv) / o_surv if o_surv else 0.0
inconclusive = (n["onset_users"] is None and n["max_tested"] is not None
Expand All @@ -51,19 +89,64 @@ def compare_results(old: dict, new: dict, *, threshold: float = _DEFAULT_THRESHO
confident = n["survives_high"] < o["survives_low"]
elif improved and o["survives_high"] is not None and n["survives_low"] is not None:
confident = n["survives_low"] > o["survives_high"]
return {
"old": o,
"new": n,
"survives_delta": n_surv - o_surv,
"survives_pct": round(pct, 3),
"peak_rps_pct": (round((n["peak_rps"] - o["peak_rps"]) / o["peak_rps"], 3)
if o["peak_rps"] else 0.0),
"regressed": regressed,
"improved": improved,
"inconclusive": inconclusive,
"confident": confident,
"summary": _summary(o, n, pct, regressed, improved, inconclusive) + _journey_note(o, n),
}
return {"pct": pct, "regressed": regressed, "improved": improved,
"inconclusive": inconclusive, "confident": confident}


def _journey_facts(j: dict) -> dict:
conf = j.get("confidence") or {}
return {"survives_users": j["survives_users"], "survives_low": conf.get("survives_low"),
"survives_high": conf.get("survives_high"), "onset_users": j["onset_users"],
"max_tested": j.get("max_tested")}


def compare_journeys(old: dict, new: dict, *, threshold: float = _DEFAULT_THRESHOLD) -> list:
"""Per-journey capacity change, matched by name. A journey that is new, gone,
or not load-tested on either side is listed with its status, never as a drop."""
before = {j["name"]: j for j in old.get("journeys") or []}
after = {j["name"]: j for j in new.get("journeys") or []}
rows = []
for name in [*before, *(n for n in after if n not in before)]:
o, n = before.get(name), after.get(name)
if o is None or n is None:
rows.append({"name": name, "status": "new" if o is None else "removed",
"regressed": False, "confident": False})
continue
if o["status"] != "ok" or n["status"] != "ok":
rows.append({"name": name, "status": f"{o['status']} → {n['status']}",
"regressed": False, "confident": False})
continue
d = _diff(_journey_facts(o), _journey_facts(n), threshold)
step = (n.get("culprit_step") or {}).get("name")
rows.append({"name": name, "status": "ok", "old": o["survives_users"],
"new": n["survives_users"], "pct": round(d["pct"], 3),
"old_held_all": o["onset_users"] is None,
"new_held_all": n["onset_users"] is None,
"regressed": d["regressed"], "improved": d["improved"],
"inconclusive": d["inconclusive"], "confident": d["confident"],
"breaks_at": step})
return rows


def journey_users(row: dict, side: str) -> str:
"""A journey's capacity in a compare row: "~20", or "200+" if it held every level."""
return f"{row[side]}+" if row.get(f"{side}_held_all") else f"~{row[side]}"


def journey_change(row: dict, *, markers: bool = False) -> str:
"""The change cell: the percentage, or "inconclusive" when the new run held
everything it tested but was never pushed as far as the old one."""
if row.get("inconclusive"):
return "inconclusive"
mark = ""
if markers:
mark = " 🔴" if row["regressed"] else (" 🟢" if row.get("improved") else "")
return f"{row['pct']:+.0%}{mark}"


def _cell(text: str) -> str:
"""Text safe inside a markdown table cell (journey and step names come from YAML)."""
return str(text).replace("|", "\\|").replace("\n", " ")


def _not_comparable(o: dict, n: dict) -> dict:
Expand Down Expand Up @@ -117,7 +200,7 @@ def to_markdown(cmp: dict) -> str:
emoji = "🟢"
else:
emoji = "⚪"
return "\n".join([
lines = [
f"### {emoji} PreScale — {cmp['summary']}",
"",
"| | survives | peak req/s | commit |",
Expand All @@ -132,4 +215,15 @@ def to_markdown(cmp: dict) -> str:
max(o["survives_users"], n["survives_users"], 1) * 1.1,
),
"```",
])
]
if cmp.get("journeys"):
lines += ["", "| journey | old | new | change | breaks at |", "|---|---:|---:|---:|---|"]
for j in cmp["journeys"]:
name = _cell(j["name"])
if j["status"] != "ok":
lines.append(f"| {name} | | | {j['status']} | |")
continue
step = _cell(j.get("breaks_at") or "—")
lines.append(f"| {name} | {journey_users(j, 'old')} | {journey_users(j, 'new')} | "
f"{journey_change(j, markers=True)} | {step} |")
return "\n".join(lines)
30 changes: 30 additions & 0 deletions cli/src/prescale_cli/mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,40 @@ def summarize_result(result: dict) -> dict:
"peak_rps": v["peak_rps"],
"saturated": v["saturated"],
"warning": result.get("warning"),
**_journey_summary(result),
"hint": "Call get_run(id) for the full per-level / per-route breakdown.",
}


def _journey_summary(result: dict) -> dict:
"""Journey runs: one line per journey, plus interference and --expect, kept short."""
journeys = result.get("journeys")
if not journeys:
return {}
out: dict = {"culprit_journey": result["verdict"].get("culprit_journey"), "journeys": []}
for j in journeys:
if j["status"] != "ok":
out["journeys"].append({"name": j["name"], "status": j["status"],
"reason": j.get("reason")})
continue
step = j.get("culprit_step")
out["journeys"].append({
"name": j["name"], "status": "ok",
"survives_users": j["survives_users"], "held_all": j["onset_users"] is None,
"breaks_at_step": (f"{step['index']}. {step['name']} ({step['route']})"
if step else None),
"bottleneck": j.get("bottleneck"),
})
combined = result.get("combined")
if combined:
out["together"] = {"survives_users": combined["survives_users"],
"held_all": combined["onset_users"] is None,
"interference": combined.get("interference")}
if result.get("expect"):
out["expect"] = {k: result["expect"].get(k) for k in ("users", "meets", "untested")}
return out


def _verdict_line(v: dict, conf: dict) -> str:
if v["onset_users"] is None:
return f"Held up through {v['max_tested']} concurrent users (the most tested)."
Expand Down
80 changes: 80 additions & 0 deletions cli/src/prescale_cli/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@
.cause .lbl{font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.5px;
color:var(--amber);margin-bottom:6px;}
.cause p{font-size:13.5px;color:var(--muted);}
.cause ul{margin:4px 0 0 18px;font-size:13.5px;color:var(--muted);}
.note{font-size:12.5px;color:var(--muted);margin:-4px 0 8px;}
td.t,th.t{text-align:left;}
td.n{white-space:nowrap;}
.cause p+p{margin-top:8px;}
.cause b{color:var(--ink);}
footer{margin-top:44px;padding-top:20px;border-top:1px solid var(--hair);display:flex;
Expand Down Expand Up @@ -262,6 +266,81 @@ def _cause(result: dict) -> str:
return f'<div class="cause"><div class="lbl">Likely cause</div>{"".join(paras)}</div>'


def _journeys_html(result: dict) -> str:
"""Journey runs: each journey's verdict, the combined pass, and --expect."""
journeys = result.get("journeys")
if not journeys:
return ""
first = result["verdict"].get("culprit_journey")
rows = []
for j in journeys:
if j["status"] != "ok":
label = "stale" if j["status"] == "stale" else "couldn't run"
rows.append(f"<tr><td>{_esc(j['name'])}</td><td class=\"n\">{label}</td>"
f"<td class=\"t\">—</td>"
f"<td class=\"t\">{_esc(j.get('reason') or '')}</td></tr>")
continue
if j["onset_users"] is None:
survives = f"{j['max_tested']}+ (held)"
else:
survives = f"~{j['survives_users']}{_band(j)}"
step = j.get("culprit_step")
where = (f"{step['index']}. {_esc(step['name'])} <code>{_esc(step['route'])}</code>"
if step else "—")
cls = ' class="onset"' if j["name"] == first and j["onset_users"] is not None else ""
rows.append(f"<tr{cls}><td>{_esc(j['name'])}</td><td class=\"n\">{survives}</td>"
f"<td class=\"t\">{where}</td>"
f"<td class=\"t\">{_esc(j.get('bottleneck') or '')}</td></tr>")
parts = [
'<div class="section"><h3>Journeys</h3>'
'<p class="note">Each journey ramped on its own.</p>'
'<div class="panel clip"><table><thead><tr><th>Journey</th><th>Survives</th>'
"<th class=\"t\">Breaks at step</th><th class=\"t\">Why</th></tr></thead><tbody>"
+ "".join(rows) + "</tbody></table></div>"
]
c = result.get("combined")
if c:
mix = ", ".join(f"{_esc(n)} {pct:g}%" for n, pct in c["mix"].items())
paras = [f"<p>All journeys together ({mix}): "
+ (f"held through {c['max_tested']} users in total.</p>"
if c["onset_users"] is None else
f"survives ~{c['survives_users']} users in total"
+ (f"; breaks first at <b>{_esc(c['culprit_journey'])}</b>."
if c.get("culprit_journey") else ".") + "</p>")]
inter = c.get("interference")
if inter:
paras.append(
f"<p><b>Interference:</b> {_esc(inter['journey'])} broke with "
f"{inter['users_at_break']} of its own users, but held {inter['held_alone']} "
f"alone. It competes with {_esc(', '.join(inter['with']))} for something "
"they share (a database pool, CPU, a cache).</p>")
parts.append('<div class="cause"><div class="lbl">Together</div>'
+ "".join(paras) + "</div>")
parts.append(_expect_html(result.get("expect")))
parts.append("</div>")
return "".join(parts)


def _expect_html(exp: dict | None) -> str:
if not exp:
return ""
answer = {True: "yes", False: "no", None: "unknown"}[exp["meets"]]
items = []
for j in exp.get("journeys") or []:
mark = {True: "holds", False: "doesn't hold", None: "unknown"}[j["meets"]]
items.append(f"<li>{_esc(j['name'])}: ~{j['users']:g} users, holds "
f"~{j['survives_users']} alone ({mark})</li>")
combined = exp.get("combined")
if combined:
items.append(f"<li>all together: holds ~{combined['survives_users']}</li>")
for name in exp.get("untested") or []:
items.append(f"<li>{_esc(name)}: not load-tested (stale or unreachable)</li>")
if not exp.get("journeys"):
items.append(f"<li>survives ~{exp['survives_users']}</li>")
return (f'<div class="cause"><div class="lbl">Expected ~{exp["users"]} users: {answer}'
f"</div><ul>{''.join(items)}</ul></div>")


def render_html(result: dict) -> str:
target = result["target"]
config = result["config"]
Expand Down Expand Up @@ -307,6 +386,7 @@ def render_html(result: dict) -> str:
{_cause(result)}
</div>
{_route_table(result)}
{_journeys_html(result)}
<footer><span>Generated by prescale {result["tool_version"]}</span>
<span>{meta}</span></footer>
</div></body></html>
Expand Down
Loading
Loading