Files
rippled/src/tests/libxrpl/telemetry/SpanGuardScope.cpp
Pratik Mankawde 9e6dd645c7 style: Rename gtest test cases to snake_case
develop's new fix-gtest-names pre-commit hook requires a snake_case test-case
name. CI runs it with --all-files, so the existing CamelCase names fail the
check on every branch that carries these files.
2026-09-23 19:05:21 +01:00

1138 lines
46 KiB
C++

// Tests for SpanGuard, ScopedSpanGuard and DeterministicIdGenerator.
//
// These verify the span-guard split and the deterministic-root fix at the
// trace level using an in-memory span exporter:
// - SpanGuard is unscoped and thread-free: it owns only the span, never the
// OTel thread-local context stack, so it may be moved to and ended on any
// thread. SpanGuard::freshRoot() starts a brand-new trace root, ignoring
// the ambient active span.
// - ScopedSpanGuard is scoped and store-bound: it also pushes an OTel Scope
// so the span is the ambient parent on the constructing context store (the
// thread's own store, or a coroutine's store). Its `operator SpanGuard() &&`
// pops that Scope eagerly on the origin store and yields a thread-free
// SpanGuard; destroying it while a different store is active trips an
// owner-store assertion.
// - DeterministicIdGenerator (installed by the test TracerProvider) mints a
// caller-pinned trace_id for a forced-root span. PendingTraceId pins the id
// for one root span; an ambient child under a live parent never adopts it.
// - addEvent records the event name and every attribute onto the exported
// span. The attribute overload copies each pair into an OTel
// key-value-iterable, so the values are read back off the exported SpanData
// rather than trusted.
//
// The whole file is telemetry-only: when XRPL_ENABLE_TELEMETRY is not defined
// SpanGuard is a no-op stub and the OpenTelemetry SDK headers are unavailable,
// so the translation unit compiles empty.
#ifdef XRPL_ENABLE_TELEMETRY
#include <xrpl/basics/LocalValue.h>
#include <xrpl/basics/scope.h>
#include <xrpl/consensus/ConsensusSpanNames.h>
#include <xrpl/telemetry/CoroAwareContextStorage.h>
#include <xrpl/telemetry/DeterministicIdGenerator.h>
#include <xrpl/telemetry/SpanGuard.h>
#include <xrpl/telemetry/SpanNames.h>
#include <xrpl/telemetry/Telemetry.h>
#include <gtest/gtest.h>
#include <opentelemetry/context/context.h>
#include <opentelemetry/context/runtime_context.h>
#include <opentelemetry/exporters/memory/in_memory_span_data.h>
#include <opentelemetry/exporters/memory/in_memory_span_exporter_factory.h>
#include <opentelemetry/metrics/meter.h>
#include <opentelemetry/metrics/meter_provider.h>
#include <opentelemetry/nostd/shared_ptr.h>
#include <opentelemetry/nostd/variant.h>
#include <opentelemetry/sdk/resource/resource.h>
#include <opentelemetry/sdk/trace/samplers/always_on_factory.h>
#include <opentelemetry/sdk/trace/simple_processor_factory.h>
#include <opentelemetry/sdk/trace/span_data.h>
#include <opentelemetry/sdk/trace/tracer_provider.h>
#include <opentelemetry/sdk/trace/tracer_provider_factory.h>
#include <opentelemetry/trace/context.h>
#include <opentelemetry/trace/span.h>
#include <opentelemetry/trace/span_context.h>
#include <opentelemetry/trace/span_id.h>
#include <opentelemetry/trace/span_metadata.h>
#include <opentelemetry/trace/span_startoptions.h>
#include <opentelemetry/trace/trace_id.h>
#include <opentelemetry/trace/tracer.h>
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <memory>
#include <string>
#include <string_view>
#include <thread>
#include <unordered_map>
#include <utility>
#include <vector>
namespace xrpl::telemetry {
namespace {
namespace otel_sdk_trace = opentelemetry::sdk::trace;
namespace otel_memory = opentelemetry::exporter::memory;
/**
* In-memory Telemetry backing for SpanGuard scope tests.
*
* Reports every trace category as enabled and creates spans through an SDK
* TracerProvider whose SimpleSpanProcessor forwards ended spans to an
* InMemorySpanExporter, so a test can read the exact exported SpanData
* (trace id, span id, parent id, name). The provider is built with a
* DeterministicIdGenerator so PendingTraceId can pin the trace_id of a
* forced-root span.
*
* Inheritance:
*
* +-----------+
* | Telemetry | (abstract interface)
* +-----+-----+
* |
* +-----+---------+
* | TestTelemetry | in-memory exporter pipeline
* +---------------+
*
* @note Test-only. Install with Telemetry::setInstance() and clear it in
* teardown. The exporter buffer is drained by InMemorySpanData::GetSpans(),
* so each GetSpans() call returns only spans exported since the previous one.
*/
class TestTelemetry : public Telemetry
{
public:
/**
* Build the SDK export pipeline and keep the exporter's span buffer.
*/
TestTelemetry()
{
// Factory populates spanData_ with the exporter's shared buffer.
auto exporter = otel_memory::InMemorySpanExporterFactory::Create(spanData_);
auto processor = otel_sdk_trace::SimpleSpanProcessorFactory::Create(std::move(exporter));
// Install the DeterministicIdGenerator (4-arg overload) so a
// PendingTraceId can pin a forced-root span's trace_id in tests.
provider_ = otel_sdk_trace::TracerProviderFactory::Create(
std::move(processor),
opentelemetry::sdk::resource::Resource::Create({}),
otel_sdk_trace::AlwaysOnSamplerFactory::Create(),
std::make_unique<DeterministicIdGenerator>());
}
/**
* @return The exporter's span buffer (drained by GetSpans()).
*/
[[nodiscard]] std::shared_ptr<otel_memory::InMemorySpanData>
spanData() const
{
return spanData_;
}
void
start() override
{
}
void
stop() override
{
}
[[nodiscard]] bool
isEnabled() const override
{
return true;
}
[[nodiscard]] bool
shouldTraceTransactions() const override
{
return true;
}
[[nodiscard]] bool
shouldTraceConsensus() const override
{
return true;
}
[[nodiscard]] bool
shouldTraceRpc() const override
{
return true;
}
[[nodiscard]] bool
shouldTracePeer() const override
{
return true;
}
[[nodiscard]] bool
shouldTraceLedger() const override
{
return true;
}
/**
* @return A fixed strategy; the scope tests do not exercise trace-id
* correlation, so either value works.
*/
[[nodiscard]] ConsensusTraceStrategy
getConsensusTraceStrategy() const override
{
return ConsensusTraceStrategy::Deterministic;
}
opentelemetry::nostd::shared_ptr<opentelemetry::trace::Tracer>
getTracer(std::string_view name) override
{
return provider_->GetTracer(std::string(name));
}
/**
* @return A meter from a noop provider; the scope tests exercise only
* tracing, so metrics instruments are inert.
*/
opentelemetry::nostd::shared_ptr<opentelemetry::metrics::Meter>
getMeter(std::string_view name) override
{
return noopMeter(name);
}
opentelemetry::nostd::shared_ptr<opentelemetry::trace::Span>
startSpan(std::string_view name, opentelemetry::trace::SpanKind kind) override
{
opentelemetry::trace::StartSpanOptions opts;
opts.kind = kind;
return getTracer(kTracerName)->StartSpan(std::string(name), opts);
}
opentelemetry::nostd::shared_ptr<opentelemetry::trace::Span>
startSpan(
std::string_view name,
opentelemetry::context::Context const& parentContext,
opentelemetry::trace::SpanKind kind) override
{
opentelemetry::trace::StartSpanOptions opts;
opts.kind = kind;
opts.parent = parentContext;
return getTracer(kTracerName)->StartSpan(std::string(name), opts);
}
private:
/**
* SDK provider owning the export pipeline.
*/
std::shared_ptr<otel_sdk_trace::TracerProvider> provider_;
/**
* Shared buffer that receives ended spans from the exporter.
*/
std::shared_ptr<otel_memory::InMemorySpanData> spanData_;
};
/**
* @return The span's name as a std::string for equality checks.
*/
std::string
nameOf(otel_sdk_trace::SpanData const& span)
{
auto view = span.GetName();
return std::string(view.data(), view.size());
}
/**
* @return Pointer to the first exported span with the given name, or null.
*/
otel_sdk_trace::SpanData*
findSpan(std::vector<std::unique_ptr<otel_sdk_trace::SpanData>> const& spans, std::string_view name)
{
for (auto const& span : spans)
{
if (nameOf(*span) == name)
return span.get();
}
return nullptr;
}
/**
* @return Number of exported spans with the given name.
*/
std::size_t
countSpans(
std::vector<std::unique_ptr<otel_sdk_trace::SpanData>> const& spans,
std::string_view name)
{
std::size_t count = 0;
for (auto const& span : spans)
{
if (nameOf(*span) == name)
++count;
}
return count;
}
/**
* Read one string attribute off an exported span event.
*
* Returns a sentinel instead of asserting so the caller's EXPECT_EQ prints the
* key that was wrong.
*
* @param event Exported event to read.
* @param key Attribute key to look up.
* @return The attribute's string value; "<missing>" when the key is absent,
* "<not-a-string>" when it holds another variant alternative.
*/
std::string
eventAttribute(otel_sdk_trace::SpanDataEvent const& event, std::string_view key)
{
auto const& attrs = event.GetAttributes();
auto const it = attrs.find(std::string(key));
if (it == attrs.end())
return "<missing>";
auto const* const value = opentelemetry::nostd::get_if<std::string>(&it->second);
return value != nullptr ? *value : "<not-a-string>";
}
/**
* Build the 16-byte deterministic trace_id used by the generator tests
* (bytes 1..16). Kept out of line so every generator test pins the same id.
* @return The fixed 16-byte trace_id {1, 2, ..., 16}.
*/
std::array<std::uint8_t, 16>
makeTraceIdBytes()
{
std::array<std::uint8_t, 16> h{};
for (int i = 0; i < 16; ++i)
h[i] = static_cast<std::uint8_t>(i + 1);
return h;
}
/**
* Installs a TestTelemetry as the global instance for each test and clears it
* afterwards so the singleton never dangles between cases.
*
* The fixture also installs a CoroAwareContextStorage as the process-global
* OTel runtime-context storage. That storage backs the ambient-context stack
* with an xrpl::LocalValue, which is what makes a ScopedSpanGuard's Scope live
* in the ACTIVE store (thread or coroutine) rather than a plain thread_local.
* The store-swap and activate() tests depend on this to observe the ambient
* context move with the store.
*
* @note SetRuntimeContextStorage is process-global. Re-installing it in every
* SetUp is idempotent (last writer wins) and safe because GTests run serially;
* a fresh storage per test keeps the coro/thread context stacks isolated.
*/
class SpanGuardScopeTest : public ::testing::Test
{
protected:
void
SetUp() override
{
telemetry_ = std::make_unique<TestTelemetry>();
Telemetry::setInstance(telemetry_.get());
// Back the OTel ambient context with an xrpl::LocalValue store so a
// scope follows the active store, not a bare thread_local. Installed
// before any span is created in the test body (SDK requirement).
storage_ = opentelemetry::nostd::shared_ptr<opentelemetry::context::RuntimeContextStorage>(
new CoroAwareContextStorage());
opentelemetry::context::RuntimeContext::SetRuntimeContextStorage(storage_);
}
void
TearDown() override
{
Telemetry::setInstance(nullptr);
telemetry_.reset();
}
/**
* @return The exporter's span buffer for the active TestTelemetry.
*/
[[nodiscard]] std::shared_ptr<otel_memory::InMemorySpanData>
spanData() const
{
return telemetry_->spanData();
}
/**
* The in-memory Telemetry installed for the duration of a test.
*/
std::unique_ptr<TestTelemetry> telemetry_;
/**
* The coro-aware runtime-context storage installed for the test. Kept
* alive by the fixture so the process-global storage pointer stays valid
* for the test body; replaced each SetUp (see class note).
*/
opentelemetry::nostd::shared_ptr<opentelemetry::context::RuntimeContextStorage> storage_;
};
// freshRoot() must ignore the ambient active span and start a brand-new trace.
TEST_F(SpanGuardScopeTest, span_guard_fresh_root_is_true_root_ignoring_ambient)
{
{
// Ambient span becomes the active span on this thread.
ScopedSpanGuard const ambient(TraceCategory::Rpc, "rpc", "command");
ASSERT_TRUE(static_cast<bool>(ambient));
// freshRoot must NOT inherit the ambient span as its parent.
auto r = SpanGuard::freshRoot(TraceCategory::Peer, "peer", "validation.receive");
ASSERT_TRUE(static_cast<bool>(r));
} // r ends first, then ambient's scope pops and ambient span ends.
auto spans = spanData()->GetSpans();
auto* ambient = findSpan(spans, "rpc.command");
auto* root = findSpan(spans, "peer.validation.receive");
ASSERT_NE(ambient, nullptr);
ASSERT_NE(root, nullptr);
// The ambient span is itself a root (first span, no parent).
EXPECT_FALSE(ambient->GetParentSpanId().IsValid());
EXPECT_TRUE(ambient->GetTraceId().IsValid());
// The freshRoot span has NO parent and lives in a DIFFERENT trace.
EXPECT_FALSE(root->GetParentSpanId().IsValid());
EXPECT_TRUE(root->GetTraceId().IsValid());
EXPECT_NE(root->GetTraceId(), ambient->GetTraceId());
}
// A ScopedSpanGuard is the ambient active span on its thread the moment it is
// constructed: a child created while it is alive parents to its span.
TEST_F(SpanGuardScopeTest, scoped_guard_is_ambient_on_construct)
{
opentelemetry::trace::SpanId activeId;
{
ScopedSpanGuard const s(TraceCategory::Rpc, "rpc", "process");
ASSERT_TRUE(static_cast<bool>(s));
// While s is alive it is the active span on this thread's context.
auto active =
opentelemetry::trace::GetSpan(opentelemetry::context::RuntimeContext::GetCurrent())
->GetContext();
ASSERT_TRUE(active.IsValid());
activeId = active.span_id();
// A child created here must parent to s's span, proving s is ambient.
auto child = s.childSpan("rpc.dispatch");
ASSERT_TRUE(static_cast<bool>(child));
} // child ends first, then s pops its scope and ends.
auto spans = spanData()->GetSpans();
auto* parent = findSpan(spans, "rpc.process");
auto* child = findSpan(spans, "rpc.dispatch");
ASSERT_NE(parent, nullptr);
ASSERT_NE(child, nullptr);
// The active span observed while s was alive WAS s's span.
EXPECT_TRUE(activeId.IsValid());
EXPECT_EQ(parent->GetSpanId(), activeId);
// The child nested under s: same trace, parent = s's span.
EXPECT_EQ(child->GetParentSpanId(), parent->GetSpanId());
EXPECT_EQ(child->GetTraceId(), parent->GetTraceId());
}
// operator SpanGuard() && pops the Scope eagerly on the origin thread, so the
// span is no longer ambient here and the resulting thread-free guard can be
// ended on a worker thread without corrupting this thread's context stack.
TEST_F(SpanGuardScopeTest, scoped_guard_conversion_pops_scope_on_this_thread)
{
{
ScopedSpanGuard s(TraceCategory::Ledger, "ledger", "build");
ASSERT_TRUE(static_cast<bool>(s));
// Convert to a bare SpanGuard: the scope is popped here, on this thread.
SpanGuard bare = std::move(s);
ASSERT_TRUE(static_cast<bool>(bare));
// The scope is gone: no span is active on this thread now.
auto active =
opentelemetry::trace::GetSpan(opentelemetry::context::RuntimeContext::GetCurrent())
->GetContext();
EXPECT_FALSE(active.IsValid());
// A new ambient span here is a fresh root, NOT nested under build.
{
ScopedSpanGuard const after(TraceCategory::Rpc, "rpc", "command");
ASSERT_TRUE(static_cast<bool>(after));
}
// End the thread-free guard on a worker thread -- no crash.
std::thread worker([g = std::move(bare)]() mutable {});
worker.join();
}
auto spans = spanData()->GetSpans();
auto* build = findSpan(spans, "ledger.build");
auto* after = findSpan(spans, "rpc.command");
ASSERT_NE(build, nullptr);
ASSERT_NE(after, nullptr);
// The span was exported exactly once, by the worker thread.
EXPECT_EQ(countSpans(spans, "ledger.build"), 1u);
// 'after' did NOT nest under build: fresh root, different trace.
EXPECT_FALSE(after->GetParentSpanId().IsValid());
EXPECT_NE(after->GetTraceId(), build->GetTraceId());
}
// The SpanGuard produced by the conversion ends the span exactly once: the
// moved-from ScopedSpanGuard must not re-end it on destruction.
TEST_F(SpanGuardScopeTest, scoped_guard_conversion_result_ends_span_once)
{
{
ScopedSpanGuard scoped(TraceCategory::Ledger, "ledger", "build");
ASSERT_TRUE(static_cast<bool>(scoped));
SpanGuard const bare = std::move(scoped);
ASSERT_TRUE(static_cast<bool>(bare));
// Nothing exported yet: the span is still open.
EXPECT_EQ(countSpans(spanData()->GetSpans(), "ledger.build"), 0u);
// 'bare' ends the span here on destruction; the moved-from 'scoped'
// guard is destroyed too but must NOT end it a second time.
}
auto spans = spanData()->GetSpans();
// Exactly one export: not zero (the guard still owns the span) and not two
// (the moved-from scoped guard does not re-end it).
EXPECT_EQ(countSpans(spans, "ledger.build"), 1u);
}
// A ScopedSpanGuard's scope lives in the ACTIVE LocalValue store, and the
// coro-aware storage makes the ambient context follow that store. This
// simulates a coroutine that yields (its store swapped out for the worker's
// own) then resumes on another worker (store swapped back in): the span must be
// hidden while off-store, visible again on resume, and pop cleanly under the
// same store it was pushed on -- so the owner-store assertion never trips.
//
// Fails without CoroAwareContextStorage: OpenTelemetry's default thread-local
// stack ignores the LocalValue swap, so the span would stay visible off-store
// (the EXPECT_NE below would fail). Passes only because the fixture installs the
// coro-aware storage that binds the ambient stack to the active store.
TEST_F(SpanGuardScopeTest, scoped_guard_survives_localvalue_store_swap)
{
namespace ctx = opentelemetry::context;
namespace trc = opentelemetry::trace;
// Stand-in stores for the coroutine and a worker thread's own store. Both
// are onCoro=true so LocalValues::cleanup() never deletes these stack
// objects. Keeping a stack store active across every GetCurrent() call below
// stops LocalValue from materializing (then leaking) a heap thread-store.
xrpl::detail::LocalValues coroStore;
xrpl::detail::LocalValues workerStore;
// Detach (do NOT delete) the fixture's active store and run on the coro
// store. A failed ASSERT_* returns from the test body, so the restore must be
// RAII or the thread pointer keeps owning a stack store that is about to die.
// Declared after both stack stores, so it is destroyed before either of them.
auto* saved = xrpl::detail::getLocalValues().release();
xrpl::ScopeExit const restoreStore{[saved]() {
xrpl::detail::getLocalValues().release();
xrpl::detail::getLocalValues().reset(saved);
}};
xrpl::detail::getLocalValues().reset(&coroStore);
trc::SpanContext captured = trc::SpanContext::GetInvalid();
{
ScopedSpanGuard const span(TraceCategory::Rpc, "rpc", "process");
ASSERT_TRUE(static_cast<bool>(span));
auto active = trc::GetSpan(ctx::RuntimeContext::GetCurrent())->GetContext();
ASSERT_TRUE(active.IsValid());
captured = active;
// Yield: swap the coro store OUT, the worker's own store IN.
xrpl::detail::getLocalValues().release();
xrpl::detail::getLocalValues().reset(&workerStore);
auto offCoro = trc::GetSpan(ctx::RuntimeContext::GetCurrent())->GetContext();
EXPECT_FALSE(offCoro.IsValid()); // no ambient span off-coro
EXPECT_NE(offCoro.span_id(), captured.span_id()); // span NOT visible
// Resume on another worker: swap the coro store back IN.
xrpl::detail::getLocalValues().release();
xrpl::detail::getLocalValues().reset(&coroStore);
auto resumed = trc::GetSpan(ctx::RuntimeContext::GetCurrent())->GetContext();
EXPECT_TRUE(resumed.IsValid()); // ambient again
EXPECT_EQ(resumed.span_id(), captured.span_id()); // same span visible
} // ~ScopedSpanGuard pops from coroStore (its owner store active) -- no assert
// The scope popped cleanly: the coro store's stack is empty again.
auto afterPop = trc::GetSpan(ctx::RuntimeContext::GetCurrent());
EXPECT_FALSE(afterPop->GetContext().IsValid());
// restoreStore re-owns the fixture's store from here on: it runs on every
// exit path, and the checks below touch no LocalValue.
//
// The span ended exactly once, when the scope popped on resume.
EXPECT_EQ(countSpans(spanData()->GetSpans(), "rpc.process"), 1u);
}
// SpanGuard::activate() makes an already-owned span the ambient context for the
// activation's lifetime WITHOUT owning or ending it: before activate() the span
// is not ambient, during it the span is the current context, and after it the
// prior (empty) context is restored. ~ScopedActivation must NOT end the span --
// the owning guard ends it exactly once. Identity is proved by matching the
// ambient span_id seen during activation to the single exported span.
TEST_F(SpanGuardScopeTest, activate_sets_ambient_without_owning)
{
namespace ctx = opentelemetry::context;
namespace trc = opentelemetry::trace;
auto guard = SpanGuard::freshRoot(TraceCategory::Transactions, "tx", "process");
ASSERT_TRUE(static_cast<bool>(guard));
// Unscoped: the guard's span is NOT ambient before activate().
EXPECT_FALSE(trc::GetSpan(ctx::RuntimeContext::GetCurrent())->GetContext().IsValid());
trc::SpanId activeId;
{
auto activation = guard.activate();
auto active = trc::GetSpan(ctx::RuntimeContext::GetCurrent())->GetContext();
EXPECT_TRUE(active.IsValid()); // now ambient
activeId = active.span_id();
} // ~ScopedActivation pops the scope; it does NOT end the span.
// A valid ambient span was observed while the activation was live.
EXPECT_TRUE(activeId.IsValid());
// Prior context restored: no ambient span after the activation drops.
EXPECT_FALSE(trc::GetSpan(ctx::RuntimeContext::GetCurrent())->GetContext().IsValid());
// Non-owning: the activation did NOT end the span, so nothing is exported.
EXPECT_EQ(countSpans(spanData()->GetSpans(), "tx.process"), 0u);
// Ending the OWNING guard exports the span exactly once.
guard = SpanGuard{};
auto spans = spanData()->GetSpans();
EXPECT_EQ(countSpans(spans, "tx.process"), 1u);
// The span made ambient during activation WAS the guard's own span.
auto* txSpan = findSpan(spans, "tx.process");
ASSERT_NE(txSpan, nullptr);
EXPECT_EQ(txSpan->GetSpanId(), activeId);
}
// addEvent(name, attrs) on a LIVE span must reach the exporter with the event
// name and every attribute value intact. The overload rebuilds each pair into an
// OTel key-value-iterable, so a dropped or mistyped pair would be invisible
// without reading the exported event back. Values are asserted individually as
// well as by count: two attributes with one value blanked still counts as two.
TEST_F(SpanGuardScopeTest, span_guard_add_event_records_name_and_attribute_values)
{
namespace cs = consensus::span;
static constexpr std::string_view kEventName{cs::event::txIncluded};
static constexpr std::string_view kTxIdKey{cs::attr::txId};
static constexpr std::string_view kTxId{"6B5F1A2C3D4E5F60718293A4B5C6D7E8"};
static constexpr std::string_view kStateKey{cs::attr::consensusState};
static constexpr std::string_view kState{cs::val::finished};
{
auto guard = SpanGuard::span(TraceCategory::Consensus, seg::consensus, cs::op::acceptApply);
ASSERT_TRUE(static_cast<bool>(guard));
guard.addEvent(kEventName, {{kTxIdKey, kTxId}, {kStateKey, kState}});
} // guard ends the span, exporting it.
auto spans = spanData()->GetSpans();
auto* applySpan = findSpan(spans, cs::acceptApply);
ASSERT_NE(applySpan, nullptr);
auto const& events = applySpan->GetEvents();
ASSERT_EQ(events.size(), 1u);
auto const& event = events.front();
EXPECT_EQ(event.GetName(), std::string(kEventName));
EXPECT_EQ(event.GetAttributes().size(), 2u);
EXPECT_EQ(event.GetDroppedAttributesCount(), 0u);
EXPECT_EQ(eventAttribute(event, kTxIdKey), std::string(kTxId));
EXPECT_EQ(eventAttribute(event, kStateKey), std::string(kState));
}
// The name-only overload records the event with NO attributes, so a regression
// that leaked attributes between the two overloads shows up here rather than as
// an extra key on a production event.
TEST_F(SpanGuardScopeTest, span_guard_add_event_without_attributes_records_bare_event)
{
namespace cs = consensus::span;
static constexpr std::string_view kEventName{cs::event::phaseAccepted};
{
auto guard = SpanGuard::span(TraceCategory::Consensus, seg::consensus, cs::op::round);
ASSERT_TRUE(static_cast<bool>(guard));
guard.addEvent(kEventName);
}
auto spans = spanData()->GetSpans();
auto* roundSpan = findSpan(spans, cs::round);
ASSERT_NE(roundSpan, nullptr);
auto const& events = roundSpan->GetEvents();
ASSERT_EQ(events.size(), 1u);
EXPECT_EQ(events.front().GetName(), std::string(kEventName));
EXPECT_EQ(events.front().GetAttributes().size(), 0u);
}
// The scoped guard records event attributes too. consensus.accept.apply relies
// on it for one tx.included event per transaction of the accepted set.
TEST_F(SpanGuardScopeTest, scoped_guard_add_event_records_name_and_attribute_values)
{
namespace cs = consensus::span;
static constexpr std::string_view kEventName{cs::event::txIncluded};
static constexpr std::string_view kTxIdKey{cs::attr::txId};
static constexpr std::string_view kTxId{"6B5F1A2C3D4E5F60718293A4B5C6D7E8"};
{
ScopedSpanGuard guard(TraceCategory::Consensus, seg::consensus, cs::op::acceptApply);
ASSERT_TRUE(static_cast<bool>(guard));
guard.addEvent(kEventName, {{kTxIdKey, kTxId}});
}
auto spans = spanData()->GetSpans();
auto* applySpan = findSpan(spans, cs::acceptApply);
ASSERT_NE(applySpan, nullptr);
auto const& events = applySpan->GetEvents();
ASSERT_EQ(events.size(), 1u);
EXPECT_EQ(events.front().GetName(), std::string(kEventName));
EXPECT_EQ(events.front().GetAttributes().size(), 1u);
EXPECT_EQ(eventAttribute(events.front(), kTxIdKey), std::string(kTxId));
}
// A scoped child of a captured context is the ambient parent of the spans
// created after it on the same thread. A hash-derived root created inside that
// scope stays a root. consensus.accept.apply relies on both.
TEST_F(SpanGuardScopeTest, scoped_child_of_captured_context_is_ambient_for_later_spans)
{
namespace cs = consensus::span;
auto const h = makeTraceIdBytes();
{
// consensus.accept: unscoped, thread-free, context captured.
auto accept =
SpanGuard::freshRoot(TraceCategory::Consensus, seg::consensus, cs::op::accept);
ASSERT_TRUE(static_cast<bool>(accept));
auto const acceptCtx = accept.spanContext();
// consensus.accept.apply: scoped child of that context.
ScopedSpanGuard const apply = ScopedSpanGuard::childSpan(cs::acceptApply, acceptCtx);
ASSERT_TRUE(static_cast<bool>(apply));
// ledger.build: a plain ambient scoped guard.
{
ScopedSpanGuard const build(TraceCategory::Ledger, seg::ledger, "build");
ASSERT_TRUE(static_cast<bool>(build));
}
// ledger.store: hash-derived, so a deterministic root.
{
auto store =
SpanGuard::hashSpan(TraceCategory::Ledger, "ledger.store", h.data(), h.size());
ASSERT_TRUE(static_cast<bool>(store));
}
}
auto spans = spanData()->GetSpans();
auto* accept = findSpan(spans, cs::accept);
auto* apply = findSpan(spans, cs::acceptApply);
auto* build = findSpan(spans, "ledger.build");
auto* store = findSpan(spans, "ledger.store");
ASSERT_NE(accept, nullptr);
ASSERT_NE(apply, nullptr);
ASSERT_NE(build, nullptr);
ASSERT_NE(store, nullptr);
EXPECT_EQ(apply->GetParentSpanId(), accept->GetSpanId());
// build nests under apply, not beside it.
EXPECT_EQ(build->GetParentSpanId(), apply->GetSpanId());
EXPECT_EQ(build->GetTraceId(), apply->GetTraceId());
// The hash-derived span is a root on its own pinned trace id.
EXPECT_FALSE(store->GetParentSpanId().IsValid());
EXPECT_TRUE(std::ranges::equal(store->GetTraceId().Id(), h));
}
// A forced-root span started while a PendingTraceId is active adopts that
// pinned 16-byte trace_id and remains a true root (no parent).
TEST_F(SpanGuardScopeTest, deterministic_id_generator_forced_root_gets_pending_trace_id)
{
auto const h = makeTraceIdBytes();
{
PendingTraceId const pending{h};
auto rootCtx = opentelemetry::context::Context{opentelemetry::trace::kIsRootSpanKey, true};
auto span =
telemetry_->startSpan("tx.receive", rootCtx, opentelemetry::trace::SpanKind::kInternal);
span->End();
} // ~PendingTraceId asserts the id was consumed.
auto spans = spanData()->GetSpans();
ASSERT_EQ(spans.size(), 1u);
// trace_id == the pinned hash.
EXPECT_EQ(std::memcmp(spans[0]->GetTraceId().Id().data(), h.data(), 16), 0);
// TRUE ROOT: no parent.
EXPECT_FALSE(spans[0]->GetParentSpanId().IsValid());
}
// A forced-root span with NO PendingTraceId gets a random (non-zero) trace_id,
// never the deterministic hash -- the safety property when no id is pinned.
TEST_F(SpanGuardScopeTest, deterministic_id_generator_no_pending_gives_random_root)
{
auto const h = makeTraceIdBytes();
{
auto rootCtx = opentelemetry::context::Context{opentelemetry::trace::kIsRootSpanKey, true};
auto span =
telemetry_->startSpan("tx.receive", rootCtx, opentelemetry::trace::SpanKind::kInternal);
span->End();
}
auto spans = spanData()->GetSpans();
ASSERT_EQ(spans.size(), 1u);
// Random root: valid (non-zero) trace_id...
EXPECT_TRUE(spans[0]->GetTraceId().IsValid());
// ...and NOT the deterministic hash (no pending id leaked in).
EXPECT_NE(std::memcmp(spans[0]->GetTraceId().Id().data(), h.data(), 16), 0);
EXPECT_FALSE(spans[0]->GetParentSpanId().IsValid());
}
// SAFETY: an ambient child under a live parent never adopts a pending id. The
// SDK inherits the parent's trace_id and never calls GenerateTraceId() for the
// child, so the pinned id stays available -- proven here by a trailing
// forced-root span that DOES adopt it (which also consumes the id so
// ~PendingTraceId's consumed-assert holds; see the report for this choice).
TEST_F(SpanGuardScopeTest, deterministic_id_generator_ambient_child_ignores_pending)
{
auto const h = makeTraceIdBytes();
{
// Ambient parent (a random-id root) active on this thread FIRST, before
// any id is pinned, so the parent itself does not consume it.
ScopedSpanGuard const parent(TraceCategory::Rpc, "rpc", "process");
ASSERT_TRUE(static_cast<bool>(parent));
// Pin the id, then start an ambient child under the live parent.
PendingTraceId const pending{h};
// The child has a valid parent, so the SDK inherits the parent's
// trace_id and never calls GenerateTraceId(): the pending id is ignored.
{
auto child = parent.childSpan("rpc.dispatch");
ASSERT_TRUE(static_cast<bool>(child));
}
// Consume the pinned id with a real forced-root span (its intended use),
// so ~PendingTraceId sees the id as consumed. Its trace_id == h.
{
auto rootCtx =
opentelemetry::context::Context{opentelemetry::trace::kIsRootSpanKey, true};
auto root = telemetry_->startSpan(
"tx.receive", rootCtx, opentelemetry::trace::SpanKind::kInternal);
root->End();
}
} // ~PendingTraceId: consumed == true, assert holds; then parent ends.
auto spans = spanData()->GetSpans();
auto* parent = findSpan(spans, "rpc.process");
auto* child = findSpan(spans, "rpc.dispatch");
auto* root = findSpan(spans, "tx.receive");
ASSERT_NE(parent, nullptr);
ASSERT_NE(child, nullptr);
ASSERT_NE(root, nullptr);
// The ambient child inherited the parent's trace and did NOT adopt h.
EXPECT_EQ(child->GetTraceId(), parent->GetTraceId());
EXPECT_EQ(child->GetParentSpanId(), parent->GetSpanId());
EXPECT_NE(std::memcmp(child->GetTraceId().Id().data(), h.data(), 16), 0);
// The forced-root span DID adopt the pinned id: proof the id was available
// the whole time -- the ambient child simply never requested it.
EXPECT_EQ(std::memcmp(root->GetTraceId().Id().data(), h.data(), 16), 0);
EXPECT_FALSE(root->GetParentSpanId().IsValid());
}
// Death test guarding the cross-store scope-leak bug on ScopedSpanGuard.
//
// A ScopedSpanGuard's Scope is bound to the LocalValue context store that was
// active when it was constructed, so it must be destroyed while that same store
// is active. A different thread has a different LocalValue store, so destroying
// the guard on another thread pops the Scope against a foreign context stack --
// ~ScopedSpanGuard's owner-store XRPL_ASSERT turns that silent corruption into a
// loud abort(). ScopedSpanGuard is non-movable, so it cannot be moved into a
// worker directly; instead we own it through a unique_ptr and move only that
// pointer to a worker thread, whose store differs from this thread's.
//
// The death happens on the WORKER thread (the moved-in unique_ptr is destroyed
// when the worker lambda's captures are torn down, before worker.join()
// completes). A failed assert() calls abort(), which raises SIGABRT
// process-wide regardless of thread, so EXPECT_DEATH -- which runs the
// statement in a forked child and checks it dies -- observes the crash. The
// regex matches the assert message substring; the assert message is written as
// two adjacent string literals in SpanGuard.cpp, so the ".*" bridges the gap
// between "the" and "constructing".
//
// The test is skipped where the assertion cannot fire: under NDEBUG (Release
// builds) XRPL_ASSERT is a no-op, and under ENABLE_VOIDSTAR a failed assert
// continues instead of aborting -- in both cases the worker would not crash and
// EXPECT_DEATH would report a spurious failure.
TEST_F(SpanGuardScopeTest, scoped_guard_cross_thread_death_asserts_at_wrong_store_destroy)
{
#ifdef NDEBUG
GTEST_SKIP() << "XRPL_ASSERT compiles to a no-op under NDEBUG (Release builds), so the "
"cross-store scope-leak assertion this test exercises does not fire.";
#elifdef ENABLE_VOIDSTAR
GTEST_SKIP() << "ENABLE_VOIDSTAR continues past a failed XRPL_ASSERT instead of aborting, so "
"the cross-store scope-leak assertion this test exercises does not crash.";
#else
EXPECT_DEATH(
{
// Scoped guard constructed on THIS thread; its Scope binds to this
// thread's LocalValue store.
auto scoped =
std::make_unique<ScopedSpanGuard>(TraceCategory::Ledger, "ledger", "build");
// Move only the owning pointer to a worker. ~ScopedSpanGuard runs on
// the worker when the lambda's captures are destroyed; the worker's
// store differs from the constructing store, tripping the owner-store
// assertion -> abort().
std::thread worker([s = std::move(scoped)]() mutable {});
worker.join();
},
// The assert message is written as two adjacent string literals in
// SpanGuard.cpp; assert() stringifies the expression source via the
// preprocessor '#' operator, keeping both quoted literals with the
// "\" \"" gap between "the" and "constructing". Match across that gap.
".*destroyed on.*constructing context store.*");
#endif
}
// ===========================================================================
// Per-ledger trace join
// ===========================================================================
//
// One ledger's spans are produced on threads that share no context:
// ledger.acquire on a JtLedgerData worker, ledger.validate from whichever thread
// enters LedgerMaster::checkAccept (a peer thread via handleNewValidation, the
// acquire-completion job, or the consensus thread via switchLCL),
// consensus.validation.accept from a validation worker, ledger.store from a
// fourth. No ambient context reaches across those boundaries, so without an
// explicit join each would be its own single-span trace and a slow ledger could
// not be read as one unit.
//
// The join derives the trace id from the ledger hash, which every one of those
// sites already holds. The whole contract is therefore: spans built from the
// SAME hash share a trace id, spans built from DIFFERENT hashes do not, and each
// is a true root rather than hanging off an unrelated ambient parent. That is a
// property of hashSpan plus the key, with no LedgerMaster state involved, so it
// is asserted here against the same in-memory exporter the tests above use.
//
// The production call site (LedgerMaster::makeLedgerTraceSpan) cannot be called
// from this binary, which links libxrpl and not xrpld. What it adds over the raw
// factory is two attributes and the arguments it forwards; the causality
// property the emitters depend on, and that no compiler enforces, is the one
// asserted here.
/**
* Build a distinct 32-byte stand-in for a ledger hash.
*
* Full width on purpose: the emitters pass the whole 32-byte hash and hashSpan
* consumes the first 16, so a 32-byte input is what the real call looks like.
* The seed varies the FIRST byte, inside the 16 that become the trace id, so two
* seeds really do produce two trace ids -- a seed placed in the tail would be
* truncated away and the difference assertion would pass for the wrong reason.
*
* @param seed Distinguishes one fake ledger from another.
* @return 32 bytes whose leading 16 differ whenever the seed differs.
*/
std::array<std::uint8_t, 32>
makeLedgerHashBytes(std::uint8_t seed)
{
std::array<std::uint8_t, 32> h{};
for (std::size_t i = 0; i < h.size(); ++i)
h[i] = static_cast<std::uint8_t>(seed + i);
return h;
}
// THE CONTRACT, positive half: three stages of one ledger, created
// independently, all land in ONE trace whose id IS the ledger hash. This is what
// makes a slow ledger readable as one connected trace instead of three orphans.
TEST_F(SpanGuardScopeTest, ledger_join_same_hash_puts_every_stage_in_one_trace)
{
auto const h = makeLedgerHashBytes(0x11);
{
// Deliberately not nested and not in pipeline order: each stage is built
// on its own, exactly as the three emitters do on three threads.
auto acquire =
SpanGuard::hashSpan(TraceCategory::Ledger, "ledger.acquire", h.data(), h.size());
auto validate =
SpanGuard::hashSpan(TraceCategory::Ledger, "ledger.validate", h.data(), h.size());
auto store = SpanGuard::hashSpan(TraceCategory::Ledger, "ledger.store", h.data(), h.size());
ASSERT_TRUE(static_cast<bool>(acquire));
ASSERT_TRUE(static_cast<bool>(validate));
ASSERT_TRUE(static_cast<bool>(store));
}
auto spans = spanData()->GetSpans();
ASSERT_EQ(spans.size(), 3u);
auto* acquire = findSpan(spans, "ledger.acquire");
auto* validate = findSpan(spans, "ledger.validate");
auto* store = findSpan(spans, "ledger.store");
ASSERT_NE(acquire, nullptr);
ASSERT_NE(validate, nullptr);
ASSERT_NE(store, nullptr);
// ONE trace: every stage shares the trace id. This is the whole point.
EXPECT_EQ(validate->GetTraceId(), acquire->GetTraceId());
EXPECT_EQ(store->GetTraceId(), acquire->GetTraceId());
// And the trace id IS the key's first 16 bytes -- not merely equal to each
// other by chance. That is what lets an operator go from a ledger hash to
// its trace and back.
EXPECT_EQ(std::memcmp(acquire->GetTraceId().Id().data(), h.data(), 16), 0);
// Still three DISTINCT spans, not one span reused.
EXPECT_NE(acquire->GetSpanId(), validate->GetSpanId());
EXPECT_NE(validate->GetSpanId(), store->GetSpanId());
}
// THE CONTRACT, negative half: two different ledgers must never share a trace.
// Without this the join would be useless -- a single trace would accumulate
// every ledger the node ever touched.
TEST_F(SpanGuardScopeTest, ledger_join_different_hashes_never_share_a_trace)
{
auto const first = makeLedgerHashBytes(0x20);
auto const second = makeLedgerHashBytes(0x60);
{
auto a = SpanGuard::hashSpan(
TraceCategory::Ledger, "ledger.validate", first.data(), first.size());
auto b = SpanGuard::hashSpan(
TraceCategory::Ledger, "ledger.store", second.data(), second.size());
ASSERT_TRUE(static_cast<bool>(a));
ASSERT_TRUE(static_cast<bool>(b));
}
auto spans = spanData()->GetSpans();
ASSERT_EQ(spans.size(), 2u);
auto* a = findSpan(spans, "ledger.validate");
auto* b = findSpan(spans, "ledger.store");
ASSERT_NE(a, nullptr);
ASSERT_NE(b, nullptr);
EXPECT_NE(a->GetTraceId(), b->GetTraceId());
// Each still matches its OWN key, so they are separate because the keys
// differ, not because one of them failed to adopt its hash at all.
EXPECT_EQ(std::memcmp(a->GetTraceId().Id().data(), first.data(), 16), 0);
EXPECT_EQ(std::memcmp(b->GetTraceId().Id().data(), second.data(), 16), 0);
}
// A joined span is a TRUE ROOT, never a child of whatever was active on the
// emitting thread. checkAccept is entered from a peer thread, from the
// acquire-completion job and from the consensus thread; if the span inherited an
// ambient parent it would be swallowed into an unrelated trace on some of those
// paths and its trace id would no longer be the ledger hash.
TEST_F(SpanGuardScopeTest, ledger_join_ignores_an_ambient_parent)
{
auto const h = makeLedgerHashBytes(0x33);
{
// An unrelated span active on this thread: the situation on a reused
// job-queue worker.
ScopedSpanGuard const ambient(TraceCategory::Rpc, "rpc", "command");
ASSERT_TRUE(static_cast<bool>(ambient));
auto joined =
SpanGuard::hashSpan(TraceCategory::Ledger, "ledger.validate", h.data(), h.size());
ASSERT_TRUE(static_cast<bool>(joined));
}
auto spans = spanData()->GetSpans();
auto* ambientSpan = findSpan(spans, "rpc.command");
auto* joined = findSpan(spans, "ledger.validate");
ASSERT_NE(ambientSpan, nullptr);
ASSERT_NE(joined, nullptr);
// No parent, and NOT in the ambient span's trace.
EXPECT_FALSE(joined->GetParentSpanId().IsValid());
EXPECT_NE(joined->GetTraceId(), ambientSpan->GetTraceId());
// The deterministic id survived: the ambient span did not displace it.
EXPECT_EQ(std::memcmp(joined->GetTraceId().Id().data(), h.data(), 16), 0);
}
// The validation-accept span joins the trace of the ledger it validates, so
// "which validation drove this acceptance, and how long did the acceptance take"
// is one trace. It is keyed on the VALIDATED ledger hash -- the key
// ledger.validate uses -- and deliberately NOT on the previous-ledger hash that
// seeds the consensus round trace, which stays a separate trace.
TEST_F(SpanGuardScopeTest, ledger_join_validation_accept_joins_the_validated_ledger)
{
auto const validated = makeLedgerHashBytes(0x41);
auto const previous = makeLedgerHashBytes(0x81);
{
auto validation = SpanGuard::hashSpan(
TraceCategory::Ledger,
"consensus.validation.accept",
validated.data(),
validated.size());
auto accept = SpanGuard::hashSpan(
TraceCategory::Ledger, "ledger.validate", validated.data(), validated.size());
// The round trace, seeded on the PREVIOUS ledger: a different trace.
auto round = SpanGuard::hashSpan(
TraceCategory::Consensus, "consensus.round", previous.data(), previous.size());
ASSERT_TRUE(static_cast<bool>(validation));
ASSERT_TRUE(static_cast<bool>(accept));
ASSERT_TRUE(static_cast<bool>(round));
}
auto spans = spanData()->GetSpans();
auto* validation = findSpan(spans, "consensus.validation.accept");
auto* accept = findSpan(spans, "ledger.validate");
auto* round = findSpan(spans, "consensus.round");
ASSERT_NE(validation, nullptr);
ASSERT_NE(accept, nullptr);
ASSERT_NE(round, nullptr);
// Joined to the acceptance of the ledger it validates...
EXPECT_EQ(validation->GetTraceId(), accept->GetTraceId());
// ...and separate from the round trace, which is keyed on another ledger.
EXPECT_NE(validation->GetTraceId(), round->GetTraceId());
}
// A key SHORTER than 16 bytes cannot seed a trace id, so hashSpan yields a null
// guard rather than a span in a garbage trace. The emitters always pass a full
// 32-byte hash, so this is the guard rail: a truncated key degrades to "no span"
// instead of to a wrong join.
TEST_F(SpanGuardScopeTest, ledger_join_too_short_a_key_yields_no_span)
{
std::array<std::uint8_t, 8> const tooShort{1, 2, 3, 4, 5, 6, 7, 8};
auto span = SpanGuard::hashSpan(
TraceCategory::Ledger, "ledger.validate", tooShort.data(), tooShort.size());
EXPECT_FALSE(static_cast<bool>(span));
// Nothing exported at all, rather than a span carrying an invalid trace id.
EXPECT_EQ(spanData()->GetSpans().size(), 0u);
}
// DISABLED PATH: with no Telemetry instance installed the join is a no-op that
// still compiles and returns a usable null guard, which is why
// LedgerMaster::makeLedgerTraceSpan needs no #ifdef or enabled-check of its own.
// Not a fixture test: the point is that NO instance is installed.
TEST(SpanGuardLedgerJoinDisabled, join_is_a_no_op_when_telemetry_is_absent)
{
Telemetry::setInstance(nullptr);
auto const h = makeLedgerHashBytes(0x55);
auto span = SpanGuard::hashSpan(TraceCategory::Ledger, "ledger.validate", h.data(), h.size());
// Null guard, and its whole surface is still safe to drive.
EXPECT_FALSE(static_cast<bool>(span));
span.setAttribute("ledger_hash", "deadbeef");
span.setAttribute("ledger_seq", static_cast<std::int64_t>(42));
span.setOk();
// It yields no context either, so no caller can build a child in a bogus
// trace off the back of it.
EXPECT_FALSE(span.spanContext().isValid());
}
} // namespace
} // namespace xrpl::telemetry
#endif // XRPL_ENABLE_TELEMETRY