Observability (OpenTelemetry)
Last updated:
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 — the span shape and attribute set (ADR-0038), the
standard metrics (ADR-0039) and the W3C traceparent
transport header (ADR-0028) that layers true cross-hop span linkage on top. To get the same
ids into your application logs, see Log correlation.
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 — see atrace_idthat is not a UUID below.
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
Every SDK targets OpenTelemetry semantic conventions v1.44.0 (schema URL
https://opentelemetry.io/schemas/1.44.0) and exposes that version and URL as public
constants (Go SemconvVersion / SchemaURL, Python SEMCONV_VERSION / SEMCONV_SCHEMA_URL,
Node SEMCONV_VERSION / SEMCONV_SCHEMA_URL, Java Tracing.SEMCONV_VERSION / SCHEMA_URL,
.NET Telemetry.SemconvVersion / SchemaUrl, PHP Tracing::SEMCONV_VERSION / SCHEMA_URL).
Where you create the tracer or meter yourself, pass the schema URL when you create it; the SDK
never wraps or replaces a tracer you supply. The messaging conventions are still in
Development status upstream, so a later semconv release may rename keys again.
Each SDK emits two span kinds:
| Span | Kind | Name | When |
|---|---|---|---|
| Produce | PRODUCER |
send <queue> |
per published message |
| Consume | CONSUMER |
process <queue> |
per handled message |
<queue>is the destination: the queue or topic the message is published to (meta.queueof the produced envelope) on the producer side, andmeta.queueof the delivered envelope on the consumer side. Both sides therefore report the same destination.- With no destination (
meta.queueabsent or blank on consume, or a producer that cannot resolve a queue), the name is the bare operation,sendorprocess, andmessaging.destination.nameis not written. - The URN is never in the span name (it is unbounded in cardinality). It is the
babelqueue.urnattribute.
Producer span attributes
| Attribute | Value | Written |
|---|---|---|
messaging.system |
the system option, see system values |
always |
messaging.operation.type |
send |
always |
messaging.operation.name |
send |
always |
messaging.destination.name |
the queue/topic published to | when a destination is known |
messaging.message.id |
meta.id of the produced envelope |
once the envelope exists |
messaging.message.conversation_id |
trace_id of the produced envelope |
once the envelope exists |
babelqueue.urn |
the URN (job) |
always |
babelqueue.schema_version |
meta.schema_version (1) |
always |
error.type |
the error’s type name | only when the publish failed |
Consumer span attributes
| Attribute | Value | Written |
|---|---|---|
messaging.system |
the system option, see system values |
always |
messaging.operation.type |
process |
always |
messaging.operation.name |
process |
always |
messaging.destination.name |
meta.queue |
when present and not blank |
messaging.message.id |
meta.id |
when present |
messaging.message.conversation_id |
trace_id, byte for byte (a non-UUID value included) |
always |
messaging.consumer.group.name |
the consumer_group option |
only when the option is given (intended for Kafka) |
babelqueue.urn |
the URN (job, else the inbound urn alias) |
always |
babelqueue.schema_version |
meta.schema_version |
always |
babelqueue.attempts |
top-level attempts |
always |
error.type |
the error’s type name | only when the handler failed |
messaging.message.id carries the BabelQueue meta.id, not a broker-assigned id: it is the id
that crosses every SDK and broker.
Retries and dead-letters are visible without extra plumbing. babelqueue.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, sets the
span status to Error and sets error.type, so the failures that eventually
dead-letter a message are right there in the trace
timeline. The error is re-raised or returned to your code unchanged.
error.type is the error’s type name in the language’s own idiom, so the value is not
identical across SDKs:
| SDK | error.type |
|---|---|
| Go | fmt.Sprintf("%T", err) |
| Python | module.QualName (builtins: the bare name) |
| Node | the constructor name, else err.name |
| Java | e.getClass().getName() |
| .NET | e.GetType().FullName |
| PHP | get_class($e) |
When no type name can be obtained, the value is _OTHER. On success error.type is not written.
system values
messaging.system is the broker, so spans group correctly in the messaging views of
APM tools. The wrappers cannot see which transport you use, so you pass the value as an
optional system option; consumer_group is passed the same way.
| Broker | Binding | system |
|---|---|---|
| Redis | §1 | redis |
| RabbitMQ | §2 | rabbitmq |
| Amazon SQS | §3 | aws_sqs |
| Azure Service Bus | §4 | servicebus |
| Apache Pulsar | §5 | pulsar |
| Apache Kafka | §6 | kafka |
| ActiveMQ / Artemis | §7 | activemq |
redis is a custom value (upstream has no well-known value for it); the other six are
semconv well-known values. A value outside this set is written as given, with no validation.
An empty string counts as absent. The BabelQueue identity lives in the babelqueue.*
attributes, not in messaging.system.
How you spell the option follows the language: Go otel.WithSystem("kafka"), Python
system="kafka", Node { system: "kafka" }, Java Tracing.Options.create().system("kafka"),
.NET system: "kafka", PHP system: 'kafka'. See each SDK’s Reliability & governance page
for working snippets.
Metrics
Opt-in, like tracing: an application that does not wire a meter sees no change. The five instruments below are identical in name, unit and kind in every SDK (ADR-0039).
| Metric | Type | Unit | Recorded | Attributes |
|---|---|---|---|---|
messaging.process.duration |
histogram | s |
once per handler invocation: the wall time of the handler call, success or failure | messaging.system, messaging.operation.name = process, messaging.destination.name (if known), error.type (on failure) |
messaging.client.sent.messages |
counter | {message} |
+1 per publish attempt, success or failure | messaging.system, messaging.operation.name = send, messaging.destination.name (if known), error.type (on failure) |
messaging.client.consumed.messages |
counter | {message} |
+1 per delivery that enters the consumer wrapper | messaging.system, messaging.operation.name = process, messaging.destination.name (if known) |
babelqueue.retries |
counter | {message} |
+1 per delivery that enters the wrapper with top-level attempts > 0 |
messaging.system, messaging.operation.name = process, messaging.destination.name (if known) |
babelqueue.dlq.messages |
counter | {message} |
+1 per dead-letter the SDK itself completed (see below) | messaging.system, messaging.operation.name = process, messaging.destination.name = the source queue (if known) |
messaging.process.duration uses the semconv advisory bucket boundaries
[0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10] where the
platform API supports advice; otherwise the reader’s defaults apply.
Cardinality is bounded on purpose. These are not metric attributes, and an SDK must not
add them: babelqueue.urn, messaging.message.id, messaging.message.conversation_id /
trace_id, babelqueue.attempts, babelqueue.schema_version and the dead-letter reason.
messaging.consumer.group.name is a span attribute only, so per-group rates need the spans.
consumed.messages carries no error.type: the delivery was received, and a handler failure is
recorded on process.duration.
Enabling metrics. Go, Python, Node, Java and PHP record metrics only when you pass a meter
(WithMeter, meter=, meter, Options.meter(...), meter:), the same way you already pass
the tracer. In .NET the instruments live in a static Meter("BabelQueue") next to the
ActivitySource("BabelQueue"), so there is no option: a listener collects them when you
subscribe, for example AddMeter("BabelQueue"). These are System.Diagnostics types, so the
.NET core stays dependency-free.
What babelqueue.dlq.messages counts
The counter is fed by an optional dead-letter observer that the SDK core (or a transport
consumer) calls after it has confirmed a dead-letter. The observer is a separate, generic hook
(logging or alerting can use it too); the otel module ships the implementation that
increments the counter.
Counted, once each, after the write or call is confirmed:
- a record the SDK wrote to the BabelQueue DLQ (
<queue>.dlq, the SQS<queue>-dlq, or the configured DLQ): exhausted retries where the SDK owns retries, an unknown URN underdead_letter, a poison record, a raw version-gated body; - a native dead-letter the SDK itself initiated, which today means the Azure Service Bus
DeadLetterMessage/deadLettercall made when the BabelQueue DLQ is off. It counts only if the native call actually dead-lettered the message; a receiver that falls back to abandon does not count.
Both kinds increment the same counter, and no attribute separates a native dead-letter from a BabelQueue DLQ write.
Not counted:
- a DLQ write or native dead-letter call that failed (the message stays unsettled);
- a dead-letter the broker makes on its own, outside the SDK: SQS
RedrivePolicyaftermaxReceiveCount, Azure Service BusMaxDeliveryCount, Pulsar or Artemis broker-side policies. That includes the SQS native settle, which is a no-op that leaves the message toRedrivePolicy; - a message dropped or acknowledged because DLQ writing is disabled.
Do not add this counter to the broker’s own DLQ metric on Azure Service Bus. The messages the SDK dead-letters natively appear in both, so summing them counts those messages twice. Use this counter for BabelQueue-visible volume and the broker’s metric for broker-initiated dead-letters, or compare them rather than add them.
An observer that throws is caught and logged; it never changes how a message is settled.
Where the observer is registered differs by SDK; each Reliability & governance page shows
the call. In short: Go, Python and PHP take it on the consume runtime or the DLQ writers; Java
and .NET take it as an option on each transport consumer (SQS, Redis, Kafka, Pulsar, Artemis,
Azure Service Bus); Node takes it as deadLetterObserver on the eight adapter packages that
write DLQ records.
A trace_id that is not a UUID
Producers generate a UUID trace_id, but an SDK can still receive any string (a message from a
non-BabelQueue producer, a hand-built message, an older tool). The rule (wire contract
message-envelope.md §5.1, ADR-0038 §7):
- It is preserved and forwarded byte for byte, like a UUID one. An SDK never rejects the
message (or a
makecall that carries it) and never regenerates or rewrites the value. - The OpenTelemetry mapping hashes it (SHA-256 of the UTF-8 bytes, first 16 bytes), so every hop that carries the same value still joins one deterministic trace.
- The
otelmodule logs a warning when it hashes a value instead of decoding it. The warning is rate-limited (once per distinct value), so a producer that emits non-UUID values does not flood the log. The core decoder does not warn. "format": "uuid"in the JSON Schema is a producer-side annotation; a consumer must not use it to reject a delivered message.
Migration from the earlier span shape
Before the semconv alignment (ADR-0025 as amended by ADR-0038), every SDK emitted this shape:
| Producer | Consumer | |
|---|---|---|
| Span name | publish <urn> |
process <urn> |
messaging.system |
babelqueue |
babelqueue |
messaging.operation |
publish |
process |
messaging.destination.name |
the URN | meta.queue |
| attempts | — | messaging.babelqueue.attempts |
It was replaced because APM messaging views group by the broker in messaging.system,
messaging.operation is deprecated upstream, the producer and consumer reported different
destinations, and messaging.babelqueue.* squatted on the OpenTelemetry namespace.
This is a dashboard-breaking change. The following change at once, in the per-SDK MINOR that ships it, and each SDK’s CHANGELOG says so:
| Was | Now |
|---|---|
span name publish <urn> |
send <queue> |
span name process <urn> |
process <queue> |
producer messaging.destination.name = the URN |
the queue/topic published to; the URN moved to babelqueue.urn |
messaging.system = babelqueue |
the broker, when you pass system (see system values) |
Two keys are written next to their replacements for one MINOR, so existing queries keep working while you move them. The next MINOR of each SDK removes both, and names the removal in its CHANGELOG:
| Deprecated key (dual-emitted for one MINOR) | Replacement |
|---|---|
messaging.operation (publish or process) |
messaging.operation.type / messaging.operation.name |
messaging.babelqueue.attempts |
babelqueue.attempts |
Updating dashboards and alerts:
- Pass
system(andconsumer_groupfor Kafka) wherever you wrap a handler or publish, then group onmessaging.systemin your APM tool. - Replace span-name filters on
publish <urn>/process <urn>withsend <queue>/process <queue>, and filter by URN on thebabelqueue.urnattribute instead. - Replace
messaging.operationfilters withmessaging.operation.name, andmessaging.babelqueue.attemptswithbabelqueue.attempts. - Producer queries that matched the URN in
messaging.destination.namenow match the queue; usebabelqueue.urnfor the URN.
Not passing system is deprecated. Until you pass it, messaging.system keeps the
earlier babelqueue value, so nothing breaks and nothing groups by broker. The CHANGELOGs and
these docs mark that default as deprecated; what an absent system means after the deprecation
window is not decided yet and will be announced before any removal ships. Some SDKs also log a
one-time warning when it is absent.
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 <queue>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; a broker binding that does not
wire it degrades to v0.1 trace_id correlation with no error. The reference transports of
all six SDKs carry the header on their broker’s native per-message metadata channel (AMQP
headers, SQS MessageAttributes, Kafka record headers, Pulsar message properties, Artemis and
Azure Service Bus application properties, the BullMQ telemetry slot, a transport-owned Redis
frame, STOMP custom headers). The traceparent value must be lower-case W3C: a zero trace-id or
parent-id, or an upper-case value, is ignored and the consumer falls back to trace_id.
Go
The OTel integration lives in its own module so the core never imports OpenTelemetry.
otel.WrapHandler(tracer, handler, opts...) emits the CONSUMER span; a Publisher built with
otel.NewPublisher(tracer, app, opts...) emits the PRODUCER span and stamps the active
trace’s id into trace_id. otel.Publish(...) is the same call with the default options:
import (
babelqueue "github.com/babelqueue/babelqueue-go"
bqotel "github.com/babelqueue/babelqueue-go/otel"
)
// consumer: a "process <queue>" 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)
},
bqotel.WithSystem("redis")))
// producer: a "send <queue>" span; the downstream consumer recovers the same trace
pub := bqotel.NewPublisher(tracer, app, bqotel.WithSystem("redis"))
id, err := pub.Publish(ctx, "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. Metrics and the dead-letter observer are shown in
Reliability & governance.
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 <queue>"
app.register("urn:babel:orders:created",
otel.wrap_handler(tracer, on_order_created, system="redis"))
# producer span: "send <queue>" — carries the active trace's id into trace_id
otel.publish(tracer, app, "urn:babel:orders:created", {"order_id": 1042}, system="redis")
otel.trace_id_of(...) / otel.uuid_of(...) expose the same bijection. Metrics and the
dead-letter observer are shown in
Reliability & governance.
Per-SDK
The OTel module is optional and identical in spirit everywhere — same two span names, same
messaging-semconv attributes, same five metrics, 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.NewPublisher / otel.Publish, NewDeadLetterObserver, TraceIDOf / UUIDOf |
| Python | babelqueue[otel] extra |
otel.wrap_handler, otel.publish, otel.dead_letter_observer, trace_id_of / uuid_of |
| Node | @babelqueue/core/otel subpath |
wrapHandler, publish, createDeadLetterObserver, traceIdOf / uuidOf |
| Java | com.babelqueue.otel.Tracing |
Tracing.wrapHandler, Tracing.publish, Tracing.Options, Metrics#deadLetterObserver, traceIdOf / uuidOf |
| .NET | BabelQueue.Core Telemetry |
Telemetry.Wrap, Telemetry.PublishAsync (header-aware overloads), MetricsDeadLetterObserver |
| PHP | BabelQueue\Otel\Tracing |
producer/consumer span helpers (Tracing::wrap, Tracing::publish), Metrics (the dead-letter observer) |
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; where one does not, propagation degrades cleanly to v0.1
trace_id correlation.
See each SDK’s reference for wiring a TracerProvider:
Go, Python,
Node, Java,
PHP. To put the same trace_id, meta.id and job on your
application logs, see Log correlation.
Continue to Per-URN schema validation.