Dead-letter & tracing
Two cross-cutting behaviours every SDK shares, both designed so a failure or a trace
crosses language boundaries unchanged: dead-lettering (where a message goes when it
can’t be processed) and trace propagation (how one correlation id ties a whole chain
of work together). Both are part of the wire contract and
neither touches the frozen schema_version: 1.
Dead-letter queue: <queue>.dlq
When a message exhausts its retries, or arrives with a URN no consumer handles (and the
unknown-URN strategy is dead_letter), it is republished to a dead-letter queue
rather than dropped. The convention is the source queue name plus a .dlq suffix —
a message that failed on orders lands on orders.dlq. Because the dead-lettered
message is still the canonical envelope, any SDK can read the DLQ and triage it,
regardless of which language produced or failed the message.
The default suffix is .dlq across the ecosystem (e.g. Laravel’s dead_letter.suffix,
Go’s WithDeadLetterSuffix). Dead-lettering is opt-in: enable it per SDK
(dead_letter: true in Python, WithDeadLetter(true) in Go, maxTries + the DLQ path
in the Node transports, the dead_letter block in Laravel config). With it disabled, a
terminally failed message is dropped instead.
The additive dead_letter block
A message sitting on a DLQ carries one extra, optional top-level field, dead_letter,
describing why it failed. It appears only on DLQ messages — never on a normal queue —
and is purely additive, so the envelope stays frozen at schema_version: 1. Normal
consumers ignore it; a triage tool reads it.
{
"job": "urn:babel:orders:created",
"trace_id": "7b3f9c2a-e41d-4f88-9b2a-1c0d5e6f7a8b",
"data": { "order_id": 1042 },
"meta": {
"id": "f1e2d3c4-b5a6-4789-90ab-cdef01234567",
"queue": "orders",
"lang": "php",
"schema_version": 1,
"created_at": 1749132727000
},
"attempts": 3,
"dead_letter": {
"reason": "failed",
"error": "Payment gateway timeout",
"exception": "App\\Exceptions\\GatewayTimeout",
"failed_at": 1749132730000,
"original_queue": "orders",
"attempts": 3,
"lang": "php"
}
}
The original envelope is preserved unchanged inside the dead-lettered message — job,
trace_id, data, meta and attempts are exactly what they were, so the DLQ message
is a faithful record of what failed and where it came from (original_queue). Every SDK
exposes a helper that wraps an envelope in this block and returns a copy, leaving the
input untouched:
| SDK | Helper |
|---|---|
| PHP / Laravel | the dead_letter policy in config/babelqueue.php (driver applies it) |
| Python | dead_letter=True runtime option (runtime applies it) |
| Go | babelqueue.Annotate(env, reason, queue, attempts, err) |
| Node.js | annotate(env, reason, queue, { attempts, error }) from @babelqueue/core |
| Java | DeadLetters.annotate(env, reason, queue, attempts, error, exception) |
| .NET | DeadLetters.Annotate(env, reason, queue, attempts: …, error: …) |
dead_letterkeys are descriptive, not contract envelope fields. This block is a diagnostic side-channel that exists only on the DLQ. Do not confuse it with the frozen envelope — and note the forbidden top-level/meta fields (timestamp,meta.max_retries,meta.source,meta.ts) still never appear, on the DLQ or anywhere else. See forbidden fields.
Broker-native dead-lettering
Some brokers have a native dead-letter mechanism, and the bindings use it alongside the
cross-language <queue>.dlq convention rather than instead of it:
- Redis / RabbitMQ / Go’s runtime — the SDK republishes the wrapped envelope to
<queue>.dlq. - Apache Kafka — no native DLQ, so the SDK owns it: terminal failures go to
<topic>.dlqwith thedead_letterblock (after the tiered<topic>.retry.<n>topics). - Apache Pulsar / ActiveMQ Artemis — a native dead-letter policy/address exists, and
the binding also writes the cross-language
<queue>.dlqso other SDKs can triage. - Amazon SQS / Azure Service Bus — these have first-class redrive /
$DeadLetterQueuefacilities you configure on the broker; the envelope on them is still canonical.
See the broker bindings for the per-broker detail.
Trace propagation (trace_id)
trace_id is a required, immutable UUID that the first producer in a chain mints,
and that every SDK then preserves and forwards unchanged across every hop — the fourth
golden rule (GR-4). It is how you follow one unit of work as it fans out across services
and languages.
The rule, straight from the consumer rules: when
a consumer produces a downstream message while handling one, it copies the inbound
trace_id onto the new envelope and mints a new meta.id. Do not conflate the two —
see id vs trace_id:
meta.id |
trace_id |
|
|---|---|---|
| Scope | one message | one causal chain (may span many messages/services) |
| Minted by | the producing SDK, per message | the first producer; reused thereafter |
| Lifetime | dies with the message | propagates across every hop |
Continuing a trace, per SDK
Every producer API takes an optional inbound trace id; pass it to continue the chain, omit it to start a fresh one:
# Python — reuse the inbound trace_id on a downstream message
app.publish("urn:babel:shipping:requested", {"order_id": 1042}, trace_id=inbound["trace_id"])
// Go — WithTraceID continues the trace; omit it to mint a new one
env, _ := babelqueue.Make(
"urn:babel:shipping:requested",
map[string]any{"order_id": 1042},
babelqueue.WithTraceID(inbound.TraceID),
)
// Node — pass traceId in the options
const env = EnvelopeCodec.make(
"urn:babel:shipping:requested",
{ order_id: 1042 },
{ traceId: inbound.trace_id },
);
// Java — pass the inbound trace id (or null to mint a fresh one)
Envelope env = EnvelopeCodec.make(
"urn:babel:shipping:requested", Map.of("order_id", 1042L), "orders", inbound.traceId());
// .NET — pass traceId to continue the trace
var env = EnvelopeCodec.Make(
"urn:babel:shipping:requested",
new Dictionary<string, object?> { ["order_id"] = 1042L },
queue: "orders", traceId: inbound.TraceId);
The framework adapters automate this so you never plumb it by hand: Laravel hands the
handler the $traceId and reuses it; Symfony’s babelqueue.messenger.trace_middleware
attaches a BabelTraceStamp so any message a handler dispatches inherits the inbound
trace_id.
Tracing through the DLQ
Because the dead-lettered message preserves the original envelope unchanged, trace_id
survives dead-lettering too. A message that failed three hops into a chain still carries
the chain’s trace_id on orders.dlq, so you can correlate the failure back to the
original producer and every service that touched it — in any language.
In one line
A failure becomes a canonical envelope on <queue>.dlq with an additive dead_letter
block any SDK can read, and one trace_id — minted once, forwarded unchanged — ties the
whole chain together, DLQ included. Neither mechanism changes the frozen
schema_version: 1.
Back to Broker bindings · or the broker setup recipes.