Deep Dive · AWS Lambda Powertools

AWS Lambda Powertools for MCP Servers: Three Observability Patterns for Structured Logging, Distributed Tracing, and Retry Safety

Published 2026-09-25 · 24 min read

AWS Lambda Powertools is a suite of production-ready utilities for Python (and TypeScript) Lambda functions — it ships structured logging, distributed tracing, CloudWatch EMF metrics, batch partial-failure handling, and idempotency as decorator-based decorators that bolt onto your existing handler without rewriting core logic. For MCP server teams running tool-dispatch Lambda functions, event-driven pipelines, or async callback handlers, these five utilities compose into a complete observability and reliability stack. Five specifics matter in practice: Logger with inject_lambda_context(clear_state=True) — Lambda reuses execution contexts across warm invocations, which means any fields added via append_keys() persist into the next invocation unless you set clear_state=True; omit it and one user's mcp_session_id leaks into the next user's logs, producing correlation nightmares that take hours to untangle. Tracer with capture_response=False — X-Ray segments have a hard 64 KB limit; MCP tool outputs routinely exceed this (file contents, search results, code blocks); using the default capture_response=True silently truncates segments and produces partial-trace gaps in your service map. Metrics via EMF (Embedded Metric Format) — Powertools writes a JSON envelope to stdout and the CloudWatch Logs agent converts it to metrics; there is no PutMetricData API call, no IAM permission needed for metric emission, and no added latency — a critical property for MCP tools that are latency-sensitive. Batch processing with process_partial_response() and FunctionResponseTypes: [ReportBatchItemFailures] — without this configuration, a single failed record in a 10-item SQS batch causes Lambda to retry all 10 records, including the 9 that already succeeded; this is the single most commonly misconfigured Lambda event source setting in MCP async pipelines. Idempotency with in_progress_expiration_seconds set to function timeout + 5 seconds — without this setting, a Lambda kill (OOM, timeout, underlying host failure) leaves the DynamoDB idempotency record permanently stuck in INPROGRESS state, blocking all retries for that request forever. This post synthesizes the five Powertools guides around three structural patterns that separate MCP Lambda functions that are observable and retry-safe from ones that silently corrupt session state, silently drop traces, or permanently block retried requests.

The core mental model: Powertools as a decorator stack, not a monitoring agent

Before the three patterns, it helps to place Powertools relative to the other observability options available to Lambda functions. The key distinction is that Powertools utilities operate inside the Lambda execution environment as Python decorators — they modify your handler's behavior at the Python import level, not by sidecar, layer injection, or external agent:

Tool Where it runs What it instruments Primary use for MCP servers
AWS Lambda Powertools Inside Lambda execution environment (Python library) Per-invocation structured logs, X-Ray segments, EMF metrics, batch responses, idempotency state Complete per-invocation observability with zero sidecar cost
AWS X-Ray SDK (without Powertools) Inside Lambda (manual instrumentation) Segments and subsegments only Custom tracing when you need fine-grained segment control without Powertools overhead
CloudWatch Lambda Insights Lambda extension layer (sidecar) CPU, memory, init duration, network — host-level metrics System-level resource monitoring; complements Powertools but not a substitute
AWS Distro for OpenTelemetry (ADOT) Lambda extension layer (sidecar) OpenTelemetry traces exportable to X-Ray, Jaeger, Honeycomb, Datadog Vendor-neutral tracing for multi-cloud MCP pipelines
Lambda Powertools (your workload) Inside Lambda (decorator-based) Structured logs + X-Ray + EMF metrics + batch partial failures + idempotency MCP tool dispatch, async queues, callback handlers — full stack in one library

The implication: Powertools utilities share the Lambda execution context with your handler code. This is both their strength — zero sidecar cold start, direct access to the Lambda context object — and the source of their most common failure modes. Warm container reuse is the critical property to understand. Lambda reuses an execution context for subsequent invocations for performance; this means module-level state (Logger's append_keys() buffer, Metrics' dimension list, any global variable you set in the handler) persists between invocations within the same container. Every Powertools pattern documented below exists specifically to manage this reuse safely.

