Merge branch 'pratik/otel-phase10-workload-validation' into pratik/otel-sync-diagnostics

Brings in the phase-10 revert of the nodestore read-latency histogram plus the
nudb_bytes -> stored_object_bytes rename.

Conflicts in MetricsRegistry.{h,cpp} resolved keeping both intents:

- MetricsRegistry.cpp: dropped everything that existed only to serve the
  reverted nodestore_read_us histogram -- the addSubMillisecondHistogramView()
  helper, its call site, the kSubMillisecondBoundaries array and the
  NodeStoreMetricNames.h include. Kept every view this branch registers
  (consensus round duration, sweep_malloc_trim_us, dns_resolve_latency_ms,
  overlay_dial_latency_ms) and the shared addHistogramView() base helper.
  Took the rename at the storage_detail observe() call site.

- MetricsRegistry.h: took phase-10's move of the four nodestore_state observe
  helpers and their ObserveFn sink from private to public, while keeping this
  branch's enriched Doxygen on observeNodeStoreTotals().

Also corrected the registered-view count in the 09 reference doc: neither side's
arithmetic survives the merge, since this branch adds four views phase-10 never
saw and the revert removes one. Ten views are registered now, not six or seven.
This commit is contained in:
Pratik Mankawde
2026-07-28 16:53:13 +01:00
22 changed files with 1079 additions and 1359 deletions

View File

@@ -98,22 +98,10 @@ if(telemetry)
HINTS "${opentelemetry-cpp_PACKAGE_FOLDER_RELEASE}/lib"
REQUIRED
)
# The metric side of the in-memory exporter is a SEPARATE archive
# (libopentelemetry_exporter_in_memory_metric.a) with the same
# no-declared-libs problem, so it needs its own find_library. The
# nodestore read-latency histogram tests use it to read exported
# histogram points back and assert per-bucket counts.
find_library(
OTEL_IN_MEMORY_METRIC_EXPORTER_LIB
NAMES opentelemetry_exporter_in_memory_metric
HINTS "${opentelemetry-cpp_PACKAGE_FOLDER_RELEASE}/lib"
REQUIRED
)
target_link_libraries(
xrpl_tests
PRIVATE
"${OTEL_IN_MEMORY_EXPORTER_LIB}"
"${OTEL_IN_MEMORY_METRIC_EXPORTER_LIB}"
opentelemetry-cpp::opentelemetry-cpp
)
# ValidationTracker lives in src/xrpld/ (not libxrpl), so we compile its

View File

@@ -204,4 +204,54 @@ INSTANTIATE_TEST_SUITE_P(
::testing::ValuesIn(backendTypes()),
[](::testing::TestParamInfo<std::string> const& info) { return info.param; });
// The std::nullopt default on the base class, exercised directly on the two
// backends that always exist in every build -- unlike rocksdb, which the
// parameterized suite above only reaches when XRPL_ROCKSDB_AVAILABLE.
//
// Why absence and not zeros: the exporter skips the whole nudb_* label group
// when getWriteStats() is empty (MetricsRegistry.cpp observeWritePathDetail
// returns early). If the base class returned a default-constructed WriteStats
// instead, every non-NuDB node would publish nudb_writers_in_flight=0 and
// nudb_insert_max_us=0 -- a perfectly idle write path, on a node whose write
// path is simply not instrumented. Each assertion below fails against that
// change.
TEST(BackendWriteStats, non_measuring_backends_report_absence_not_zeros)
{
for (auto const& type : {std::string{"memory"}, std::string{"none"}})
{
SCOPED_TRACE("type=" + type);
DummyScheduler scheduler;
beast::Journal const journal{TestSink::instance()};
beast::TempDir const tempDir;
Section params;
params.set("type", type);
params.set("path", tempDir.path());
auto backend = Manager::instance().makeBackend(params, megabytes(4), scheduler, journal);
ASSERT_TRUE(backend);
backend->open();
// Absent before any write.
EXPECT_FALSE(backend->getWriteStats().has_value());
// Still absent after real writes. Cause, not just state: the
// backend has genuinely been used, so the absence is the base-class
// default and not an unopened backend.
beast::xor_shift_engine rng(kSeedValue);
auto const batch = createPredictableBatch(16, rng());
storeBatch(*backend, batch);
EXPECT_FALSE(backend->getWriteStats().has_value());
// These backends queue nothing, so their own write load stays 0. The
// pairing matters: absent stats plus a 0 load is what tells the
// exporter "not measured", whereas present stats reading 0 would mean
// "measured, and idle".
EXPECT_EQ(backend->getWriteLoad(), 0);
backend->close();
}
}
} // namespace xrpl::node_store

View File

@@ -253,12 +253,23 @@ TEST_P(NodeStoreDatabaseTest, write_stats_forwarded_from_backend)
ASSERT_TRUE(after.has_value());
EXPECT_EQ(after->insertCount, batch_.size());
// One writer thread, so the depth recorded at each insert is exactly 1.
// This catches a depth accumulator fed the wrong quantity (insertCount's
// running value, or the elapsed microseconds), but it CANNOT catch one fed
// a constant 1, because here the real depth is 1. That case needs genuine
// overlap and is covered by NuDBFactory.cpp's
// write_stats_measure_depth_under_real_overlap.
EXPECT_EQ(after->depthSum, batch_.size());
// No writer is left in flight once the calls have returned.
EXPECT_EQ(after->concurrentWriters, 0u);
// Same live depth through the other accessor on the same object, so the
// two cannot drift onto different fields.
EXPECT_EQ(db->getWriteLoad(), 0);
EXPECT_GT(after->insertTotalUs, 0u);
// A maximum is never below the mean, which fails if the field held the
// minimum or the first sample instead of a running maximum.
// max * n >= sum. Catches a field holding the running MINIMUM, since
// min * n <= sum with equality only when every sample is identical.
// Degenerates when the samples do not vary; the unconditional guarantee
// that the field is a maximum is the non-decreasing check in
// NuDBFactory.cpp's write_stats_accumulate_per_insert.
EXPECT_GE(after->insertMaxUs * after->insertCount, after->insertTotalUs);
}

