--- title: "Dead-letter & tracing" description: "How a failed message lands on .dlq with the additive dead_letter block, and how trace_id is preserved and forwarded unchanged across every hop — both cross-language, both keeping the envelope frozen at schema_version 1." source: https://babelqueue.com/docs/spec/1.x/dead-letter-and-tracing/ updated: 2026-06-14T00:00:00.000Z --- # 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](/docs/spec/1.x/envelope/) and neither touches the frozen `schema_version: 1`. ## Dead-letter 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. ```json { "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_letter` keys 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](/docs/spec/1.x/envelope/#forbidden-fields). ## Broker-native dead-lettering Some brokers have a native dead-letter mechanism, and the bindings use it *alongside* the cross-language `.dlq` convention rather than instead of it: - **Redis / RabbitMQ / Go's runtime** — the SDK republishes the wrapped envelope to `.dlq`. - **Apache Kafka** — no native DLQ, so the SDK owns it: terminal failures go to `.dlq` with the `dead_letter` block (after the tiered `.retry.` topics). - **Apache Pulsar / ActiveMQ Artemis** — a native dead-letter policy/address exists, and the binding also writes the cross-language `.dlq` so other SDKs can triage. - **Amazon SQS / Azure Service Bus** — these have first-class redrive / `$DeadLetterQueue` facilities you configure on the broker; the envelope on them is still canonical. See the [broker bindings](/docs/spec/1.x/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](/docs/spec/1.x/envelope/#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`](/docs/spec/1.x/envelope/#id-vs-trace_id--do-not-conflate): | | `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 # 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 // 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), ) ``` ```ts // Node — pass traceId in the options const env = EnvelopeCodec.make( "urn:babel:shipping:requested", { order_id: 1042 }, { traceId: inbound.trace_id }, ); ``` ```java // 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()); ``` ```csharp // .NET — pass traceId to continue the trace var env = EnvelopeCodec.Make( "urn:babel:shipping:requested", new Dictionary { ["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 `.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](/docs/spec/1.x/broker-bindings/) · or the [broker setup recipes](/docs/spec/1.x/broker-recipes/).