Skip to main content
The gateway now ships in the praisonai-bot package. praisonai serve gateway still works exactly as documented here; for a standalone install see praisonai-bot Migration.
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

1

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.
2

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:
A production exporter follows the same shape but calls tracer.start_as_current_span(...) from the OpenTelemetry SDK inside stage. That exporter lives in the separate praisonai-plugins package — core ships only the seam and the no-op default.
3

Continue an inbound trace — parent agent.run under the caller

An inbound request arrives carrying a traceparent; pull it out with extract_carrier and pass it as parent_carrier so agent.run continues the caller’s trace instead of starting a fresh detached one.
With the no-op default, extract_carrier returns None and stage ignores parent_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.

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:

GatewayTraceHook

@runtime_checkable Protocol — the structural contract a tracer implements.

NullGatewayTraceHook

Zero-cost no-op default class used when tracing is disabled.

NULL_GATEWAY_TRACE_HOOK

Shared stateless singleton of the no-op default.

resolve_trace_hook

Returns the supplied hook, or the no-op default when passed None.

inject_context

Write the active W3C traceparent onto an outbound carrier at an egress boundary. No-op in the default.

extract_carrier

Read W3C traceparent / tracestate from an inbound header mapping and return a carrier for stage(..., parent_carrier=...). Returns None in the default.
The stage contract is deliberately dependency-free — no OpenTelemetry import lives in core:

Propagation methods (new in #4103)

Two additive methods move W3C trace context across process boundaries — one at egress, one at ingress:
In NullGatewayTraceHook, inject_context is a no-op that leaves carrier untouched, and extract_carrier always returns None. There is no OpenTelemetry import in core.

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.
Pin your tracer to these names so span names line up with the pipeline out of the box. Add new names to GATEWAY_TRACE_STAGES in a follow-up PR rather than inventing them per plugin.
The message reaches the gateway. Span attributes typically carry the channel and correlation id.
Concurrency and rate-limit policies decide whether the turn proceeds or is rejected.
The agent processes the turn end to end. This is usually the parent span for the model and tool calls below.
A single LLM request. Attributes commonly include the model name.
A single tool execution. Attributes commonly include the tool name.
The reply is placed on the outbox for reliable delivery.
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.

Pattern 2: Wrapping a custom stage

Fire the seam around an async stage with the same synchronous with block:

Pattern 3: resolve_trace_hook in a constructor

Accept an optional tracer= and resolve it once, so no stage ever branches on None:

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:
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:
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

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.
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.
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.
The no-op default is stateless, so reuse the shared NULL_GATEWAY_TRACE_HOOK singleton instead of constructing NullGatewayTraceHook() per call.
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.
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.
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

The additions in #4103 are fully additive. Existing stage(name, correlation_id=..., **attrs) call sites continue to work unchanged. The default NullGatewayTraceHook remains a zero-cost no-op with no OpenTelemetry import in core.

Gateway Overview

Bot gateway architecture and core concepts

Bot Gateway

Multi-bot WebSocket gateway — the host whose pipeline these spans wrap

Gateway Metrics

Prometheus counters — the other observability rail alongside traces

Correlation IDs

The join key that ties spans, logs, and metrics to one turn

Gateway Forensics

Crash and shutdown forensics, keyed on the same correlation id

Observability Hooks

Lifecycle hooks for logging, metrics, and tracing

Gateway Observability

The gateway observability section that links here