View File

@@ -4,6 +4,7 @@
#include <xrpl/config/BasicConfig.h>
#include <xrpl/nodestore/DummyScheduler.h>
#include <xrpl/nodestore/Manager.h>
#include <xrpl/nodestore/WriteStats.h>
#include <gtest/gtest.h>
#include <helpers/CaptureSink.h>
@@ -14,7 +15,9 @@
#include <cstddef>
#include <cstdint>
#include <exception>
#include <latch>
#include <memory>
#include <optional>
#include <string>
#include <thread>
#include <utility>
@@ -56,6 +59,48 @@ runRoundTrip(Section const& params, std::size_t expectedBlocksize)
EXPECT_EQ(batch, copy);
}
/**
* Threads used by the overlapping-insert round below.
*/
constexpr std::uint64_t kOverlapThreads = 8;
/**
* Inserts each of those threads performs per round.
*/
constexpr std::uint64_t kOverlapPerThread = 50;
/**
* Run one round of deliberately overlapping inserts against @p backend.
*
* All kOverlapThreads threads are released from a single latch, so they reach
* doInsert() together rather than one after another; staggered starts are what
* would let every insert run end to end and never overlap.
*
* @param backend Backend to insert into. Must be open.
* @param round Round index, mixed into the seeds so every round writes
* fresh keys and no insert takes the duplicate short-circuit.
*/
void
runOverlappingInsertRound(Backend& backend, int round)
{
std::latch start(static_cast<std::ptrdiff_t>(kOverlapThreads));
std::vector<std::thread> threads;
threads.reserve(kOverlapThreads);
for (auto t = 0uz; t < kOverlapThreads; ++t)
{
threads.emplace_back([&backend, &start, t, round] {
auto const batch = createPredictableBatch(
kOverlapPerThread, 1000 + t + (static_cast<std::uint64_t>(round) * 100'000));
start.arrive_and_wait();
for (auto const& obj : batch)
backend.store(obj);
});
}
for (auto& th : threads)
th.join();
}
} // namespace
TEST(NuDBFactory, default_block_size)
@@ -290,6 +335,14 @@ TEST(NuDBFactory, write_stats_accumulate_per_insert)
// Exactly 10 inserts must be counted as 10, and depthSum must be 10
// because a single-threaded caller is always the only writer, so the
// depth recorded at each insert is exactly 1.
//
// What this pins and what it cannot: on one thread depthSum == insertCount
// catches an accumulator fed the wrong quantity -- fed insertCount it
// would read 1+2+...+n, and fed the elapsed time it would read the
// microseconds. It does NOT catch depthSum being fed a constant 1, which
// is indistinguishable here because the real depth IS 1. That bug is the
// one that would silently zero every derived wait time, and it is caught
// by write_stats_measure_depth_under_real_overlap below.
constexpr std::uint64_t kFirstBatch = 10;
auto const batch = createPredictableBatch(kFirstBatch, 12345);
storeBatch(*backend, batch);
@@ -301,11 +354,12 @@ TEST(NuDBFactory, write_stats_accumulate_per_insert)
EXPECT_EQ(after->depthSum, kFirstBatch);
EXPECT_GT(after->insertTotalUs, 0u);
EXPECT_GT(after->insertMaxUs, 0u);
// The largest single insert cannot exceed the sum of all of them.
EXPECT_LE(after->insertMaxUs, after->insertTotalUs);
// A maximum is never below the mean. This fails if the field were
// holding the minimum, or the first or last sample, instead of the
// running maximum.
// A maximum is never below the mean, so max * n >= sum. Catches a field
// fed the running MINIMUM: min * n <= sum, with equality only when every
// sample is identical, so any variation at all makes the two orderings
// exclusive. It does not discriminate when the samples happen not to
// vary; the running-maximum property is pinned unconditionally by the
// non-decreasing check after the second batch below.
EXPECT_GE(after->insertMaxUs * after->insertCount, after->insertTotalUs);
// No writer remains in flight once the calls have returned.
EXPECT_EQ(after->concurrentWriters, 0u);
@@ -334,6 +388,22 @@ TEST(NuDBFactory, write_stats_accumulate_per_insert)
// Negative path: NuDB reports key_exists for a duplicate key and doInsert
// deliberately does not treat that as an error. The accounting must still
// run, and in particular the depth must come back down.
//
// What this does NOT cover, stated plainly because a comment claiming absent
// coverage is worse than none: this is not the throwing path. nudb::insert()
// sets error::key_exists and RETURNS (nudb/impl/basic_store.ipp:294, :307,
// :329), and doInsert() filters exactly that code out before it would throw
// (NuDBFactory.cpp:283), so a duplicate key takes the identical non-throwing
// control flow as a fresh key. It reaches the ScopeExit guard by the same
// route the happy path does.
//
// The throwing path -- where the guard is the only reason the depth comes
// back down -- is not reachable from a unit test: it needs nudb::insert() to
// fail with something other than key_exists (an I/O or allocation failure
// inside the library), which cannot be induced through the Backend interface
// without a fault-injection seam that does not exist. Its RAII contract is
// covered generically instead: src/tests/libxrpl/basics/scope.cpp:34-45 proves
// ScopeExit runs its function during unwinding.
TEST(NuDBFactory, write_stats_count_duplicate_key_inserts)
{
beast::TempDir const tempDir;
@@ -353,6 +423,7 @@ TEST(NuDBFactory, write_stats_count_duplicate_key_inserts)
if (!first.has_value())
FAIL() << "nudb must report write stats";
ASSERT_EQ(first->insertCount, kBatchSize);
ASSERT_EQ(first->depthSum, kBatchSize);
// Re-storing the identical batch writes nothing new, but each call is
// still an insert attempt that entered and left the backend.
@@ -361,16 +432,40 @@ TEST(NuDBFactory, write_stats_count_duplicate_key_inserts)
auto const second = backend->getWriteStats();
if (!second.has_value())
FAIL() << "nudb must report write stats after re-storing";
EXPECT_EQ(second->insertCount, kBatchSize * 2);
EXPECT_EQ(second->depthSum, kBatchSize * 2);
// The depth returned to zero, so the early-return error path did not
// The duplicate round is counted, so a key_exists early return is not
// skipping the accounting. Written as the first snapshot plus the batch
// size rather than as one product, because the two sides must differ by
// exactly the second round: an implementation that counted only the
// rounds that stored new data would leave these equal.
EXPECT_EQ(second->insertCount, first->insertCount + kBatchSize);
EXPECT_EQ(second->depthSum, first->depthSum + kBatchSize);
// The depth returned to zero, so the key_exists early return did not
// leak a writer.
EXPECT_EQ(second->concurrentWriters, 0u);
EXPECT_EQ(backend->getWriteLoad(), 0);
backend->close();
}
TEST(NuDBFactory, write_stats_observe_concurrent_writers)
// depthSum is the L in Little's Law: mean depth L and mean insert time W give
// service time S = W / L, and the queuing time the whole diagnosis rests on is
// W - S. If depthSum were fed a constant 1 instead of the observed depth then
// L would read exactly 1.0, S would equal W, and every derived wait would read
// 0 -- a stalled write path indistinguishable from a healthy one, with nothing
// on any dashboard looking wrong.
//
// A single-threaded test cannot see that bug, because there the real depth IS
// 1. This test forces genuine overlap so the correct implementation records a
// depth above 1 and the constant-1 implementation cannot.
//
// Why the overlap is reachable and not merely hoped for: NuDB takes one global
// mutex for the entire insert, and doInsert() reads the depth BEFORE entering
// it. So while one thread is inside an insert, every other thread that reaches
// doInsert() records a depth of at least 2 and then blocks. All threads are
// released from one latch, and the round is retried until the overlap is
// observed -- so a constant-1 implementation exhausts every round and fails,
// while the real one satisfies it as soon as any two inserts overlap.
TEST(NuDBFactory, write_stats_measure_depth_under_real_overlap)
{
beast::TempDir const tempDir;
auto const params = makeSection(tempDir.path());
@@ -381,36 +476,57 @@ TEST(NuDBFactory, write_stats_observe_concurrent_writers)
ASSERT_TRUE(backend);
backend->open();
// Four threads insert distinct objects concurrently. The exact peak
// depth is racy, but two invariants are not: every insert is counted,
// and depthSum is at least insertCount because depth is >= 1 per
// insert.
constexpr std::uint64_t kThreads = 4;
constexpr std::uint64_t kPerThread = 50;
// Bounded so a genuine regression fails instead of hanging. Each round
// runs kOverlapThreads * kOverlapPerThread inserts through one global
// mutex, so one round already gives the correct implementation many
// chances to overlap.
constexpr int kMaxRounds = 20;
std::vector<std::thread> threads;
threads.reserve(kThreads);
for (auto t = 0uz; t < kThreads; ++t)
std::uint64_t completedRounds = 0;
std::optional<WriteStats> stats;
for (auto round = 0; round < kMaxRounds; ++round)
{
threads.emplace_back([&backend, t] {
auto const batch = createPredictableBatch(kPerThread, 1000 + t);
for (auto const& obj : batch)
backend->store(obj);
});
}
for (auto& th : threads)
th.join();
runOverlappingInsertRound(*backend, round);
++completedRounds;
stats = backend->getWriteStats();
if (!stats.has_value())
FAIL() << "nudb must report write stats after concurrent inserts";
if (stats->depthSum > stats->insertCount)
break;
}
auto const stats = backend->getWriteStats();
if (!stats.has_value())
FAIL() << "nudb must report write stats after concurrent inserts";
EXPECT_EQ(stats->insertCount, kThreads * kPerThread);
EXPECT_GE(stats->depthSum, stats->insertCount);
FAIL() << "no round produced write stats";
// Every insert of every round is counted exactly once. A lost increment
// under contention fails this.
EXPECT_EQ(stats->insertCount, completedRounds * kOverlapThreads * kOverlapPerThread);
// THE assertion this test exists for: strictly greater, so a depthSum fed
// a constant 1 (or fed nothing, or fed insertCount's own delta) cannot
// satisfy it however many rounds run.
EXPECT_GT(stats->depthSum, stats->insertCount)
<< "depthSum must record the observed depth, not a constant 1; rounds run="
<< completedRounds;
// Upper bound with teeth: at most kThreads writers can be inside an
// insert at once, so no single insert can observe a depth above kThreads.
// A missing fetch_sub in recordInsert() would let the gauge climb once
// per insert, giving a depthSum near insertCount squared over two --
// vastly over this bound at these counts.
EXPECT_LE(stats->depthSum, stats->insertCount * kOverlapThreads);
// State plus cause: the gauge is back to exactly zero, so every one of
// the increments taken above was matched by its decrement. Exactly 0 and
// not "small": a single leaked writer strands getWriteLoad() nonzero for
// the life of the process, which gates history acquisition.
EXPECT_EQ(stats->concurrentWriters, 0u);
EXPECT_EQ(backend->getWriteLoad(), 0);
EXPECT_GT(stats->insertMaxUs, 0u);
// Depth cannot exceed the number of threads that could be inside the
// insert at once, so the mean depth is bounded by kThreads.
EXPECT_LE(stats->depthSum, stats->insertCount * kThreads);
backend->close();
}
@@ -431,15 +547,29 @@ TEST(NuDBFactory, write_load_reports_writer_depth)
// After writes complete the depth returns to 0 rather than staying
// elevated, because this is an instantaneous gauge and not a counter.
// Exactly 0 and not merely small: were getWriteLoad() to return one of
// the cumulative fields instead of the live depth -- insertCount would
// read 5 here, insertTotalUs some microsecond total -- this fails.
auto const batch = createPredictableBatch(5, 777);
storeBatch(*backend, batch);
EXPECT_EQ(backend->getWriteLoad(), 0);
// The value must stay far below the history-acquisition cutoff that
// LedgerMaster applies (kMaxWriteLoadAcquire), or history acquisition
// would silently stop. Depth is bounded by the writing threads.
constexpr int kMaxWriteLoadAcquire = 8192;
EXPECT_LT(backend->getWriteLoad(), kMaxWriteLoadAcquire);
// Same value as the write-stats snapshot reports, since both read the one
// depth atomic. Catches the two accessors drifting onto different fields.
auto const stats = backend->getWriteStats();
if (!stats.has_value())
FAIL() << "nudb must report write stats";
EXPECT_EQ(static_cast<std::uint64_t>(backend->getWriteLoad()), stats->concurrentWriters);
// The cumulative fields did move, so the 0 above is the gauge being
// instantaneous and not the backend having done nothing.
EXPECT_EQ(stats->insertCount, 5u);
// NOTE. LedgerMaster gates history acquisition on getWriteLoad() staying
// below kMaxWriteLoadAcquire (8192), declared static constexpr inside
// src/xrpld/app/ledger/detail/LedgerMaster.cpp and so unreachable from
// this binary. Depth is bounded by the number of writing threads, which
// cannot approach that figure, so the coupling is recorded here rather
// than asserted against a literal copy of the constant that could drift.
backend->close();
}

