mirror of
https://github.com/XRPLF/rippled.git
synced 2026-07-14 02:30:20 +00:00
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:
18
.github/scripts/otel-naming/README.md
vendored
18
.github/scripts/otel-naming/README.md
vendored
@@ -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
|
||||
|
||||
104
.github/scripts/otel-naming/check_otel_naming.py
vendored
104
.github/scripts/otel-naming/check_otel_naming.py
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user