Skip to content

Debug Lambda timeouts and crashes

See how each Lambda invocation ended for each SQS record, including timeouts and out-of-memory kills the handler never saw.

Based on 13 repo files at 54ea61b

python/src/trailbeam/aws_lambda.py, node/src/lambda.js, node/index.d.ts, server/src/trailbeam_store/timeline.py, server/src/trailbeam_store/server.py, server/src/trailbeam_store/__main__.py, server/tests/test_timeline.py, server/tests/test_lambda_end_to_end.py, python/tests/test_aws_lambda.py, docs/span-contract.md, docs/store-api.md, python/README.md, node/README.md

On this page

Use this when an SQS-triggered Lambda function fails, retries, times out, or a message "disappears" between the queue and your handler.

1Step 1: Wrap the handler#

The wrapper records each SQS record twice: a trailbeam receive before your handler runs, and a trailbeam outcome after it returns or throws.

python
import trailbeam.aws_lambda

@trailbeam.aws_lambda.wrap
def handler(event, context):
    failed = []
    for record in event["Records"]:
        try:
            process(record)
        except RetryableError:
            failed.append({"itemIdentifier": record["messageId"]})
    return {"batchItemFailures": failed}
javascript
const trailbeam = require('@trailbeam/sdk');

exports.handler = trailbeam.wrapHandler(async (event, context) => {
  const batchItemFailures = [];
  for (const record of event.Records) {
    try { await process(record); } catch (e) { batchItemFailures.push({ itemIdentifier: record.messageId }); }
  }
  return { batchItemFailures };
});
Option (Python / Node.js) Type Default What it does
receipt_hash_key / receiptHashKey string TRAILBEAM_RECEIPT_HASH_KEY, else the published default key Key for the receipt-handle hash
flush_timeout_millis / flushTimeoutMs int 2000 Longest wait for each flush
report_batch_item_failures / reportBatchItemFailures bool true Read batchItemFailures from the result. Set to match the event source mapping's ReportBatchItemFailures.
span_exporter / spanExporter exporter OTLP/HTTP to TRAILBEAM_OTLP_TRACES_ENDPOINT Replace the exporter
span_processor / spanProcessor processor batch processor Replace the processor

