Merge branch 'pratik/otel-phase3-tx-tracing' into pratik/otel-phase4-consensus-tracing

Forward-merge the OTel naming-check Rule C/D fixes (TraceQL span. prefix
stripping, L6 native-metric labels, tempo datasource span-filter enforcement).
This commit is contained in:
Pratik Mankawde
2026-06-11 19:32:00 +01:00
3 changed files with 226 additions and 29 deletions

View File

@@ -33,15 +33,15 @@ hardcoded allowlist:
### Rules (each fails the build, when its inputs are present)
| Rule | Check |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A | No stray dotted span-attribute key (only the derived resource keys may be dotted). |
| G | Attribute keys are `lower_snake_case` (`^[a-z][a-z0-9_]*$` per dot-segment) — no camelCase, UPPERCASE, or spaces. |
| F | No string literals as attribute keys or span-name arguments in `setAttribute`/`addEvent`/`span`/`childSpan`. Attribute _values_ are exempt (runtime data); `*SpanNames.h` definitions and test files are exempt. |
| B | Every collector `spanmetrics.dimensions` name exists in the L1 key set. |
| C | Every Tempo span-filter tag exists in the L1 key set. |
| D | Every dashboard PromQL label (non-builtin) exists in the L1 key set. |
| E | No dotted `xrpl.<domain>.<field>` attribute key in the runbook (only the L1 resource attrs `xrpl.network.*` may be dotted). Span names, filenames, OTel-standard keys, and metric labels are not flagged. |
| Rule | Check |
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A | No stray dotted span-attribute key (only the derived resource keys may be dotted). |
| G | Attribute keys are `lower_snake_case` (`^[a-z][a-z0-9_]*$` per dot-segment) — no camelCase, UPPERCASE, or spaces. |
| F | No string literals as attribute keys or span-name arguments in `setAttribute`/`addEvent`/`span`/`childSpan`. Attribute _values_ are exempt (runtime data); `*SpanNames.h` definitions and test files are exempt. |
| B | Every collector `spanmetrics.dimensions` name 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, a native-metric label (L6, emitted by MetricsRegistry), or a Prometheus/Grafana builtin. TraceQL scope prefixes (`span.`/`resource.`/…) are stripped before the L1 lookup. |
| E | No dotted `xrpl.<domain>.<field>` attribute key in the runbook (only the L1 resource attrs `xrpl.network.*` may be dotted). Span names, filenames, OTel-standard keys, and metric labels are not flagged. |
Rule F runs **unconditionally** (it is a purely syntactic check on the
call-sites and needs no `*SpanNames.h`), so a code path that calls

View File

