mirror of
https://github.com/XRPLF/rippled.git
synced 2026-07-23 15:10:34 +00:00
551 lines
19 KiB
C++
551 lines
19 KiB
C++
// Tests for SpanGuard::rootSpan() and SpanGuard::detached().
|
|
//
|
|
// These verify the cross-thread scope-leak fix at the trace level using an
|
|
// in-memory span exporter: rootSpan() must start a fresh trace root (ignoring
|
|
// the ambient active span), and detached() must strip the thread-local Scope
|
|
// on the origin thread so the guard can be moved to and ended on another
|
|
// thread without corrupting the origin thread's context stack.
|
|
//
|
|
// 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/telemetry/SpanGuard.h>
|
|
#include <xrpl/telemetry/Telemetry.h>
|
|
|
|
#include <gtest/gtest.h>
|
|
#include <opentelemetry/context/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/metrics/noop.h>
|
|
#include <opentelemetry/nostd/shared_ptr.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/span.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 <cstddef>
|
|
#include <memory>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <thread>
|
|
#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).
|
|
*
|
|
* 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));
|
|
provider_ = otel_sdk_trace::TracerProviderFactory::Create(
|
|
std::move(processor),
|
|
opentelemetry::sdk::resource::Resource::Create({}),
|
|
otel_sdk_trace::AlwaysOnSamplerFactory::Create());
|
|
}
|
|
|
|
/**
|
|
* @return The exporter's span buffer (drained by GetSpans()).
|
|
*/
|
|
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 label; the scope tests do not exercise
|
|
* deterministic trace-id correlation, so any stable value works.
|
|
*/
|
|
[[nodiscard]] std::string const&
|
|
getConsensusTraceStrategy() const override
|
|
{
|
|
static std::string const kStrategy{"none"};
|
|
return kStrategy;
|
|
}
|
|
|
|
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
|
|
{
|
|
static auto noopProvider =
|
|
opentelemetry::nostd::shared_ptr<opentelemetry::metrics::MeterProvider>(
|
|
new opentelemetry::metrics::NoopMeterProvider());
|
|
return noopProvider->GetMeter(std::string(name), std::string(kMeterVersion));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Installs a TestTelemetry as the global instance for each test and clears it
|
|
* afterwards so the singleton never dangles between cases.
|
|
*/
|
|
class SpanGuardScopeTest : public ::testing::Test
|
|
{
|
|
protected:
|
|
void
|
|
SetUp() override
|
|
{
|
|
telemetry_ = std::make_unique<TestTelemetry>();
|
|
Telemetry::setInstance(telemetry_.get());
|
|
}
|
|
|
|
void
|
|
TearDown() override
|
|
{
|
|
Telemetry::setInstance(nullptr);
|
|
telemetry_.reset();
|
|
}
|
|
|
|
/**
|
|
* @return The exporter's span buffer for the active TestTelemetry.
|
|
*/
|
|
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_;
|
|
};
|
|
|
|
// rootSpan() must ignore the ambient active span and start a brand-new trace.
|
|
TEST_F(SpanGuardScopeTest, rootSpan_ignores_ambient_parent)
|
|
{
|
|
{
|
|
// Ambient span becomes the active span on this thread.
|
|
auto ambient = SpanGuard::span(TraceCategory::Rpc, "rpc", "command");
|
|
ASSERT_TRUE(static_cast<bool>(ambient));
|
|
|
|
// rootSpan must NOT inherit the ambient span as its parent.
|
|
auto root = SpanGuard::rootSpan(TraceCategory::Peer, "peer", "validation.receive");
|
|
ASSERT_TRUE(static_cast<bool>(root));
|
|
} // root ends first, then ambient -- both on this thread.
|
|
|
|
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 rootSpan has NO parent and lives in a DIFFERENT trace than ambient.
|
|
EXPECT_FALSE(root->GetParentSpanId().IsValid());
|
|
EXPECT_TRUE(root->GetTraceId().IsValid());
|
|
EXPECT_NE(root->GetTraceId(), ambient->GetTraceId());
|
|
}
|
|
|
|
// detached() pops the Scope on the origin thread, so ending the guard on
|
|
// another thread leaves the origin thread's context stack clean.
|
|
TEST_F(SpanGuardScopeTest, detached_does_not_corrupt_origin_stack)
|
|
{
|
|
// Scoped guard is active on this (origin) thread.
|
|
auto guard = SpanGuard::span(TraceCategory::Ledger, "ledger", "build");
|
|
ASSERT_TRUE(static_cast<bool>(guard));
|
|
|
|
// Strip the thread-local Scope here on the origin thread.
|
|
auto detached = std::move(guard).detached();
|
|
ASSERT_TRUE(static_cast<bool>(detached));
|
|
|
|
// End the detached span on a worker thread.
|
|
std::thread worker([d = std::move(detached)]() mutable {});
|
|
worker.join();
|
|
|
|
// Back on the origin thread: a new span must be a fresh root, proving the
|
|
// origin stack top was restored by detached() (not left holding
|
|
// ledger.build).
|
|
{
|
|
auto after = SpanGuard::span(TraceCategory::Rpc, "rpc", "command");
|
|
ASSERT_TRUE(static_cast<bool>(after));
|
|
}
|
|
|
|
auto spans = spanData()->GetSpans();
|
|
auto* build = findSpan(spans, "ledger.build");
|
|
auto* after = findSpan(spans, "rpc.command");
|
|
ASSERT_NE(build, nullptr);
|
|
ASSERT_NE(after, nullptr);
|
|
|
|
// 'after' is a new root: zero parent and a different trace than
|
|
// ledger.build.
|
|
EXPECT_FALSE(after->GetParentSpanId().IsValid());
|
|
EXPECT_NE(after->GetTraceId(), build->GetTraceId());
|
|
}
|
|
|
|
// A detached span is ended exactly once, by the worker thread that owns it.
|
|
TEST_F(SpanGuardScopeTest, detached_span_ends_once)
|
|
{
|
|
auto guard = SpanGuard::span(TraceCategory::Ledger, "ledger", "build");
|
|
auto detached = std::move(guard).detached();
|
|
ASSERT_TRUE(static_cast<bool>(detached));
|
|
|
|
// Before the worker ends it, the span is still open: nothing exported yet.
|
|
auto before = spanData()->GetSpans();
|
|
EXPECT_EQ(countSpans(before, "ledger.build"), 0u);
|
|
|
|
// Ending happens once, on the worker thread, when the guard is destroyed.
|
|
{
|
|
std::thread worker([d = std::move(detached)]() mutable {});
|
|
worker.join();
|
|
}
|
|
|
|
auto after = spanData()->GetSpans();
|
|
EXPECT_EQ(countSpans(after, "ledger.build"), 1u);
|
|
}
|
|
|
|
// rootSpan().detached() is the combination used at RPC coroutine entry points
|
|
// (e.g. RipplePathFind) that hold a span across a coro yield: the span must be
|
|
// a fresh root AND must not leave a Scope on the worker's context stack.
|
|
TEST_F(SpanGuardScopeTest, root_then_detached_is_root_and_leaves_stack_clean)
|
|
{
|
|
{
|
|
// Ambient span active on this (origin) thread.
|
|
auto ambient = SpanGuard::span(TraceCategory::Rpc, "rpc", "command");
|
|
ASSERT_TRUE(static_cast<bool>(ambient));
|
|
|
|
// Fresh root, then detach the Scope on the origin thread.
|
|
auto req = SpanGuard::rootSpan(TraceCategory::Rpc, "pathfind", "request").detached();
|
|
ASSERT_TRUE(static_cast<bool>(req));
|
|
|
|
// End the detached root span on a worker thread (mimics coro resume).
|
|
std::thread worker([r = std::move(req)]() mutable {});
|
|
worker.join();
|
|
|
|
// Origin stack must be clean: a new span here is still parented by the
|
|
// ambient span (proving the detached root did not linger on the stack).
|
|
{
|
|
auto child = SpanGuard::span(TraceCategory::Rpc, "rpc", "sub");
|
|
ASSERT_TRUE(static_cast<bool>(child));
|
|
}
|
|
}
|
|
|
|
auto spans = spanData()->GetSpans();
|
|
auto* ambient = findSpan(spans, "rpc.command");
|
|
auto* req = findSpan(spans, "pathfind.request");
|
|
auto* child = findSpan(spans, "rpc.sub");
|
|
ASSERT_NE(ambient, nullptr);
|
|
ASSERT_NE(req, nullptr);
|
|
ASSERT_NE(child, nullptr);
|
|
|
|
// The rooted-then-detached span has NO parent and a DIFFERENT trace.
|
|
EXPECT_FALSE(req->GetParentSpanId().IsValid());
|
|
EXPECT_NE(req->GetTraceId(), ambient->GetTraceId());
|
|
|
|
// The detached root did not corrupt the origin stack: 'child' still nests
|
|
// under the ambient span (same trace, parent = ambient), NOT under req.
|
|
EXPECT_EQ(child->GetParentSpanId(), ambient->GetSpanId());
|
|
EXPECT_EQ(child->GetTraceId(), ambient->GetTraceId());
|
|
}
|
|
|
|
// Negative control (documents the bug detached() fixes): a scoped guard NOT
|
|
// detached, moved to and destroyed on a worker thread, pops the WRONG thread's
|
|
// stack, so a later span on the origin thread inherits the stale span. The
|
|
// whole scenario runs on a dedicated origin thread so the corrupted
|
|
// thread-local stack cannot leak into other tests.
|
|
TEST_F(SpanGuardScopeTest, non_detached_cross_thread_corrupts_origin_stack)
|
|
{
|
|
std::thread origin([&] {
|
|
auto guard = SpanGuard::span(TraceCategory::Ledger, "ledger", "build");
|
|
{
|
|
// ~SpanGuard on the worker ends the span and Detaches on the
|
|
// WORKER stack -- the origin stack is never popped.
|
|
std::thread worker([d = std::move(guard)]() mutable {});
|
|
worker.join();
|
|
}
|
|
// Origin stack still holds the stale ledger.build as active, so this
|
|
// span inherits it as parent instead of being a root.
|
|
auto after = SpanGuard::span(TraceCategory::Rpc, "rpc", "command");
|
|
ASSERT_TRUE(static_cast<bool>(after));
|
|
});
|
|
origin.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);
|
|
|
|
// BUG: 'after' inherited the stale span -- non-zero parent, same trace.
|
|
EXPECT_TRUE(after->GetParentSpanId().IsValid());
|
|
EXPECT_EQ(after->GetParentSpanId(), build->GetSpanId());
|
|
EXPECT_EQ(after->GetTraceId(), build->GetTraceId());
|
|
}
|
|
|
|
// detachInPlace(optional&) must detach the live guard the same way the
|
|
// hand-rolled emplace(std::move(*opt).detached()) idiom does: after the call
|
|
// the guard can be moved to and ended on a worker thread without leaving the
|
|
// origin thread's context stack holding it.
|
|
TEST_F(SpanGuardScopeTest, detach_in_place_optional_detaches_active_guard)
|
|
{
|
|
// Scoped optional guard is active on this (origin) thread.
|
|
std::optional<SpanGuard> opt = SpanGuard::span(TraceCategory::Ledger, "ledger", "build");
|
|
ASSERT_TRUE(opt.has_value());
|
|
ASSERT_TRUE(static_cast<bool>(*opt));
|
|
|
|
// Strip the thread-local Scope here on the origin thread via the helper.
|
|
detachInPlace(opt);
|
|
ASSERT_TRUE(opt.has_value());
|
|
ASSERT_TRUE(static_cast<bool>(*opt));
|
|
|
|
// End the detached span on a worker thread.
|
|
std::thread worker([d = std::move(*opt)]() mutable {});
|
|
worker.join();
|
|
|
|
// Back on the origin thread: a new span must be a fresh root, proving the
|
|
// origin stack top was restored by detachInPlace() (not left holding
|
|
// ledger.build).
|
|
{
|
|
auto after = SpanGuard::span(TraceCategory::Rpc, "rpc", "command");
|
|
ASSERT_TRUE(static_cast<bool>(after));
|
|
}
|
|
|
|
auto spans = spanData()->GetSpans();
|
|
auto* build = findSpan(spans, "ledger.build");
|
|
auto* after = findSpan(spans, "rpc.command");
|
|
ASSERT_NE(build, nullptr);
|
|
ASSERT_NE(after, nullptr);
|
|
|
|
// 'after' is a new root: zero parent and a different trace than
|
|
// ledger.build.
|
|
EXPECT_FALSE(after->GetParentSpanId().IsValid());
|
|
EXPECT_NE(after->GetTraceId(), build->GetTraceId());
|
|
}
|
|
|
|
// detachInPlace(optional&) on an empty optional is a no-op: it must not crash,
|
|
// must leave the optional empty, and must export nothing.
|
|
TEST_F(SpanGuardScopeTest, detach_in_place_optional_noop_on_empty)
|
|
{
|
|
std::optional<SpanGuard> opt;
|
|
ASSERT_FALSE(opt.has_value());
|
|
|
|
detachInPlace(opt);
|
|
|
|
// Still empty, no span was created or exported.
|
|
EXPECT_FALSE(opt.has_value());
|
|
EXPECT_TRUE(spanData()->GetSpans().empty());
|
|
}
|
|
|
|
// detachInPlace(shared_ptr) must detach the live guard and return it as a new
|
|
// shared_ptr, so the returned guard can be moved to and ended on a worker
|
|
// thread without corrupting the origin thread's context stack.
|
|
TEST_F(SpanGuardScopeTest, detach_in_place_shared_detaches_active_guard)
|
|
{
|
|
// Scoped shared guard is active on this (origin) thread.
|
|
auto span =
|
|
std::make_shared<SpanGuard>(SpanGuard::span(TraceCategory::Ledger, "ledger", "build"));
|
|
ASSERT_NE(span, nullptr);
|
|
ASSERT_TRUE(static_cast<bool>(*span));
|
|
|
|
// Strip the thread-local Scope here on the origin thread via the helper.
|
|
span = detachInPlace(std::move(span));
|
|
ASSERT_NE(span, nullptr);
|
|
ASSERT_TRUE(static_cast<bool>(*span));
|
|
|
|
// End the detached span on a worker thread.
|
|
std::thread worker([d = std::move(span)]() mutable {});
|
|
worker.join();
|
|
|
|
// Back on the origin thread: a new span must be a fresh root, proving the
|
|
// origin stack top was restored by detachInPlace() (not left holding
|
|
// ledger.build).
|
|
{
|
|
auto after = SpanGuard::span(TraceCategory::Rpc, "rpc", "command");
|
|
ASSERT_TRUE(static_cast<bool>(after));
|
|
}
|
|
|
|
auto spans = spanData()->GetSpans();
|
|
auto* build = findSpan(spans, "ledger.build");
|
|
auto* after = findSpan(spans, "rpc.command");
|
|
ASSERT_NE(build, nullptr);
|
|
ASSERT_NE(after, nullptr);
|
|
|
|
// 'after' is a new root: zero parent and a different trace than
|
|
// ledger.build.
|
|
EXPECT_FALSE(after->GetParentSpanId().IsValid());
|
|
EXPECT_NE(after->GetTraceId(), build->GetTraceId());
|
|
}
|
|
|
|
// detachInPlace(shared_ptr) on a null pointer is a no-op: it must not crash and
|
|
// must return null unchanged.
|
|
TEST_F(SpanGuardScopeTest, detach_in_place_shared_noop_on_null)
|
|
{
|
|
auto result = detachInPlace(std::shared_ptr<SpanGuard>{});
|
|
EXPECT_EQ(result, nullptr);
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace xrpl::telemetry
|
|
|
|
#endif // XRPL_ENABLE_TELEMETRY
|