feat(telemetry): coroutine-aware OTel context storage

Backs the OTel active-context stack with xrpl::LocalValue so the ambient
context follows a JobQueue::Coro across yield/resume. Not yet installed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pratik Mankawde
2026-07-22 16:56:16 +01:00
parent d865a266b5
commit f1b7104bb8
3 changed files with 190 additions and 0 deletions

View File

@@ -243,6 +243,7 @@ xrpl.server > xrpl.resource
xrpl.shamap > xrpl.basics
xrpl.shamap > xrpl.nodestore
xrpl.shamap > xrpl.protocol
xrpl.telemetry > xrpl.basics
xrpl.telemetry > xrpl.config
xrpl.tx > xrpl.basics
xrpl.tx > xrpl.core

View File

@@ -0,0 +1,121 @@
#pragma once
/**
* A coroutine-aware OpenTelemetry runtime-context storage.
*
* OpenTelemetry's default ThreadLocalContextStorage keeps the active-context
* stack in a plain static thread_local, so the ambient span does NOT follow a
* JobQueue::Coro across yield/resume — a scope pushed on one worker is stranded
* when the coroutine resumes on another. This storage instead keeps the stack
* in an xrpl::LocalValue, which JobQueue::Coro::resume() swaps in and out with
* the coroutine (see Coro.ipp). The active context therefore rides the
* coroutine: scopes held across yield are safe, and per-line log-trace
* correlation (Log.cpp reads RuntimeContext::GetCurrent()) is retained.
*
* Off a coroutine, LocalValue transparently provides a per-thread store, so
* behaviour is identical to the default thread-local storage.
*
* +-------------------------------------------------+
* | CoroAwareContextStorage |
* | (opentelemetry RuntimeContextStorage) |
* +-------------------------------------------------+
* | - stack_ : LocalValue<vector<Context>> |
* +-------------------------------------------------+
* | + GetCurrent() : Context |
* | + Attach(ctx) : unique_ptr<Token> |
* | + Detach(token): bool |
* +-------------------------------------------------+
* | backed by (coro-aware)
* +------------------------+
* | xrpl::LocalValue store |
* | (swapped by Coro) |
* +------------------------+
*
* Install once at telemetry start via
* opentelemetry::context::RuntimeContext::SetRuntimeContextStorage(), BEFORE
* any span is created (SDK requirement).
*
* @note Thread-safety: each thread/coroutine sees its own LocalValue store, so
* the stack is never shared across threads — no locking needed. The storage
* object itself is stateless apart from the LocalValue handle.
* @note Known limitation: install once, before the first span; resetting the
* storage while spans exist is undefined behaviour (SDK). A span created on a
* raw worker thread and later moved onto a coroutine does not retro-attach to
* the coroutine's store — not a pattern here (spans are created inside their
* own coro/job body).
*
* Example 1 — install at telemetry start (primary use):
* @code
* using opentelemetry::context::RuntimeContext;
* RuntimeContext::SetRuntimeContextStorage(
* opentelemetry::nostd::shared_ptr<
* opentelemetry::context::RuntimeContextStorage>(
* new xrpl::telemetry::CoroAwareContextStorage()));
* @endcode
*
* Example 2 — a scope held across a coroutine yield stays correct:
* @code
* // Inside a JobQueue::Coro body:
* auto span = ScopedSpanGuard(TraceCategory::Rpc, "rpc", "process");
* context.coro->yield(); // may resume on another worker
* // span is still the ambient context here — the storage rode the coro.
* @endcode
*
* Example 3 — edge case: off a coroutine, behaves like thread-local storage:
* @code
* // On a plain worker thread (no coro): each thread has its own stack,
* // identical to opentelemetry's default ThreadLocalContextStorage.
* auto span = ScopedSpanGuard(TraceCategory::Ledger, "ledger", "build");
* @endcode
*/
#ifdef XRPL_ENABLE_TELEMETRY
#include <xrpl/basics/LocalValue.h>
#include <opentelemetry/context/context.h>
#include <opentelemetry/context/runtime_context.h>
#include <opentelemetry/nostd/unique_ptr.h>
#include <vector>
namespace xrpl::telemetry {
class CoroAwareContextStorage : public opentelemetry::context::RuntimeContextStorage
{
public:
CoroAwareContextStorage() = default;
/**
* @return the current (top-of-stack) context for this coro/thread.
*/
opentelemetry::context::Context
GetCurrent() noexcept override;
/**
* Push a context frame onto this coro/thread's stack.
* @param context the context to make current.
* @return a token that Detach() uses to pop back to the prior frame.
*/
opentelemetry::nostd::unique_ptr<opentelemetry::context::Token>
Attach(opentelemetry::context::Context const& context) noexcept override;
/**
* Pop the stack back through the frame the token refers to.
* @param token a token returned by Attach().
* @return true if the frame was found and detached.
*/
bool
Detach(opentelemetry::context::Token& token) noexcept override;
private:
/**
* The active-context stack, stored coro-locally. LocalValue hands back the
* stack for whichever store (coro or thread) is currently installed.
*/
LocalValue<std::vector<opentelemetry::context::Context>> stack_;
};
} // namespace xrpl::telemetry
#endif // XRPL_ENABLE_TELEMETRY

View File

@@ -0,0 +1,68 @@
/**
* Implementation of CoroAwareContextStorage.
*
* Mirrors opentelemetry's ThreadLocalContextStorage stack semantics, but the
* stack lives in an xrpl::LocalValue so it follows a JobQueue::Coro across
* yield/resume. All OpenTelemetry types stay confined to this translation unit.
*
* @see CoroAwareContextStorage (CoroAwareContextStorage.h)
*/
#ifdef XRPL_ENABLE_TELEMETRY
#include <xrpl/telemetry/CoroAwareContextStorage.h>
#include <opentelemetry/context/context.h>
#include <opentelemetry/context/runtime_context.h>
#include <opentelemetry/nostd/unique_ptr.h>
namespace xrpl::telemetry {
opentelemetry::context::Context
CoroAwareContextStorage::GetCurrent() noexcept
{
auto& stack = *stack_;
return stack.empty() ? opentelemetry::context::Context{} : stack.back();
}
opentelemetry::nostd::unique_ptr<opentelemetry::context::Token>
CoroAwareContextStorage::Attach(opentelemetry::context::Context const& context) noexcept
{
stack_->push_back(context);
return CreateToken(context);
}
bool
CoroAwareContextStorage::Detach(opentelemetry::context::Token& token) noexcept
{
auto& stack = *stack_;
// Fast path: the token's frame is on top (the common LIFO case).
if (!stack.empty() && token == stack.back())
{
stack.pop_back();
return true;
}
// Fallback: token not on top — verify it is present, then pop down to and
// including it (mirrors ThreadLocalContextStorage::Detach; also detaches
// any child frames left above it).
bool found = false;
for (auto const& frame : stack)
{
if (token == frame)
{
found = true;
break;
}
}
if (!found)
return false;
while (!stack.empty() && !(token == stack.back()))
stack.pop_back();
if (!stack.empty())
stack.pop_back();
return true;
}
} // namespace xrpl::telemetry
#endif // XRPL_ENABLE_TELEMETRY