mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 06:10:58 +00:00
The harness manifests asserted things the code cannot produce and missed most of what it does. Two assertions were failing every run, and the metric set covered 16 of the ~41 emitted names. expected_spans.json: rpc.process was required with rpc.ws_message as its parent, but it is created only in ServerHandler::processRequest() on the HTTP path, so a WebSocket-only workload never produces it -- it is now optional and parented to rpc.http_request, and the rpc.process -> rpc.command.* edge is skipped with the real reason instead of a coroutine-context-loss diagnosis that was never the cause. Adds the missing rpc.ws_upgrade span, corrects four parents (consensus.mode_change, pathfind.request, and update_positions/check, which are children of consensus.establish rather than consensus.round), and demotes conditionally-set attributes out of required_attributes so a healthy run stops failing. Counts recomputed from the file: 41 span types, 62 unique required attributes. expected_metrics.json: 16 -> 52 asserted entries across the job-queue, RPC method, reduce-relay, overflow and validation families, plus the fifteenth dashboard uid. Metrics the harness workload cannot exercise -- erroring RPC, ledger-mismatch, TxQ overflow, and the lazily-created getobject_* instruments -- are listed in a not_asserted group the validator skips, rather than as assertions that would fail on a healthy node. The workflow's push trigger listed two globs matching nothing (include/xrpl/basics/Telemetry*.h, src/xrpld/app/misc/Telemetry*), so no C++ telemetry change ever triggered validation. Replaced with the paths the code actually lives in, including src/libxrpl/beast/insight/** for the insight export path the harness depends on. The four inert workflow_dispatch inputs are now labelled UNUSED rather than looking like working knobs. Docs: the workload README described a StatsD dirty-flag mechanism under a member name that does not exist, on a code path the harness never uses -- it sets [insight] server=otel, so gauges export through an observable-gauge callback every cycle. Adds the missing txq-burst phase, reconciles three different dashboard counts, and drops "posts summary to PR", which the workflow has no permission to do. The runbook's phase-10 section loses the last sampling_ratio reference (not a config key), gains a Regression Gate and CI subsection covering the gate that can fail CI, and its compose-logs command now names the workload compose file. cmake --preset default is left for a separate change: no CMakePresets.json is tracked, so it is wrong everywhere it appears. Also drops the dead exporter=otlp_http key the harness wrote into every node config, and stops capture_timings.py defaulting --profile to a profile that does not exist.
191 lines
5.5 KiB
Python
191 lines
5.5 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",
|
|
"metrics": {
|
|
"span.tx.process.p99": {"value": 12.4, "unit": "ms"},
|
|
"job.transaction.queued.p95": {"value": 850.0, "unit": "us"},
|
|
...
|
|
}
|
|
}
|
|
|
|
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,
|
|
) -> dict:
|
|
"""Build and execute the query plan, return the full report dict."""
|
|
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)
|
|
|
|
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,
|
|
"metrics": dict(sorted(metrics.items())),
|
|
}
|
|
|
|
|
|
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,
|
|
)
|
|
)
|
|
|
|
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")
|
|
|
|
captured = sum(1 for v in report["metrics"].values() if v["value"] is not None)
|
|
total = len(report["metrics"])
|
|
logger.info("Wrote %s (%d/%d metrics captured)", args.output, captured, total)
|
|
|
|
if total > 0 and (captured / total) < args.min_capture_ratio:
|
|
logger.error(
|
|
"Only %d/%d (%.0f%%) metrics captured — below the %.0f%% minimum. "
|
|
"Is Prometheus reachable at %s?",
|
|
captured,
|
|
total,
|
|
captured / total * 100,
|
|
args.min_capture_ratio * 100,
|
|
args.prometheus,
|
|
)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|