feat(telemetry): let a call site choose a span's role, not just its category

Span kind was derived from TraceCategory alone, so every Rpc-category span was
kServer. A category cannot tell an inbound handler from the internal work under
it, and trace backends pair kServer with kClient, so internal spans left as
kServer become unpaired edges in a service graph and read as extra inbound
requests.

SpanRole is a new xrpl-owned enum, orthogonal to TraceCategory: the category
names the subsystem and gates the span on config, the role says whether the span
handles a remote call. It is a defaulted fourth parameter on span(), freshRoot()
and the ScopedSpanGuard equivalents, defaulting to SpanRole::FromCategory, so no
existing call site changes. resolveSpanKind() applies an explicit role and falls
back to the category map, which keeps its single responsibility. The
telemetry-disabled stubs mirror all four signatures.

No call site passes a role yet. The two that need it are on a later branch.

Also fixes a ScopedSpanGuard example that passed a bare op:: suffix to
childSpan(), which takes the name verbatim. Naming the child rpc.command made it
a child of rpc.command.<cmd>, inverting the hierarchy, so the example's parent is
now rpc.process and the command attribute moved onto the command span.
This commit is contained in:
Pratik Mankawde
2026-09-07 13:39:57 +01:00
parent 4d2841ccda
commit 18abd100b5
2 changed files with 147 additions and 25 deletions

View File

@@ -168,11 +168,13 @@ namespace {
constexpr char const* kLinkTypeKey = "link_type";
constexpr char const* kLinkTypeFollowsFrom = "follows_from";
// Map a TraceCategory to an OTel SpanKind so Tempo's service-graph /
// RED metrics see the correct direction. RPC spans are emitted at the
// server entry point (handler dispatch), Peer spans at inbound-message
// receipt. Transactions / Consensus / Ledger are internal processing
// and keep the default kInternal.
// Per-category default OTel SpanKind, used when a call site passes no
// SpanRole. A category cannot tell an inbound entry point from the
// internal work under it, so RPC and Peer default to the entry-point
// kind and any call site below the entry point passes SpanRole::Internal
// instead. Transactions / Consensus / Ledger are internal throughout.
// The kind drives direction in Tempo's service-graph / RED metrics,
// which pair kServer with kClient and kConsumer with kProducer.
otel_trace::SpanKind
categoryToSpanKind(TraceCategory cat)
{
@@ -190,6 +192,38 @@ categoryToSpanKind(TraceCategory cat)
return otel_trace::SpanKind::kInternal; // unreachable
}
/**
* Resolve the span kind to start a span with.
*
* An explicit SpanRole wins; SpanRole::FromCategory falls back to the
* category default above. Role and category are separate axes, so a single
* category can emit both an inbound handler and the internal work under it.
*
* @param cat Trace subsystem category. Read only for SpanRole::FromCategory.
* @param role Role the caller asked for.
* @return The OTel span kind for this span.
*/
[[nodiscard]] otel_trace::SpanKind
resolveSpanKind(TraceCategory cat, SpanRole role)
{
switch (role)
{
case SpanRole::FromCategory:
return categoryToSpanKind(cat);
case SpanRole::Internal:
return otel_trace::SpanKind::kInternal;
case SpanRole::Server:
return otel_trace::SpanKind::kServer;
case SpanRole::Client:
return otel_trace::SpanKind::kClient;
case SpanRole::Producer:
return otel_trace::SpanKind::kProducer;
case SpanRole::Consumer:
return otel_trace::SpanKind::kConsumer;
}
return categoryToSpanKind(cat); // unreachable
}
/**
* Join a span-name prefix and suffix into the dotted full name.
*
@@ -221,7 +255,11 @@ joinSpanName(std::string_view prefix, std::string_view name) noexcept
} // namespace
SpanGuard
SpanGuard::span(TraceCategory cat, std::string_view prefix, std::string_view name) noexcept
SpanGuard::span(
TraceCategory cat,
std::string_view prefix,
std::string_view name,
SpanRole role) noexcept
{
auto* tel = Telemetry::getInstance();
if ((tel == nullptr) || !tel->isEnabled() || !isCategoryEnabled(*tel, cat))
@@ -229,11 +267,15 @@ SpanGuard::span(TraceCategory cat, std::string_view prefix, std::string_view nam
auto const fullName = joinSpanName(prefix, name);
if (!fullName)
return {};
return SpanGuard(std::make_unique<Impl>(tel->startSpan(*fullName, categoryToSpanKind(cat))));
return SpanGuard(std::make_unique<Impl>(tel->startSpan(*fullName, resolveSpanKind(cat, role))));
}
SpanGuard
SpanGuard::freshRoot(TraceCategory cat, std::string_view prefix, std::string_view name) noexcept
SpanGuard::freshRoot(
TraceCategory cat,
std::string_view prefix,
std::string_view name,
SpanRole role) noexcept
{
auto* tel = Telemetry::getInstance();
if ((tel == nullptr) || !tel->isEnabled() || !isCategoryEnabled(*tel, cat))
@@ -244,7 +286,7 @@ SpanGuard::freshRoot(TraceCategory cat, std::string_view prefix, std::string_vie
// Force a fresh trace root: do NOT inherit this thread's active span.
auto rootCtx = opentelemetry::context::Context{otel_trace::kIsRootSpanKey, true};
return SpanGuard(
std::make_unique<Impl>(tel->startSpan(*fullName, rootCtx, categoryToSpanKind(cat))));
std::make_unique<Impl>(tel->startSpan(*fullName, rootCtx, resolveSpanKind(cat, role))));
}
// ===== Child / linked span creation ========================================
@@ -532,8 +574,9 @@ ScopedSpanGuard::~ScopedSpanGuard()
ScopedSpanGuard::ScopedSpanGuard(
TraceCategory cat,
std::string_view prefix,
std::string_view name) noexcept
: ScopedSpanGuard(SpanGuard::span(cat, prefix, name))
std::string_view name,
SpanRole role) noexcept
: ScopedSpanGuard(SpanGuard::span(cat, prefix, name, role))
{
}
@@ -541,9 +584,10 @@ ScopedSpanGuard
ScopedSpanGuard::freshRoot(
TraceCategory cat,
std::string_view prefix,
std::string_view name) noexcept
std::string_view name,
SpanRole role) noexcept
{
return ScopedSpanGuard(SpanGuard::freshRoot(cat, prefix, name));
return ScopedSpanGuard(SpanGuard::freshRoot(cat, prefix, name, role));
}
ScopedSpanGuard