Pattern 1 — The three core decorators every MCP server Lambda needs

The first structural pattern is the decorator stack that every MCP tool-dispatch Lambda should have as a baseline. Three decorators — one from each of Logger, Tracer, and Metrics — compose onto a single handler and solve the three most common production failures: stale session context, oversized trace segments, and missing cold start signals.

inject_lambda_context(clear_state=True) — why clear_state is not optional

The inject_lambda_context decorator adds Lambda context fields (function name, memory size, ARN, request ID, X-Ray trace ID, and cold start boolean) to every log line automatically. The clear_state=True parameter tells Powertools to flush any fields added via append_keys() at the start of each invocation. Without it, fields from invocation N persist into invocation N+1 within the same warm container:

from aws_lambda_powertools import Logger, Tracer, Metrics
from aws_lambda_powertools.metrics import MetricUnit
from aws_lambda_powertools.utilities.typing import LambdaContext

logger = Logger(service="mcp-tool-dispatcher")
tracer = Tracer(service="mcp-tool-dispatcher")
metrics = Metrics(namespace="MCPServer")

@logger.inject_lambda_context(
    correlation_id_path="headers.x-correlation-id",
    clear_state=True,
    log_event=False,
)
@tracer.capture_lambda_handler(
    capture_response=False,
    capture_error=True,
)
@metrics.log_metrics(capture_cold_start_metric=True)
def handler(event: dict, context: LambdaContext) -> dict:
    logger.append_keys(
        mcp_session_id=event.get("sessionId"),
        tool_name=event.get("toolName"),
    )
    logger.info("tool call received")
    result = dispatch_tool(event)
    logger.info("tool call complete", extra={"duration_ms": result["ms"]})
    return result

The decorator order matters. Powertools decorators apply bottom-up, so @metrics.log_metrics wraps the handler first (guaranteeing the metrics flush runs even if an unhandled exception propagates), @tracer.capture_lambda_handler wraps the metrics-wrapped handler (so the X-Ray segment captures the full duration including the metrics flush), and @logger.inject_lambda_context wraps outermost (so the Lambda context fields are set before any logging occurs).

The clear_state=True mechanism: at the start of each invocation, inject_lambda_context calls logger.structure_logs(reset_state=True) internally, which empties the persistent keys buffer. Any append_keys() calls in the handler body then re-populate it for this invocation only. Without clear_state=True, the mcp_session_id from session A persists into session B's logs because the warm container reused the same Logger instance. This produces correlation log entries that point to the wrong session, which is worse than missing correlation data — it actively misleads debugging.

capture_response=False — why this is the right default for MCP tools

The capture_lambda_handler decorator creates an X-Ray segment for the full handler duration and optionally captures the response payload as metadata. The default capture_response=True is wrong for MCP tool-dispatch functions because MCP tool outputs routinely contain file contents, search results, or code blocks that exceed the 64 KB X-Ray segment size limit. When the response exceeds 64 KB, X-Ray silently truncates the segment — you get a partial trace with no error, no warning, and a misleading segment that appears to contain a complete response:

# Wrong for MCP tools — large outputs silently truncate the segment
@tracer.capture_lambda_handler()  # capture_response=True is the default

# Right for MCP tools — full response never stored in segment
@tracer.capture_lambda_handler(
    capture_response=False,
    capture_error=True,   # still capture exception details
)

Use annotations for the fields you actually need to filter on in the X-Ray console. Annotations are indexed (searchable, filterable via GetTraceSummaries) but limited to 50 per segment and string/number/boolean types only. Metadata is not indexed but accepts any JSON-serialisable value up to the 64 KB segment total. The correct split for MCP tools: annotate low-cardinality filter fields (ToolName, Status, UserTier), store large or structured data in metadata:

@tracer.capture_lambda_handler(capture_response=False)
def handler(event: dict, context: LambdaContext) -> dict:
    tool_name = event.get("toolName", "unknown")

    # Annotations — indexed, filterable, low-cardinality only
    tracer.put_annotation(key="ToolName", value=tool_name)
    tracer.put_annotation(key="UserTier", value=event.get("userTier", "free"))

    result = dispatch_tool(event)

    tracer.put_annotation(key="Status", value="success")
    # Metadata — not indexed, large/structured data acceptable
    tracer.put_metadata(key="tool_input_summary", value={
        "tool": tool_name,
        "input_keys": list(event.get("input", {}).keys()),
    })
    return result

log_metrics(capture_cold_start_metric=True) — cold start as a first-class signal

The log_metrics decorator flushes the metrics buffer at the end of the handler and emits a ColdStart metric when capture_cold_start_metric=True. The cold start metric is emitted as a separate EMF blob so it doesn't count against the 100-metric-per-EMF-blob limit. For MCP servers, cold start latency is directly visible to the end user — an MCP tool that takes 800 ms cold vs 40 ms warm is a qualitatively different user experience, and the cold start metric is how you distinguish cold-start regressions from warm-path regressions when diagnosing latency spikes:

metrics = Metrics(namespace="MCPServer")

# Set constant dimensions once — these persist across warm invocations
# unlike add_dimension() which is cleared by log_metrics on each flush
metrics.set_default_dimensions(
    Service="mcp-tool-dispatcher",
    Environment=os.environ.get("ENVIRONMENT", "prod"),
)

@metrics.log_metrics(capture_cold_start_metric=True)
def handler(event: dict, context: LambdaContext) -> dict:
    tool_name = event.get("toolName", "unknown")

    # Per-invocation varying dimension — added after default dims
    metrics.add_dimension(name="ToolName", value=tool_name)
    metrics.add_metric(name="ToolInvocation", unit=MetricUnit.Count, value=1)

    start = time.monotonic()
    try:
        result = execute_tool(event)
        metrics.add_metric(name="ToolSuccess", unit=MetricUnit.Count, value=1)
        return result
    except Exception as e:
        metrics.add_metric(name="ToolError", unit=MetricUnit.Count, value=1)
        raise
    finally:
        duration_ms = (time.monotonic() - start) * 1000
        metrics.add_metric(name="ToolDuration", unit=MetricUnit.Milliseconds, value=duration_ms)

The set_default_dimensions() vs add_dimension() distinction is important for cost control. Default dimensions are set once at module level and persist across warm invocations — right for constant labels like Service and Environment. add_dimension() is cleared by log_metrics at each flush — right for per-invocation varying labels like ToolName. CloudWatch charges approximately $0.30/month per unique dimension combination. Annotating ToolName as a default dimension would create a new metric stream for each unique tool name seen by that Lambda function — the right cardinality model is ToolName as a per-invocation dimension so each invocation's metrics are separate but the metric stream count stays bounded.

Pattern 2 — The EMF → X-Ray → CloudWatch Logs triad

The second structural pattern is understanding how the three observability pillars — metrics via EMF, traces via X-Ray, and structured logs via CloudWatch Logs — complement each other. Each pillar answers a different class of production question, and Powertools implements all three with explicit design choices that prevent the most common cross-pillar confusion.

What each pillar answers

Pillar Powertools utility Primary question answered Data retention / cost model
Metrics (EMF) Metrics Is something wrong? (rate, count, error rate, latency percentiles over time) CloudWatch Metrics: $0.30/metric/month + $0.01/1,000 API requests (EMF eliminates the API request cost)
Traces (X-Ray) Tracer Where is the latency? (which service, which downstream call, which segment took longest) X-Ray: $5.00 per million traces recorded; default 5% sampling rate reduces cost for high-volume MCP tools
Logs (structured JSON) Logger What happened in this specific request? (full context, parameters, intermediate state) CloudWatch Logs: $0.50/GB ingested + $0.03/GB/month stored; use POWERTOOLS_LOGGER_SAMPLE_RATE to control DEBUG volume

