fix(telemetry): address the PR review findings

Four defects from the automated review on PR #7875, each verified against the
current tree before fixing (one further comment, the row-63 dashboard overlap,
was already fixed by an earlier commit and needed nothing).

1. Rule J could not detect an instrument-kind mismatch. instrument_kinds() wrote
   `kinds[wire] = ...`, so a wire name created through two different factories
   kept only the kind visited last and whichever emit site the file walk reached
   last silently decided the verdict. It now collects a set per name and reports
   the conflict itself -- one name exporting two instruments is the defect, and
   no suffix can be correct for both. Added a regression test that builds a name
   as both a counter and an observable gauge and asserts the message names both.

2. A duplicate connection was reported as `tls_fail`. The TLS handshake had in
   fact succeeded; PeerFinder simply already held a slot for that address, which
   is ordinary churn on a healthy node. Conflating the two made a rising
   `tls_fail` unreadable -- it could mean unreachable peers or merely a busy
   PeerFinder, and those need opposite responses. Added a distinct `duplicate`
   outcome and carried the widened vocabulary through every place that
   enumerates it: the panel description, both filter descriptions, the runbook
   branch table, the runbook outcome list and the expected_spans note. The
   `dial_outcome` template variable is a label_values() query, so it picks the
   new value up on its own.

3. ConnectAttempt::onShutdown had no `operation_aborted` guard, unlike the five
   other handlers in the same file. A clean teardown was therefore counted as
   `upgrade_fail`, inflating that outcome on any node shutting down with dials in
   flight.

4. ValidatorSite used the raw configured URI as a Prometheus label.
   [validator_list_sites] accepts credentials in the URI and ParsedUrl keeps them
   in username/password, so a configured `https://user:pass@host` would have
   copied the secret into a metric label and on into the collector, Prometheus
   and every dashboard. The label is now rebuilt from scheme, host, port and
   path -- everything needed to tell one site apart, and nothing more.

Verified: naming checker exits 0 with Rule J still passing all 40 real
instrument names; its unit tests now number 139 and all pass; 15 dashboards
validate; both workload JSON files parse; clang-tidy over the full compile
database reports no finding on either changed .cpp; pre-commit passes.

Not verified: not compiled. Item 4 introduces string concatenation and item 2 a
new constexpr, so CI's build is the first real check on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pratik Mankawde
2026-07-28 17:04:28 +01:00
parent 0ddb4e2686
commit f649670ef7
8 changed files with 96 additions and 29 deletions

View File

@@ -1293,9 +1293,9 @@ def iter_sources(root: Path) -> List[Path]:
]
def instrument_kinds(root: Path, wire_by_symbol: Dict[str, str]) -> Dict[str, str]:
"""Map each declared instrument's WIRE name to its OTel instrument kind, by
looking at how the emit sites actually create it.
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
@@ -1303,10 +1303,15 @@ def instrument_kinds(root: Path, wire_by_symbol: Dict[str, str]) -> Dict[str, st
label VALUES (e.g. `nodestore_state` observing `write_mean_us`), which is
a legitimate shape, not a violation.
Returns one of `counter`, `histogram`, `gauge`, `updown` per wire name.
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, str] = {}
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
@@ -1330,7 +1335,7 @@ def instrument_kinds(root: Path, wire_by_symbol: Dict[str, str]) -> Dict[str, st
)
if wire is None:
continue
kinds[wire] = classify_instrument_kind(kind)
kinds.setdefault(wire, set()).add(classify_instrument_kind(kind))
return kinds
@@ -1404,7 +1409,14 @@ def run_rule_j_metric_suffixes(root: Path, report: Report) -> None:
if name.startswith(("xrpld_", "xrpl_")):
flag(name, "drop the prefix; the exporter adds it")
continue
kind = kinds.get(name)
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):

View File

@@ -1264,6 +1264,23 @@ class RuleJMetricSuffixes(unittest.TestCase):
# shape-independent rules apply -- a bare gauge-ish name is fine.
self.assertEqual(self._run(_mc("syncState", "sync_state")), [])
def test_one_name_created_as_two_kinds_is_flagged(self):
# One wire name built through two different factories exports two
# instruments under a single name, and no suffix can satisfy both. The
# kind map therefore records a SET per name: keeping only the last kind
# visited silently hid this, because whichever emit site the walk
# reached last decided the verdict.
violations = self._run(
_mc("dualKind", "dual_kind_total"),
'meter_->CreateUInt64Counter(metric::dualKind, "d");\n'
'meter_->CreateInt64ObservableGauge(metric::dualKind, "d");\n',
)
self.assertEqual(len(violations), 1, violations)
# The message names both kinds, so the reader sees the conflict rather
# than a suffix complaint that would contradict one of the two sites.
self.assertIn("counter", violations[0][-1])
self.assertIn("gauge", violations[0][-1])
def test_skip_when_no_header(self):
d = Path(tempfile.mkdtemp())
try: