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) <noreply@anthropic.com>
This commit is contained in:
Pratik Mankawde
2026-07-22 19:32:18 +01:00
parent 7dbbf95c72
commit e3c724423f
2 changed files with 178 additions and 0 deletions

View File

@@ -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> |
* | 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<SpanGuard> 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> impl_;
friend class SpanGuard;
explicit ScopedActivation(std::unique_ptr<Impl> 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
{

View File

@@ -634,6 +634,64 @@ operator bool() const
return impl_ && static_cast<bool>(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<otel_trace::Span> const& span)
: scope(span), owner(detail::getLocalValues().get())
{
}
};
ScopedActivation::ScopedActivation() = default;
ScopedActivation::ScopedActivation(std::unique_ptr<Impl> 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<ScopedActivation::Impl>(impl_->span));
}
} // namespace xrpl::telemetry
#endif // XRPL_ENABLE_TELEMETRY