Merge branch 'pratik/otel-sync-diagnostics' into pratik/otel-sync-diagnostics-freshen-fix

# Conflicts:
#	OpenTelemetryPlan/09-data-collection-reference.md
#	docker/telemetry/workload/expected_spans.json
#	include/xrpl/telemetry/HistogramBuckets.h
#	src/tests/libxrpl/telemetry/HistogramBuckets.cpp
This commit is contained in:
Pratik Mankawde
2026-09-15 14:29:36 +01:00
4 changed files with 16 additions and 8 deletions

View File

@@ -2409,7 +2409,7 @@ no panel (it is read in Tempo instead).
| `txset.acquire` span (`outcome`, `txset_hash`, `duration_ms`, `timeouts`, `peer_count`) | span | `TransactionAcquire.cpp` — `TransactionAcquire::finalizeAcquireSpan` | Tx-Set Acquire Outcomes; Tx-Set Acquire Duration (p95) | One attempt to fetch the transaction set a consensus proposal referenced but this node did not hold. `TransactionAcquire` had **zero** telemetry of any kind before this, so a consensus round stalled waiting on a set was indistinguishable from an idle one. The sibling of `ledger.acquire`: same `TimeoutCounter` base, same trigger/onTimer/takeNodes shape, and the same `trace_ledger` flag so the two halves of a stuck sync cannot be enabled apart. `outcome` is `complete` \| `failed` \| `timeout` \| `abandoned`, stamped by one idempotent finalizer on every exit: `done()`; `abandonAcquireSpan()` when `InboundTransactions` stops pursuing the fetch (`giveSet`, the `newRound` sweep, `stop`, or the container being destroyed); and `cancel()`, a `TimeoutCounter` exit that fails the task without reaching `done()`. Each closes the span where the fetch really stops, so `duration_ms` never includes the time a stray reference lived; the destructor only asserts one of them ran. A fetch dropped before `init()` emits no span at all, so these outcomes cover the spans that exist, not every fetch constructed. `timeout` is distinct from `failed` because the exhausted-budget path sets the terminal `failed_` flag too — that flag is how the timer loop stops — so the outcome rule checks the timeout first or every timeout would read as a data fault. `txset_hash` identifies WHICH set stalled and stays span-only: one metric series per consensus round would be unbounded. **Which round(s) asked** is a repeated `round.request` **event** (`current_ledger_hash` + `current_ledger_seq`, re-exported keys), one per requesting round rather than a parent, a link or one attribute — each of those flattens a many-to-many relation to 1:1. Fired once per ROUND, not per peer proposal, keyed on the round's parent-ledger hash: that separates rounds STARTED on different forks at the same height, but NOT a mid-round wrong-ledger recovery, which never re-reaches `preStartRound`. The count is a LOWER BOUND valid only while the span is open — after it closes, later requests add nothing, so a fetch that kept three rounds waiting can show one event. Requester, never consumer. |
| `ledger.serve` span (`object_type`, `outcome`, `served_nodes`, `peer_id`, `ledger_seq`) | span | `PeerImp.cpp` — `PeerImp::processLedgerRequest` (the `JtLedgerReq` worker) | Ledger Serve Rate by Object Type | This node answering a peer's `TMGetLedger` request — the **supply side** of the sync exchange, and the trace-level companion to the existing `serve_refused_total` counter. The whole serve path had no span, so how long this node takes to answer, and whether it answered at all, was unobservable. A fresh trace root, because the request arrives from the wire on a shared worker whose ambient span is unrelated. `object_type` (`header` \| `tx` \| `as` \| `txset`) and `outcome` (`complete` \| `partial` \| `refused`) are both derived by shared rules in `LedgerSpanNames.h` rather than named per branch, which is what stops the eight exits of `processLedgerRequest` disagreeing about one request. `outcome` is derived from the reply itself — `served_nodes` is the reply's own node count and is 0 on all seven refusal paths — so nothing is accumulated and no work is added to the per-node assembly loop. `partial` means the reply hit `Tuning::kSoftMaxReplyNodes`, so the peer must make another round trip. |
| `peer.dial` span (`outcome`, `remote_endpoint`, `duration_ms`) | span | `ConnectAttempt.cpp` — `ConnectAttempt::reportOutcome` | Outbound Dial Outcomes (span-derived, per attempt) | One outbound connect attempt, as a per-attempt timeline rather than a rate. The trace-level companion to `overlay_connect_total` / `overlay_dial_latency_ms`: it carries the same six `outcome` values, set from the same `reportOutcome` funnel, so span and counter cannot disagree, and the funnel's existing first-call-wins guard makes the span exactly-once for free. What it adds is `remote_endpoint` — WHICH peer — which the counter deliberately cannot carry, because one series per peer address would be unbounded cardinality; it is a dedicated Tempo span column instead. A fresh trace root: a dial is the first thing a starting node does, so there is nothing to parent it to. An attempt torn down mid-dial by shutdown ends its span in the destructor with no `outcome`, which is the honest record of "never concluded" rather than a dropped span. |
| `nodestore.rotate` span + 7 children (`ledger_seq`, `last_rotated`, `outcome`; children carry a phase-specific attribute) | span | `SHAMapStoreImp.cpp` — `SHAMapStoreImp::run` rotation block; `RotationPhase` helper | Rotation Phase Duration (p95 by stage) | One trace per online-delete rotation. Fresh root: the SHAMapStore thread has no ambient span. The seven children — `clear_prior`, `copy`, `freshen.fetch`, `new_backend`, `clear_caches`, `swap`, `health_wait` — are child spans through the thread's ambient scope, so each one covers exactly its own step. `outcome` is `complete`\|`expired`\|`stopping`\|`missing_node`, stamped by the `RotationOutcome` finalizer on whichever exit runs first; its destructor asserts one exit was named, catching a new return path that forgot to. Exists to make the 2026-09-13 dev-box discovery reproducible from telemetry alone. A `freshen.keys` child once covered the single `cache.getKeys()` call whose multi-second mutex hold froze every job and flapped the node; the freshen now copies keys one partition at a time (`TaggedCache::forEachKeyPartition`), so that child is gone and the per-partition hold is read from `cache_metrics{metric="treenode_lock_hold_peak_us"}` instead. |
| `nodestore.rotate` span + 7 children (`ledger_seq`, `last_rotated`, `outcome`; children carry a phase-specific attribute) | span | `SHAMapStoreImp.cpp` — `SHAMapStoreImp::run` rotation block; `RotationPhase` helper | Rotation Phase Duration (p95 by stage) | One trace per online-delete rotation. Fresh root: the SHAMapStore thread has no ambient span. The seven children — `clear_prior`, `copy`, `freshen.fetch`, `new_backend`, `clear_caches`, `swap`, `health_wait` — are child spans through the thread's ambient scope, so each one covers exactly its own step. `outcome` is `complete`\|`expired`\|`stopping`\|`missing_node`, stamped by the `RotationOutcome` finalizer on whichever exit runs first; its destructor asserts one exit was named, catching a new return path that forgot to. Exists to make a rotation-driven stall reproducible from telemetry alone. A `freshen.keys` child once covered the single `cache.getKeys()` call whose multi-second mutex hold froze every job and flapped the node; the freshen now copies keys one partition at a time (`TaggedCache::forEachKeyPartition`), so that child is gone and the per-partition hold is read from `cache_metrics{metric="treenode_lock_hold_peak_us"}` instead. |
| `rotation_phase_duration_seconds` (`stage`) | histogram | `SHAMapStoreImp.cpp` — `RotationPhase::~RotationPhase` | Rotation Phase Duration (p95 by stage) | Wall-clock seconds spent in one rotation phase, recorded once per phase end. Exists beside the spans because the Grafana Cloud collector applies 0.5% probabilistic tail sampling, so any single trace is likely dropped; the histogram is unsampled and exact. `stage` is the child span's suffix (see `lval::rotation_phase`), so a p95 panel breaks down by phase. Bucket ladder covers 1 s to 3600 s (see `HistogramBuckets.h`'s `kRotationPhaseSecondsBuckets`), because a `copy` phase runs ~10 min on mainnet and the spanmetrics 120 s ceiling would pin every quantile. |
| `rotation_freshen_keys_total` (`cache` = `treenode` \| `master_tx`, `outcome` = `fetched` \| `copied`) | counter | `SHAMapStoreImp.cpp` — `SHAMapStoreImp::recordFreshen` | Rotation Freshen Yield (keys per rotation) | The freshen's yield. `fetched` is every cached key the rotation re-read from the node store; `copied` is the subset that was only in the archive and was written to the writable backend (`DatabaseRotating::duplicateCopyForwardTotal` before/after the loop). Two Adds per cache per rotation, none per key. Exists because `copy_forward` and `node_writes` both miss these writes: the rotating store counts copy-forwards only on `duplicate == false` reads and stores them straight to the backend, so until this counter the freshen's usefulness was unmeasured. The same delta is stamped on the `freshen.fetch` span as `keys_copied` and on the `copy` span as `nodes_copied`. |
| `jobq_stall_total` (`job_type`) | counter | `MetricsRegistry.cpp` — `recordJobFinished` (compares against `kJobStallThresholdUs`) | Job Stalls >=1 s (Count By Job Type) | Every job whose run time reached `LoadMonitor`'s 1 s warn bar (the same "Job: … run:" log line). A process-wide freeze shows up here as several job types crossing the bar in the same second — the exact detector for a rotation-driven stall, since sampling cannot drop a counter. `job_type` comes from `JobTypes::name()`; no per-handler dimension, because the rotation stall pathology is not per-handler. |
@@ -2424,8 +2424,8 @@ threshold. A single wall-clock duration spanning the whole rotation would fold
work-time and throttle-time into one number, and the throttle dominates precisely
when the node is unhealthy — which is when the number would be read.
The trigger for splitting is the measurement of 2026-09-13/14: on `aws-dev-xrpl-1/2`
every rotation freezes the process 5–6 s at the copy-walk → freshen boundary, and no
The trigger for splitting was a measurement on two mainnet nodes with populated stores:
every rotation froze the process for seconds at the copy-walk → freshen boundary, and no
existing signal could place that freeze in time (the `rotation_state{in_flight}` gauge
is 60 s-resolution and only brackets the freshen/swap window, not the whole rotation).
The fix, WP-B6, is per-phase timing: `rotation_phase_duration_seconds{stage}` and seven

