feat(perf): add --format {text,json,csv} to `headroom perf` (#648)
* feat(perf): add structured summary/record builders to analyzer
parse_log_files() already returns a fully-structured PerfReport, but
the only way to read it was the colored text report. Add reusable
machine-readable views so CI guards, dashboards, and agent harnesses
can consume perf data without scraping ANSI text:
- build_perf_summary(report) -> dict with the aggregated KPIs
(savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring
format_report() numbers exactly.
- perf_records_as_dicts(report) -> per-record list for --raw output.
- PERF_RECORD_FIELDS: shared column order for CSV/raw consumers.
Pure additions; no behaviour change to existing callers. Part of #595.
* feat(perf): add --format {text,json,csv} to headroom perf
Adds a machine-readable output path to the perf command (issue #595):
- --format json: aggregated summary (default) or, with --raw, a JSON
array of per-record dicts.
- --format csv: per-model breakdown (default) or, with --raw, one row
per PERF record using the shared PERF_RECORD_FIELDS column order.
- --format text (default): unchanged human-readable report.
Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent
wrappers to consume perf data without scraping ANSI text.
Closes #595.
* test(perf): cover --format json/csv and structured builders
Unit tests for build_perf_summary (totals, savings/cache pct,
by_model/by_transform, empty-report zero-division guard) and
perf_records_as_dicts, plus CliRunner integration tests for
--format json, json --raw, csv, csv --raw, the unchanged text
default, and rejection of an unknown format. Part of #595.
* fix(perf): rename transform loop var to satisfy mypy
The structured-summary builder reused `recs` for both the per-model
(list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so
mypy flagged the second assignment as an incompatible-type reuse
(analyzer.py:704). Rename the transform loop variable to `t_recs` so each
loop keeps a single element type. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
|
|
|
"""Tests for `headroom perf --format {text,json,csv}` (issue #595)."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import csv
|
|
|
|
|
import io
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from click.testing import CliRunner
|
|
|
|
|
|
|
|
|
|
from headroom.cli.main import main
|
|
|
|
|
from headroom.perf import analyzer
|
|
|
|
|
from headroom.perf.analyzer import (
|
|
|
|
|
PerfRecord,
|
|
|
|
|
PerfReport,
|
|
|
|
|
TransformRecord,
|
2026-07-15 14:04:21 -07:00
|
|
|
build_overhead_summary,
|
feat(perf): add --format {text,json,csv} to `headroom perf` (#648)
* feat(perf): add structured summary/record builders to analyzer
parse_log_files() already returns a fully-structured PerfReport, but
the only way to read it was the colored text report. Add reusable
machine-readable views so CI guards, dashboards, and agent harnesses
can consume perf data without scraping ANSI text:
- build_perf_summary(report) -> dict with the aggregated KPIs
(savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring
format_report() numbers exactly.
- perf_records_as_dicts(report) -> per-record list for --raw output.
- PERF_RECORD_FIELDS: shared column order for CSV/raw consumers.
Pure additions; no behaviour change to existing callers. Part of #595.
* feat(perf): add --format {text,json,csv} to headroom perf
Adds a machine-readable output path to the perf command (issue #595):
- --format json: aggregated summary (default) or, with --raw, a JSON
array of per-record dicts.
- --format csv: per-model breakdown (default) or, with --raw, one row
per PERF record using the shared PERF_RECORD_FIELDS column order.
- --format text (default): unchanged human-readable report.
Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent
wrappers to consume perf data without scraping ANSI text.
Closes #595.
* test(perf): cover --format json/csv and structured builders
Unit tests for build_perf_summary (totals, savings/cache pct,
by_model/by_transform, empty-report zero-division guard) and
perf_records_as_dicts, plus CliRunner integration tests for
--format json, json --raw, csv, csv --raw, the unchanged text
default, and rejection of an unknown format. Part of #595.
* fix(perf): rename transform loop var to satisfy mypy
The structured-summary builder reused `recs` for both the per-model
(list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so
mypy flagged the second assignment as an incompatible-type reuse
(analyzer.py:704). Rename the transform loop variable to `t_recs` so each
loop keeps a single element type. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
|
|
|
build_perf_summary,
|
|
|
|
|
perf_records_as_dicts,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def runner() -> CliRunner:
|
|
|
|
|
return CliRunner()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sample_report() -> PerfReport:
|
|
|
|
|
"""A small report with two models, cache numbers, and a transform."""
|
|
|
|
|
return PerfReport(
|
|
|
|
|
perf_records=[
|
|
|
|
|
PerfRecord(
|
|
|
|
|
timestamp="2026-06-05 10:00:00,000",
|
|
|
|
|
request_id="hr_1",
|
|
|
|
|
model="claude-sonnet-4.5",
|
|
|
|
|
num_messages=10,
|
|
|
|
|
tokens_before=1000,
|
|
|
|
|
tokens_after=400,
|
|
|
|
|
tokens_saved=600,
|
|
|
|
|
cache_read=800,
|
|
|
|
|
cache_write=200,
|
|
|
|
|
cache_hit_pct=80,
|
|
|
|
|
optimization_ms=12.0,
|
|
|
|
|
transforms=["content_router"],
|
|
|
|
|
),
|
|
|
|
|
PerfRecord(
|
|
|
|
|
timestamp="2026-06-05 11:00:00,000",
|
|
|
|
|
request_id="hr_2",
|
|
|
|
|
model="claude-opus-4-8",
|
|
|
|
|
num_messages=4,
|
|
|
|
|
tokens_before=1000,
|
|
|
|
|
tokens_after=600,
|
|
|
|
|
tokens_saved=400,
|
|
|
|
|
cache_read=200,
|
|
|
|
|
cache_write=0,
|
|
|
|
|
cache_hit_pct=100,
|
|
|
|
|
optimization_ms=8.0,
|
|
|
|
|
transforms=["content_router"],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
transform_records=[
|
|
|
|
|
TransformRecord(
|
|
|
|
|
timestamp="2026-06-05 10:00:00,000",
|
|
|
|
|
name="content_router",
|
|
|
|
|
tokens_before=2000,
|
|
|
|
|
tokens_after=1000,
|
|
|
|
|
tokens_saved=1000,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
log_files_read=1,
|
|
|
|
|
total_lines_parsed=42,
|
|
|
|
|
requested_hours=24.0,
|
|
|
|
|
oldest_kept_ts="2026-06-05 10:00:00,000",
|
|
|
|
|
newest_kept_ts="2026-06-05 11:00:00,000",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Pure builders
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_perf_summary_totals_and_pct():
|
|
|
|
|
summary = build_perf_summary(_sample_report())
|
|
|
|
|
|
|
|
|
|
assert summary["total_requests"] == 2
|
|
|
|
|
assert summary["total_tokens_before"] == 2000
|
|
|
|
|
assert summary["total_tokens_after"] == 1000
|
|
|
|
|
assert summary["tokens_saved"] == 1000
|
|
|
|
|
# 1000 / 2000 == 50.0%
|
|
|
|
|
assert summary["savings_pct"] == 50.0
|
|
|
|
|
# cache: read 1000, write 200 -> 1000 / 1200 == 83.3%
|
|
|
|
|
assert summary["cache_read_tokens"] == 1000
|
|
|
|
|
assert summary["cache_write_tokens"] == 200
|
|
|
|
|
assert summary["cache_hit_pct"] == 83.3
|
|
|
|
|
assert summary["window_hours"] == 24.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_perf_summary_by_model_and_transform():
|
|
|
|
|
summary = build_perf_summary(_sample_report())
|
|
|
|
|
|
|
|
|
|
models = {m["model"]: m for m in summary["by_model"]}
|
|
|
|
|
assert set(models) == {"claude-sonnet-4.5", "claude-opus-4-8"}
|
|
|
|
|
assert models["claude-sonnet-4.5"]["tokens_saved"] == 600
|
|
|
|
|
assert models["claude-sonnet-4.5"]["savings_pct"] == 60.0
|
|
|
|
|
assert models["claude-opus-4-8"]["savings_pct"] == 40.0
|
|
|
|
|
|
|
|
|
|
assert summary["by_transform"][0]["transform"] == "content_router"
|
|
|
|
|
assert summary["by_transform"][0]["tokens_saved"] == 1000
|
|
|
|
|
assert summary["by_transform"][0]["uses"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_perf_summary_empty_report_no_zero_division():
|
|
|
|
|
summary = build_perf_summary(PerfReport(requested_hours=168.0))
|
|
|
|
|
assert summary["total_requests"] == 0
|
|
|
|
|
assert summary["savings_pct"] == 0.0
|
|
|
|
|
assert summary["cache_hit_pct"] == 0.0
|
|
|
|
|
assert summary["by_model"] == []
|
2026-07-15 14:04:21 -07:00
|
|
|
assert summary["overhead"]["optimization_ms"]["count"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_overhead_summary_attributes_slow_stages():
|
|
|
|
|
report = PerfReport(
|
|
|
|
|
perf_records=[
|
|
|
|
|
PerfRecord(
|
|
|
|
|
timestamp="2026-06-05 10:00:00,000",
|
|
|
|
|
request_id="fast",
|
|
|
|
|
model="gpt-5",
|
|
|
|
|
tokens_before=1000,
|
|
|
|
|
tokens_after=500,
|
|
|
|
|
tokens_saved=500,
|
|
|
|
|
optimization_ms=100.0,
|
|
|
|
|
total_ms=300.0,
|
|
|
|
|
stages={"cache_align": 10.0, "content_router": 90.0},
|
|
|
|
|
),
|
|
|
|
|
PerfRecord(
|
|
|
|
|
timestamp="2026-06-05 10:01:00,000",
|
|
|
|
|
request_id="slow",
|
|
|
|
|
model="gpt-5",
|
|
|
|
|
tokens_before=1000,
|
|
|
|
|
tokens_after=500,
|
|
|
|
|
tokens_saved=500,
|
|
|
|
|
optimization_ms=700.0,
|
|
|
|
|
total_ms=900.0,
|
|
|
|
|
stages={"kompress": 650.0, "content_router": 40.0},
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
overhead = build_overhead_summary(report, slow_threshold_ms=500.0)
|
|
|
|
|
|
|
|
|
|
assert overhead["optimization_ms"]["count"] == 2
|
|
|
|
|
assert overhead["optimization_ms"]["average_ms"] == 400.0
|
|
|
|
|
assert overhead["optimization_ms"]["p50_ms"] == 400.0
|
|
|
|
|
assert overhead["optimization_ms"]["p95_ms"] == 670.0
|
|
|
|
|
assert overhead["optimization_ms"]["p99_ms"] == 694.0
|
|
|
|
|
assert overhead["optimization_ms"]["slow_request_count"] == 1
|
|
|
|
|
assert overhead["stage_breakdown"][0]["stage"] == "kompress"
|
|
|
|
|
assert overhead["stage_breakdown"][0]["total_ms"] == 650.0
|
|
|
|
|
assert overhead["top_slow_requests"][0]["request_id"] == "slow"
|
|
|
|
|
assert overhead["top_slow_requests"][0]["slowest_stage"] == "kompress"
|
feat(perf): add --format {text,json,csv} to `headroom perf` (#648)
* feat(perf): add structured summary/record builders to analyzer
parse_log_files() already returns a fully-structured PerfReport, but
the only way to read it was the colored text report. Add reusable
machine-readable views so CI guards, dashboards, and agent harnesses
can consume perf data without scraping ANSI text:
- build_perf_summary(report) -> dict with the aggregated KPIs
(savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring
format_report() numbers exactly.
- perf_records_as_dicts(report) -> per-record list for --raw output.
- PERF_RECORD_FIELDS: shared column order for CSV/raw consumers.
Pure additions; no behaviour change to existing callers. Part of #595.
* feat(perf): add --format {text,json,csv} to headroom perf
Adds a machine-readable output path to the perf command (issue #595):
- --format json: aggregated summary (default) or, with --raw, a JSON
array of per-record dicts.
- --format csv: per-model breakdown (default) or, with --raw, one row
per PERF record using the shared PERF_RECORD_FIELDS column order.
- --format text (default): unchanged human-readable report.
Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent
wrappers to consume perf data without scraping ANSI text.
Closes #595.
* test(perf): cover --format json/csv and structured builders
Unit tests for build_perf_summary (totals, savings/cache pct,
by_model/by_transform, empty-report zero-division guard) and
perf_records_as_dicts, plus CliRunner integration tests for
--format json, json --raw, csv, csv --raw, the unchanged text
default, and rejection of an unknown format. Part of #595.
* fix(perf): rename transform loop var to satisfy mypy
The structured-summary builder reused `recs` for both the per-model
(list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so
mypy flagged the second assignment as an incompatible-type reuse
(analyzer.py:704). Rename the transform loop variable to `t_recs` so each
loop keeps a single element type. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_perf_records_as_dicts_roundtrips_fields():
|
|
|
|
|
dicts = perf_records_as_dicts(_sample_report())
|
|
|
|
|
assert len(dicts) == 2
|
|
|
|
|
assert dicts[0]["request_id"] == "hr_1"
|
|
|
|
|
assert dicts[0]["tokens_saved"] == 600
|
|
|
|
|
# transforms stays a list for JSON consumers
|
|
|
|
|
assert dicts[0]["transforms"] == ["content_router"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# CLI integration
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _patch_report(monkeypatch, report: PerfReport) -> None:
|
|
|
|
|
monkeypatch.setattr(analyzer, "parse_log_files", lambda last_n_hours=168.0: report)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_perf_json_format(runner, monkeypatch):
|
|
|
|
|
_patch_report(monkeypatch, _sample_report())
|
|
|
|
|
result = runner.invoke(main, ["perf", "--format", "json"])
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
data = json.loads(result.output)
|
|
|
|
|
assert data["savings_pct"] == 50.0
|
|
|
|
|
assert "by_model" in data
|
|
|
|
|
assert data["total_requests"] == 2
|
2026-07-15 14:04:21 -07:00
|
|
|
assert data["overhead"]["optimization_ms"]["p95_ms"] == 11.8
|
feat(perf): add --format {text,json,csv} to `headroom perf` (#648)
* feat(perf): add structured summary/record builders to analyzer
parse_log_files() already returns a fully-structured PerfReport, but
the only way to read it was the colored text report. Add reusable
machine-readable views so CI guards, dashboards, and agent harnesses
can consume perf data without scraping ANSI text:
- build_perf_summary(report) -> dict with the aggregated KPIs
(savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring
format_report() numbers exactly.
- perf_records_as_dicts(report) -> per-record list for --raw output.
- PERF_RECORD_FIELDS: shared column order for CSV/raw consumers.
Pure additions; no behaviour change to existing callers. Part of #595.
* feat(perf): add --format {text,json,csv} to headroom perf
Adds a machine-readable output path to the perf command (issue #595):
- --format json: aggregated summary (default) or, with --raw, a JSON
array of per-record dicts.
- --format csv: per-model breakdown (default) or, with --raw, one row
per PERF record using the shared PERF_RECORD_FIELDS column order.
- --format text (default): unchanged human-readable report.
Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent
wrappers to consume perf data without scraping ANSI text.
Closes #595.
* test(perf): cover --format json/csv and structured builders
Unit tests for build_perf_summary (totals, savings/cache pct,
by_model/by_transform, empty-report zero-division guard) and
perf_records_as_dicts, plus CliRunner integration tests for
--format json, json --raw, csv, csv --raw, the unchanged text
default, and rejection of an unknown format. Part of #595.
* fix(perf): rename transform loop var to satisfy mypy
The structured-summary builder reused `recs` for both the per-model
(list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so
mypy flagged the second assignment as an incompatible-type reuse
(analyzer.py:704). Rename the transform loop variable to `t_recs` so each
loop keeps a single element type. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_perf_json_raw_is_array(runner, monkeypatch):
|
|
|
|
|
_patch_report(monkeypatch, _sample_report())
|
|
|
|
|
result = runner.invoke(main, ["perf", "--format", "json", "--raw"])
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
data = json.loads(result.output)
|
|
|
|
|
assert isinstance(data, list)
|
|
|
|
|
assert len(data) == 2
|
|
|
|
|
assert data[0]["request_id"] == "hr_1"
|
|
|
|
|
|
|
|
|
|
|
feat: add dashboard agent usage stats (#814)
## Description
Add a clear dashboard view for per-agent token usage so end users can
see Cursor, Claude, Codex, and other detected clients with before/after
token counts, tokens saved, and savings percentages. The stats API now
exposes a stable `agent_usage` object that the dashboard renders near
the top of the session view.
Fixes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
### New Files
**Tests:**
- `tests/test_dashboard_agent_usage.py` — Covers agent classification,
exact per-request aggregation, and aggregate fallback behavior.
### Modified Files
- `headroom/proxy/server.py` — Adds per-agent usage aggregation to
`/stats` with before tokens, after tokens, output tokens, saved tokens,
savings percentage, source, providers, and models.
- `headroom/dashboard/templates/dashboard.html` — Adds a prominent Agent
Usage panel with totals, coverage status, per-agent token-flow bars,
request counts, before/after tokens, saved tokens, and share of savings.
## Testing
- [x] Unit tests pass: `.venv312/bin/pytest
tests/test_dashboard_agent_usage.py`
- [x] Linting passes: `.venv312/bin/ruff check headroom/proxy/server.py
tests/test_dashboard_agent_usage.py`
- [x] Diff whitespace check passes: `git diff --check
origin/main...HEAD`
- [x] Dashboard smoke render: local proxy on `127.0.0.1:8790`, captured
Chrome headless screenshot of `/dashboard`
- [x] New tests added for new functionality
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing relevant unit tests pass locally with my changes
- [ ] I have made corresponding changes to the documentation
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
The agent usage panel uses exact request-log data when available. If
detailed request logs are empty, it falls back to aggregate
provider/model request counts and labels the coverage as aggregate
fallback so users are not misled.
2026-06-12 22:12:22 +03:00
|
|
|
def test_perf_json_raw_preserves_client_field(runner, monkeypatch):
|
|
|
|
|
report = _sample_report()
|
|
|
|
|
report.perf_records[0].client = "codex"
|
|
|
|
|
_patch_report(monkeypatch, report)
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(main, ["perf", "--format", "json", "--raw"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
data = json.loads(result.output)
|
|
|
|
|
assert data[0]["client"] == "codex"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_perf_line_preserves_client_field(monkeypatch, tmp_path):
|
|
|
|
|
log_dir = tmp_path / "logs"
|
|
|
|
|
log_dir.mkdir()
|
|
|
|
|
(log_dir / "proxy.log").write_text(
|
|
|
|
|
"2026-06-10 10:00:00,000 - headroom.proxy - INFO - "
|
|
|
|
|
"[hr_codex] PERF model=gpt-5 msgs=3 tok_before=1000 "
|
|
|
|
|
"tok_after=90 tok_saved=910 cache_read=0 cache_write=0 "
|
|
|
|
|
"cache_hit_pct=0 opt_ms=12 transforms=content_router client=codex\n"
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(analyzer, "LOG_DIR", log_dir)
|
|
|
|
|
|
|
|
|
|
report = analyzer.parse_log_files(last_n_hours=0)
|
|
|
|
|
|
|
|
|
|
assert len(report.perf_records) == 1
|
|
|
|
|
assert report.perf_records[0].client == "codex"
|
|
|
|
|
|
|
|
|
|
|
feat(perf): add --format {text,json,csv} to `headroom perf` (#648)
* feat(perf): add structured summary/record builders to analyzer
parse_log_files() already returns a fully-structured PerfReport, but
the only way to read it was the colored text report. Add reusable
machine-readable views so CI guards, dashboards, and agent harnesses
can consume perf data without scraping ANSI text:
- build_perf_summary(report) -> dict with the aggregated KPIs
(savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring
format_report() numbers exactly.
- perf_records_as_dicts(report) -> per-record list for --raw output.
- PERF_RECORD_FIELDS: shared column order for CSV/raw consumers.
Pure additions; no behaviour change to existing callers. Part of #595.
* feat(perf): add --format {text,json,csv} to headroom perf
Adds a machine-readable output path to the perf command (issue #595):
- --format json: aggregated summary (default) or, with --raw, a JSON
array of per-record dicts.
- --format csv: per-model breakdown (default) or, with --raw, one row
per PERF record using the shared PERF_RECORD_FIELDS column order.
- --format text (default): unchanged human-readable report.
Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent
wrappers to consume perf data without scraping ANSI text.
Closes #595.
* test(perf): cover --format json/csv and structured builders
Unit tests for build_perf_summary (totals, savings/cache pct,
by_model/by_transform, empty-report zero-division guard) and
perf_records_as_dicts, plus CliRunner integration tests for
--format json, json --raw, csv, csv --raw, the unchanged text
default, and rejection of an unknown format. Part of #595.
* fix(perf): rename transform loop var to satisfy mypy
The structured-summary builder reused `recs` for both the per-model
(list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so
mypy flagged the second assignment as an incompatible-type reuse
(analyzer.py:704). Rename the transform loop variable to `t_recs` so each
loop keeps a single element type. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
|
|
|
def test_perf_csv_by_model(runner, monkeypatch):
|
|
|
|
|
_patch_report(monkeypatch, _sample_report())
|
|
|
|
|
result = runner.invoke(main, ["perf", "--format", "csv"])
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
rows = list(csv.DictReader(io.StringIO(result.output)))
|
|
|
|
|
assert {r["model"] for r in rows} == {"claude-sonnet-4.5", "claude-opus-4-8"}
|
|
|
|
|
sonnet = next(r for r in rows if r["model"] == "claude-sonnet-4.5")
|
|
|
|
|
assert sonnet["tokens_saved"] == "600"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_perf_csv_raw_per_record(runner, monkeypatch):
|
2026-06-12 02:58:06 +03:00
|
|
|
report = _sample_report()
|
|
|
|
|
report.perf_records[0].client = "codex"
|
|
|
|
|
_patch_report(monkeypatch, report)
|
feat(perf): add --format {text,json,csv} to `headroom perf` (#648)
* feat(perf): add structured summary/record builders to analyzer
parse_log_files() already returns a fully-structured PerfReport, but
the only way to read it was the colored text report. Add reusable
machine-readable views so CI guards, dashboards, and agent harnesses
can consume perf data without scraping ANSI text:
- build_perf_summary(report) -> dict with the aggregated KPIs
(savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring
format_report() numbers exactly.
- perf_records_as_dicts(report) -> per-record list for --raw output.
- PERF_RECORD_FIELDS: shared column order for CSV/raw consumers.
Pure additions; no behaviour change to existing callers. Part of #595.
* feat(perf): add --format {text,json,csv} to headroom perf
Adds a machine-readable output path to the perf command (issue #595):
- --format json: aggregated summary (default) or, with --raw, a JSON
array of per-record dicts.
- --format csv: per-model breakdown (default) or, with --raw, one row
per PERF record using the shared PERF_RECORD_FIELDS column order.
- --format text (default): unchanged human-readable report.
Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent
wrappers to consume perf data without scraping ANSI text.
Closes #595.
* test(perf): cover --format json/csv and structured builders
Unit tests for build_perf_summary (totals, savings/cache pct,
by_model/by_transform, empty-report zero-division guard) and
perf_records_as_dicts, plus CliRunner integration tests for
--format json, json --raw, csv, csv --raw, the unchanged text
default, and rejection of an unknown format. Part of #595.
* fix(perf): rename transform loop var to satisfy mypy
The structured-summary builder reused `recs` for both the per-model
(list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so
mypy flagged the second assignment as an incompatible-type reuse
(analyzer.py:704). Rename the transform loop variable to `t_recs` so each
loop keeps a single element type. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
|
|
|
result = runner.invoke(main, ["perf", "--format", "csv", "--raw"])
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
rows = list(csv.DictReader(io.StringIO(result.output)))
|
|
|
|
|
assert len(rows) == 2
|
|
|
|
|
assert rows[0]["request_id"] == "hr_1"
|
2026-06-12 02:58:06 +03:00
|
|
|
assert rows[0]["client"] == "codex"
|
feat(perf): add --format {text,json,csv} to `headroom perf` (#648)
* feat(perf): add structured summary/record builders to analyzer
parse_log_files() already returns a fully-structured PerfReport, but
the only way to read it was the colored text report. Add reusable
machine-readable views so CI guards, dashboards, and agent harnesses
can consume perf data without scraping ANSI text:
- build_perf_summary(report) -> dict with the aggregated KPIs
(savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring
format_report() numbers exactly.
- perf_records_as_dicts(report) -> per-record list for --raw output.
- PERF_RECORD_FIELDS: shared column order for CSV/raw consumers.
Pure additions; no behaviour change to existing callers. Part of #595.
* feat(perf): add --format {text,json,csv} to headroom perf
Adds a machine-readable output path to the perf command (issue #595):
- --format json: aggregated summary (default) or, with --raw, a JSON
array of per-record dicts.
- --format csv: per-model breakdown (default) or, with --raw, one row
per PERF record using the shared PERF_RECORD_FIELDS column order.
- --format text (default): unchanged human-readable report.
Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent
wrappers to consume perf data without scraping ANSI text.
Closes #595.
* test(perf): cover --format json/csv and structured builders
Unit tests for build_perf_summary (totals, savings/cache pct,
by_model/by_transform, empty-report zero-division guard) and
perf_records_as_dicts, plus CliRunner integration tests for
--format json, json --raw, csv, csv --raw, the unchanged text
default, and rejection of an unknown format. Part of #595.
* fix(perf): rename transform loop var to satisfy mypy
The structured-summary builder reused `recs` for both the per-model
(list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so
mypy flagged the second assignment as an incompatible-type reuse
(analyzer.py:704). Rename the transform loop variable to `t_recs` so each
loop keeps a single element type. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
|
|
|
# transforms flattened to a string cell
|
|
|
|
|
assert rows[0]["transforms"] == "content_router"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_perf_text_default_unchanged(runner, monkeypatch):
|
|
|
|
|
_patch_report(monkeypatch, _sample_report())
|
|
|
|
|
result = runner.invoke(main, ["perf"])
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert "Headroom Performance Report" in result.output
|
2026-07-15 14:04:21 -07:00
|
|
|
assert "p50/p95/p99" in result.output
|
feat(perf): add --format {text,json,csv} to `headroom perf` (#648)
* feat(perf): add structured summary/record builders to analyzer
parse_log_files() already returns a fully-structured PerfReport, but
the only way to read it was the colored text report. Add reusable
machine-readable views so CI guards, dashboards, and agent harnesses
can consume perf data without scraping ANSI text:
- build_perf_summary(report) -> dict with the aggregated KPIs
(savings_pct, cache_hit_pct, by_model, by_transform, ...), mirroring
format_report() numbers exactly.
- perf_records_as_dicts(report) -> per-record list for --raw output.
- PERF_RECORD_FIELDS: shared column order for CSV/raw consumers.
Pure additions; no behaviour change to existing callers. Part of #595.
* feat(perf): add --format {text,json,csv} to headroom perf
Adds a machine-readable output path to the perf command (issue #595):
- --format json: aggregated summary (default) or, with --raw, a JSON
array of per-record dicts.
- --format csv: per-model breakdown (default) or, with --raw, one row
per PERF record using the shared PERF_RECORD_FIELDS column order.
- --format text (default): unchanged human-readable report.
Enables CI guards (jq '.savings_pct < 70'), dashboards, and agent
wrappers to consume perf data without scraping ANSI text.
Closes #595.
* test(perf): cover --format json/csv and structured builders
Unit tests for build_perf_summary (totals, savings/cache pct,
by_model/by_transform, empty-report zero-division guard) and
perf_records_as_dicts, plus CliRunner integration tests for
--format json, json --raw, csv, csv --raw, the unchanged text
default, and rejection of an unknown format. Part of #595.
* fix(perf): rename transform loop var to satisfy mypy
The structured-summary builder reused `recs` for both the per-model
(list[PerfRecord]) and per-transform (list[TransformRecord]) groupings, so
mypy flagged the second assignment as an incompatible-type reuse
(analyzer.py:704). Rename the transform loop variable to `t_recs` so each
loop keeps a single element type. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Kumario1 <ramsakal.ipec@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 00:43:10 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_perf_rejects_unknown_format(runner, monkeypatch):
|
|
|
|
|
_patch_report(monkeypatch, _sample_report())
|
|
|
|
|
result = runner.invoke(main, ["perf", "--format", "xml"])
|
|
|
|
|
assert result.exit_code != 0
|
feat: add dashboard agent usage stats (#814)
## Description
Add a clear dashboard view for per-agent token usage so end users can
see Cursor, Claude, Codex, and other detected clients with before/after
token counts, tokens saved, and savings percentages. The stats API now
exposes a stable `agent_usage` object that the dashboard renders near
the top of the session view.
Fixes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
### New Files
**Tests:**
- `tests/test_dashboard_agent_usage.py` — Covers agent classification,
exact per-request aggregation, and aggregate fallback behavior.
### Modified Files
- `headroom/proxy/server.py` — Adds per-agent usage aggregation to
`/stats` with before tokens, after tokens, output tokens, saved tokens,
savings percentage, source, providers, and models.
- `headroom/dashboard/templates/dashboard.html` — Adds a prominent Agent
Usage panel with totals, coverage status, per-agent token-flow bars,
request counts, before/after tokens, saved tokens, and share of savings.
## Testing
- [x] Unit tests pass: `.venv312/bin/pytest
tests/test_dashboard_agent_usage.py`
- [x] Linting passes: `.venv312/bin/ruff check headroom/proxy/server.py
tests/test_dashboard_agent_usage.py`
- [x] Diff whitespace check passes: `git diff --check
origin/main...HEAD`
- [x] Dashboard smoke render: local proxy on `127.0.0.1:8790`, captured
Chrome headless screenshot of `/dashboard`
- [x] New tests added for new functionality
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing relevant unit tests pass locally with my changes
- [ ] I have made corresponding changes to the documentation
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
The agent usage panel uses exact request-log data when available. If
detailed request logs are empty, it falls back to aggregate
provider/model request counts and labels the coverage as aggregate
fallback so users are not misled.
2026-06-12 22:12:22 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_parse_perf_line_preserves_blank_client_field(
|
|
|
|
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
logs_dir = tmp_path / "logs"
|
|
|
|
|
logs_dir.mkdir()
|
|
|
|
|
monkeypatch.setattr(analyzer, "LOG_DIR", logs_dir)
|
|
|
|
|
(logs_dir / "proxy.log").write_text(
|
|
|
|
|
"2026-06-10 10:00:00,000 - headroom.proxy - INFO - [req-blank] PERF "
|
|
|
|
|
"model=gpt-5 msgs=1 tok_before=100 tok_after=50 tok_saved=50 "
|
|
|
|
|
"cache_read=0 cache_write=0 cache_hit_pct=0 opt_ms=1 transforms=test client=\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
report = analyzer.parse_log_files(last_n_hours=0)
|
|
|
|
|
|
|
|
|
|
assert len(report.perf_records) == 1
|
|
|
|
|
assert report.perf_records[0].client == ""
|
feat: measure and surface token throughput (tokens/sec) through the proxy (#983)
## Description
This PR implements measuring and surfacing token throughput
(tokens/second) through the proxy in the `headroom perf` CLI/analyzer
and the dashboard UI. It tracks multiple throughput metrics—Input
(wall-clock/active), Compression, Forward, and Generation
throughput—supporting both rolling percentiles (p50/p95) and current
(last 5 minutes) metrics.
Closes #959
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [x] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- **Proxy Instrumentation (`headroom/proxy/outcome.py`)**: Added
`total_ms`, `tok_out`, and `ttfb_ms` to the structured `PERF` logging
payload.
- **Log Parsing & Computations (`headroom/perf/analyzer.py`)**: Updated
log parsing to read `STAGE_TIMINGS` and correlation fields from `PERF`,
computing active/wall-clock throughputs for input, compression, forward,
and generation stages.
- **API Exposing (`headroom/proxy/server.py`)**: Exposes calculated
rolling throughput percentiles and last-5-minute averages under the
`throughput` field in `/stats`.
- **Dashboard UI Layout
(`headroom/dashboard/templates/dashboard.html`)**: Refactored the
dashboard grid layout from 3 columns to 4 columns to house the new
throughput hero card showing real-time token performance.
- **Verification Tests (`tests/test_cli_perf_format.py`)**: Added test
coverage specifically targeting token throughput log parser extraction,
stage correlation, math correctness, and edge-case handling (empty
fields, division by zero).
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$env:PYTHONPATH="c:\Users\hp\Desktop\Headroom_oss"; .venv\Scripts\pytest tests/test_cli_perf_format.py
============================= test session starts =============================
platform win32 -- Python 3.11.15, pytest-9.1.0, pluggy-1.6.0 -- C:\Users\hp\Desktop\Headroom_oss\.venv\Scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\hp\Desktop\Headroom_oss
configfile: pyproject.toml
plugins: anyio-4.13.0
collecting ... collected 14 items
tests/test_cli_perf_format.py::test_build_perf_summary_totals_and_pct PASSED [ 7%]
tests/test_cli_perf_format.py::test_build_perf_summary_by_model_and_transform PASSED [ 14%]
tests/test_cli_perf_format.py::test_build_perf_summary_empty_report_no_zero_division PASSED [ 21%]
tests/test_cli_perf_format.py::test_perf_records_as_dicts_roundtrips_fields PASSED [ 28%]
tests/test_cli_perf_format.py::test_perf_json_format PASSED [ 35%]
tests/test_cli_perf_format.py::test_perf_json_raw_is_array PASSED [ 42%]
tests/test_cli_perf_format.py::test_perf_json_raw_preserves_client_field PASSED [ 50%]
tests/test_cli_perf_format.py::test_parse_perf_line_preserves_client_field PASSED [ 57%]
tests/test_cli_perf_format.py::test_perf_csv_by_model PASSED [ 64%]
tests/test_cli_perf_format.py::test_perf_csv_raw_per_record PASSED [ 71%]
tests/test_cli_perf_format.py::test_perf_text_default_unchanged PASSED [ 78%]
tests/test_cli_perf_format.py::test_perf_rejects_unknown_format PASSED [ 85%]
tests/test_cli_perf_format.py::test_parse_perf_line_preserves_blank_client_field PASSED [ 92%]
tests/test_cli_perf_format.py::test_throughput_parsing_and_calculations PASSED [100%]
============================== warnings summary ===============================
.venv\Lib\site-packages\_pytest\config\__init__.py:1464
C:\Users\hp\Desktop\Headroom_oss\.venv\Lib\site-packages\_pytest\config\__init__.py:1464: PytestConfigWarning: Unknown config option: asyncio_mode
self._warn_or_fail_if_strict(f"Unknown config option: {key}\n")
.venv\Lib\site-packages\opentelemetry\util\_importlib_metadata.py:32
C:\Users\hp\Desktop\Headroom_oss\.venv\Lib\site-packages\opentelemetry\util\_importlib_metadata.py:32: DeprecationWarning: SelectableGroups dict interface is deprecated. Use select.
return EntryPoints(ep for group_eps in eps.values() for ep in group_eps)
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================= 14 passed, 2 warnings in 4.77s ========================
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.11.15
- Exact command / steps: Run the pytest suite against the newly created
token throughput parsing routines:
`$env:PYTHONPATH="c:\Users\hp\Desktop\Headroom_oss";
.venv\Scripts\pytest tests/test_cli_perf_format.py`
- Observed result: The suite executes 14 tests successfully, including
the newly added `test_throughput_parsing_and_calculations` verification
test verifying mathematical precision and fallback logic.
- Not tested: None (all metrics are fully covered by unit tests in
`test_cli_perf_format.py`)
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- Backwards compatibility: Older log outputs lacking `tok_out` or
`ttfb_ms` parse cleanly and fallback defaults prevent parser crashes.
---------
Co-authored-by: Antigravity Agent <agent@antigravity.local>
2026-06-17 20:12:38 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_throughput_parsing_and_calculations(monkeypatch, tmp_path):
|
|
|
|
|
logs_dir = tmp_path / "logs"
|
|
|
|
|
logs_dir.mkdir()
|
|
|
|
|
monkeypatch.setattr(analyzer, "LOG_DIR", logs_dir)
|
|
|
|
|
|
|
|
|
|
log_content = (
|
|
|
|
|
'2026-06-10 10:00:00,000 - headroom.proxy - INFO - [req1] STAGE_TIMINGS {"event": "stage_timings", "stages": {"compression_first_stage": 100.0, "upstream_connect": 50.0}}\n'
|
|
|
|
|
"2026-06-10 10:00:01,000 - headroom.proxy - INFO - [req1] PERF model=gpt-5 msgs=1 tok_before=1000 tok_after=400 tok_saved=600 opt_ms=10 total_ms=500 tok_out=500 ttfb_ms=100 transforms=test client=codex\n"
|
|
|
|
|
'2026-06-10 10:00:02,000 - headroom.proxy - INFO - [req2] STAGE_TIMINGS {"event": "stage_timings", "stages": {"compression": 200.0, "upstream_connect": 50.0}}\n'
|
|
|
|
|
"2026-06-10 10:00:03,000 - headroom.proxy - INFO - [req2] PERF model=gpt-5 msgs=1 tok_before=2000 tok_after=1000 tok_saved=1000 opt_ms=20 total_ms=1000 tok_out=1000 ttfb_ms=200 transforms=test client=codex\n"
|
|
|
|
|
"2026-06-10 10:00:05,000 - headroom.proxy - INFO - [req3] PERF model=gpt-5 msgs=1 tok_before=1500 tok_after=500 tok_saved=1000 opt_ms=15 total_ms=600 tok_out=600 ttfb_ms=150 transforms=test client=codex\n"
|
|
|
|
|
'2026-06-10 10:00:06,000 - headroom.proxy - INFO - [req4] STAGE_TIMINGS {"event": "stage_timings", "stages": {"compression_first_stage": 150.0, "upstream_connect": 50.0}}\n'
|
|
|
|
|
"2026-06-10 10:00:07,000 - headroom.proxy - INFO - [req4] PERF model=gpt-5 msgs=1 tok_before=1200 tok_after=300 tok_saved=900 opt_ms=12 total_ms=400 tok_out=400 ttfb_ms=80 transforms=test client=codex\n"
|
|
|
|
|
'2026-06-10 10:00:08,000 - headroom.proxy - INFO - [req5] STAGE_TIMINGS {"event": "stage_timings", "stages": {"compression_first_stage": 50.0, "upstream_connect": 50.0}}\n'
|
|
|
|
|
"2026-06-10 10:00:09,000 - headroom.proxy - INFO - [req5] PERF model=gpt-5 msgs=1 tok_before=800 tok_after=200 tok_saved=600 opt_ms=5 total_ms=300 tok_out=300 ttfb_ms=50 transforms=test client=codex\n"
|
|
|
|
|
)
|
|
|
|
|
(logs_dir / "proxy.log").write_text(log_content, encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
report = analyzer.parse_log_files(last_n_hours=0)
|
|
|
|
|
|
|
|
|
|
assert len(report.perf_records) == 5
|
|
|
|
|
|
|
|
|
|
assert report.perf_records[0].request_id == "req1"
|
|
|
|
|
assert report.perf_records[0].total_ms == 500.0
|
|
|
|
|
assert report.perf_records[0].tokens_out == 500
|
|
|
|
|
assert report.perf_records[0].ttfb_ms == 100.0
|
|
|
|
|
assert report.perf_records[0].stages == {
|
|
|
|
|
"compression_first_stage": 100.0,
|
|
|
|
|
"upstream_connect": 50.0,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assert report.perf_records[2].request_id == "req3"
|
|
|
|
|
assert report.perf_records[2].stages == {}
|
|
|
|
|
|
|
|
|
|
summary = build_perf_summary(report)
|
|
|
|
|
assert "throughput" in summary
|
|
|
|
|
tp = summary["throughput"]
|
|
|
|
|
|
|
|
|
|
rolling = tp["rolling"]
|
|
|
|
|
assert rolling["input_wall_clock"] > 0
|
|
|
|
|
assert rolling["input_active_p50"] == 2500.0
|
|
|
|
|
assert rolling["compression_p50"] == 10000.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_throughput_empty_and_percentiles():
|
|
|
|
|
from headroom.perf.analyzer import (
|
|
|
|
|
PerfReport,
|
|
|
|
|
_calculate_throughput_stats,
|
|
|
|
|
_percentile,
|
|
|
|
|
calculate_throughput,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Empty percentiles
|
|
|
|
|
assert _percentile([], 0.5) == 0.0
|
|
|
|
|
|
|
|
|
|
# Percentiles boundary checks
|
|
|
|
|
assert _percentile([10.0], 0.5) == 10.0
|
|
|
|
|
assert _percentile([10.0, 20.0], 0.5) == 15.0
|
|
|
|
|
assert _percentile([10.0, 20.0], 0.0) == 10.0
|
|
|
|
|
assert _percentile([10.0, 20.0], 1.0) == 20.0
|
|
|
|
|
assert _percentile([10.0, 20.0], 1.5) == 20.0
|
|
|
|
|
|
|
|
|
|
# Empty calculate_throughput
|
|
|
|
|
empty_report = PerfReport()
|
|
|
|
|
tp = calculate_throughput(empty_report)
|
|
|
|
|
assert tp["rolling"]["input_wall_clock"] == 0.0
|
|
|
|
|
assert tp["current"]["input_wall_clock"] == 0.0
|
|
|
|
|
|
|
|
|
|
# _calculate_throughput_stats with empty records
|
|
|
|
|
stats = _calculate_throughput_stats([], 10.0)
|
|
|
|
|
assert stats["input_wall_clock"] == 0.0
|