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
36 changes: 32 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Compare performance results between two Locust runs and show changes relative to
- Compare any two runs (base vs. current).
- Parses CSV `report.csv` for aggregated and per-endpoint metrics.
- Parses per-feature `.html` pages and compares the latest history sample.
- Outputs human-readable tables or machine-friendly JSON.
- Outputs human-readable tables, markdown with emoji indicators, or machine-friendly JSON.

## Requirements

Expand Down Expand Up @@ -73,10 +73,16 @@ locust-compare test_runs/HTML-Report-292/report.csv test_runs/HTML-Report-294/re
- JSON output for scripting:

```bash
locust-compare test_runs/HTML-Report-292 test_runs/HTML-Report-294 --json
locust-compare test_runs/HTML-Report-292 test_runs/HTML-Report-294 -o json
```

- Colorize output and show verdicts (green=better, red=worse):
- Markdown output with emoji indicators (✅ better, ❌ worse, ➖ same):

```
python3 compare_runs.py test_runs/HTML-Report-292 test_runs/HTML-Report-294 -o markdown
```

- Colorize text output (green=better, red=worse):

```bash
locust-compare test_runs/HTML-Report-292 test_runs/HTML-Report-294 --color
Expand Down Expand Up @@ -104,9 +110,31 @@ If a metric is not available for an item, it is shown as `-`.
<img width="598" height="255" alt="image" src="https://github.com/user-attachments/assets/f5394045-6d1e-498e-aa3f-624928ec70a7" />


## Markdown Output Example

The `-o markdown` flag produces markdown tables with emoji indicators for verdicts:

```markdown
## Aggregated

