From e3c724423f34a603aee12dc89eb2f99decfb3888 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:32:18 +0100 Subject: [PATCH] feat(telemetry): add ScopedActivation for non-owning span activation Add SpanGuard::activate() returning a ScopedActivation RAII helper that activates an already-owned span (from a thread-free SpanGuard) as the current context WITHOUT taking ownership. The activation pushes the span onto the current LocalValue context store on construction and pops it on destruction; it never ends the span (its owning SpanGuard does). This lets a job-handoff span be made ambient for the duration of a synchronous, non-yielding worker body so log lines there carry the span's trace_id. Non-copyable and non-movable, mirroring ScopedSpanGuard. owner is captured after the Scope push via declaration-order member initialization (scope declared before owner), matching the A3 capture-after-materialization invariant. A #else no-op stub keeps the API zero-overhead when telemetry is compiled out. Co-Authored-By: Claude Opus 4.8 (1M context) --- include/xrpl/telemetry/SpanGuard.h | 120 ++++++++++++++++++++++++++++ src/libxrpl/telemetry/SpanGuard.cpp | 58 ++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/include/xrpl/telemetry/SpanGuard.h b/include/xrpl/telemetry/SpanGuard.h index d0abaa3769..d4543e145b 100644 --- a/include/xrpl/telemetry/SpanGuard.h +++ b/include/xrpl/telemetry/SpanGuard.h @@ -42,6 +42,7 @@ * | + addEvent(name) | * | + recordException(e) | * | + discard() | + * | + activate() : ScopedActivation | * | + operator bool() | * +--------------------------------------------+ * | hides (pimpl) @@ -233,6 +234,11 @@ public: // --------------------------------------------------------------------------- #ifdef XRPL_ENABLE_TELEMETRY +// SpanGuard::activate() returns a ScopedActivation by value; the full class is +// defined after ScopedSpanGuard below. Forward-declared here so the return type +// is named before its definition. +class ScopedActivation; + /** * RAII owner of an OTel span (unscoped, thread-free). * @@ -440,6 +446,17 @@ public: void discard(); + // --- Activation (non-owning) --------------------------------------- + + /** + * Activate this guard's span as the current context for the returned + * activation's lifetime, without transferring ownership. Returns a no-op + * activation if this guard is null. See ScopedActivation. + * @return an RAII activation; drop it to restore the prior context. + */ + [[nodiscard]] ScopedActivation + activate() const; + /** * @return true if this guard holds an active span. */ @@ -712,11 +729,106 @@ public: operator bool() const; }; +/** + * RAII activation of an already-owned span as the current context, WITHOUT + * taking ownership. Use to give a thread-free SpanGuard (e.g. one handed into + * a JobQueue job) ambient status for the duration of a synchronous, non-yielding + * worker body, so log lines emitted there carry the span's trace_id. The span + * is neither owned nor ended here — its owning SpanGuard still controls its + * lifetime. Non-copyable, non-movable; must be created and destroyed while the + * same context store is active (see ScopedSpanGuard). + * + * Dependency diagram: + * + * +---------------------------------------------+ + * | ScopedActivation | + * | (non-owning RAII span activation) | + * +---------------------------------------------+ + * | - impl_ : unique_ptr | + * | Impl { scope : otel::Scope; owner } | + * +---------------------------------------------+ + * | + (ctor) pushes span onto current store | + * | + (dtor) pops; does NOT end the span | + * +---------------------------------------------+ + * | activates (borrows, no ownership) + * +-----------+ ends the span + * | SpanGuard |----------------------------> (owner) + * +-----------+ + * + * @note Thread-safety: like ScopedSpanGuard, it pushes/pops the current + * LocalValue context store; construct and destroy it while the same store is + * active (asserted in debug). Must NOT outlive the SpanGuard whose span it + * activates. Intended as a short-lived stack local inside a worker body that + * runs to completion on one thread (no coro yield inside the activation). + * + * Example 1 — correlate a handed-off span's worker logs (primary use): + * @code + * // span is std::shared_ptr handed into this job body: + * auto activation = (span && *span) ? span->activate() : ScopedActivation{}; + * JLOG(j.info()) << "applied"; // carries span's trace_id + * // span is still owned/ended elsewhere; activation only pops here. + * @endcode + * + * Example 2 — edge case: a null guard yields a no-op activation: + * @code + * SpanGuard g; // null (telemetry off, or disabled category) + * auto a = g.activate(); // no-op; GetCurrent() unchanged + * @endcode + */ +class ScopedActivation +{ + struct Impl; + std::unique_ptr impl_; + friend class SpanGuard; + explicit ScopedActivation(std::unique_ptr impl); + +public: + /** + * Construct a null / no-op activation. Its destructor does nothing and + * leaves the current context unchanged. Returned by SpanGuard::activate() + * for a null guard. + */ + ScopedActivation(); + + /** + * Pop the activated span off the current context store, restoring the + * prior context. Does NOT end the span (the owning SpanGuard does that). + * A null activation is a no-op. + */ + ~ScopedActivation(); + + ScopedActivation(ScopedActivation&&) = delete; + ScopedActivation& + operator=(ScopedActivation&&) = delete; + ScopedActivation(ScopedActivation const&) = delete; + ScopedActivation& + operator=(ScopedActivation const&) = delete; +}; + // --------------------------------------------------------------------------- // No-op stub (all inline, zero overhead, no OTel dependency) // --------------------------------------------------------------------------- #else // XRPL_ENABLE_TELEMETRY not defined +/** + * No-op activation used when telemetry is disabled at compile time. Holds no + * state and does nothing on construction or destruction. Declared before + * SpanGuard so its inline activate() can return one by value. Non-copyable and + * non-movable to match the real ScopedActivation. + */ +class ScopedActivation +{ +public: + ScopedActivation() = default; + ~ScopedActivation() = default; + ScopedActivation(ScopedActivation&&) = delete; + ScopedActivation& + operator=(ScopedActivation&&) = delete; + ScopedActivation(ScopedActivation const&) = delete; + ScopedActivation& + operator=(ScopedActivation const&) = delete; +}; + class SpanGuard { public: @@ -818,6 +930,14 @@ public: { } + // NOLINTBEGIN(readability-convert-member-functions-to-static) + [[nodiscard]] ScopedActivation + activate() const + { + return {}; + } + // NOLINTEND(readability-convert-member-functions-to-static) + explicit operator bool() const { diff --git a/src/libxrpl/telemetry/SpanGuard.cpp b/src/libxrpl/telemetry/SpanGuard.cpp index 744f93a35c..c7b021c357 100644 --- a/src/libxrpl/telemetry/SpanGuard.cpp +++ b/src/libxrpl/telemetry/SpanGuard.cpp @@ -634,6 +634,64 @@ operator bool() const return impl_ && static_cast(impl_->guard); } +// ===== ScopedActivation ==================================================== + +struct ScopedActivation::Impl +{ + /** + * Active OTel Scope binding an externally-owned span to the current + * context store. Popped on destruction; never ends the span. + */ + otel_trace::Scope scope; + + /** + * Identity of the LocalValue store the Scope was pushed onto. The Scope + * must pop while the SAME store is active (see + * ScopedSpanGuard::ScopedImpl::owner). Initialized AFTER `scope` in the + * member init list: members initialize in declaration order, so the + * Scope's push (the first LocalValue touch on a fresh worker, which + * materializes the store) runs first, then this captures the + * now-materialized store. Capturing before the push would record + * nullptr while the destructor sees the materialized store. + */ + void const* owner; + + /** + * Push an externally-owned span onto the current context store. + * @param span The already-owned span to activate (not owned here). + */ + explicit Impl(opentelemetry::nostd::shared_ptr const& span) + : scope(span), owner(detail::getLocalValues().get()) + { + } +}; + +ScopedActivation::ScopedActivation() = default; + +ScopedActivation::ScopedActivation(std::unique_ptr impl) : impl_(std::move(impl)) +{ +} + +ScopedActivation::~ScopedActivation() +{ + // A live Scope must be popped while its constructing context store is + // active; destroying the activation under a different store corrupts + // that store's context stack. A null activation holds no Scope, so the + // check is skipped. + XRPL_ASSERT( + !impl_ || impl_->owner == detail::getLocalValues().get(), + "xrpl::telemetry::ScopedActivation::~ScopedActivation : destroyed on the " + "constructing context store"); +} + +ScopedActivation +SpanGuard::activate() const +{ + if (!impl_ || !impl_->span) + return {}; + return ScopedActivation(std::make_unique(impl_->span)); +} + } // namespace xrpl::telemetry #endif // XRPL_ENABLE_TELEMETRY