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 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_ids usable — see a trace_id that 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.queue of the produced envelope) on the producer side, and meta.queue of the delivered envelope on the consumer side. Both sides therefore report the same destination.
  • With no destination (meta.queue absent or blank on consume, or a producer that cannot resolve a queue), the name is the bare operation, send or process, and messaging.destination.name is not written.
  • The URN is never in the span name (it is unbounded in cardinality). It is the babelqueue.urn attribute.

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 under dead_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 / deadLetter call 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 RedrivePolicy after maxReceiveCount, Azure Service Bus MaxDeliveryCount, Pulsar or Artemis broker-side policies. That includes the SQS native settle, which is a no-op that leaves the message to RedrivePolicy;
  • 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 make call 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 otel module 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:

  1. Pass system (and consumer_group for Kafka) wherever you wrap a handler or publish, then group on messaging.system in your APM tool.
  2. Replace span-name filters on publish <urn> / process <urn> with send <queue> / process <queue>, and filter by URN on the babelqueue.urn attribute instead.
  3. Replace messaging.operation filters with messaging.operation.name, and messaging.babelqueue.attempts with babelqueue.attempts.
  4. Producer queries that matched the URN in messaging.destination.name now match the queue; use babelqueue.urn for 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 (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 <queue> 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; 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.