View File

@@ -446,7 +446,8 @@ TEST(MetricsRegistryScaledMean, default_scale_is_one)
{
// The two-argument form is the latency case and must not scale silently;
// if the default were 100 every published latency would be 100x wrong.
EXPECT_EQ(Registry::scaledMean(360, 8), Registry::scaledMean(360, 8, 1));
// 360/8 is 45 by hand -- an independent literal, not a restatement of the
// implementation. A default of 100 would read 4500 here.
EXPECT_EQ(Registry::scaledMean(360, 8), 45);
}

View File

@@ -1,526 +0,0 @@
/**
* @file NodeStoreMetricNames.cpp
* Unit tests for the nodestore read-latency histogram wiring.
*
* Two independent groups, split by what they can link:
*
* 1. The shared name/label constants and the three record-site helpers from
* `<xrpl/telemetry/NodeStoreMetricNames.h>`. Header-only and free of any
* OTel dependency, so these run in **both** builds. That matters: the
* constants are what keeps the bucket-view registration in
* MetricsRegistry.cpp and the record site in NodeStoreScheduler.cpp
* agreeing on one instrument name, and a divergence there silently drops
* the sub-millisecond bucket override.
*
* 2. An end-to-end record-and-read-back over a real SDK MeterProvider fitted
* with the same explicit sub-millisecond boundaries production registers,
* asserting the exact bucket counts a set of known latencies must land in.
* Guarded on XRPL_ENABLE_TELEMETRY because the metrics SDK headers only
* exist in that build.
*
* Why the second group does not drive NodeStoreScheduler directly: that class
* lives in xrpld (`src/xrpld/app/main/`) and its onFetch() needs a live
* JobQueue plus a ServiceRegistry, neither of which the standalone xrpl_tests
* binary can supply -- the same reason MetricsRegistry.cpp is only compiled
* into this binary on the no-op path (see src/tests/libxrpl/CMakeLists.txt).
* What is testable here is everything that decides *what* gets recorded: the
* instrument name, the two label values, the negative-value guard, and the
* bucket ladder the value is filed into. The remaining step -- that
* Database::fetchNodeObject actually reaches onFetch -- is covered by the
* existing nodestore suites, which already exercise that call path.
*/
#include <xrpl/telemetry/NodeStoreMetricNames.h>
#include <gtest/gtest.h>
#include <string_view>
namespace {
using namespace xrpl::telemetry;
// ---------------------------------------------------------------------------
// Group 1: shared constants and record-site helpers. Compile-time first, so a
// regression is a build failure rather than only a test failure; the runtime
// duplicates below name the offending case when one does fail.
// ---------------------------------------------------------------------------
// The instrument name is the contract between the two call sites. Pinned to
// the exact literal: bare lower snake_case, no `xrpld_` prefix (no metric in
// this codebase carries one), and the `_us` suffix stating the unit.
static_assert(std::string_view{kNodeStoreReadUs} == "nodestore_read_us");
// Label keys. `found` rather than `was_found` and `fetch_type` rather than
// `type`, matching what the dashboards query.
static_assert(std::string_view{kFetchTypeLabel} == "fetch_type");
static_assert(std::string_view{kFetchFoundLabel} == "found");
// Label values.
static_assert(std::string_view{kFetchTypeAsync} == "async");
static_assert(std::string_view{kFetchTypeSync} == "sync");
static_assert(std::string_view{kFetchFoundTrue} == "true");
static_assert(std::string_view{kFetchFoundFalse} == "false");
// The helpers map each input to exactly one value, and the two arms differ.
static_assert(std::string_view{fetchTypeLabelValue(true)} == "async");
static_assert(std::string_view{fetchTypeLabelValue(false)} == "sync");
static_assert(std::string_view{fetchFoundLabelValue(true)} == "true");
static_assert(std::string_view{fetchFoundLabelValue(false)} == "false");
// The negative guard. Zero is admitted on purpose -- a page-cache hit really
// can round to 0 us -- while anything below it is refused.
static_assert(shouldRecordFetchLatency(0));
static_assert(shouldRecordFetchLatency(1));
static_assert(shouldRecordFetchLatency(25'000));
static_assert(!shouldRecordFetchLatency(-1));
} // namespace
TEST(NodeStoreMetricNames, instrument_name_is_the_exact_shared_literal)
{
// Both the view registration (MetricsRegistry.cpp) and the record site
// (NodeStoreScheduler.cpp) read this one constant. If it changes, the
// dashboard query and the reference doc must change with it, so the exact
// string is asserted rather than merely its shape.
EXPECT_EQ(std::string_view{kNodeStoreReadUs}, "nodestore_read_us");
EXPECT_EQ(std::string_view{kNodeStoreReadUs}.size(), 17u);
// No `xrpld_` prefix: verified against the live metric surface, where 0 of
// 537 exported names carry one. A prefix here would make this the only
// odd metric out and break every dashboard that globs the family.
EXPECT_FALSE(std::string_view{kNodeStoreReadUs}.starts_with("xrpld_"));
// The `_us` suffix is load-bearing: FetchReport::elapsed is
// std::chrono::microseconds, and a name implying milliseconds would make
// every reading 1000x wrong to a reader.
EXPECT_TRUE(std::string_view{kNodeStoreReadUs}.ends_with("_us"));
// The description must name the unit too, since that is all a Prometheus
// consumer sees alongside the metric.
EXPECT_EQ(
std::string_view{kNodeStoreReadUsDesc}, "NodeStore backend fetch latency in microseconds");
}
TEST(NodeStoreMetricNames, label_keys_and_values_are_the_exact_literals)
{
EXPECT_EQ(std::string_view{kFetchTypeLabel}, "fetch_type");
EXPECT_EQ(std::string_view{kFetchFoundLabel}, "found");
EXPECT_EQ(std::string_view{kFetchTypeAsync}, "async");
EXPECT_EQ(std::string_view{kFetchTypeSync}, "sync");
EXPECT_EQ(std::string_view{kFetchFoundTrue}, "true");
EXPECT_EQ(std::string_view{kFetchFoundFalse}, "false");
// The two keys must differ, or one label would overwrite the other in the
// attribute map and a whole dimension would vanish.
EXPECT_NE(std::string_view{kFetchTypeLabel}, std::string_view{kFetchFoundLabel});
}
TEST(NodeStoreMetricNames, helpers_map_each_input_to_its_own_value)
{
// Positive path for both arms of both helpers.
EXPECT_EQ(std::string_view{fetchTypeLabelValue(true)}, "async");
EXPECT_EQ(std::string_view{fetchTypeLabelValue(false)}, "sync");
EXPECT_EQ(std::string_view{fetchFoundLabelValue(true)}, "true");
EXPECT_EQ(std::string_view{fetchFoundLabelValue(false)}, "false");
// Cause, not just state: the two arms are genuinely distinct, so a
// copy-paste that returned the same value for both would fail here rather
// than quietly collapsing async and sync into one series.
EXPECT_NE(
std::string_view{fetchTypeLabelValue(true)}, std::string_view{fetchTypeLabelValue(false)});
EXPECT_NE(
std::string_view{fetchFoundLabelValue(true)},
std::string_view{fetchFoundLabelValue(false)});
// Each helper returns one of its own two constants and never the other
// helper's, which is what keeps the two dimensions independent.
EXPECT_EQ(fetchTypeLabelValue(true), kFetchTypeAsync);
EXPECT_EQ(fetchTypeLabelValue(false), kFetchTypeSync);
EXPECT_EQ(fetchFoundLabelValue(true), kFetchFoundTrue);
EXPECT_EQ(fetchFoundLabelValue(false), kFetchFoundFalse);
}
TEST(NodeStoreMetricNames, latency_guard_admits_zero_and_refuses_negatives)
{
// Negative path -- the reason the guard exists. The OTel SDK drops a
// negative histogram value AND logs a warning for it; on a per-fetch path
// that is a log flood, so the sample is filtered before it gets there.
EXPECT_FALSE(shouldRecordFetchLatency(-1));
EXPECT_FALSE(shouldRecordFetchLatency(-1'000));
// Zero must NOT be filtered: a read served from the page cache genuinely
// truncates to 0 us, and suppressing it would hide the fastest reads and
// bias the whole distribution upward.
EXPECT_TRUE(shouldRecordFetchLatency(0));
// Ordinary and cold-tail values pass.
EXPECT_TRUE(shouldRecordFetchLatency(1));
EXPECT_TRUE(shouldRecordFetchLatency(9));
EXPECT_TRUE(shouldRecordFetchLatency(250));
EXPECT_TRUE(shouldRecordFetchLatency(30'000));
// The boundary is exactly at zero, not near it.
EXPECT_TRUE(shouldRecordFetchLatency(0));
EXPECT_FALSE(shouldRecordFetchLatency(-1));
}
// ---------------------------------------------------------------------------
// Group 2: record into a real histogram carrying production's explicit
// sub-millisecond boundaries, then read the exported point back and assert the
// exact per-bucket counts.
//
// This is what proves the signal is usable rather than merely emitted. The
// SDK's default boundaries begin at 0/5/10/25... but top out at 10,000, and
// the microsecond ladder used by the other duration histograms begins at 100
// us -- above the entire range a warm read occupies. Under that ladder every
// warm read files into bucket 0 and the distribution reads flat. The
// assertions below pin warm reads into distinct low buckets, which is exactly
// the property the sub-millisecond ladder exists to provide.
// ---------------------------------------------------------------------------
#ifdef XRPL_ENABLE_TELEMETRY
#include <opentelemetry/context/context.h>
#include <opentelemetry/exporters/memory/in_memory_metric_data.h>
#include <opentelemetry/exporters/memory/in_memory_metric_exporter_factory.h>
#include <opentelemetry/metrics/meter.h>
#include <opentelemetry/nostd/unique_ptr.h>
#include <opentelemetry/nostd/variant.h>
#include <opentelemetry/sdk/metrics/aggregation/aggregation_config.h>
#include <opentelemetry/sdk/metrics/data/point_data.h>
#include <opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_factory.h>
#include <opentelemetry/sdk/metrics/export/periodic_exporting_metric_reader_options.h>
#include <opentelemetry/sdk/metrics/instruments.h>
#include <opentelemetry/sdk/metrics/meter_provider.h>
#include <opentelemetry/sdk/metrics/meter_provider_factory.h>
#include <opentelemetry/sdk/metrics/view/instrument_selector_factory.h>
#include <opentelemetry/sdk/metrics/view/meter_selector_factory.h>
#include <opentelemetry/sdk/metrics/view/view_factory.h>
#include <opentelemetry/sdk/metrics/view/view_registry.h>
#include <array>
#include <chrono>
#include <cstddef>
#include <memory>
#include <string>
#include <utility>
#include <vector>
namespace {
namespace metric_sdk = opentelemetry::sdk::metrics;
namespace in_memory = opentelemetry::exporter::memory;
/**
* The same edges MetricsRegistry.cpp's kSubMillisecondBoundaries holds.
*
* Deliberately a second, independent copy rather than an include of the
* production array: that constant lives in an unnamed namespace inside
* MetricsRegistry.cpp and is unreachable from here, and re-deriving the edges
* from the implementation would make the bucket-index assertions below
* tautological. Written out by hand, they pin the ladder -- so silently
* re-tuning an edge in production without revisiting this file fails here.
*/
constexpr std::array kExpectedBoundaries{
1.0,
2.0,
5.0,
10.0,
25.0,
50.0,
100.0,
250.0,
500.0,
1'000.0,
5'000.0,
25'000.0};
/**
* Meter identity used by the production view selector, so the view this
* fixture registers matches the instrument the fixture creates.
*/
constexpr char kMeterName[] = "xrpld";
constexpr char kMeterVersion[] = "1.0.0";
/**
* A MeterProvider carrying one explicit-bucket view for kNodeStoreReadUs and
* an in-memory exporter, so a test can record values and read the resulting
* histogram point back without any network or OTLP involvement.
*
* Mirrors what MetricsRegistry::initExporterAndProvider() builds for this
* instrument, minus the OTLP exporter.
*/
class HistogramFixture
{
public:
HistogramFixture()
{
// The view: same instrument type, same name, same meter selector and
// the same boundaries production registers.
auto config = std::make_shared<metric_sdk::HistogramAggregationConfig>();
config->boundaries_ = {kExpectedBoundaries.begin(), kExpectedBoundaries.end()};
auto views = std::make_unique<metric_sdk::ViewRegistry>();
views->AddView(
metric_sdk::InstrumentSelectorFactory::Create(
metric_sdk::InstrumentType::kHistogram, kNodeStoreReadUs, ""),
metric_sdk::MeterSelectorFactory::Create(kMeterName, kMeterVersion, ""),
metric_sdk::ViewFactory::Create(
kNodeStoreReadUs, "", metric_sdk::AggregationType::kHistogram, config));
provider_ = metric_sdk::MeterProviderFactory::Create(std::move(views));
// A long export interval keeps the background thread from exporting
// on its own schedule; the test drives collection via ForceFlush().
metric_sdk::PeriodicExportingMetricReaderOptions readerOpts;
readerOpts.export_interval_millis = std::chrono::milliseconds(600'000);
readerOpts.export_timeout_millis = std::chrono::milliseconds(5'000);
provider_->AddMetricReader(
metric_sdk::PeriodicExportingMetricReaderFactory::Create(
in_memory::InMemoryMetricExporterFactory::Create(data_), readerOpts));
histogram_ = provider_->GetMeter(kMeterName, kMeterVersion)
->CreateDoubleHistogram(kNodeStoreReadUs, kNodeStoreReadUsDesc);
}
~HistogramFixture()
{
provider_->Shutdown();
}
HistogramFixture(HistogramFixture const&) = delete;
HistogramFixture&
operator=(HistogramFixture const&) = delete;
/**
* Record one latency with the exact label set the production record site
* attaches, built through the same two helpers.
*
* @param elapsedUs Latency in microseconds.
* @param isAsync True for an async (prefetch) read.
* @param wasFound True when the object was found.
*/
void
record(double elapsedUs, bool isAsync, bool wasFound)
{
histogram_->Record(
elapsedUs,
{{kFetchTypeLabel, std::string(fetchTypeLabelValue(isAsync))},
{kFetchFoundLabel, std::string(fetchFoundLabelValue(wasFound))}},
opentelemetry::context::Context{});
}
/**
* Flush the reader, then return every exported histogram point for
* kNodeStoreReadUs keyed by its attribute set.
*/
[[nodiscard]] in_memory::SimpleAggregateInMemoryMetricData::AttributeToPoint const&
collect()
{
provider_->ForceFlush();
return data_->Get(kMeterName, kNodeStoreReadUs);
}
private:
/**
* Sink the in-memory exporter writes each collection into.
*/
std::shared_ptr<in_memory::SimpleAggregateInMemoryMetricData> data_ =
std::make_shared<in_memory::SimpleAggregateInMemoryMetricData>();
/**
* Provider owning the view registry and the in-memory reader.
*/
std::shared_ptr<metric_sdk::MeterProvider> provider_;
/**
* The instrument under test.
*/
opentelemetry::nostd::unique_ptr<opentelemetry::metrics::Histogram<double>> histogram_;
};
/**
* Extract the HistogramPointData for the one series matching @p isAsync and
* @p wasFound, or nullptr when no such series was exported.
*
* @param points Exported points keyed by attribute set.
* @param isAsync fetch_type dimension to match.
* @param wasFound found dimension to match.
*/
[[nodiscard]] metric_sdk::HistogramPointData const*
findPoint(
in_memory::SimpleAggregateInMemoryMetricData::AttributeToPoint const& points,
bool isAsync,
bool wasFound)
{
for (auto const& [attributes, point] : points)
{
auto const type = attributes.find(kFetchTypeLabel);
auto const found = attributes.find(kFetchFoundLabel);
if (type == attributes.end() || found == attributes.end())
continue;
if (opentelemetry::nostd::get<std::string>(type->second) != fetchTypeLabelValue(isAsync) ||
opentelemetry::nostd::get<std::string>(found->second) != fetchFoundLabelValue(wasFound))
continue;
return &opentelemetry::nostd::get<metric_sdk::HistogramPointData>(point);
}
return nullptr;
}
} // namespace
TEST(NodeStoreReadHistogram, view_applies_the_sub_millisecond_boundaries)
{
HistogramFixture fixture;
fixture.record(9.0, /*isAsync=*/false, /*wasFound=*/true);
auto const* point = findPoint(fixture.collect(), /*isAsync=*/false, /*wasFound=*/true);
ASSERT_NE(point, nullptr);
// The exported point must carry OUR boundaries, not the SDK defaults. This
// is the assertion that catches a name mismatch between the view selector
// and the instrument: on a mismatch the view is never applied and the
// default ladder appears here instead.
ASSERT_EQ(point->boundaries_.size(), kExpectedBoundaries.size());
for (std::size_t i = 0; i < kExpectedBoundaries.size(); ++i)
EXPECT_EQ(point->boundaries_[i], kExpectedBoundaries[i]) << "boundary index " << i;
// The first edge is 1 us, three orders of magnitude below the microsecond
// ladder's 100 us first edge. That difference is the entire point: without
// it a warm read cannot be distinguished from an instant one.
EXPECT_EQ(point->boundaries_.front(), 1.0);
EXPECT_EQ(point->boundaries_.back(), 25'000.0);
}
TEST(NodeStoreReadHistogram, warm_reads_land_in_distinct_low_buckets)
{
HistogramFixture fixture;
// Four warm latencies, each chosen to fall in a different low bucket.
// Bucket i counts values in (boundaries[i-1], boundaries[i]].
// 0.5 us -> bucket 0 ( <= 1 )
// 3 us -> bucket 2 ( 2 < v <= 5 )
// 9 us -> bucket 3 ( 5 < v <= 10 )
// 40 us -> bucket 5 ( 25 < v <= 50 )
fixture.record(0.5, /*isAsync=*/false, /*wasFound=*/true);
fixture.record(3.0, /*isAsync=*/false, /*wasFound=*/true);
fixture.record(9.0, /*isAsync=*/false, /*wasFound=*/true);
fixture.record(40.0, /*isAsync=*/false, /*wasFound=*/true);
auto const* point = findPoint(fixture.collect(), /*isAsync=*/false, /*wasFound=*/true);
ASSERT_NE(point, nullptr);
// Exact counts, bucket by bucket -- not merely "the total is 4". Under the
// microsecond ladder all four would sit in bucket 0 and this test would
// fail, which is precisely the regression it guards against.
ASSERT_EQ(point->counts_.size(), kExpectedBoundaries.size() + 1);
EXPECT_EQ(point->counts_[0], 1u); // 0.5 us
EXPECT_EQ(point->counts_[1], 0u);
EXPECT_EQ(point->counts_[2], 1u); // 3 us
EXPECT_EQ(point->counts_[3], 1u); // 9 us
EXPECT_EQ(point->counts_[4], 0u);
EXPECT_EQ(point->counts_[5], 1u); // 40 us
for (std::size_t i = 6; i < point->counts_.size(); ++i)
EXPECT_EQ(point->counts_[i], 0u) << "bucket " << i << " should be empty";
// Aggregate state must agree with the per-bucket detail.
EXPECT_EQ(point->count_, 4u);
EXPECT_EQ(opentelemetry::nostd::get<double>(point->sum_), 52.5);
EXPECT_EQ(opentelemetry::nostd::get<double>(point->min_), 0.5);
EXPECT_EQ(opentelemetry::nostd::get<double>(point->max_), 40.0);
}
TEST(NodeStoreReadHistogram, a_cold_read_lands_in_the_tail_not_the_ceiling)
{
HistogramFixture fixture;
// A cold read and an outlier beyond the top edge. 800 us falls in bucket 9
// ( 500 < v <= 1000 ); 30000 us exceeds the 25000 top edge and so lands in
// the overflow bucket, index 12.
fixture.record(800.0, /*isAsync=*/false, /*wasFound=*/true);
fixture.record(30'000.0, /*isAsync=*/false, /*wasFound=*/true);
auto const* point = findPoint(fixture.collect(), /*isAsync=*/false, /*wasFound=*/true);
ASSERT_NE(point, nullptr);
EXPECT_EQ(point->counts_[9], 1u); // 800 us -- resolved, not saturated
EXPECT_EQ(point->counts_[12], 1u); // 30 ms -- overflow bucket
EXPECT_EQ(point->count_, 2u);
EXPECT_EQ(opentelemetry::nostd::get<double>(point->max_), 30'000.0);
}
TEST(NodeStoreReadHistogram, the_two_labels_split_the_series_four_ways)
{
HistogramFixture fixture;
// One record per (fetch_type, found) combination, each with a distinct
// latency so the series cannot be confused with one another.
fixture.record(3.0, /*isAsync=*/true, /*wasFound=*/true);
fixture.record(9.0, /*isAsync=*/true, /*wasFound=*/false);
fixture.record(40.0, /*isAsync=*/false, /*wasFound=*/true);
fixture.record(800.0, /*isAsync=*/false, /*wasFound=*/false);
auto const& points = fixture.collect();
// Four distinct label sets means four distinct time series. If either
// label were dropped or misspelled these would collapse into fewer.
EXPECT_EQ(points.size(), 4u);
struct Expected
{
bool isAsync;
bool wasFound;
double value;
std::size_t bucket;
};
// Each series holds exactly its own one sample, in its own bucket. This is
// the assertion that a label mix-up would break: swapping two values would
// put a sample in the wrong series and fail here.
for (auto const& [isAsync, wasFound, value, bucket] : std::array<Expected, 4>{
{{.isAsync = true, .wasFound = true, .value = 3.0, .bucket = 2},
{.isAsync = true, .wasFound = false, .value = 9.0, .bucket = 3},
{.isAsync = false, .wasFound = true, .value = 40.0, .bucket = 5},
{.isAsync = false, .wasFound = false, .value = 800.0, .bucket = 9}}})
{
auto const* point = findPoint(points, isAsync, wasFound);
ASSERT_NE(point, nullptr) << "missing series for fetch_type="
<< fetchTypeLabelValue(isAsync)
<< " found=" << fetchFoundLabelValue(wasFound);
EXPECT_EQ(point->count_, 1u);
EXPECT_EQ(opentelemetry::nostd::get<double>(point->sum_), value);
EXPECT_EQ(point->counts_[bucket], 1u);
}
}
TEST(NodeStoreReadHistogram, a_guarded_negative_latency_never_reaches_the_instrument)
{
HistogramFixture fixture;
// Negative path, end to end: the record site consults
// shouldRecordFetchLatency() before calling Record(), so a clock anomaly
// produces no sample at all. Reproduced here with the same guard.
for (double const elapsedUs : {-1.0, -1'000.0})
{
if (shouldRecordFetchLatency(static_cast<long long>(elapsedUs)))
fixture.record(elapsedUs, /*isAsync=*/false, /*wasFound=*/true);
}
// No series at all: nothing was recorded, so the instrument exported
// nothing rather than exporting a zero-count point.
EXPECT_TRUE(fixture.collect().empty());
// Cause, not just state: the same fixture DOES accept a valid sample, so
// the emptiness above is the guard working and not a broken fixture.
fixture.record(9.0, /*isAsync=*/false, /*wasFound=*/true);
auto const* point = findPoint(fixture.collect(), /*isAsync=*/false, /*wasFound=*/true);
ASSERT_NE(point, nullptr);
EXPECT_EQ(point->count_, 1u);
EXPECT_EQ(point->counts_[3], 1u);
}
#endif // XRPL_ENABLE_TELEMETRY