The operational workflow: metrics alert on anomaly → trace reveals which function or downstream service introduced the latency spike → logs for the specific request IDs surfaced by the trace provide the full debugging context. The three pillars are most useful when they share correlation identifiers. Powertools Logger's inject_lambda_context injects xray_trace_id into every log line automatically — this is the bridge. When CloudWatch Logs Insights finds the log lines for a failing function_request_id, the embedded xray_trace_id links directly to the corresponding X-Ray trace without any manual correlation step.

EMF mechanics — why zero API calls matters for MCP latency

The conventional CloudWatch Metrics API (PutMetricData) is a synchronous HTTP call that adds 5–50 ms to your Lambda invocation on every call. For MCP tools where the user is waiting for a result, this is unacceptable overhead if called in the hot path. EMF eliminates this entirely by writing a specially-formatted JSON blob to stdout:

# What Powertools emits to stdout (simplified)
{
  "_aws": {
    "Timestamp": 1727260800000,
    "CloudWatchMetrics": [{
      "Namespace": "MCPServer",
      "Dimensions": [["Service", "Environment", "ToolName"]],
      "Metrics": [
        {"Name": "ToolInvocation", "Unit": "Count"},
        {"Name": "ToolDuration", "Unit": "Milliseconds"}
      ]
    }]
  },
  "Service": "mcp-tool-dispatcher",
  "Environment": "prod",
  "ToolName": "search_codebase",
  "ToolInvocation": 1,
  "ToolDuration": 142.3
}

