Gateway Tracing Hook
Emit OpenTelemetry-compatible spans across the gateway pipeline via a zero-cost core seam
from praisonaiagents import Agent
agent = Agent(name="Support", instructions="Help the customer.")
# The gateway wraps each pipeline stage in a span your tracer can pick up
agent.start("Where is my order?")Wrap each stage of the gateway pipeline in a span your own OpenTelemetry exporter can pick up — and carry W3C trace context (traceparent / tracestate) across process boundaries so the trace tree doesn’t break at the gateway.
The user sends a turn; the gateway opens a span around every stage — inbound, admission, agent run, each LLM and tool call, outbox, delivery — so latency and failures show up per stage in Jaeger, Tempo, Datadog, or Honeycomb.
When an exporter is attached, an inbound traceparent from a caller becomes the parent of agent.run, and the active context is propagated to downstream LLM / MCP / tool calls.
Quick Start
- Simple Usage — the safe, zero-cost default
Do nothing and the gateway holds a no-op hook. Every
stage(...)returns a null context manager that ignores its arguments, so tracing adds negligible overhead until you attach a real exporter.python from praisonaiagents.gateway import NULL_GATEWAY_TRACE_HOOK # The default a gateway uses until an exporter is supplied — one shared, stateless instance with NULL_GATEWAY_TRACE_HOOK.stage("agent.run", correlation_id="turn-42", session="s1") as span: assert span is None # no-op: nothing is recorded, nothing is swallowed - With a real tracer — plug a custom hook
Any object with a matching
stage(name, *, correlation_id, **attrs)context-manager factory satisfies the protocol — no base class needed. This recorder captures each stage as it opens and closes:python from contextlib import contextmanager from praisonaiagents.gateway import resolve_trace_hook class Recorder: def __init__(self): self.events = [] def stage(self, name, *, correlation_id=None, **attrs): return self._span(name, correlation_id, attrs) @contextmanager def _span(self, name, correlation_id, attrs): self.events.append(("start", name, correlation_id)) try: yield name finally: self.events.append(("end", name, correlation_id)) tracer = resolve_trace_hook(Recorder()) with tracer.stage("agent.run", correlation_id="c1", session="s1") as span: ... # run the stage; the span opens here and closes on exitA production exporter follows the same shape but calls
tracer.start_as_current_span(...)from the OpenTelemetry SDK insidestage. That exporter lives in the separatepraisonai-pluginspackage — core ships only the seam and the no-op default. - Continue an inbound trace — parent agent.run under the caller
An inbound request arrives carrying a
traceparent; pull it out withextract_carrierand pass it asparent_carriersoagent.runcontinues the caller’s trace instead of starting a fresh detached one.python from praisonaiagents import Agent from praisonaiagents.gateway import resolve_trace_hook tracer = resolve_trace_hook(None) # swap for a real exporter in production # inbound.headers e.g. {"traceparent": "00-...-...-01", "tracestate": "..."} parent = tracer.extract_carrier(inbound.headers) agent = Agent(name="Support", instructions="Help the customer.") with tracer.stage("agent.run", correlation_id="turn-42", parent_carrier=parent, session="s1"): reply = agent.start("Where is my order?")With the no-op default,
extract_carrierreturnsNoneandstageignoresparent_carrier— the code is safe to write before you attach an exporter.
How It Works
The hook is a synchronous context-manager factory, so it wraps both sync and async stages with the same with ...: block.
| Step | What happens |
|---|---|
Enter with | The hook starts the span for that stage name |
| Body runs | The stage (sync or async) executes inside the span’s scope |
Exit with | The hook ends the span |
| Exception raised | Propagates out and the span is marked failed — implementations must not swallow it |
With W3C trace-context propagation
When an exporter is attached, the gateway continues the caller’s trace on ingress and re-injects the active context on egress, so a single distributed trace flows inbound → agent.run → tool / mcp / llm across processes.
With the no-op default, extract_carrier returns None and inject_context leaves the carrier untouched, so the same code runs unchanged with no exporter attached — agent.run simply starts a fresh root span.
Configuration Options
Core exposes the seam through five top-level symbols on praisonaiagents.gateway:
@runtime_checkable Protocol — the structural contract a tracer implements.
Zero-cost no-op default class used when tracing is disabled.
NULL_GATEWAY_TRACE_HOOKShared stateless singleton of the no-op default.
resolve_trace_hookReturns the supplied hook, or the no-op default when passed None.
Write the active W3C traceparent onto an outbound carrier at an egress boundary. No-op in the default.
Read W3C traceparent / tracestate from an inbound header mapping and return a carrier for stage(..., parent_carrier=...). Returns None in the default.
from praisonaiagents.gateway import (
GatewayTraceHook,
NullGatewayTraceHook,
NULL_GATEWAY_TRACE_HOOK,
resolve_trace_hook,
GATEWAY_TRACE_STAGES,
)The stage contract is deliberately dependency-free — no OpenTelemetry import lives in core:
def stage(
self,
name: str,
*,
correlation_id: Optional[str] = None,
parent_carrier: Optional[Mapping[str, str]] = None, # NEW
**attrs: Any,
) -> AbstractContextManager[Any]:
...Propagation methods (new in #4103)
Two additive methods move W3C trace context across process boundaries — one at egress, one at ingress:
def inject_context(self, carrier: MutableMapping[str, str]) -> None:
"""Write the active span context into `carrier` as W3C headers."""def extract_carrier(
self, carrier: Mapping[str, str]
) -> Optional[Mapping[str, str]]:
"""Return a propagation carrier extracted from inbound `carrier`, or None."""Canonical Stage Names
GATEWAY_TRACE_STAGES is a tuple of the seven canonical span names, so a tracer plugin and the wrapper agree on names without importing each other.
| Stage name | Fires around |
|---|---|
inbound | Reading a new message off a channel adapter |
admit | Admission / rate-limit / concurrency gating |
agent.run | One full agent turn |
llm.call | An individual LLM call inside a turn |
tool.call | An individual tool call inside a turn |
outbox.enqueue | Handing a reply to the outbound queue |
delivery | Actually sending the reply on the channel |
inbound — a turn arrives
The message reaches the gateway. Span attributes typically carry the channel and correlation id.
admit — admission control
Concurrency and rate-limit policies decide whether the turn proceeds or is rejected.
agent.run — the agent turn
The agent processes the turn end to end. This is usually the parent span for the model and tool calls below.
llm.call — a model call
A single LLM request. Attributes commonly include the model name.
tool.call — a tool invocation
A single tool execution. Attributes commonly include the tool name.
outbox.enqueue — queued for delivery
The reply is placed on the outbox for reliable delivery.
delivery — sent to the user
The reply is delivered to the channel. Attributes commonly include the channel.
Common Patterns
Pattern 1: Correlation id as a span attribute
Pass the inbound turn’s existing correlation id so spans and logs share a single key — the same id you already join logs on.
from praisonaiagents.gateway import NULL_GATEWAY_TRACE_HOOK
trace = NULL_GATEWAY_TRACE_HOOK # swap for a real exporter in production
with trace.stage("delivery", correlation_id="turn-42", channel="telegram"):
... # deliver the reply; the correlation id lands on the span as an attributePattern 2: Wrapping a custom stage
Fire the seam around an async stage with the same synchronous with block:
with self._trace.stage("agent.run", correlation_id=current_correlation_id(), session=sid):
reply = await agent.astart(text)Pattern 3: resolve_trace_hook in a constructor
Accept an optional tracer= and resolve it once, so no stage ever branches on None:
from praisonaiagents.gateway import resolve_trace_hook
class MyGateway:
def __init__(self, tracer=None):
self._trace = resolve_trace_hook(tracer) # a callable hook either way
async def handle(self, text, sid):
with self._trace.stage("agent.run", correlation_id="c1", session=sid):
...Pattern 4: Continue a caller’s trace on ingress
Extract the inbound carrier once and pass it as parent_carrier, so agent.run nests under the caller’s span:
# inbound.headers e.g. {"traceparent": "00-...", "tracestate": "..."}
parent = self._trace.extract_carrier(inbound.headers)
with self._trace.stage(
"agent.run",
correlation_id=current_correlation_id(),
parent_carrier=parent, # None with the no-op default; agent.run then starts a fresh root
session=sid,
):
reply = await agent.astart(text)End to end the user sees one distributed trace — inbound → agent.run → tool / mcp / llm — where the agent turn hangs off the caller’s span instead of starting over.
Pattern 5: Inject the active context on egress
Seed the outbound headers with the provider’s own headers, then inject so the active context wins over any stale traceparent:
# LLM / MCP / HTTP-tool egress: propagate the active trace to downstream services.
# Seed with provider headers first, then inject so the active context wins over
# any stale traceparent / tracestate.
headers = dict(provider_headers)
self._trace.inject_context(headers) # no-op unless an exporter is attached
await http.post(url, headers=headers, json=payload)End to end the downstream service’s span nests under agent.run, so the same distributed trace continues across the process boundary instead of breaking at the egress call.
Best Practices
Keep OpenTelemetry out of core
The seam is dependency-free on purpose. Core holds only the protocol and the no-op default, so there is no OTel import and no hot-path overhead when tracing is off. The heavy opentelemetry-sdk dependency belongs in the praisonai-plugins exporter, not in your agent code.
Reuse the correlation id as a span attribute
Pass the inbound turn’s correlation_id to every stage(...) call. Spans and logs then share one key, so a trace in Jaeger and a log line in your aggregator line up on the same id.
Do not swallow exceptions in the context manager
An exception propagating out of the with block is what marks the span as failed. Catching it inside stage hides errors from your tracer. Let it propagate — the no-op default already does.
Prefer the shared NULL_GATEWAY_TRACE_HOOK instance
The no-op default is stateless, so reuse the shared NULL_GATEWAY_TRACE_HOOK singleton instead of constructing NullGatewayTraceHook() per call.
Seed provider headers before injecting the active context
Build the egress headers with dict(provider_headers) first, then call inject_context(headers). The active traceparent then overwrites any stale one already present in the provider headers, so downstream spans nest under the current turn rather than an old trace.
Extract once per turn at the ingress boundary
Call extract_carrier(inbound.headers) once when the turn arrives and reuse the returned carrier for the agent.run stage. Re-extracting inside nested stages is wasteful and risks parenting a span under the wrong context.
`parent_carrier=None` is always safe
With the no-op default, stage ignores parent_carrier and behaves exactly as before. Code that always passes parent_carrier=self._trace.extract_carrier(inbound.headers) works whether or not an exporter is attached.
Choosing an Integration Path
Related
Bot gateway architecture and core concepts
Bot GatewayMulti-bot WebSocket gateway — the host whose pipeline these spans wrap
Gateway MetricsPrometheus counters — the other observability rail alongside traces
Correlation IDsThe join key that ties spans, logs, and metrics to one turn
Gateway ForensicsCrash and shutdown forensics, keyed on the same correlation id
Observability HooksLifecycle hooks for logging, metrics, and tracing
Gateway ObservabilityThe gateway observability section that links here