Files
rippled/docker/telemetry/workload/capture_timings.py
Pratik Mankawde 8521b96d85 fix(telemetry): stop an incomplete capture becoming the committed baseline
The regression baseline is bootstrapped by copying a CI artifact. The workflow
tested only that timings.json existed, then printed it verbatim under a heading
inviting the reader to paste it in as the new baseline.

capture_timings.py writes that file and only then enforces --min-capture-ratio,
so an incomplete capture leaves a file that exists but covers fewer keys than
the contract declares. The verdict lived in CAPTURE_EXIT, a shell variable local
to run-full-validation.sh that no other program could read. So on a placeholder
baseline plus a thin capture, CI offered an incomplete artifact as the next
baseline, and pasting it narrowed the gate with nothing reporting that it had.
That is the failure shape this harness keeps producing: a degraded result that
looks exactly like a good one.

The artifact now carries its own completeness, next to metrics:

  "capture": { "declared": 20, "captured": 20, "min_ratio": 0.5, "complete": true }

complete is the same condition the producer exits 0 on, computed once with the
exit code read off it, so the flag and the status cannot drift apart. Any
consumer can now tell a complete capture from a thin one, not just CI.

Both paste-me paths refuse rather than warn: the workflow prints the counts and
an error annotation with no JSON, and the comparator explains on stderr while
leaving stdout empty, so a redirect cannot produce a plausible-looking file. A
warning above a copyable block is still a copyable block, and a reader who has
just hit a red gate is already predisposed to re-baseline. A missing capture
block fails closed.

Refusal is scoped to bootstrapping a baseline, not to comparing against one, so
artifacts captured before this change still replay: verified against the run the
current baseline came from, which carries no capture block and still reports 0
regressions. An injected regression is still caught, and the gated surface is
unchanged at 20 keys with 5 excluded.
2026-08-27 09:52:48 +01:00

