Skip to content

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.

Based on 13 repo files at 54ea61b

README.md, docs/architecture.md, python/src/trailbeam/_emitter.py, server/src/trailbeam_store/otlp.py, server/src/trailbeam_store/timeline.py, docs/store-api.md, docs/span-contract.md, server/README.md, server/src/trailbeam_store/__main__.py, server/src/trailbeam_store/server.py, python/README.md, node/README.md, collector/README.md

On this page

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-botocore in 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:4318 by 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 clock source 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#

  1. 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.
  2. 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.
  3. 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).
  4. Tell Trailbeam about your queues. Set --lambda-timeout to your function's timeout and --max-receive-count to each queue's redrive setting, so inferred timeouts and dead-letter notices are right. See Configuration.
  5. 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