@@ -36,6 +36,8 @@ Layers
L3 tempo : docker/telemetry/tempo.yaml (span filter tags)
L4 dashboards: docker/telemetry/grafana/dashboards/*.json (PromQL labels)
L5 runbook : docs/telemetry-runbook.md (attr tables)
L6 metrics : MetricsRegistry.cpp instrument labels (native-metric
label keys, a valid dashboard-label source besides L1)
Rules (each FAILS the build, when its inputs are present)
---------------------------------------------------------
@@ -50,7 +52,9 @@ Rules (each FAILS the build, when its inputs are present)
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 PromQL label (non-builtin) 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.
E No dotted `xrpl.<domain>.<field>` attribute key in the runbook (only the
L1 resource attrs xrpl.network.* may be dotted). Span names, filenames,
OTel-standard keys, and metric labels are not flagged.
@@ -131,6 +135,13 @@ 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 key as emitted in C++: `Add(.., {{"label", ...}})` /
# `{{"label", value}}` instrument calls in MetricsRegistry.
METRIC_LABEL = re.compile(r'\{\{\s*"([a-z_][a-z0-9_]*)"\s*,')
def strip_comments(text: str) -> str:
@@ -410,9 +421,14 @@ def main() -> None:
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, report)
run_rule_d_dashboards(root, l1_keys, metric_labels, report)
run_rule_e_runbook(root, l1_keys, report)
report.render_and_exit()
@@ -669,27 +685,70 @@ def extract_spanmetrics_dimensions(text: str) -> List[str]:
def run_rule_c_tempo(root: Path, l1_keys: Set[str], report: Report) -> None:
path = root / "docker" / "telemetry" / "tempo.yaml"
if not path.is_file():
report.skip("C", "tempo.yaml not present")
return
text = read_source(path)
tags = re.findall(r"(?:tag|attribute)\s*:\s*([A-Za-z0-9_.]+)", text)
span_tags = [t for t in tags if not t.startswith(("service.", "span."))]
if not span_tags:
report.skip("C", "no span-attribute tags in tempo.yaml")
# 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 miss:
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 tag(s) all in L1")
report.ok(f"C: {len(span_tags)} tempo span-filter tag(s) all in L1")
def run_rule_d_dashboards(root: Path, l1_keys: Set[str], report: Report) -> None:
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)."""
labels: Set[str] = set()
for base in ("src", "include"):
for p in (root / base).rglob("*.cpp"):
if not p.is_file():
continue
text = read_source(p)
if "MetricsRegistry" not in p.name and "metric" not in text.lower():
continue
labels |= set(METRIC_LABEL.findall(text))
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:
@@ -710,6 +769,9 @@ def run_rule_d_dashboards(root: Path, l1_keys: Set[str], report: Report) -> None
"job",
"instance",
}
# A dashboard label is valid if it is a span attribute (L1), a native-metric
# label (L6), or a Prometheus/Grafana builtin.
valid = l1_keys | metric_labels | builtins
found = False
for f in files:
try:
@@ -720,17 +782,23 @@ def run_rule_d_dashboards(root: Path, l1_keys: Set[str], report: Report) -> None
labels: Set[str] = set()
for m in re.finditer(r"by\s*\(([^)]*)\)", text):
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*\"", text):
for m in re.finditer(r"\b([a-z_][a-z0-9_.]*)\s*[=!]~?\s*\"", text):
labels.add(m.group(1))
for lbl in sorted(labels):
if lbl in builtins or lbl in l1_keys:
# 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 or builtin"
"D",
str(f.relative_to(root)),
lbl,
"must exist in L1, a metric label, or be a builtin",
)
if not found:
report.ok(f"D: dashboard PromQL labels all in L1 ({len(files)} file(s))")
report.ok(f"D: dashboard PromQL labels all resolve ({len(files)} file(s))")
def run_rule_e_runbook(root: Path, l1_keys: Set[str], report: Report) -> None:

View File

@@ -476,8 +476,71 @@ class RuleBCollector(unittest.TestCase):
self.assertTrue(any("SKIP: B" in s for s in skips))
class RuleCTempo(unittest.TestCase):
"""Rule C reads the Grafana Tempo DATASOURCE file's search.filters and
validates only span-scope tags against L1."""
DS = "docker/telemetry/grafana/provisioning/datasources/tempo.yaml"
def _run(self, yaml_text, l1):
d = Path(tempfile.mkdtemp())
try:
_write(d / self.DS, yaml_text)
report = chk.Report()
chk.run_rule_c_tempo(d, set(l1), report)
return sorted(v[2] for v in report.violations), report.skips
finally:
shutil.rmtree(d)
def _filter(self, fid, tag, scope):
return (
f" - id: {fid}\n"
f" tag: {tag}\n"
f' operator: "="\n'
f" scope: {scope}\n"
f" type: static\n"
)
def test_span_tag_not_in_l1_flagged(self):
y = "search:\n filters:\n" + self._filter("f1", "bogus_tag", "span")
v, _ = self._run(y, {"command"})
self.assertEqual(v, ["bogus_tag"])
def test_span_tags_in_l1_pass(self):
y = (
"search:\n filters:\n"
+ self._filter("f1", "command", "span")
+ self._filter("f2", "tx_hash", "span")
)
v, _ = self._run(y, {"command", "tx_hash"})
self.assertEqual(v, [])
def test_resource_and_intrinsic_tags_ignored(self):
# service.* (resource) and name/status/duration (intrinsic) are not
# span attributes — they must not be validated against L1.
y = (
"search:\n filters:\n"
+ self._filter("f1", "service.instance.id", "resource")
+ self._filter("f2", "name", "intrinsic")
+ self._filter("f3", "duration", "intrinsic")
)
v, skips = self._run(y, {"command"})
self.assertEqual(v, [])
self.assertTrue(any("SKIP: C" in s for s in skips))
def test_skip_when_datasource_absent(self):
d = Path(tempfile.mkdtemp())
try:
report = chk.Report()
chk.run_rule_c_tempo(d, {"command"}, report)
self.assertEqual(report.violations, [])
self.assertTrue(any("SKIP: C" in s for s in report.skips))
finally:
shutil.rmtree(d)
class RuleDDashboards(unittest.TestCase):
def _run(self, json_text, l1):
def _run(self, json_text, l1, metric_labels=frozenset()):
d = Path(tempfile.mkdtemp())
try:
_write(
@@ -485,7 +548,7 @@ class RuleDDashboards(unittest.TestCase):
json_text,
)
report = chk.Report()
chk.run_rule_d_dashboards(d, set(l1), report)
chk.run_rule_d_dashboards(d, set(l1), set(metric_labels), report)
return sorted(v[2] for v in report.violations)
finally:
shutil.rmtree(d)
@@ -513,6 +576,72 @@ class RuleDDashboards(unittest.TestCase):
def test_l1_label_passes(self):
self.assertEqual(self._run('"q": "{command=\\"x\\"}"', {"command"}), [])
def test_traceql_span_prefix_stripped(self):
# `span.establish_count` must validate against the bare L1 key.
self.assertEqual(
self._run(
'"expr": "count_over_time(x) by (span.establish_count)"',
{"establish_count"},
),
[],
)
def test_traceql_resource_prefix_stripped(self):
self.assertEqual(self._run('"q": "{resource.service_name=\\"x\\"}"', set()), [])
def test_native_metric_label_passes(self):
# `job_type` / `reason` are emitted by MetricsRegistry, not span attrs.
self.assertEqual(
self._run(
'"expr": "sum by (job_type, reason) (x)"',
{"command"},
metric_labels={"job_type", "reason"},
),
[],
)
def test_unknown_label_still_flagged_with_metric_labels(self):
# A label that is neither L1, metric label, nor builtin still fails.
self.assertEqual(
self._run(
'"expr": "sum by (bogus) (x)"',
{"command"},
metric_labels={"job_type"},
),
["bogus"],
)
def test_span_prefixed_unknown_still_flagged(self):
# `span.not_a_key` whose bare form is unknown is still a violation.
self.assertEqual(
self._run('"expr": "x by (span.not_a_key)"', {"command"}),
["span.not_a_key"],
)
class MetricLabelExtraction(unittest.TestCase):
"""L6: native-metric label keys parsed from C++ instrument calls."""
def test_extracts_add_label(self):
d = Path(tempfile.mkdtemp())
try:
_write(
d / "src" / "xrpld" / "telemetry" / "MetricsRegistry.cpp",
'counter->Add(1, {{"job_type", std::string(jobType)}});\n'
'c2->Add(1, {{"reason", std::string(r)}});\n',
)
self.assertEqual(chk.metric_label_names(d), {"job_type", "reason"})
finally:
shutil.rmtree(d)
def test_no_metrics_file_empty(self):
d = Path(tempfile.mkdtemp())
try:
(d / "src").mkdir()
self.assertEqual(chk.metric_label_names(d), set())
finally:
shutil.rmtree(d)
class ReportExitContract(unittest.TestCase):
@staticmethod