Set TRAILBEAM_OTLP_TRACES_ENDPOINT on the function (default http://localhost:4318/v1/traces) to a Collector or Trailbeam address the function can reach.

2Step 2: Know what the wrapper records#

For each record whose eventSource is aws:sqs, before the handler runs:

  • one trailbeam receive with messaging.operation.name LambdaEvent, trailbeam.source lambda, trailbeam.receive_count, trailbeam.sent_timestamp_ms, trailbeam.first_receive_timestamp_ms, the receipt-handle hash, trailbeam.queue.arn (the record's eventSourceARN), faas.name (from the context, else AWS_LAMBDA_FUNCTION_NAME) and faas.invocation_id (aws_request_id / awsRequestId);
  • the span is zero-length, at the moment the event arrived, with trailbeam.start_observed = false. Lambda's poller made the ReceiveMessage call, so only an upper bound is known. The screen shows "no later than" that time;
  • the wrapper flushes these spans before calling your handler, so a kill cannot lose them.

After the handler, one trailbeam outcome per record, flushed again:

trailbeam.outcome When
success The handler returned, and the record is not in a valid batchItemFailures
failed_reported The record's messageId is in batchItemFailures
failed_invalid_response batchItemFailures is not a list, or one entry has an itemIdentifier that is missing, empty, not a string or not in the batch. Every record gets this value, because Lambda fails the whole batch.
failed_exception The handler threw. Every record gets this value. trailbeam.error_type is the class name only, never the message.
no outcome The process died first: timeout, out of memory, or in Python a BaseException such as SystemExit

messaging.client.id is AWS_LAMBDA_LOG_STREAM_NAME, so each execution environment is one consumer. If that variable is not set, it is the wrapper's service.instance.id. Events with no SQS records pass through untouched.

3Step 3: Tell Trailbeam your function's timeout#

Trailbeam infers a kill when a Lambda receive has no trailbeam outcome for the same faas.invocation_id once the Lambda timeout has passed. Trailbeam's timeout defaults to 900 s (the Lambda maximum), because spans do not carry your function's real timeout.

shell
trailbeam-store --lambda-timeout 30           # or TRAILBEAM_LAMBDA_TIMEOUT_S=30

Or per request, from 1 to 86400:

shell
curl -s "http://127.0.0.1:4318/api/messages/$MESSAGE_ID?lambda_timeout_s=30"

The web page takes the same parameter: /message?id=...&lambda_timeout_s=30.

4Step 4: Read the timeline#

A record whose first invocation timed out (function timeout 30 s, queue visibility timeout 60 s):

Timeline sample
  1. 08:15:00.004Sentorders-api · queue orders
  2. 08:15:00.380Receivedno later than · delivery 1 · receive count 1 · function orders-fn
  3. inferredLambda invocation ended without an outcomeA Lambda receive with no outcome after 30 s: the invocation most likely timed out or ran out of memory.
  4. inferredVisibility timeout expiredafter delivery 1, before delivery 2
  5. 08:16:01.120Receivedno later than · delivery 2 · receive count 2 · invocation 4b1e
  6. 08:16:01.940Lambda outcomedelivery 2 · outcome success
  7. inferredDeleted by LambdaThe handler succeeded for this record, so Lambda's event source mapping deletes the message. That delete is not observed.

The kill's window runs from the receive's end to the receive's end plus the Lambda timeout. It carries delivery and faas_invocation_id, so you can find the invocation in CloudWatch Logs. The final state is deleted_inferred.

What the timeline shows Cause Fix
Received, then inferred Lambda invocation ended without an outcome Timeout or out-of-memory kill. Nothing inside the process can see it. Check the invocation's REPORT line. Raise the function's timeout or memory.
Received, nothing else, state processing The invocation is still within the Lambda timeout Wait, or set --lambda-timeout to the real value
Lambda outcome failed_exception with error_type The handler threw. Every record in the batch is retried. Catch per record and return batchItemFailures
Lambda outcome failed_invalid_response on every record A batchItemFailures entry is malformed or names an ID not in the batch Return {"itemIdentifier": record["messageId"]} for failed records only
Lambda outcome failed_reported, expiry, a new delivery You reported the record as failed. SQS redelivers it after the visibility timeout. Expected for a retry. Watch the receive count against maxReceiveCount.
Lambda outcome failed_reported or failed_invalid_response, but no redelivery ever comes and the message stays not_deleted The event source mapping does not use ReportBatchItemFailures. Lambda ignored batchItemFailures and deleted the batch, unobserved. Set report_batch_item_failures=False / reportBatchItemFailures: false, or enable it on the mapping
Lambda outcome with no delivery Its receive span never reached Trailbeam (for example a flush timed out) Check the function can reach TRAILBEAM_OTLP_TRACES_ENDPOINT

5Step 5: Find invocations that are still open or killed#

processing means the latest delivery is a Lambda receive, inside the timeout, with no outcome. Such messages are also on the stuck list once they pass --stuck-after (300 s), because there is no success yet.

shell
curl -s 'http://127.0.0.1:4318/api/stuck?lambda_timeout_s=30' \
  | jq '.messages[] | {message_id, state, deliveries, age_s}'

Prevent it#

Return partial batch failures instead of throwing. A throw marks every record failed_exception and retries the whole batch. A batchItemFailures entry retries only that record.

Match the wrapper to the event source mapping. If FunctionResponseTypes does not include ReportBatchItemFailures, Lambda ignores batchItemFailures, and so must Trailbeam:

python
@trailbeam.aws_lambda.wrap(report_batch_item_failures=False)
def handler(event, context): ...
javascript
exports.handler = trailbeam.wrapHandler(handler, { reportBatchItemFailures: false });

Budget for the flushes. Each invocation flushes twice (receives, then outcomes). Each flush waits at most the flush timeout (default 2000 ms), so an unreachable endpoint can add up to about 4 s to an invocation. Lower it if your function's timeout is tight:

javascript
exports.handler = trailbeam.wrapHandler(handler, { flushTimeoutMs: 500 });
python
@trailbeam.aws_lambda.wrap(flush_timeout_millis=500)
def handler(event, context): ...