Files
rippled/docker/telemetry/workload/validate_telemetry.py
Pratik Mankawde cb88a12883 Merge branch 'pratik/otel-phase10-workload-validation' into pratik/otel-sync-diagnostics
Nine conflicts, resolved as follows.

src/xrpld/app/ledger/detail/InboundLedger.cpp -- kept this branch's version.
phase10 sets the span's outcome/timeouts/peer_count attributes inline at each
exit; this branch replaced that with the idempotent finalizeAcquireSpan(), called
on all four exits (init, done, give-up, destructor). Taking phase10's blocks
would have set the outcome twice against a helper documented as not overwriting
what the real exit recorded. phase10's comment explains why peer_count must not
be read in a destructor; the helper solves that structurally by taking
std::optional<std::size_t> and being passed std::nullopt from there.

src/xrpld/telemetry/MetricsRegistry.cpp -- kept metric::ledgerEconomy over
phase10's "ledger_economy" literal. This branch added the naming check that
requires constants for converted families, so the literal would regress it. Took
phase10's comment cleanup.

src/xrpld/telemetry/MetricsRegistry.h -- kept registerRotationStateGauge(), which
only exists here, and took phase10's removal of the stale task-number comment.

validate_telemetry.py -- combined both. phase10 replaced serial metric polling
with a concurrent fan-out on one shared deadline, because 58 metrics x 45 s of
additive timeout overran the CI budget; that is kept. Its target list filters on
SKIPPED_METRIC_GROUPS rather than the two literals it hardcoded, so the
sync_diagnostics group stays owned by assert_sync_diagnostics_metrics() instead
of being polled and reported twice. Both SYNC_DIAGNOSTICS_GROUP and
METRIC_POLL_CONCURRENCY are needed and both are kept.

check_otel_naming.py -- both sides extend the rule docstring. Took phase10's
fuller Rule E text (doc discovery, allow-dotted markers) and re-appended rules
I/J/K/L, which exist only here.

expected_metrics.json -- the two sides add disjoint sibling groups, so both are
kept: sync_diagnostics alongside node_health_gauges, overlay_reduce_relay,
overlay_overflow, validation_lifetime_counters and not_asserted. Both dashboard
uids are kept, giving 16 asserted uids against 16 dashboards on disk.

expected_spans.json -- kept this branch's span set, a superset that adds the
acquire phase spans, ledger.serve, txset.acquire and peer.dial, and expands
ledger.acquire's required attributes. Took phase10's description, which documents
what the totals mean, and its note on how the RPC wildcard span is created.
total_span_types and total_unique_attributes are recomputed for the union: 48 and
74, since each side's figure counted only its own spans.

Docs: took phase10's more accurate wording on what the dashboard check actually
covers, and corrected the dashboard count from 15 to 16 where the merge made it
stale.

Verified: no conflict markers remain, both JSON contracts parse, both Python
files compile, asserted dashboard uids match the dashboards on disk exactly, and
the OTel naming check reports all layers consistent.
2026-08-17 19:24:12 +01:00

1823 lines
65 KiB
Python

