--- title: "Observability (OpenTelemetry)" description: "How every SDK maps the envelope's trace_id to an OpenTelemetry TraceID (ADR-0025) — UUID to 16 bytes, else SHA-256 — and emits a CONSUMER 'process ' span and PRODUCER 'publish ' span with messaging-semconv attributes, so a trace_id shared across hops becomes one OTel trace. The v0.2 W3C traceparent transport header (ADR-0028) adds true cross-hop span parent-child linkage, now in all six SDK cores. Retry and DLQ are visible via attempts and error status. An opt-in module; the wire envelope stays frozen at schema_version 1." source: https://babelqueue.com/docs/spec/1.x/observability/ updated: 2026-06-21T00:00:00.000Z --- # Observability (OpenTelemetry) The [wire contract](/docs/spec/1.x/envelope/) 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](https://opentelemetry.io/) (ADR-0025) and the optional module each SDK ships to do it — plus the [W3C `traceparent`](#w3c-traceparent--true-cross-hop-span-linkage) 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_id`s 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 string** → **SHA-256, first 16 bytes** (and never the all-zero invalid TraceID). This keeps non-UUID `trace_id`s usable without breaking the [envelope rule](/docs/spec/1.x/envelope/#producer-rules) 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](#w3c-traceparent--true-cross-hop-span-linkage)): 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](https://opentelemetry.io/docs/specs/semconv/messaging/): | Span | Kind | Name | When | | :--- | :--- | :--- | :--- | | Consume | `CONSUMER` | `process ` | per handled message | | Publish | `PRODUCER` | `publish ` | 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](/docs/spec/1.x/dead-letter-and-tracing/) 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](/docs/spec/1.x/redrive-and-replay/)) — **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 ` 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`: ```go import ( babelqueue "github.com/babelqueue/babelqueue-go" bqotel "github.com/babelqueue/babelqueue-go/otel" ) // consumer: a "process " 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 " 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: ```python 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 " app.register("urn:babel:orders:created", otel.wrap_handler(tracer, on_order_created)) # producer span: "publish " — 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](#w3c-traceparent--true-cross-hop-span-linkage)), 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](/docs/babelqueue-go/1.x/introduction/), [Python](/docs/babelqueue-python/1.x/introduction/), [Node](/docs/babelqueue-node/1.x/introduction/), [Java](/docs/babelqueue-java/1.x/introduction/), [PHP](/docs/php-sdk/1.x/introduction/). Continue to [Per-URN schema validation](/docs/spec/1.x/schema-validation/).