View File

@@ -133,8 +133,8 @@ log "Collecting metrics for ${DURATION}s (${SAMPLES} samples, ${#RPC_PORTS[@]} n
# exit 0 with an all-zero JSON — a silent false pass.
#
# The clock has to be cheap as well as precise, because the latency it
# measures is compared against a 2 ms threshold. Measured on a dev box: `date
# +%s%N` costs ~1.2 ms per call, forking python3 for the same value ~13 ms.
# measures is compared against a 2 ms threshold. Measured on one Linux host:
# `date +%s%N` costs ~1.2 ms per call, forking python3 for the same value ~13 ms.
# Two calls bracket every request, so a python3 fallback would add ~26 ms of
# its own overhead to a 2 ms budget and make the number meaningless. There is
# no cheap alternative worth having, so probe once and refuse to run without

View File

@@ -3390,9 +3390,8 @@ increase(nodestore_state{metric="acquire_ledger_timeouts", service_instance_id=~
#### Measured reference points
**Provenance.** The two columns below are our own measurements: node2 on the AWS
dev box, build `e3c2f8279a`, 2026-07-27/28, same host and same binary for both
runs, differing only in the state of the store. Use them as the shape to compare
**Provenance.** The two columns below are our own measurements: one mainnet node,
same host and same binary for both runs, differing only in the state of the store. Use them as the shape to compare
against, not as thresholds. The read figures below come from the `read_mean_us`
gauge, the only read-latency signal exported; the "highest sample" row is the
largest value that gauge reached over the run, not a read-latency percentile. The third dataset in this section — the 25-minute devnet stall and its

View File

@@ -109,8 +109,10 @@ TEST(WalletNodeIdentity, store_then_read_returns_the_same_pair)
auto db = (*wallet).checkoutDb();
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value());
// NOLINTBEGIN(bugprone-unchecked-optional-access): presence asserted above.
EXPECT_EQ(stored->first, minted.first);
EXPECT_TRUE(std::ranges::equal(stored->second, minted.second));
// NOLINTEND(bugprone-unchecked-optional-access)
}
TEST(WalletNodeIdentity, store_appends_rather_than_replacing)
@@ -137,6 +139,7 @@ TEST(WalletNodeIdentity, store_appends_rather_than_replacing)
// comes back -- not which one.
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access): presence asserted above.
EXPECT_TRUE(stored->first == first.first || stored->first == second.first)
<< "readNodeIdentity must return one of the two stored pairs";
}
@@ -159,8 +162,10 @@ TEST(WalletNodeIdentity, clear_then_store_installs_the_new_pair)
storeNodeIdentity(*db, replacement);
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value());
// NOLINTBEGIN(bugprone-unchecked-optional-access): presence asserted above.
EXPECT_EQ(stored->first, replacement.first);
EXPECT_TRUE(std::ranges::equal(stored->second, replacement.second));
// NOLINTEND(bugprone-unchecked-optional-access)
}
TEST(WalletNodeIdentity, get_mints_and_persists_when_the_table_is_empty)
@@ -175,6 +180,7 @@ TEST(WalletNodeIdentity, get_mints_and_persists_when_the_table_is_empty)
auto const stored = readNodeIdentity(*db);
ASSERT_TRUE(stored.has_value()) << "getNodeIdentity() must persist what it mints";
// NOLINTNEXTLINE(bugprone-unchecked-optional-access): presence asserted above.
EXPECT_EQ(stored->first, minted.first);
EXPECT_EQ(getNodeIdentity(*db).first, minted.first);
}
@@ -217,6 +223,7 @@ TEST(ParseNodeIdentitySeed, valid_cmdline_returns_that_seed)
auto const seed = parseNodeIdentitySeed(std::string{kValidSeed}, std::nullopt);
ASSERT_TRUE(seed.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access): presence asserted above.
auto const sk = generateSecretKey(KeyType::Secp256k1, *seed);
auto const pk = derivePublicKey(KeyType::Secp256k1, sk);
EXPECT_EQ(toBase58(TokenType::NodePublic, pk), std::string{kValidSeedPublic});
@@ -229,6 +236,7 @@ TEST(ParseNodeIdentitySeed, valid_config_returns_that_seed)
auto const seed = parseNodeIdentitySeed(std::nullopt, std::string{kValidSeed});
ASSERT_TRUE(seed.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access): presence asserted above.
auto const sk = generateSecretKey(KeyType::Secp256k1, *seed);
auto const pk = derivePublicKey(KeyType::Secp256k1, sk);
EXPECT_EQ(toBase58(TokenType::NodePublic, pk), std::string{kValidSeedPublic});
@@ -261,6 +269,7 @@ TEST(ParseNodeIdentitySeed, cmdline_wins_over_config)
auto const seed = parseNodeIdentitySeed(std::string{kValidSeed}, std::string{kOtherSeed});
ASSERT_TRUE(seed.has_value());
// NOLINTNEXTLINE(bugprone-unchecked-optional-access): presence asserted above.
auto const sk = generateSecretKey(KeyType::Secp256k1, *seed);
auto const pk = derivePublicKey(KeyType::Secp256k1, sk);
EXPECT_EQ(toBase58(TokenType::NodePublic, pk), std::string{kValidSeedPublic});