The CloudWatch Logs agent (which Lambda's log delivery infrastructure uses) parses this JSON structure and creates CloudWatch Metrics data points from it automatically — no API call from your Lambda function, no IAM permission for cloudwatch:PutMetricData, no network round-trip in your hot path. The only requirement: the @metrics.log_metrics decorator must be present on the handler, because it calls metrics.flush_metrics() at the end of the invocation to ensure the EMF blob is written to stdout. Without the decorator, metrics added via add_metric() are silently discarded when the invocation ends.

POWERTOOLS_LOGGER_SAMPLE_RATE — the bridge between cost and debuggability

In production, logging every DEBUG statement for every invocation is prohibitively expensive. But disabling DEBUG entirely means you can't diagnose intermittent failures without deploying a code change. Powertools Logger's sampling mechanism solves this: set POWERTOOLS_LOGGER_SAMPLE_RATE=0.05 and 5% of invocations will log at DEBUG level, with the rest at INFO. The sampling decision is made per-invocation (not per-log-line), so a sampled invocation produces a complete DEBUG trace for that request — useful for debugging production issues without the 20× log volume increase of enabling DEBUG globally:

# Environment variable in Lambda function configuration
POWERTOOLS_LOGGER_SAMPLE_RATE=0.05   # 5% of invocations log at DEBUG
LOG_LEVEL=DEBUG                       # Required — sampling only works if base level allows DEBUG
POWERTOOLS_SERVICE_NAME=mcp-tool-dispatcher

# Check if current invocation was sampled
logger.debug("full request payload", extra={"event": event})
# This line only appears in 5% of invocations

Common misconfiguration: setting POWERTOOLS_LOGGER_SAMPLE_RATE=0.05 while leaving LOG_LEVEL=INFO. The logger evaluates the effective log level first — if the level is INFO, DEBUG messages are filtered before the sampling check runs and the sampling setting has no effect. Set LOG_LEVEL=DEBUG and let the sample rate control frequency, not the level.

X-Ray Active Tracing requirement and IAM

Powertools Tracer requires two preconditions that are not set by default and whose absence produces completely different symptoms:

Precondition Default state Symptom when missing Fix
Active Tracing on Lambda function Pass-Through (traces forwarded but not recorded) No traces appear in X-Ray console at all Set Tracing: Active in SAM/CDK; or enable in Lambda console
IAM permissions on execution role Not included in AWSLambdaBasicExecutionRole AccessDeniedException errors in CloudWatch Logs; trace data appears missing Add xray:PutTraceSegments and xray:PutTelemetryRecords to the function's execution role
# CDK — both requirements in one block
const fn = new lambda.Function(this, "McpToolDispatcher", {
  tracing: lambda.Tracing.ACTIVE,  // Active tracing
});
fn.addToRolePolicy(new iam.PolicyStatement({
  actions: [
    "xray:PutTraceSegments",
    "xray:PutTelemetryRecords",
  ],
  resources: ["*"],
}));

In unit tests, set POWERTOOLS_TRACE_DISABLED=true as an environment variable. Without it, the X-Ray SDK attempts to find an active segment for the test context and raises SegmentNotFoundException — a confusing error that has nothing to do with your test logic and everything to do with X-Ray SDK internals. Powertools Tracer auto-disables when this variable is set, or when AWS_SAM_LOCAL=true is detected.

Pattern 3 — Retry safety: batch partial failures and idempotency composed

The third structural pattern is the most operationally critical: making Lambda functions safe to retry. MCP async pipelines typically use SQS or Kinesis as the queue layer — both services retry delivery on Lambda errors. Without Batch and Idempotency configured correctly, retries either reprocess records that already succeeded (producing duplicate side effects) or permanently block on records that can never succeed (causing queue backlog that grows until the DLQ overflows).

FunctionResponseTypes: ReportBatchItemFailures — the most commonly misconfigured Lambda setting

When Lambda processes an SQS batch, the default behavior on any error is binary: either the entire batch succeeds and all records are deleted from the queue, or the entire batch fails and all records are returned for retry. Without partial failure reporting, a single bad record forces all 9 healthy records in a 10-item batch to be retried — potentially indefinitely, with exponential backoff, until the message retention period expires or they hit the DLQ. This is the silent failure mode that produces "mysterious" duplicate tool invocations in MCP async pipelines.

The fix has two parts that must both be present:

# Part 1 — Event source mapping configuration (CloudFormation/SAM)
Resources:
  McpToolWorker:
    Type: AWS::Serverless::Function
    Properties:
      Events:
        McpToolQueue:
          Type: SQS
          Properties:
            Queue: !GetAtt McpToolQueue.Arn
            BatchSize: 10
            FunctionResponseTypes:
              - ReportBatchItemFailures  # Required — omit and partial failures are impossible
            MaximumBatchingWindowInSeconds: 5
# Part 2 — Handler code using Powertools BatchProcessor
from aws_lambda_powertools.utilities.batch import (
    BatchProcessor, EventType, process_partial_response
)
from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord

processor = BatchProcessor(event_type=EventType.SQS)

def record_handler(record: SQSRecord) -> dict:
    payload = json.loads(record.body)
    # CRITICAL: do NOT catch exceptions here — let them propagate
    # Powertools marks a record as failed only if the handler raises
    result = execute_mcp_tool(payload["toolName"], payload["input"])
    store_result(payload.get("callbackId"), result)
    return result

def handler(event: dict, context: LambdaContext) -> dict:
    return process_partial_response(
        event=event,
        record_handler=record_handler,
        processor=processor,
        context=context,
    )

The trap: a record handler that catches all exceptions and returns normally is indistinguishable from a record handler that succeeded. Powertools marks a record as failed only if the handler raises an exception. If your record handler has a broad try/except Exception: return {"status": "error"} pattern, every record is reported as succeeded regardless of what happened inside — no batchItemFailures entries, no retries, no DLQ routing. Remove exception-swallowing from record handlers and reserve error handling for the outer process_partial_response call where you can handle BatchProcessingError (raised when all records in the batch fail).

For Kinesis Data Streams, the partial failure behavior has an ordering implication that doesn't exist for SQS: Kinesis records are ordered within a shard, and a failed record blocks all records behind it in the same shard until the failure is resolved. The fix — make the record handler idempotent using Powertools Idempotency (covered next) so failed records that are retried don't produce duplicate side effects when they eventually succeed.

in_progress_expiration_seconds — the critical safety parameter for Lambda kills

Powertools Idempotency uses DynamoDB to store idempotency state keyed on a client-provided ID extracted from the event via JMESPath. When a request arrives for the first time, the handler writes an INPROGRESS record to DynamoDB, executes the handler, and transitions the record to COMPLETED with the cached response. Subsequent requests with the same key return the cached response without executing the handler again.

The problem: Lambda functions can be killed mid-execution by OOM, timeout, or underlying host failure. When this happens, the handler never completes, the record never transitions from INPROGRESS to COMPLETED — and without expiry configuration, it stays INPROGRESS forever. Every retry for that request ID hits the INPROGRESS record and raises IdempotencyAlreadyInProgressError, permanently blocking that request from ever completing:

from aws_lambda_powertools.utilities.idempotency import (
    DynamoDBPersistenceLayer, IdempotencyConfig, idempotent,
)

persistence_store = DynamoDBPersistenceLayer(table_name="mcp-idempotency")

config = IdempotencyConfig(
    event_key_jmespath="body.callId",       # Stable client-generated UUID
    expires_after_seconds=3600,              # Cached response TTL
    in_progress_expiration_seconds=35,       # Lambda timeout (30s) + 5s buffer
    raise_on_no_idempotency_key=True,        # Force clients to send a callId
    payload_validation_jmespath="body.userId",  # Prevent key re-use across users
)

@idempotent(config=config, persistence_store=persistence_store)
def handler(event: dict, context: LambdaContext) -> dict:
    tool_name = event["body"]["toolName"]
    result = execute_mcp_tool(tool_name, event["body"]["input"])
    return {"statusCode": 200, "body": json.dumps(result)}

The formula: in_progress_expiration_seconds = Lambda timeout + 5. If the Lambda function has a 30-second timeout, set in_progress_expiration_seconds=35. After 35 seconds with no COMPLETED transition, the INPROGRESS record is treated as stale and the next retry re-executes the handler. The 5-second buffer accounts for the window between Lambda timeout and DynamoDB write settling.

Idempotency key selection — what makes a key stable

The idempotency key must be identical across all retries of the same logical request. This sounds obvious but produces the most common Idempotency misconfiguration in practice: using a field that looks stable but isn't.

Event source Stable key path Avoid (changes on retry)
API Gateway requestContext.requestId or custom headers.x-idempotency-key headers.x-amzn-trace-id (new trace per retry), full event body (contains timestamp)
SQS standard messageId — stable across all delivery attempts of the same message Message body fields if any contain auto-generated or timestamp content
Custom MCP client body.callId — UUID generated by the client before any retries body.timestamp, body.requestedAt — client regenerates these on each retry
Async EventBridge callback detail.transactionId detail.eventTime (changes per delivery attempt)

The diagnostic step when idempotency doesn't seem to be working: add a temporary logger.debug("idempotency key", extra={"key": event.get("body", {}).get("callId")}) before the handler body and verify the extracted key is actually identical across retries. JMESPath path errors (wrong nesting, wrong field name) silently produce None as the key — and if raise_on_no_idempotency_key=False (the default), None keys are accepted, making every request with a missing key share a single idempotency slot.

Composing Batch and Idempotency for Kinesis retry safety

The two utilities compose naturally: use Batch to handle partial failures at the queue level, and Idempotency inside the record handler to prevent duplicate side effects when a Kinesis shard record is retried after a partial failure. The key detail: the idempotency key for Kinesis records is kinesis.sequenceNumber — Kinesis assigns a unique sequence number per record per shard, and it's stable across all delivery attempts:

from aws_lambda_powertools.utilities.batch import BatchProcessor, EventType, process_partial_response
from aws_lambda_powertools.utilities.data_classes.kinesis_stream_event import KinesisStreamRecord
from aws_lambda_powertools.utilities.idempotency import idempotent_function

kinesis_processor = BatchProcessor(event_type=EventType.KinesisDataStreams)

config = IdempotencyConfig(
    event_key_jmespath="sequenceNumber",   # Stable within a shard
    in_progress_expiration_seconds=35,
    expires_after_seconds=86400,
)

@idempotent_function(
    data_keyword_argument="record",
    config=config,
    persistence_store=persistence_store,
)
def record_handler(record: KinesisStreamRecord) -> dict:
    data = json.loads(base64.b64decode(record.kinesis.data).decode())
    return process_mcp_event(data)

def handler(event: dict, context: LambdaContext) -> dict:
    return process_partial_response(
        event=event,
        record_handler=record_handler,
        processor=kinesis_processor,
        context=context,
    )

The composition effect: if process_mcp_event fails on record 7 of a 10-record Kinesis batch, Batch reports records 7–10 as failed (Kinesis is ordered — record 7's failure blocks 8, 9, 10 as well). On retry, records 1–6 hit the Idempotency layer, find COMPLETED records in DynamoDB, and return cached responses without re-executing. Record 7 either succeeds this time (INPROGRESS → COMPLETED) or fails again (INPROGRESS record expires per in_progress_expiration_seconds and the record is routed to DLQ on the next retry cycle).

Consolidated failure modes

All five Lambda Powertools utilities share the Lambda execution model and have failure modes that cluster into three root causes: warm container state leakage, missing infrastructure configuration, and incorrect exception handling in record handlers. The following table covers the most operationally consequential failures across all five utilities:

Utility Symptom Root cause Fix
Logger Previous session's mcp_session_id appears in current request's logs inject_lambda_context missing clear_state=True — warm container reuses Logger state Add clear_state=True to all inject_lambda_context decorators
Logger DEBUG logs never appear despite POWERTOOLS_LOGGER_SAMPLE_RATE=0.1 LOG_LEVEL=INFO overrides sampling — INFO level filters DEBUG before sample check runs Set LOG_LEVEL=DEBUG and use sample rate to control frequency
Tracer No traces appear in X-Ray console at all Lambda function has Pass-Through tracing (not Active), or missing IAM permissions Set Tracing: Active; add xray:PutTraceSegments + xray:PutTelemetryRecords to execution role
Tracer Partial traces with unexplained gaps; no error reported capture_response=True (default) — MCP tool output exceeds 64 KB X-Ray segment limit; segment silently truncated Set capture_response=False on capture_lambda_handler
Tracer SegmentNotFoundException in unit tests X-Ray SDK can't find an active segment in test environment Set POWERTOOLS_TRACE_DISABLED=true in test environment variables
Metrics Metrics never appear in CloudWatch despite add_metric() calls @metrics.log_metrics decorator missing — metrics buffer is flushed only by the decorator Add @metrics.log_metrics to handler; or call metrics.flush_metrics() explicitly
Metrics CloudWatch costs unexpectedly high High-cardinality dimension (user_id, request_id) creating thousands of metric streams Remove high-cardinality dimensions; use ToolName, Environment, Tier only
Batch All 10 records retried even when only 1 fails FunctionResponseTypes: [ReportBatchItemFailures] missing from event source mapping Add to CloudFormation/CDK event source mapping; verify in Lambda console under event source configuration
Batch Failed records silently disappear — no retry, no DLQ routing Record handler catches all exceptions and returns normally — Powertools marks record as succeeded Remove exception-swallowing from record handler; let exceptions propagate to process_partial_response
Idempotency Every call treated as unique despite identical payload event_key_jmespath points to a field that changes per call (timestamp, nonce, trace ID) Log extracted key at DEBUG; fix JMESPath to a stable client-generated UUID
Idempotency Retries permanently blocked after Lambda kill in_progress_expiration_seconds not set — INPROGRESS record never expires Set in_progress_expiration_seconds to Lambda function timeout + 5 seconds
Idempotency Different users share the same cached response via same key Key collision — two users with different userId happen to use the same callId Set payload_validation_jmespath="body.userId" so the key is validated against the payload hash

Quick-start configuration checklist

For a new MCP tool-dispatch Lambda function, this is the minimal baseline configuration that satisfies all three patterns documented above. Copy this and adjust the JMESPath paths and DynamoDB table name to match your event shape:

import json
import os
import time
import base64
from aws_lambda_powertools import Logger, Tracer, Metrics
from aws_lambda_powertools.metrics import MetricUnit
from aws_lambda_powertools.utilities.idempotency import (
    DynamoDBPersistenceLayer, IdempotencyConfig, idempotent,
)
from aws_lambda_powertools.utilities.typing import LambdaContext

# Module-level initialization — runs once per cold start, reused on warm invocations
logger = Logger(service="mcp-tool-dispatcher")
tracer = Tracer(service="mcp-tool-dispatcher")
metrics = Metrics(namespace="MCPServer")

metrics.set_default_dimensions(
    Service="mcp-tool-dispatcher",
    Environment=os.environ.get("ENVIRONMENT", "prod"),
)

persistence_store = DynamoDBPersistenceLayer(
    table_name=os.environ["IDEMPOTENCY_TABLE"]
)
idempotency_config = IdempotencyConfig(
    event_key_jmespath="body.callId",
    expires_after_seconds=3600,
    in_progress_expiration_seconds=int(os.environ.get("FUNCTION_TIMEOUT", 30)) + 5,
    raise_on_no_idempotency_key=True,
    payload_validation_jmespath="body.userId",
)

@logger.inject_lambda_context(
    correlation_id_path="headers.x-correlation-id",
    clear_state=True,
    log_event=False,
)
@tracer.capture_lambda_handler(
    capture_response=False,
    capture_error=True,
)
@metrics.log_metrics(capture_cold_start_metric=True)
@idempotent(config=idempotency_config, persistence_store=persistence_store)
def handler(event: dict, context: LambdaContext) -> dict:
    tool_name = event["body"]["toolName"]

    logger.append_keys(tool_name=tool_name, user_id=event["body"].get("userId"))
    tracer.put_annotation(key="ToolName", value=tool_name)
    metrics.add_dimension(name="ToolName", value=tool_name)
    metrics.add_metric(name="ToolInvocation", unit=MetricUnit.Count, value=1)

    start = time.monotonic()
    try:
        result = execute_mcp_tool(tool_name, event["body"]["input"])
        metrics.add_metric(name="ToolSuccess", unit=MetricUnit.Count, value=1)
        tracer.put_annotation(key="Status", value="success")
        return {"statusCode": 200, "body": json.dumps(result)}
    except Exception:
        metrics.add_metric(name="ToolError", unit=MetricUnit.Count, value=1)
        tracer.put_annotation(key="Status", value="error")
        raise
    finally:
        duration_ms = (time.monotonic() - start) * 1000
        metrics.add_metric(name="ToolDuration", unit=MetricUnit.Milliseconds, value=duration_ms)

Required infrastructure to accompany this handler:

AliveMCP monitors MCP server uptime and alert latency so you know when a tool-dispatch Lambda has a cold-start regression or error rate spike before your users notice. The metrics pattern above feeds directly into the kind of alerting we instrument — if you're running MCP tools at scale, AliveMCP's monitoring gives you the external view that CloudWatch metrics alone can't provide.