# xrpld Telemetry Operator Runbook ## Table of Contents - [Overview](#overview) - [Quick Start](#quick-start) - [Configuration Reference](#configuration-reference) - [Exporting to Grafana Cloud](#exporting-to-grafana-cloud) - [Span Reference](#span-reference) - [RPC Spans](#rpc-spans) - [Transaction Spans](#transaction-spans) - [Transaction Queue Spans](#transaction-queue-spans) - [PathFinding Spans](#pathfinding-spans) - [Consensus Spans](#consensus-spans) - [Ledger Spans](#ledger-spans) - [Peer Spans](#peer-spans) - [Protocol Span Flow](#protocol-span-flow) - [Master overview](#master-overview) - [Client and peer ingress](#client-and-peer-ingress) - [Shared transaction apply pipeline](#shared-transaction-apply-pipeline) - [Consensus round](#consensus-round) - [Accept, build, and finalize the ledger](#accept-build-and-finalize-the-ledger) - [Side flows: pathfinding and ledger acquire](#side-flows-pathfinding-and-ledger-acquire) - [Where telemetry parenting differs from protocol flow](#where-telemetry-parenting-differs-from-protocol-flow) - [Insights and Sample Queries](#insights-and-sample-queries) - [Transaction Workflow Analysis](#transaction-workflow-analysis) - [DEX (OfferCreate / OfferCancel)](#dex-offercreate--offercancel) - [Apply Pipeline by Stage](#apply-pipeline-by-stage) - [Transaction Queue Health](#transaction-queue-health) - [RPC Debugging](#rpc-debugging) - [PathFinding Performance](#pathfinding-performance) - [Consensus Health](#consensus-health) - [Cross-Subsystem Correlation](#cross-subsystem-correlation) - [Cross-Node Trace Propagation](#cross-node-trace-propagation) - [Prometheus Metrics (Spanmetrics)](#prometheus-metrics-spanmetrics) - [System Metrics (OTel native -- beast::insight)](#system-metrics-otel-native----beastinsight) - [Grafana Dashboards](#grafana-dashboards) - [Alerting](#alerting) - [Log-Trace Correlation](#log-trace-correlation) - [Troubleshooting](#troubleshooting) - [Performance Tuning](#performance-tuning) - [Disabling Telemetry](#disabling-telemetry) - [Validating Telemetry Stack](#validating-telemetry-stack) - [Performance Benchmarking](#performance-benchmarking) ## Overview xrpld supports OpenTelemetry distributed tracing to provide visibility into RPC requests, transaction processing, and consensus rounds. This runbook covers operating a running node and querying its traces. For building xrpld with telemetry support and the internal architecture, see [build/telemetry.md](build/telemetry.md). For plain-language definitions of the XRP Ledger terms used in the dashboards, see the [telemetry glossary](telemetry-glossary.md). ## Quick Start ### 1. Start the observability stack ```bash docker compose -f docker/telemetry/docker-compose.yml up -d ``` This starts: - **OTel Collector** on ports 4317 (gRPC) and 4318 (HTTP), and 13133 (health) - **Tempo** on http://localhost:3200 (trace backend) - **Prometheus** on http://localhost:9090 - **Loki** on http://localhost:3100 (log aggregation) - **Grafana** on http://localhost:3000 (Tempo pre-configured as datasource) ### 2. Enable telemetry in xrpld Add to your `xrpld.cfg`: ```ini [telemetry] enabled=1 endpoint=http://localhost:4318/v1/traces ``` ### 3. Build with telemetry support ```bash conan install . --build=missing -o telemetry=True cmake --preset default -Dtelemetry=ON cmake --build --preset default ``` ### 4. Run against a live network Two ready-made configs connect a tracking node (no validator credentials) to a public network with all tracing and native metrics enabled: | Config | Network | | ---------------------------------------------- | ------- | | `docker/telemetry/xrpld-telemetry.cfg` | Devnet | | `docker/telemetry/xrpld-telemetry-mainnet.cfg` | Mainnet | ```bash .build/xrpld --conf docker/telemetry/xrpld-telemetry-mainnet.cfg ``` Both set `[insight] server=otel` (native metrics → collector → Prometheus, which drives the dashboards) and `service_instance_id`, exposed by Prometheus as the `service_instance_id` label that the `$node` dashboard variable filters on. The mainnet config logs to `/var/log/xrpld/mainnet/debug.log` — the path the collector's filelog receiver tails for log-trace correlation. Metrics begin flowing as soon as the node connects to peers (`server_state` ≥ `connected`); full ledger and consensus panels populate after sync (`server_state` = `full`). Check progress with: ```bash curl -s http://localhost:5005 -d '{"method":"server_info"}' | jq '.result.info | {server_state, peers, complete_ledgers}' ``` > Mainnet sync is bandwidth- and disk-heavy. For a quick check use the devnet > config or the standalone test in `docker/telemetry/TESTING.md`, which > generates spans without waiting for a live sync. ## Configuration Reference | Option | Default | Description | | -------------------------- | --------------------------------- | --------------------------------------------------------- | | `enabled` | `0` | Master switch for telemetry | | `endpoint` | `http://localhost:4318/v1/traces` | OTLP/HTTP endpoint | | `service_name` | `xrpld` | OpenTelemetry service name resource attribute | | `service_instance_id` | node public key | OpenTelemetry service instance ID resource attribute | | `trace_rpc` | `1` | Enable RPC request tracing | | `trace_transactions` | `1` | Enable transaction tracing | | `trace_consensus` | `1` | Enable consensus tracing | | `trace_peer` | `1` | Enable peer message tracing (high volume) | | `trace_ledger` | `1` | Enable ledger tracing | | `consensus_trace_strategy` | `deterministic` | Consensus trace ID strategy (`deterministic` or `random`) | | `batch_size` | `512` | Max spans per batch export | | `batch_delay_ms` | `5000` | Delay between batch exports | | `max_queue_size` | `2048` | Max spans queued before dropping | | `use_tls` | `0` | Use TLS for exporter connection | | `tls_ca_cert` | (empty) | Path to CA certificate bundle | | `tls_client_cert` | (empty) | Client cert (PEM) for mutual TLS; empty = one-way TLS | | `tls_client_key` | (empty) | Private key (PEM) for `tls_client_cert` | ## Exporting to Grafana Cloud The collector can ship traces, metrics, and logs to a hosted **Grafana Cloud** stack instead of (or alongside) the local Tempo/Prometheus/Loki backends. This is a runtime choice — no xrpld rebuild and no change to the base stack. xrpld still exports to the local collector exactly as before; the collector adds one OTLP/HTTP exporter that forwards all three signals to the Grafana Cloud OTLP gateway, which fans them out to hosted Tempo, Mimir, and Loki. ### Credentials Find these under **Grafana Cloud → Connections → OpenTelemetry (OTLP)**: | Value | Used as | Notes | | ----------------------------- | ----------------- | -------------------------------------------------- | | `GRAFANA_CLOUD_OTLP_ENDPOINT` | exporter endpoint | Full gateway URL incl. `/otlp` path | | `GRAFANA_CLOUD_INSTANCE_ID` | Basic-auth user | Numeric stack/instance id | | `GRAFANA_CLOUD_API_TOKEN` | Basic-auth pass | Access-policy token with `*:write` for all signals | ### Enable 1. Copy the template and fill in the three values: ```bash cp docker/telemetry/.env.grafanacloud.example docker/telemetry/.env.grafanacloud # edit .env.grafanacloud — this file is gitignored, never commit tokens ``` 2. Bring the stack up with the base file **and** the Grafana Cloud override: ```bash docker compose -f docker/telemetry/docker-compose.yml \ -f docker/telemetry/docker-compose.grafanacloud.yaml up -d ``` To return to local-only export, bring the stack up with just the base `docker-compose.yml`. ### Files | File | Role | | ----------------------------------------- | ---------------------------------------------------------------------------------------------- | | `otel-collector-config.grafanacloud.yaml` | Collector config: local backends **plus** a Grafana Cloud OTLP exporter on all three pipelines | | `docker-compose.grafanacloud.yaml` | Override that mounts that config and injects the credentials | | `.env.grafanacloud.example` | Credential template (copy to `.env.grafanacloud`) | ### Local + cloud vs cloud-only The prepared config **dual-exports**: data goes to both the local stack and Grafana Cloud, so the on-box backends remain a fallback. For cloud-only, remove the local exporters (`debug`, `otlp/tempo`, `prometheus`, `otlphttp/loki`) from the respective pipelines in `otel-collector-config.grafanacloud.yaml`, leaving only `otlphttp/grafanacloud`. > **Note**: shipping logs to Grafana Cloud requires keeping xrpld file > logging on (at least `warning` level) so the collector's filelog receiver > has a `debug.log` to tail. Traces and metrics are unaffected by log level. ### Importing dashboards to Grafana Cloud Shipping data (above) is independent of installing the dashboards. The local stack auto-provisions dashboards from a mounted folder (`grafana/provisioning/dashboards/dashboards.yaml`, `type: file`); Grafana Cloud cannot read your filesystem, so its dashboards must be imported over the HTTP API or the UI. The dashboard JSON in `docker/telemetry/grafana/dashboards/` references its backends through datasource **template variables** (`${DS_PROMETHEUS}`, `${DS_TEMPO}`) rather than fixed UIDs. On import, Grafana binds each variable to a datasource of the matching type — auto-selecting it when only one exists (the usual case: one Mimir, one Tempo). This is what makes the same files work unchanged on both the local stack and Cloud. > Dashboards are parameterized by `grafana/parameterize-datasources.py`. If you > add a dashboard exported with hardcoded UIDs, re-run that script (idempotent) > before committing so it stays portable. To import: 1. In Grafana Cloud, go to **Dashboards → New → Import**. 2. Upload a file from `docker/telemetry/grafana/dashboards/` (or paste its JSON), then click **Load**. 3. At the datasource prompt, confirm the auto-selected **Prometheus/Mimir** datasource — and **Tempo** for dashboards that query traces — then **Import**. 4. Repeat per dashboard. Only `consensus-health` uses Tempo; the rest need only the Prometheus/Mimir datasource. ## Span Reference All spans instrumented in xrpld, grouped by subsystem: ### RPC Spans | Span Name | Source File | Attributes | Description | | -------------------- | ----------------- | ----------------------------------------------------------- | ----------------------------------------------------- | | `rpc.http_request` | ServerHandler.cpp | `request_payload_size` | Top-level HTTP RPC request | | `rpc.ws_upgrade` | ServerHandler.cpp | — | WebSocket upgrade handshake | | `rpc.ws_message` | ServerHandler.cpp | `command` | WebSocket RPC message | | `rpc.process` | ServerHandler.cpp | `is_batch`, `batch_size` | RPC processing (child of rpc.http_request/ws_message) | | `rpc.command.` | RPCHandler.cpp | `command`, `version`, `rpc_role`, `rpc_status`, `load_type` | Per-command span (e.g., `rpc.command.server_info`) | ### Transaction Spans | Span Name | Source File | Attributes | Description | | --------------- | --------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------- | | `tx.process` | NetworkOPs.cpp | `tx_hash`, `local`, `path`, `tx_type`, `fee`, `sequence`, `ter_result`, `applied`, `current_ledger_seq` | Transaction submission and processing | | `tx.receive` | PeerImp.cpp | `peer_id`, `tx_hash`, `tx_type`, `peer_version`, `suppressed`, `tx_status`, `current_ledger_seq` | Transaction received from peer relay | | `tx.apply` | BuildLedger.cpp | `ledger_seq`, `tx_count`, `tx_failed` | Transaction set applied per ledger | | `tx.preflight` | applySteps.cpp | `stage`, `tx_type`, `ter_result` | Stateless checks stage | | `tx.preclaim` | applySteps.cpp | `stage`, `tx_type`, `ter_result`, `current_ledger_seq`, `current_ledger_hash` | Ledger-aware checks stage | | `tx.transactor` | Transactor.cpp | `stage`, `tx_type`, `ter_result`, `applied`, `current_ledger_seq`, `current_ledger_hash` | Apply stage (transactor runs) | The three apply-pipeline spans (`tx.preflight`, `tx.preclaim`, `tx.transactor`) share a deterministic `trace_id` from `txID[0:16]`, so they group under one trace per transaction. The `stage` attribute (`preflight` / `preclaim` / `apply`) drives the collector spanmetrics `stage` dimension, giving per-stage RED metrics on the _Transaction Overview_ dashboard. `current_ledger_seq` is the current (open/in-flight) ledger index a span acted on — the ledger being worked on, not an established one — so a transaction's txID-keyed spans can be joined to the ledger trace it targeted (`span.current_ledger_seq`). The view-bearing stages (`tx.preclaim`, `tx.transactor`) also carry `current_ledger_hash` (the current ledger's parent hash); `tx.preflight` is stateless and omits both. ### Transaction Queue Spans | Span Name | Source File | Attributes | Description | | ------------------ | ----------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `txq.enqueue` | TxQ.cpp | `tx_hash`, `tx_type`, `current_ledger_seq`, `current_ledger_hash` | Enqueue decision; parents to `tx.process` on the submission path (explicit context), a root on the open-ledger rebuild path — `current_ledger_seq` correlates it to the ledger in both cases | | `txq.apply_direct` | TxQ.cpp | -- | Direct apply attempt (bypassing queue) | | `txq.batch_clear` | TxQ.cpp | -- | Batch clear of queued transactions for an account | | `txq.accept` | TxQ.cpp | `queue_size`, `ledger_changed` | Ledger-close accept loop over queued transactions | | `txq.accept_tx` | TxQ.cpp | `tx_hash`, `retries_remaining`, `ter_code`, `txq_status` | Per-transaction apply during accept | | `txq.cleanup` | TxQ.cpp | `ledger_seq` | Post-close cleanup of expired queue entries | ### PathFinding Spans | Span Name | Source File | Attributes | Description | | --------------------- | --------------------------------- | -------------------------------------------------- | ------------------------------------------------------- | | `pathfind.request` | PathFind.cpp / RipplePathFind.cpp | `pathfind_source_account`, `pathfind_dest_account` | Path-find RPC entry (accounts hashed; set when present) | | `pathfind.compute` | PathRequest.cpp | `pathfind_fast`, `pathfind_dest_currency` | Path computation for one request (`doUpdate`) | | `pathfind.discover` | PathRequest.cpp | `pathfind_search_level`, `pathfind_num_paths` | Graph exploration (one per RPC call in `findPaths`) | | `pathfind.update_all` | PathRequestManager.cpp | `pathfind_ledger_index`, `pathfind_num_requests` | Async recomputation of active requests on ledger close | ### Consensus Spans | Span Name | Source File | Attributes | Description | | ------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `consensus.round` | RCLConsensus.cpp | `consensus_ledger_id`, `ledger_seq`, `consensus_mode`, `trace_strategy`, `consensus_round_id` | Root span for a consensus round (deterministic or random trace ID) | | `consensus.phase.open` | Consensus.h | -- | Open phase duration (child of round) | | `consensus.proposal.send` | RCLConsensus.cpp | `consensus_round`, `is_bow_out` | Consensus proposal broadcast | | `consensus.ledger_close` | RCLConsensus.cpp | `ledger_seq`, `consensus_mode` | Ledger close event | | `consensus.establish` | Consensus.h | `converge_percent`, `establish_count`, `proposers` | Establish phase duration (child of round) | | `consensus.update_positions` | Consensus.h | `converge_percent`, `proposers`, `disputes_count` | Position update and dispute resolution (see Events below) | | `consensus.check` | Consensus.h | `agree_count`, `disagree_count`, `converge_percent`, `have_close_time_consensus`, `threshold_percent`, `proposers_finished`, `consensus_stalled`, `establish_count`, `consensus_result` | Consensus threshold check | | `consensus.accept` | RCLConsensus.cpp | `proposers`, `round_time_ms`, `quorum`, `disputes_count`, `consensus_state` | Ledger accepted by consensus | | `consensus.accept.apply` | RCLConsensus.cpp | `ledger_seq`, `close_time`, `close_time_correct`, `close_resolution_ms`, `consensus_state`, `proposing`, `round_time_ms`, `parent_close_time`, `close_time_self`, `close_time_vote_bins`, `resolution_direction`, `tx_count` | Ledger application with close time details (see Events below) | | `consensus.validation.send` | RCLConsensus.cpp | `ledger_seq`, `proposing`, `ledger_hash`, `full_validation`, `validation_sign_time` | Validation sent after accept (follows-from link) | | `consensus.mode_change` | RCLConsensus.cpp | `mode_old`, `mode_new` | Consensus mode transition | | `consensus.proposal.receive` | PeerImp.cpp | `proposal_trusted`, `consensus_round` | Proposal received from peer (extracts parent context from TraceContext when present; falls back to standalone span for older peers) | | `consensus.validation.receive` | PeerImp.cpp | `validation_trusted`, `ledger_seq` | Validation received from peer (extracts parent context from TraceContext when present; falls back to standalone span for older peers) | #### Consensus Span Events | Parent Span | Event Name | Event Attributes | Description | | ---------------------------- | ----------------- | ----------------------------------------------------------- | ------------------------------------------------------- | | `consensus.update_positions` | `dispute.resolve` | `tx_id`, `dispute_our_vote`, `dispute_yays`, `dispute_nays` | Emitted per dispute when votes are tallied | | `consensus.accept.apply` | `tx.included` | `tx_id` | Emitted per transaction included in the accepted ledger | #### Close Time Queries (Tempo TraceQL) Span attributes are filtered with `span.` inside `{}`. Combine conditions with `&&`. ``` # Find rounds where validators disagreed on close time {name="consensus.accept.apply" && span.close_time_correct = false} # Find consensus failures (moved_on) {name="consensus.accept.apply" && span.consensus_state = "moved_on"} # Find slow ledger applications (>5s) {name="consensus.accept.apply" && duration > 5000ms} # Find specific ledger's consensus details {name="consensus.accept.apply" && span.ledger_seq = 92345678} # Find all spans in a consensus round (deterministic trace strategy) {name="consensus.round" && span.consensus_round_id = ""} # Find dispute resolutions {name="consensus.update_positions"} >> {event:name="dispute.resolve"} ``` ### Ledger Spans | Span Name | Source File | Attributes | Description | | ----------------- | -------------------- | ------------------------------------- | ----------------------------- | | `ledger.build` | BuildLedger.cpp:31 | `ledger_seq`, `tx_count`, `tx_failed` | Ledger build during consensus | | `ledger.validate` | LedgerMaster.cpp:915 | `ledger_seq`, `validations` | Ledger promoted to validated | | `ledger.store` | LedgerMaster.cpp:409 | `ledger_seq` | Ledger stored in history | ### Peer Spans | Span Name | Source File | Attributes | Description | | ------------------------- | ---------------- | ------------------------------- | ----------------------------- | | `peer.proposal.receive` | PeerImp.cpp:1667 | `peer_id`, `proposal_trusted` | Proposal received from peer | | `peer.validation.receive` | PeerImp.cpp:2264 | `peer_id`, `validation_trusted` | Validation received from peer | Both peer receive spans are `kConsumer` inbound entry points started as fresh trace roots. They never inherit an ambient span left active on the peer thread, so they do not nest under an unrelated transaction's trace. The distributed child span that links back to the sending node is the separate `consensus.*.receive` / `tx.receive` span (see Cross-Node Trace Propagation). --- ## Protocol Span Flow This section maps every span type onto the **real xrpld control flow and XRPL protocol order** (verified against code and [docs/consensus.md](consensus.md)) — what the code actually executes next, in what order, with which loops and branches. Spans are drawn as **labels on real operations**, not as their OpenTelemetry parent links; the SDK's span parenting is listed separately in [Where telemetry parenting differs from protocol flow](#where-telemetry-parenting-differs-from-protocol-flow). These diagrams are the **canonical key for linking the span hierarchy** — every node and every branch is labelled with the span that represents that state or transition, so a span can be wired to its true protocol parent/child by reading the graph. They are therefore drawn **exact, not simplified**: every real loop, retry, recovery, and drop branch is shown even when it adds clutter. Naming and edge conventions: - **Rectangle `[ ]`** — a state/operation that **emits a span**; the first line is the exact `span.name`, the parenthetical below is the operation. - **Rounded `( )` with `(no span)`** — a real protocol step that emits **no span**; shown so the flow stays continuous and is never mistaken for a missing span. - **Solid arrow** — the code calls or sequences directly into the next operation. When the transition itself emits a span, the edge is labelled `→[span.name]`; otherwise it carries the branch condition. - **`↻`** — the edge repeats (per tx, per peer, per dispute, per pass, per round). - **Dashed arrow** — a conditional branch or an async job hand-off. - **Dotted `⇢ ctx`** — trace context crosses a node boundary over a protobuf peer message (`sender.span ⇢ receiver.span`); a different node continues the trace — **not** an in-process call. - **Red-bordered node** — a terminal **drop / abandon** state. ### Master overview Five ingress origins feed two shared engines — the per-transaction **apply pipeline** and the **consensus round** — which converge on ledger build → store → validate. Pathfinding and ledger-acquire are side flows. A single ledger can take **many consensus rounds** to settle (see [Consensus round](#consensus-round)). ```mermaid flowchart TB classDef ingress fill:#1d4ed8,stroke:#1e3a8a,color:#fff; classDef engine fill:#047857,stroke:#064e3b,color:#fff; classDef consensus fill:#b45309,stroke:#7c2d12,color:#fff; classDef ledger fill:#6d28d9,stroke:#4c1d95,color:#fff; classDef side fill:#0e7490,stroke:#155e75,color:#fff; classDef plain fill:#334155,stroke:#0f172a,color:#fff; subgraph ING["Ingress (protocol entry points)"] direction TB RPC["rpc.http_request / rpc.ws_message
rpc.ws_upgrade / grpc.MethodName
(client transport in)"]:::ingress SUB(["submit command
(no span)"]):::plain PRELAY["tx.receive
(peer relay in)"]:::ingress PMSG["peer.proposal.receive
peer.validation.receive
(peer overlay in)"]:::ingress end TXP["tx.process
(NetworkOPs::processTransaction)"]:::engine OPEN["txq.enqueue
(open-ledger apply + TxQ decision)"]:::engine PIPE["tx.preflight → tx.preclaim → tx.transactor
(SHARED apply pipeline)"]:::engine subgraph CONS["Consensus round"] direction TB ROUND["consensus.round
(Open → Establish → Accepted)"]:::consensus ACC["consensus.accept → consensus.accept.apply"]:::consensus end subgraph LGR["Ledger finalize"] direction TB BUILD["ledger.build
(tx.apply over agreed set)"]:::ledger STORE["ledger.store
(built, NOT yet final)"]:::ledger VAL["ledger.validate
(promoted at quorum)"]:::ledger end subgraph SIDE["Side flows"] direction TB PF["pathfind.update_all
pathfind.request/compute/discover"]:::side ACQ["ledger.acquire
(fetch missing / correct prior)"]:::side end RPC -.->|submit / submit_multisigned| SUB --> TXP RPC -.->|path_find / ripple_path_find| PF PRELAY --> TXP TXP --> OPEN OPEN -->|↻ up to 3 passes| PIPE OPEN -.->|txq.accept re-apply queued tx each close ↻| PIPE PMSG -->|peerProposal / recvValidation| ROUND ROUND --> ACC --> BUILD BUILD -->|↻ each tx × up to 3 passes| PIPE BUILD --> STORE PMSG -. "trusted validations arrive async → checkAccept quorum" .-> VAL ROUND -. "avalanche rounds ↻ (threshold 50→65→70→95%)" .-> ROUND ACC -->|endConsensus ↻ next round until a ledger validates| ROUND ROUND -.->|wrong-ledger: request correct prior| ACQ ACQ -.->|switch-ledger: resume round on correct prior| ROUND ACQ --> STORE VAL -.->|missing ledger| ACQ BUILD -.->|every close re-runs| PF ``` ### Client and peer ingress RPC submit and peer relay **converge** at `tx.process`, the single NetworkOPs entry. gRPC serves ledger queries only — it has no submit path and never runs `doCommand`. ```mermaid flowchart TB classDef span fill:#1d4ed8,stroke:#1e3a8a,color:#fff; classDef plain fill:#334155,stroke:#0f172a,color:#fff; classDef drop fill:#7f1d1d,stroke:#ef4444,color:#fff; HTTP["rpc.http_request
(HTTP entry)"]:::span PROC["rpc.process
(parse + batch)"]:::span CMD["rpc.command.NAME
(one command)"]:::span WSU["rpc.ws_upgrade
(WS handshake)"]:::span WSM["rpc.ws_message
(one frame)"]:::span GRPC["grpc.MethodName
(ledger query)"]:::span GH(["handler_ ctx
(no span)"]):::plain SUBMIT(["doSubmit
(no span)"]):::plain TXP["tx.process
(NetworkOPs::processTransaction)"]:::span RELAYOUT(["Overlay::relay fan-out to N peers
(no span; if applied / terQUEUED,
shouldRelay, not tfInnerBatchTxn)"]):::plain PREDROP(["Diverged / needNetworkLedger
(no span — dropped before tx.receive)"]):::drop RCV["tx.receive
(peer TMTransaction in)"]:::span RCVDROP["tx.receive
tx_status = rejected_inner_batch /
suppressed / dropped_no_sync /
dropped_queue_full"]:::drop CHK(["checkTransaction
(JtTransaction worker, no span)"]):::plain PRELAY_IN(["TMTransaction in (no span)"]):::plain HTTP -->|processRequest| PROC PROC -->|↻ each batch request → doCommand| CMD WSU -. "each inbound frame → onWSMessage" .-> WSM WSM -->|doCommand| CMD GRPC --> GH CMD -.->|submit / submit_multisigned| SUBMIT SUBMIT -->|processTransaction| TXP TXP -.->|relay applied / queued tx| RELAYOUT PRELAY_IN -.->|tracking == Diverged / needNetworkLedger| PREDROP PRELAY_IN -->|else| RCV RCV -.->|inner-batch / dup / age>4min / JtTransaction full| RCVDROP RCV -->|addJob JtTransaction| CHK CHK -->|processTransaction, trusted=peer| TXP RELAYOUT -. "tx.process ⇢ tx.receive (span_id over TMTransaction)" .-> RCV ``` Ingress branches (all evidence in code): - `onHandoff`: WS upgrade vs peer bundle vs status page vs legacy HTTP ([ServerHandler.cpp:227](../src/xrpld/rpc/detail/ServerHandler.cpp#L227)). - `doSubmit`: `tx_blob` present → submit signed blob; absent → server sign-and-submit ([Submit.cpp:49](../src/xrpld/rpc/handlers/transaction/Submit.cpp#L49)). - `tx.process`: local RPC → `doTransactionSync`; peer → `doTransactionAsync` (JtBatch) ([NetworkOPs.cpp:1434](../src/xrpld/app/misc/NetworkOPs.cpp#L1434)). - **Pre-span peer drops** (no `tx.receive` created): `Diverged` ([PeerImp.cpp:1299](../src/xrpld/overlay/detail/PeerImp.cpp#L1299)) / `needNetworkLedger` ([1302](../src/xrpld/overlay/detail/PeerImp.cpp#L1302)), before the span at ~1320. - **Post-span peer drops** (span exists, `tx_status` set, no job enqueued): `tfInnerBatchTxn` ([1348](../src/xrpld/overlay/detail/PeerImp.cpp#L1348)), HashRouter dup/`BAD` ([1361](../src/xrpld/overlay/detail/PeerImp.cpp#L1361)), `dropped_no_sync` when validated-ledger age > 4 min ([1416](../src/xrpld/overlay/detail/PeerImp.cpp#L1416)), `dropped_queue_full` when `JtTransaction` jobs > `maxTransactions` ([1421](../src/xrpld/overlay/detail/PeerImp.cpp#L1421)). - **Relay fan-out**: an accepted/queued `tx.process` relays to N peers via `Overlay::relay`, gated on `applied || (non-FULL local) || terQUEUED`, HashRouter `shouldRelay`, and not `tfInnerBatchTxn`; the span context is injected here ([NetworkOPs.cpp:1797](../src/xrpld/app/misc/NetworkOPs.cpp#L1797)). Inbound consensus messages take a two-stage handler — a fresh-root `peer.*.receive` span created first (kConsumer, always), then a `consensus.*.receive` span (only if not dropped) that carries the sender's context — before enqueuing a `checkPropose` / `checkValidation` worker job. The drop points are **asymmetric**: proposals drop entirely before `consensus.proposal.receive`, while validations can drop both before and after `consensus.validation.receive`. ```mermaid flowchart TB classDef span fill:#0e7490,stroke:#155e75,color:#fff; classDef plain fill:#334155,stroke:#0f172a,color:#fff; classDef drop fill:#7f1d1d,stroke:#ef4444,color:#fff; PPR["peer.proposal.receive
(freshRoot, always)"]:::span PPRDROP(["no consensus.proposal.receive
(untrusted+relay-off / dup /
untrusted+Diverged / untrusted+loaded)"]):::drop CPR["consensus.proposal.receive
(carries sender ctx)"]:::span CP(["checkPropose worker
(no span)"]):::plain SIGP(["sig-fail: charge, drop
(no relay, no span)"]):::drop PTP(["processTrustedProposal → peerProposal
(no span)"]):::plain PVR["peer.validation.receive
(freshRoot, always)"]:::span pvrDrop1(["no consensus.validation.receive
(!isCurrent / relay-off / dup)"]):::drop CVR["consensus.validation.receive
(carries sender ctx)"]:::span cvrDrop2(["dropped after span
(untrusted+Diverged /
untrusted+loaded → no job/relay)"]):::drop CV(["checkValidation worker
(no span)"]):::plain SIGV(["!isValid: charge, drop
(no span)"]):::drop RV(["recvValidation → handleNewValidation
(no span)"]):::plain RELAY(["Overlay::relay fan-out to N peers
(no span)"]):::plain PPR -.->|4 drop conditions| PPRDROP PPR -->|else| CPR CPR -->|addJob JtProposalT/Ut| CP CP -.->|!checkSign| SIGP CP -->|isTrusted| PTP CP -.->|if relay| RELAY PVR -.->|3 drop conditions| pvrDrop1 PVR -->|else| CVR CVR -.->|untrusted+Diverged / loaded| cvrDrop2 CVR -->|addJob JtValidationT/Ut| CV CV -.->|!isValid| SIGV CV -->|recvValidation| RV CV -.->|if relay / cluster| RELAY ``` Consensus-message drop evidence: - Both `peer.proposal.receive` and `peer.validation.receive` are `freshRoot` spans created at the top of `onMessage` ([PeerImp.cpp:1766](../src/xrpld/overlay/detail/PeerImp.cpp#L1766), [2389](../src/xrpld/overlay/detail/PeerImp.cpp#L2389)) — so they exist even for dropped messages. - **Proposal drops (all before `consensus.proposal.receive` at [1868](../src/xrpld/overlay/detail/PeerImp.cpp#L1868))**: untrusted+relay-off ([1807](../src/xrpld/overlay/detail/PeerImp.cpp#L1807)), duplicate ([1832](../src/xrpld/overlay/detail/PeerImp.cpp#L1832)), untrusted+Diverged ([1840](../src/xrpld/overlay/detail/PeerImp.cpp#L1840)), untrusted+loaded ([1846](../src/xrpld/overlay/detail/PeerImp.cpp#L1846)). - **Validation drops (asymmetric around `consensus.validation.receive` at [2476](../src/xrpld/overlay/detail/PeerImp.cpp#L2476))**: before — `!isCurrent` ([2426](../src/xrpld/overlay/detail/PeerImp.cpp#L2426)), relay-off ([2445](../src/xrpld/overlay/detail/PeerImp.cpp#L2445)), duplicate ([2468](../src/xrpld/overlay/detail/PeerImp.cpp#L2468)); after — untrusted+Diverged ([2489](../src/xrpld/overlay/detail/PeerImp.cpp#L2489)), untrusted+loaded ([2506](../src/xrpld/overlay/detail/PeerImp.cpp#L2506)). - **Worker sig-fail drops** (charged `kFeeInvalidSignature`, suppress processing and relay): `checkPropose !checkSign` ([PeerImp.cpp:3105](../src/xrpld/overlay/detail/PeerImp.cpp#L3105)), `checkValidation !isValid` ([3149](../src/xrpld/overlay/detail/PeerImp.cpp#L3149)). ### Shared transaction apply pipeline The apply pipeline is the **single protocol tx-processing chain**, expressed in code as one composed call ([apply.cpp:118](../src/libxrpl/tx/apply.cpp#L118)): `doApply(preclaim(preflight(), …), …)`. C++ evaluates inner-to-outer, so `preflight` runs first, feeds `preclaim`, which feeds `doApply`. Each stage inspects the prior stage's `TER` and no-ops if it already failed. **Four invokers** point into this one pipeline; the diagram draws it once. ```mermaid flowchart TB classDef span fill:#047857,stroke:#064e3b,color:#fff; classDef plain fill:#334155,stroke:#0f172a,color:#fff; classDef inv fill:#1d4ed8,stroke:#1e3a8a,color:#fff; classDef drop fill:#7f1d1d,stroke:#ef4444,color:#fff; I1(["open-ledger applyOne (no span)
↻ up to 3 passes"]):::inv I2["txq.apply_direct
(fee ≥ required)"]:::span I3["txq.batch_clear / txq.accept_tx
↻ per queued tx"]:::span I4["tx.apply
(consensus set, ↻ each tx × 3 passes)"]:::span REPF(["txq path: rules/flags changed?
re-run preflight (no span)"]):::plain FREE(["xrpl::apply() (no span)"]):::plain PF["tx.preflight
(stateless checks)"]:::span PC["tx.preclaim
(ledger-aware checks)"]:::span TR["tx.transactor
(mutate stage)"]:::span CLS(["classify final TER (no span)"]):::plain OK(["Success / erase (no span)"]):::plain FAIL(["tef / tem / tel → hard fail, erase"]):::drop RETRY(["retriable ter → keep in set (no span)"]):::plain I1 --> FREE I2 --> FREE I3 --> REPF --> FREE I4 --> FREE FREE --> PF PF -->|preflight tesSUCCESS| PC PF -. "else → classify (no preclaim/transactor)" .-> CLS PC -->|likelyToClaimFee| TR PC -. "else → classify (no transactor)" .-> CLS TR --> CLS CLS --> OK CLS --> FAIL CLS --> RETRY RETRY -. "next pass while pass<3 and changes>0" .-> FREE RETRY -. "last pass → drop from set" .-> FAIL ``` Pipeline gates and retry (evidence): - `preclaim` short-circuits if preflight `!tesSUCCESS` ([applySteps.cpp:498](../src/libxrpl/tx/applySteps.cpp#L498)); `doApply` short-circuits if `!likelyToClaimFee` ([applySteps.cpp:532](../src/libxrpl/tx/applySteps.cpp#L532)); the transactor mutates only when preclaim is `tesSUCCESS` ([Transactor.cpp:1647](../src/libxrpl/tx/Transactor.cpp#L1647)). - **Final-TER classification**: `applied` → Success; `tef | tem | tel` → hard Fail; else → Retry ([apply.cpp:226](../src/libxrpl/tx/apply.cpp#L226)). - **Multi-pass retry**: both open-ledger `applyOne` and consensus `tx.apply` loop `pass < LEDGER_TOTAL_PASSES` (= 3); a `Retry` tx is kept for the next pass, and the final pass converts lingering retriable txs into drops ([OpenLedger.h:237](../src/xrpld/app/ledger/OpenLedger.h#L237), [BuildLedger.cpp:129](../src/xrpld/app/ledger/detail/BuildLedger.cpp#L129); `LEDGER_TOTAL_PASSES` [OpenLedger.h:29](../src/xrpld/app/ledger/OpenLedger.h#L29)). - **TxQ re-preflight**: the queue path re-runs `preflight` when the ledger's rules/flags changed since enqueue ([TxQ.cpp:315](../src/xrpld/app/misc/detail/TxQ.cpp#L315)). - **TxQ cross-ledger retry**: a queued tx that fails with a retriable result keeps its slot with `--retriesRemaining` (`kRetriesAllowed` = 10) and is re-applied at a **later** ledger close; on `retriesRemaining ≤ 0` or `tef|tem` it is dropped with an account `retryPenalty` ([TxQ.cpp:1528](../src/xrpld/app/misc/detail/TxQ.cpp#L1528)). - `TxQ::apply` outcome fork: preflight-reject / `applied_direct` / `batch_clear` / `queued` (`terQUEUED`) / reject ([TxQ.cpp:762](../src/xrpld/app/misc/detail/TxQ.cpp#L762)). > **`tx.apply` is set-level, consensus-only.** It wraps the retry-pass loop over > the agreed set during `buildLedger` and exists on **no other** invoker. It is > not a per-transaction span, and TxQ / open-ledger apply create no `tx.apply`. ### Consensus round `beginConsensus → startRound` starts the round (`consensus.round`, `Open` phase). The **heartbeat timer** drives `Consensus::timerEntry` each pass; the round stays in `Establish` across many heartbeats until the outcome is decided. > **A single ledger can take many rounds to settle.** Two nested multi-round > mechanisms (see [docs/consensus.md](consensus.md)): > > 1. **Avalanche rounds inside one Establish phase** — each `timerEntry` runs > `phaseEstablish` again (`establishCounter_++`) and raises the inclusion > threshold **50% → 65% → 70% → 95%** as the round ages > ([ConsensusParms.h:145](../src/xrpld/consensus/ConsensusParms.h#L145)). > `checkConsensus` returning `No` keeps the node in `Establish` and loops; a > round cannot even `Expire` before a minimum of > `avalancheCutoffs.size() × avMinRounds = 4 × 2 = 8` passes > ([Consensus.h:1938](../src/xrpld/consensus/Consensus.h#L1938)). > 2. **Retry across consensus rounds** — a round can end `MovedOn` / `Expired`, > meaning the network settled a _different_ ledger. The node still builds a > ledger, but the **next** round's `checkLedger` detects the wrong prior, > switches to `WrongLedger` / `SwitchedLedger` mode, acquires the correct > ledger, and re-deliberates. A ledger is only truly settled once trusted > **validations reach quorum** (`ledger.validate`); the alternate is abandoned. ```mermaid flowchart TB classDef span fill:#b45309,stroke:#7c2d12,color:#fff; classDef plain fill:#334155,stroke:#0f172a,color:#fff; BEGIN(["beginConsensus → startRound
(Proposing OR Observing; no span)"]):::plain ROUND["consensus.round
(one attempt at next ledger)"]:::span OPENS["consensus.phase.open
(collect txs; buffer peer
proposals / gotTxSet)"]:::span HB(["heartbeat → timerEntry
(every LEDGER_MIN_CLOSE; no span)"]):::plain CKL(["checkLedger
(correct prior ledger? no span)"]):::plain WRONG(["handleWrongLedger → leaveConsensus (no span):
if Proposing send BOW-OUT,
mode → Observing; acquire ledger"]):::plain MODE["consensus.mode_change
(mode transition)"]:::span POPEN(["phaseOpen: shouldCloseLedger? (no span)"]):::plain CLOSE["consensus.ledger_close
(close open ledger, seed disputes)"]:::span pSend["consensus.proposal.send
(broadcast our position)"]:::span PEST["consensus.establish
(phaseEstablish; avalanche round ↻
threshold 50→65→70→95%)"]:::span UPOS["consensus.update_positions
(add/drop disputed txs; child of establish)"]:::span acqTx(["acquireTxSet → gotTxSet
(async peer tx set; no span)"]):::plain PAUSE(["shouldPause?
(wait on laggards; no span)"]):::plain CHECK["consensus.check
(checkConsensus; child of establish)"]:::span CTC(["haveCloseTimeConsensus?
(else agree-to-disagree +1s; no span)"]):::plain ACCEPT["consensus.accept
(round complete)"]:::span BEGIN --> ROUND --> OPENS HB -->|under mutex| CKL CKL -.->|wrong prior| WRONG WRONG --> MODE WRONG -. "recovered → re-enter Open (playbackProposals)" .-> OPENS WRONG -. "still missing → keep deliberating, defer to peers" .-> HB CKL -->|prior OK| POPEN HB -.->|phase==Open| POPEN HB -.->|phase==Establish| PEST POPEN -.->|shouldClose| CLOSE CLOSE -.->|mode==Proposing| pSend PEST --> UPOS UPOS -.->|position changed && Proposing| pSend UPOS -.->|disagreeing peer position| acqTx acqTx -. "gotTxSet ↻ → new disputes" .-> UPOS PEST --> PAUSE PAUSE -. "pausing → wait (loop)" .-> HB PAUSE -->|ready| CHECK CHECK -. "No / Expired < 8 passes → next avalanche round" .-> HB CHECK --> CTC CTC -. "no CT consensus → loop" .-> HB CTC -.->|Yes / MovedOn / Expired ≥ 8| ACCEPT ROUND -.->|mode set at start| MODE ACCEPT -. "endConsensus → next round ↻ (until a ledger validates)" .-> BEGIN ``` Consensus loops and branches (evidence): - **`consensus.establish` is the parent of `update_positions` and `check`**: `phaseEstablish` creates the establish span (`startEstablishTracing`), and both child spans parent to its captured context ([Consensus.h:2100](../src/xrpld/consensus/Consensus.h#L2100), [1629](../src/xrpld/consensus/Consensus.h#L1629), [1838](../src/xrpld/consensus/Consensus.h#L1838)). - **Avalanche-convergence loop (rounds within one ledger)**: repeated `heartbeat → timerEntry → phaseEstablish` bumps `establishCounter_` and raises the inclusion threshold each pass; `checkConsensus` = `No` stays in `Establish` ([NetworkOPs.cpp:1214](../src/xrpld/app/misc/NetworkOPs.cpp#L1214); [Consensus.h:1468](../src/xrpld/consensus/Consensus.h#L1468); thresholds [ConsensusParms.h:145](../src/xrpld/consensus/ConsensusParms.h#L145)). - **Retry-across-rounds loop (many rounds per settled ledger)**: `MovedOn` / `Expired` accepts a non-preferred ledger; the next round's `checkLedger` finds the wrong prior and recovers before re-deliberating ([Consensus.h:1194](../src/xrpld/consensus/Consensus.h#L1194)); round-to-round via `endConsensus → beginConsensus` ([NetworkOPs.cpp:2315](../src/xrpld/app/misc/NetworkOPs.cpp#L2315)). - **Two extra establish loop-backs before accept**: `shouldPause` (laggard backpressure) and `!haveCloseTimeConsensus_` (TX consensus but not close-time) each `return` and re-loop, distinct from `checkConsensus == No` ([Consensus.h:1497](../src/xrpld/consensus/Consensus.h#L1497), [1500](../src/xrpld/consensus/Consensus.h#L1500)); close time can "agree to disagree" at prior close + 1s ([docs/consensus.md:163](consensus.md)). - **acquireTxSet / gotTxSet loop**: a disagreeing peer position triggers an async `acquireTxSet`; the later `gotTxSet` regenerates disputes and can extend the establish phase ([Consensus.h:932](../src/xrpld/consensus/Consensus.h#L932)). - **Bow-out / mode change**: `handleWrongLedger → leaveConsensus` sends a bow-out proposal and demotes Proposing → Observing for the rest of the round ([Consensus.h:1977](../src/xrpld/consensus/Consensus.h#L1977)); `startRound` begins in Proposing **or** Observing ([docs/consensus.md:176](consensus.md)). - **Buffered Open-phase inputs**: `peerProposal` / `gotTxSet` arriving during Open are stored, then seeded as disputes at `closeLedger` (`createDisputes`); `playbackProposals` replays them at `startRound` / `handleWrongLedger` ([docs/consensus.md:244](consensus.md); [Consensus.h:817](../src/xrpld/consensus/Consensus.h#L817)). - **Outcome fork** after `checkConsensus`: `No` (loop) / `Yes` (onAccept) / `MovedOn` / `Expired` ([Consensus.h:1516](../src/xrpld/consensus/Consensus.h#L1516)). - **Expired guard**: a round cannot leave on `Expired` before `avalancheCutoffs.size() × avMinRounds` (= 8) passes — below that, `Expired` loops like `No` ([Consensus.h:1938](../src/xrpld/consensus/Consensus.h#L1938)). - The **deterministic-vs-random trace-strategy** branch at round start ([RCLConsensus.cpp:1291](../src/xrpld/app/consensus/RCLConsensus.cpp#L1291)) sets only the trace ID — it has **zero protocol effect**. ### Accept, build, and finalize the ledger `onAccept` enqueues a `JtAccept` job; `doAccept` runs on that worker (`consensus.accept.apply`). It builds the ledger (running the apply pipeline over the agreed set), cleans the queue, stores the ledger, optionally broadcasts a validation, and rebuilds the open ledger. A built ledger is **not final** — it is promoted to `ledger.validate` only when trusted validations reach quorum, an **async, validation-driven** path re-entered per incoming trusted validation; a built ledger that loses is **abandoned**. ```mermaid flowchart TB classDef span fill:#6d28d9,stroke:#4c1d95,color:#fff; classDef plain fill:#334155,stroke:#0f172a,color:#fff; classDef drop fill:#7f1d1d,stroke:#ef4444,color:#fff; onAcc["consensus.accept
(round complete)"]:::span APPLY["consensus.accept.apply
(JtAccept worker)"]:::span BLCL(["buildLCL: replay data? (no span)"]):::plain BUILD["ledger.build
(normal: apply agreed set)"]:::span RPLY["ledger.build
(replay: TapNone, no tx.apply child)"]:::span TXAP["tx.apply
(↻ each tx × up to 3 passes)"]:::span CLEAN["txq.cleanup
(expire queue entries)"]:::span STORE["ledger.store
(built, NOT yet final)"]:::span vSend["consensus.validation.send
(broadcast our validation)"]:::span CACC(["consensusBuilt → checkAccept
(quorum gate; no span)"]):::plain NEWVAL(["inbound trusted validation
→ handleNewValidation → checkAccept
(async, per validation; no span)"]):::plain VAL["ledger.validate
(promote highest-seq ledger ≥ quorum)"]:::span LOSE(["built ledger loses:
never promoted → abandoned"]):::drop OACC(["OpenLedger::accept
(rebuild open ledger; no span)"]):::plain tqAcc["txq.accept
(↻ drain queued txs)"]:::span swlStd(["switchLCL standalone:
setFullLedger + tryAdvance (no span)"]):::plain swlNet(["switchLCL networked:
checkAccept (no span)"]):::plain END(["endConsensus → next round ↻ (no span)"]):::plain onAcc -.->|addJob JtAccept| APPLY APPLY --> BLCL BLCL -->|normal path| BUILD --> TXAP BLCL -. "replay path" .-> RPLY APPLY --> CLEAN APPLY --> STORE APPLY -. "validating && isCompatible && !fail && canValidateSeq" .-> vSend APPLY --> CACC NEWVAL --> CACC CACC -.->|highest-seq trusted ledger ≥ quorum| VAL CACC -.->|tvc < quorum → no promotion| LOSE APPLY --> OACC OACC -->|TxQ::accept callback| tqAcc OACC --> swlStd OACC --> swlNet swlStd -. "marks full-validated (no ledger.validate span)" .-> END swlNet --> CACC onAcc --> END ``` - Order inside `doAccept`: `buildLCL` (build → `tx.apply`, then `txq.cleanup`, then `ledger.store`) → optional `validate` → `consensusBuilt`/`checkAccept` → `OpenLedger::accept` (rebuilds the open ledger; `txq.accept` runs in its callback) → `switchLCL` promotes the built ledger to the new LCL ([RCLConsensus.cpp:812](../src/xrpld/app/consensus/RCLConsensus.cpp#L812) then [833](../src/xrpld/app/consensus/RCLConsensus.cpp#L833)). - **buildLCL replay branch**: if `releaseReplay()` has data, `buildLedger` replays the stored set with `TapNone` — it **still emits `ledger.build`** (via `buildLedgerImpl`) but applies txns directly with **no `tx.apply` child** and no 3-pass loop; else the normal consensus-set path runs `tx.apply` over 3 passes ([RCLConsensus.cpp:929](../src/xrpld/app/consensus/RCLConsensus.cpp#L929); [BuildLedger.cpp:252](../src/xrpld/app/ledger/detail/BuildLedger.cpp#L252)). - `processClosedLedger` (`txq.cleanup`) runs **after** build, **before** store ([RCLConsensus.cpp:950](../src/xrpld/app/consensus/RCLConsensus.cpp#L950) vs [953](../src/xrpld/app/consensus/RCLConsensus.cpp#L953)). - **`ledger.validate` is async + lossy**: `checkAccept` is re-entered per incoming trusted validation (`handleNewValidation → checkAccept`, [RCLValidations.cpp:193](../src/xrpld/app/consensus/RCLValidations.cpp#L193)); it promotes the **highest-seq** trusted ledger whose `valCount > neededValidations` ([LedgerMaster.cpp:1180](../src/xrpld/app/ledger/detail/LedgerMaster.cpp#L1180)), which may be a **different** ledger than the one this node built. Below quorum (`tvc < minVal`) it returns early with no promotion — a built ledger that loses is abandoned ([LedgerMaster.cpp:980](../src/xrpld/app/ledger/detail/LedgerMaster.cpp#L980); [docs/consensus.md:50](consensus.md)). The `ledger.validate` span is emitted only inside `checkAccept` ([LedgerMaster.cpp:987](../src/xrpld/app/ledger/detail/LedgerMaster.cpp#L987)). - **validation-send guard**: broadcast only if `validating_ && isCompatible && !consensusFail && canValidateSeq(seq)` — silently suppressed for incompatible ledgers or an already-validated seq ([RCLConsensus.cpp:730](../src/xrpld/app/consensus/RCLConsensus.cpp#L730)). - **switchLCL**: standalone → `setFullLedger` + `tryAdvance` — marks the ledger full-validated **without** emitting `ledger.validate` (that span lives only in `checkAccept`); networked → `checkAccept` (shared async quorum gate) ([LedgerMaster.cpp:442](../src/xrpld/app/ledger/detail/LedgerMaster.cpp#L442)). ### Side flows: pathfinding and ledger acquire **Pathfinding** — an RPC one-shot (`path_find` / `ripple_path_find`) plus an async recompute that fires on **every ledger close** for all active subscriptions, and also garbage-collects dead subscriptions: ```mermaid flowchart TB classDef span fill:#0e7490,stroke:#155e75,color:#fff; classDef plain fill:#334155,stroke:#0f172a,color:#fff; classDef drop fill:#7f1d1d,stroke:#ef4444,color:#fff; REQ["pathfind.request
(path_find / ripple_path_find)"]:::span CREATE(["subscribe: makePathRequest →
persistent subscription (no span)"]):::plain COMP["pathfind.compute
(doUpdate, one pass)"]:::span DISC["pathfind.discover
(findPaths)"]:::span PFDR(["Pathfinder + RippleCalc
↻ per source asset (no span)"]):::plain UP(["updatePaths (JtUpdatePf, every close; no span)"]):::plain UALL["pathfind.update_all
(recompute all active)"]:::span DEAD(["dead subscriber → doAborting +
remove_if erase"]):::drop REQ -.->|subcommand create| CREATE REQ -->|doUpdate| COMP -->|findPaths| DISC --> PFDR UP -->|once per close| UALL UALL -->|↻ each active request| COMP UALL -. "new request arrived → extra pass ↻" .-> UALL UALL -.->|dead / aborted| DEAD ``` **Ledger acquire** — a **separate trace root** (not part of the close flow) that fetches a missing or correct-prior ledger from peers, retries per peer/timer, and finishes with a reason-dependent store; `checkAccept` + `tryAdvance` run on **any** completed acquire: ```mermaid flowchart TB classDef span fill:#0e7490,stroke:#155e75,color:#fff; classDef plain fill:#334155,stroke:#0f172a,color:#fff; classDef drop fill:#7f1d1d,stroke:#ef4444,color:#fff; HIST(["tryAdvance → doAdvance → fetchForHistory
(Reason::HISTORY; no span)"]):::plain NEED(["checkAccept / handleNewValidation /
consensus wrong-ledger (no span)"]):::plain INB(["InboundLedgers::acquire (no span)"]):::plain ACQ["ledger.acquire
(InboundLedger::init)"]:::span TRIG(["trigger / addPeers / onTimer
↻ per peer / chunk (no span)"]):::plain FAILED(["timeouts > 6 → failed_ →
logFailure (NO store, NO checkAccept)"]):::drop DONE(["done() — complete && !failed (no span)"]):::plain ONF(["onLedgerFetched (no span)
(HISTORY: no store)"]):::plain STORE["ledger.store
(GENERIC / CONSENSUS)"]:::span CACC(["checkAccept + tryAdvance (no span)
(↻ may publish/advance many ledgers)"]):::plain NEED -.->|GENERIC / CONSENSUS| INB --> ACQ HIST -.->|HISTORY| INB ACQ -.->|not complete| TRIG TRIG -. "retry ↻" .-> TRIG TRIG -.->|timeout cap| FAILED TRIG -.->|complete| DONE DONE -.->|reason == HISTORY| ONF DONE -.->|GENERIC / CONSENSUS| STORE DONE --> CACC CACC -. "advanceWork ↻ → further HISTORY acquire" .-> HIST ``` Side-flow evidence: - **Pathfind subscription lifecycle**: `path_find` create inserts a persistent subscription (`makePathRequest`); `update_all` re-runs each active request every close, removes dead subscribers (`doAborting` + `remove_if` erase), and takes an extra pass when a new request arrived mid-run ([PathRequestManager.cpp:103](../src/xrpld/rpc/detail/PathRequestManager.cpp#L103), [169](../src/xrpld/rpc/detail/PathRequestManager.cpp#L169), [181](../src/xrpld/rpc/detail/PathRequestManager.cpp#L181)). - **Acquire outcome fork**: `timeouts_ > kLedgerTimeoutRetriesMax` (= 6) sets `failed_` → terminal `logFailure`, no store/checkAccept ([InboundLedger.cpp:387](../src/xrpld/app/ledger/detail/InboundLedger.cpp#L387)). - **done() reason branch (store side only)**: `HISTORY` → `onLedgerFetched`, **no** `storeLedger`; else → `storeLedger`. But `checkAccept` + `tryAdvance` run for **any** `complete_ && !failed_` acquire regardless of reason ([InboundLedger.cpp:495](../src/xrpld/app/ledger/detail/InboundLedger.cpp#L495) store switch; [507](../src/xrpld/app/ledger/detail/InboundLedger.cpp#L507) reason-independent checkAccept/tryAdvance). - **tryAdvance multi-ledger loop**: `doAdvance` runs `do { … } while (advanceWork_)`, publishing a range of ledgers and recursively triggering further HISTORY acquire ([LedgerMaster.cpp:1905](../src/xrpld/app/ledger/detail/LedgerMaster.cpp#L1905)). ### Where telemetry parenting differs from protocol flow The graph above is protocol control flow. The OpenTelemetry span **parent links** are built differently and, in several places, do **not** represent a real call edge. Read a trace with these in mind: | Telemetry does this | Real protocol flow | | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tx.process` is a `hashSpan` root from `txID` — an independent trace root ([TxTracing.h:63](../src/xrpld/telemetry/TxTracing.h#L63)). | The real edge is the synchronous `doSubmit → processTransaction` call; it is **not** a child of `rpc.command.submit`. | | `tx.preflight` / `tx.preclaim` / `tx.transactor` share one `txID`-derived trace ID. | That shared ID is a correlation trick, not a call edge. The real order is the composed `apply()` at [apply.cpp:118](../src/libxrpl/tx/apply.cpp#L118). They are **not** children of `tx.process` or `tx.apply`. | | `consensus.round` uses a deterministic trace ID from the previous ledger hash. | This makes **all validators share one trace ID** (a cross-node shared root), not a per-node parent. The real round-to-round edge is `endConsensus → beginConsensus`. | | `consensus.accept` (main thread) and `consensus.accept.apply` (JtAccept worker) are wired via a captured context. | The real edge is the queued `JtAccept` job, a thread hand-off ([RCLConsensus.cpp:483](../src/xrpld/app/consensus/RCLConsensus.cpp#L483)). | | `pathfind.update_all` parents nothing from the original `pathfind.request`. | The causal link is the ledger-close job on `JtUpdatePf`, not span nesting. | | `ledger.acquire` and its downstream `ledger.store` / `ledger.validate`. | Reached via the `AcqDone` job, not parent inheritance; `ledger.acquire` is its own root. | | `peer.*.receive` (fresh `kConsumer` root) and `consensus.*.receive` on the same message. | Two **sequential stages of one synchronous handler**, not parent/child; on a duplicate/untrusted drop the `consensus.*.receive` is never created. | | Receive spans adopt the sender's `trace_id` + `span_id` as a genuine cross-node parent. | Deliberate: the receive span becomes a child of a **different node's** span (a cross-node context marker, not an in-process edge). `tx.receive` is asymmetric — it borrows only the sender's `span_id` and re-derives its own `trace_id` from `txID`. | > **Known telemetry artifacts** (from live audits, memory `otel-span-hierarchy-audit`): > an RPC entry span's scope can leak across a reused coroutine worker, and the > `hashSpan` roots (`tx.*`) — along with plain roots like `ledger.acquire` — can > surface in Tempo as dangling "root span not yet received". These are > exporter/parenting artifacts, not real control-flow parents. --- ## Insights and Sample Queries This section shows what questions you can answer using the span attributes, with example Tempo TraceQL queries. **TraceQL syntax note:** span attributes must be referenced with the `span.` prefix inside `{}`. Conditions are combined with `&&`. The `|` pipeline operator is not supported on this Tempo version. ``` # General pattern {name="" && span. = && span. != } # Duration filter (no prefix needed) {name="" && duration > 500ms} # Regex match {name="" && span. =~ ".*"} # Multiple span names {name = "" || name = ""} # Name regex {name =~ ".*" && span. = } # Structural: find parent spans that have a matching child/event {name=""} >> {event:name=""} ``` ### Transaction Workflow Analysis ``` # Find all AMM transactions (AMMDeposit, AMMWithdraw, AMMVote) {name="tx.process" && span.tx_type =~ "AMM.*"} # Find a specific AMM operation {name="tx.process" && span.tx_type = "AMMDeposit"} {name="tx.process" && span.tx_type = "AMMWithdraw"} {name="tx.process" && span.tx_type = "AMMVote"} # Find Payment transactions that failed {name="tx.process" && span.tx_type = "Payment" && span.ter_result != "tesSUCCESS"} # Find Payment failures due to path issues {name="tx.process" && span.tx_type = "Payment" && span.ter_result =~ "tecPATH.*"} # Compare latency of different transaction types {name="tx.process" && span.tx_type = "OfferCreate"} {name="tx.process" && span.tx_type = "Payment"} # Find high-fee transactions (fee > 1 XRP = 1000000 drops) {name="tx.process" && span.fee > 1000000} # Find transactions that were not applied {name="tx.process" && span.applied = false} # Find NFTokenMint across tx and txq spans {name =~ "tx.*|txq.*" && span.tx_type = "NFTokenMint"} # Find all NFT-related activity {name =~ "tx.*|txq.*" && span.tx_type =~ "NFToken.*"} # Find TrustSet transactions (IOU trust lines) {name="tx.process" && span.tx_type = "TrustSet"} # Find oracle price updates {name="tx.process" && span.tx_type = "OracleSet"} ``` ### DEX (OfferCreate / OfferCancel) ``` # All DEX offer creates {name="tx.process" && span.tx_type = "OfferCreate"} # Offers killed (ImmediateOrCancel/FillOrKill with no fill) {name="tx.process" && span.tx_type = "OfferCreate" && span.ter_result = "tecKILLED"} # Offers that failed due to insufficient funds {name="tx.process" && span.tx_type = "OfferCreate" && span.ter_result = "tecUNFUNDED_OFFER"} # Offers failed due to insufficient reserve to place the offer {name="tx.process" && span.tx_type = "OfferCreate" && span.ter_result = "tecINSUF_RESERVE_OFFER"} # Offer cancellations {name="tx.process" && span.tx_type = "OfferCancel"} # OfferCreate transactions received from peers (cross-node relay) {name="tx.receive" && span.tx_type = "OfferCreate"} ``` ### Apply Pipeline by Stage ``` # All three stages of one transaction (preflight -> preclaim -> apply) {name=~"tx.preflight|tx.preclaim|tx.transactor"} # Transactions that failed at the preclaim stage {name="tx.preclaim"} | ter_result != "tesSUCCESS" # Transactions that hard-failed preflight (never reached preclaim/apply) {name="tx.preflight"} | ter_result != "tesSUCCESS" ``` PromQL on the span-derived metrics (dashboard: _Transaction Overview_): ``` # Per-stage throughput — the funnel preflight >= preclaim >= apply sum by (stage) (rate(span_calls_total{span_name=~"tx.preflight|tx.preclaim|tx.transactor"}[5m])) # Per-stage p95 latency histogram_quantile(0.95, sum by (le, stage) (rate(span_duration_milliseconds_bucket{span_name=~"tx.preflight|tx.preclaim|tx.transactor"}[5m]))) # Per-stage failure rate (ter_result != tesSUCCESS; a failing ter completes the # span normally, so filter on the attribute, not status_code which only flags exceptions) sum by (stage) (rate(span_calls_total{span_name=~"tx.preflight|tx.preclaim|tx.transactor", ter_result!~"tesSUCCESS|"}[5m])) ``` > **Alerting**: a rising `tx.preflight` / `tx.preclaim` failure rate points to > malformed or stale-sequence submissions (often spam or a misbehaving client); > a rising `tx.transactor` failure rate points to apply-time problems. Alert per > stage rather than on a single aggregate so the failing stage is obvious. > **Sampling caveat**: these stage metrics are span-derived and inherit the > **tracer head-sampling** ratio (`sampling_ratio`). At `sampling_ratio < 1.0` > they undercount proportionally — treat them as relative trends, not absolute > transaction counts. Native StatsD metrics are unsampled. ### Transaction Queue Health ``` # Find transactions rejected from the queue {name="txq.accept_tx" && span.txq_status = "failed"} # Find transactions being retried {name="txq.accept_tx" && span.txq_status = "retried"} # Find transactions that exhausted retries {name="txq.accept_tx" && span.txq_status = "retried" && span.retries_remaining = 0} # Which transaction types get queued most often? {name="txq.enqueue" && span.tx_type = "Payment"} {name="txq.enqueue" && span.tx_type = "OfferCreate"} {name="txq.enqueue" && span.tx_type =~ "NFToken.*"} # Find ledger closes that applied queued transactions {name="txq.accept" && span.ledger_changed = true} ``` ### RPC Debugging ``` # Find batch RPC requests {name="rpc.process" && span.is_batch = true} # Find large RPC payloads (>100KB) {name="rpc.http_request" && span.request_payload_size > 100000} # Find resource-heavy RPC commands (by load_type) {name =~ "rpc.command.*" && span.load_type = "exceptioned RPC"} # Find a specific WebSocket command {name="rpc.ws_message" && span.command = "subscribe"} # Find server_info calls {name="rpc.command.server_info"} # Find slow pathfinding with many source assets {name="pathfind.discover" && span.pathfind_num_source_assets > 10} ``` ### PathFinding Performance ``` # Find pathfinding for specific currencies {name="pathfind.compute" && span.pathfind_dest_currency = "USD"} # Find expensive pathfinding (many source assets to explore) {name="pathfind.discover" && span.pathfind_num_source_assets > 20} # Find slow pathfinding requests {name="pathfind.compute" && duration > 1000ms} ``` ### Consensus Health ``` # Find rounds where consensus timed out (expired) {name="consensus.accept" && span.consensus_state = "expired"} # Find rounds where we moved on without full agreement {name="consensus.accept" && span.consensus_state = "moved_on"} # Find rounds with many disputes {name="consensus.accept" && span.disputes_count > 5} # Find slow consensus rounds (>5s) {name="consensus.accept" && span.round_time_ms > 5000} # Find bow-out proposals (node resigned from round) {name="consensus.proposal.send" && span.is_bow_out = true} # Correlate validation with its ledger {name="consensus.validation.send" && span.ledger_hash = ""} # Find rounds where validators disagreed on close time {name="consensus.accept.apply" && span.close_time_correct = false} # Find both validation send and receive (compare sender vs receiver latency) {name = "consensus.validation.send" || name = "consensus.validation.receive"} ``` ### Cross-Subsystem Correlation ``` # Follow a transaction from receive through queue to ledger {name =~ "tx.*|txq.*" && span.tx_type = "Payment" && duration > 500ms} # Find all NFT-related activity across tx and txq spans {name =~ "tx.*|txq.*" && span.tx_type =~ "NFToken.*"} # Find all AMM activity across tx and txq spans {name =~ "tx.*|txq.*" && span.tx_type =~ "AMM.*"} # Find cross-node transaction receives (no errors) {name="tx.receive" && status != error} ``` ### Where to Look (Quick Reference) | Question | Span | Key Attributes | | ----------------------------------- | --------------------------- | ---------------------------------------- | | "Which tx type is slowest?" | `tx.process` | `span.tx_type` + duration | | "Why was my tx rejected?" | `tx.process` | `span.ter_result`, `span.applied` | | "What AMM operations happened?" | `tx.process` | `span.tx_type =~ "AMM.*"` | | "What DEX offers failed?" | `tx.process` | `span.tx_type`, `span.ter_result` | | "What NFT activity occurred?" | `tx.process`, `txq.enqueue` | `span.tx_type =~ "NFToken.*"` | | "Is the TxQ backing up?" | `txq.accept` | `span.queue_size`, `span.ledger_changed` | | "Why was my tx dropped from queue?" | `txq.accept_tx` | `span.txq_status`, `span.ter_code` | | "Are batch requests a problem?" | `rpc.process` | `span.is_batch`, `span.batch_size` | | "Which RPC is expensive?" | `rpc.command.*` | `span.load_type`, duration | | "Did consensus reach threshold?" | `consensus.check` | `span.consensus_result` | | "Was consensus outcome normal?" | `consensus.accept` | `span.consensus_state` | | "Did a validator bow out?" | `consensus.proposal.send` | `span.is_bow_out` | | "Which ledger was validated?" | `consensus.validation.send` | `span.ledger_hash` | | "Did close time agreement fail?" | `consensus.accept.apply` | `span.close_time_correct` | | "What tx work fed a given ledger?" | `tx.*` / `txq.*` | `span.current_ledger_seq` | ### Correlating a transaction to the ledger it was worked on The `tx.process`, `tx.receive`, `txq.enqueue`, `tx.preclaim`, and `tx.transactor` spans carry `current_ledger_seq` — the open/in-flight ledger they acted on (not an established ledger). Because these spans are keyed on the transaction id (their own trace) while the ledger/consensus spans are keyed on the ledger, use the attribute to bridge the two id-spaces: ``` # All transaction-side work recorded against ledger N {span.current_ledger_seq = N} # Join to the ledger build/consensus trace for the same ledger {name="ledger.build" && span.ledger_seq = N} ``` `txq.enqueue` and the view-bearing apply stages also carry `current_ledger_hash` (the current ledger's parent hash), which equals the `consensus.round` deterministic trace-id seed on the consensus-build path. `tx.preflight` is stateless and omits both attributes. --- ## Cross-Node Trace Propagation xrpld propagates trace context across nodes via protobuf `TraceContext` fields embedded in peer-to-peer messages. When Node A sends a transaction, proposal, or validation, it injects its active span's trace/span IDs into the protobuf message. Node B extracts that context on receipt and creates a child span, linking the two nodes into a single distributed trace. ### How It Works ``` Node A (sender) Node B (receiver) +-----------------------------+ +-------------------------------+ | tx.process / consensus.* | | PeerImp::onMessage() | | | | | | | | v | | v | | SpanGuard::getTraceBytes() | | extract TraceContext from | | | | | protobuf message | | v | send | | | | injectSpanContext() --------|--------->| v | | sets TraceContext fields | proto | txReceiveSpan() | | (trace_id, span_id, flags) | msg | proposalReceiveSpan() | +-----------------------------+ | validationReceiveSpan() | | | | | v | | child span with parent link | +-------------------------------+ ``` ### Send-Side Injection | Message Type | Injection Point | Mechanism | | ------------- | -------------------------- | ------------------------------------------ | | TMTransaction | `NetworkOPs::apply()` | Injects `tx.process` span into relay msg | | TMProposeSet | `RCLConsensus::propose()` | Injects active context into proposal msg | | TMValidation | `RCLConsensus::validate()` | Injects active context into validation msg | ### Receive-Side Extraction | Message Type | Extraction Point | Helper Function | | ------------- | ----------------------------------- | -------------------------------------------------- | | TMTransaction | `PeerImp::onMessage(TMTransaction)` | `TxTracing::txReceiveSpan()` | | TMProposeSet | `PeerImp::onMessage(TMProposeSet)` | `ConsensusReceiveTracing::proposalReceiveSpan()` | | TMValidation | `PeerImp::onMessage(TMValidation)` | `ConsensusReceiveTracing::validationReceiveSpan()` | ### Key Files | File | Role | | ------------------------------------------------- | ----------------------------------------------- | | `src/xrpld/telemetry/PropagationHelpers.h` | `injectSpanContext()` — SpanGuard to protobuf | | `include/xrpl/telemetry/TraceContextPropagator.h` | OTel context <-> protobuf conversion primitives | | `src/xrpld/telemetry/ConsensusReceiveTracing.h` | Proposal/validation receive span factories | | `src/xrpld/telemetry/TxTracing.h` | Transaction receive span factory | ### Backwards Compatibility Older peers that do not populate `TraceContext` fields in their messages will simply produce empty trace bytes on the receive side. The extraction helpers detect this and create standalone (root) spans instead of child spans. No errors are logged and no data is lost — the receive span is still created with all its normal attributes, it just lacks a cross-node parent link. ### Example Tempo Queries ``` # Find cross-node transaction traces (tx.receive spans with no errors) {name="tx.receive" && status != error} # Find proposals received with cross-node parent context {} >> {name="consensus.proposal.receive"} # Trace a transaction across the network by its hash {name =~ "tx.*" && span.tx_hash = ""} # Find all spans in a cross-node consensus trace {resource.service.name="xrpld" && span.consensus_round_id = ""} # Compare latency between sender and receiver for validations {name = "consensus.validation.send" || name = "consensus.validation.receive"} ``` ## Prometheus Metrics (Spanmetrics) The OTel Collector's spanmetrics connector automatically derives RED (Rate, Errors, Duration) metrics from every span. No custom metrics code is needed in xrpld. ### Generated Metric Names | Prometheus Metric | Type | Description | | ----------------------------------- | --------- | ---------------------------- | | `span_calls_total` | Counter | Total span invocations | | `span_duration_milliseconds_bucket` | Histogram | Latency distribution buckets | | `span_duration_milliseconds_count` | Histogram | Latency observation count | | `span_duration_milliseconds_sum` | Histogram | Cumulative latency | ### Metric Labels Every metric carries these standard labels: | Label | Source | Example | | -------------- | ------------------ | ---------------------------------------- | | `span_name` | Span name | `rpc.command.server_info` | | `status_code` | Span status | `STATUS_CODE_UNSET`, `STATUS_CODE_ERROR` | | `service_name` | Resource attribute | `xrpld` | | `span_kind` | Span kind | `SPAN_KIND_INTERNAL` | Additionally, span attributes configured as dimensions in the collector become metric labels. The span attribute keys are already underscore form (the naming convention forbids dots), so the label name matches the attribute name verbatim. Prometheus' dots → underscores sanitization only fires for dotted attribute names (e.g. resource attributes like `service.name`), which does not apply to these dimensions. | Span Attribute | Metric Label | Applies To | | -------------------- | -------------------- | ------------------------------- | | `command` | `command` | `rpc.command.*` spans | | `rpc_status` | `rpc_status` | `rpc.command.*` spans | | `consensus_mode` | `consensus_mode` | `consensus.ledger_close` spans | | `local` | `local` | `tx.process` spans | | `proposal_trusted` | `proposal_trusted` | `peer.proposal.receive` spans | | `validation_trusted` | `validation_trusted` | `peer.validation.receive` spans | ### Histogram Buckets Configured in `otel-collector-config.yaml` (spanmetrics connector, `unit: ms`): ``` 1ms, 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2s, 3s, 4s, 5s, 10s, 30s ``` Sub-second boundaries cover RPC/tx/ledger spans; 2s-4s resolve second-scale consensus spans (`consensus.round`, `consensus.establish`) that would otherwise pile into one 1s-5s bucket and make `histogram_quantile` a meaningless interpolation; 10s/30s give the `ledger.acquire` catch-up tail a measurable home. Boundaries must stay strictly ascending. The native beast::insight histograms (ms-scale RPC/IO timers) keep the original 1ms-5s buckets in `Telemetry.cpp` — they never exceed 5s, so they need no high-range buckets. ## System Metrics (OTel native -- beast::insight) xrpld has a built-in metrics framework (`beast::insight`) that exports metrics natively via OTLP to the OTel Collector. These complement the span-derived RED metrics by providing system-level gauges, counters, and timers that don't map to individual trace spans. ### Configuration Add to `xrpld.cfg`: ```ini [insight] server=otel endpoint=http://localhost:4318/v1/metrics prefix=xrpld ``` The `OTelCollector` implementation exports metrics via OTLP/HTTP to the same OTel Collector that receives traces. No separate StatsD receiver is needed. > **Fallback**: Set `server=statsd` and `address=127.0.0.1:8125` to use the legacy StatsD UDP path. This requires re-enabling the `statsd` receiver in `otel-collector-config.yaml` and uncommenting port 8125 in `docker-compose.yml`. ### Metric Reference #### Gauges | Prometheus Metric | Source | Description | | ------------------------------------- | ------------------------- | -------------------------------------------------------------------------- | | `ledgermaster_validated_ledger_age` | LedgerMaster.h:373 | Age of validated ledger (seconds) | | `ledgermaster_published_ledger_age` | LedgerMaster.h:374 | Age of published ledger (seconds) | | `state_accounting_{mode}_duration` | NetworkOPs.cpp:774 | Time in each operating mode (Disconnected/Connected/Syncing/Tracking/Full) | | `state_accounting_{mode}_transitions` | NetworkOPs.cpp:780 | Transition count per mode | | `peer_finder_active_inbound_peers` | PeerfinderManager.cpp:214 | Active inbound peer connections | | `peer_finder_active_outbound_peers` | PeerfinderManager.cpp:215 | Active outbound peer connections | | `overlay_peer_disconnects` | OverlayImpl.h:557 | Peer disconnect count | | `jobq_job_count` | JobQueue.cpp:26 | Current job queue depth (all types) | | `jobq_{jobtype}_waiting` | JobTypeData.h | Jobs of this type enqueued but not yet running | | `jobq_{jobtype}_running` | JobTypeData.h | Jobs of this type currently executing | | `jobq_{jobtype}_deferred` | JobTypeData.h | Jobs of this type held back because the type's concurrency limit was hit | | `{category}_bytes_in/out` | OverlayImpl.h:535 | Overlay traffic bytes per category (57 categories) | | `{category}_messages_in/out` | OverlayImpl.h:535 | Overlay traffic messages per category | Note that `job_count` is exported as `jobq_job_count`: the JobQueue is constructed with `collectorManager_->group("jobq")` (Application.cpp:386), `GroupImp::makeName()` joins prefix and name with a `.` (Groups.cpp:42), and `OTelCollectorImp::formatName()` then turns the `.` into `_` and lowercases the whole string (OTelCollector.cpp:860). The same mechanism produces the `jobq_{jobtype}_*` names above and the pre-existing `jobq_{jobtype}_milliseconds` timing family. #### Per-Job-Type Queue Saturation The three `jobq_{jobtype}_{waiting,running,deferred}` families expose the per-type counters that `JobTypeData` already maintained but never exported. `{jobtype}` is the lowercased `JobTypes` name, so `JtLedgerReq` ("ledgerRequest") becomes `jobq_ledgerrequest_waiting` / `_running` / `_deferred`. They are emitted for every **non-special** job type — 35 of the 46 declared types. A "special" type is one whose concurrency limit is 0 (`JobTypeInfo::special()`, JobTypeInfo.h:71-74); the limit logic never applies to it, so its `deferred` is always zero. The gauge members are declared at JobTypeData.h:78-80 and created at :100-102, next to the existing `dequeue`/`execute` events and under the same `!info.special()` guard (:95). They are published by `JobQueue::collect()` under the `mutex_` that already guards the counters (JobQueue.cpp:66-93) — no new locking. **`deferred` is the leading indicator.** `JobQueue::addJob()` never rejects work — when a type is at its limit, `addRefCountedJob()` increments `deferred` and returns `true` anyway (JobQueue.cpp:131-142), and `finishJob()` drains one deferred job per completion (JobQueue.cpp:353-362). Backpressure on a capped type therefore shows up **only as latency**, after the harm is done. `deferred > 0` says the cap is being hit _now_, before the duration histograms move. Limits that matter for ledger sync (JobTypes.h:54-77): | Job type | Metric prefix | Limit | Producers | | -------------- | --------------------- | ----- | ------------------------------------------------------------------------------- | | `JtPack` | `jobq_makefetchpack_` | 1 | `MakeFetchPack` | | `JtLedgerReq` | `jobq_ledgerrequest_` | 3 | `RcvGetLedger`, `RcvGetObjByHash` | | `JtLedgerData` | `jobq_ledgerdata_` | 3 | `ProcessLData`, `GotStaleData`, `GotFetchPack`, `AcqDone`, `InboundLedger` | | `JtUpdatePf` | `jobq_updatepaths_` | 1 | `PthFindNewReq`, `PthFindOBDB`, `PthFindNewLed`, `OB` — see the note below | | `JtTxnData` | `jobq_fetchtxndata_` | 5 | `TxAcq`, `ComplAcquire`, `RcvPeerData` | > **`JtUpdatePf` has four producers, three of them individually visible.** All > four run the same `updatePaths()` work but arrive under different names. Three > come through `LedgerMaster::newPFWork()` (LedgerMaster.cpp:1545), which passes > its caller's name straight to `addJob`: `PthFindNewReq` (:1512), > `PthFindOBDB` (:1533), and `PthFindNewLed` (:1984). All three are all-letters, > so each is its own `handler` series. The fourth is > `"OB" + std::to_string(seq)` (OrderBookDBImpl.cpp:84), which contains digits > and therefore folds to `handler="other"` — order-book rebuild traffic is the > only one of the four that is not directly attributable. Do not read the whole > type as invisible: three of its four producers are named. > **Sampling caveat**: these are gauges read by the `JobQueue::collect()` hook, > which the beast::insight `PeriodicMetricReader` drives every 1 s > (Telemetry.cpp:441). A `deferred` spike shorter than the sample interval can be > missed entirely. Treat a non-zero reading as real saturation, but do not treat > a zero reading as proof that no saturation occurred — cross-check > `job_queued_us` for the same type. #### OTel MetricsRegistry Gauges These gauges are exported via the OTel Metrics SDK `PeriodicMetricReader` (10s interval), NOT through beast::insight. | Prometheus Metric | Source | Description | | --------------------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `server_info{metric="server_state"}` | MetricsRegistry.cpp | Operating mode (0=DISCONNECTED .. 4=FULL) | | `server_info{metric="uptime"}` | MetricsRegistry.cpp | Seconds since server start | | `server_info{metric="peers"}` | MetricsRegistry.cpp | Total connected peers | | `server_info{metric="validated_ledger_seq"}` | MetricsRegistry.cpp | Validated ledger sequence number | | `server_info{metric="ledger_current_index"}` | MetricsRegistry.cpp | Current open ledger sequence | | `server_info{metric="peer_disconnects_resources"}` | MetricsRegistry.cpp | Cumulative resource-related peer disconnects | | `server_info{metric="last_close_proposers"}` | MetricsRegistry.cpp | Proposers in last closed round | | `server_info{metric="last_close_converge_time_ms"}` | MetricsRegistry.cpp | Last close convergence time (ms) | | `server_info{metric="last_close_time"}` | MetricsRegistry.cpp | Network close time of last closed ledger (NetClock secs since XRPL epoch). Age = `time() - (value + 946684800)`; close interval = `1/rate(ledgers_closed_total)`, not a gauge delta | | `build_info{version=""}` | MetricsRegistry.cpp | Info-style metric (always 1) | | `complete_ledgers{bound="start\|end",index=""}` | MetricsRegistry.cpp | Complete ledger range start/end pairs | | `db_metrics{metric="db_kb_total"}` | MetricsRegistry.cpp | Total database size (KB) | | `db_metrics{metric="db_kb_ledger"}` | MetricsRegistry.cpp | Ledger database size (KB) | | `db_metrics{metric="db_kb_transaction"}` | MetricsRegistry.cpp | Transaction database size (KB) | | `db_metrics{metric="historical_perminute"}` | MetricsRegistry.cpp | Historical ledger fetches per minute | | `cache_metrics{metric="AL_size"}` | MetricsRegistry.cpp | AcceptedLedger cache size | | `nodestore_state{metric="node_reads_duration_us"}` | MetricsRegistry.cpp | Cumulative read time (microseconds) | | `nodestore_state{metric="node_writes_duration_us"}` | MetricsRegistry.cpp | Cumulative write time (microseconds) | | `nodestore_state{metric="read_request_bundle"}` | MetricsRegistry.cpp | Read request bundle count | | `nodestore_state{metric="read_threads_running"}` | MetricsRegistry.cpp | Active read threads | | `nodestore_state{metric="read_threads_total"}` | MetricsRegistry.cpp | Total read threads configured | | `rpc_in_flight_requests` | PerfLogImp.cpp | RPC requests currently executing (UpDownCounter) | #### Sync Diagnosis Signals More label values on the same `nodestore_state` gauge. They exist to separate the two different reasons a node is slow to reach `full` — see [Slow to reach `full`](#slow-to-reach-full). The `nudb_*` group is published only when the writable backend is NuDB; a memory or RocksDB backend omits those four label values rather than reporting them as zero. | Prometheus Metric | Source | Description | | ---------------------------------------------------- | ------------------- | ----------------------------------------------------------- | | `nodestore_state{metric="read_mean_us"}` | MetricsRegistry.cpp | Mean time per backend read (microseconds) | | `nodestore_state{metric="write_mean_us"}` | MetricsRegistry.cpp | Mean time per backend write (microseconds) | | `nodestore_state{metric="nudb_writers_in_flight"}` | MetricsRegistry.cpp | Threads inside a NuDB insert right now | | `nodestore_state{metric="nudb_writer_depth_x100"}` | MetricsRegistry.cpp | Mean queue depth at the NuDB insert mutex, ×100 | | `nodestore_state{metric="nudb_insert_mean_us"}` | MetricsRegistry.cpp | Mean NuDB insert time, queueing included (microseconds) | | `nodestore_state{metric="nudb_insert_max_us"}` | MetricsRegistry.cpp | Slowest single NuDB insert seen (microseconds) | | `nodestore_state{metric="acquire_deferrals"}` | MetricsRegistry.cpp | Timer jobs skipped because the lane was full, **all lanes** | | `nodestore_state{metric="acquire_timeouts"}` | MetricsRegistry.cpp | Timer bodies that ran and advanced retry, **all lanes** | | `nodestore_state{metric="acquire_ledger_deferrals"}` | MetricsRegistry.cpp | Deferrals from ledger acquisition alone | | `nodestore_state{metric="acquire_ledger_timeouts"}` | MetricsRegistry.cpp | Timeouts from ledger acquisition alone | | `nodestore_state{metric="acquire_give_ups"}` | MetricsRegistry.cpp | Acquisitions that exhausted their retry budget | | `nodestore_state{metric="acquire_aborts"}` | MetricsRegistry.cpp | Acquisitions destroyed before finishing | | `nodestore_state{metric="acquire_aborts_partial"}` | MetricsRegistry.cpp | Subset of aborts that discarded partly built maps | | `nodestore_state{metric="acquire_completions"}` | MetricsRegistry.cpp | Acquisitions that finished successfully | | `nodestore_state{metric="acquire_sweep_evictions"}` | MetricsRegistry.cpp | Acquisitions evicted by the 1-minute sweep | `nudb_writer_depth_x100` is fixed-point: divide by 100 to read it. The depth sits just above 1.0 even under load, so an integer gauge would truncate the whole signal away. It is `depthSum / depthSamples`, both accumulated when an insert **enters** the critical section, so an insert still in flight is part of the mean. `acquire_deferrals` and `acquire_timeouts` sum every `TimeoutCounter` subclass — inbound ledgers, transaction sets and the three ledger-replay tasks — because both are recorded in that shared base. They answer "is any lane deferring", not "is ledger acquisition deferring". Use `acquire_ledger_deferrals` and `acquire_ledger_timeouts` for the ledger-acquisition diagnosis; see [The deferral/timeout pair](#the-deferraltimeout-pair). #### Counters | Prometheus Metric | Source | Description | | ------------------------- | --------------------- | ------------------------------ | | `rpc_requests` | ServerHandler.cpp:108 | Total RPC request count | | `ledger_fetches` | InboundLedgers.cpp:44 | Ledger fetch request count | | `ledger_history_mismatch` | LedgerHistory.cpp:16 | Ledger hash mismatch count | | `warn` | Logic.h:33 | Resource manager warning count | | `drop` | Logic.h:34 | Resource manager drop count | #### Histograms | Prometheus Metric | Source | Description | | ----------------- | --------------------- | ------------------------------ | | `rpc_time` | ServerHandler.cpp:110 | RPC response time (ms) | | `rpc_size` | ServerHandler.cpp:109 | RPC response size (bytes) | | `ios_latency` | Application.cpp:438 | I/O service loop latency (ms) | | `pathfind_fast` | PathRequests.h:23 | Fast pathfinding duration (ms) | | `pathfind_full` | PathRequests.h:24 | Full pathfinding duration (ms) | #### Job Instruments These five come from the `PerfLog` job hooks, not from beast::insight, so they are exported by the `MetricsRegistry` meter. `job_queued_us` and `job_running_us` have explicit microsecond bucket views registered (`addMicrosecondHistogramView()`, MetricsRegistry.cpp:253-254) spanning 100 µs to 60 s; without those the SDK default buckets stop at 10 ms and every quantile saturates. | Prometheus Metric | Kind | Labels | Description | | -------------------- | --------- | --------------------- | ------------------------------------ | | `job_queued_total` | Counter | `job_type`, `handler` | Jobs enqueued | | `job_started_total` | Counter | `job_type`, `handler` | Jobs dequeued and started | | `job_finished_total` | Counter | `job_type`, `handler` | Jobs run to completion | | `job_queued_us` | Histogram | `job_type`, `handler` | Time spent waiting in the queue (µs) | | `job_running_us` | Histogram | `job_type`, `handler` | Time spent executing (µs) | #### The `handler` Label `job_type` names the queue a job ran on, not the code that submitted it. Several job types have more than one producer, so `job_type` alone cannot attribute a latency spike. The clearest case: `RcvGetLedger` (PeerImp.cpp:1566) and `RcvGetObjByHash` (PeerImp.cpp:2603) both submit to `JtLedgerReq`, so both report as `job_type="ledgerRequest"`. `JtLedgerData` has five producers, `JtUpdatePf` has four, and `JtAdvance` has four. The `handler` label carries the name string passed to `addJob()` — the specific call site. It resolves all of those at once, not just the GetObject path. **The value is sanitized, not raw.** A raw job name would be unbounded, because two production job names embed a ledger sequence number: - `"Pub" + std::to_string(ledger->seq())` (LedgerPersistence.cpp:84) - `"OB" + std::to_string(ledger->seq() % 1000000000)` (OrderBookDBImpl.cpp:84) A raw label would mint a new Prometheus series for every ledger — unbounded growth at ~1 series every 3-5 s, forever. `MetricsRegistry::sanitiseHandler()` (declared inline in `src/xrpld/telemetry/MetricsRegistry.h`) therefore applies one rule: - Keep the name when it is **non-empty and every character is an ASCII letter**. - Otherwise return the constant `"other"`. An empty name, a digit, a hyphen, or any punctuation all fall here. Both dynamic names always contain digits, so both collapse to `"other"`. Because the rule is a pure function of compile-time string literals, the label domain is fixed at build time and cannot grow at runtime. That is a stronger guarantee than an allowlist, which would silently mislabel any job added later; this rule degrades to `"other"` instead. **Five static job names also fall into `"other"`.** Sweeping every `addJob` / `addRefCountedJob` / `postCoro` / `newPFWork` / `TimeoutCounter::jobName` call site outside `src/test` finds 48 distinct static name literals. 43 are all-letters and pass through; five are not, and two more are built at runtime from a ledger sequence. The domain is therefore **44 values** (43 names plus `"other"`): | Name | Why it is not all-letters | Call site | | ------------- | ------------------------- | ---------------------- | | `GetConsL1` | digit | RCLConsensus.cpp:169 | | `GetConsL2` | digit | RCLValidations.cpp:135 | | `gRPC-Client` | hyphen | GRPCServer.cpp:156 | | `RPC-Client` | hyphen | ServerHandler.cpp:332 | | `WS-Client` | hyphen | ServerHandler.cpp:376 | > The consequence worth remembering: `handler="other"` is a **mixed bucket**, not > a residual. It holds the two per-ledger dynamic names _and_ those five static > ones, so `GetConsL1` and `GetConsL2` — two different `JtAdvance` producers — > are not separable, and neither are the three RPC client-session names. Do not > read a handler breakdown as exhaustive. The GetObject path is unaffected: > `RcvGetObjByHash` and `RcvGetLedger` are all-letters and pass through as > distinct series. > > The count is "at the time of writing" — it is a property of the source, not of > the rule. Re-derive it from the call sites after adding a job rather than > trusting this number. #### GetObject Request Metrics Five instruments on the `TMGetObjectByHash` query path, recorded via the `XRPL_METRIC_*` macros at their call sites in `PeerImp.cpp`. Label cardinality is fixed and tiny: two `result` values, two `reason` values. | Prometheus Metric | Kind | Labels | Description | | --------------------------- | --------- | ----------------------------------------------- | ----------------------------------------------------- | | `getobject_lookup_us` | Histogram | none | Time inside the NodeStore fetch loop (µs) | | `getobject_request_objects` | Histogram | none | Objects requested per request | | `getobject_lookups_total` | Counter | `result` = `hit` \| `miss` | NodeStore hit/miss volume | | `getobject_rejected_total` | Counter | `reason` = `oversize` \| `malformed_ledgerhash` | Requests refused before any NodeStore access | | `getobject_charge` | Histogram | none | Dynamic component of the differential resource charge | Aggregation choices worth knowing when reading these: - `getobject_lookup_us` times the **whole fetch loop once** (`processGetObjectByHash()`, PeerImp.cpp:2713-2742 — the iteration cap is set at :2713 and the loop ends at :2742), not each iteration. The loop can run up to `kHardMaxReplyNodes` = 12288 times (Tuning.h:30); timing each `fetchNodeObject()` would cost more than the lookups. It needs an `addMicrosecondHistogramView()` entry for the same reason the job histograms do — a 12288-lookup loop routinely exceeds 10 ms, so without the view the metric saturates exactly when it matters. - `getobject_lookups_total` is incremented **once per request with the batch totals**, not once per object. A 12288-iteration loop incrementing per object would be a measurable hot-path cost for no extra information. - `getobject_request_objects` records `packet.objects_size()` — the _requested_ count, which is what the charge bands price on, not the count actually found. - `getobject_charge` records only the **dynamic** part returned by `computeGetObjectByHashFee()` (PeerImp.cpp:3658-3681), applied at PeerImp.cpp:2757-2758 just after the loop. The admission-time base charge is a constant (`kFeeModerateBurdenPeer`, PeerImp.cpp:2634) and is already implied. - `getobject_rejected_total` counts the two early returns in `onMessage(TMGetObjectByHash)`: the malformed-ledgerhash check (PeerImp.cpp:2569, counter at :2573) and the oversize gate (PeerImp.cpp:2585, counter at :2591). Both fire before the job is enqueued, so a rejected request contributes to no other GetObject metric. > On a healthy local network `getobject_rejected_total` reads zero — no honest > peer sends an oversized request. Verify its panel with a synthetic oversized > request; do not assume it works because the query parses. #### Adding a New Metric Use the call-site macros in `src/xrpld/telemetry/MetricMacros.h` -- no `MetricsRegistry.h`/`.cpp` edit is needed for any of these: | Need | Macro | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Monotonic tally (never decreases) | `XRPL_METRIC_COUNTER_INC` / `_ADD` [+ `_LABELED`] | | Running total that can decrease | `XRPL_METRIC_UPDOWN_ADD` [+ `_LABELED`] | | Distribution of values (latency, size) | `XRPL_METRIC_HISTOGRAM_RECORD` [+ `_LABELED`] | | Last-value snapshot (not a distribution) | `XRPL_METRIC_GAUGE_RECORD` [+ `_LABELED`] -- requires an ABI v2 opentelemetry-cpp build; this repo currently builds ABI v1, so use the observable-gauge row below instead | | Value your own code already tracks, sampled on a timer | `XRPL_METRIC_OBSERVABLE_GAUGE_REGISTER` / `_COUNTER_REGISTER` / `_UPDOWN_REGISTER` | ```cpp #include // Monotonic counter: XRPL_METRIC_COUNTER_INC(app_, "my_new_thing_total", "Description of what this counts"); // Value that can go up and down, e.g. in-flight work (no _total suffix -- that // is reserved for monotonic counters; an UpDownCounter is a current value): XRPL_METRIC_UPDOWN_ADD(app_, "my_in_flight_requests", "Currently executing", 1); // on start XRPL_METRIC_UPDOWN_ADD(app_, "my_in_flight_requests", "Currently executing", -1); // on finish // Sampled from your own state, on the OTel export timer (register ONCE, in init code): XRPL_METRIC_OBSERVABLE_GAUGE_REGISTER(app_, "my_thing_size", "Current size", [this] { return static_cast(myThing_.size()); }); ``` Counters use a `_total` suffix by convention. A histogram whose values can exceed ~10,000 units (e.g. a microsecond duration beyond 10ms) still needs one line added to `addMicrosecondHistogramView()` in `MetricsRegistry.cpp` -- the only case that still touches a central file. There is no way to read a metric's current value back from application code -- OTel's API is write-only by design; keep your own state if your logic needs to both record and read a running value (see the Doxygen header in `MetricMacros.h` and "Use Case 4" in `tasks/metric-macro-plan.md` for the full explanation and the `prometheus-cpp` contrast rationale). ## Deployment Tiers Multiple xrpld instances can send telemetry to per-tier collectors that all forward to one Grafana stack. Four resource attributes segregate the data so one dashboard set serves every deployment: | Dimension | Attribute | Set by | Example values | | ----------- | ------------------------ | ---------- | ------------------------------------------------ | | Node | `service.instance.id` | xrpld cfg | `alice-laptop`, `ci-runner-7` | | Service | `service.name` | xrpld cfg | `xrpld`, `xrpld-validator` | | Network | `xrpl.network.type` | xrpld node | `mainnet`, `testnet`, `devnet`, `perf` | | Environment | `deployment.environment` | collector | `local`, `test`, `ci`, `prod` | | Work Item | `xrpl.work.item` | perf-iac | `RIPD-7455` (empty outside perf runs) | | Branch | `xrpl.branch` | perf-iac | `baseline::`, `test::` | | Node Role | `xrpl.node.role` | perf-iac | `validator`, `peer` | Dashboards expose these as the template variables `$node`, `$service_name`, `$xrpl_network_type`, `$deployment_environment`, `$xrpl_work_item`, `$xrpl_branch`, and `$xrpl_node_role` (each variable name matches its Prometheus label). Select them top-down — work item → branch → node role → node for a perf comparison run, or environment → network → service → node for general use. Selecting **All** matches every value, including series lacking the label, so mixed old/new data never disappears. The last three (`$xrpl_work_item`, `$xrpl_branch`, `$xrpl_node_role`) are populated only during perf-iac comparison runs, which stamp them as resource attributes from their own alloy pipeline. Outside those runs the labels are absent; leaving the filters on **All** keeps every dashboard rendering normally. ### Who owns which attribute - **Node and service** come from xrpld config (`service_instance_id`, `service_name`). Unique per process. - **Network** is a property of the chain the node joined; the node derives it from `[network_id]` and stamps `xrpl.network.type` on all three signals. - **Environment** is a property of where the collector runs; each collector serves one environment and stamps it. ### The upsert vs insert rule The collector's `resource/tier` processor uses two actions on purpose: - `deployment.environment` → **`upsert`** (overwrite). The collector _is_ the environment, so it is authoritative. - `xrpl.network.type` → **`insert`** (fill only if absent). The node knows its real network, so the collector must not overwrite it — `insert` only supplies a value when the source did not (e.g. an older xrpld build). This is what lets a local node connected to mainnet report `network=mainnet`, not the collector's default. ### Configuring a collector for a tier Each tier runs its own collector. Set the two values in the `resource/tier` processor of the collector config (`otel-collector-config.yaml` for local backends, `otel-collector-config.grafanacloud.yaml` for Grafana Cloud): ```yaml processors: resource/tier: attributes: - key: deployment.environment value: # local | test | ci | prod action: upsert - key: xrpl.network.type value: # mainnet | testnet | devnet (fallback only) action: insert ``` Suggested per-tier values: | Collector | `deployment.environment` | `xrpl.network.type` (fallback) | | ------------------- | ------------------------ | ------------------------------ | | Developer laptop | `local` | `devnet` | | Test machines | `test` | `testnet` | | CI runs | `ci` | `testnet` | | Production observer | `prod` | `mainnet` | The `xrpl.network.type` value is only a fallback: when the node stamps its own network (all current builds do), the node's value wins. Set it to the network the collector most commonly serves. ### How the tier labels reach metrics Resource attributes do not become Prometheus labels automatically. Two collector settings make it work, both already enabled: - `prometheus.resource_to_telemetry_conversion: enabled: true` promotes resource attributes to metric labels on the local scrape surface. - `spanmetrics.resource_metrics_key_attributes` lists the tier attributes so span-derived series stay grouped per node and tier. Traces and logs carry resource attributes natively; Grafana Cloud ingests all three signals' attributes over OTLP directly. ## Grafana Dashboards Fifteen dashboards are pre-provisioned in `docker/telemetry/grafana/dashboards/`. Fourteen are Prometheus-backed; `log-derived-insights` is the only Loki/LogQL board and is documented last, together with the LogQL-specific traps it exposed. > Nine dashboards have a reference section below. `fee-market`, `job-queue`, > `ledger-data-sync`, `overlay-traffic-detail`, `peer-quality`, and > `validator-health` are provisioned but not yet documented here — their panel > descriptions carry the same six-heading reference format, so open the panel > info icon in Grafana until a section is written. ### RPC Performance (`rpc-performance`) | Panel | Type | PromQL | Labels Used | | --------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------ | | RPC Request Rate by Command | timeseries | `sum by (command) (rate(span_calls_total{span_name=~"rpc.command.*"}[5m]))` | `command` | | RPC Latency p95 by Command | timeseries | `histogram_quantile(0.95, sum by (le, command) (rate(span_duration_milliseconds_bucket{span_name=~"rpc.command.*"}[5m])))` | `command` | | RPC Error Rate | bargauge | Error spans / total spans × 100, grouped by `command` | `command`, `status_code` | | RPC Latency Heatmap | heatmap | `sum(increase(span_duration_milliseconds_bucket{span_name=~"rpc.command.*"}[5m])) by (le)` | `le` (bucket boundaries) | | Overall RPC Throughput | timeseries | `rpc.request` + `rpc.process` rate | — | | RPC Success vs Error | timeseries | by `status_code` (UNSET vs ERROR) | `status_code` | | Top Commands by Volume | bargauge | `topk(10, ...)` by `command` | `command` | | WebSocket Message Rate | stat | `rpc.ws_message` rate | — | ### Transaction Overview (`transaction-overview`) | Panel | Type | PromQL | Labels Used | | ---------------------------------- | -------------- | -------------------------------------------------------------------------------------------- | ----------------------------------- | | Transaction Processing Rate | timeseries | `rate(span_calls_total{span_name="tx.process"}[5m])` and `tx.receive` | `span_name` | | Transaction Processing Latency | timeseries | `histogram_quantile(0.95 / 0.50, ... {span_name="tx.process"})` | — | | Transaction Path Distribution | piechart | `sum by (local) (increase(span_calls_total{span_name="tx.process"}[5m]))` | `local` | | Transaction Receive vs Suppressed | timeseries | `rate(span_calls_total{span_name="tx.receive"}[5m])` | — | | TX Processing Duration Heatmap | heatmap | `tx.process` histogram buckets | `le` | | TX Apply Duration per Ledger | timeseries | p95/p50 of `tx.apply` | — | | Peer TX Receive Rate | timeseries | `tx.receive` rate | — | | TX Apply Failed Rate | stat | `rate(span_calls_total{span_name="tx.transactor",stage="apply",ter_result!~"tesSUCCESS\|"})` | `stage`, `ter_result` | | TxQ Accept: Applied Ratio per Node | state-timeline | applied / (applied+failed) of `span_calls_total{span_name="txq.accept_tx"}` per node | `txq_status`, `service_instance_id` | ### Consensus Health (`consensus-health`) | Panel | Type | PromQL | Labels Used | | ----------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | | Consensus Round Duration | timeseries | `histogram_quantile(0.95 / 0.50, ... {span_name="consensus.accept"})` | — | | Consensus Proposals Sent Rate | timeseries | `rate(span_calls_total{span_name="consensus.proposal.send"}[5m])` | — | | Ledger Close Duration | timeseries | `histogram_quantile(0.95, ... {span_name="consensus.round"})` (full round, not `consensus.ledger_close` which is only the sub-ms onClose prologue) | `consensus_mode` | | Validation Send Rate | stat | `rate(span_calls_total{span_name="consensus.validation.send"}[5m])` | — | | Ledger Apply Duration | timeseries | `histogram_quantile(0.95 / 0.50, ... {span_name="consensus.accept.apply"})` | — | | Close Time Agreement | timeseries | `rate(span_calls_total{span_name="consensus.accept.apply"}[5m])` | — | | Consensus Mode Over Time | timeseries | `consensus.ledger_close` by `consensus_mode` | `consensus_mode` | | Accept vs Close Rate | timeseries | `consensus.accept` vs `consensus.ledger_close` rate | — | | Validation vs Close Rate | timeseries | `consensus.validation.send` vs `consensus.ledger_close` | — | | Accept Duration Heatmap | heatmap | `consensus.accept` histogram buckets | `le` | ### Ledger Operations (`ledger-operations`) | Panel | Type | PromQL | Labels Used | | --------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------- | | Ledger Build Rate | stat | `ledger.build` call rate | — | | Ledger Build Duration | timeseries | p95/p50 of `ledger.build` | — | | Ledger Validation Rate | stat | `ledger.validate` call rate | — | | Build Duration Heatmap | heatmap | `ledger.build` histogram buckets | `le` | | TX Apply Duration | timeseries | p95/p50 of `tx.apply` | — | | TX Apply Rate | timeseries | `tx.apply` call rate | — | | Ledger Store Rate | stat | `ledger.store` call rate | — | | Build vs Close Duration | timeseries | p95 `ledger.build` vs `consensus.round` (full round, not `consensus.ledger_close` which is only the sub-ms onClose prologue) | — | | Ledger Close Interval & Age | timeseries | Interval: `1/rate(ledgers_closed_total)`; Age: `time() - (server_info{metric="last_close_time"} + 946684800)` | — | ### Peer Network (`peer-network`) Requires `trace_peer=1` in the `[telemetry]` config section. | Panel | Type | PromQL | Labels Used | | -------------------------------- | ---------- | ------------------------------------------------------------------------- | -------------------- | | Proposal Receive Rate | timeseries | `peer.proposal.receive` rate | — | | Validation Receive Rate | timeseries | `peer.validation.receive` rate | — | | Proposals Trusted vs Untrusted | piechart | `increase()` counts in the selected window, split by `proposal_trusted` | `proposal_trusted` | | Validations Trusted vs Untrusted | piechart | `increase()` counts in the selected window, split by `validation_trusted` | `validation_trusted` | ### Node Health -- System Metrics (`node-health`) | Panel | Type | PromQL | Labels Used | | ------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | | Validated Ledger Age | stat | `ledgermaster_validated_ledger_age` | — | | Published Ledger Age | stat | `ledgermaster_published_ledger_age` | — | | Operating Mode (Time Share) | timeseries | `rate(state_accounting_X_duration) / sum(rate(all modes))` | — | | Operating Mode Transitions | timeseries | `state_accounting_*_transitions` | — | | I/O Latency | timeseries | `histogram_quantile(0.95, ios_latency_bucket)` | — | | Job Queue Depth | timeseries | `jobq_job_count` | — | | Ledger Fetch Rate | stat | `rate(ledger_fetches[5m])` | — | | Ledger History Mismatches | stat | `rate(ledger_history_mismatch[5m])` | — | | Key Jobs Execution Time | timeseries | `acceptledger{quantile="$quantile"}` (+ 10 more key jobs) | `quantile` | | Key Jobs Dequeue Wait Time | timeseries | `acceptledger_q{quantile="$quantile"}` (+ 10 more) | `quantile` | | FullBelowCache Size | timeseries | `node_family_full_below_cache_size` | — | | FullBelowCache Hit Rate | gauge | `node_family_full_below_cache_hit_rate` | — | | Ledger Publish Gap | stat | `Published_Ledger_Age - Validated_Ledger_Age` | — | | State Duration Rate (Full vs Tracking) | timeseries | `rate(state_accounting_full_duration[5m]) / 1000000` | — | | All Jobs Execution Time (Detail) | timeseries | `{__name__=~"", quantile="$quantile"}` | `quantile` | | All Jobs Dequeue Wait (Detail) | timeseries | `{__name__=~"_q", quantile="$quantile"}` | `quantile` | | Server State | stat | `server_info{metric="server_state"}` | `metric` | | Uptime | stat | `server_info{metric="uptime"}` | `metric` | | Peer Count | stat | `server_info{metric="peers"}` | `metric` | | Validated Ledger Seq -- Lag Behind Network Tip | stat | `max by (xrpl_network_type) (server_info{metric="validated_ledger_seq"}) - on(xrpl_network_type) group_right() server_info{metric="validated_ledger_seq"}` | `metric` | | Validated Ledger Seq -- Convergence (Max - Min, per network) | stat | `max by (xrpl_network_type) (server_info{metric="validated_ledger_seq"}) - min by (xrpl_network_type) (server_info{metric="validated_ledger_seq"})` | `metric` | | Build Version | stat | `build_info` | `version` | | Complete Ledger Ranges | table | `complete_ledgers` | `bound`, `index` | | Database Sizes | timeseries | `db_metrics{metric=~"db_kb_.*"}` | `metric` | | Historical Fetch Rate | stat | `db_metrics{metric="historical_perminute"}` | `metric` | ### Network Traffic -- System Metrics (`network-traffic`) | Panel | Type | PromQL | Labels Used | | ------------------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------- | ----------- | | Active Peers | timeseries | `peer_finder_active_*_peers` | — | | Peer Disconnects | timeseries | `increase(overlay_peer_disconnects[$__rate_interval])` | — | | Total Network Bytes | timeseries | `rate(total_bytes_in/out[$__rate_interval])` | — | | Total Network Messages | timeseries | `rate(total_messages_in/out[$__rate_interval])` | — | | Transaction Traffic | timeseries | `rate(transactions_messages_in/out[$__rate_interval])` | — | | Proposal Traffic | timeseries | `rate(proposals_messages_in/out[$__rate_interval])` | — | | Validation Traffic | timeseries | `rate(validations_messages_in/out[$__rate_interval])` | — | | Traffic by Category | bargauge | `topk(10, label_replace(sum by (service_instance_id)(rate([$__rate_interval])),"__name__","","","") or …)` | — | | Duplicate Traffic (Wasted Bandwidth) | timeseries | `rate(*_duplicate_bytes_in/out[$__rate_interval])` | — | | All Traffic Categories (Detail) | timeseries | `topk(15, label_replace(sum by (service_instance_id)(rate([$__rate_interval])),"__name__","","","") or …)` | — | > **Why the per-category panels enumerate each metric.** A bare > `rate({__name__=~".*_bytes_in"}[…])` fails on Mimir/Cloud with _"vector > cannot contain metrics with the same labelset"_: `rate()` drops the > `__name__` label, so the many matched counters collapse to identical > labelsets. Wrapping in `sum by (__name__, …)` does **not** help (the inner > vector is rejected before the outer `sum`). The working form enumerates each > `*_bytes_in` metric and re-attaches its name with `label_replace(..., "__name__", "", "", "")`, so the existing `{{__name__}}` legend and > the per-series display-name overrides keep working. ### RPC & Pathfinding -- System Metrics (`rpc-pathfinding`) | Panel | Type | PromQL | Labels Used | | ------------------------- | ---------- | ------------------------------------------------ | ----------- | | RPC Request Rate | stat | `rate(rpc_requests[5m])` | — | | RPC Response Time | timeseries | `histogram_quantile(0.95, rpc_time_bucket)` | — | | RPC Response Size | timeseries | `histogram_quantile(0.95, rpc_size_bucket)` | — | | RPC Response Time Heatmap | heatmap | `rpc_time_bucket` | — | | Pathfinding Fast Duration | timeseries | `histogram_quantile(0.95, pathfind_fast_bucket)` | — | | Pathfinding Full Duration | timeseries | `histogram_quantile(0.95, pathfind_full_bucket)` | — | | Resource Warnings Rate | stat | `rate(warn_total[$__rate_interval])` | — | | Resource Drops Rate | stat | `rate(drop_total[$__rate_interval])` | — | ### Span → Metric → Dashboard Summary | Span Name | Prometheus Metric Filter | Grafana Dashboard | | ------------------------------ | -------------------------------------------- | --------------------------------------------- | | `rpc.http_request` | `{span_name="rpc.http_request"}` | RPC Performance (Overall Throughput) | | `rpc.ws_upgrade` | `{span_name="rpc.ws_upgrade"}` | -- (available but not paneled) | | `rpc.ws_message` | `{span_name="rpc.ws_message"}` | RPC Performance (WebSocket Rate) | | `rpc.process` | `{span_name="rpc.process"}` | RPC Performance (Overall Throughput) | | `rpc.command.*` | `{span_name=~"rpc.command.*"}` | RPC Performance (Rate, Latency, Error, Top) | | `tx.process` | `{span_name="tx.process"}` | Transaction Overview (Rate, Latency, Heatmap) | | `tx.receive` | `{span_name="tx.receive"}` | Transaction Overview (Rate, Receive) | | `tx.apply` | `{span_name="tx.apply"}` | Transaction Overview + Ledger Ops (Apply) | | `txq.enqueue` | `{span_name="txq.enqueue"}` | -- (available but not paneled) | | `txq.apply_direct` | `{span_name="txq.apply_direct"}` | -- (available but not paneled) | | `txq.batch_clear` | `{span_name="txq.batch_clear"}` | -- (available but not paneled) | | `txq.accept` | `{span_name="txq.accept"}` | -- (available but not paneled) | | `txq.accept_tx` | `{span_name="txq.accept_tx"}` | -- (available but not paneled) | | `txq.cleanup` | `{span_name="txq.cleanup"}` | -- (available but not paneled) | | `consensus.round` | `{span_name="consensus.round"}` | -- (available but not paneled) | | `consensus.phase.open` | `{span_name="consensus.phase.open"}` | -- (available but not paneled) | | `consensus.establish` | `{span_name="consensus.establish"}` | -- (available but not paneled) | | `consensus.update_positions` | `{span_name="consensus.update_positions"}` | -- (available but not paneled) | | `consensus.check` | `{span_name="consensus.check"}` | -- (available but not paneled) | | `consensus.accept` | `{span_name="consensus.accept"}` | Consensus Health (Duration, Rate, Heatmap) | | `consensus.proposal.send` | `{span_name="consensus.proposal.send"}` | Consensus Health (Proposals Rate) | | `consensus.ledger_close` | `{span_name="consensus.ledger_close"}` | Consensus Health (Close, Mode) | | `consensus.validation.send` | `{span_name="consensus.validation.send"}` | Consensus Health (Validation Rate) | | `consensus.accept.apply` | `{span_name="consensus.accept.apply"}` | Consensus Health (Apply Duration, Close Time) | | `consensus.mode_change` | `{span_name="consensus.mode_change"}` | -- (available but not paneled) | | `consensus.proposal.receive` | `{span_name="consensus.proposal.receive"}` | -- (available but not paneled) | | `consensus.validation.receive` | `{span_name="consensus.validation.receive"}` | -- (available but not paneled) | | `ledger.build` | `{span_name="ledger.build"}` | Ledger Ops (Build Rate, Duration, Heatmap) | | `ledger.validate` | `{span_name="ledger.validate"}` | Ledger Ops (Validation Rate) | | `ledger.store` | `{span_name="ledger.store"}` | Ledger Ops (Store Rate) | | `peer.proposal.receive` | `{span_name="peer.proposal.receive"}` | Peer Network (Rate, Trusted/Untrusted) | | `peer.validation.receive` | `{span_name="peer.validation.receive"}` | Peer Network (Rate, Trusted/Untrusted) | ## Alerting xrpld provisions thirteen Grafana alert rules on the health-critical metrics, so a stock stack alerts out of the box with no UI setup. Rules are provisioned from `docker/telemetry/grafana/provisioning/alerting/` and load automatically when the Grafana container starts. They appear under **Alerting → Alert rules**, folder **xrpld**. > **All rules ship `isPaused: true`.** Thresholds are tuned against a small > dev/devnet population, so every rule is deactivated on arrival — compare it > against your own baseline, then unpause. The key is camelCase: `is_paused` is > **silently ignored** by the provisioning loader (no error, no warning) and > leaves the rule live. Note the sibling field `notification_settings` _is_ > snake_case. ### Alert catalogue All rules evaluate every minute against the Prometheus datasource and aggregate `by (service_instance_id)` so each node alerts on its own. Every expr selects `{service_name="xrpld"}` — the same Prometheus may also host a legacy statsd fleet exporting some of these names (`state_accounting_*` in particular) with no xrpld resource attributes, and without the selector those series get summed in. Alerts fire only after the condition holds for the `for` dwell time. | Alert | Severity | Fires when | For | | ------------------------- | -------- | -------------------------------------------------- | --- | | `LedgerHistoryMismatch` | critical | `increase(ledger_history_mismatch_total[15m])` > 0 | 2m | | `LedgerCloseStalled` | critical | `rate(ledgers_closed_total)` ≈ 0 | 3m | | `ValidatedLedgerStale` | critical | `ledgermaster_validated_ledger_age` > 60s | 5m | | `ValidationsMissed` | warning | validator miss _ratio_ > 0.1 | 15m | | `ValidationsNotChecked` | warning | `rate(validations_checked_total)` ≈ 0 | 5m | | `JobQueueTxOverflow` | warning | `increase(jq_trans_overflow_total[15m])` > 0 | 2m | | `JobQueueLatencyHigh` | warning | p99 `job_queued_us` > 1s | 5m | | `NodeStoreIOLatencyHigh` | warning | p95 `ios_latency_milliseconds` > 1s | 10m | | `NodeStateFlapping` | warning | > 3 re-entries into FULL per hour | 15m | | `NodeNotFull` | warning | `server_state` < 4 (FULL) | 15m | | `ManifestJobQueueConvoy` | warning | `jobq_manifest_waiting` > 3 | 10m | | `ManifestFloodInbound` | warning | `rate(overhead_manifest_bytes_in)` > 512 kB/s | 10m | | `PeerResourceDisconnects` | warning | > 5 resource-driven peer disconnects per 30m | 5m | Two expression idioms recur and are load-bearing — do not "simplify" them away: - **Sparse counters use `increase(...[15m])` with a short `for`,** not `rate(...[5m])` with `for: 5m`. A single increment keeps `rate[5m]` nonzero for only ~4 minutes of dwell, so a 5-minute `for` can never be satisfied and the rule silently never fires for the one-off events it exists to catch. - **"Node stopped doing X" rules synthesise an explicit zero** via `or (0 * max_over_time(...[1h]))`, because `sum by()` returns rows only for still-reporting nodes: one dead node's row simply disappears from the result, so `noDataState` never triggers unless _every_ node vanishes at once. #### Consensus / ledger health **LedgerHistoryMismatch** — The node closed a ledger whose history diverges from the validated network chain. Likely causes: corrupted local state, a bug, or a node that fell out of sync and rebuilt incorrectly. Investigate the node's ledger acquisition logs; a healthy node never mismatches. **LedgerCloseStalled** — No ledgers closed for 3 minutes. A healthy node closes one every ~3-5s. Likely causes: lost peer connectivity, consensus stall, or the process is hung. This rule also fires on _NoData_ — if the series disappears the node is likely down. Check peer count and process health first. **ValidatedLedgerStale** — The validated ledger has fallen more than 60s behind. This is the clearest single "is this node healthy" signal on XRPL: it is the symptom nearly every consensus or sync failure eventually produces, so it is often the first thing to check and the last thing to clear. Measured over 7 days: p50 2s, p95 4s, p99 5s on every node. > **The `< 1209600` clause in this rule's expression is required — do not remove > it.** When a node holds no validated ledger at all, > `LedgerMaster::getValidatedLedgerAge()` returns `weeks{2}` (1 209 600 s) as a > **sentinel**, not a measurement. Without the clause the rule reads that as "14 > days stale" and fires on every node during startup — measured, it produced > sustained firing on all nine nodes over a six-day window, healthy ones included. > A node genuinely stuck without a validated ledger is caught by > `LedgerCloseStalled` and `NodeNotFull` instead. #### Validator health **ValidationsMissed** — This validator's validations are not agreeing with the validated ledger. Sustained misses risk removal from UNLs. Check clock sync, peer connectivity, and whether the node is keeping up with ledger close. > **Why this is a ratio gated on `validations_sent_total`, not > `rate(validation_missed_total) > 0`:** `ValidationTracker` classifies a ledger > as a miss whenever `weValidated && networkValidated` is not _both_ true. A node > that does not validate never sets `weValidated`, so **every** reconciled ledger > counts as a miss and the raw rate is permanently nonzero — the measured miss > ratio is exactly `1.0` on non-validating nodes. No threshold can separate "not > a validator" from "validator disagreeing", so the rule gates on > `validations_sent_total > 0` to exclude non-validators entirely, and then > measures the ratio among nodes that genuinely do validate. **ValidationsNotChecked** — The node has stopped checking incoming validations from peers. Likely causes: overlay/peer disconnection or a stalled validation pipeline. Fires on NoData as well. #### Job queue / resource health **JobQueueTxOverflow** — The transaction job queue is full and transactions are being dropped. The node is shedding load it cannot process. Check CPU, the `JobQueueLatencyHigh` alert, and offered load. **JobQueueLatencyHigh** — p99 queue wait exceeds 1 second, i.e. jobs back up before running. The node is saturated. Correlate with CPU and the Job Queue dashboard. **NodeStoreIOLatencyHigh** — p95 node-store IO latency exceeds 1s. Sustained store latency is the usual _upstream cause_ of state flapping and sync stalls, so this often fires alongside `NodeStateFlapping` and explains it. Check disk utilisation and whether the node store sits on a slow volume — moving it to a local NVMe has previously cut time-to-`full` by more than 3x. Measured p99-of-p95 is 37-49ms on healthy nodes and 488-566ms on nodes that are actively flapping. #### Node operating state **NodeStateFlapping** — The node is oscillating `full → syncing/connected → full` instead of holding sync. Measured: a flapping node re-enters `full` 4-6 times per hour sustained, while a healthy node manages 0-1, so the `> 3` threshold sits between the two populations with roughly a 3x margin. The rule counts `state_accounting_full_transitions`, which counts transitions _into_ `full` and is exported as a cumulative gauge — `increase()` is therefore correct, and its counter-reset correction turns a process restart into a small positive delta rather than a false spike. `state_changes_total` cannot be used here: it carries no from/to labels, so it cannot tell a flap from a normal startup walk. **The `uptime > 3600` gate is load-bearing.** Every node walks `disconnected → connected → syncing → tracking → full` once at boot; without the gate, every restart pages. The trade-off is deliberate: flapping confined to the first hour after boot is not alerted. Investigate in this order: `NodeStoreIOLatencyHigh` (most common cause), peer connectivity, then clock sync. **NodeNotFull** — The node has been below `FULL` for 15m (`0`=disconnected, `1`=connected, `2`=syncing, `3`=tracking, `4`=full). This is deliberately a _separate_ rule from `NodeStateFlapping`: a node that drops to syncing and stays there produces no further full-transitions, so the flapping counter by definition cannot catch it. #### Overlay / manifests **ManifestJobQueueConvoy** — Manifest jobs are backing up in the job queue. Peers send `TMManifests` dumps up to ~57MB (just under `kMaximumMessageSize`, see `overlay/Message.h`), and `JtManifest` is registered with `maxLimit` (`core/JobTypes.h`), so every peer's dump runs concurrently and they convoy on `ManifestCache::mutex_`; `OverlayImpl::onManifests` also re-verifies the blob a second time on Accept. Measured effect: each `RcvManifests` job took 16-18s and the entire 8-worker pool was occupied. This is the most reliable manifest-flood signal because `jobq_manifest_waiting` is `0` at the 99.9th percentile on every node over 24h — any sustained backlog is a genuine outlier rather than normal variance. **ManifestFloodInbound** — Inbound manifest byte-rate exceeds 512 kB/s. Catches the wire-level cause (a peer shipping oversized dumps) even when the job pool absorbs it without a visible backlog. Measured over 7 days: healthy p95 0.2-0.5 kB/s and p99 1.0-1.8 kB/s, against peaks up to 2.7 MB/s during real storms — so the threshold sits ~280x above healthy p99 and ~5x below the peaks. > An earlier revision used 50 kB/s, justified from a 24-hour window. Over a full > week that produced ~41 sustained 5-minute firings across six **healthy** nodes, > i.e. routine paging. Prefer a 7-day sample when tuning any threshold here; 24 > hours is too short to expose weekly variation. > **Both manifest rules deliberately suppress startup.** The manifest storm at > boot is _measured normal behaviour_, so `ManifestFloodInbound` carries an > `uptime > 1800` gate and `ManifestJobQueueConvoy` relies on a 10m dwell that the > startup burst does not outlast. A flood confined to the first 30 minutes after > boot will therefore not alert. **PeerResourceDisconnects** — The node dropped more than 5 peers in 30m for exceeding resource budgets. Sustained disconnects starve the node of peers and precede sync loss. ### Tuning thresholds Thresholds live in `docker/telemetry/grafana/provisioning/alerting/rules.yaml` as the `params` array of each rule's `C` (threshold) node. Common tunables: - **`JobQueueLatencyHigh`** — `params: [1000000]` is 1 000 000 µs (1s). Lower it for latency-sensitive deployments. - **`LedgerCloseStalled` / `ValidationsNotChecked`** — use `lt` with a tiny epsilon (`0.001`) rather than `0`, so floating-point rate noise near zero does not suppress the alert. Edit the file and restart the Grafana container to reload: ```bash docker compose -f docker/telemetry/docker-compose.yml restart grafana ``` ### Sending alerts somewhere real Two contact points are provisioned in `docker/telemetry/grafana/provisioning/alerting/contactpoints.yaml`: | Contact point | Receivers | Gets | | ---------------- | ------------- | ------------------------ | | `xrpld-default` | Slack | warning-severity alerts | | `xrpld-critical` | Slack + email | critical-severity alerts | The severity split lives in `docker/telemetry/grafana/provisioning/alerting/policies.yaml`: the root route sends everything to `xrpld-default`, and a child route matching `severity = critical` overrides to `xrpld-critical`. So a critical alert goes to Slack **and** email; a warning goes to Slack only. Both group by `alertname` + `service_instance_id`; critical alerts re-page hourly vs the 4h default. #### Configure delivery (no secrets in git) The Slack webhook and email address are **not** hard-coded. `contactpoints.yaml` ships deliberately unroutable placeholders — an `https://hooks.slack.invalid/…` host and an `…@xrpld.invalid` address — which keep provisioning valid so the stack boots with zero configuration while alerts route nowhere. To enable delivery, edit those two values **in place** with a real webhook and address, and do not commit the result. ```bash cp docker/telemetry/.env.alerting.example docker/telemetry/.env.alerting # edit .env.alerting — gitignored; holds the SMTP relay settings $EDITOR docker/telemetry/grafana/provisioning/alerting/contactpoints.yaml docker compose -f docker/telemetry/docker-compose.yml up -d grafana ``` - **Slack** — replace the placeholder `url:` with an incoming-webhook URL. Drives both tiers. - **Email** — replace the placeholder `addresses:` (comma- or semicolon-separated) **and** point the `GF_SMTP_*` vars in `.env.alerting` at a real relay with `GF_SMTP_ENABLED=true`. Grafana can only send mail once SMTP is configured. Three traps worth knowing before you edit this file: - **Do not substitute `${SLACK_WEBHOOK_URL}` / `${ALERT_EMAIL_TO}` here.** Grafana expands `${VAR}` but does **not** support `${VAR:-default}`, so an unset variable expands to empty, fails validation, and Grafana **exits 1** — taking the whole telemetry stack down, not just alerting. A blank variable does not "disable that path"; it breaks startup. - **Never empty a `receivers:` list to disable a tier.** A contact point with no receivers ceases to exist, the policy tree then references a missing receiver, and Grafana refuses to boot. Point the route at a contact point that still exists instead. - **File provisioning is upsert-only.** Deleting a receiver from the YAML does not remove it from an instance that already booted with it — the old receiver keeps delivering. Removal needs an explicit `deleteContactPoints:` block listing the uid (a commented example sits at the bottom of `contactpoints.yaml`). To add a third destination (PagerDuty, Opsgenie, a custom webhook), add a receiver to the relevant contact point. #### Panel screenshots on alerts need a matching render token Alert notifications that carry a panel image are rendered by the `renderer` sidecar, not by Grafana itself. Grafana 13 enables the `renderAuthJWT` feature toggle by default, so the renderer rejects any request whose token is missing or still the `-` default — notifications then arrive with no image. `docker-compose.yml` feeds both sides from one variable, so they cannot drift: `GF_RENDERING_RENDERER_TOKEN` on the `grafana` service and `AUTH_TOKEN` on the `renderer` service both read `${GF_RENDERING_RENDERER_TOKEN}`, defaulting to a local development value. Override it in the environment to use your own: ```bash GF_RENDERING_RENDERER_TOKEN=$(openssl rand -hex 16) \ docker compose -f docker/telemetry/docker-compose.yml up -d grafana renderer ``` If images stop appearing, check that the two containers agree — a token set on only one side fails exactly this way: ```bash docker compose -f docker/telemetry/docker-compose.yml exec grafana \ printenv GF_RENDERING_RENDERER_TOKEN docker compose -f docker/telemetry/docker-compose.yml exec renderer \ printenv AUTH_TOKEN ``` #### Deploying alerts to Grafana Cloud Grafana Cloud has **no provisioning filesystem**, so these `apiVersion: 1` files cannot be loaded there. Cloud deployment goes through the REST API via `docker/telemetry/upload_alerts_to_grafana.py`, which reads the same tracked `rules.yaml` as the single source of truth (so local and Cloud cannot drift) and applies the Cloud-specific transforms: the local `prometheus` datasource uid is swapped for the Cloud one, the `folder:` _name_ becomes an existing `folderUID`, and `interval` becomes integer seconds. ```bash cd docker/telemetry python3 upload_alerts_to_grafana.py --dry-run # always dry-run first python3 upload_alerts_to_grafana.py # create rules, paused python3 upload_alerts_to_grafana.py --verify # read back what is deployed ``` Credentials come from `.env.grafanaserviceapi` (gitignored, a service-account token with `alert.rules:write`); the recipient address comes from `ALERT_EMAIL_TO` in `.env.alerting`. Neither is ever written to a tracked file. Use `--no-delivery` to land the rules before a recipient is chosen, and `--activate` only once the thresholds have been checked against the target fleet's baseline. > **The Cloud notification policy tree must not be pushed.** There is exactly one > policy tree per org and the PUT endpoint **replaces it wholesale**. On a shared > stack the root receiver and its sibling routes belong to other teams, so pushing > an xrpld-shaped tree would silently re-route their alerts. The uploader > therefore never touches the tree; instead each rule carries > `notification_settings.receiver`, which routes that rule directly to the xrpld > contact point and bypasses the tree entirely. Verify with a before/after hash of > `GET /api/v1/provisioning/policies`. ### Verifying alert provisioning loaded After the stack is up: ```bash # All thirteen rules present, and is each one paused? curl -s http://localhost:3000/api/v1/provisioning/alert-rules | jq -r '.[] | "\(.title)\tpaused=\(.isPaused)"' # Contact points present? curl -s http://localhost:3000/api/v1/provisioning/contact-points | jq '.[].name' ``` Check `paused=true` explicitly rather than assuming it: a mis-spelled `is_paused` is dropped without any error and the rule provisions **live**. Grafana logs a provisioning error and skips the file if the YAML is malformed: ```bash docker compose -f docker/telemetry/docker-compose.yml logs grafana | grep -i alerting ``` A malformed _expression_ fails differently and more quietly — the rule loads but every evaluation errors. After a threshold or expr change, confirm each rule's query still returns data: ```bash # Should print a numeric value per node, and no empty results curl -sG http://localhost:9090/api/v1/query \ --data-urlencode 'query=sum by (service_instance_id) (rate(ledgers_closed_total{service_name="xrpld"}[5m]))' | jq '.data.result | length' ``` ## Log-Trace Correlation When xrpld is built with `telemetry=ON`, log lines emitted within an active OpenTelemetry span automatically include `trace_id` and `span_id` fields: ``` 2024-Jan-15 10:30:45.123456 UTC LedgerMaster:NFO trace_id=abc123def456789012345678abcdef01 span_id=0123456789abcdef Validated ledger 42 ``` This enables bidirectional navigation between logs and traces in Grafana: - **Tempo -> Loki**: Click "Logs for this trace" on any trace in Grafana Tempo to see all log lines from that trace. - **Loki -> Tempo**: Click the `TraceID` derived field link on any log line containing `trace_id=` to jump to the full trace in Tempo. ### Log Ingestion Pipeline Log files are ingested by the OTel Collector's `filelog` receiver, which tails `debug.log` files and parses them with a regex that extracts `timestamp`, `partition`, `severity`, `trace_id`, `span_id`, and `message` fields. Parsed entries are exported to Grafana Loki. The receiver tails `/var/log/xrpld/*/debug.log` inside the collector container. docker-compose bind-mounts the host log root there; the source defaults to the repo-relative `docker/telemetry/data/logs`, which the telemetry configs write to (`data/logs//debug.log`) and which needs no root. To tail logs from elsewhere, set `XRPLD_LOG_DIR` before `docker compose up` (the integration test does this to point at its own workdir). The single trailing `*` matches one per-network or per-node subdirectory. ### LogQL Query Examples The OTel Collector emits logs to Loki with `service_name="xrpld"` (not `job="xrpld"`). For log-derived panels built on these queries, see the [Log-Derived Insights](#log-derived-insights-log-derived-insights) dashboard and its LogQL trap list — `partition`, `severity`, and `xrpl_network_type` are **structured metadata**, not stream labels, so they must be filtered with `|` after the selector and cannot be discovered by `label_values()`. ```logql # Find all logs for a specific trace {service_name="xrpld"} |= "trace_id=abc123def456789012345678abcdef01" # Error logs with trace context (log lines with ERR severity that have a trace_id). # Use the severity field, not `|= "ERR"`: a line filter also matches the literal # "ERR" anywhere in the message body (measured: 4 DBG lines per 6h on devnet). {service_name="xrpld"} | severity = `ERR` | trace_id != "" # All logs from a specific partition that were emitted during a span. # Prefer the structured-metadata filter over a line match: `|= "LedgerMaster"` # also matches the substring anywhere in the message body. {service_name="xrpld"} | partition = `LedgerMaster` | trace_id != "" # Logs from a specific subsystem during a span (e.g. LedgerConsensus) {service_name="xrpld"} | partition = `LedgerConsensus` | trace_id != "" # Logs from the last hour containing trace context. `partition`, `severity`, and # `trace_id` are already parsed into structured metadata by the collector's # filelog receiver, so re-extracting them with regexp is unnecessary work. {service_name="xrpld"} | trace_id != "" # Count of traced vs untraced log lines sum(count_over_time({service_name="xrpld"} | trace_id != "" [5m])) sum(count_over_time({service_name="xrpld"} | trace_id = "" [5m])) ``` ### Verifying Log Correlation 1. Start the observability stack and xrpld with telemetry enabled. 2. Send an RPC request: `curl http://localhost:5005 -d '{"method":"server_info"}'` 3. Check the debug.log for `trace_id=` entries: `grep trace_id= /path/to/debug.log` 4. Open Grafana at http://localhost:3000 -> Explore -> Loki and search for `{service_name="xrpld"} | trace_id != ""`. 5. Click the TraceID link to navigate to the corresponding trace in Tempo. ### Log-Derived Insights (`log-derived-insights`) The only **Loki/LogQL** dashboard. It surfaces detail that no metric or span records, by parsing `debug.log` text. 41 panels in 10 rows: 8 stat, 18 timeseries, 2 table, 1 state-timeline, 1 logs, 1 text, across 35 queries. > **REQUIRES DEBUG LOGS for most rows.** xrpld's default threshold is `Info` > (`Severity thresh = Severity::Info`, `app/main/Main.cpp`). Rows tagged `[DBG]` > read `DBG`-severity lines that a default node never writes, so those panels are > **empty** on an unmodified node — and an empty panel means _not collecting_, not > _no problem_. Rows tagged `[DEFAULT OK]` work as shipped. > > Enable per partition rather than globally (`Resource` alone emits ~329k > lines/6h): > > ``` > log_level ManifestCache debug > log_level Resource debug > log_level InboundLedger debug > log_level Peer debug > log_level PeerFinder debug > ``` > > Those five cover every `[DBG]` row. The `[MIXED]` stat row additionally > reads `LedgerConsensus` and `LoadMonitor`, both of which already emit at > the default level, so its error/consensus/breach/sync panels populate > without any change — only its manifest, fee, and fetch-waste panels need > debug enabled. | Row | Gate | Key panels | | --------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Worst Offenders — Node Ranking | `[MIXED]` | 8 stat panels ranking nodes by error volume, attack-like input, total fee charged, manifest rejections, consensus problems, job latency breaches, sync instability, and ledger fetch waste | | Node Operating State Transitions | `[DEFAULT OK]` | Transition rate and state timeline from `STATE->` (`NetworkOPsImp::setMode`, info) | | Log Volume & Severity Mix | `[DEFAULT OK]` | Line rate by severity; top-N partitions by rate | | Manifests — Disposition & Producers | `[DBG]` | Disposition rate; accept-vs-reject; top-N master keys | | Resource Fee Charges — Load Attribution | `[DBG]` | Charge rate by reason; fee-weighted load; top-N peers by IP and public key | | Ledger Acquisition Efficiency | `[DBG]` | Duplicate ratio; good vs duplicate vs timeout | | Peer Lifecycle & Disconnects | `[DBG]` | Disconnect reason breakdown; handshake and accept rate | | Consensus Phase & Mode | `[DEFAULT OK]` | Phase transitions; operating-mode proxy; quorum and trusted-set size | | Slow Job Latency Breaches | `[DEFAULT OK]` | Run p99, wait p99, breach rate by job (`LoadMonitor`, >500ms only) | | Error & Warning Stream | `[DEFAULT OK]` | WRN/ERR/FTL rate by partition; live log tail | Filters: `$service_name`, `$deployment_environment`, `$node`, `$xrpl_network_type`, `$severity`, plus log-derived `$consensus_phase`, `$consensus_mode`, `$manifest_action`, `$charge_reason`, and `$topn`. #### LogQL traps this dashboard exposed Ten mistakes that fail **silently** — each cost a debugging cycle, so check them before adding any LogQL panel. 1. **`partition` is structured metadata, not a stream label.** `{service_name="xrpld", partition="ManifestCache"}` returns **zero rows with no error**. Correct form: `{service_name="xrpld"} | partition = \`ManifestCache\``. Stream labels are only `service_name`, `service_instance_id`, `deployment_environment`. Everything else — `partition`, `severity`, `xrpl_network_type`, `message`, `trace_id` — is structured metadata. 2. **`label_values()` cannot see structured metadata.** A `query`-type template variable over `xrpl_network_type`, `severity`, or `partition` returns an empty dropdown; only true stream labels populate. Use a `custom` variable with enumerated values instead. This is why filters appeared blank. 3. **A target with no datasource `uid` resolves to the DEFAULT datasource.** The Prometheus dashboards use `{"type": "prometheus"}` with no uid and work only because Prometheus _is_ the default. A Loki target written the same way sends LogQL to Prometheus and returns nothing. Always pin `{"type": "loki", "uid": "${DS_LOKI}"}`. 4. **`$__rate_interval` is Prometheus-only — Loki panels must use `[$__auto]`.** Grafana does not substitute `$__rate_interval` for a Loki target, so Loki receives the literal string and fails with `parse error: not a valid duration string: "$__rate_interval"`, which surfaces as "No data". The other 14 dashboards all use `$__rate_interval` because they are Prometheus-backed; do **not** align LogQL panels to that convention. 5. **Loki caps a query at 2000 series.** Any per-key or per-IP aggregation must be wrapped in `topk(N, ...)` or it fails with HTTP 400. A true distinct-key count over a large key space is therefore not possible in a panel. 6. **Loki tables need `labelsToFields` plus `reduce`.** Loki attaches labels to the Value field instead of returning columns, so a table panel renders bare Time/Value without `labelsToFields`. Grafana also runs a Loki table target as a **range** query even when `instant: true` is set, producing one row per series _per timestamp_ — visible as the same key repeated many times. Use `reduce(lastNotNull, labelsToFields)` then `organize`, and note the value column is then named `Last *`, which any field override must match. 7. **Title Case legends need `label_format`, not value mappings.** A label-driven legend renders the raw log value (`full`, `moderate peer request`). Grafana value mappings do not help — they map the metric _value_, not label text in `displayName`. Rewrite the label in the query: `| label_format state=\`{{if eq .state "full"}}Full{{else}}{{.state}}{{end}}\``. 8. **`unwrap` must be the last pipeline stage.** Any label filter or `label_format` placed after `| unwrap ` makes the query invalid and it returns zero frames. 9. **Non-matching lines yield an empty label.** A line in the selected partition that does not match the panel's `regexp` still passes through with an empty extracted label, which renders as a blank legend entry. Guard with `|