feat: Phase 6: Integrate beast::insight StatsD metrics into telemetry pipeline - #6439
feat: Phase 6: Integrate beast::insight StatsD metrics into telemetry pipeline#6439pratikmankawde wants to merge 264 commits into
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
8e54a05 to
2c0b65a
Compare
2c0b65a to
b006bb0
Compare
f705af8 to
c8d0abd
Compare
a440d6b to
b179661
Compare
c525fc3 to
d4c69fe
Compare
e886cf6 to
3e896ec
Compare
|
This PR has conflicts, please resolve them in order for the PR to be reviewed. |
|
All conflicts have been resolved. Assigned reviewers can now start or resume their review. |
| "datasource": { | ||
| "type": "prometheus" | ||
| }, | ||
| "expr": "rippled_State_Accounting_Full_duration{exported_instance=~\"$node\"}", |
There was a problem hiding this comment.
Operating Mode Duration unit mismatch — divide metric by 1000000 (microseconds to seconds):
| "expr": "rippled_State_Accounting_Full_duration{exported_instance=~\"$node\"}", | |
| // Divide by 1000000 to convert microseconds to seconds: | |
| rippled_State_Accounting_Full_duration / 1000000 |
| "expr": "rippled_State_Accounting_Full_duration{exported_instance=~\"$node\"}", | |
| "expr": "rippled_State_Accounting_Full_duration{exported_instance=~\"$node\"} / 1000000", |
PeerImp::onMessage(TMValidation) runs once per inbound validation message, and it reaches these attribute calls before the HashRouter duplicate check, so every peer's copy of every validation paid for them. Span setAttribute is a real inline function whose arguments are evaluated even when telemetry is compiled out, and to_string(val->getLedgerHash()) heap-allocates a 64-character hex string on each call. Wrap the ledger_hash and full_validation attributes in if (valSpan), so neither the string build nor the flags lookup behind isFull() runs when telemetry is compiled out, when it is switched off in the config, or when the Peer trace category is disabled. A span that exists but was sampled out still pays; there is no isRecording() to test. peer_id and validation_trusted stay unguarded: their arguments are an integer cast and a bool the surrounding logic already computes.
…ase6-statsd Two conflicts, both in PeerImp's proposal and validation receive paths, and both resolved by taking the incoming side: it holds the span in a handle that stays empty when telemetry is compiled out and moves every attribute behind if (span && *span), which supersedes the unguarded form on this side. Taking the incoming text renamed consSpan to span in both blocks, while the two job-lambda captures further down had merged cleanly and still named consSpan. Renamed those captures so each names the handle its own function declares. This branch's own guard on the inbound-validation ledger_hash attribute is a different span in a different function; it merged cleanly and is preserved.
onMessage(TMProposeSet) already owns a ScopedSpanGuard called span, the root for the inbound peer message. The thread-free handle for the proposal receive span was declared with the same name in the same scope, so the second declaration conflicted with the first and every use of it -- the assignment, the liveness test, the attribute writes and the job capture -- resolved against the wrong type. Call it proposalSpan. The validation handler already keeps its two apart the same way, with valSpan for the root.
| three gauge families export them per job type, so queue pressure can be attributed to a type | ||
| instead of only being visible as a single total. | ||
|
|
||
| For each of the same 35 non-special job types, three gauges are created in the `JobTypeData` |
There was a problem hiding this comment.
Present tense contradicts the immediately-following caveat about phase-9. Change 'are created'/'assigned' to future tense so the sentence doesn't mislead.
| For each of the same 35 non-special job types, three gauges are created in the `JobTypeData` | |
| For each of the same 35 non-special job types, three gauges will be created in the `JobTypeData` |
pratikmankawde
left a comment
There was a problem hiding this comment.
Review summary
3 BLOCKING · 2 SHOULD-FIX · 0 NIT posted inline, plus the notes below.
Scope of this pass: the ten dashboards, validate_dashboards.py, the Grafana provisioning, the collector config, docker-compose.yml, the StatsD C++ change, and the ledger/peer span additions. I read every file with git show pratik/otel-phase6-statsd:<path> at 6283a1eb12.
How the runtime evidence was obtained. For anything that depends on what actually reaches Prometheus, I ran this PR's own otel-collector-config.yaml in the pinned otel/opentelemetry-collector-contrib:0.158.0 alongside prom/prometheus:v3.13.2 with this PR's own prometheus.yml, on throwaway ports, and fed it real OTLP spans and StatsD datagrams in the exact format StatsDCollector emits. My collector config differed from the committed one by exactly one line, in the traces exporter list (exporters: [debug, otlp/tempo, spanmetrics] -> [spanmetrics], because there was no Tempo container), which cannot affect metric naming or labels. Containers were removed afterwards. Two useful side results: the committed config starts cleanly on the pinned image, and the StatsD receiver is wired (otel-collector-config.yaml:22 defines it, :149 is receivers: [spanmetrics, statsd]) — so any claim that this branch has no StatsD receiver is wrong.
Already raised in threads that were resolved without the fix landing
Not re-posting these, but they are all still live at this tip and I verified each independently:
-
The metric-name prefix still does not match, so all five StatsD dashboards return exactly zero series.
docker/telemetry/xrpld-telemetry.cfg:67isprefix=xrpld;docker/telemetry/integration-test.sh:347writesprefix=rippledand its assertions at:590-605checkrippled_*; and the five dashboards contain 167rippled_*references and zeroxrpld_*. The prefix really does become the metric-name prefix —StatsDCollector.cpp:524, 565, 613, 688all emitimpl_->prefix() << "." << name_— and I confirmed the whole path live:xrpld.total.Bytes_In:12345|garrives asxrpld_total_Bytes_In. The fix direction is forced, and three of the resolved threads propose the wrong one.xrpldis the only committable value in a.cfg:.github/scripts/rename/docs.sh:33globs*.cfgand:39appliess@([^/+-])rippled@\1xrpld@g, and.github/workflows/reusable-check-rename.yml:37runs that script with:49failing the job on a dirty tree. I proved the rewrite by running that glob and sed over throwaway.cfg/.sh/.jsonfiles: the.cfglineprefix=rippledbecameprefix=xrpldwhile the.shand.jsoncopies were untouched — which is also why the legacy name survived in the script and the dashboards in the first place. So the script and dashboards must move toxrpld_*. ThreadPRRT_kwDOACmRR86TlukFproposed exactly that and was resolved without being applied;PRRT_kwDOACmRR86TjAo7,PRRT_kwDOACmRR86XGo6randPRRT_kwDOACmRR86ZbdYlpropose changing the cfg torippled, whichcheck-renamewould revert. The still-open thread onOpenTelemetryPlan/09-data-collection-reference.md:481is the live tracker; note its neighbouring text at:483assertsprefix=xrpldis "matching the integration test and Grafana dashboards", which is the part that is factually wrong today. -
The job-queue metrics are all missing the
jobq_group segment (threadPRRT_kwDOACmRR86TjAo2, resolved).src/xrpld/app/main/Application.cpp:375passescollectorManager_->group("jobq")andsrc/libxrpl/beast/insight/Groups.cpp:44isreturn name_ + "." + name;, so the group comes first. Confirmed live:xrpld.jobq.job_count:4|garrives asxrpld_jobq_job_count. The dashboards ask forrippled_job_countand the per-job-type names without the segment, across "Job Queue Depth", "Key Jobs Execution Time", "Key Jobs Dequeue Wait Time" and the two__name__=~detail panels. Because these five files exist on phase-6 only, "it is fixed downstream" is not available here — the downstream boards are different files. -
Resource Warnings RateandResource Drops Ratecan never plot (threadPRRT_kwDOACmRR86CFc1s, resolved).include/xrpl/resource/detail/Logic.hcreates these withmakeMeter, andStatsDCollector.cpp:688emits meters as|m, which the OTel StatsD receiver does not implement. I sentxrpld.warn:1|mandxrpld.drop:1|mand got zero series and, notably, zero diagnostics in the collector log. Two stat tiles that will read a reassuring flat zero forever. -
The PR adds a linter and 558 violations of it in the same commit (also raised in
PRRT_kwDOACmRR86TjAo2). Running the committedvalidate_dashboards.pyunmodified against the committed dashboards gives exit code 1 andFAIL: 558 violation(s)— 149 in ledger-data-sync, 140 in overlay-traffic-detail, 138 in node-health, 86 in network-traffic, 45 in rpc-pathfinding, and 0 in the five span-derived boards. By category: 486 missing tier template variables, 72 cumulative-raw. It is not wired into CI on this branch (grep -rn validate_dashboards .github/finds nothing), so the failure is latent — but it leaves reviewers no baseline. -
The three raw-counter traffic panels (
statsd-network-traffic.json:210,:263,:323) are still unwrapped; I left the line-210 thread open as the single tracker and resolved the other two as duplicates. One correction for whoever fixes them: these are registered as beastinsightgauges (OverlayImpl.h:583-586) whose value is a cumulative total copied from the monotonic atomics inTrafficCount.h:45,47(OverlayImpl.h:640-643), so they carry counter semantics without a_totalsuffix —rate()is still the right function, and the absence of_totalis correct rather than a bug. I verified the_totalrule live:|ccounters get it (xrpld.rpc_requests:7|c->xrpld_rpc_requests_total),|ggauges do not.
Two convention items, reported here rather than as inline comments
- Every panel is
gridPos.h: 8; the project convention is 10. All 111 panels across all ten dashboards: timeseries 82, stat 14, bargauge 4, heatmap 4, piechart 4, barchart 1, gauge 1, state-timeline 1 — every one at height 8..claude/instructions.mdfixes these at 10 (12 for table/logs, of which there are none here). This is already fixed for the span-derived boards on phase-7, so only phase-6 and the five phase-6-only StatsD boards need it. - No panel or target declares a datasource
uid— every panel is"datasource": nulland every target is{"type": "prometheus"}or{"type": "tempo"}, whileprovisioning/datasources/prometheus.yamlgives Prometheusuid: prometheusandisDefault: trueandtempo.yamlgives Tempouid: tempowith noisDefault. The six TraceQL targets inconsensus-health.jsonare the exposed ones. I am flagging this as an observation rather than a defect because I could not exercise Grafana's frontend datasource resolution, so the impact is unverified; what is verified is that nouidis present, that only Prometheus is the default, and that phase-9 rewrote exactly these targets to"uid": "${DS_PROMETHEUS}"/"${DS_TEMPO}".
| "label": "Node", | ||
| "description": "Filter by rippled node (service.instance.id — e.g. Node-1)", | ||
| "type": "query", | ||
| "query": "label_values(target_info, service_instance_id)", |
There was a problem hiding this comment.
[BLOCKING · High] — The $node dropdown is empty on all five span-derived dashboards, because target_info has no service_instance_id label.
This variable is "query": "label_values(target_info, service_instance_id)". I ran the PR's own collector config (otel/opentelemetry-collector-contrib:0.158.0, the tag docker-compose.yml:24 pins) plus prom/prometheus:v3.13.2 with the PR's own prometheus.yml, sent one OTLP span whose resource had service.instance.id="Node-1", and queried Prometheus:
target_info{deployment_environment="local", exported_instance="Node-1", exported_job="xrpld",
instance="otel-collector:8889", job="otel-collector", service_version="2.4.0",
xrpl_network_id="0", xrpl_network_type="mainnet"} 1
label_values(target_info, service_instance_id) -> []
label_values(target_info, exported_instance) -> ['Node-1']
service.instance.id is translated to instance on target_info, and Prometheus renames it to exported_instance because prometheus.yml sets no honor_labels. So the label the variable asks for does not exist there and the dropdown lists nothing but "All".
The panel selectors are fine — service_instance_id genuinely does exist on the span metrics, because otel-collector-config.yaml:186-188 enables resource_to_telemetry_conversion. Same run:
traces_span_metrics_calls_total{..., service_instance_id="Node-1", span_name="tx.apply", ...}
So only the variable query is wrong. Nothing looks broken — panels still draw with $node falling back to .* — you simply can never isolate a node on Consensus Health, Transaction Overview, RPC Performance, Ledger Operations or Peer Network.
Same line in the other four: transaction-overview.json:929, rpc-performance.json:499, ledger-operations.json:398, peer-network.json:226.
Fix: label_values(traces_span_metrics_calls_total, service_instance_id), which is where the label actually lives. Not fixed downstream — git show pratik/otel-phase10-workload-validation:docker/telemetry/grafana/dashboards/consensus-health.json | grep -c 'label_values(target_info, service_instance_id)' still returns 1, so the same fix is needed there separately.
| "label": "Node", | ||
| "description": "Filter by xrpld node (service.instance.id \u2014 e.g. Node-1)", | ||
| "type": "query", | ||
| "query": "label_values(exported_instance)", |
There was a problem hiding this comment.
[BLOCKING · High] — Choosing a node blanks every panel on all five StatsD dashboards: exported_instance does not exist on StatsD series at all.
The StatsD receiver produces metrics with no service resource, so there is no service.instance.id for the exporter to map to instance, and nothing for Prometheus to rename. In the live run described in my comment on consensus-health.json:1081 I sent a datagram in the exact format StatsDCollector emits (src/libxrpl/beast/insight/StatsDCollector.cpp:613 is ss << impl_->prefix() << "." << name_ << ":" << value_ << "|g") and got the complete label set:
xrpld_Peer_Finder_Active_Inbound_Peers{
deployment_environment="local", instance="otel-collector:8889", job="otel-collector",
metric_type="gauge", otel_scope_name=".../receiver/statsdreceiver",
otel_scope_version="0.158.0", xrpl_network_type="mainnet"} 5
No exported_instance, no service_instance_id. Queried both ways:
xrpld_..._Active_Inbound_Peers{exported_instance=~"Node-1"} -> result count: 0
xrpld_..._Active_Inbound_Peers{exported_instance=~".*"} -> result count: 1
This is worse than a filter that plainly does not work. label_values(exported_instance) here is unscoped, so on a live stack it returns the node names coming from the span pipeline and the dropdown looks populated — then the moment an operator picks one, all 162 targets across these five boards go to "No data". Affected target counts: node-health 46, ledger-data-sync 39, overlay-traffic-detail 36, network-traffic 26, rpc-pathfinding 15.
There is no per-node dimension on the StatsD path to filter on, so this cannot be fixed in the dashboard alone. Either add a collector-side processor that stamps a node identity onto StatsD metrics, or drop the node variable and the exported_instance=~"$node" clauses and state in the dashboard description that these boards are single-node.
Worth knowing for scoping: these five files exist on phase-6 only — git ls-tree -r --name-only <branch> -- docker/telemetry/grafana/dashboards/ | grep -c statsd- gives 5 here and 0 on phase-7, phase-9 and phase-10. So there is no downstream copy that inherits a fix, and equally no downstream copy that is already correct.
| "datasource": { | ||
| "type": "prometheus" | ||
| }, | ||
| "expr": "topk(15, rate({__name__=~\"rippled_.*_Bytes_In\", __name__!~\"rippled_total_.*\", exported_instance=~\"$node\"}[5m]))", |
There was a problem hiding this comment.
[BLOCKING · High] — This panel does not render a wrong number, it returns a Prometheus execution error.
Panel "All Traffic Categories (Detail)". rate() drops __name__, and StatsD series carry no per-category labels (see my comment on statsd-node-health.json:892 for the full label dump), so once the name is gone every traffic category collapses to the same label set and the result is rejected. Reproduced against the live stack with two categories present, with a control to isolate the cause:
topk(15, rate({__name__=~"xrpld_.*_Bytes_In", __name__!~"xrpld_total_.*"}[5m]))
-> status=error errorType=execution
error="vector cannot contain metrics with the same labelset"
topk(15, {__name__=~"xrpld_.*_Bytes_In", __name__!~"xrpld_total_.*"}) <- same selector, no rate()
-> status=success
xrpld_proposals_Bytes_In 333
xrpld_transactions_Bytes_In 222
The control shows the selector itself is fine; rate() is what breaks it. Two categories is not an edge case — src/xrpld/overlay/detail/TrafficCount.h defines 57 of them, so any real node hits this immediately.
legendFormat: "{{__name__}}" on this target would also render empty for the same reason, even if the query ran.
This is the only site: grep -nE '(rate|irate|increase)\(\{__name__' docker/telemetry/grafana/dashboards/*.json returns line 754 and nothing else.
Fix: aggregate by name so the label set stays unique — topk(15, sum by (__name__) (rate({...}[$__rate_interval]))).
| from pathlib import Path | ||
|
|
||
| # Prometheus gauges that hold a CUMULATIVE total -> must be rate()/increase()-wrapped. | ||
| CUMULATIVE_PREFIXES = ( |
There was a problem hiding this comment.
[SHOULD-FIX · Medium] — This list is lowercase and the comparison is case-sensitive, so the linter is blind to the metric names this branch actually produces.
CUMULATIVE_PREFIXES here is all-lowercase ("total_bytes_", "transactions_messages_", …), STATE_DURATION at line 43 is re.compile(r"state_accounting_\w+_duration") with no re.I, and the test is a plain m in expr substring match. But StatsD metric names on this branch are case-preserved: src/libxrpl/beast/insight/StatsDCollector.cpp:613 passes the beast name through verbatim, and src/xrpld/overlay/detail/OverlayImpl.h:583-586 registers those names as "Bytes_In" / "Messages_In". I confirmed the end result live through the PR's own collector: xrpld.total.Bytes_In:12345|g arrives as xrpld_total_Bytes_In.
I mutation-tested the linter on copies in /tmp (the repo files were not touched), with a control:
rate() present, lowercase name (control, should pass) exit=0 cumulative-raw violations=0
raw + LOWERCASE name (should be caught) exit=1 cumulative-raw violations=1
raw + REAL casing Bytes_In (same defect) exit=0 cumulative-raw violations=0
Identical defect, only the case differs, and the real-world spelling slips through.
Why it matters concretely: this is why the linter does not flag the "Transaction Traffic" panel that the still-open thread on statsd-network-traffic.json:210 is about (rippled_transactions_Messages_In, plotted raw), nor "Peer Disconnects" (rippled_Overlay_Peer_Disconnects), nor "Operating Mode Duration". "Proposal Traffic" and "Validation Traffic" are caught only by luck, because proposals_ and validations_ happen to be lowercase inside the real names.
Fix: lower-case both sides — compare against expr.lower(), and add re.I to STATE_DURATION. Two lines. Note validate_dashboards.py has a second variant later in the chain (143 lines here, 196 on phase-9/10), so the same fix is needed there too.
| { | ||
| "matcher": { | ||
| "id": "byName", | ||
| "options": "rippled_ping_Bytes_In" |
There was a problem hiding this comment.
[SHOULD-FIX · Low] — Five of the 22 display-name overrides on this panel target field names that can never exist, so the one panel meant to be human-readable is only partly readable.
Panel "Overlay Traffic by Category (Bytes In)". The authoritative category list is the Category -> string table in src/xrpld/overlay/detail/TrafficCount.h (57 names, starting at line 240 with {Category::Base, "overhead"}). I checked all 22 byName matchers against it programmatically; 17 match a real category and these five do not:
rippled_ping_Bytes_In -> "ping" not a category (display "Ping")
rippled_status_Bytes_In -> "status" not a category (display "Status")
rippled_getObject_Bytes_In -> "getObject" not a category (display "Get Object")
rippled_haveTxSet_Bytes_In -> "haveTxSet" not a category (display "Have Tx Set")
rippled_ledgerData_Bytes_In -> "ledgerData" not a category (display "Ledger Data")
Absence check — grep -c '"ping"' src/xrpld/overlay/detail/TrafficCount.h and the same for the other four all return 0. The nearest real names are overhead, getobject_* (lowercase o, and always with a sub-type suffix such as getobject_get), have_transactions, and ledger_data_get / ledger_data_share.
The other five matchers I initially suspected — the mixed-case ledger_data_Account_State_Node_get family — are genuine; that was an artifact of my first grep excluding uppercase, and they do appear in the table.
Fix: delete the five dead matchers, or repoint them at the real names if those message classes were the intent.
Review feedback, plus a sweep of the branch for the same defects elsewhere. Scope: the ledger.validate guard was a plain local, so it stayed alive until checkAccept returned and the flag-ledger upgrade check ran inside the measured span. That check reads every trusted validation of the parent, so one span in 256 became a duration outlier for work unrelated to promoting a ledger. The span is now scoped to the promotion. tryAdvance stays inside it because it only sets a flag and posts a job. Attributes: tx.apply now carries ledger_seq, which the runbook already documented. The parent ledger.build span has it, but a child cannot be selected by its parent's attributes, so the span could not be found by ledger. Guard names: each span guard is now named after the span it holds, so proposalReceiveSpan, validationReceiveSpan, storeSpan and validateSpan. The name "span" previously meant the trace root in one inbound-message handler and the job-queue handle in its sibling, which taught a reader the opposite of the truth in the next function. Comments: the StatsD gauge rationale now sits with the initialiser it explains rather than in the constructor. The peer span header described its trust flags as shared when they are in fact re-declared to match the consensus keys; the duplication is intentional and the wording was not. Docs: the ledger and peer span tables disagreed with the code, crediting ledger.build with attributes that are set on tx.apply and omitting several that it does set, and all five source-file line numbers in them were stale. The testing guide listed attribute keys that exist nowhere in the code, so its catalog now points at the runbook instead of keeping a second copy that drifts.
Review feedback on the testing guide: - rm -rf targeted data/, but this config writes under docker/telemetry/data/, so teardown did nothing and a second run reused the old NuDB and SQLite state. Corrected at both sites, including the Test 2 keygen node, which launches with the same config. - The standalone span table said consensus.* does not fire. It does: ledger_accept drives a simulated round, so consensus.round, .phase.open, .ledger_close, .accept and .accept.apply all appear. Only .establish, .update_positions, .check, .proposal.* , .validation.receive and .mode_change cannot. The test intro claimed the same thing and now agrees with the table. - Three blocks duplicated content the file already had. Test 1 now points at the shared Verification Queries section as Test 2 already did, and the Test 2 submit block checks engine_result like Test 1 does. - The numbered step list was a copy of the script's own Step N headers and had drifted by four entries, so it now points at those headers instead. Also corrects the runbook's ledger and peer span tables against the code: ledger.build was credited with tx_count and tx_failed, which tx.apply sets, and was missing its three close-time attributes; peer.validation.receive was missing ledger_hash and full_validation. The five source line numbers in those two tables were stale, so they now name the file only, as the other nineteen rows do.
The rename arrived from phase-1b by merge. Four files still wrote the old key, which the parser no longer reads, so each would have silently fallen back to the default collector URL. integration-test.sh is the load-bearing one: it generates the node config the test harness starts, so the stale key would have pointed the node at localhost regardless of the compose network. xrpld-telemetry.cfg is the standalone node config; the other two document the key. Note this cfg has a second, divergent variant on the devnet branches that needs the same fix there.
phase-4 renamed the shared close_time attribute to close_time_ripple_epoch_s, naming its unit and epoch. Two consumers here still referenced the old spelling and broke the build once the rename merged forward: the re-export in LedgerSpanNames.h and the setAttribute call in BuildLedger.cpp. Both are phase-6 content, so they are fixed here rather than upstream.
…ers on The Consensus Health template-variable table documented $node as resolving via exported_instance. That dashboard defines $node as label_values(target_info, service_instance_id) and its panels filter on service_instance_id; exported_instance appears in it zero times. exported_instance is a real label, but it belongs to the StatsD boards shipped alongside, where Prometheus renames a scraped instance label that collides with the target's own. Documenting it against an OTel dashboard pointed readers at the wrong pipeline's label.
| ], | ||
| "fieldConfig": { | ||
| "defaults": { | ||
| "unit": "s", |
There was a problem hiding this comment.
Unit mismatch between panels—this shows 's' but another divides by 1M implying µs. Confirm unit and sync:
Verify whether State_Accounting_*_duration is emitted in microseconds or seconds, then:
- If microseconds: change this panel's unit to 'µs' and remove /1000000 from the 'State Duration Rate' panel
- If seconds: add /1000000 scaling to this panel's metric expressions
High Level Overview of Change
Bridges xrpld's existing
beast::insightStatsD metrics into the telemetry pipeline, adds the first ten Grafana dashboards, fills the empty ledger/peer span categories, and extends the integration test to cover metrics.No need to review tasklist files.
Context of Change
StatsD → Collector → Prometheus — the Collector gains a
statsdreceiver on UDP 8125 withtimer_histogram_mapping, and the metrics pipeline becomesreceivers: [spanmetrics, statsd]behind a single Prometheus endpoint on :8889. Node configs set[insight] server=statsd. This exposes the ~300 metrics xrpld already computes without touching any call site.Gauge fix —
StatsDGaugeImplstarted withdirty_{false}, so a gauge whose value never moved off 0 was never flushed and never appeared in Prometheus. It now starts dirty, so the initial value is emitted on the first flush.Spans added — fills the previously empty
trace_ledgercategory and extends peer tracing:ledger.build,ledger.validate,ledger.store(BuildLedger.cpp,LedgerMaster.cpp)tx.apply— batch transaction application per ledger, attrstx_count/tx_failedpeer.proposal.receive,peer.validation.receive(off by default)Dashboards (10, all new) — consensus-health, ledger-operations, peer-network, rpc-performance, transaction-overview (span-derived) and statsd-node-health, statsd-network-traffic, statsd-rpc-pathfinding, statsd-ledger-data-sync, statsd-overlay-traffic-detail (StatsD-derived), with
validate_dashboards.pyas a structural gate and provisioned Prometheus/Tempo datasources.Docs —
OpenTelemetryPlan/09-data-collection-reference.mdis introduced here as the signal inventory;integration-test.shverifies StatsD metrics reach Prometheus.Known limitation — Resource Manager
warn/dropuse the non-standard|mStatsD type, which the Collector's receiver silently drops. Changing it to|cis a wire-format change for existing StatsD consumers and is deferred.API Impact
Test Plan
python3 -m json.toolon every dashboard, plusvalidate_dashboards.py.docker compose upexposes 8125/udp; StatsD metrics appear in Prometheus once[insight]is set.ledger.build/ledger.validate/ledger.store/tx.applyvisible in Tempo.integration-test.shpasses its metric step.