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 into trace_id and the consumer recovers exactly that TraceID.
  • Any other stringSHA-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 (and tracestate) header via OTel’s TraceContext propagator, then publishes through the header-carrying transport. It also still stamps trace_id (the v0.1 path), so a header-blind consumer or a transport that drops headers recovers the same trace.
  • Consumer — reads traceparent off the delivered message’s headers, extracts the remote parent, and starts the process <urn> span as a child of the producer span. Absent the header, it falls back to the v0.1 trace_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.