feat(telemetry): add DeterministicIdGenerator + PendingTraceId for true-root deterministic trace_ids

Add a custom OTel IdGenerator that returns a thread-local pending trace_id on
the SDK no-parent (root) branch and a random one otherwise, plus a PendingTraceId
RAII guard that pins that id for the next forced-root span and asserts on
destruction that it was consumed. Wire the generator into
TracerProviderFactory::Create via its 4-arg overload.

This lets hash-derived spans become true trace roots so they line up into one
trace across nodes. It is installed but dormant on this branch: the caller
(hashSpan) arrives on a later branch (phase-3). GenerateSpanId is always random
and is_random_ is false so the W3C random-trace-id flag is not set on
deterministic ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pratik Mankawde
2026-07-21 18:35:00 +01:00
parent 508c91d36c
commit 4d95f455f4
3 changed files with 271 additions and 2 deletions

View File

@@ -0,0 +1,170 @@
#pragma once
#ifdef XRPL_ENABLE_TELEMETRY
#include <opentelemetry/sdk/trace/id_generator.h>
#include <array>
#include <cstdint>
#include <memory>
namespace xrpl::telemetry {
/**
* OTel IdGenerator that can mint a deterministic (hash-derived) trace_id.
*
* By default the OTel SDK generates random trace_ids, so a span derived from
* a stable hash (e.g. a transaction id) cannot become a real trace root: the
* SDK either inherits the active span as parent or invents a random root id.
* This generator lets a caller pin the trace_id of the next forced-root span
* to a chosen 16-byte value, so hash-derived spans line up across nodes into
* one trace. When no value is pinned it behaves exactly like the default
* random generator.
*
* The pinned value lives in a thread-local slot set by PendingTraceId (below).
* Only GenerateTraceId() consults that slot, and only on the SDK's no-parent
* (root) branch; span_ids are always random. is_random_ is false so the W3C
* random-trace-id flag is not set on these deterministic ids.
*
* Dependency / data-flow diagram:
*
* +-----------------------------------------------------------+
* | DeterministicIdGenerator |
* | (IdGenerator) |
* +-----------------------------------------------------------+
* | - random_ : unique_ptr<IdGenerator> (random delegate) |
* +-----------------------------------------------------------+
* | GenerateTraceId(): |
* | thread-local pending id set? --yes--> return that id |
* | --no --> random_ ... |
* | GenerateSpanId(): always ---------------> random_ ... |
* +-----------------------------------------------------------+
* ^ |
* sets/clears| delegates|
* | v
* +----------------+ +--------------------+
* | PendingTraceId | | RandomIdGenerator |
* | (RAII guard) | | (delegate) |
* +----------------+ +--------------------+
*
* @note Thread safety: the pending trace_id is a file-local thread_local, so
* each thread sees only its own pinned value and no synchronization is needed.
* The pending id is consumed ONLY by GenerateTraceId(), which the SDK calls
* solely when a span has no valid parent; GenerateSpanId() never reads it and
* is always random.
* @note Limitation: minting a deterministic root only works when the caller
* forces the SDK's root branch (e.g. by starting the span with a
* Context{kIsRootSpanKey, true}); otherwise the SDK reuses the parent's
* trace_id and never calls GenerateTraceId(). That caller (hashSpan) lands on
* a later branch, so this generator is installed but dormant here.
*
* Example 1 - primary use, inside a forced-root hash span (arrives on a later
* branch; shown as pseudocode):
* @code
* // std::array<std::uint8_t, 16> id = deriveTraceIdFromHash(txHash);
* // PendingTraceId const pending{id}; // pin id for this thread
* // auto root = Context{kIsRootSpanKey, true}; // force the no-parent branch
* // auto guard = telemetry.startSpan("tx.process", root);
* // // GenerateTraceId() returns `id`; the guard's trace_id == id.
* @endcode
*
* Example 2 - edge case, a normal child span never consults the pending id:
* @code
* // With an active parent span, startSpan() inherits the parent's trace_id
* // and the SDK does NOT call GenerateTraceId(), so no PendingTraceId is used.
* // auto child = parentGuard.childSpan("subtask"); // random/parent trace_id
* @endcode
*/
class DeterministicIdGenerator final : public opentelemetry::sdk::trace::IdGenerator
{
/**
* Random generator the deterministic path falls back to. Used for every
* span_id and for any trace_id when no PendingTraceId is active.
*/
std::unique_ptr<opentelemetry::sdk::trace::IdGenerator> random_;
public:
/**
* Build a generator with is_random_ = false and a random delegate.
*/
DeterministicIdGenerator();
/**
* @return the thread's pending trace_id if a PendingTraceId is active,
* otherwise a fresh random trace_id from the delegate. Consuming the
* pending id clears it so it applies to exactly one root span.
*/
opentelemetry::trace::TraceId
GenerateTraceId() noexcept override;
/**
* @return a fresh random span_id. Never uses the pending trace_id.
*/
opentelemetry::trace::SpanId
GenerateSpanId() noexcept override;
};
/**
* RAII guard that pins a deterministic trace_id for the next forced-root span
* started on this thread.
*
* Mirrors DiscardScope: it sets a thread-local pending trace_id on
* construction and clears it on destruction, so the pinned id stays confined
* to the guard's scope and cannot leak onto a later span. On destruction it
* asserts that the id was actually consumed by GenerateTraceId() — if it was
* not, the SDK took a branch other than the root branch the caller intended,
* which is a bug worth catching in debug/test builds.
*
* Wrap ONLY the single forced-root startSpan() call that must receive the
* deterministic id. Non-copyable and non-movable: its sole purpose is the
* scoped lifetime of the pending id.
*
* @note Thread safety: the pending id is thread-local, so a guard on one
* thread never affects another. Construct and destroy the guard on the same
* thread as the startSpan() call it wraps.
* @note Limitation: the consumed-assert only holds when the wrapped span is
* started on the SDK root branch (Context{kIsRootSpanKey, true}); wrapping a
* child span would leave the id unconsumed and trip the assert. Nesting two
* guards on one thread is unsupported: the inner guard consumes/clears first,
* so the outer id is silently dropped and its destructor trips the assert.
*
* Example 1 - primary use, wrapping a forced-root span start (pseudocode; the
* caller lands on a later branch):
* @code
* // {
* // PendingTraceId const pending{id}; // pin for this scope
* // auto root = Context{kIsRootSpanKey, true};
* // auto guard = telemetry.startSpan(name, root);// consumes the pinned id
* // } // ~PendingTraceId asserts the id was consumed, then clears it
* @endcode
*
* Example 2 - edge case, misuse detection: wrapping a non-root span leaves the
* id unconsumed, so ~PendingTraceId trips XRPL_ASSERT in debug/test builds.
*/
class PendingTraceId
{
public:
/**
* Pin @p id as the pending trace_id for this thread.
* @param id The 16-byte trace_id the next forced-root span should adopt.
*/
explicit PendingTraceId(std::array<std::uint8_t, 16> const& id) noexcept;
/**
* Assert the pinned id was consumed by a root span, then clear it so it
* never leaks onto the next span (even in release, where the assert is a
* no-op).
*/
~PendingTraceId() noexcept;
PendingTraceId(PendingTraceId const&) = delete;
PendingTraceId&
operator=(PendingTraceId const&) = delete;
PendingTraceId(PendingTraceId&&) = delete;
PendingTraceId&
operator=(PendingTraceId&&) = delete;
};
} // namespace xrpl::telemetry
#endif // XRPL_ENABLE_TELEMETRY

