mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-19 21:30:56 +00:00
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.
1823 lines
65 KiB
Python
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()
|