Files
rippled/.github/scripts/otel-naming/check_otel_naming.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

1885 lines
86 KiB
Python

#!/usr/bin/env python3
"""
Usage: check_otel_naming.py
This script takes no parameters and can be called from any directory inside the
repository (it locates the repo root via `git rev-parse`).
Enforces the OpenTelemetry naming conventions documented in CONTRIBUTING.md
("Telemetry span attribute naming" and "Telemetry metric naming") across every
layer of the telemetry pipeline. The `*SpanNames.h` constants are the single
source of truth for span attributes (L1) and the `*MetricNames.h` constants for
metric names and label keys (L1-metrics); every other layer must agree with
them.
Design principles
-----------------
1. No hardcoded allowlist. The set of valid attribute keys — including which
dotted keys are legitimate resource attributes — is derived dynamically by
parsing the repository's own OTel code:
* `*SpanNames.h` `namespace attr { ... }` blocks (the underscore/bare keys
and the `join(seg::..., ...)` dotted resource compositions), and
* the keys the code passes to `Resource::Create({ ... })` in Telemetry.cpp
(the standard `semconv::service::*` keys -> service.name/version/...).
The one narrow, explicit exception is EXTERNAL_INFRA_LABELS (Rules D & E):
identity labels stamped by infrastructure outside this repo's OTel code
(the perf-iac harness), which by definition have no source in-tree to
derive from. Kept separate from the generic Prometheus/Grafana builtins
set so the exception stays visible rather than blending into "things
every OTel setup has". perf-iac's alloy pipeline stamps each identity at
two layers -- dotted on the OTel resource attribute (xrpl.work.item/
.branch/.node.role, checked by Rule E) and underscore on the derived
Prometheus metric-datapoint label (xrpl_work_item/_branch/_node_role,
checked by Rule D) -- so both forms are exempt from the same constant.
2. Presence-gated enforcement. Every rule runs ONLY when the source files it
needs are present in the tree, and is otherwise skipped (never failed). This
keeps the check correct no matter how work is split across PRs: a stacked
chain, one large PR, or independent per-stage PRs where (for example) the
collector config lands in a different PR than the dashboards. The check never
assumes a file from another phase/PR exists.
Layers
------
L1 code : src/**/*SpanNames.h, include/**/*SpanNames.h (ground truth)
L1 resource : src/libxrpl/telemetry/Telemetry.cpp (dotted allowlist)
L1 callsites : setAttribute/addEvent/span/rootSpan/childSpan in src/**,
include/**
L2 collector : docker/telemetry/otel-collector-config.yaml (spanmetrics dims)
L3 tempo : docker/telemetry/tempo.yaml (span filter tags)
L4 dashboards: docker/telemetry/grafana/dashboards/*.json (PromQL labels;
Loki/LogQL queries exempt -- see LOG_QUERY_DATASOURCES)
L5 docs : every *.md under docs/ and docker/telemetry/ (attr tables;
the set is DISCOVERED, never enumerated -- see
RULE_E_DOC_ROOTS)
L6 metrics : MetricsRegistry.cpp instrument labels (native-metric
label keys, a valid dashboard-label source besides L1)
L1-metrics : src/**/*MetricNames.h, include/**/*MetricNames.h (ground truth
for metric instrument names, label keys and bounded values)
L7 workload : docker/telemetry/workload/expected_metrics.json (asserted
metric names)
Rules (each FAILS the build, when its inputs are present)
---------------------------------------------------------
A No stray dotted span-attribute key. A dotted `<a>.<b>` used as a span
attribute that is not in the derived resource-key set is a violation.
G Attribute keys must be lower_snake_case (^[a-z][a-z0-9_]*$ per segment).
Flags camelCase, UPPERCASE, spaces, and other stray characters.
F No string literals as attribute keys or span-name arguments. The
setAttribute/addEvent key and the span/rootSpan/childSpan prefix/name args
must reference a *SpanNames.h constant, never a "literal". Attribute VALUES
are exempt (runtime data). Definitions inside *SpanNames.h are exempt, and
test files are exempt (they pass arbitrary literals to exercise the API).
B Every collector spanmetrics dimension exists in the L1 key set.
C Every tempo span-filter tag exists in the L1 key set.
D Every dashboard label resolves to an L1 span attribute, an L6
native-metric label, or a builtin. TraceQL `span.`/`resource.` scope
prefixes are stripped before the L1 lookup. Log-datasource (Loki/LogQL)
queries are exempt: their labels are minted by collector `regex_parser`
captures or an in-query `| regexp` stage, so they have no L1/L6 source to
resolve against (see LOG_QUERY_DATASOURCES). The exemption is per QUERY,
not per file, so a mixed dashboard still has its PromQL panels checked.
E No dotted `xrpl.<domain>.<field>` attribute key in any L5 doc. The doc set
is DISCOVERED, not listed: every `*.md` under RULE_E_DOC_ROOTS, so a doc
added or renamed inside a root is covered without editing this script.
Only the L1 resource
attrs xrpl.network.* and the EXTERNAL_INFRA_LABELS dotted form --
xrpl.work.item/.branch/.node.role -- may be dotted. Span names, filenames,
OTel-standard keys, and metric labels are not flagged. A doc that names the
wrong form as a deliberate counter-example opts that mention out with a
key-scoped marker (RULE_E_MARKER_SYNTAX), which exempts ONLY the keys it
lists on the line it appears on -- any other dotted token on that same line
still fails.
I No string literals as METRIC instrument names or label keys. The mirror of
Rule F for metrics: the name passed to an XRPL_METRIC_* macro or to a
meter->Create* factory, and the label KEYS in its label set, must reference
a *MetricNames.h constant. Label VALUES are exempt (runtime data), as are
the descriptions, *MetricNames.h itself, MetricMacros.h, and test files.
Scoped by metric FAMILY (first underscore segment) so the metric surface
can be converted subsystem by subsystem -- declaring a constant for a
family opts that family into enforcement. See Rule L.
J Metric instrument names follow the naming and suffix conventions:
lower_snake_case, no xrpld_/xrpl_ prefix (the exporter adds it), a counter
ends in _total, a histogram ends in _us/_ms/_seconds, and a gauge does not
end in _total. The instrument KIND is read from the emit site, never
guessed from words in the name.
K Every metric named in expected_metrics.json resolves to a *MetricNames.h
constant, so a rename in code cannot leave the workload validator
asserting a name nothing emits. PromQL selectors and exporter-appended
histogram suffixes are normalized away first; groups fed by another emit
path (statsd_*, spanmetrics) are out of scope.
Warnings (printed, but do NOT fail the build)
----------------------------------------------
H A constant referenced at a telemetry call-site is not defined in any
*SpanNames.h. Span constants should live in the corresponding
*SpanNames.h (single source of truth); defining one in-place bypasses the
naming rules. A warning (not a failure) because the argument may instead
be a legitimately dynamic local (e.g. a computed span-name leaf).
E A RULE_E_REQUIRED_DOCS anchor is not present in the tree (renamed, moved,
or deleted -- so doc discovery can no longer be trusted to have found the
docs that matter), an allow-dotted marker names no key (so it exempts
nothing), or it names a key the line no longer mentions (a stale
exemption). Warnings, not failures, because presence-gating must keep
working on partial branches -- but the skip is now visible instead of
silent.
L A literal metric name in a family that has no *MetricNames.h constants
yet. Rule I's ratchet defers these rather than failing the build on the
whole pre-existing metric surface at once; the warning is what keeps the
outstanding conversion work visible instead of silently accepted.
Exit code is non-zero if any present-and-enforced rule finds a violation.
Warnings never change the exit code.
"""
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
# ---------------------------------------------------------------------------
# Repo location
# ---------------------------------------------------------------------------
def repo_root() -> Path:
"""Return the repository root, so the script works from any CWD.
Exits with a readable message (not a traceback) if git is unavailable or the
CWD is outside a repository."""
try:
out = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
print(
"error: check_otel_naming.py must be run inside the git repository.",
file=sys.stderr,
)
sys.exit(2)
return Path(out.stdout.strip())
def read_source(path: Path) -> str:
"""Read a file as UTF-8, tolerating stray non-UTF-8 bytes rather than
crashing the whole check on one bad byte."""
return path.read_text(encoding="utf-8", errors="ignore")
# ---------------------------------------------------------------------------
# Regexes (compiled once)
# ---------------------------------------------------------------------------
# A segment/string constant definition: `inline constexpr auto NAME = <expr>;`
CONST_DEF = re.compile(r"inline\s+constexpr\s+auto\s+(\w+)\s*=\s*(.+?);", re.DOTALL)
MAKESTR = re.compile(r'makeStr\(\s*"([^"]*)"\s*\)')
# A `namespace <name> {` opener, to track which namespace a constant lives in.
NS_OPEN = re.compile(r"namespace\s+([\w:]+)\s*\{")
# A `using ::a::b::field;` re-export inside an attr block; captures the leaf.
USING_DECL = re.compile(r"using\s+(?:::)?[\w:]*::(\w+)\s*;")
# Telemetry call-sites whose string arguments must be constants, not literals.
# Require a receiver so we match real SpanGuard calls, not std::span / a math
# `span(...)` / a bare method declaration:
# - `SpanGuard::span(` / `SpanGuard::rootSpan(` / `SpanGuard::childSpan(`
# (static factories)
# - `<obj>.span(` / `<obj>->setAttribute(` etc. (member call)
# `span`/`rootSpan`/`childSpan` additionally require the `SpanGuard`/`.`/`->`
# receiver; `setAttribute`/`addEvent` only ever exist on a guard, so a `.`/`->`
# suffices. `rootSpan` shares `span`'s (cat, prefix, name) signature.
CALLSITE = re.compile(
r"(?:SpanGuard::|\.|->)\s*(setAttribute|addEvent|span|rootSpan|childSpan)\s*\("
)
# A C++ string literal (used to flag literals inside call-site argument lists).
STRING_LITERAL = re.compile(r'"((?:[^"\\]|\\.)*)"')
# A C++ line comment (`//` ... end of line) and a block comment (`/* ... */`).
LINE_COMMENT = re.compile(r"//[^\n]*")
BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
# A TraceQL scope prefix on a label (`span.`, `resource.`, `event.`, etc.).
# Dashboards reference span attributes in TraceQL as `span.<attr>`; the bare
# attribute is what must exist in L1, so strip the scope before validating.
TRACEQL_SCOPE = re.compile(r"^(?:span|resource|event|link|instrumentation_scope)\.")
# An OTel metric label map as emitted in C++: the `{{...}}` argument of an
# instrument call, e.g. `counter->Add(1, {{"job_type", a}, {"handler", b}})`.
# Matching the whole map first, rather than scanning the file for `{"key",`,
# keeps ordinary brace initializers (`{"http", "https", ...}`) out of the
# label set — they are not labels and must not license a dashboard filter.
METRIC_LABEL_MAP = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
# One key inside such a map: a string literal, or a `kFooLabel` constant name
# resolved through LABEL_CONST_DEF. A label set is a nested initializer list
# (`{{"a", x}, {"b", y}}`), so inside the map each pair opens with `{` except
# the first, whose `{` was consumed by the map's own `{{` — hence the `^`
# alternative. Matching only the doubled-brace form would derive just the first
# label of every multi-label instrument, silently under-deriving the L6 key set
# and making Rule D reject a dashboard that queries a label the code genuinely
# emits.
METRIC_LABEL = re.compile(r'(?:^|\{)\s*"([a-z_][a-z0-9_]*)"\s*,')
METRIC_LABEL_CONST = re.compile(r"(?:^|\{)\s*(k[A-Za-z0-9_]*)\s*,")
# A label-key constant definition, with or without `inline`:
# `constexpr char kHandlerLabel[] = "handler";`
#
# This resolves the flat `k`-prefixed per-subsystem header style. The
# `namespace label` style used by MetricNames.h is covered separately, by
# metric_constants(), because those identifiers are not `k`-prefixed and are
# referenced at call sites as `label::jobType` — see metric_label_names().
LABEL_CONST_DEF = re.compile(
r'(?:inline\s+)?constexpr\s+char\s+(k[A-Za-z0-9_]*)\s*\[\s*\]\s*=\s*"([a-z_][a-z0-9_]*)"'
)
def strip_comments(text: str) -> str:
"""Remove C/C++ `//` line comments and `/* ... */` block comments.
Used only for L1 attribute-key extraction so that a commented-out or
illustrative `makeStr("...")` inside a `namespace attr` block does not leak
into the authoritative key set. Rule F deliberately does NOT strip comments
— it must still see `@code` doc-comment examples so their call-site
arguments are held to the constant-only convention.
String literals are not specially handled; a `//` or `/*` appearing inside a
string is vanishingly rare in the *SpanNames.h headers and would at worst
drop a constant from L1 (a conservative direction).
"""
text = BLOCK_COMMENT.sub("", text)
text = LINE_COMMENT.sub("", text)
return text
# ---------------------------------------------------------------------------
# L1: parse *SpanNames.h into the authoritative key set
# ---------------------------------------------------------------------------
def find_spanname_headers(root: Path) -> List[Path]:
return sorted(
p
for p in list((root / "src").rglob("*SpanNames.h"))
+ list((root / "include").rglob("*SpanNames.h"))
if p.is_file()
)
def resolve_constants(
text: str, symbols: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
"""Resolve `inline constexpr auto NAME = <makeStr/join expr>` to strings.
Supports the small constexpr DSL used by SpanNames.h:
makeStr("x") -> "x"
join(a, b) -> resolve(a) + "." + resolve(b)
seg::xrpl / attr::foo -> looked up in the symbol table
The optional `symbols` argument seeds (and is updated in place with) the
table, so a global pass over ALL *SpanNames.h headers can resolve
cross-file references such as `join(seg::rpc, ...)` where `seg::rpc` is
defined in the base SpanNames.h. Keys are stored by their bare name
(last `::` component), so `seg::rpc` and `rpc` both resolve.
"""
if symbols is None:
symbols = {}
def resolve_expr(expr: str) -> Optional[str]:
expr = expr.strip()
m = MAKESTR.fullmatch(expr)
if m:
return m.group(1)
if expr.startswith("join(") and expr.endswith(")"):
args = split_top_level_args(expr[len("join(") : -1])
parts = [resolve_expr(a) for a in args]
if any(p is None for p in parts):
return None
return ".".join(p for p in parts if p is not None)
# Bare or qualified symbol reference, e.g. `seg::xrpl` or `networkId`.
key = expr.split("::")[-1]
return symbols.get(key, symbols.get(expr))
# Iterate definitions in source order so earlier symbols are available.
for m in CONST_DEF.finditer(text):
name, expr = m.group(1), m.group(2)
val = resolve_expr(expr)
if val is not None:
symbols[name] = val
return symbols
def build_global_symbols(headers: List[Path]) -> Dict[str, str]:
"""Resolve constants across ALL headers so cross-file `seg::`/`join`
references (e.g. `join(seg::rpc, ...)` in RpcSpanNames.h, where `seg::rpc`
lives in the base SpanNames.h) resolve. Base SpanNames.h is processed
first so its `seg::` segments seed the table."""
symbols: Dict[str, str] = {}
ordered = sorted(headers, key=lambda p: (p.name != "SpanNames.h", str(p)))
# Two passes: the first seeds segments, the second resolves dependents.
# Comments are stripped so a commented-out constant cannot seed the table.
for _ in range(2):
for h in ordered:
resolve_constants(strip_comments(read_source(h)), symbols)
return symbols
def split_top_level_args(s: str) -> List[str]:
"""Split a comma-separated arg list, respecting nested parentheses and
ignoring parens/commas that appear inside a "string literal" (so a value
like `setAttribute(k, ",")` does not get mis-split)."""
args, depth, cur = [], 0, ""
in_str = False
escaped = False
for ch in s:
if in_str:
cur += ch
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
cur += ch
elif ch == "(":
depth += 1
cur += ch
elif ch == ")":
depth -= 1
cur += ch
elif ch == "," and depth == 0:
args.append(cur)
cur = ""
else:
cur += ch
if cur.strip():
args.append(cur)
return args
def attr_namespace_spans(text: str) -> List[str]:
"""Return the source text of each `namespace attr { ... }` block in `text`.
Brace-matched over the whole (comment-stripped) text, so a definition that
wraps across several physical lines is contained in one span. Nested braces
inside the block are balanced correctly."""
spans: List[str] = []
for opener in NS_OPEN.finditer(text):
if opener.group(1).split("::")[-1] != "attr":
continue
# Walk from the opening brace, balancing nesting to the matching close.
i = opener.end() # one char past the namespace's `{`
depth = 1
start = i
while i < len(text) and depth > 0:
c = text[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
i += 1
spans.append(text[start : i - 1])
return spans
def attr_keys_from_header(path: Path, symbols: Dict[str, str]) -> Set[str]:
"""Return the set of attribute-key strings declared in a header's
`namespace attr { ... }` block(s). `symbols` is the global cross-file
table, used ONLY to seed `seg::`/segment references for `join(...)`
resolution — never to look up an attr constant's value.
A constant DEFINED in this header is resolved against this header's OWN
text, so two headers that each define a same-named constant (e.g. the base
`attr::ledgerHash = xrpl.ledger.hash` and consensus
`attr::ledgerHash = ledger_hash`) each report their real wire key. The
global table is keyed by bare name and would otherwise let a later header
clobber an earlier one, erasing the real key from L1 (a Rule-A blind spot).
A `using`-re-export, by contrast, imports a constant defined elsewhere, so
it is resolved against the global table.
Comments are stripped first (a commented constant must not enter L1), and
each attr block is brace-matched over the whole text so multi-line
`inline constexpr auto NAME = join(\\n ...);` definitions are captured."""
text = strip_comments(read_source(path))
# Local table: the global segments/symbols seed cross-file `join` parts,
# then this header's own definitions overwrite any same-named global entry
# so a locally-defined attr resolves to ITS value, not another header's.
local = dict(symbols)
resolve_constants(text, local)
keys: Set[str] = set()
for block in attr_namespace_spans(text):
for md in CONST_DEF.finditer(block):
# Resolve a locally-defined constant against the LOCAL table; this
# captures makeStr("x") and join(seg::y, ...) with the header's own
# value, immune to cross-header bare-name collisions.
val = local.get(md.group(1))
if val is not None:
keys.add(val)
# `using ::ns::attr::field;` re-exports a constant defined in ANOTHER
# header (e.g. PeerSpanNames imports the base ledgerHash). Resolve the
# imported name against the global table.
for um in USING_DECL.finditer(block):
val = symbols.get(um.group(1))
if val is not None:
keys.add(val)
return keys
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
class Report:
def __init__(self) -> None:
self.violations: List[Tuple[str, str, str, str]] = []
self.warnings: List[Tuple[str, str, str, str]] = []
self.skips: List[str] = []
self.checked: List[str] = []
def violation(self, rule: str, loc: str, token: str, expected: str) -> None:
self.violations.append((rule, loc, token, expected))
def warning(self, rule: str, loc: str, token: str, note: str) -> None:
"""A non-fatal finding: printed, but does not fail the build. Used where
the script cannot be certain a finding is wrong (e.g. a constant used at
a call-site that is not defined in any *SpanNames.h — it might be a
misplaced constant, or a legitimately dynamic value)."""
self.warnings.append((rule, loc, token, note))
def skip(self, rule: str, reason: str) -> None:
self.skips.append(f"SKIP: {rule}{reason}")
def ok(self, msg: str) -> None:
self.checked.append(f"OK: {msg}")
def render_and_exit(self) -> None:
for line in self.skips:
print(line)
for line in self.checked:
print(line)
if self.warnings:
print("\nNaming-convention warnings (non-fatal):\n")
print(f" {'RULE':<5} {'LOCATION':<48} {'TOKEN':<28} NOTE")
print(f" {'-' * 5} {'-' * 48} {'-' * 28} {'-' * 30}")
for rule, loc, token, note in self.warnings:
print(f" {rule:<5} {loc:<48} {token:<28} {note}")
if self.violations:
print("\nNaming-convention violations:\n")
print(f" {'RULE':<5} {'LOCATION':<48} {'TOKEN':<28} EXPECTED")
print(f" {'-' * 5} {'-' * 48} {'-' * 28} {'-' * 30}")
for rule, loc, token, expected in self.violations:
print(f" {rule:<5} {loc:<48} {token:<28} {expected}")
print(
"\nSee CONTRIBUTING.md -> 'Telemetry span attribute naming'. "
"The *SpanNames.h constants are the single source of truth."
)
sys.exit(1)
print("\nAll present telemetry naming layers are consistent.")
sys.exit(0)
def main() -> None:
root = repo_root()
report = Report()
# --- Build the L1 ground-truth key set (presence-gated) ----------------
headers = find_spanname_headers(root)
l1_keys: Set[str] = set()
if headers:
symbols = build_global_symbols(headers)
# Map each key to the header(s) that declare it, so Rule A can tell a
# legitimate resource attr (declared in the base SpanNames.h) from a
# stray dotted key declared in a domain header.
keys_by_header: Dict[Path, Set[str]] = {}
for h in headers:
hk = attr_keys_from_header(h, symbols)
keys_by_header[h] = hk
l1_keys |= hk
report.ok(
f"L1: {len(l1_keys)} attribute keys from {len(headers)} "
f"*SpanNames.h header(s)"
)
else:
report.skip("L1", "no *SpanNames.h present (not a naming-relevant tree)")
keys_by_header = {}
# --- Derive the legitimate dotted (resource) keys dynamically ----------
# ONLY the keys actually passed to Resource::Create() in Telemetry.cpp
# (semconv service.* + the attr:: constants set there, e.g. xrpl.network.*).
# A dotted key declared in a header but NOT set as a resource attr is a
# Rule-A violation, not an allowlist entry.
resource_symbols = symbols if headers else {}
dotted_allow = derive_dotted_resource_keys(root, resource_symbols, report)
# --- Rule A: no stray dotted span-attribute keys -----------------------
if l1_keys:
run_rule_a(keys_by_header, dotted_allow, report)
# --- Rule G: keys must be lower_snake_case -----------------------------
if l1_keys:
run_rule_g(keys_by_header, report)
# --- Rule F (+ Rule H): scan telemetry call-sites ----------------------
# Runs UNCONDITIONALLY: Rule F is a purely syntactic check (is this argument
# a literal?) and does not need the L1 key set, so a code path that uses
# SpanGuard::span/setAttribute directly without ever defining a *SpanNames.h
# is still caught. Rule H (warning) additionally flags constant references
# not defined in any *SpanNames.h.
header_symbols = spanname_symbol_names(headers)
run_rule_f(root, report, header_symbols)
# --- Cross-layer rules B/C/D/E (each presence-gated) -------------------
# L6 native-metric labels: span attributes are not the only valid dashboard
# labels — the MetricsRegistry emits OTel metrics whose label keys are an
# additional source of truth. Derive them dynamically (same principle as L1)
# so dashboards may reference them without tripping Rule D.
metric_labels = metric_label_names(root)
run_rule_b_collector(root, l1_keys, report)
run_rule_c_tempo(root, l1_keys, report)
run_rule_d_dashboards(root, l1_keys, metric_labels, report)
run_rule_e_docs(root, l1_keys, report)
# --- Metric rules I/J/K --------------------------------------------------
# The metric-name counterpart of the span rules above. Rule I is the mirror
# of Rule F (no literals at an emit site) and, like it, runs
# unconditionally because it is purely syntactic. Rules J and K are
# presence-gated on *MetricNames.h existing.
l1_metric_names, l1_metric_labels, _ = metric_constants(root)
if find_metricname_headers(root):
report.ok(
f"L1-metrics: {len(l1_metric_names)} instrument name(s) and "
f"{len(l1_metric_labels)} label key(s) from "
f"{len(find_metricname_headers(root))} *MetricNames.h header(s)"
)
else:
report.skip("L1-metrics", "no *MetricNames.h present")
run_rule_i_metric_literals(root, report)
run_rule_j_metric_suffixes(root, report)
run_rule_k_expected_metrics(root, report)
report.render_and_exit()
def resource_create_block(text: str) -> str:
"""Return the text inside the first `Resource::Create({ ... })` argument
list, brace-matched so nested `{key, value}` initializers are contained.
Empty string if the call is absent."""
m = re.search(r"Resource::Create\(\s*\{", text)
if not m:
return ""
i = m.end() # one char past the opening `{`
depth, start = 1, i
while i < len(text) and depth > 0:
c = text[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
i += 1
return text[start : i - 1]
def derive_dotted_resource_keys(
root: Path, symbols: Dict[str, str], report: Report
) -> Set[str]:
"""Legitimate dotted keys = ONLY the keys the code actually sets as RESOURCE
attributes, i.e. the entries inside Telemetry.cpp's `Resource::Create({...})`
call: the standard semconv keys (`service.*`) plus any `attr::<name>`
constants passed there (resolved to their wire key via the global symbol
table, e.g. `attr::networkId` -> `xrpl.network.id`).
A dotted key DECLARED in a `*SpanNames.h` header but NOT passed to
Resource::Create() is a span attribute wearing the resource form — a Rule-A
violation, never allowlisted. Deriving the allowlist from the actual
resource call (not from "any dotted key in the base header") is what lets
Rule A catch a stray dotted span attr such as `xrpl.ledger.hash`."""
allow: Set[str] = set()
tele = root / "src" / "libxrpl" / "telemetry" / "Telemetry.cpp"
if not tele.is_file():
report.skip("resource-derive", "Telemetry.cpp not present")
return allow
block = resource_create_block(read_source(tele))
# semconv::<group>::k<CamelKey> -> the dotted OTel-standard key. The
# CamelKey already embeds the group, e.g. service::kServiceInstanceId
# -> service.instance.id. Split the CamelCase name into dotted lowercase
# segments; if it does not lead with the group, prepend the group.
for m in re.finditer(r"semconv::(\w+)::k(\w+)", block):
group, camel = m.group(1), m.group(2)
segments = camel_to_dotsegments(camel)
if segments and segments[0] == group:
allow.add(".".join(segments))
else:
allow.add(group + "." + ".".join(segments))
# attr::<name> constants set as resource attrs (e.g. networkId/networkType);
# resolve each to its wire key and allowlist only the dotted ones.
for m in re.finditer(r"attr::(\w+)", block):
val = symbols.get(m.group(1))
if val is not None and "." in val:
allow.add(val)
report.ok(f"resource dotted-key allowlist derived: {sorted(allow)}")
return allow
def camel_to_dotsegments(s: str) -> List[str]:
"""Split a CamelCase identifier into lowercase dot-segment parts, e.g.
`ServiceInstanceId` -> ['service', 'instance', 'id']."""
return [w.lower() for w in re.findall(r"[A-Z][a-z0-9]*", s)]
def run_rule_a(
keys_by_header: Dict[Path, Set[str]], dotted_allow: Set[str], report: Report
) -> None:
"""Any dotted attribute key that is not an allowed resource key is a
violation, reported against the header that declares it."""
found = False
for h in sorted(keys_by_header):
for key in sorted(keys_by_header[h]):
if "." in key and key not in dotted_allow:
found = True
report.violation("A", h.name, key, "underscore form, not dotted")
if not found:
report.ok("A: no stray dotted span-attribute keys")
# A lower_snake_case identifier segment: starts lowercase, then lowercase /
# digits / underscores. No uppercase, no spaces, no camelCase.
SNAKE_SEGMENT = re.compile(r"^[a-z][a-z0-9_]*$")
def run_rule_g(keys_by_header: Dict[Path, Set[str]], report: Report) -> None:
"""Every attribute key must be lower_snake_case. Bare/underscore keys must
match ^[a-z][a-z0-9_]*$; dotted resource keys must be lowercase
dot-separated segments (each segment lower_snake_case). Flags camelCase,
UPPERCASE, spaces, and other stray characters."""
found = False
for h in sorted(keys_by_header):
for key in sorted(keys_by_header[h]):
segments = key.split(".")
if all(SNAKE_SEGMENT.match(seg) for seg in segments):
continue
found = True
report.violation("G", h.name, key, "must be lower_snake_case")
if not found:
report.ok("G: all attribute keys are lower_snake_case")
# Which argument positions of each call must be a constant (0-based). The
# attribute VALUE position is intentionally absent: values are runtime data
# (command names, hashes, counts), not naming-convention surface.
# setAttribute(key, value) -> check arg 0 (key); value (arg 1) exempt
# addEvent(name[, attrs]) -> check arg 0 (event name)
# span(category, prefix, name) -> check args 1,2 (prefix + span-name leaf)
# rootSpan(category, prefix, name)-> check args 1,2 (same signature as span)
# childSpan(name[, parentCtx]) -> check arg 0 (span-name leaf)
CONSTANT_ARG_POSITIONS: Dict[str, Set[int]] = {
"setAttribute": {0},
"addEvent": {0},
"span": {1, 2},
"rootSpan": {1, 2}, # same signature as span(cat, prefix, name)
"childSpan": {0},
}
def is_test_path(path: Path) -> bool:
"""True if the path is test code. Tests legitimately pass arbitrary literal
keys/names to exercise the API mechanics, so Rule F does not apply to them.
Matches a `test`/`tests` directory anywhere in the path (e.g. src/test/,
src/tests/, .../detail/tests/)."""
return any(part in ("test", "tests") for part in path.parts)
# A constant reference passed at a call-site, e.g. `rpc_span::attr::command`
# or a bare `myKey`. We capture the leaf identifier (after the last `::`).
IDENTIFIER_ARG = re.compile(r"^[\s&*]*([A-Za-z_][\w:]*)\s*$")
def spanname_symbol_names(headers: List[Path]) -> Set[str]:
"""Every `inline constexpr auto NAME = ...;` symbol defined across the
*SpanNames.h headers, by bare name. Used by Rule H to tell whether a
constant referenced at a call-site actually lives in a SpanNames header."""
names: Set[str] = set()
for h in headers:
for m in CONST_DEF.finditer(strip_comments(read_source(h))):
names.add(m.group(1))
return names
def run_rule_f(root: Path, report: Report, header_symbols: Set[str]) -> None:
"""Walk every telemetry call-site (non-test, non-*SpanNames.h) and check the
constant-only argument positions of
setAttribute/addEvent/span/rootSpan/childSpan:
Rule F (FAIL): a string literal in a key / span-name position. Attribute
VALUES are exempt (runtime data).
Rule H (WARN): a constant reference whose name is not defined in any
*SpanNames.h. The constant should live in the corresponding
*SpanNames.h (single source of truth); defining it in-place bypasses
the naming rules. Warn rather than fail — the argument may instead be a
legitimately dynamic local (e.g. a computed span-name leaf)."""
found_f = False
sources = [
p
for base in ("src", "include")
for ext in ("*.h", "*.cpp")
for p in (root / base).rglob(ext)
if p.is_file()
]
for path in sorted(sources):
if path.name.endswith("SpanNames.h") or is_test_path(path):
continue
text = read_source(path)
rel = path.relative_to(root)
for call, arglist, lineno in iter_calls(text):
positions = CONSTANT_ARG_POSITIONS.get(call, set())
args = split_top_level_args(arglist)
for idx in positions:
if idx >= len(args):
continue
arg = args[idx]
lit = STRING_LITERAL.search(arg)
if lit:
found_f = True
report.violation(
"F",
f"{rel}:{lineno}",
f'{call} arg{idx} "{lit.group(1)}"',
"use a *SpanNames.h constant",
)
continue
# Not a literal: Rule H warns when a NAMESPACE-QUALIFIED constant
# reference (e.g. `consensus::span::accept`) is not defined in
# any *SpanNames.h — i.e. the constant was defined in-place
# instead of in the proper header. We only consider qualified
# refs (containing `::`): a bare lowercase identifier is almost
# always a legitimately dynamic local (a computed span-name leaf
# or attribute value), not a misplaced constant, so warning on it
# would be noise. Standard-library types (std::...) are skipped.
ident = IDENTIFIER_ARG.match(arg)
if not (ident and header_symbols):
continue
ref = ident.group(1)
if "::" not in ref or ref.startswith("std::"):
continue
leaf = ref.split("::")[-1]
if leaf not in header_symbols:
report.warning(
"H",
f"{rel}:{lineno}",
f"{call} arg{idx} {ref}",
"not defined in any *SpanNames.h",
)
if not found_f:
report.ok("F: no string-literal keys/names at telemetry call-sites")
def iter_calls(text: str):
"""Yield (call_name, raw_arglist, lineno) for each setAttribute/addEvent/
span/rootSpan/childSpan invocation, spanning multiple physical lines if
needed."""
for m in CALLSITE.finditer(text):
name = m.group(1)
# Walk from the opening paren, balancing nesting to find the close.
# Parens inside a "string literal" are ignored so a value such as
# `setAttribute(k, ")")` does not close the call early.
i = m.end() # one char past the '('
depth = 1
in_str = False
escaped = False
while i < len(text) and depth > 0:
c = text[i]
if in_str:
if escaped:
escaped = False
elif c == "\\":
escaped = True
elif c == '"':
in_str = False
elif c == '"':
in_str = True
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
i += 1
arglist = text[m.end() : i - 1]
lineno = text.count("\n", 0, m.start()) + 1
yield name, arglist, lineno
def run_rule_b_collector(root: Path, l1_keys: Set[str], report: Report) -> None:
path = root / "docker" / "telemetry" / "otel-collector-config.yaml"
if not path.is_file():
report.skip("B", "collector config not present")
return
text = read_source(path)
if "spanmetrics" not in text:
report.skip("B", "no spanmetrics block in collector config")
return
dims = extract_spanmetrics_dimensions(text)
if not l1_keys:
report.skip("B", "no L1 key set to validate against")
return
miss = [d for d in dims if d not in l1_keys]
for d in miss:
report.violation("B", str(path.relative_to(root)), d, "must exist in L1")
if not miss:
report.ok(f"B: {len(dims)} collector dimension(s) all in L1")
def extract_spanmetrics_dimensions(text: str) -> List[str]:
dims: List[str] = []
in_dims = False
for line in text.splitlines():
if re.search(r"\bdimensions\s*:", line):
in_dims = True
continue
if in_dims:
m = re.search(r"-\s*name\s*:\s*([A-Za-z0-9_.]+)", line)
if m:
dims.append(m.group(1))
elif line.strip() and not line.lstrip().startswith("-") and ":" in line:
in_dims = False
return dims
def run_rule_c_tempo(root: Path, l1_keys: Set[str], report: Report) -> None:
# The trace-search filter tags live in the Grafana Tempo DATASOURCE
# provisioning file (search.filters[].{tag,scope}); the Tempo server
# tempo.yaml has no such tags. Prefer the datasource file; fall back to the
# server file so the rule still does something if the layout changes.
candidates = [
root / "docker/telemetry/grafana/provisioning/datasources/tempo.yaml",
root / "docker/telemetry/tempo.yaml",
]
path = next((p for p in candidates if p.is_file()), None)
if path is None:
report.skip("C", "tempo datasource provisioning not present")
return
if not l1_keys:
report.skip("C", "no L1 key set to validate against")
return
# Pair each filter's `tag:` with its `scope:` (a few lines below it) and
# validate only span-scope tags — resource/intrinsic tags (service.*, name,
# status, duration) are not span attributes. Strip a TraceQL span. prefix.
lines = read_source(path).splitlines()
span_tags: List[str] = []
for i, line in enumerate(lines):
m = re.search(r"^\s*tag:\s*(\S+)", line)
if not m:
continue
scope = next(
(
sm.group(1)
for j in range(i, min(i + 4, len(lines)))
for sm in [re.search(r"scope:\s*(\S+)", lines[j])]
if sm
),
"",
)
if scope == "span":
span_tags.append(TRACEQL_SCOPE.sub("", m.group(1)))
if not span_tags:
report.skip("C", "no span-scope filter tags in tempo datasource")
return
miss = [t for t in span_tags if t not in l1_keys]
for t in sorted(set(miss)):
report.violation("C", str(path.relative_to(root)), t, "must exist in L1")
if not miss:
report.ok(f"C: {len(span_tags)} tempo span-filter tag(s) all in L1")
def metric_label_names(root: Path) -> Set[str]:
"""L6: OTel native-metric label keys emitted by the telemetry code, e.g.
`counter->Add(1, {{"job_type", value}})` in MetricsRegistry.cpp. These are
a valid source of dashboard labels distinct from span attributes (L1).
Collects both the first label of a set and every subsequent one, so a
multi-label instrument such as `{{"from", a}, {"to", b}}` contributes ALL
of its keys.
A label key may be written inline as a string literal or hoisted into a
named constant (`constexpr char kHandlerLabel[] = "handler";`, then
`{{kHandlerLabel, value}}`). Both forms are collected, and the constants
may be declared in a header, so headers are scanned as well as sources.
Also includes the keys declared as constants in `*MetricNames.h`
(L1-metrics), because Rule I requires call sites to reference a constant
rather than a literal — so once a subsystem is converted, its label keys no
longer appear as literals anywhere and a literal-only scan would
under-derive the set and make Rule D reject a dashboard querying a label
the code really emits. That covers the `namespace label` header style,
whose identifiers are not `k`-prefixed and so are invisible to
LABEL_CONST_DEF; the two derivations are complementary."""
labels: Set[str] = set()
# Constant name -> label string, gathered across every scanned file so a
# `{{kFooLabel, ...}}` call site resolves even when the definition lives in
# a different file from the instrument call.
constants: Dict[str, str] = {}
used_constants: Set[str] = set()
for base in ("src", "include"):
for pattern in ("*.cpp", "*.h"):
for p in (root / base).rglob(pattern):
if not p.is_file():
continue
# Test code passes arbitrary literal pairs to exercise APIs
# (`signers("alice", 1, {{"alice", 1}, {"bob", 2}})`). Those are
# not metric labels, and must not license a dashboard filter.
if is_test_path(p):
continue
text = read_source(p)
if "MetricsRegistry" not in p.name and "metric" not in text.lower():
continue
constants.update(dict(LABEL_CONST_DEF.findall(text)))
for label_map in METRIC_LABEL_MAP.findall(text):
labels |= set(METRIC_LABEL.findall(label_map))
used_constants |= set(METRIC_LABEL_CONST.findall(label_map))
labels |= {constants[name] for name in used_constants if name in constants}
labels |= metric_constants(root)[1]
return labels
# ---------------------------------------------------------------------------
# L1-metrics: parse *MetricNames.h into the authoritative metric-name/label set
# ---------------------------------------------------------------------------
# A metric-name constant definition: `inline constexpr char NAME[] = "wire";`.
# The metric headers use `constexpr char[]` rather than the `makeStr`/StaticStr
# DSL the span headers use, because the OTel C++ API takes nostd::string_view,
# which constructs from `char const*` but NOT from std::string_view.
METRIC_CONST_DEF = re.compile(
r"inline\s+constexpr\s+char\s+(\w+)\s*\[\s*\]\s*=\s*\"([^\"]*)\"\s*;"
)
def find_metricname_headers(root: Path) -> List[Path]:
"""Every `*MetricNames.h` in the tree (the metric-side counterpart of
`find_spanname_headers`)."""
return sorted(
p
for p in list((root / "src").rglob("*MetricNames.h"))
+ list((root / "include").rglob("*MetricNames.h"))
if p.is_file()
)
def metric_constants(root: Path) -> Tuple[Set[str], Set[str], Set[str]]:
"""Return (instrument_names, label_keys, label_values) declared across the
`*MetricNames.h` headers, split by which namespace block each constant sits
in: `namespace metric` -> instrument names, `namespace label` -> label keys,
`namespace lval` -> bounded label values.
Comments are stripped first, so an illustrative `@code` example in a doc
comment cannot seed the authoritative set (same reasoning as
`strip_comments` for L1 spans).
Two header styles are recognised, because both are in use:
* Namespaced: constants sit inside `namespace metric` / `label` / `lval`,
and the enclosing namespace decides the bucket.
* Flat `k`-prefixed: a header with no such namespaces names the role in the
identifier instead -- `kLabelFoo` is a label key, `kResultFoo` and
`kReasonFoo` are label values. Used by the per-subsystem headers.
A constant that neither sits in one of the three namespaces nor carries a
recognised prefix is ignored rather than guessed at, keeping the derivation
conservative in the same direction as L1."""
names: Set[str] = set()
keys: Set[str] = set()
values: Set[str] = set()
for h in find_metricname_headers(root):
text = strip_comments(read_source(h))
flat = text
for ns, bucket in (
("metric", names),
("label", keys),
("lval", values),
):
for block in namespace_spans(text, ns):
for m in METRIC_CONST_DEF.finditer(block):
bucket.add(m.group(2))
# Remove the block before the flat pass below, so the
# enclosing namespace stays the only thing that decides a
# namespaced constant's bucket. Without this, a `kLabel`-style
# identifier written inside `namespace metric` lands in both
# `names` and `keys`, and an instrument name silently becomes
# an allowed label key for Rule D.
flat = flat.replace(block, "", 1)
# Flat style: classify by identifier prefix. Only constants outside the
# namespaced blocks reach here, so a namespaced header is unaffected.
for m in METRIC_CONST_DEF.finditer(flat):
ident, value = m.group(1), m.group(2)
if ident.startswith("kLabel"):
keys.add(value)
elif ident.startswith(("kResult", "kReason")):
values.add(value)
return names, keys, values
def namespace_spans(text: str, want: str) -> List[str]:
"""Return the body text of each `namespace <want> { ... }` block, brace
matched so nested namespaces (e.g. `lval::dns_resolve`) are contained in
their parent's span. Generalizes `attr_namespace_spans`, which is hardcoded
to `attr`."""
spans: List[str] = []
for opener in NS_OPEN.finditer(text):
if opener.group(1).split("::")[-1] != want:
continue
i = opener.end()
depth, start = 1, i
while i < len(text) and depth > 0:
c = text[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
i += 1
spans.append(text[start : i - 1])
return spans
# Identity labels stamped by EXTERNAL infrastructure the OTel pipeline in this
# repo does not own: the perf-iac harness attaches these to every metric it
# scrapes so dashboards can filter by which build/role produced a series. They
# have no L1 (*SpanNames.h), L2 (collector config), or L6 (MetricsRegistry.cpp)
# source to derive from, so — unlike every other dashboard label — they cannot
# be validated dynamically. This is a deliberate, narrow exception to the "no
# hardcoded allowlist" design principle, kept separate from the generic
# Prometheus/Grafana `builtins` set below so it stays visible and auditable.
# Add a label here ONLY if it is genuinely injected by infra outside this
# repo's OTel code (never as a workaround for a dashboard querying a label
# that nothing actually emits — that is a real Rule D violation).
EXTERNAL_INFRA_LABELS = {
"xrpl_work_item", # perf-iac: ticket/work-item id for the perf comparison run
"xrpl_branch", # perf-iac: git ref of the xrpld build under test
"xrpl_node_role", # perf-iac: validator/peer role in the perf cluster
}
# L5 doc layer (Rule E): the doc trees whose markdown publishes span-attribute
# keys a reader is expected to copy into a TraceQL/PromQL query. A dotted
# `xrpl.*` key left in any of them hands out a query that silently matches
# nothing, so the whole tree is checked, not just the operator runbook:
# * docs/ operator runbook, telemetry glossary, and the
# build/telemetry guides (recursively)
# * docker/telemetry/ stack bring-up guide; its TraceQL/PromQL examples
# are meant to be pasted verbatim
#
# The set is DISCOVERED (every `*.md` under these roots), never enumerated. Two
# reasons this beats a hardcoded path list:
# * it cannot go stale -- a telemetry doc added, renamed, or moved within a
# root is picked up with no edit here, which is the same "derive it, do not
# hardcode it" principle the rest of this script follows (design
# principle 1), and coverage therefore grows with the docs; and
# * it names only doc ROOTS that ship on the default branch, so the rule can
# never be wired to a path that does not exist there.
# Each root is presence-gated (design principle 2): a branch that carries only
# one of them still gets that one checked. Both roots are also CI path-triggers
# for this check -- see `.github/workflows/on-pr.yml`.
RULE_E_DOC_ROOTS = (
Path("docs"),
Path("docker") / "telemetry",
)
# The anchor doc(s) discovery is REQUIRED to find. Discovery on its own can pass
# vacuously: if a root were renamed away, or emptied of markdown, Rule E would
# report a clean zero/near-zero-file run and nobody would notice the layer had
# stopped being checked. Demanding the one doc that is the whole reason Rule E
# exists turns that silent green into a visible warning (plus a count on the OK
# line). Deliberately tiny: this is a tripwire, not a second doc list. Every
# entry must live under RULE_E_DOC_ROOTS, or discovery could never find it.
RULE_E_REQUIRED_DOCS = (Path("docs") / "telemetry-runbook.md",)
def rule_e_docs(root: Path) -> List[Path]:
"""Discover the Rule E doc set: every `*.md` under RULE_E_DOC_ROOTS.
Returns repo-relative paths, sorted for stable reporting and de-duplicated
so overlapping roots (say `docs` and `docs/build`) cannot check one file
twice. A root absent from the tree is skipped rather than treated as an
error, so presence gating applies to the roots as it does to every other
layer."""
found: Set[Path] = set()
for rel_root in RULE_E_DOC_ROOTS:
base = root / rel_root
if not base.is_dir():
continue
found.update(p.relative_to(root) for p in base.rglob("*.md") if p.is_file())
return sorted(found)
# Line-scoped AND key-scoped opt-out for Rule E. Put it on a line that names a
# dotted key as a deliberate counter-example ("`tx_hash`, not `xrpl.tx.hash`") —
# a mention, not a published attribute key — and list exactly the keys that line
# is allowed to mention:
#
# ... use `tx_hash`, not `xrpl.tx.hash`.
# <!-- otel-naming:allow-dotted: xrpl.tx.hash -->
#
# The keys are part of the marker so an exemption cannot widen silently: a dotted
# token added to an already-marked line later, and not named in the marker, still
# fails. A marker with an empty key list therefore exempts NOTHING (and warns) —
# it is not a blanket line opt-out. NEVER use the marker to keep a real attribute
# table dotted; fix the table instead.
RULE_E_MARKER_SYNTAX = "<!-- otel-naming:allow-dotted: <key>[, <key>...] -->"
# The marker itself; group(1) is the raw key list (empty for the bare form).
# `[^>]` keeps the match inside one comment, so a marker cannot swallow the rest
# of the line, and the whole thing is matched per line (never across lines).
RULE_E_MARKER = re.compile(r"<!--\s*otel-naming:allow-dotted\s*:?\s*([^>]*?)\s*-->")
# Keys inside the marker are separated by commas and/or whitespace.
RULE_E_MARKER_KEY_SEP = re.compile(r"[,\s]+")
# The only doc form Rule E flags: a backticked dotted `xrpl.<domain>.<field>`.
RULE_E_DOTTED_TOKEN = re.compile(r"`(xrpl\.[a-z][a-z0-9_.]*)`")
def rule_e_allowed_keys(line: str) -> Tuple[Set[str], int]:
"""Parse every Rule-E allow-dotted marker on one line.
Returns `(keys named by the markers, number of markers seen)`. The count is
returned separately so the caller can tell "no marker" (enforce everything,
silently) from "a marker that names no key" (enforce everything, and warn
that the marker does nothing).
Keys may be written bare or backticked and separated by commas and/or
spaces, so both of these parse to the same two keys::
<!-- otel-naming:allow-dotted: xrpl.tx.hash, xrpl.peer.id -->
<!-- otel-naming:allow-dotted: `xrpl.tx.hash` `xrpl.peer.id` -->
"""
keys: Set[str] = set()
count = 0
for m in RULE_E_MARKER.finditer(line):
count += 1
for raw in RULE_E_MARKER_KEY_SEP.split(m.group(1)):
token = raw.strip().strip("`")
if token:
keys.add(token)
return keys, count
# Datasource types whose query language draws its label names from the log
# stream at query time rather than from anything this repo's OTel code emits.
#
# A LogQL label has a third provenance Rule D cannot model: it is minted either
# by the collector's `regex_parser` named capture groups (partition, severity —
# see otel-collector-config.yaml) or by a `| regexp` stage inside the query
# itself (action, pk, state, mode, phase, jobname, ip, pubkey). Both create the
# label at ingest/query time, so no L1 (*SpanNames.h) or L6 (MetricsRegistry)
# lookup can ever resolve them — checking them against those layers reports a
# violation for a label that is correct by construction.
#
# This skips the QUERY, not the file: a dashboard mixing Prometheus and Loki
# panels still has its Prometheus panels validated.
LOG_QUERY_DATASOURCES = {"loki"}
# JSON keys that hold a query string (`expr` on panel targets, `query` on
# template variables).
QUERY_KEYS = ("expr", "query")
def datasource_type(node: object) -> Optional[str]:
"""Return the lowercased `datasource.type` of a dashboard node, if any.
Grafana writes the datasource either as an object (`{"type": "loki",
"uid": "..."}`) or, on template variables, as a bare uid string. Only the
object form carries the type, which is what identifies the query language.
"""
if isinstance(node, dict):
ds = node.get("datasource")
if isinstance(ds, dict):
ds_type = ds.get("type")
if isinstance(ds_type, str):
return ds_type.lower()
return None
def iter_dashboard_queries(node: object, inherited: Optional[str] = None):
"""Yield `(query_string, datasource_type)` for every query in a dashboard.
`datasource_type` is the query's own datasource when it declares one, else
the nearest enclosing one (a panel's datasource covers its targets), else
None. Walks the whole tree so panels nested inside collapsed rows are not
missed.
"""
if isinstance(node, dict):
current = datasource_type(node) or inherited
for key in QUERY_KEYS:
value = node.get(key)
if isinstance(value, str) and value:
yield value, current
for value in node.values():
yield from iter_dashboard_queries(value, current)
elif isinstance(node, list):
for value in node:
yield from iter_dashboard_queries(value, inherited)
def promoted_resource_labels(root: Path) -> Set[str]:
"""Return resource attributes the collector promotes to metric labels.
The spanmetrics connector's `resource_metrics_key_attributes` lists the
resource attributes copied onto every derived metric datapoint, where the
prometheus exporter rewrites dots to underscores. So `deployment.environment`
in the collector config becomes the `deployment_environment` label a
dashboard filters on. Both forms are returned: the underscore form for
PromQL, the dotted form for TraceQL `resource.<key>` references.
Derived from the config rather than hardcoded, so adding a key there is
picked up automatically -- the same no-allowlist principle as Rules B and C.
"""
path = root / "docker" / "telemetry" / "otel-collector-config.yaml"
if not path.is_file():
return set()
try:
text = read_source(path)
except OSError:
return set()
labels: Set[str] = set()
in_block = False
for line in text.splitlines():
if re.search(r"\bresource_metrics_key_attributes\s*:", line):
in_block = True
continue
if in_block:
m = re.match(r"\s*-\s*([A-Za-z0-9_.]+)\s*$", line)
if m:
key = m.group(1)
labels.add(key.replace(".", "_"))
labels.add(key)
elif line.strip() and not line.lstrip().startswith("-"):
in_block = False
return labels
def run_rule_d_dashboards(
root: Path, l1_keys: Set[str], metric_labels: Set[str], report: Report
) -> None:
dash_dir = root / "docker" / "telemetry" / "grafana" / "dashboards"
files = sorted(dash_dir.glob("*.json")) if dash_dir.is_dir() else []
if not files:
report.skip("D", "no dashboard JSON present")
return
if not l1_keys:
report.skip("D", "no L1 key set to validate against")
return
builtins = {
"__name__", # Prometheus reserved label for the metric name itself
"le",
"exported_instance",
"span_name",
"status_code",
"service_name",
"service_version",
"service_instance_id",
"job",
"job_type", # standard Prometheus label for job-queue metrics
"instance",
# TraceQL intrinsics: fields of the span itself rather than attributes,
# so they have no *SpanNames.h entry. `name` is the span name, matched
# as `{name="consensus.accept"}`.
"name",
"duration",
"kind",
# Dotted form of the service identity, as TraceQL writes it
# (`resource.service.instance.id` -> stripped to `service.instance.id`).
"service.instance.id",
"service.name",
"service.version",
}
# A dashboard label is valid if it is a span attribute (L1), a native-metric
# label (L6), a resource attribute the collector promotes onto metrics (L2),
# a Prometheus/Grafana/TraceQL builtin, or an external-infra identity label
# (EXTERNAL_INFRA_LABELS).
valid = (
l1_keys
| metric_labels
| promoted_resource_labels(root)
| builtins
| EXTERNAL_INFRA_LABELS
)
found = False
checked = 0
skipped_log_queries = 0
for f in files:
try:
text = read_source(f)
except OSError:
continue
# Parse so each query can be attributed to its datasource. Anything that
# is not a well-formed dashboard object falls back to scanning the raw
# text, which cannot attribute a datasource and so checks every query.
# JSON validity itself is already enforced by the prettier pre-commit
# hook, so this is a fallback and not a silent pass.
try:
queries = list(iter_dashboard_queries(json.loads(text)))
except ValueError:
queries = [(text, None)]
labels: Set[str] = set()
for query, ds_type in queries:
if ds_type in LOG_QUERY_DATASOURCES:
skipped_log_queries += 1
continue
checked += 1
# PromQL `sum by (a, b)` and `{label="..."}` references. The
# raw-text fallback still carries JSON's backslash-escaped quotes,
# so accept an optional backslash before the quote.
for m in re.finditer(r"by\s*\(([^)]*)\)", query):
labels |= {x.strip() for x in m.group(1).split(",") if x.strip()}
for m in re.finditer(r"\b([a-z_][a-z0-9_.]*)\s*[=!]~?\s*\\?\"", query):
labels.add(m.group(1))
for lbl in sorted(labels):
# Strip a TraceQL scope prefix (span./resource./...) — the bare
# attribute is what must resolve against L1.
bare = TRACEQL_SCOPE.sub("", lbl)
if bare in valid:
continue
found = True
report.violation(
"D",
str(f.relative_to(root)),
lbl,
"must exist in L1, a metric label, or be a builtin",
)
if not found:
note = f"D: dashboard PromQL labels all resolve ({len(files)} file(s), {checked} query(s)"
if skipped_log_queries:
note += f", {skipped_log_queries} log query(s) skipped"
report.ok(note + ")")
def run_rule_e_docs(root: Path, l1_keys: Set[str], report: Report) -> None:
discovered = rule_e_docs(root)
if not discovered:
roots = ", ".join(str(rel) for rel in RULE_E_DOC_ROOTS)
report.skip("E", f"no doc-layer file present (no *.md under {roots})")
return
# Presence-gating (design principle 2) must stay VISIBLE. Discovery cannot go
# stale, but it can go QUIET: a root renamed away, or stripped of its
# telemetry docs, still yields a clean run over whatever markdown is left, so
# the layer would stop being checked while the rule reported green. Warn per
# missing anchor — non-fatal, so a partial branch still passes — and name the
# count on the OK line, so the doc set cannot quietly shrink.
missing_required = [
rel for rel in RULE_E_REQUIRED_DOCS if not (root / rel).is_file()
]
for rel in missing_required:
report.warning(
"E", str(rel), "absent", "required Rule E doc not in tree (renamed?)"
)
if not l1_keys:
report.skip("E", "no L1 key set to validate against")
return
found = False
# Only the dotted `xrpl.<domain>.<field>` attribute form is a violation. The
# `xrpl.`-with-trailing-dot anchor is the discriminator: it matches the old
# dotted attribute convention being migrated away from, while everything
# else legitimately dotted in these docs does NOT match it —
# * span names (`consensus.round`, `tx.process`) no `xrpl.` prefix
# * filenames (`xrpld.cfg`, `RCLConsensus.cpp`) `xrpld.`/`.cpp`, not `xrpl.`
# * OTel-standard (`service.name`, `http.method`) no `xrpl.` prefix
# * metric labels (`xrpl_rpc_command`) underscore, no dot
# Legitimate dotted resource attrs (`xrpl.network.id`/`.type`) are in L1 and
# are skipped. A dotted `xrpl.` token absent from L1 is a genuine doc/code
# mismatch (e.g. `xrpl.tx.hash` where the code emits `tx_hash`).
# EXTERNAL_INFRA_LABELS (Rule D) holds the underscore/metric-label form of
# the perf-iac identity attrs; the resource-attribute layer stamps the same
# identities dotted (xrpl.work.item/.branch/.node.role -- see the alloy
# pipeline that owns them), so also skip a token whose dotted-to-underscore
# form is in that set.
# A doc that TEACHES the convention has to be able to name the wrong form as
# a counter-example ("`tx_hash`, not `xrpl.tx.hash`"). The allow-dotted
# marker is the opt-out for exactly that: the token is a mention, not a
# published attribute key. It follows the repo's existing inline-marker
# precedent (`<!-- cspell:ignore ... -->`), and is scoped BOTH to the line
# and to the keys it names, so it can neither exempt a whole table nor grow
# to cover a violation appended to an already-marked line.
external_infra_dotted = {lbl.replace("_", ".") for lbl in EXTERNAL_INFRA_LABELS}
for rel in discovered:
for lineno, line in enumerate(read_source(root / rel).splitlines(), start=1):
allowed, markers = rule_e_allowed_keys(line)
if markers and not allowed:
report.warning(
"E",
f"{rel}:{lineno}",
"allow-dotted",
f"marker names no key: exempts nothing. Use {RULE_E_MARKER_SYNTAX}",
)
tokens = [m.group(1) for m in RULE_E_DOTTED_TOKEN.finditer(line)]
for token in tokens:
if token in l1_keys: # legitimate dotted resource attr (xrpl.network.*)
continue
if token in external_infra_dotted: # perf-iac resource-attr layer
continue
if token in allowed: # named counter-example on this line
continue
found = True
report.violation(
"E",
f"{rel}:{lineno}",
token,
"underscore, not dotted",
)
# A key the marker names but the line no longer mentions is a stale
# exemption: harmless today, but it is how a marker silently starts
# covering more than the author reviewed. Flagged, never fatal.
for stale in sorted(allowed.difference(tokens)):
report.warning(
"E", f"{rel}:{lineno}", stale, "allow-dotted key not on this line"
)
if not found:
note = (
"E: doc attribute references consistent with L1 "
f"({len(discovered)} file(s) checked"
)
if missing_required:
note += (
f", {len(missing_required)} of {len(RULE_E_REQUIRED_DOCS)} "
"required doc(s) absent"
)
report.ok(note + ")")
# ---------------------------------------------------------------------------
# Metric rules I / J / K
# ---------------------------------------------------------------------------
# A metric emit site whose name argument must be a constant. Two families:
# * the XRPL_METRIC_* call-site macros, where arg0 is the app/registry and
# arg1 is the instrument name;
# * the OTel meter factories, where arg0 is the instrument name.
# Matching the macro by name (rather than by receiver, as CALLSITE does) is
# sufficient because the XRPL_METRIC_ prefix is unambiguous in this repo.
METRIC_MACRO_CALL = re.compile(r"\bXRPL_METRIC_([A-Z_]+)\s*\(")
METRIC_FACTORY_CALL = re.compile(
r"\bCreate(?:UInt64|Int64|Double)"
r"(?:Counter|UpDownCounter|Histogram|Gauge|ObservableGauge|"
r"ObservableCounter|ObservableUpDownCounter)\s*\("
)
# A `{ KEY , ...` label pair opener inside an already-isolated argument list.
# KEY is what Rule I checks; the VALUE after the comma is exempt (runtime data).
METRIC_PAIR_KEY = re.compile(r"\{\s*(\"(?:[^\"\\]|\\.)*\"|[A-Za-z_][\w:]*)\s*,")
# The unit/kind suffix convention every instrument name must satisfy (Rule J).
# A cumulative counter reads correctly under rate() only if a reader can tell it
# from a gauge, and a duration is ambiguous unless it carries its unit -- the
# OTel `unit` argument is not surfaced on the Prometheus metric name.
METRIC_COUNTER_SUFFIX = "_total"
METRIC_DURATION_SUFFIXES = ("_us", "_ms", "_seconds")
def metric_calls(text: str):
"""Yield (kind, name_index, arglist, lineno) for every metric emit site.
`kind` is the macro suffix (e.g. `COUNTER_INC_LABELED`) for a macro call, or
the factory method name for a `meter->Create*` call. `name_index` is which
top-level argument holds the instrument name."""
for m in METRIC_MACRO_CALL.finditer(text):
arglist = balanced_arglist(text, m.end())
yield m.group(1), 1, arglist, text.count("\n", 0, m.start()) + 1
for m in METRIC_FACTORY_CALL.finditer(text):
arglist = balanced_arglist(text, m.end())
name = m.group(0).rstrip("( \t\n")
yield name, 0, arglist, text.count("\n", 0, m.start()) + 1
def balanced_arglist(text: str, start: int) -> str:
"""Return the argument-list text of a call whose '(' ends at `start`-1,
balancing nesting and ignoring parens inside string literals."""
i, depth, in_str, esc = start, 1, False, False
while i < len(text) and depth > 0:
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
elif c == '"':
in_str = True
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
i += 1
return text[start : i - 1]
def run_rule_i_metric_literals(root: Path, report: Report) -> None:
"""Rule I: no string literal as a metric instrument NAME or label KEY.
The metric-side mirror of Rule F. An instrument name or label key passed to
an `XRPL_METRIC_*` macro or to a `meter->Create*` factory must reference a
`*MetricNames.h` constant, so a rename is one edit instead of six and a typo
is a compile error instead of a metric that silently never appears.
Exempt, for the same reasons Rule F exempts them:
* label VALUES -- the argument after a label key is runtime data;
* the instrument DESCRIPTION -- prose, not naming surface;
* `*MetricNames.h` itself -- that is where the strings are defined;
* `MetricMacros.h` -- the macro definitions, whose `(name)` is a parameter;
* test code -- tests pass arbitrary literals to exercise the API, and
asserting on a literal is what proves a constant's value is unchanged.
Scope -- a RATCHET, not a flag day. The rule fires only on a metric whose
FAMILY already has constants declared in a `*MetricNames.h` (matched on the
name's first underscore segment, e.g. `sync_`, `jobq_`, `unl_`). The metric
surface predates this rule by many phases, and failing the build on ~27
pre-existing instruments at once would force one un-reviewable mega-change.
Declaring a constant for a family is what opts that family in, so:
* a NEW metric in an already-converted family is caught immediately;
* a literal left behind in a family being converted is caught;
* an untouched legacy family stays green until someone converts it, at
which point the whole family is enforced.
A literal whose family has no constants at all is reported as a Rule L
warning instead (non-fatal), so the remaining work stays visible.
Presence-gated on `*MetricNames.h` existing at all; with no metric header
in the tree the rule has no families to enforce and is skipped."""
if not find_metricname_headers(root):
report.skip("I", "no *MetricNames.h present")
return
names, keys, _ = metric_constants(root)
owned = metric_prefixes(names)
found = False
deferred: List[Tuple[str, str]] = []
def report_literal(rel: object, lineno: int, token: str, wire: str) -> None:
nonlocal found
# A label key is enforced once ANY label-key constant exists, because
# the label vocabulary is shared across families -- `outcome` means the
# same thing everywhere, so there is no per-family ratchet for it.
in_scope = (
any(wire.startswith(p) for p in owned) if token.startswith("name") else True
)
if in_scope:
found = True
report.violation(
"I", f"{rel}:{lineno}", token, "use a *MetricNames.h constant"
)
else:
deferred.append((f"{rel}:{lineno}", token))
for path in sorted(iter_sources(root)):
if path.name.endswith("MetricNames.h") or path.name == "MetricMacros.h":
continue
if is_test_path(path):
continue
text = strip_comments(read_source(path))
rel = path.relative_to(root)
for kind, name_idx, arglist, lineno in metric_calls(text):
args = split_top_level_args(arglist)
if len(args) > name_idx:
lit = STRING_LITERAL.search(args[name_idx])
if lit:
report_literal(
rel, lineno, f'name "{lit.group(1)}" ({kind})', lit.group(1)
)
# Label KEYS live in the label-set initializer, e.g.
# `{{"outcome", v}, {"reason", w}}`. Scanned over the WHOLE argument
# list rather than per split argument, because
# `split_top_level_args` balances only parentheses -- a brace-
# enclosed label set contains top-level commas and would be split
# apart mid-pair, hiding every key from the pattern.
#
# Scanning the whole list is safe: METRIC_PAIR_KEY requires an
# opening `{` before the key, which the instrument name and the
# description never have. Only the key position is checked; the
# VALUE after it is runtime data and exempt.
if keys:
for pm in METRIC_PAIR_KEY.finditer(arglist):
key = pm.group(1)
if not key.startswith('"'):
continue
report_literal(rel, lineno, f"label {key} ({kind})", key.strip('"'))
for loc, token in deferred:
report.warning("L", loc, token, "metric family not yet converted")
if not found:
report.ok("I: no string-literal names/label keys in converted metric families")
def iter_sources(root: Path) -> List[Path]:
"""Every C++ source/header under src/ and include/."""
return [
p
for base in ("src", "include")
for ext in ("*.h", "*.cpp")
for p in (root / base).rglob(ext)
if p.is_file()
]
def instrument_kinds(root: Path, wire_by_symbol: Dict[str, str]) -> Dict[str, Set[str]]:
"""Map each declared instrument's WIRE name to the set of OTel instrument
kinds its emit sites actually create it with.
The kind is what decides which suffix is correct, so it must be read from
the emit site rather than guessed from the name -- guessing from words like
"latency" or "us" mislabels a multi-series gauge whose units live in its
label VALUES (e.g. `nodestore_state` observing `write_mean_us`), which is
a legitimate shape, not a violation.
A SET rather than one kind per name, because one wire name created through
two different factories is itself the defect worth reporting: the SDK would
export two instruments under one name and the collector would see whichever
arrived last. Recording only the last kind visited hid exactly that case.
Values are drawn from `counter`, `histogram`, `gauge`, `updown`. A name whose
emit site is not found is absent from the result, so Rule J checks only the
shape-independent rules for it."""
kinds: Dict[str, Set[str]] = {}
for path in iter_sources(root):
if path.name == "MetricMacros.h" or path.name.endswith("MetricNames.h"):
continue
if is_test_path(path):
continue
text = strip_comments(read_source(path))
for kind, name_idx, arglist, _ in metric_calls(text):
args = split_top_level_args(arglist)
if len(args) <= name_idx:
continue
arg = args[name_idx].strip()
lit = re.fullmatch(r'"((?:[^"\\]|\\.)*)"', arg)
if lit:
wire: Optional[str] = lit.group(1)
else:
ident = IDENTIFIER_ARG.match(arg)
wire = (
wire_by_symbol.get(ident.group(1).split("::")[-1])
if ident
else None
)
if wire is None:
continue
classified = classify_instrument_kind(kind)
# `other` is the sentinel for a macro that is not an instrument
# factory. Adding it would let a future non-instrument
# `XRPL_METRIC_*` macro turn a correctly named metric into
# "created as counter and other", a conflict message that reads as
# nonsense. Dropping it keeps the sentinel doing exactly what it did
# before: matching no shape rule.
if classified != "other":
kinds.setdefault(wire, set()).add(classified)
return kinds
def classify_instrument_kind(kind: str) -> str:
"""Normalise a macro suffix (`COUNTER_INC_LABELED`) or a factory method
name (`CreateInt64ObservableGauge`) to one of counter/histogram/gauge/
updown. Order matters: `UpDown` and `Observable` are checked before the
bare `Counter` substring they both contain."""
if "HISTOGRAM" in kind or "Histogram" in kind:
return "histogram"
if "UPDOWN" in kind or "UpDown" in kind:
return "updown"
if "GAUGE" in kind or "Gauge" in kind:
return "gauge"
if "COUNTER" in kind or "Counter" in kind:
return "counter"
return "other"
def symbol_wire_names(root: Path) -> Dict[str, str]:
"""Map each `*MetricNames.h` constant's C++ symbol name to its wire string,
so an emit site referencing `metric::dnsResolveTotal` can be resolved back
to `dns_resolve_total`."""
out: Dict[str, str] = {}
for h in find_metricname_headers(root):
for m in METRIC_CONST_DEF.finditer(strip_comments(read_source(h))):
out[m.group(1)] = m.group(2)
return out
def run_rule_j_metric_suffixes(root: Path, report: Report) -> None:
"""Rule J: instrument names follow the naming and unit/kind suffix rules.
Checked against the `namespace metric` constants in `*MetricNames.h`, which
Rule I makes the only place an instrument name can be spelled. Enforced:
* lower_snake_case (same shape as Rule G for span attributes);
* no `xrpld_`/`xrpl_` prefix -- the Prometheus exporter adds the namespace
itself, so a name carrying it would emit `xrpld_xrpld_*` on the wire;
* a monotonic COUNTER ends in `_total`, so `rate()` over it reads
correctly and a reader can tell it from a gauge;
* a HISTOGRAM ends in `_us`, `_ms` or `_seconds`: histograms in this repo
are all durations, and the OTel `unit` argument is not surfaced on the
Prometheus metric name, so an unlabelled duration is ambiguous;
* a GAUGE does not end in `_total`, which is reserved for counters.
The instrument KIND comes from the emit site (see `instrument_kinds`), not
from words in the name, so a multi-series gauge that carries its units in
its label values is not mistaken for a mis-suffixed duration.
Presence-gated: skipped when no `*MetricNames.h` exists."""
if not find_metricname_headers(root):
report.skip("J", "no *MetricNames.h present")
return
names, _, _ = metric_constants(root)
if not names:
report.skip("J", "no metric instrument-name constants declared")
return
kinds = instrument_kinds(root, symbol_wire_names(root))
found = False
def flag(name: str, expected: str) -> None:
nonlocal found
found = True
report.violation("J", "*MetricNames.h", name, expected)
for name in sorted(names):
if not SNAKE_SEGMENT.match(name):
flag(name, "must be lower_snake_case")
continue
if name.startswith(("xrpld_", "xrpl_")):
flag(name, "drop the prefix; the exporter adds it")
continue
found_kinds = kinds.get(name) or set()
if len(found_kinds) > 1:
# One wire name created through two factories exports two
# instruments under one name; no suffix can be right for both, so
# report the conflict itself rather than picking one arbitrarily.
flag(name, f"created as {' and '.join(sorted(found_kinds))}; pick one kind")
continue
kind = next(iter(found_kinds), None)
if kind == "counter" and not name.endswith(METRIC_COUNTER_SUFFIX):
flag(name, "counter must end in _total")
elif kind == "histogram" and not name.endswith(METRIC_DURATION_SUFFIXES):
flag(name, "histogram needs _us/_ms/_seconds suffix")
elif kind == "gauge" and name.endswith(METRIC_COUNTER_SUFFIX):
flag(name, "_total is reserved for counters")
if not found:
report.ok(f"J: {len(names)} metric instrument name(s) follow the naming rules")
def run_rule_k_expected_metrics(root: Path, report: Report) -> None:
"""Rule K: every metric named in the workload's `expected_metrics.json`
exists as a `namespace metric` constant.
The layer that cannot reference a C++ constant, so it is validated against
one instead. This is the check that catches the failure mode this rule set
exists for: a metric renamed in code while the workload validator still
asserts on the old name, which fails as "metric never appeared" at runtime
rather than at review time.
Scope. Only groups whose metrics come from the OTel instrument API are
checked. The file also inventories `beast::insight` statsd gauges/counters
(`statsd_gauges`, `statsd_counters`) and collector-derived spanmetrics, whose
names are minted by a different emit path -- `formatName()` lowercasing an
insight metric, or the spanmetrics connector -- and so have no
`*MetricNames.h` constant to resolve to by design. Checking them would fail
the build on metrics this convention does not govern. Within the OTel groups
the family ratchet still applies, so an unconverted family is out of scope
too. Presence-gated on both layers."""
path = root / "docker" / "telemetry" / "workload" / "expected_metrics.json"
if not path.is_file():
report.skip("K", "expected_metrics.json not present")
return
if not find_metricname_headers(root):
report.skip("K", "no *MetricNames.h to validate against")
return
names, _, _ = metric_constants(root)
try:
doc = json.loads(read_source(path))
except json.JSONDecodeError as exc:
report.violation("K", str(path.relative_to(root)), str(exc), "valid JSON")
return
declared = {
base_metric_name(e) for e in expected_metric_names(doc, NON_OTEL_METRIC_GROUPS)
}
# Only entries inside a metric FAMILY the constants already own are checked.
# Anything else in the file predates *MetricNames.h, so demanding a constant
# for it would fail the build on unrelated pre-existing metrics rather than
# on the drift this rule targets.
owned = metric_prefixes(names)
unknown = sorted(
n for n in declared if n not in names and any(n.startswith(p) for p in owned)
)
for n in unknown:
report.violation(
"K",
str(path.relative_to(root)),
n,
"no matching *MetricNames.h constant",
)
if not unknown:
checked = sum(1 for n in declared if n in names)
report.ok(f"K: {checked} expected-metric name(s) resolve to constants")
# Suffixes the Prometheus exporter appends to a histogram instrument. The
# instrument name declared in code is the stem, so they are stripped before the
# constant lookup.
HISTOGRAM_SUFFIXES = ("_bucket", "_count", "_sum")
def base_metric_name(entry: str) -> str:
"""Reduce an `expected_metrics.json` entry to the bare instrument name.
Entries are written the way an operator would query them, not the way the
code declares them, so two forms have to be normalized away:
* a PromQL label selector -- `sync_state{metric="ledgers_behind"}`;
* an exporter-appended histogram suffix -- `..._ms_bucket`/`_count`/`_sum`,
which the SDK adds and the code never names.
Without this the rule would reject every entry in the file, which is a
checker bug rather than a naming violation."""
name = entry.split("{", 1)[0].strip()
for suffix in HISTOGRAM_SUFFIXES:
if name.endswith(suffix):
return name[: -len(suffix)]
return name
def metric_prefixes(names: Set[str]) -> Set[str]:
"""The first underscore-delimited segment of each declared instrument name,
e.g. {`sync_`, `jobq_`, `unl_`}. Used by Rule K to decide whether an entry
in `expected_metrics.json` belongs to a family this repo's constants own --
so the rule flags a stale name inside a converted family without demanding a
constant for every unrelated metric in the file."""
return {n.split("_", 1)[0] + "_" for n in names if "_" in n}
# Groups in expected_metrics.json whose names are NOT minted by the OTel
# instrument API, and therefore have no *MetricNames.h constant by design:
# statsd_gauges / statsd_counters -- beast::insight metrics, whose wire names
# come from formatName() lowercasing an insight metric path;
# spanmetrics -- synthesised by the collector's spanmetrics connector from
# span names, not declared in C++ at all.
NON_OTEL_METRIC_GROUPS = frozenset({"statsd_gauges", "statsd_counters", "spanmetrics"})
def expected_metric_names(
doc: object, skip_groups: frozenset = frozenset()
) -> Set[str]:
"""Collect metric names from `expected_metrics.json`.
The file groups metrics by source (`spanmetrics`, `statsd_gauges`,
`phase9_nodestore`, ...), each group holding a `metrics` LIST OF STRINGS
alongside a prose `description`. Both that shape and a
`[{"metric": "..."}]` shape are accepted, and the walk is generic, so a
layout change degrades to "checks fewer names" rather than silently
checking none. `description` is skipped explicitly -- it is prose, and
letting it through would feed whole sentences to the family match.
`skip_groups` drops whole top-level groups (see NON_OTEL_METRIC_GROUPS)."""
out: Set[str] = set()
def walk(node: object, in_metrics: bool = False) -> None:
if isinstance(node, dict):
for key, val in node.items():
if key == "description" or key in skip_groups:
continue
if key in ("metric", "name") and isinstance(val, str):
out.add(val)
else:
walk(val, in_metrics or key == "metrics")
elif isinstance(node, list):
for item in node:
if in_metrics and isinstance(item, str):
out.add(item)
else:
walk(item, in_metrics)
walk(doc)
return out
if __name__ == "__main__":
main()