---
title: "Distributed Tracing Across a Polyglot Queue"
description: "Turning a message that crosses PHP, Go and Python over a frozen envelope into one OpenTelemetry trace — no new field, no new dependency."
url: https://sade.dev/en/systems/polyglot-queue-distributed-tracing/
lang: en
author: "Muhammet Şafak"
published: 2026-06-19
updated: 2026-09-06
section: System
tags: ["opentelemetry","observability","distributed-tracing","messaging","architecture"]
summary: "The trick is not instrumentation. A UUID trace_id already on the wire is exactly a 16-byte OpenTelemetry TraceID — reuse it and every hop shares one trace with zero wire change. What that buys, and the cross-hop span parenting it deliberately left for a phase two that has since shipped."
---

# Distributed Tracing Across a Polyglot Queue

> The trick is not instrumentation. A UUID trace_id already on the wire is exactly a 16-byte OpenTelemetry TraceID — reuse it and every hop shares one trace with zero wire change. What that buys, and the cross-hop span parenting it deliberately left for a phase two that has since shipped.

A message is produced by a PHP service, lands on a queue, is consumed by a Go worker, which publishes a follow-up that a Python service handles. Four hops, three languages, one or more brokers in between. When that flow is slow, or one message in ten thousand dies, the question is always the same: **where did this message actually go, and what happened at each step?**

The honest answer, for most polyglot queue systems, is that nobody knows. You have a correlation id in the logs — if you remembered to log it in all three languages — and you have the patience to `grep` across three log streams on different hosts. What you do not have is a picture. You cannot see "produced by PHP in 2ms → sat in Redis for 40ms → processed by Go in 210ms, retried twice, dead-lettered" as one connected thing.

That picture is a **distributed trace**, and OpenTelemetry is the standard way to draw it. This piece is about adding it to a message standard whose wire format is **frozen** and whose cores carry **zero dependencies** — two constraints that, together, make the obvious approach illegal and force a more interesting one.

## The one rule up front

Let me state it before anything else: **do not add a field to the envelope.**

The instinct, when you want distributed tracing across a message bus, is to carry a W3C `traceparent` — the standard 55-character string that encodes a trace id, a span id, and flags. HTTP does exactly this in a header. The instinct is correct for HTTP and wrong here, because the envelope is a frozen contract. Every SDK in every language emits the byte-identical shape `job`, `trace_id`, `data`, `meta`, `attempts`. Adding a `traceparent` field — even an optional one — changes that shape, which means a version bump, which means coordinating a wire change across six language implementations and every broker binding. For a feature that is supposed to be *optional observability*, that is an absurd price.

So the rule is the constraint: solve tracing **without touching the wire**. Everything below follows from taking that seriously.

## The insight nobody uses

Here is the thing the envelope already gives you for free. The `trace_id` field is a correlation id — a UUID, minted at produce time and [forwarded unchanged across every hop](/en/systems/data-intensive-systems-breaking-points). It is already on the wire, already propagated, already the one value that ties the whole flow together.

Now look at what OpenTelemetry uses to tie a trace together: a **TraceID**. In the spec, a TraceID is exactly **16 bytes**.

A UUID is exactly 16 bytes.

That is the whole trick. A `trace_id` UUID maps one-to-one onto an OTel TraceID — strip the hyphens, read the 32 hex characters as the 16-byte id, done. (A `trace_id` that is *not* a UUID — say it came from a non-BabelQueue producer — gets hashed to 16 bytes with SHA-256, deterministically.) Every hop that shares a `trace_id` therefore derives the **same** OTel TraceID, with no agreement protocol and no new field. The correlation id you were already carrying *is* the distributed trace.

So the design writes itself:

- On the **consumer** side, wrap the handler. Before it runs, start a span named `process <urn>`, but force that span into the trace derived from the message's `trace_id`. Tag it with the messaging conventions — `messaging.system`, `messaging.destination.name`, `messaging.message.id`, and `messaging.message.conversation_id` set to the `trace_id` itself — then run the handler, and record any exception as the span's error status. The runtime's retry / dead-letter behaviour is untouched; the span just observes it.
- On the **producer** side, the mirror. Open a `publish <urn>` span, take *its* trace id, format it back into a UUID, and stamp that into the message's `trace_id` as you build the envelope. The downstream consumer, deriving its TraceID from that same `trace_id`, lands in the same trace.

Wire a `TracerProvider` for Jaeger, Tempo, Honeycomb or Datadog and the flow shows up as one waterfall, across all three languages, with per-hop timing and the retry/error markers in place. Don't wire one, and nothing changes — it is entirely opt-in.

## The mechanic, and a phantom

There is one detail worth being precise about, because it is where the design is both clever and limited.

To start a span *inside a specific trace* in OpenTelemetry, you give it a **remote parent** — a span context carrying the trace id you want. But a span context is only valid if it has *both* a trace id and a span id. We have the trace id (from `trace_id`); we do **not** have the upstream span's id, because we deliberately did not propagate one. So we synthesize a deterministic, non-zero span id by hashing the `trace_id`. The parent is valid, the consumer span lands in the right trace — but its parent points at a span that never existed. A **phantom**.

This is the honest limit of the design as it stands above, and it is worth stating plainly. Cross-hop spans all share **one trace** — you can see every step of a message's life grouped together, timed, with errors marked. What this alone does **not** give you is exact parent-child linkage *between* hops: the consumer's span is not wired as the child of the producer's span, because the producer's real span id was never carried across the wire. Within a single process the hierarchy is correct; across the queue it is flat under one trace.

