From 4d95f455f4a7efeb95a42ada1b257e0e162c2399 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:35:00 +0100 Subject: [PATCH] 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) --- .../xrpl/telemetry/DeterministicIdGenerator.h | 170 ++++++++++++++++++ .../telemetry/DeterministicIdGenerator.cpp | 92 ++++++++++ src/libxrpl/telemetry/Telemetry.cpp | 11 +- 3 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 include/xrpl/telemetry/DeterministicIdGenerator.h create mode 100644 src/libxrpl/telemetry/DeterministicIdGenerator.cpp diff --git a/include/xrpl/telemetry/DeterministicIdGenerator.h b/include/xrpl/telemetry/DeterministicIdGenerator.h new file mode 100644 index 0000000000..6e83054797 --- /dev/null +++ b/include/xrpl/telemetry/DeterministicIdGenerator.h @@ -0,0 +1,170 @@ +#pragma once + +#ifdef XRPL_ENABLE_TELEMETRY + +#include + +#include +#include +#include + +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 (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 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 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 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 diff --git a/src/libxrpl/telemetry/DeterministicIdGenerator.cpp b/src/libxrpl/telemetry/DeterministicIdGenerator.cpp new file mode 100644 index 0000000000..2a2c8c65ec --- /dev/null +++ b/src/libxrpl/telemetry/DeterministicIdGenerator.cpp @@ -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 + +#include + +#include +#include +#include +#include + +#include +#include +#include + +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> 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(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 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 diff --git a/src/libxrpl/telemetry/Telemetry.cpp b/src/libxrpl/telemetry/Telemetry.cpp index 33c5036315..a7ba3a4a11 100644 --- a/src/libxrpl/telemetry/Telemetry.cpp +++ b/src/libxrpl/telemetry/Telemetry.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include @@ -321,9 +322,15 @@ public: std::make_shared(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()); // Set as global provider trace_api::Provider::SetTracerProvider(