AWS Lambda
Instrument AWS Lambda functions with OpenTelemetry using ADOT layers for automatic tracing and observability
Use OpenTelemetry to instrument your AWS Lambda functions and send telemetry data to Last9. This integration uses the AWS Distro for OpenTelemetry (ADOT) Layer for automatic instrumentation with no code changes required.
Prerequisites
Before setting up AWS Lambda monitoring, ensure you have:
- AWS Account: With access to Lambda service
- Lambda Functions: Deployed functions to instrument
- AWS Console Access: Or AWS CLI configured
- Last9 Account: With OpenTelemetry integration credentials
Supported Runtimes
ADOT layers support the following Lambda runtimes:
- Node.js: 14.x, 16.x, 18.x, 20.x
- Python: 3.8, 3.9, 3.10, 3.11, 3.12
- Java: 8, 11, 17, 21
- .NET: Core 3.1, 6.0, 8.0
-
Find the ADOT Layer ARN
Get the latest ADOT layer ARN for your AWS region and runtime from the AWS ADOT Lambda documentation.
US East 1 (N. Virginia) - us-east-1:
# Node.jsarn:aws:lambda:us-east-1:901920570463:layer:aws-otel-nodejs-amd64-ver-1-18-1:4# Pythonarn:aws:lambda:us-east-1:901920570463:layer:aws-otel-python-amd64-ver-1-20-0:3# Javaarn:aws:lambda:us-east-1:901920570463:layer:aws-otel-java-wrapper-amd64-ver-1-32-0:3US West 2 (Oregon) - us-west-2:
# Node.jsarn:aws:lambda:us-west-2:901920570463:layer:aws-otel-nodejs-amd64-ver-1-18-1:4# Pythonarn:aws:lambda:us-west-2:901920570463:layer:aws-otel-python-amd64-ver-1-20-0:3# Javaarn:aws:lambda:us-west-2:901920570463:layer:aws-otel-java-wrapper-amd64-ver-1-32-0:3Asia Pacific (Mumbai) - ap-south-1:
# Node.jsarn:aws:lambda:ap-south-1:615299751070:layer:AWSOpenTelemetryDistroJs:9# Pythonarn:aws:lambda:ap-south-1:615299751070:layer:AWSOpenTelemetryDistro:5# Javaarn:aws:lambda:ap-south-1:615299751070:layer:AWSOpenTelemetryDistroJava:7Asia Pacific (Singapore) - ap-southeast-1:
# Node.jsarn:aws:lambda:ap-southeast-1:901920570463:layer:aws-otel-nodejs-amd64-ver-1-18-1:4# Pythonarn:aws:lambda:ap-southeast-1:901920570463:layer:aws-otel-python-amd64-ver-1-20-0:3# Javaarn:aws:lambda:ap-southeast-1:901920570463:layer:aws-otel-java-wrapper-amd64-ver-1-32-0:3Europe (Ireland) - eu-west-1:
# Node.jsarn:aws:lambda:eu-west-1:901920570463:layer:aws-otel-nodejs-amd64-ver-1-18-1:4# Pythonarn:aws:lambda:eu-west-1:901920570463:layer:aws-otel-python-amd64-ver-1-20-0:3# Javaarn:aws:lambda:eu-west-1:901920570463:layer:aws-otel-java-wrapper-amd64-ver-1-32-0:3 -
Add the ADOT Layer to Your Lambda Function
Using the AWS Lambda Console:
- Open your Lambda function in the AWS Console
- Scroll down to the Layers section
- Click Add a layer
- Select Specify an ARN
- Paste the appropriate ADOT layer ARN for your region and runtime
- Click Add
Using the AWS CLI:
aws lambda update-function-configuration \--function-name your-function-name \--layers arn:aws:lambda:us-east-1:901920570463:layer:aws-otel-nodejs-amd64-ver-1-18-1:4Replace with your function name and appropriate layer ARN.
Using Terraform:
resource "aws_lambda_function" "example" {function_name = "your-function-name"# ... other configurationlayers = ["arn:aws:lambda:us-east-1:901920570463:layer:aws-otel-nodejs-amd64-ver-1-18-1:4"]} -
Configure Environment Variables
Add the following environment variables to your Lambda function configuration:
- Go to Configuration → Environment variables
- Click Edit and add these variables:
AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrumentOTEL_SERVICE_NAME=<your-service-name>OTEL_EXPORTER_OTLP_ENDPOINT=$last9_otlp_endpointOTEL_EXPORTER_OTLP_HEADERS=authorization=$last9_otlp_auth_headerOTEL_EXPORTER_OTLP_PROTOCOL=http/protobufOTEL_TRACES_EXPORTER=otlpOTEL_TRACES_SAMPLER=always_onOTEL_PROPAGATORS=tracecontext,baggageOTEL_RESOURCE_ATTRIBUTES=deployment.environment=productionReplace
<your-service-name>with a descriptive service name (e.g.,payment-lambda,user-service).aws lambda update-function-configuration \--function-name your-function-name \--environment Variables='{"AWS_LAMBDA_EXEC_WRAPPER": "/opt/otel-instrument","OTEL_SERVICE_NAME": "your-service-name","OTEL_EXPORTER_OTLP_ENDPOINT": "$last9_otlp_endpoint","OTEL_EXPORTER_OTLP_HEADERS": "authorization=$last9_otlp_auth_header","OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf","OTEL_TRACES_EXPORTER": "otlp","OTEL_TRACES_SAMPLER": "always_on","OTEL_PROPAGATORS": "tracecontext,baggage","OTEL_RESOURCE_ATTRIBUTES": "deployment.environment=production"}'resource "aws_lambda_function" "example" {function_name = "your-function-name"# ... other configurationenvironment {variables = {AWS_LAMBDA_EXEC_WRAPPER = "/opt/otel-instrument"OTEL_SERVICE_NAME = "your-service-name"OTEL_EXPORTER_OTLP_ENDPOINT = "$last9_otlp_endpoint"OTEL_EXPORTER_OTLP_HEADERS = "authorization=$last9_otlp_auth_header"OTEL_EXPORTER_OTLP_PROTOCOL = "http/protobuf"OTEL_TRACES_EXPORTER = "otlp"OTEL_TRACES_SAMPLER = "always_on"OTEL_PROPAGATORS = "tracecontext,baggage"OTEL_RESOURCE_ATTRIBUTES = "deployment.environment=production"}}}Important Configuration Notes:
- Replace
<your-service-name>with a descriptive name (e.g.,payment-lambda,user-service) - Use lowercase
authorizationin the headers configuration - Update the
deployment.environmentto match your environment (production, staging, development) - Replace placeholder values with your actual Last9 credentials
-
Test and Verify Installation
Test your Lambda function to ensure instrumentation is working:
- Go to your Lambda function in the AWS Console
- Click Test
- Create a test event or use an existing one
- Click Test to invoke the function
- Check the execution logs for OpenTelemetry initialization messages
aws lambda invoke \--function-name your-function-name \--payload '{"test": "event"}' \response.json# View the responsecat response.jsonIf your Lambda is behind API Gateway, test via HTTP request:
curl -X POST https://your-api-gateway-url/your-endpoint \-H "Content-Type: application/json" \-d '{"test": "data"}'
Understanding the Setup
AWS Distro for OpenTelemetry (ADOT)
ADOT provides:
- Zero-Code Instrumentation: No application code changes required
- AWS Service Integration: Built-in support for AWS services and APIs
- Lambda Optimizations: Optimized for serverless environments
- Multiple Language Support: Comprehensive runtime coverage
Environment Variables Explained
| Variable | Purpose | Example |
|---|---|---|
AWS_LAMBDA_EXEC_WRAPPER | Enables ADOT instrumentation | /opt/otel-instrument |
OTEL_SERVICE_NAME | Service identifier in traces | payment-service |
OTEL_EXPORTER_OTLP_ENDPOINT | Last9 traces endpoint | Last9 provided URL |
OTEL_EXPORTER_OTLP_HEADERS | Authentication headers | Last9 auth token |
OTEL_TRACES_SAMPLER | Sampling strategy | always_on or traceidratio |
OTEL_RESOURCE_ATTRIBUTES | Additional metadata | deployment.environment=prod |
What Gets Traced
The ADOT layer automatically traces:
- Lambda Handler: Function entry and exit
- AWS SDK Calls: DynamoDB, S3, SQS, etc.
- HTTP Requests: Outbound API calls
- Database Calls: RDS, DynamoDB operations
- Message Queue Operations: SQS send/receive
Advanced Configuration
Sampling Configuration
Control trace sampling to manage costs:
# Production: Sample 10% of tracesOTEL_TRACES_SAMPLER=traceidratioOTEL_TRACES_SAMPLER_ARG=0.1
# Development: Sample all tracesOTEL_TRACES_SAMPLER=always_onCustom Resource Attributes
Add additional metadata to traces:
OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production,team=payments,version=2.1.0,region=us-east-1Error Handling Configuration
Configure error reporting:
OTEL_INSTRUMENTATION_COMMON_DEFAULT_ENABLED=trueOTEL_INSTRUMENTATION_AWS_SDK_SUPPRESSORS_MESSAGING_RECEIVE_TELEMETRY_ENABLED=trueVerification
-
Check Lambda Execution Logs
Look for ADOT initialization messages in CloudWatch Logs:
aws logs filter-log-events \--log-group-name "/aws/lambda/your-function-name" \--filter-pattern "OpenTelemetry" -
Verify Layer Attachment
Confirm the ADOT layer is properly attached:
aws lambda get-function-configuration \--function-name your-function-name \--query 'Layers[?starts_with(Arn, `arn:aws:lambda`)].Arn' -
View Traces in Last9
- Log into your Last9 dashboard
- Navigate to the Traces section
- Filter by your service name
- Traces should appear within 1-2 minutes of invocation
-
Test Trace Propagation
If your Lambda calls other services, verify trace propagation by checking connected spans in the trace view.
Common Issues
No Traces Appearing
Enable Debug Logging:
OTEL_LOG_LEVEL=debugCheck Common Issues:
- Verify ADOT layer is attached to your function
- Confirm environment variables are set correctly (no extra quotes)
- Check CloudWatch Logs for error messages
- Ensure Last9 credentials are valid
Lambda Cold Start Issues
Optimize Cold Starts:
- Use provisioned concurrency for critical functions
- Consider using reserved concurrency to limit concurrent executions
- Monitor cold start metrics in CloudWatch
Memory and Performance Impact
Monitor Resource Usage:
- Check function memory usage in CloudWatch metrics
- Adjust memory allocation if needed
- Monitor execution duration for performance impact
Error Messages
Common Error Resolutions:
| Error | Solution |
|---|---|
| ”Recording is off” | Set OTEL_TRACES_SAMPLER=always_on |
| ”Layer not found” | Use correct layer ARN for your region |
| ”Permission denied” | Check Lambda execution role permissions |
| ”Timeout” | Increase function timeout or optimize code |
Ruby Lambda
Ruby does not have an AWS-managed ADOT language layer. Instead, use the OpenTelemetry Ruby SDK with a direct OTLP export to Last9 — no collector sidecar required.
-
Add OTel gems to your Gemfile
gem 'opentelemetry-exporter-otlp'gem 'opentelemetry-instrumentation-aws_lambda'gem 'opentelemetry-instrumentation-aws_sdk'gem 'opentelemetry-sdk'gem 'rexml' # required by opentelemetry-instrumentation-aws_sdk for XML parsingThen install:
bundle install -
Create
setup_otel.rbInitialize the OTel SDK once. The
return if defined?(OTEL_TRACER)guard prevents double-initialization when tests configure their own exporter first.# frozen_string_literal: truereturn if defined?(OTEL_TRACER)require 'opentelemetry/sdk'require 'opentelemetry/exporter/otlp'require 'opentelemetry/instrumentation/aws_lambda'require 'opentelemetry/instrumentation/aws_sdk'OpenTelemetry::SDK.configure do |c|c.service_name = ENV.fetch('OTEL_SERVICE_NAME', 'ruby-lambda')c.use 'OpenTelemetry::Instrumentation::AwsLambda' # auto root span per invocationc.use 'OpenTelemetry::Instrumentation::AwsSdk' # auto-instruments S3, SQS, SES, DynamoDBc.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(OpenTelemetry::Exporter::OTLP::Exporter.new))endOTEL_TRACER = OpenTelemetry.tracer_provider.tracer(ENV.fetch('OTEL_SERVICE_NAME', 'ruby-lambda'),'1.0.0') -
Instrument your Lambda handler
Require
setup_otelat the top of your handler file. Callforce_flushin anensureblock — Lambda freezes the process after the response returns, which kills buffered spans without this.# frozen_string_literal: truerequire_relative './setup_otel'def lambda_handler(event:, context:)OTEL_TRACER.in_span('process_event') do |span|# your business logicspan.set_attribute('event.source', event['source'].to_s){ status: 'ok' }rescue StandardError => espan.record_exception(e)span.status = OpenTelemetry::Trace::Status.error(e.message)raiseendensure# Critical: flush buffered spans before Lambda process freezes.OpenTelemetry.tracer_provider.force_flushendThe trace hierarchy produced:
lambda_handler ← auto (AwsLambda instrumentation)└─ process_event ← manual span└─ S3.GetObject ← auto (AwsSdk instrumentation) -
Set environment variables
aws lambda update-function-configuration \--function-name your-function-name \--environment 'Variables={OTEL_SERVICE_NAME=ruby-lambda-example,OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-aps1.last9.io:443,OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic <your-base64-credentials>,OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf,OTEL_TRACES_SAMPLER=always_on,OTEL_PROPAGATORS=tracecontext,baggage,xray,OTEL_RESOURCE_ATTRIBUTES=deployment.environment=production}' -
Test locally before deploying
The handler is plain Ruby — test OTel export without AWS infrastructure:
# run_local.rb$LOAD_PATH.unshift(__dir__)require_relative 'lambda_function'result = lambda_handler(event: { 'message' => 'test' }, context: nil)puts result.inspectOTEL_SERVICE_NAME=ruby-lambda-example \OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp-aps1.last9.io:443 \OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <your-credentials>" \OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \OTEL_TRACES_SAMPLER=always_on \OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local \bundle exec ruby run_local.rbTraces appear in Last9 within seconds.
-
Package gems and deploy
Lambda requires gems bundled inside the deployment zip:
bundle config set --local path 'vendor/bundle'bundle installzip -qr function.zip lambda_function.rb setup_otel.rb Gemfile Gemfile.lock vendor/aws lambda update-function-code \--function-name your-function-name \--zip-file fileb://function.zip
A complete working example with a deploy script is available at last9/opentelemetry-examples — aws/lambda-ruby.
SQS-Triggered Lambda — Trace Propagation
When Lambda is triggered by SQS via Event Source Mapping (ESM), traces from the upstream producer are not automatically linked. You must extract W3C traceparent from SQS MessageAttributes and use it as the parent context for your Lambda spans.
How It Works
- The upstream service (producer) injects
traceparentinto SQS MessageAttributes when sending a message - SQS delivers the message to Lambda via ESM
- Lambda extracts the trace context from the record’s
messageAttributesand creates child spans
ESM Attribute Format
| Field | ESM Format (Lambda trigger) | SDK Format (ReceiveMessage) |
|---|---|---|
| String value | stringValue | StringValue |
| Data type | dataType | DataType |
Your extraction code must handle both formats.
Extract Trace Context in Lambda
from opentelemetry.propagate import extractfrom opentelemetry import trace
tracer = trace.get_tracer(__name__)
def extract_context_from_sqs_record(record): carrier = {} # Support both Lambda ESM ("messageAttributes") and SDK ReceiveMessage ("MessageAttributes") msg_attrs = record.get("messageAttributes") or record.get("MessageAttributes") or {} for key, attr in msg_attrs.items(): # ESM uses lowercase "stringValue"; SDK uses "StringValue" value = ( attr.get("stringValue") if "stringValue" in attr else attr.get("StringValue") ) if value is not None: carrier[key] = value return extract(carrier)
def handler(event, context): for record in event.get("Records", []): ctx = extract_context_from_sqs_record(record)
with tracer.start_as_current_span( "process_message", context=ctx, # Links to producer's trace kind=trace.SpanKind.CONSUMER, ): # Your business logic here passconst { propagation, trace, SpanKind } = require("@opentelemetry/api");
const tracer = trace.getTracer("lambda-consumer");
function extractContextFromSqsRecord(record) { const carrier = {}; const attrs = record.messageAttributes || {}; for (const [key, attr] of Object.entries(attrs)) { const value = attr.stringValue || attr.StringValue; if (value) carrier[key] = value; } return propagation.extract(trace.context.active(), carrier);}
exports.handler = async (event) => { for (const record of event.Records) { const ctx = extractContextFromSqsRecord(record);
await tracer.startActiveSpan( "process_message", { kind: SpanKind.CONSUMER }, ctx, async (span) => { // Your business logic here span.end(); }, ); }};require 'opentelemetry'
TRACER = OpenTelemetry.tracer_provider.tracer('lambda-consumer')
def extract_context_from_sqs_record(record) carrier = {} # Support both Lambda ESM ("messageAttributes") and SDK ReceiveMessage ("MessageAttributes") attrs = record['messageAttributes'] || record['MessageAttributes'] || {} attrs.each do |key, attr| # ESM uses lowercase "stringValue"; SDK uses "StringValue" value = attr['stringValue'] || attr['StringValue'] carrier[key] = value if value end OpenTelemetry.propagation.extract(carrier)end
def lambda_handler(event:, context:) (event['Records'] || []).each do |record| parent_ctx = extract_context_from_sqs_record(record)
OpenTelemetry::Context.with_current(parent_ctx) do TRACER.in_span('process_message', kind: OpenTelemetry::Trace::SpanKind::CONSUMER) do |span| # Your business logic here end end endensure OpenTelemetry.tracer_provider.force_flushendProducer Side — Injecting Trace Context
The upstream service must inject trace context into SQS MessageAttributes. See the AWS SQS integration — Trace Context Propagation for producer-side code examples.
Batch Processing
Each SQS record in a batch may carry a different trace context (from different producer requests). Always extract and create spans per-record, not per-invocation. Enable ReportBatchItemFailures on your ESM for partial batch failure reporting.
Full Example
See the Python SQS → Lambda trace propagation example for a complete working implementation with local testing.
Lambda Log-Trace Correlation
By default, Lambda logs in CloudWatch have no link to the traces generated by the ADOT layer. To correlate logs with traces in Last9, inject the current trace_id and span_id into your structured log output before forwarding logs.
Why Logs and Traces Disconnect
ADOT automatically creates spans for your Lambda invocations. Those spans carry a trace_id. Your application logs — written with print, logging, or a structured logger — don’t know about that trace_id unless you explicitly inject it.
Without injection, you see traces in Last9’s Traces view and logs separately, with no way to jump from a log line to the trace that produced it.
Injecting Trace Context into Logs
Use the OTel SDK to get the current span context and add trace_id/span_id to your log records:
import jsonimport loggingfrom opentelemetry import trace
logger = logging.getLogger()logger.setLevel(logging.INFO)
def get_trace_context(): span = trace.get_current_span() ctx = span.get_span_context() if ctx.is_valid: return { "trace_id": format(ctx.trace_id, "032x"), "span_id": format(ctx.span_id, "016x"), } return {}
def handler(event, context): log_entry = { "level": "INFO", "message": "Processing event", **get_trace_context(), # injects trace_id and span_id } print(json.dumps(log_entry)) # ... business logicWith AWS Lambda Powertools (recommended for Python):
from aws_lambda_powertools import Loggerfrom aws_lambda_powertools.logging import correlation_paths
logger = Logger(service="payment-lambda")
@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST)def handler(event, context): logger.info("Processing request") # Powertools automatically includes xray_trace_id # Add OTel trace_id manually via get_trace_context() if neededconst { trace } = require("@opentelemetry/api");
function getTraceContext() { const span = trace.getActiveSpan(); if (!span) return {}; const ctx = span.spanContext(); return { trace_id: ctx.traceId, span_id: ctx.spanId, };}
exports.handler = async (event) => { console.log(JSON.stringify({ level: "INFO", message: "Processing event", ...getTraceContext(), })); // ... business logic};With pino:
const pino = require("pino");const { trace } = require("@opentelemetry/api");
const logger = pino({ mixin() { const span = trace.getActiveSpan(); if (!span) return {}; const ctx = span.spanContext(); return { trace_id: ctx.traceId, span_id: ctx.spanId }; },});
exports.handler = async (event) => { logger.info("Processing event");};require 'json'require 'opentelemetry'
def trace_context span = OpenTelemetry::Trace.current_span ctx = span.context return {} unless ctx.valid? { trace_id: ctx.hex_trace_id, span_id: ctx.hex_span_id, }end
def lambda_handler(event:, context:) puts JSON.generate({ level: 'INFO', message: 'Processing event', **trace_context }) # ... business logicensure OpenTelemetry.tracer_provider.force_flushendForwarding Lambda Logs to Last9
After injecting trace context into logs, forward your CloudWatch logs to Last9 so they appear alongside traces:
Option 1 — CloudWatch Subscription Filter (push-based):
- Create a Lambda function that forwards logs to Last9’s OTLP endpoint
- Add a CloudWatch Logs subscription filter on your Lambda’s log group to trigger it
Option 2 — OTel Collector sidecar (ECS/fargate only):
If running Lambda on ECS with a sidecar collector, configure the filelog receiver to tail /proc/1/fd/1.
Option 3 — Direct OTLP logs from Lambda:
Install the OTel Logs SDK and emit logs directly to Last9:
from opentelemetry._logs import set_logger_providerfrom opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporterfrom opentelemetry.sdk._logs import LoggerProviderfrom opentelemetry.sdk._logs.export import BatchLogRecordProcessor
provider = LoggerProvider()provider.add_log_record_processor( BatchLogRecordProcessor(OTLPLogExporter()))set_logger_provider(provider)Searching Correlated Logs in Last9
Once logs carry trace_id, filter in Last9 Log Management:
trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"Or from a trace in Last9’s Traces view, click View Logs to jump directly to log lines matching that trace’s trace_id.
Best Practices
- Service Naming: Use descriptive, consistent service names across your Lambda functions
- Environment Segregation: Use different service names or resource attributes per environment
- Sampling Strategy: Use appropriate sampling rates for production to control costs
- Monitoring: Set up CloudWatch alarms for Lambda errors and duration
- Resource Attribution: Include meaningful metadata like version, team, and environment
- Testing: Test instrumentation in development before deploying to production
Troubleshooting
Please get in touch with us on Discord or Email if you have any questions.