Getting true cross-hop parent-child back means propagating a span id, which means a `traceparent` — and since the envelope is frozen, that `traceparent` has to ride **out of band**, as a transport header alongside whichever slot each broker binding already uses for the `trace_id`: a `bq-trace-id` attribute on SQS, Pulsar and Kafka, the native correlation id on RabbitMQ, Azure Service Bus and ActiveMQ Artemis, and on Redis — which carries no per-message metadata at all — nothing but the envelope body. That is a real feature, but it is a different scale of work: it touches every transport binding in every SDK. So it was left as a deliberate **phase two** — and that phase closed two days after this piece went up: ADR-0028 shipped `traceparent` transport-header propagation as v0.2 across all six SDK cores on 21 June 2026. A delivered `traceparent` now upgrades the consumer span to a true child of the producer span, and where it cannot be carried — PHP's Kafka, Pulsar and STOMP producers are the remaining gap — propagation degrades cleanly back to the v0.1 `trace_id` correlation described above. The ordering was the point: the phase-one design delivers correlation, per-hop timing and error/retry visibility — the 90% — with zero wire change and a few hundred lines per language, and it stays the floor that v0.2 sits on top of. Shipping the 90% first and the last 10% behind a bigger lift was the right order, the same way [you do the cheap scaling steps before the expensive ones](/en/systems/data-intensive-systems-breaking-points).

## One semantic, six packaging idioms

The constraint that the **core stays dependency-free** has a consequence: wherever the OpenTelemetry code has to import the OTel API, it cannot live in the core. So in five of the six languages it lives *beside* the core, reached only when you opt in — and "beside the core, optional" is spelled differently in every ecosystem. (.NET is the exception, for a reason we will get to.) The semantics are identical in all six; the packaging is where each language shows its personality:

- **Go** — a separate module (`babelqueue-go/otel`, its own `go.mod`), exactly like the transport submodules. The core module never sees the OTel dependency.
- **Python** — an `[otel]` extra. `pip install babelqueue[otel]` pulls `opentelemetry-api`; the module imports it, so it is only importable when you asked for it. A TraceID here is a 128-bit `int`, not bytes — same value, different shape.
- **Node** — a subpath export, `@babelqueue/core/otel`, with `@opentelemetry/api` as an *optional* peer dependency. Critically, the tracing code is **not** re-exported from the package root: if it were, importing `@babelqueue/core` would eagerly load the OTel import and break for anyone who didn't install the peer. The subpath keeps the main entry truly dependency-free.
- **Java** — an `optional` Maven dependency on `opentelemetry-api`. Optional dependencies are not transitive, so a consumer who never touches the tracing classes never pulls OTel onto their classpath.
- **.NET** — the interesting one. The idiomatic tracing primitive in .NET is `System.Diagnostics.ActivitySource`, which lives in the **base class library** — and it is *exactly* what OpenTelemetry .NET is built on. So the .NET module needs **no dependency at all** — which is why it is the one that sits *inside* the core (`BabelQueue.Core / Telemetry`) rather than beside it: it emits `Activity` objects, and the consumer's OTel pipeline collects them by calling `AddSource("BabelQueue")`. The core stays zero-dep not by isolating the dependency but by not having one.
- **PHP** — a Composer `suggest` plus a dev requirement, mirroring the existing optional helpers. The tracing namespace is there; `open-telemetry/api` is only needed if you use it.

Six idioms, one rule held in all of them: **using observability is a choice the consumer makes, never a tax the core charges.**

## What do you actually get?

Strip away the mechanics and the payoff is small to describe and large to have. In your existing tracing backend, a message that used to be a needle in three log haystacks becomes a single waterfall: which service produced it, how long it waited on the broker, how long each consumer took, whether it was retried, whether it was dead-lettered — across languages and brokers you never had to make agree on anything but a UUID they were already passing around.

And the cost of that, on the wire, is **nothing**. The envelope that shipped before tracing existed and the envelope that ships with it are byte-for-byte identical. The trace was hiding in the `trace_id` the whole time; all the work was in noticing that a UUID and a TraceID are the same sixteen bytes.

---

*See also:* the [BabelQueue observability spec](https://babelqueue.com/docs/spec/1.x/observability) writes this design down as a standard, and the SDKs that implement it — across PHP, Go, Python, Node, Java and .NET — live in the [BabelQueue ecosystem](https://github.com/BabelQueue).

## Frequently asked

**How can a UUID trace_id become an OpenTelemetry TraceID?**

Because they are the same size. An OpenTelemetry TraceID is exactly 16 bytes and a UUID is exactly 16 bytes, so the mapping is: strip the hyphens and read the 32 hex characters as the id. A trace_id that is not a UUID is hashed to 16 bytes with SHA-256, deterministically, so every hop still derives the same TraceID.

**Why not carry a W3C traceparent inside the message envelope?**

Because the envelope is a frozen contract that every SDK emits byte-identically, so adding even an optional field means a version bump coordinated across six language implementations and every broker binding. For something that is meant to be optional observability, that price is absurd; the traceparent rides out of band as a transport header instead.


## Sources

- [OpenTelemetry Tracing API: a valid trace identifier is a 16-byte array](https://opentelemetry.io/docs/specs/otel/trace/api/) — OpenTelemetry
- [W3C Trace Context: the traceparent header](https://www.w3.org/TR/trace-context/) — W3C
- [Semantic conventions for messaging spans](https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/) — OpenTelemetry
