Python (boto3)
Add Trailbeam hooks to boto3 SQS clients, what they record, and how to check they work.
Install#
The trailbeam package (version 0.1.0.dev0) is not published. Install it into your application's environment from a clone of the repository, which is private for now:
gh repo clone nick-lennox/trailbeam
pip install ./trailbeam/pythonTo work on the package itself, run pip install -e 'python[test]' from the repository root instead.
Requirements: Python 3.10 or later (CI tests 3.10 and 3.12). It pulls in opentelemetry-api, opentelemetry-sdk and opentelemetry-exporter-otlp-proto-http (all >=1.27), opentelemetry-instrumentation-botocore>=0.48b0 and botocore>=1.34.
Add the hooks#
Call trailbeam.boto.install() once per process. It works before or after you create clients.
import os
import boto3
import trailbeam.boto
trailbeam.boto.install() # reads TRAILBEAM_RECEIPT_HASH_KEY and TRAILBEAM_OTLP_TRACES_ENDPOINT
sqs = boto3.client("sqs")
url = os.environ["QUEUE_URL"]
sqs.send_message(QueueUrl=url, MessageBody="hello")
resp = sqs.receive_message(QueueUrl=url, WaitTimeSeconds=5)
for m in resp.get("Messages", []):
sqs.delete_message(QueueUrl=url, ReceiptHandle=m["ReceiptHandle"])Run it with the shared key and a service name:
export TRAILBEAM_RECEIPT_HASH_KEY='a-long-random-secret-shared-by-every-service'
export OTEL_SERVICE_NAME=orders-worker
python worker.pyFor Lambda handlers use the wrapper instead. See AWS Lambda.
How the hooks attach#
install() registers a request_hook and response_hook on opentelemetry-instrumentation-botocore's BotocoreInstrumentor.
- botocore not instrumented yet: Trailbeam calls
BotocoreInstrumentor().instrument(...)with its own tracer provider. The stock botocore span is then exported too, to the same endpoint. Trailbeam counts those spans asignored. - botocore already instrumented by your app: Trailbeam keeps your instrumentation and its tracer provider. It chains your existing hooks to run after its own.
Each Trailbeam span is a child of the stock botocore span for the same call, so it shares that trace ID.
Trailbeam uses its own TracerProvider with the ALWAYS_ON sampler and a BatchSpanProcessor. Your application's sampling never drops Trailbeam spans. Calling install() again shuts down the previous provider and replaces it. trailbeam.boto.uninstall() removes the hooks, flushes, and shuts the provider down.
The hooks only act on service sqs. They never raise: a failure inside a hook is logged at debug level on the trailbeam logger and your SQS call continues unchanged.
Options#
install() arguments#
Signature: install(receipt_hash_key=None, *, span_exporter=None, span_processor=None) -> TracerProvider.
| Option | Type | Default | What it does |
|---|---|---|---|
receipt_hash_key |
str or None |
None (then TRAILBEAM_RECEIPT_HASH_KEY, then the published default key) |
Key for the HMAC of receipt handles. Takes precedence over the environment variable. |
span_exporter |
SpanExporter or None |
None (OTLP/HTTP exporter to TRAILBEAM_OTLP_TRACES_ENDPOINT) |
Replaces the exporter. It is wrapped in a BatchSpanProcessor. |
span_processor |
SpanProcessor or None |
None |
Replaces the processor and the exporter. Use SimpleSpanProcessor in tests to export each span at once. |
The return value is the Trailbeam TracerProvider. Call .force_flush() on it when you need spans out now.
Environment variables#
| Option | Type | Default | What it does |
|---|---|---|---|
TRAILBEAM_RECEIPT_HASH_KEY |
string | trailbeam-default-receipt-hash-key, with one warning per process |
Hash key. Must be identical on every service that receives or settles messages on the same queues. A key under 16 bytes is used with one warning. |
TRAILBEAM_OTLP_TRACES_ENDPOINT |
URL | http://localhost:4318/v1/traces |
Full OTLP/HTTP traces URL (protobuf). Point it at Trailbeam or a Collector. |
OTEL_SERVICE_NAME |
string | unknown_service |
Resource service.name. The SDK default unknown_service:<executable> is replaced with unknown_service. |
OTEL_RESOURCE_ATTRIBUTES |
k=v,... |
a random UUID v4 for service.instance.id, generated on each install() call |
service.instance.id here becomes messaging.client.id, which Trailbeam uses to tell consumers apart. |
What it records#
| SQS call | Trailbeam span | Spans per call | Attributes added |
|---|---|---|---|
SendMessage |
trailbeam send |
1, if the response has a MessageId |
messaging.message.id |
SendMessageBatch |
trailbeam send |
1 per entry in Successful |
messaging.message.id, trailbeam.batch_entry_id |
ReceiveMessage |
trailbeam receive |
1 per message returned | messaging.message.id, hash, trailbeam.receive_count, trailbeam.sent_timestamp_ms, trailbeam.first_receive_timestamp_ms, trailbeam.aws_trace_header, trailbeam.dead_letter_queue_source_arn |
DeleteMessage(Batch) |
trailbeam delete |
1 per receipt handle in the request | hash, trailbeam.result, trailbeam.error_code, trailbeam.batch_entry_id (batch), messaging.message.id if known |
ChangeMessageVisibility(Batch) |
trailbeam change_visibility |
1 per receipt handle in the request | as delete, plus trailbeam.visibility_timeout (0 is recorded) |
"Hash" is trailbeam.receipt_handle.hmac (first 32 hex characters of HMAC-SHA256 of the handle) and trailbeam.receipt_handle.key_id (first 8 hex characters of HMAC-SHA256 of "trailbeam key id"). The receipt handle itself and the message body are never exported.
No span is emitted for an empty receive, a send that failed, or a failed batch-send entry. Every span also carries the common attributes: trailbeam.schema_version = 3, trailbeam.kind, trailbeam.source = client, messaging.system.name = aws.sqs, messaging.operation.type, messaging.operation.name (the API action), messaging.client.id, process.pid, thread.name, messaging.destination.name (last path segment of QueueUrl) and aws.sqs.queue.url (the QueueUrl parameter, verbatim). See the span contract.
Receive request attributes#
Before ReceiveMessage is sent, the hook adds the system attributes it needs: ApproximateReceiveCount, SentTimestamp, ApproximateFirstReceiveTimestamp, AWSTraceHeader and DeadLetterQueueSourceArn. Nothing you asked for is removed. If you already ask for All, nothing is added. If you use only the deprecated AttributeNames, the names are added there; otherwise to MessageSystemAttributeNames. Message attributes are never requested.
Settle results and error codes#
trailbeam.result is ok or failed. When it is failed, trailbeam.error_code says why:
trailbeam.error_code |
When |
|---|---|
SQS error code, e.g. ReceiptHandleIsInvalid |
The whole call returned a ClientError. Every entry gets this code. |
Exception class name, e.g. EndpointConnectionError |
The call raised before a response arrived. |
NoResponse |
No response and no exception visible. |
Entry Code from Failed, or Failed |
One batch entry failed. |
MissingFromResponse |
A batch entry is in neither Successful nor Failed. |
Handle map#
A receive stores receipt handle to message ID in an in-process map. A later settle span in the same process gets messaging.message.id from it. The map holds at most 100,000 entries (oldest dropped first), expires entries after 12 hours, and drops an entry after a successful delete. A delete from another process carries only the hash; Trailbeam joins it by hash anyway. See Receipt-handle hashing.
Timing and call order#
Each span starts just before the request is sent (when the request hook runs) and ends when the response or error arrives, read from time.time_ns(). Every span from one call, including each batch entry, shares that interval.
Each span also carries trailbeam.order.issued and trailbeam.order.returned, ticks of one counter shared by every thread in the process, and trailbeam.order.scope, a UUID naming that counter. A forked child gets a new scope and a counter starting at 1. If a fork happens while a call is in flight, that span has no trailbeam.order.issued. See Ordering.
Short-lived processes#
The BatchSpanProcessor exports in the background. Before a script or job exits, flush:
trailbeam.boto.uninstall() # flushes and shuts down the Trailbeam providerVerify it works#
In a test, capture spans in memory:
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
import trailbeam.boto
exporter = InMemorySpanExporter()
trailbeam.boto.install("test-key-at-least-16-bytes", span_processor=SimpleSpanProcessor(exporter))
# ... make SQS calls ...
print([s.name for s in exporter.get_finished_spans()])
# ['trailbeam send', 'trailbeam receive', 'trailbeam delete']
trailbeam.boto.uninstall()With Trailbeam running, check that spans arrive and the message has a timeline:
curl -s http://127.0.0.1:4318/api/health
curl -s http://127.0.0.1:4318/api/messages/<message-id>In /api/health, ingest.inserted grows and store.unjoined_settles stays at 0 when keys match. The timeline should look like this:
- 12:00:01.204Sentorders-api · SendMessage
- 12:00:01.230Receivedorders-worker · receive count 1
- 12:00:01.410Deleteorders-worker · ok · final
Common errors#
| What you see | Cause | Fix |
|---|---|---|
Log trailbeam: no receipt-hash key configured (TRAILBEAM_RECEIPT_HASH_KEY); using the published default key... and the default_key notice |
No key set | Set TRAILBEAM_RECEIPT_HASH_KEY on every service. |
Log trailbeam: the receipt-hash key is shorter than 16 bytes |
Short key | Use a key of 16 bytes or more. |
| key_mismatch notice, deletes not attached to a delivery | Services use different keys | Use one key everywhere. |
| Nothing in Trailbeam | Wrong TRAILBEAM_OTLP_TRACES_ENDPOINT, or the process exited before the batch export |
Check the URL ends in /v1/traces; call uninstall() before exit. |
service_name is unknown_service |
OTEL_SERVICE_NAME not set |
Set it per service. |
| Every worker thread looks like one consumer | Expected: messaging.client.id is per process |
Threads are told apart by thread.name. |
| Delete status stale_handle | The delete used the handle of an earlier delivery | See Visibility timeout. |
Related#
Handler
Amazon SQS terms in Vocabulary
Span
OpenTelemetry terms in Vocabulary
Hooks
trailbeam.boto.install()), the AWS SDK v3 middleware (trailbeam.instrument(client)) and the Lambda wrapper.Trailbeam span
trailbeam. Trailbeam reads only these.Sampling
OpenTelemetry terms in Vocabulary
Receipt handle
Amazon SQS terms in Vocabulary
OTLP
OpenTelemetry 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
Message
Amazon SQS terms in Vocabulary
Queue
Amazon SQS terms in Vocabulary
Collector
OpenTelemetry terms in Vocabulary
Consumer
ReceiveMessage) and then deletes them. A Lambda function behind an event source mapping is a consumer too.Amazon SQS terms in Vocabulary
Receipt-hash key
TRAILBEAM_RECEIPT_HASH_KEY. Every service on the same queues must use the same key.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
Timeline
Final delete
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
DeadLetterQueueSourceArn
Amazon SQS terms in Vocabulary
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
Settle span
trailbeam delete or trailbeam change_visibility span: a call that settles what happens to an in-flight message.In flight
Amazon SQS terms in Vocabulary
Delivery