| Metric | Base | Current | Diff | % Change | Verdict |
| --- | --- | --- | --- | --- | --- |
| Requests/s | 286.200 | 300 | +13.800 | +4.8% | ✅ |
| Request Count | 1500 | 1800 | +300 | +20.0% | ✅ |
| Failure Count | 7 | 4 | -3 | -42.9% | ✅ |
| Average Response Time | 85.200 | 78.500 | -6.700 | -7.9% | ✅ |
| 95% | 150 | 140 | -10 | -6.7% | ✅ |
```

Verdict emojis:
- ✅ Better performance
- ❌ Worse performance
- ➖ No change


## JSON Schema

The `--json` output is a single JSON object containing keys for each compared item.
The `-o json` output is a single JSON object containing keys for each compared item.

- CSV items use their request name; the aggregated row is keyed as `Aggregated`.
- HTML feature pages are keyed as `HTML:<feature_file_stem>`.
Expand Down
191 changes: 154 additions & 37 deletions compare_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,13 @@ def print_section(title: str):
print("-" * len(title))


def print_section_markdown(title: str, level: int = 2):
"""Print a markdown section header."""
print("")
print("#" * level + " " + title)
print("")


def _metric_direction(metric: str) -> str:
"""Return 'higher', 'lower', or 'neutral' for a metric's desirable direction.

Expand Down Expand Up @@ -365,6 +372,70 @@ def _verdict_for(metric: str, b: Optional[float], c: Optional[float]) -> Optiona
return None


def _verdict_to_emoji(verdict: Optional[str]) -> str:
"""Convert verdict to emoji for markdown output."""
if verdict == "better":
return "✅"
elif verdict == "worse":
return "❌"
elif verdict == "same":
return "➖"
return ""


def render_comparison_markdown(
base_row: Optional[Row],
curr_row: Optional[Row],
important_fields: List[str],
*,
show_verdict: bool = True,
):
"""Render comparison as markdown table with emoji indicators."""
headers = [
"Metric",
"Base",
"Current",
"Diff",
"% Change",
]
if show_verdict:
headers.append("Verdict")
rows: List[List[str]] = []

base_data = base_row.data if base_row else {}
curr_data = curr_row.data if curr_row else {}

fields = important_fields[:]
# Also include any extra percentile columns present in data
extra_fields = [k for k in curr_data.keys() | base_data.keys() if k.endswith("%") and k not in fields]
fields.extend(sorted(extra_fields))

for field in fields:
b = base_data.get(field)
c = curr_data.get(field)
d = diff(b, c)
p = pct_change(b, c)
p_str = "-" if p is None else f"{p:+.1f}%"
row = [
field,
format_number(b),
format_number(c),
("-" if d is None else (f"{d:+.3f}" if abs(d - round(d)) > 1e-9 else f"{int(d):+d}")),
p_str,
]
if show_verdict:
v = _verdict_for(field, b, c)
emoji = _verdict_to_emoji(v)
row.append(emoji)
rows.append(row)

# Print markdown table
print("| " + " | ".join(headers) + " |")
print("| " + " | ".join(["---"] * len(headers)) + " |")
for r in rows:
print("| " + " | ".join(r) + " |")


def render_comparison(
base_row: Optional[Row],
curr_row: Optional[Row],
Expand Down Expand Up @@ -433,7 +504,7 @@ def render_comparison(
def compare_reports(
base_path: Path,
curr_path: Path,
as_json: bool = False,
output_format: str = "text",
*,
colorize: bool = False,
show_verdict: bool = True,
Expand Down Expand Up @@ -465,7 +536,7 @@ def compare_reports(
base_html_map = load_html_feature_map(base_path if base_path.is_dir() else base_path.parent)
curr_html_map = load_html_feature_map(curr_path if curr_path.is_dir() else curr_path.parent)

if as_json:
if output_format == "json":
# Produce a structured JSON dict
out: Dict[str, Dict[str, Dict[str, Optional[float]]]] = {}
for key in all_keys:
Expand Down Expand Up @@ -517,45 +588,85 @@ def compare_reports(
return 0

# Human readable output
print_section("Aggregated")
render_comparison(
base_idx.get("__Aggregated__"),
curr_idx.get("__Aggregated__"),
important_fields,
colorize=colorize,
show_verdict=show_verdict,
)
if output_format == "markdown":
print("# Locust Performance Comparison")
print("")
print_section_markdown("Aggregated", 2)
render_comparison_markdown(
base_idx.get("__Aggregated__"),
curr_idx.get("__Aggregated__"),
important_fields,
show_verdict=show_verdict,
)

endpoint_keys = [k for k in all_keys if k != "__Aggregated__"]
for ek in endpoint_keys:
title = f"Endpoint: {ek}"
print_section(title)
endpoint_keys = [k for k in all_keys if k != "__Aggregated__"]
for ek in endpoint_keys:
title = f"Endpoint: {ek}"
print_section_markdown(title, 3)
render_comparison_markdown(
base_idx.get(ek),
curr_idx.get(ek),
important_fields,
show_verdict=show_verdict,
)

# Render HTML features
feature_keys = sorted(set(base_html_map.keys()) | set(curr_html_map.keys()))
if feature_keys:
print_section_markdown("HTML Features", 2)
for fk in feature_keys:
print_section_markdown(f"Feature: {fk}", 3)
b_map = base_html_map.get(fk, {})
c_map = curr_html_map.get(fk, {})
ep_keys = sorted(set(b_map.keys()) | set(c_map.keys()))
for ep in ep_keys:
print_section_markdown(f"Endpoint: {ep}", 4)
render_comparison_markdown(
b_map.get(ep),
c_map.get(ep),
important_fields,
show_verdict=show_verdict,
)
else:
print_section("Aggregated")
render_comparison(
base_idx.get(ek),
curr_idx.get(ek),
base_idx.get("__Aggregated__"),
curr_idx.get("__Aggregated__"),
important_fields,
colorize=colorize,
show_verdict=show_verdict,
)

# Render HTML features
feature_keys = sorted(set(base_html_map.keys()) | set(curr_html_map.keys()))
if feature_keys:
print_section("HTML Features")
for fk in feature_keys:
print_section(f"Feature: {fk}")
b_map = base_html_map.get(fk, {})
c_map = curr_html_map.get(fk, {})
ep_keys = sorted(set(b_map.keys()) | set(c_map.keys()))
for ep in ep_keys:
print_section(f"Endpoint: {ep}")
render_comparison(
b_map.get(ep),
c_map.get(ep),
important_fields,
colorize=colorize,
show_verdict=show_verdict,
)
endpoint_keys = [k for k in all_keys if k != "__Aggregated__"]
for ek in endpoint_keys:
title = f"Endpoint: {ek}"
print_section(title)
render_comparison(
base_idx.get(ek),
curr_idx.get(ek),
important_fields,
colorize=colorize,
show_verdict=show_verdict,
)

# Render HTML features
feature_keys = sorted(set(base_html_map.keys()) | set(curr_html_map.keys()))
if feature_keys:
print_section("HTML Features")
for fk in feature_keys:
print_section(f"Feature: {fk}")
b_map = base_html_map.get(fk, {})
c_map = curr_html_map.get(fk, {})
ep_keys = sorted(set(b_map.keys()) | set(c_map.keys()))
for ep in ep_keys:
print_section(f"Endpoint: {ep}")
render_comparison(
b_map.get(ep),
c_map.get(ep),
important_fields,
colorize=colorize,
show_verdict=show_verdict,
)

return 0

Expand All @@ -569,11 +680,17 @@ def main():
)
parser.add_argument("base", type=Path, help="Base run directory or report.csv path")
parser.add_argument("current", type=Path, help="Current run directory or report.csv path")
parser.add_argument("--json", action="store_true", help="Output results as JSON")
parser.add_argument(
"-o",
"--output",
choices=["text", "json", "markdown"],
default="text",
help="Output format: text (default), json, or markdown with emoji indicators (✅ better, ❌ worse, ➖ same)",
)
parser.add_argument(
"--color",
action="store_true",
help="Colorize rows: green if better, red if worse",
help="Colorize rows: green if better, red if worse (only for text output)",
)
parser.add_argument(
"--no-verdict",
Expand All @@ -587,7 +704,7 @@ def main():
return compare_reports(
args.base,
args.current,
as_json=args.json,
output_format=args.output,
colorize=args.color,
show_verdict=args.show_verdict,
)
Expand Down
Loading