Observability (OpenTelemetry)
The wire contract already carries the one field a tracing
system needs: trace_id, a UUID generated once by the first producer and forwarded
unchanged across every hop (GR-4). That is what makes a Python producer and a Go
consumer show up on one trace. This page is the cross-SDK contract for projecting
that trace_id into OpenTelemetry (ADR-0025) and the
optional module each SDK ships to do it — plus the W3C traceparent
transport header (ADR-0028) that layers true cross-hop span linkage on top.
Status: Authoritative · Optional module · envelope frozen at
schema_version: 1
The OTel module is a tooling layer. The core never imports OpenTelemetry — wiring a
TracerProvider is opt-in (a Go submodule, a Python/Node extra, a PHP component). Nothing
is added to the envelope: correlation rides the trace_id that is already there.
trace_id ↔ OTel TraceID — a deterministic bijection
OTel TraceIDs are 128-bit (16 bytes); BabelQueue trace_ids are UUID strings. The mapping
is deterministic and the same in every SDK, so any two hops that share a trace_id derive
the same TraceID:
- A UUID
trace_id→ its 16 raw bytes, verbatim. The inverse formats those 16 bytes back to a canonical UUID string, so a producer can stamp the active trace’s id intotrace_idand the consumer recovers exactly that TraceID. - Any other string → SHA-256, first 16 bytes (and never the all-zero invalid
TraceID). This keeps non-UUID
trace_ids usable without breaking the envelope rule that producers emit a UUID.
Because the function is pure, no out-of-band header is needed for trace identity — the
trace_id field is the carrier. Exact cross-hop span parent-child linkage layers on top of
this via the W3C traceparent transport header (below):
the trace_id bijection is the v0.1 floor (every hop sharing a trace_id shares one trace),
and a delivered traceparent upgrades the consumer span to a true child of the producer span.
Spans and attributes
Each SDK emits two span kinds, named to the messaging semantic conventions:
| Span | Kind | Name | When |
|---|---|---|---|
| Consume | CONSUMER |
process <urn> |
per handled message |
| Publish | PRODUCER |
publish <urn> |
per published message |
The CONSUMER span carries the messaging-semconv attributes drawn from the envelope:
| Attribute | Source |
|---|---|
messaging.system |
babelqueue |
messaging.operation |
process |
messaging.destination.name |
meta.queue |
messaging.message.id |
meta.id |
messaging.message.conversation_id |
trace_id |
messaging.babelqueue.attempts |
top-level attempts |
Retries and dead-letters are visible without extra plumbing. attempts is on every
CONSUMER span, so a redelivery (attempts: 2) is distinguishable from a first attempt
(attempts: 0) on the trace. A handler that throws records the error on its span and sets
the span status to Error, so the failures that eventually
dead-letter a message are right there in the
trace timeline.
W3C traceparent — true cross-hop span linkage
The trace_id bijection (v0.1) gives you shared-trace correlation: every hop that shares
a trace_id shows up on one trace. What it does not carry is the exact span
parent-child edge — the producer span’s span_id as the consumer span’s parent — so a trace
UI shows the spans together but can’t draw the real produce→consume edge or attribute per-hop
latency to a specific parent span. The v0.2 mechanism (ADR-0028) closes that, and it now
ships in all six SDK cores.
It rides the out-of-band transport-header seam (the same HeaderPublisher /
ReceivedMessage.Headers mechanism that carries the replay-bypass marker) —
not an envelope field, so the wire stays frozen at schema_version: 1:
- Producer — injects the active span context as the standard W3C
traceparent(andtracestate) header via OTel’sTraceContextpropagator, then publishes through the header-carrying transport. It also still stampstrace_id(the v0.1 path), so a header-blind consumer or a transport that drops headers recovers the same trace. - Consumer — reads
traceparentoff the delivered message’s headers, extracts the remote parent, and starts theprocess <urn>span as a child of the producer span. Absent the header, it falls back to the v0.1trace_id-derived parent.
So enabling propagation is a strict upgrade, never a regression: a message produced
without a traceparent behaves exactly as v0.1. The public surface (Publish /
WrapHandler and their per-SDK equivalents) is unchanged in shape — the upgrade is internal —
and because the W3C format is the standard one, a babelqueue traceparent interoperates with
any OTel SDK or W3C-compliant peer.
Per-transport coverage is the honest limit. Propagation works on any transport that
implements the header capability and surfaces inbound headers; until a given broker binding
wires it, a publish on that broker degrades to v0.1 trace_id correlation with no error.
Today the in-tree reference transports carry it (in-memory, Redis, AMQP/RabbitMQ, SQS), and
the Node/.NET/Java adapter transports are wired across their native per-message metadata
channels (AMQP headers, SQS MessageAttributes, Kafka/Pulsar/Artemis/Azure properties,
BullMQ telemetry slot, a transport-owned Redis frame). The remaining gap is PHP’s
Kafka/Pulsar/STOMP producers, a documented follow-up.
Go
The OTel integration lives in its own module so the core never imports OpenTelemetry.
otel.WrapHandler(tracer, handler) emits the CONSUMER span; otel.Publish(...) emits the
PRODUCER span and stamps the active trace’s id into trace_id:
import (
babelqueue "github.com/babelqueue/babelqueue-go"
bqotel "github.com/babelqueue/babelqueue-go/otel"
)
// consumer: a "process <urn>" span per message, in the trace derived from trace_id
app.Handle("urn:babel:orders:created", bqotel.WrapHandler(tracer,
func(ctx context.Context, env babelqueue.Envelope) error {
return handle(ctx, env)
}))
// producer: a "publish <urn>" span; the downstream consumer recovers the same trace
id, err := bqotel.Publish(ctx, tracer, app, "urn:babel:orders:created",
map[string]any{"order_id": 1042})
bqotel.TraceIDOf(traceID) and bqotel.UUIDOf(t) expose the bijection directly if you
need to correlate by hand.
Python
The module is reachable only with the [otel] extra (pip install babelqueue[otel]), so
importing the core stays dependency-free:
from opentelemetry import trace
from babelqueue import BabelQueue, otel
tracer = trace.get_tracer("orders")
app = BabelQueue("redis://localhost:6379/0", queue="orders")
# consumer span: "process <urn>"
app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created))
# producer span: "publish <urn>" — carries the active trace's id into trace_id
otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042})
otel.trace_id_of(...) / otel.uuid_of(...) expose the same bijection.
Per-SDK
The OTel module is optional and identical in spirit everywhere — same two span names, same
messaging-semconv attributes, same trace_id ↔ TraceID bijection, and the same v0.2 W3C
traceparent span linkage, all driven off the frozen envelope.
| SDK | How it ships | Entry points |
|---|---|---|
| Go | …/otel submodule |
otel.WrapHandler, otel.Publish, TraceIDOf / UUIDOf |
| Python | babelqueue[otel] extra |
otel.wrap_handler, otel.publish, trace_id_of / uuid_of |
| Node | @babelqueue/core/otel subpath |
wrapHandler, publish, traceIdOf / uuidOf |
| Java | com.babelqueue.otel.Tracing |
Tracing.wrapHandler, Tracing.publish, traceIdOf / uuidOf |
| .NET | BabelQueue.Core Telemetry |
Telemetry.Wrap, Telemetry.PublishAsync (header-aware overloads) |
| PHP | BabelQueue\Otel\Tracing |
producer/consumer span helpers (Tracing::wrap, Tracing::publish) |
All six cores implement the v0.2 traceparent mechanism (traceparent injected on publish,
extracted on consume to parent the span); it activates on any transport that carries headers,
and degrades cleanly to v0.1 trace_id correlation otherwise.
Trace identity is bridged on every transport via the trace_id bijection (v0.1);
exact span parent-child linkage across hops, via the W3C traceparent transport header
(above), now ships in all six cores and on
every transport that carries headers — the remaining gap is PHP’s Kafka/Pulsar/STOMP
producers, where propagation cleanly degrades to v0.1 trace_id correlation.
See each SDK’s reference for wiring a TracerProvider:
Go, Python,
Node, Java,
PHP.
Continue to Per-URN schema validation.