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.
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.
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)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.
trailbeam-store --max-receive-count orders=3,payments=5 # or TRAILBEAM_MAX_RECEIVE_COUNT=orders=3,payments=5The 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:
curl -s http://127.0.0.1:4318/api/health | jq .config.max_receive_counts3Step 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.
# 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:
- 14:02:00.112Sentorders-api · queue orders
- 14:02:01.020Receiveddelivery 1 · receive count 1 · orders-worker
- inferredVisibility timeout expiredafter delivery 1, before delivery 2
- 14:02:32.040Receiveddelivery 2 · receive count 2 · orders-worker
- inferredVisibility timeout expiredafter delivery 2, before delivery 3
- 14:03:03.051Receiveddelivery 3 · receive count 3 · orders-worker
- Moved to dead-letter queuefrom orders to orders-dlq · reported by SQS (DeadLetterQueueSourceArn on the DLQ receive)
- 14:05:20.300Receiveddelivery 4 · queue orders-dlq · dlq-worker
- 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_queueagain andin_dead_letter_queuebecomesfalse. - Deleted at
maxReceiveCount. If the last delivery was deleted,dead_letter_queue_movestaysnull. 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
maxReceiveCountequal to the redrive policy, sonot_seen_on_dlq_yetfires at the right count. The demo runsTRAILBEAM_MAX_RECEIVE_COUNT=orders=3against a queue with a redrive policy after 3 receives.Release transient failures explicitly.
ChangeMessageVisibilityto0shows as Visibility changed0 s, and the expiry's reason says so. That tells a deliberate retry apart from a crash in the timeline.
Related#
Message
Amazon SQS terms in Vocabulary
Dead-letter queue (DLQ)
Amazon SQS terms in Vocabulary
Receive
ReceiveMessage call returning a message. A receive does not remove the message; it starts its visibility timeout.Amazon SQS terms in Vocabulary
DeadLetterQueueSourceArn
Amazon SQS terms in Vocabulary
Queue
Amazon SQS terms in Vocabulary
Hooks
trailbeam.boto.install()), the AWS SDK v3 middleware (trailbeam.instrument(client)) and the Lambda wrapper.Span
OpenTelemetry terms in Vocabulary
Step
Message ID
MessageId, assigned by SQS when the message is sent. It stays the same on every receive, including a receive from a dead-letter queue. Trailbeam joins everything about a message by this ID.Amazon SQS terms in Vocabulary
maxReceiveCount
Amazon SQS terms in Vocabulary
Redrive policy
maxReceiveCount.Amazon SQS terms in Vocabulary
Final delete
Visibility timeout
Amazon SQS terms in Vocabulary
Delivery
Consumer
ReceiveMessage) and then deletes them. A Lambda function behind an event source mapping is a consumer too.Amazon SQS terms in Vocabulary
Receive count
ApproximateReceiveCount: how many times SQS has returned the message. It goes up by one on every receive.Amazon SQS terms in Vocabulary
Receipt handle
Amazon SQS terms in Vocabulary
Inferred
inferred: true. See Recorded and inferred steps.Lambda wrapper
trailbeam.aws_lambda.wrap (Python) or trailbeam.wrapHandler (Node.js), around a handler invoked by an event source mapping.Record
AWS Lambda terms in Vocabulary
Change visibility
ChangeMessageVisibility: sets a new visibility timeout on an in-flight message, counted from the call. A value of 0 makes it visible at once.Amazon SQS terms in Vocabulary
Timeline