255 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Capture OTel-derived timings from Prometheus for the regression gate.
Queries Prometheus for every metric declared in ``regression-metrics.json``
and writes the results to a JSON file in the exact schema
``baseline-timings.json`` expects. When a user wants to refresh the
baseline, they copy a CI run's ``timings.json`` artifact (or the block
printed to the workflow step summary) into
``baselines/baseline-timings.json`` in a reviewable PR.
Output schema (stable — ``compare_to_baseline.py`` reads it verbatim)::
{
"schema_version": 1,
"captured_at": "2026-04-24T17:30:00Z",
"window": "3m",
"git_sha": "<from $GITHUB_SHA or `git rev-parse HEAD`>",
"profile": "full-validation",
"capture": {
"declared": 20,
"captured": 20,
"min_ratio": 0.5,
"complete": true
},
"metrics": {
"span.tx.process.p99": {"value": 12.4, "unit": "ms"},
"job.transaction.queued.p95": {"value": 850.0, "unit": "us"},
...
}
}
The ``capture`` block is what makes this file safe to use as baseline material.
The output is written BEFORE ``--min-capture-ratio`` is enforced, so a run that
reached too little of Prometheus still leaves a ``timings.json`` behind. That
file exists, parses, and carries every declared key — some with ``value: null``
— so a thin capture is indistinguishable from a good one to a reader who only
checks that the file is there. Pasted into
``baselines/baseline-timings.json`` it would narrow the gate to whichever keys
happened to come back, with nothing reporting that the gate had narrowed.
``complete`` is exactly the condition this script exits 0 on. It is computed
once, in ``_capture_status``, and drives both the exit code and the block, so
the two cannot disagree. Consumers read the flag rather than re-deriving the
ratio rule for themselves: the workflow's "Print regression summary" step, the
paste-me path in ``compare_to_baseline.py``, and a human reading the artifact
all get the same answer from one place. ``declared``, ``captured`` and
``min_ratio`` sit alongside it so a rejected capture can be judged without
re-running it.
The block is additive — a sibling of ``metrics``, never an entry inside it — so
it is neither a metric key nor a gated entry, and readers that predate it are
unaffected. Its ABSENCE means an artifact from before it existed, whose
completeness cannot be established; the paste-me paths treat that as not
complete rather than as complete.
Usage::
python3 capture_timings.py \\
--prometheus http://localhost:9090 \\
--metrics regression-metrics.json \\
--output /tmp/timings.json \\
--window 3m \\
--profile regression
"""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
import aiohttp
from prom_queries import build_query_plan, run_query_plan
logger = logging.getLogger("capture_timings")
SCHEMA_VERSION = 1
async def capture(
prom_url: str,
metrics_path: Path,
window: str,
profile: str,
min_capture_ratio: float,
) -> dict:
"""Build and execute the query plan, return the full report dict.
``min_capture_ratio`` is recorded in the report rather than only applied to
the exit code, so the artifact states the bar it was judged against.
"""
plan = build_query_plan(metrics_path, window=window)
logger.info("Capturing %d metrics from %s (window=%s)", len(plan), prom_url, window)
async with aiohttp.ClientSession() as session:
metrics = await run_query_plan(session, prom_url, plan)
metrics = dict(sorted(metrics.items()))
return {
"schema_version": SCHEMA_VERSION,
"captured_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"window": window,
"git_sha": _detect_git_sha(),
"profile": profile,
"capture": _capture_status(metrics, min_capture_ratio),
"metrics": metrics,
}
def _capture_status(metrics: dict, min_ratio: float) -> dict:
"""Summarise how much of the declared surface this capture actually got.
``declared`` is every key the surface asked for; ``captured`` is how many
came back with a value. ``complete`` is the single fact every consumer
keys on, and it is the same predicate that decides this script's exit
code — see the module docstring for why it lives in the artifact.
An empty surface is vacuously complete: there is nothing for the capture
to have fallen short of, and that is the case the exit-code check has
always passed. Defining it any other way here would make the flag and the
exit code disagree, which is the drift this block exists to remove.
"""
declared = len(metrics)
captured = sum(1 for entry in metrics.values() if entry["value"] is not None)
return {
"declared": declared,
"captured": captured,
"min_ratio": min_ratio,
"complete": declared == 0 or (captured / declared) >= min_ratio,
}
def _detect_git_sha() -> str:
"""Return the current commit SHA from env or git, else ``"unknown"``.
Prefers ``GITHUB_SHA`` (set in Actions), falls back to ``git rev-parse``.
Silent fallback is fine here — a missing SHA only affects the captured
metadata, not the comparison logic.
"""
env_sha = os.environ.get("GITHUB_SHA")
if env_sha:
return env_sha
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return "unknown"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--prometheus",
default="http://localhost:9090",
help="Prometheus base URL (default: http://localhost:9090)",
)
parser.add_argument(
"--metrics",
type=Path,
default=Path(__file__).parent / "regression-metrics.json",
help="Path to regression-metrics.json",
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Where to write the captured timings JSON",
)
parser.add_argument(
"--window",
default="3m",
help="Prometheus rate() window (default: 3m)",
)
parser.add_argument(
"--profile",
default="full-validation",
help=(
"Workload profile used during capture, recorded as metadata in the "
"timings file (default: full-validation). Must name a profile in "
"workload-profiles.json; run-full-validation.sh always passes this "
"explicitly."
),
)
parser.add_argument(
"--min-capture-ratio",
type=float,
default=0.5,
help="Fail if fewer than this fraction of metrics are captured (default: 0.5)",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable debug logging",
)
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(levelname)s %(name)s: %(message)s",
)
report = asyncio.run(
capture(
prom_url=args.prometheus,
metrics_path=args.metrics,
window=args.window,
profile=args.profile,
min_capture_ratio=args.min_capture_ratio,
)
)
args.output.parent.mkdir(parents=True, exist_ok=True)
with open(args.output, "w") as f:
json.dump(report, f, indent=2, sort_keys=True)
f.write("\n")
# The exit code is read off the same flag the artifact carries, so a file
# marked complete is always one this script exited 0 on.
status = report["capture"]
captured, total = status["captured"], status["declared"]
logger.info("Wrote %s (%d/%d metrics captured)", args.output, captured, total)
if not status["complete"]:
logger.error(
"Only %d/%d (%.0f%%) metrics captured — below the %.0f%% minimum. "
"Is Prometheus reachable at %s? The file is marked "
"capture.complete=false and must not be pasted into the baseline.",
captured,
total,
captured / total * 100,
args.min_capture_ratio * 100,
args.prometheus,
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())