View File

@@ -0,0 +1,92 @@
/**
* Implementation of DeterministicIdGenerator and PendingTraceId.
*
* The pending trace_id lives in file-local thread_locals shared between the
* generator and the RAII guard. GenerateTraceId() consumes the pending id on
* the SDK's no-parent branch; PendingTraceId sets it and asserts consumption.
* All OpenTelemetry SDK types stay confined to telemetry translation units.
*
* @see DeterministicIdGenerator, PendingTraceId (DeterministicIdGenerator.h)
*/
#ifdef XRPL_ENABLE_TELEMETRY
#include <xrpl/telemetry/DeterministicIdGenerator.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <opentelemetry/nostd/span.h>
#include <opentelemetry/sdk/trace/random_id_generator_factory.h>
#include <opentelemetry/trace/span_id.h>
#include <opentelemetry/trace/trace_id.h>
#include <array>
#include <cstdint>
#include <optional>
namespace xrpl::telemetry {
namespace {
/**
* Trace_id pinned by PendingTraceId, consumed by GenerateTraceId(). File-local
* and thread-local, so it is set and read on the same thread with no locking.
*/
thread_local std::optional<std::array<std::uint8_t, 16>> tlsPendingTraceId;
/**
* True once GenerateTraceId() has consumed the pending id. ~PendingTraceId
* asserts on it to catch a forced-root span that never reached the SDK root
* branch.
*/
thread_local bool tlsPendingConsumed = false;
} // namespace
DeterministicIdGenerator::DeterministicIdGenerator()
: opentelemetry::sdk::trace::IdGenerator(/*is_random=*/false)
, random_(opentelemetry::sdk::trace::RandomIdGeneratorFactory::Create())
{
}
opentelemetry::trace::TraceId
DeterministicIdGenerator::GenerateTraceId() noexcept
{
if (tlsPendingTraceId)
{
auto const id = *tlsPendingTraceId;
tlsPendingTraceId.reset();
tlsPendingConsumed = true;
return opentelemetry::trace::TraceId(
opentelemetry::nostd::span<std::uint8_t const, 16>(id.data(), 16));
}
return random_->GenerateTraceId();
}
opentelemetry::trace::SpanId
DeterministicIdGenerator::GenerateSpanId() noexcept
{
return random_->GenerateSpanId(); // ALWAYS random — never uses the pending id
}
PendingTraceId::PendingTraceId(std::array<std::uint8_t, 16> const& id) noexcept
{
tlsPendingTraceId = id;
tlsPendingConsumed = false;
}
PendingTraceId::~PendingTraceId() noexcept
{
// The forced no-parent (root) span-start MUST have consumed the pending id
// via GenerateTraceId(). If not, the SDK took a different branch than the
// caller forced — a bug — so fail loudly in debug/test builds.
XRPL_ASSERT(
tlsPendingConsumed,
"xrpl::telemetry::PendingTraceId : deterministic trace_id was not consumed");
tlsPendingTraceId.reset(); // never leak, even in release
tlsPendingConsumed = false;
}
} // namespace xrpl::telemetry
#endif // XRPL_ENABLE_TELEMETRY

View File

@@ -20,6 +20,7 @@
#include <xrpl/basics/Log.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/telemetry/DeterministicIdGenerator.h>
#include <xrpl/telemetry/DiscardFlag.h>
#include <opentelemetry/context/context.h>
@@ -321,9 +322,15 @@ public:
std::make_shared<trace_sdk::TraceIdRatioBasedSampler>(setup_.samplingRatio);
auto sampler = trace_sdk::ParentBasedSamplerFactory::Create(std::move(rootSampler));
// Create TracerProvider
// Create TracerProvider with a DeterministicIdGenerator. It returns a
// deterministic trace_id when a PendingTraceId is active on the thread,
// else a random one — letting hash-derived roots (introduced on a later
// branch) become true trace roots. Dormant until such a caller exists.
sdkProvider_ = trace_sdk::TracerProviderFactory::Create(
std::move(processor), resourceAttrs, std::move(sampler));
std::move(processor),
resourceAttrs,
std::move(sampler),
std::make_unique<DeterministicIdGenerator>());
// Set as global provider
trace_api::Provider::SetTracerProvider(