Is Trailbeam a good fit?
Where Trailbeam earns its place, where it doesn't, and what you take on by running 0.1. Worth five minutes before you instrument anything.
We'd rather you find out here than three weeks into a rollout. Trailbeam does one thing: it answers what happened to this SQS message? with a timeline you can defend. It is deliberately narrow, and 0.1 is a pre-release. Here is where we think it fits, and where it doesn't.
Trailbeam fits when#
Trailbeam fits if most of these are true for you. Each point names what Trailbeam actually records, so you can check it against your own needs.
You need to follow one message end to end, by its message ID. Trailbeam builds one timeline per messaging.message.id, across every service that touches the message: the send, every receive (with SQS's own ApproximateReceiveCount, SentTimestamp and ApproximateFirstReceiveTimestamp), every ChangeMessageVisibility, the delete and whether SQS accepted it, the outcome your handler reported, and the move to a dead-letter queue (from DeadLetterQueueSourceArn). Visibility timeouts that expired between those steps are drawn as inferred steps with their time window. See Reading a timeline.
You need to know which consumer handled each delivery: the service, the pod, the process and the thread. Every step on a timeline carries a consumer block:
| Field | Comes from | What it tells you |
|---|---|---|
service_name |
OTEL_SERVICE_NAME |
Which service, for example orders-worker |
client_id |
service.instance.id in OTEL_RESOURCE_ATTRIBUTES (if unset, a random UUID each time the package creates its tracer provider: once per install call or wrapped handler) |
Which instance. Set it to the pod name and you get the pod |
pid, thread_name |
The process itself | Which process and thread inside that instance |
faas_name, faas_invocation_id |
The Lambda context | Which function and which invocation (the AWS request ID). On Lambda, client_id is the log stream, so one ID per execution environment. Without a log stream name it falls back to service.instance.id |
So "received by orders-worker, pod orders-worker-7c9f-x2k4, pid 4242, thread worker-1, then deleted 40 s later by cleanup-job" is what you read off the timeline, not something you piece together from logs.
You need every step to point back to the span that produced it. Each observed step carries the trace_id and span_id of exactly one Trailbeam span, so you can open it in your tracing backend. Inferred steps are labelled inferred and carry a reason string that says what they were deduced from, for example the receive count jumping or the visibility timeout running out. Two steps are drawn in order only when a host clock, a per-process call counter, SQS's receive count or SQS's own timestamps prove it, and each step names the fact that placed it; otherwise they share a box marked concurrent, order unknown. If you've been burned by tools that showed a tidy story that turned out to be wrong, this is the trade we made. See How steps are ordered and Recorded and inferred steps.
Receives and deletes happen in different places. Batch deleters, cleanup jobs and Lambda's event source mapping all settle messages away from the code that received them. Trailbeam joins each delete to the exact delivery by an HMAC of the receipt handle, keyed with a secret you share across services, so a delete from another process lands on the right delivery even after redeliveries. This is the case Trailbeam handles best, not an edge case. See Receipt-handle hashing.
You don't want sampling to lose the message you're asked about. Trailbeam sends SQS spans through its own tracer provider with an always-on sampler, separately from your app's tracing, so every send, receive and delete is kept even if you sample traces at 1%. See Keep SQS spans when sampling.
Your SQS code is Python (boto3) or Node.js (AWS SDK v3), including Lambda. These are the only two packages. One call wraps the client or the handler, and the hooks never change a result or an error your code sees.
You don't run Datadog or another APM, and you'd rather not bring one in for this. Trailbeam is meant to be small enough to add for one queue:
- Hooks: only the OpenTelemetry SDK and its OTLP/HTTP exporter (plus
opentelemetry-instrumentation-botocorein Python). No agent or sidecar on your hosts. - Trailbeam: one container built on
python:slim, with one runtime dependency (opentelemetry-proto), a SQLite file, and OTLP/HTTP plus the timeline UI and API on one port (127.0.0.1:4318by default). - No vendor account and no SaaS. Spans go from your services to the Trailbeam you run, and nowhere else.
If you do run an APM, Trailbeam still fits. It isn't meant to replace it. The Collector overlay sends the same SQS spans to Trailbeam and to your existing backend.
Trailbeam is the wrong tool when#
| You need | Why Trailbeam isn't it (in 0.1) | Use instead |
|---|---|---|
| Queue health: depth, throughput, age of the oldest message | Trailbeam is per message. /api/stuck lists stuck messages, but there are no graphs or aggregates |
CloudWatch SQS metrics such as ApproximateAgeOfOldestMessage |
| Consumers in Java, Go, .NET or anything else | No hooks exist for them. Their deliveries show up only as gaps (deliveries_not_observed) when an instrumented consumer later sees a higher receive count | Your existing tracing for those services |
| Message bodies, or search by tenant or field | Bodies and message attributes are never recorded | Log payloads keyed by message ID. Search and payload capture are planned |
| Shared access across teams | Trailbeam has no authentication. Anyone who reaches the port can read every timeline | Keep it on 127.0.0.1 or a private network for now |
| Months of history | There is no retention policy and no delete API; the SQLite file only grows | Plan to rotate the database file |
| A supported product with published packages | Nothing is on PyPI, npm or a container registry. You install from a clone of a private repository, so you need access to it | Wait for a release, or vendor the packages |
Some cases fall in between. Be careful with these:
- Hosts with drifting clocks. The
clocksource trusts each host's wall clock, and 0.1 does not correct offsets between hosts. With NTP this is fine. Without it, a delete can appear before the receive it used. The hash join still names the right delivery, but the drawn order is wrong. - FIFO queues. The repo has no FIFO-specific handling or tests. Message groups and deduplication IDs are not recorded. We would treat FIFO as untested.
- High volume through one instance. Requests to Trailbeam are serialised over one SQLite connection, and each timeline is built on read. There are no published throughput numbers for 0.1. Trailbeam is sized for a single container, so measure before you point a busy fleet at it.
- A partly instrumented queue. If only some consumers carry the hooks, timelines have real gaps. Trailbeam says so, but it's easy to misread a gap as a bug in your code.
What you take on#
| Piece | Runs where | What it asks of you |
|---|---|---|
| Hooks | In each producer, consumer and Lambda | One span per message per SQS call; on ReceiveMessage, five extra SQS system attributes requested (receive count, timestamps, trace header, DLQ source). Hooks fail quietly (debug log) and never block the call |
| Receipt-hash key | Shared by every service on the queue | One secret of at least 16 bytes, the same everywhere. The default key works but gives no protection |
| Trailbeam | One container | A port and a disk. It makes no AWS calls and needs no AWS credentials. Bind it privately |
| Collector | Optional, otelcol-contrib |
Only if you already run one, or want SQS spans in your own backend as well |
Trailbeam never records message bodies, message attributes or receipt handles (only a keyed hash of each handle). It does record message IDs, queue names and queue URLs (a URL contains your AWS account ID), the AWSTraceHeader value, service names, process IDs, Python thread names, and Lambda function names and invocation IDs. If those need a review where you work, that's the list.
Before you decide, read Known limits. It covers every limit we know of, with what it does to your timelines.
How we'd roll it out#
- Run the Quickstart first. About ten minutes, no AWS account. The demo plays out redeliveries, a failed late delete, a dead-lettered message and duplicates, so you see what each looks like before it happens to you.
- Pick one queue and instrument all of it. That means the producer, every consumer and anything that deletes. A whole queue gives timelines you can trust; a third of the fleet gives gaps.
- Set a real receipt-hash key on day one. Changing it later breaks the join for any message received under the old key and settled under the new one (key_mismatch).
- Tell Trailbeam about your queues. Set
--lambda-timeoutto your function's timeout and--max-receive-countto each queue's redrive setting, so inferred timeouts and dead-letter notices are right. See Configuration. - Wait for a real incident, then compare. Open the message in Trailbeam next to whatever you used before. If Trailbeam didn't answer faster, tell us; that is the test that matters.
How it sits next to what you have#
Trailbeam doesn't replace your observability stack. It covers the one view that stack is bad at.
| Tool | Good at | Where Trailbeam adds something |
|---|---|---|
| CloudWatch metrics | Is the queue healthy overall? | Which message is stuck, and which step it is stuck after |
| Tracing backend (X-Ray, Jaeger, an APM) | One request's path through your services | Every SQS span is kept unsampled, and grouped by message rather than by trace. Redeliveries and deletes from other processes land on the same timeline. The Collector overlay can feed both |
| Logs | Whatever you remembered to log | Steps no one logs: the timeout that expired, the delete from another process, the move to the DLQ |
Related#
Message
Amazon SQS terms in Vocabulary
Timeline
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
Receive
ReceiveMessage call returning a message. A receive does not remove the message; it starts its visibility timeout.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
SentTimestamp
Amazon SQS terms in Vocabulary
ApproximateFirstReceiveTimestamp
Amazon SQS 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
Handler
Amazon SQS terms in Vocabulary
Dead-letter queue (DLQ)
Amazon SQS terms in Vocabulary
DeadLetterQueueSourceArn
Amazon SQS terms in Vocabulary
Visibility timeout
Amazon SQS terms in Vocabulary
Inferred
inferred: true. See Recorded and inferred steps.Step
Consumer
ReceiveMessage) and then deletes them. A Lambda function behind an event source mapping is a consumer too.Amazon SQS terms in Vocabulary
Delivery
Request ID
AWS Lambda terms in Vocabulary
Attribute
messaging.message.id.OpenTelemetry terms in Vocabulary
Span
OpenTelemetry terms in Vocabulary
Recorded (observed)
inferred: false.Trailbeam span
trailbeam. Trailbeam reads only these.Event source mapping
AWS Lambda terms in Vocabulary
Delete
DeleteMessage, made with a receipt handle. This is the only way a consumer removes a message. With the handle of an older receive, the call can still succeed while the message is received again.Amazon SQS terms in Vocabulary
Receipt handle
Amazon SQS terms in Vocabulary
Sampling
OpenTelemetry terms in Vocabulary
Hooks
trailbeam.boto.install()), the AWS SDK v3 middleware (trailbeam.instrument(client)) and the Lambda wrapper.Queue
Amazon SQS terms in Vocabulary
OTLP
OpenTelemetry terms in Vocabulary
Producer
SendMessage or SendMessageBatch).Amazon SQS terms in Vocabulary
Receipt-hash key
TRAILBEAM_RECEIPT_HASH_KEY. Every service on the same queues must use the same key.Collector
OpenTelemetry terms in Vocabulary
Function timeout
Task timed out after N seconds.