Skip to content

Trace a message to the dead-letter queue

See each failed delivery, the move SQS reported, and what is waiting in the DLQ, and know why a move is or is not shown.

Based on 12 repo files at 54ea61b

server/src/trailbeam_store/timeline.py, server/src/trailbeam_store/queries.py, server/src/trailbeam_store/__main__.py, server/src/trailbeam_store/web.py, server/tests/test_timeline.py, python/src/trailbeam/boto.py, python/src/trailbeam/aws_lambda.py, node/src/sqs.js, docs/span-contract.md, docs/store-api.md, demo/README.md, demo/compose.yaml

On this page

Use this when a message ended up in a dead-letter queue (DLQ) and you need to know why, or you expected it there and cannot find it.

1Step 1: Know how Trailbeam sees the move#

SQS moves a message to the DLQ without any API call that Trailbeam could record. It reports the move afterwards: the next receive of that message, on the DLQ, carries the same MessageId and the system attribute DeadLetterQueueSourceArn, naming the queue it came from.

The hooks copy that attribute to trailbeam.dead_letter_queue_source_arn on the trailbeam receive span. Trailbeam shows a Moved to dead-letter queue step when:

  • a receive has trailbeam.dead_letter_queue_source_arn, and
  • the last : field of that ARN (the queue name) differs from the queue of that receive.

The step is inferred: false with source: "sqs_dlq_source", because SQS reported it. SQS gives it no time, so it has time: null and a window. It is placed after the last receive on the source queue (or after the send, if no receive there was observed) and before the first DLQ receive that reports it. There is one move per source queue and DLQ pair, however many DLQ receives carry the attribute.

2Step 2: Set up the DLQ reader and Trailbeam#

Instrument whatever reads the DLQ. The client hooks add DeadLetterQueueSourceArn to MessageSystemAttributeNames on every ReceiveMessage, unless you already asked for it or for All. Nothing you asked for is removed.

python
import boto3, trailbeam.boto
trailbeam.boto.install()                     # same TRAILBEAM_RECEIPT_HASH_KEY as the main consumers
sqs = boto3.client("sqs")
resp = sqs.receive_message(QueueUrl=dlq_url, WaitTimeSeconds=5)
javascript
const { SQSClient, ReceiveMessageCommand } = require('@aws-sdk/client-sqs');
const trailbeam = require('@trailbeam/sdk');
const sqs = trailbeam.instrument(new SQSClient({}));
await sqs.send(new ReceiveMessageCommand({ QueueUrl: dlqUrl, WaitTimeSeconds: 5 }));

Tell Trailbeam each queue's maxReceiveCount. Spans do not carry the redrive policy. Without it, Trailbeam never says a message is due on its DLQ.

shell
trailbeam-store --max-receive-count orders=3,payments=5     # or TRAILBEAM_MAX_RECEIVE_COUNT=orders=3,payments=5

The format is QUEUE=N, comma-separated, with N from 1 to 1000. The key is the queue name (messaging.destination.name), not its URL or ARN. A bad value stops Trailbeam at startup with expected QUEUE=N with N from 1 to 1000. Check what Trailbeam loaded:

shell
curl -s http://127.0.0.1:4318/api/health | jq .config.max_receive_counts

3Step 3: Find the message#

Messages waiting in a DLQ are on the stuck list, because their latest receive (on the DLQ) has no final delete.

shell
# Everything sitting in orders-dlq
curl -s 'http://127.0.0.1:4318/api/stuck?queue=orders-dlq' \
  | jq '.messages[] | select(.in_dead_letter_queue) | {message_id, source_queue, deliveries, age_s}'

# Messages that hit maxReceiveCount but have not been seen on the DLQ
curl -s 'http://127.0.0.1:4318/api/stuck?queue=orders' \
  | jq '.messages[] | select(.dead_letter_queue_move == "not_seen_on_dlq_yet") | .message_id'

On the screen, the queue column says (DLQ, from orders) or (maxReceiveCount reached; not seen on the DLQ yet).

4Step 4: Read the timeline#

A poison message on orders (visibility timeout 30 s, maxReceiveCount 3), read by a DLQ worker:

Timeline sample
  1. 14:02:00.112Sentorders-api · queue orders
  2. 14:02:01.020Receiveddelivery 1 · receive count 1 · orders-worker
  3. inferredVisibility timeout expiredafter delivery 1, before delivery 2
  4. 14:02:32.040Receiveddelivery 2 · receive count 2 · orders-worker
  5. inferredVisibility timeout expiredafter delivery 2, before delivery 3
  6. 14:03:03.051Receiveddelivery 3 · receive count 3 · orders-worker
  7. Moved to dead-letter queuefrom orders to orders-dlq · reported by SQS (DeadLetterQueueSourceArn on the DLQ receive)
  8. 14:05:20.300Receiveddelivery 4 · queue orders-dlq · dlq-worker
  9. 14:05:21.004Deletedelivery 4 · result ok · status final

The summary has source_queue: "orders", current_queue: "orders-dlq", in_dead_letter_queue: true and dead_letter_queue_move: "observed". The move event carries from_queue, to_queue, source_arn (the full ARN SQS sent) and reported_on_delivery. Delivery numbers run across queues, so the DLQ receive is delivery 4.

Compare deliveries 1 to 3. Same consumer and no delete each time, or a different consumer each time with the same failure in your logs: the problem is the message, not one consumer.

What the timeline shows Cause Fix
Moved to dead-letter queue sqs_dlq_source then a DLQ Received SQS moved it after maxReceiveCount receives Fix your processing for this payload, then redrive or delete it
No move, notice dlq_move_not_seen "Receive count exceeded maxReceiveCount; not seen on the DLQ yet." Highest receive count on the source queue reached the configured maxReceiveCount, the message is not deleted, and no DLQ receive has reported it Instrument the DLQ reader, or wait for it to poll. Trailbeam does not read the DLQ itself, because a receive changes the message's receive count, visibility and receipt handle.
A Received on orders-dlq with no move, in_dead_letter_queue: false That receive had no DeadLetterQueueSourceArn: a DLQ reader without Trailbeam hooks, or an SQS that does not set it Use the hooks on the DLQ reader
Sent, then Moved to dead-letter queue, no receives on the source queue The source queue's consumers are not instrumented. The move is anchored on the send. Instrument those consumers
Inferred Deliveries not observed before the move Some receives on the source queue were not recorded Instrument every consumer of the source queue
dead_letter_queue_move: null although the message should be in the DLQ maxReceiveCount is not configured for source_queue, or the name does not match Set --max-receive-count with the queue name

Edge cases#

  • Several DLQ receives. Every receive from the DLQ carries the attribute. The move is placed before the first of them only. Between later DLQ receives whose receive count goes up, Trailbeam adds Visibility timeout expired steps, as on any queue.
  • Receive count on the DLQ. No Deliveries not observed step is added for a first DLQ receive with a count above 1. That check runs only on the source queue. Whether AWS SQS carries the receive count over to the DLQ is not verified.
  • Redrive back to the source queue. Not shown as a step. A later receive on the source queue makes it current_queue again and in_dead_letter_queue becomes false.
  • Deleted at maxReceiveCount. If the last delivery was deleted, dead_letter_queue_move stays null. Nothing is waiting.
  • Two queues with the same name in different accounts or regions are merged in 0.1.

Prevent it#

Poison messages are a processing problem, but you can catch them before they reach the DLQ:

  • Alert on the DLQ. Run the stuck query for the DLQ from monitoring:

    shell
    curl -s 'http://127.0.0.1:4318/api/stuck?queue=orders-dlq&older_than_s=0' | jq '.messages | length'
  • Keep Trailbeam's maxReceiveCount equal to the redrive policy, so not_seen_on_dlq_yet fires at the right count. The demo runs TRAILBEAM_MAX_RECEIVE_COUNT=orders=3 against a queue with a redrive policy after 3 receives.

  • Release transient failures explicitly. ChangeMessageVisibility to 0 shows as Visibility changed 0 s, and the expiry's reason says so. That tells a deliberate retry apart from a crash in the timeline.