#!/usr/bin/env python3
"""Telemetry Validation Suite for xrpld.
Validates that the full telemetry stack is emitting expected data after
a workload run. Queries Tempo (spans), Prometheus (metrics), Loki (logs),
and Grafana (dashboards) APIs to produce a pass/fail report.
Validation categories:
1. Span validation — Every required span type in expected_spans.json, each
carrying its required attributes
2. Metric validation — SpanMetrics, StatsD, and MetricsRegistry OTLP metrics
are non-zero
3. Sync diagnostics — Fresh-node sync signals (bootstrap + acquire
pipeline) declared in the "sync_diagnostics" group
4. Log-trace correlation — Loki logs contain trace_id/span_id fields
5. Dashboard validation — Every dashboard uid in expected_metrics.json
provisions and loads (panel count only, not panel data)
6. External parity — Span attrs, metric existence, and value sanity for
external dashboard parity (validator-health,
peer-quality, node-health)
Usage:
python3 validate_telemetry.py --report /tmp/validation-report.json
# Custom API endpoints:
python3 validate_telemetry.py \\
--tempo http://localhost:3200 \\
--prometheus http://localhost:9090 \\
--loki http://localhost:3100 \\
--grafana http://localhost:3000
"""
import argparse
import asyncio
import fnmatch
import json
import logging
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import aiohttp
logger = logging.getLogger("validate_telemetry")
# ---------------------------------------------------------------------------
# Configuration defaults
# ---------------------------------------------------------------------------
DEFAULT_TEMPO = "http://localhost:3200"
DEFAULT_PROMETHEUS = "http://localhost:9090"
DEFAULT_LOKI = "http://localhost:3100"
DEFAULT_GRAFANA = "http://localhost:3000"
SCRIPT_DIR = Path(__file__).parent
EXPECTED_SPANS_FILE = SCRIPT_DIR / "expected_spans.json"
EXPECTED_METRICS_FILE = SCRIPT_DIR / "expected_metrics.json"
# Some beast::insight gauges/counters (ledger-age, peer-finder, overlay
# traffic) only populate after the node validates ledgers and sustains peer
# traffic, then travel a 1s periodic OTLP export + a 15s Prometheus scrape
# before they are queryable. On a slow CI runner the fixed post-workload wait
# can end before that pipeline settles, so a single query races and reports 0
# series. Poll each missing metric for up to this long (covering two scrape
# cycles) before failing, so the check is robust to runner speed.
METRIC_POLL_TIMEOUT_SEC = 45.0
METRIC_POLL_INTERVAL_SEC = 5.0
# Group key in expected_metrics.json holding the fresh-node sync-diagnostics
# metrics (bootstrap + acquire pipeline). Owned by
# assert_sync_diagnostics_metrics() so those signals get their own report
# category and a single, explicit failure per missing metric.
SYNC_DIAGNOSTICS_GROUP = "sync_diagnostics"
# All metrics are polled concurrently against ONE shared deadline, so the
# metric phase costs a single poll window instead of one per metric. This caps
# how many /api/v1/series requests are in flight at a time, so the fan-out does
# not hammer the single-container Prometheus the harness runs.
METRIC_POLL_CONCURRENCY = 8
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class CheckResult:
"""Result of a single validation check.
Attributes:
name: Check identifier (e.g., "span.rpc.ws_message").
category: Validation category (span, metric, log, dashboard).
passed: Whether the check passed.
message: Human-readable description of the result.
details: Optional additional data (counts, values, etc.).
"""
name: str
category: str
passed: bool
message: str
details: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
"""Serialize to a JSON-compatible dict."""
return {
"name": self.name,
"category": self.category,
"passed": self.passed,
"message": self.message,
"details": self.details,
}
@dataclass
class ValidationReport:
"""Aggregated validation report.
Attributes:
checks: List of all individual check results.
start_time: ISO timestamp when validation started.
end_time: ISO timestamp when validation completed.
"""
checks: list[CheckResult] = field(default_factory=list)
start_time: str = ""
end_time: str = ""
@property
def total_checks(self) -> int:
"""Total number of checks executed."""
return len(self.checks)
@property
def passed(self) -> int:
"""Number of checks that passed."""
return sum(1 for c in self.checks if c.passed)
@property
def failed(self) -> int:
"""Number of checks that failed."""
return sum(1 for c in self.checks if not c.passed)
@property
def all_passed(self) -> bool:
"""Whether all checks passed."""
return self.failed == 0
def add(self, check: CheckResult) -> None:
"""Add a check result to the report."""
self.checks.append(check)
status = "PASS" if check.passed else "FAIL"
logger.info("[%s] %s: %s", status, check.name, check.message)
def to_dict(self) -> dict[str, Any]:
"""Serialize to a JSON-compatible dict."""
return {
"summary": {
"total": self.total_checks,
"passed": self.passed,
"failed": self.failed,
"all_passed": self.all_passed,
},
"start_time": self.start_time,
"end_time": self.end_time,
"checks": [c.to_dict() for c in self.checks],
}
# ---------------------------------------------------------------------------
# Tempo API helpers
# ---------------------------------------------------------------------------
async def _tempo_search(
session: aiohttp.ClientSession,
tempo_url: str,
query: str,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Search traces in Tempo using TraceQL.
Args:
session: aiohttp client session.
tempo_url: Base URL for Tempo API (e.g., http://localhost:3200).
query: TraceQL query string.
limit: Maximum number of traces to return.
Returns:
List of trace summary dicts from Tempo search results.
"""
params = {"q": query, "limit": str(limit)}
async with session.get(f"{tempo_url}/api/search", params=params) as resp:
data = await resp.json()
return data.get("traces", [])
async def _tempo_get_trace(
session: aiohttp.ClientSession,
tempo_url: str,
trace_id: str,
) -> list[dict[str, Any]]:
"""Fetch a full trace from Tempo by trace ID.
Returns the list of spans extracted from the OTLP-format response.
Args:
session: aiohttp client session.
tempo_url: Tempo API base URL.
trace_id: Hex trace ID string.
Returns:
Flat list of span dicts with 'name' and 'attributes' keys.
"""
async with session.get(f"{tempo_url}/api/traces/{trace_id}") as resp:
data = await resp.json()
spans: list[dict[str, Any]] = []
for batch in data.get("batches", []):
for scope_spans in batch.get("scopeSpans", []):
spans.extend(scope_spans.get("spans", []))
return spans
def _otlp_span_attr_keys(span: dict[str, Any]) -> set[str]:
"""Extract all attribute key names from an OTLP span.
Args:
span: OTLP span dict with an 'attributes' list.
Returns:
Set of attribute key strings.
"""
return {a["key"] for a in span.get("attributes", []) if "key" in a}
def _span_name_matches(emitted_name: str, expected_name: str) -> bool:
"""Test an emitted span name against a name from expected_spans.json.
Contract names are either literals or globs containing "*" (for example
"rpc.command.*"). Literals are compared for exact equality so a longer
emitted name cannot satisfy a shorter contract: "consensus.accept.apply"
must not stand in for "consensus.accept".
Args:
emitted_name: Span name as reported by Tempo.
expected_name: Span name or glob pattern from expected_spans.json.
Returns:
True when the emitted name satisfies the expected name.
"""
if "*" in expected_name:
return fnmatch.fnmatchcase(emitted_name, expected_name)
return emitted_name == expected_name
# ---------------------------------------------------------------------------
# Span Validation (Tempo API)
# ---------------------------------------------------------------------------
async def validate_spans(
session: aiohttp.ClientSession,
tempo_url: str,
report: ValidationReport,
) -> None:
"""Validate that all expected spans appear in Tempo.
Queries the Tempo TraceQL API for each expected span name and checks
that traces exist. Also validates required attributes on spans and
parent-child relationships.
Args:
session: aiohttp client session.
tempo_url: Base URL for Tempo API (e.g., http://localhost:3200).
report: ValidationReport to accumulate results.
"""
logger.info("--- Span Validation (Tempo) ---")
# Load expected spans.
with open(EXPECTED_SPANS_FILE) as f:
expected = json.load(f)
# Check service registration.
try:
async with session.get(
f"{tempo_url}/api/v2/search/tag/resource.service.name/values"
) as resp:
data = await resp.json()
tag_values = data.get("tagValues", [])
services = [tv.get("value", "") for tv in tag_values]
has_xrpld = "xrpld" in services
report.add(
CheckResult(
name="span.service_registration",
category="span",
passed=has_xrpld,
message=(
f"Service 'xrpld' registered (found: {services})"
if has_xrpld
else f"Service 'xrpld' NOT found (found: {services})"
),
)
)
except Exception as exc:
report.add(
CheckResult(
name="span.service_registration",
category="span",
passed=False,
message=f"Tempo API unreachable: {exc}",
)
)
return
# Diagnostic: list all available operations (span names) for the xrpld
# service. This output appears in CI logs and helps debug missing-span
# failures without needing to reproduce the full stack locally.
try:
async with session.get(
f"{tempo_url}/api/v2/search/tag/span.name/values"
) as resp:
ops_data = await resp.json()
tag_values = ops_data.get("tagValues", [])
operations = [tv.get("value", "") for tv in tag_values]
logger.info(
"Tempo operations (%d total): %s",
len(operations),
operations,
)
except Exception as exc:
logger.warning("Failed to fetch Tempo operations: %s", exc)
# Concrete probe names for wildcard span entries. Exact-match TraceQL can't
# match a literal "*", so a representative operation name is substituted.
# Wildcards without a known concrete example (e.g. grpc.<MethodName> when no
# gRPC client runs) are skipped when marked optional.
wildcard_probes = {"rpc.command.*": "rpc.command.server_info"}
# Check each expected span.
for span_def in expected["spans"]:
span_name = span_def["name"]
is_optional = span_def.get("optional", False)
check_name = f"span.{span_name}"
if "*" in span_name:
operation = wildcard_probes.get(span_name)
if operation is None:
# No concrete probe. Optional wildcards (e.g. grpc.*) are skipped;
# a required one would be a config error worth surfacing.
if is_optional:
logger.info(
"[SKIP] %s: optional wildcard span with no concrete "
"probe (not exercised by the workload)",
check_name,
)
continue
report.add(
CheckResult(
name=check_name,
category="span",
passed=False,
message=f"{span_name}: required wildcard has no probe name",
)
)
continue
else:
operation = span_name
try:
query = '{resource.service.name="xrpld" && name="' + operation + '"}'
traces = await _tempo_search(session, tempo_url, query, limit=5)
count = len(traces)
# Optional spans only fire under specific traffic (mode changes,
# missing-ledger fetch, fee escalation). Absence is not a failure —
# mirror the parent-child "skip" handling so CI stays green.
if count == 0 and is_optional:
logger.info(
"[SKIP] %s: optional span not emitted under this workload",
check_name,
)
report.add(
CheckResult(
name=check_name,
category="span",
passed=True,
message=f"{span_name}: optional, not emitted (skipped)",
details={"trace_count": 0, "optional": True},
)
)
continue
report.add(
CheckResult(
name=check_name,
category="span",
passed=count > 0,
message=(
f"{span_name}: {count} traces found"
if count > 0
else f"{span_name}: 0 traces (expected > 0)"
),
details={"trace_count": count},
)
)
# Validate required attributes on first trace.
if count > 0 and span_def.get("required_attributes"):
await _check_attributes_on_first_trace(
session, tempo_url, traces, span_def, report
)
except Exception as exc:
report.add(
CheckResult(
name=check_name,
category="span",
passed=False,
message=f"{span_name}: query failed ({exc})",
)
)
# Validate parent-child relationships.
for rel in expected.get("parent_child_relationships", []):
# Skip relationships marked with "skip: true" (e.g., cross-thread
# parent-child that requires a C++ fix to propagate span context).
if rel.get("skip", False):
reason = rel.get("skip_reason", "marked skip in expected_spans.json")
logger.info(
"[SKIP] span.hierarchy.%s->%s: %s",
rel["parent"],
rel["child"],
reason,
)
continue
await _validate_parent_child(session, tempo_url, rel, report)
async def _check_attributes_on_first_trace(
session: aiohttp.ClientSession,
tempo_url: str,
traces: list[dict[str, Any]],
span_def: dict[str, Any],
report: ValidationReport,
) -> None:
"""Fetch the first trace and check the span's required attributes.
Fetching the trace is a second network call, so it carries its own error
handling. Letting it fall through to the caller's handler would add a
second result under the span's own check name, which has already recorded
the trace as found -- one entry passing and one failing for the same name,
inflating the check total and blaming the trace-existence check for an
attribute-fetch failure.
Args:
session: aiohttp client session.
tempo_url: Base URL for the Tempo API.
traces: Traces returned for this span, most recent first.
span_def: The span's entry from expected_spans.json.
report: ValidationReport to accumulate results.
"""
span_name = span_def["name"]
try:
trace_id = traces[0].get("traceID", "")
if not trace_id:
return
spans = await _tempo_get_trace(session, tempo_url, trace_id)
await _validate_span_attributes_otlp(spans, span_def, report)
except Exception as exc:
report.add(
CheckResult(
name=f"span.attrs.{span_name}",
category="span",
passed=False,
message=f"{span_name}: attribute check failed ({exc})",
)
)
async def _validate_span_attributes_otlp(
spans: list[dict[str, Any]],
span_def: dict[str, Any],
report: ValidationReport,
) -> None:
"""Check that the contract's own span carries its required attributes.
Only spans whose name matches ``span_def["name"]`` are inspected.
Attributes are never borrowed from siblings: many span types share keys
such as ledger_seq or tx_hash, so a trace-wide scan would satisfy every
one of those contracts from a single carrier span and make the per-span
contract unenforceable.
A span type passes when at least one instance of it carries every required
attribute. When none does, the closest instance's missing keys are
reported.
Args:
spans: Every OTLP span dict in the fetched trace.
span_def: Span definition from expected_spans.json.
report: ValidationReport to accumulate results.
"""
required_attrs = span_def.get("required_attributes", [])
if not required_attrs:
return
span_name = span_def["name"]
check_name = f"span.attrs.{span_name}"
matching = [s for s in spans if _span_name_matches(s.get("name", ""), span_name)]
if not matching:
report.add(
CheckResult(
name=check_name,
category="span",
passed=False,
message=(
f"{span_name}: no span named '{span_name}' in the fetched "
"trace, cannot verify its attributes"
),
details={"required": required_attrs, "instances": 0},
)
)
return
# Keep the instance that is missing the fewest required attributes, so the
# failure message names the closest witness rather than an arbitrary one.
best_found: set[str] = set()
best_missing: list[str] = list(required_attrs)
for span in matching:
found = _otlp_span_attr_keys(span)
missing = [a for a in required_attrs if a not in found]
if len(missing) < len(best_missing):
best_found, best_missing = found, missing
if not best_missing:
break
report.add(
CheckResult(
name=check_name,
category="span",
passed=not best_missing,
message=(
f"{span_name}: all {len(required_attrs)} attributes present"
if not best_missing
else f"{span_name}: no '{span_name}' span carried all "
f"{len(required_attrs)} required attributes; closest of "
f"{len(matching)} instance(s) missing {best_missing}"
),
details={
"required": required_attrs,
"found": sorted(best_found),
"missing": best_missing,
"instances": len(matching),
},
)
)
async def _validate_parent_child(
session: aiohttp.ClientSession,
tempo_url: str,
relationship: dict[str, Any],
report: ValidationReport,
) -> None:
"""Validate a parent-child span relationship in Tempo traces.
Args:
session: aiohttp client session.
tempo_url: Base URL for Tempo API.
relationship: Dict with 'parent' and 'child' span names.
report: ValidationReport to accumulate results.
"""
parent_name = relationship["parent"]
child_name = relationship["child"]
try:
# Query traces for the parent span.
query = '{resource.service.name="xrpld" && name="' + parent_name + '"}'
traces = await _tempo_search(session, tempo_url, query, limit=3)
if not traces:
report.add(
CheckResult(
name=f"span.hierarchy.{parent_name}->{child_name}",
category="span",
passed=False,
message=f"No {parent_name} traces to check hierarchy",
)
)
return
# Check if child spans exist within parent traces. Names are matched
# exactly (globs for wildcard contracts) — a substring test let a
# longer emitted name satisfy a shorter contract, so
# consensus.round -> consensus.accept passed on a
# consensus.accept.apply span alone.
found_child = False
for trace_summary in traces:
trace_id = trace_summary.get("traceID", "")
if not trace_id:
continue
spans = await _tempo_get_trace(session, tempo_url, trace_id)
if any(
_span_name_matches(span.get("name", ""), child_name) for span in spans
):
found_child = True
break
report.add(
CheckResult(
name=f"span.hierarchy.{parent_name}->{child_name}",
category="span",
passed=found_child,
message=(
f"Found {child_name} as child of {parent_name}"
if found_child
else f"{child_name} not found in {parent_name} traces"
),
)
)
except Exception as exc:
report.add(
CheckResult(
name=f"span.hierarchy.{parent_name}->{child_name}",
category="span",
passed=False,
message=f"Hierarchy check failed: {exc}",
)
)
# ---------------------------------------------------------------------------
# Trace-join Validation (Tempo API)
# ---------------------------------------------------------------------------
async def assert_trace_join_groups(
session: aiohttp.ClientSession,
tempo_url: str,
report: ValidationReport,
) -> None:
"""Assert each declared trace-join group really lands in ONE trace.
A trace-join group is a set of spans that share one trace id with NO
parent/child link between them: each derives its trace id deterministically
from the same hash (SpanGuard::hashSpan over a ledger hash), which is how
spans produced on unrelated threads are joined without propagating any
context. The parent/child check above cannot express that -- there is no
parent to look for -- so the assertion here is co-occurrence: search for the
group's anchor span, then require at least one of its traces to also contain
every span in required_members.
A regression this catches: if the join key or the deterministic-root
mechanism breaks, each span reverts to its own single-span trace and no trace
contains the members together, so a slow ledger is no longer readable as one
unit. That is invisible to every other check in this harness -- the spans are
all still emitted with all their attributes.
An absent "trace_join_groups" key is a genuine no-op (nothing declared yet),
not a failure: unlike the sync_diagnostics metric group, this key is optional
and older expected_spans.json files predate it.
Args:
session: aiohttp client session.
tempo_url: Base URL for the Tempo API.
report: ValidationReport to accumulate results.
"""
logger.info("--- Trace-Join Validation (Tempo) ---")
with open(EXPECTED_SPANS_FILE) as f:
expected = json.load(f)
groups = expected.get("trace_join_groups", {}).get("groups", [])
if not groups:
logger.info("[SKIP] span.trace_join: no join groups declared")
return
for group in groups:
await _validate_trace_join_group(session, tempo_url, group, report)
async def _validate_trace_join_group(
session: aiohttp.ClientSession,
tempo_url: str,
group: dict[str, Any],
report: ValidationReport,
) -> None:
"""Validate one trace-join group: anchor and members share a trace.
Args:
session: aiohttp client session.
tempo_url: Tempo API base URL.
group: One entry from expected_spans.json trace_join_groups.groups.
report: ValidationReport to accumulate results.
"""
name = group.get("name", "<unnamed>")
anchor = group["anchor"]
required = list(group.get("required_members", []))
check_name = f"span.trace_join.{name}"
try:
query = '{resource.service.name="xrpld" && name="' + anchor + '"}'
traces = await _tempo_search(session, tempo_url, query, limit=10)
if not traces:
report.add(
CheckResult(
name=check_name,
category="span",
passed=False,
message=f"{name}: no {anchor} traces to check the join against",
details={"anchor": anchor, "required_members": required},
)
)
return
# Walk the anchor's traces and keep the best result: the join holds as
# soon as ONE trace carries every required member. Several traces are
# examined because a given ledger may legitimately be missing an
# optional member (e.g. a self-built ledger has no arriving validation).
best_missing = required
for summary in traces:
trace_id = summary.get("traceID", "")
if not trace_id:
continue
spans = await _tempo_get_trace(session, tempo_url, trace_id)
present = {s.get("name", "") for s in spans}
missing = [m for m in required if m not in present]
if len(missing) < len(best_missing):
best_missing = missing
if not missing:
break
report.add(
CheckResult(
name=check_name,
category="span",
passed=not best_missing,
message=(
f"{name}: {anchor} shares a trace with {required}"
if not best_missing
else (
f"{name}: no {anchor} trace contained {best_missing} "
f"-- the per-{group.get('join_key', 'hash')} trace join "
f"is broken (spans are landing in separate traces)"
)
),
details={
"anchor": anchor,
"join_key": group.get("join_key"),
"required_members": required,
"missing": best_missing,
"traces_examined": len(traces),
},
)
)
except Exception as exc:
report.add(
CheckResult(
name=check_name,
category="span",
passed=False,
message=f"{name}: trace-join check failed ({exc})",
)
)
# ---------------------------------------------------------------------------
# Metric Validation (Prometheus API)
# ---------------------------------------------------------------------------
# Top-level keys of expected_metrics.json that validate_metrics() must not walk
# with its generic group loop: "description" is prose, "grafana_dashboards"
# holds dashboard UIDs (checked by validate_dashboards), and
# "sync_diagnostics" has its own validator, assert_sync_diagnostics_metrics().
SKIPPED_METRIC_GROUPS = ("description", "grafana_dashboards", SYNC_DIAGNOSTICS_GROUP)
async def _log_prometheus_metric_names(
session: aiohttp.ClientSession, prometheus_url: str
) -> None:
"""Log the harness-relevant metric names Prometheus currently knows.
Diagnostic only — this output appears in CI logs and helps debug name
mismatches between expected_metrics.json and actual emissions. Failures
are warnings, never check failures.
Args:
session: aiohttp client session.
prometheus_url: Prometheus base URL.
"""
try:
async with session.get(
f"{prometheus_url}/api/v1/label/__name__/values"
) as resp:
label_data = await resp.json()
all_metrics = label_data.get("data", [])
relevant = [
m
for m in all_metrics
if m.startswith(
(
"span_",
"rpc_method",
"cache_",
"txq_",
"object_count",
"load_factor",
"nodestore",
"ledgermaster",
"peer_finder",
"jobq_",
"total_bytes",
"total_messages",
"validation_agreement",
"validator_health",
"peer_quality",
"ledger_economy",
"state_tracking",
"storage_detail",
# Fresh-node sync diagnostics. Two of these only exist
# under specific conditions (a rejected handshake, a
# configured UNL site), so listing them here is how a
# failed run shows whether the metric was absent or
# merely misnamed.
"dns_resolve",
"overlay_connect",
"overlay_dial",
"handshake_",
"unl_",
"clock_close_offset",
# Sync-state signals. sync_state carries the gate,
# stall-seconds, ledgers-behind and time-to-first-FULL
# sub-series; state_changes_total is now labelled with
# the {from,to} transition edge, and
# server_stall_events_total is the stall episode count.
"sync_state",
"state_changes_total",
"server_stall_events",
# Acquire + SHAMap signals. sync_acquire carries the
# missing-node, stash-depth and in-flight sub-series and
# shamap_cache_hit_rate the tree-node cache rate; both
# are asserted. The sync_acquire_* / sync_addnode_total
# counters are listed here for diagnosis only -- they
# need a real ledger acquire, so they are not asserted
# (see _acquire_note in expected_metrics.json).
"sync_acquire",
"sync_addnode_total",
"shamap_cache_hit_rate",
# JobQueue saturation signals. jobq_saturation carries
# the pool running_tasks/worker_threads/total_waiting
# sub-series and is asserted. No new prefix entry is
# needed -- the "jobq_" prefix above already matches it
# (it was added for the StatsD jobq_job_count gauge),
# so listing it again would only duplicate the
# diagnostic output.
)
)
]
logger.info(
"Prometheus metrics (relevant, %d of %d total): %s",
len(relevant),
len(all_metrics),
relevant,
)
except Exception as exc:
logger.warning("Failed to fetch Prometheus metric names: %s", exc)
async def validate_metrics(
session: aiohttp.ClientSession,
prometheus_url: str,
report: ValidationReport,
) -> None:
"""Validate that expected metrics appear in Prometheus with non-zero values.
Args:
session: aiohttp client session.
prometheus_url: Base URL for Prometheus API (e.g., http://localhost:9090).
report: ValidationReport to accumulate results.
"""
logger.info("--- Metric Validation (Prometheus) ---")
await _log_prometheus_metric_names(session, prometheus_url)
with open(EXPECTED_METRICS_FILE) as f:
expected = json.load(f)
# Flatten every (category, metric) pair the contract asserts, then poll
# them concurrently against ONE shared deadline. Polling them serially made
# each metric own its own timeout, so the waits were additive: 58 metrics x
# 45 s = 43.5 min, which overran the CI job budget and lost the
# artifact-upload and summary diagnostics. Sharing the deadline bounds the
# whole phase to a single poll window.
targets = [
(category_key, metric_name)
for category_key, category_data in expected.items()
if category_key not in SKIPPED_METRIC_GROUPS
for metric_name in category_data.get("metrics", [])
]
deadline = time.monotonic() + METRIC_POLL_TIMEOUT_SEC
sem = asyncio.Semaphore(METRIC_POLL_CONCURRENCY)
checks = await asyncio.gather(
*(
_check_prometheus_metric(
session, prometheus_url, metric_name, category, deadline, sem
)
for category, metric_name in targets
)
)
# Add in contract order, not completion order, so the report and its log
# lines stay deterministic across runs.
for check in checks:
report.add(check)
async def _poll_series_count(
session: aiohttp.ClientSession,
prometheus_url: str,
metric_name: str,
deadline: float,
sem: asyncio.Semaphore,
) -> int:
"""Poll Prometheus until a metric has series or the deadline passes.
Uses the /api/v1/series endpoint instead of an instant query.
Beast::insight StatsD gauges only mark dirty on value *changes*, so a gauge
that stabilizes (e.g. peer count stays at 1) may go stale in Prometheus and
disappear from instant queries. The series endpoint returns any metric
that existed in the window, regardless of staleness.
Polls rather than querying once: late-populating gauges/counters may not
have completed the export+scrape pipeline when this runs, so a single query
races. A metric that never appears still fails once the deadline passes.
Args:
session: aiohttp client session.
prometheus_url: Prometheus base URL.
metric_name: Prometheus metric name.
deadline: Monotonic deadline shared by every metric in the run.
sem: Bounds how many requests reach Prometheus at once. It
is held only across the request, never across the
sleep, so one absent metric cannot starve the others.
Returns:
Number of series found, or 0 if the metric never appeared.
"""
params: dict[str, str] = {"match[]": metric_name}
while True:
async with sem:
async with session.get(
f"{prometheus_url}/api/v1/series", params=params
) as resp:
data = await resp.json()
series_count = len(data.get("data", []))
if series_count > 0 or time.monotonic() >= deadline:
return series_count
# Never sleep past the shared deadline.
await asyncio.sleep(min(METRIC_POLL_INTERVAL_SEC, deadline - time.monotonic()))
async def _check_prometheus_metric(
session: aiohttp.ClientSession,
prometheus_url: str,
metric_name: str,
category: str,
deadline: float,
sem: asyncio.Semaphore,
) -> CheckResult:
"""Query Prometheus for a specific metric and check it exists.
Args:
session: aiohttp client session.
prometheus_url: Prometheus base URL.
metric_name: Prometheus metric name.
category: Metric category for the report.
deadline: Monotonic deadline shared by every metric in the run.
sem: Bounds how many requests reach Prometheus at once.
Returns:
The CheckResult for this metric. The caller adds it to the report so
report order follows the contract file rather than completion order.
"""
try:
series_count = await _poll_series_count(
session, prometheus_url, metric_name, deadline, sem
)
return CheckResult(
name=f"metric.{category}.{metric_name}",
category="metric",
passed=series_count > 0,
message=(
f"{metric_name}: {series_count} series"
if series_count > 0
else f"{metric_name}: 0 series (expected > 0)"
),
details={"series_count": series_count},
)
except Exception as exc:
return CheckResult(
name=f"metric.{category}.{metric_name}",
category="metric",
passed=False,
message=f"{metric_name}: query failed ({exc})",
)
async def assert_sync_diagnostics_metrics(
session: aiohttp.ClientSession,
prometheus_url: str,
report: ValidationReport,
) -> None:
"""Assert every metric in the 'sync_diagnostics' group is present.
Fresh-node sync-diagnostics work packages append native metric names to the
"sync_diagnostics" group in expected_metrics.json; this check fails the run
if any listed metric regresses to absent, so a dropped signal cannot pass
CI silently. An empty group is a genuine no-op: nothing is queried and no
check is recorded, which is the state before any signal has landed.
A missing group key is itself a failure — the key is the anchor the sync
work packages append to, so its absence means the harness lost the contract
rather than that there is nothing to check.
Args:
session: aiohttp client session.
prometheus_url: Prometheus API base URL.
report: ValidationReport to accumulate results.
"""
logger.info("--- Fresh-Node Sync Diagnostics Metrics ---")
with open(EXPECTED_METRICS_FILE) as f:
expected = json.load(f)
group = expected.get(SYNC_DIAGNOSTICS_GROUP)
if group is None:
report.add(
CheckResult(
name=f"metric.{SYNC_DIAGNOSTICS_GROUP}.group_present",
category="metric",
passed=False,
message=(
f"'{SYNC_DIAGNOSTICS_GROUP}' group missing from "
f"{EXPECTED_METRICS_FILE.name}"
),
)
)
return
metrics = group.get("metrics", [])
if not metrics:
logger.info(
"[SKIP] metric.%s: group is empty (no sync-diagnostics signals "
"declared yet)",
SYNC_DIAGNOSTICS_GROUP,
)
return
for metric_name in metrics:
await _check_prometheus_metric(
session, prometheus_url, metric_name, SYNC_DIAGNOSTICS_GROUP, report
)
# ---------------------------------------------------------------------------
# Log-Trace Correlation Validation (Loki API)
# ---------------------------------------------------------------------------
async def validate_log_trace_correlation(
session: aiohttp.ClientSession,
loki_url: str,
tempo_url: str,
report: ValidationReport,
) -> None:
"""Validate that Loki logs contain trace_id/span_id for correlation.
Checks:
1. Logs with trace_id= field exist in Loki.
2. A random trace_id from Tempo can be found in Loki logs.
Args:
session: aiohttp client session.
loki_url: Base URL for Loki API (e.g., http://localhost:3100).
tempo_url: Base URL for Tempo API.
report: ValidationReport to accumulate results.
"""
logger.info("--- Log-Trace Correlation Validation (Loki) ---")
# Check 1: Any logs with trace_id exist.
try:
params = {
"query": '{job="xrpld"} |= "trace_id="',
"limit": 5,
"direction": "backward",
}
async with session.get(
f"{loki_url}/loki/api/v1/query_range", params=params
) as resp:
data = await resp.json()
streams = data.get("data", {}).get("result", [])
total_entries = sum(len(s.get("values", [])) for s in streams)
report.add(
CheckResult(
name="log.trace_id_present",
category="log",
passed=total_entries > 0,
message=(
f"Found {total_entries} log entries with trace_id"
if total_entries > 0
else "No log entries with trace_id found"
),
details={"log_count": total_entries},
)
)
except Exception as exc:
report.add(
CheckResult(
name="log.trace_id_present",
category="log",
passed=False,
message=f"Loki query failed: {exc}",
)
)
# Check 2: Cross-reference a trace_id from Tempo to Loki.
try:
# Get a recent trace from Tempo.
traces = await _tempo_search(
session,
tempo_url,
'{resource.service.name="xrpld"}',
limit=1,
)
if traces:
trace_id = traces[0].get("traceID", "")
if trace_id:
# Search Loki for this trace_id.
loki_params = {
"query": f'{{job="xrpld"}} |= "{trace_id}"',
"limit": 5,
"direction": "backward",
}
async with session.get(
f"{loki_url}/loki/api/v1/query_range",
params=loki_params,
) as loki_resp:
loki_data = await loki_resp.json()
loki_streams = loki_data.get("data", {}).get("result", [])
loki_count = sum(len(s.get("values", [])) for s in loki_streams)
report.add(
CheckResult(
name="log.trace_id_cross_reference",
category="log",
passed=loki_count > 0,
message=(
f"trace_id {trace_id[:16]}... found in "
f"{loki_count} Loki entries"
if loki_count > 0
else f"trace_id {trace_id[:16]}... not found " "in Loki"
),
details={
"trace_id": trace_id,
"loki_count": loki_count,
},
)
)
else:
report.add(
CheckResult(
name="log.trace_id_cross_reference",
category="log",
passed=False,
message="No traces in Tempo to cross-reference",
)
)
except Exception as exc:
report.add(
CheckResult(
name="log.trace_id_cross_reference",
category="log",
passed=False,
message=f"Cross-reference check failed: {exc}",
)
)
# ---------------------------------------------------------------------------
# Dashboard Validation (Grafana API)
# ---------------------------------------------------------------------------
def _leaf_panel_count(dashboard: dict[str, Any]) -> int:
"""Count the panels a dashboard actually renders.
Grafana models a row as an entry of ``type: "row"`` in the top-level
``panels`` list, and a collapsed row carries its children in its own
nested ``panels`` list. So ``len(dashboard["panels"])`` counts rows as
though they were panels and misses everything inside a collapsed one --
on ``node-health`` that reads 55 where the true figure is 51, and a
dashboard consisting only of collapsed rows would report a positive
count while rendering nothing.
Args:
dashboard: The ``dashboard`` object from the Grafana API response.
Returns:
The number of non-row panels, including those nested inside rows.
"""
total = 0
for panel in dashboard.get("panels", []):
if panel.get("type") == "row":
total += len(panel.get("panels", []))
else:
total += 1
return total
async def validate_dashboards(
session: aiohttp.ClientSession,
grafana_url: str,
report: ValidationReport,
) -> None:
"""Validate that all Grafana dashboards are accessible and return data.
For each expected dashboard UID, queries the Grafana API to verify
the dashboard exists and is loadable.
Args:
session: aiohttp client session.
grafana_url: Base URL for Grafana API (e.g., http://localhost:3000).
report: ValidationReport to accumulate results.
"""
logger.info("--- Dashboard Validation (Grafana) ---")
with open(EXPECTED_METRICS_FILE) as f:
expected = json.load(f)
dashboard_uids = expected.get("grafana_dashboards", {}).get("uids", [])
for uid in dashboard_uids:
try:
async with session.get(f"{grafana_url}/api/dashboards/uid/{uid}") as resp:
if resp.status == 200:
data = await resp.json()
dashboard = data.get("dashboard", {})
panel_count = _leaf_panel_count(dashboard)
report.add(
CheckResult(
name=f"dashboard.{uid}",
category="dashboard",
passed=panel_count > 0,
message=(
f"{uid}: loaded ({panel_count} panels)"
if panel_count
else f"{uid}: loaded but renders no panels"
),
details={"panel_count": panel_count},
)
)
else:
report.add(
CheckResult(
name=f"dashboard.{uid}",
category="dashboard",
passed=False,
message=f"{uid}: HTTP {resp.status}",
)
)
except Exception as exc:
report.add(
CheckResult(
name=f"dashboard.{uid}",
category="dashboard",
passed=False,
message=f"{uid}: query failed ({exc})",
)
)
# ---------------------------------------------------------------------------
# Span duration validation
# ---------------------------------------------------------------------------
async def validate_span_durations(
session: aiohttp.ClientSession,
tempo_url: str,
report: ValidationReport,
) -> None:
"""Validate that span durations are within reasonable bounds.
Checks that spans have duration > 0 and < 60s, flagging any anomalies.
Args:
session: aiohttp client session.
tempo_url: Base URL for Tempo API.
report: ValidationReport to accumulate results.
"""
logger.info("--- Span Duration Validation ---")
try:
traces = await _tempo_search(
session,
tempo_url,
'{resource.service.name="xrpld"}',
limit=5,
)
if not traces:
report.add(
CheckResult(
name="span.duration_bounds",
category="span",
passed=False,
message="No traces available for duration check",
)
)
return
total_spans = 0
invalid_spans = 0
max_duration_ns = 0
for trace_summary in traces:
trace_id = trace_summary.get("traceID", "")
if not trace_id:
continue
spans = await _tempo_get_trace(session, tempo_url, trace_id)
for span in spans:
start_ns = int(span.get("startTimeUnixNano", "0"))
end_ns = int(span.get("endTimeUnixNano", "0"))
duration_ns = end_ns - start_ns
total_spans += 1
max_duration_ns = max(max_duration_ns, duration_ns)
# Invalid if negative or > 60 seconds.
if duration_ns < 0 or duration_ns > 60_000_000_000:
invalid_spans += 1
max_duration_ms = max_duration_ns / 1_000_000
report.add(
CheckResult(
name="span.duration_bounds",
category="span",
passed=invalid_spans == 0,
message=(
f"All {total_spans} spans have valid durations "
f"(max: {max_duration_ms:.1f}ms)"
if invalid_spans == 0
else f"{invalid_spans}/{total_spans} spans have invalid "
"durations (<0 or >60s)"
),
details={
"total_spans": total_spans,
"invalid_spans": invalid_spans,
"max_duration_ms": round(max_duration_ms, 2),
},
)
)
except Exception as exc:
report.add(
CheckResult(
name="span.duration_bounds",
category="span",
passed=False,
message=f"Duration check failed: {exc}",
)
)
# ---------------------------------------------------------------------------
# External Dashboard Parity Validation
# ---------------------------------------------------------------------------
# Span attributes that external dashboards (validator-health, peer-quality,
# node-health) depend on. Each entry maps a span name to the
# attributes that must be present for external dashboard panels to render.
# Keys follow the 2026-05-13 span-attr naming redesign (bare/underscore form;
# dotted xrpl.* reserved for resource attributes). The amendment_blocked,
# server_state, and proposers_validated values that earlier external-dashboard
# work tracked are NOT span attributes — they exist only as MetricsRegistry
# metrics (validator_health{metric="amendment_blocked"},
# state_tracking{metric="state_value"}, etc.), so they are validated by
# PARITY_VALUE_SANITY below rather than as span attributes here.
PARITY_SPAN_ATTRS: list[dict[str, str]] = [
{"span": "tx.receive", "attr": "peer_version"},
{"span": "consensus.validation.send", "attr": "ledger_hash"},
{"span": "consensus.validation.send", "attr": "full_validation"},
# peer.validation.receive shares the ledger_hash / full_validation keys with
# consensus.validation.send (same keys, told apart by span name).
{"span": "peer.validation.receive", "attr": "ledger_hash"},
{"span": "peer.validation.receive", "attr": "full_validation"},
{"span": "consensus.accept", "attr": "quorum"},
]
# Value sanity bounds for external-parity metrics. Each entry specifies a
# Prometheus query and the acceptable range [lo, hi] for the returned value.
PARITY_VALUE_SANITY: list[dict[str, Any]] = [
{
"name": "validation_agreement_pct_1h",
"query": 'validation_agreement{metric="agreement_pct_1h"}',
"lo": 0,
"hi": 100,
},
{
"name": "unl_expiry_days",
"query": 'validator_health{metric="unl_expiry_days"}',
"lo": 0,
"hi": None,
"exclusive_lo": True,
},
{
"name": "peer_latency_p90_ms",
"query": 'peer_quality{metric="peer_latency_p90_ms"}',
"lo": 0,
"hi": None,
},
{
"name": "state_value",
"query": 'state_tracking{metric="state_value"}',
"lo": 0,
"hi": 7,
},
]
async def validate_parity_span_attrs(
session: aiohttp.ClientSession,
tempo_url: str,
report: ValidationReport,
) -> None:
"""Validate span attributes required by external dashboard panels.
For each (span, attribute) pair in PARITY_SPAN_ATTRS, queries Tempo
for the span and checks that the attribute key exists on at least one
span in the returned traces.
Args:
session: aiohttp client session.
tempo_url: Base URL for Tempo API.
report: ValidationReport to accumulate results.
"""
logger.info("--- External Parity: Span Attribute Checks ---")
for entry in PARITY_SPAN_ATTRS:
span_name = entry["span"]
attr_name = entry["attr"]
check_name = f"parity.span_attr.{span_name}.{attr_name}"
try:
query = '{resource.service.name="xrpld" && name="' + span_name + '"}'
traces = await _tempo_search(session, tempo_url, query, limit=5)
if not traces:
report.add(
CheckResult(
name=check_name,
category="parity",
passed=False,
message=(
f"{span_name}: no traces found, "
f"cannot verify attr {attr_name}"
),
)
)
continue
# Fetch full trace and search spans for the attribute.
found = False
for trace_summary in traces:
trace_id = trace_summary.get("traceID", "")
if not trace_id:
continue
spans = await _tempo_get_trace(session, tempo_url, trace_id)
for span in spans:
if attr_name in _otlp_span_attr_keys(span):
found = True
break
if found:
break
report.add(
CheckResult(
name=check_name,
category="parity",
passed=found,
message=(
f"{span_name}: attribute '{attr_name}' present"
if found
else f"{span_name}: attribute '{attr_name}' missing"
),
)
)
except Exception as exc:
report.add(
CheckResult(
name=check_name,
category="parity",
passed=False,
message=f"{span_name}: attr check failed ({exc})",
)
)
def _series_label(series: dict[str, Any]) -> str:
"""Name a Prometheus series for use in a failure message.
Args:
series: One entry from a Prometheus query result.
Returns:
The series' service_instance_id when it carries one (the label that
tells harness cluster nodes apart), else its full label set.
"""
metric = series.get("metric", {})
instance = metric.get("service_instance_id")
if instance:
return f"service_instance_id={instance}"
return str(metric) if metric else "<unlabelled series>"
def _value_in_bounds(
value: float, lo: float, hi: float | None, exclusive_lo: bool
) -> bool:
"""Test one sample against a sanity range.
Args:
value: Sample value.
lo: Lower bound.
hi: Upper bound, or None when unbounded above.
exclusive_lo: True when the lower bound is exclusive.
Returns:
True when the value is inside the range.
"""
lo_ok = value > lo if exclusive_lo else value >= lo
return lo_ok and (hi is None or value <= hi)
def _bounds_description(lo: float, hi: float | None, exclusive_lo: bool) -> str:
"""Build the human-readable bound text used in check messages.
Args:
lo: Lower bound.
hi: Upper bound, or None when unbounded above.
exclusive_lo: True when the lower bound is exclusive.
Returns:
A phrase such as "> 0 and <= 100".
"""
desc = f"{'>' if exclusive_lo else '>='} {lo}"
if hi is not None:
desc += f" and <= {hi}"
return desc
async def _check_parity_value(
session: aiohttp.ClientSession,
prometheus_url: str,
entry: dict[str, Any],
) -> CheckResult:
"""Bounds-check every series returned by one parity sanity query.
Args:
session: aiohttp client session.
prometheus_url: Prometheus API base URL.
entry: One PARITY_VALUE_SANITY entry.
Returns:
A CheckResult that fails if any series is out of bounds, naming each
offending series.
"""
name = entry["name"]
lo = entry["lo"]
hi = entry["hi"]
exclusive_lo = entry.get("exclusive_lo", False)
check_name = f"parity.value_sanity.{name}"
try:
async with session.get(
f"{prometheus_url}/api/v1/query", params={"query": entry["query"]}
) as resp:
data = await resp.json()
results = data.get("data", {}).get("result", [])
if not results:
return CheckResult(
name=check_name,
category="parity",
passed=False,
message=f"{name}: no data returned from Prometheus",
)
values: list[float] = []
offenders: list[str] = []
for series in results:
value = float(series["value"][1])
values.append(value)
if not _value_in_bounds(value, lo, hi, exclusive_lo):
offenders.append(f"{_series_label(series)} value {value}")
bound_desc = _bounds_description(lo, hi, exclusive_lo)
return CheckResult(
name=check_name,
category="parity",
passed=not offenders,
message=(
f"{name}: all {len(values)} series within bounds ({bound_desc})"
if not offenders
else f"{name}: {len(offenders)} of {len(values)} series out of "
f"bounds (expected {bound_desc}): " + "; ".join(offenders)
),
details={
"values": values,
"series_count": len(values),
"out_of_bounds": offenders,
"lo": lo,
"hi": hi,
},
)
except Exception as exc:
return CheckResult(
name=check_name,
category="parity",
passed=False,
message=f"{name}: sanity check failed ({exc})",
)
async def validate_parity_value_sanity(
session: aiohttp.ClientSession,
prometheus_url: str,
report: ValidationReport,
) -> None:
"""Validate that external-parity metric values fall within sane bounds.
For each entry in PARITY_VALUE_SANITY, queries Prometheus and checks
*every* returned series against the specified [lo, hi] range. These
queries are bare selectors with no aggregation, so a multi-node harness
cluster returns one series per service_instance_id; checking only the
first would let an out-of-range node pass silently.
Args:
session: aiohttp client session.
prometheus_url: Prometheus API base URL.
report: ValidationReport to accumulate results.
"""
logger.info("--- External Parity: Value Sanity Checks ---")
for entry in PARITY_VALUE_SANITY:
report.add(await _check_parity_value(session, prometheus_url, entry))
# ---------------------------------------------------------------------------
# Main validation orchestrator
# ---------------------------------------------------------------------------
async def run_validation(
tempo_url: str,
prometheus_url: str,
loki_url: str,
grafana_url: str,
skip_loki: bool = False,
) -> ValidationReport:
"""Run all validation checks and return a report.
Args:
tempo_url: Tempo API base URL.
prometheus_url: Prometheus API base URL.
loki_url: Loki API base URL.
grafana_url: Grafana API base URL.
skip_loki: If True, skip log-trace correlation checks.
Returns:
ValidationReport with all check results.
"""
report = ValidationReport()
report.start_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
async with aiohttp.ClientSession() as session:
await validate_spans(session, tempo_url, report)
await validate_span_durations(session, tempo_url, report)
await assert_trace_join_groups(session, tempo_url, report)
await validate_metrics(session, prometheus_url, report)
await assert_sync_diagnostics_metrics(session, prometheus_url, report)
if not skip_loki:
await validate_log_trace_correlation(session, loki_url, tempo_url, report)
await validate_dashboards(session, grafana_url, report)
await validate_parity_span_attrs(session, tempo_url, report)
await validate_parity_value_sanity(session, prometheus_url, report)
report.end_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
return report
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Telemetry Validation Suite for xrpld",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Run all validations with defaults:
python3 validate_telemetry.py
# Write report to file:
python3 validate_telemetry.py --report /tmp/validation-report.json
# Custom endpoints:
python3 validate_telemetry.py \\
--tempo http://tempo:3200 --prometheus http://prom:9090
# Skip Loki checks (if log-trace correlation is not set up):
python3 validate_telemetry.py --skip-loki
""",
)
parser.add_argument(
"--tempo",
type=str,
default=DEFAULT_TEMPO,
help=f"Tempo API URL (default: {DEFAULT_TEMPO})",
)
parser.add_argument(
"--prometheus",
type=str,
default=DEFAULT_PROMETHEUS,
help=f"Prometheus API URL (default: {DEFAULT_PROMETHEUS})",
)
parser.add_argument(
"--loki",
type=str,
default=DEFAULT_LOKI,
help=f"Loki API URL (default: {DEFAULT_LOKI})",
)
parser.add_argument(
"--grafana",
type=str,
default=DEFAULT_GRAFANA,
help=f"Grafana API URL (default: {DEFAULT_GRAFANA})",
)
parser.add_argument(
"--skip-loki",
action="store_true",
help="Skip log-trace correlation validation",
)
parser.add_argument(
"--report",
type=str,
default=None,
help="Write JSON report to this file path",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable debug logging",
)
return parser.parse_args()
def main() -> None:
"""Main entry point for the telemetry validation suite."""
args = parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
)
report = asyncio.run(
run_validation(
tempo_url=args.tempo,
prometheus_url=args.prometheus,
loki_url=args.loki,
grafana_url=args.grafana,
skip_loki=args.skip_loki,
)
)
# Print summary.
print("")
print("=" * 60)
print(" TELEMETRY VALIDATION REPORT")
print("=" * 60)
print(f" Total checks: {report.total_checks}")
print(f" Passed: {report.passed}")
print(f" Failed: {report.failed}")
print("=" * 60)
print("")
# Print failures.
if report.failed > 0:
print("FAILED CHECKS:")
for check in report.checks:
if not check.passed:
print(f" [{check.category}] {check.name}: {check.message}")
print("")
# Write report file.
report_dict = report.to_dict()
if args.report:
with open(args.report, "w") as f:
json.dump(report_dict, f, indent=2)
logger.info("Report written to %s", args.report)
else:
print(json.dumps(report_dict, indent=2))
# Exit with appropriate code for CI.
sys.exit(0 if report.all_passed else 1)
if __name__ == "__main__":
main()