Guide · Lambda Powertools
AWS Lambda Powertools Metrics for MCP Servers
AWS Lambda Powertools Metrics emits custom CloudWatch metrics via Embedded Metric Format (EMF) — structured JSON written to stdout that the CloudWatch Logs agent converts into CloudWatch Metrics automatically, with no PutMetricData API call and no added latency to your Lambda execution. For MCP server instrumentation this is the right approach for per-tool invocation counters, tool latency histograms, and error-rate metrics that you want to alert on. Three things teams get wrong: forgetting @metrics.log_metrics on the handler — without it, metrics accumulate in memory but never flush to stdout, so they silently disappear; adding too many dimension combinations — CloudWatch charges per metric stream, and each unique set of dimension values is a separate stream; emitting more than 100 metrics per EMF blob without calling metrics.flush_metrics() mid-invocation, which causes a SchemaValidationError on flush.
TL;DR
Always use @metrics.log_metrics(capture_cold_start_metric=True). Set POWERTOOLS_METRICS_NAMESPACE in env vars. Call metrics.set_default_dimensions(Service="mcp-tool-dispatcher", Environment="prod") once at module level. For high-volume handlers emitting more than 100 metrics per invocation, call metrics.flush_metrics() at the 100-metric boundary.
How EMF works — no API calls, no latency
Traditional CloudWatch custom metric publishing requires a PutMetricData API call — network round-trip, IAM permission check, SDK overhead, and a 20 metrics/call limit that forces batching logic. EMF bypasses all of this: Powertools writes a specially-structured JSON blob to stdout, the same place Lambda sends log output. The CloudWatch Logs agent that already processes all Lambda stdout reads the EMF JSON and converts it to CloudWatch metric data points automatically.
The result: metric emission adds zero milliseconds to your Lambda duration and requires no cloudwatch:PutMetricData permission. The only requirement is logs:CreateLogGroup and logs:PutLogEvents, which Lambda already needs.
from aws_lambda_powertools import Metrics
from aws_lambda_powertools.metrics import MetricUnit
from aws_lambda_powertools.utilities.typing import LambdaContext
# POWERTOOLS_METRICS_NAMESPACE env var sets the CloudWatch namespace
metrics = Metrics(namespace="MCPServer")
@metrics.log_metrics(capture_cold_start_metric=True)
def handler(event: dict, context: LambdaContext) -> dict:
tool_name = event.get("toolName", "unknown")
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)
Default dimensions — set once, apply to all metrics
Dimensions slice CloudWatch metrics: you can filter by ToolName=search_codebase or graph ToolError broken down by Environment. But every unique combination of dimension values creates a separate metric stream in CloudWatch, which is what you're charged for. Set dimensions that are truly constant for all metrics in the handler as default dimensions:
from aws_lambda_powertools import Metrics
metrics = Metrics(namespace="MCPServer")
# Set at module level — applies to all metrics in this Lambda function
metrics.set_default_dimensions(
Service="mcp-tool-dispatcher",
Environment=os.environ.get("ENVIRONMENT", "prod"),
)
# Now every add_metric() call inherits Service and Environment automatically
# You don't need to call add_dimension() before each metric
Default dimensions are not cleared between invocations (unlike add_dimension, which is cleared after each flush). If you have per-invocation dimensions that vary (like ToolName), add those with add_dimension() at the start of the handler after extracting from the event.
Dimension cardinality guideline: each unique dimension value combination costs ~$0.30/month in CloudWatch metric streams. With 10 tools and 3 environments = 30 streams × $0.30 = $9/month — acceptable. If you add user_id as a dimension at thousands of distinct values, that's thousands of streams and a large bill. Keep dimensions low-cardinality (tool name, environment, tier).
Cold start metric
Lambda cold starts add latency and are invisible in standard CloudWatch metrics. Powertools Metrics can automatically emit a ColdStart metric on the first invocation of each execution context:
@metrics.log_metrics(capture_cold_start_metric=True)
def handler(event: dict, context: LambdaContext) -> dict:
# ColdStart metric is emitted automatically before handler runs
# Value: 1 on cold start, not emitted on warm invocations
# Dimensions: Service + FunctionName
pass
The ColdStart metric uses a separate EMF flush with its own namespace and dimensions so it doesn't mix with your business metrics. You can build a CloudWatch alarm: if ColdStart count increases sharply, provisioned concurrency may be needed for your SLA-sensitive MCP tools.
Important: the ColdStart metric is emitted in a separate log_metrics call from your other metrics, so it does not count against your 100-metric-per-EMF-blob limit.
High-resolution metrics
Standard CloudWatch metric resolution is 60 seconds — you can't detect a 10-second spike in tool errors unless it happens to align with a 1-minute period boundary. High-resolution metrics store data at 1-second granularity. Powertools Metrics supports them via the resolution parameter:
from aws_lambda_powertools.metrics import MetricUnit, MetricResolution
# High resolution (1s) — $0.02/metric/month vs $0.10/metric/month for standard
# Use for time-sensitive SLOs: P99 latency, error spike detection
metrics.add_metric(
name="ToolDuration",
unit=MetricUnit.Milliseconds,
value=duration_ms,
resolution=MetricResolution.High, # 1-second resolution
)
# Standard resolution (60s) — for coarse business counters
metrics.add_metric(
name="ToolInvocation",
unit=MetricUnit.Count,
value=1,
resolution=MetricResolution.Standard,
)
Use high-resolution sparingly: it costs 5× more per metric than standard resolution. Reserve it for latency metrics where you need sub-minute alarming, and use standard resolution for volume counters where minute-level aggregation is fine.
single_metric — flush immediately without decorator
The log_metrics decorator flushes all accumulated metrics at the end of the handler. Sometimes you need to emit a metric immediately — before the handler completes, from a background thread, or from a module that doesn't own the handler. Use the single_metric context manager:
from aws_lambda_powertools.metrics import single_metric, MetricUnit
def record_tool_timeout(tool_name: str) -> None:
# Flushes immediately on context exit — no decorator needed
with single_metric(
name="ToolTimeout",
unit=MetricUnit.Count,
value=1,
namespace="MCPServer",
) as metric:
metric.add_dimension(name="ToolName", value=tool_name)
single_metric creates an isolated Metrics instance that flushes on __exit__. It does not inherit default dimensions from the module-level Metrics instance, so you must set dimensions explicitly inside the context manager.
Flushing at 100-metric boundary
A single EMF blob can contain at most 100 metrics. Lambda functions that process SQS batches or fan out across many tools can easily exceed this limit. Powertools raises SchemaValidationError on flush if the limit is exceeded. Flush mid-invocation to reset the counter:
def process_tool_batch(tool_calls: list) -> list:
results = []
for i, call in enumerate(tool_calls):
metrics.add_metric(name="ToolInvocation", unit=MetricUnit.Count, value=1)
result = execute_tool(call)
metrics.add_metric(name="ToolDuration", unit=MetricUnit.Milliseconds, value=result["ms"])
results.append(result)
# Flush every 40 tools (2 metrics each = 80 per batch; flush before hitting 100)
if i > 0 and i % 40 == 0:
metrics.flush_metrics()
return results
Common failures
| Symptom | Root cause | Fix |
|---|---|---|
| Metrics never appear in CloudWatch | @metrics.log_metrics decorator missing — metrics accumulate but never flush to stdout |
Add @metrics.log_metrics to the handler; or call metrics.flush_metrics() explicitly before return |
SchemaValidationError on flush |
More than 100 metrics added since last flush, or missing required namespace | Call metrics.flush_metrics() before reaching 100; set POWERTOOLS_METRICS_NAMESPACE env var |
| Metrics appear but without expected dimensions | add_dimension() called after add_metric() — dimensions must precede metrics in EMF spec |
Always call add_dimension() before add_metric() in the handler |
| CloudWatch metric cost much higher than expected | High-cardinality dimension (user_id, request_id) creating thousands of metric streams | Remove high-cardinality dimensions; keep to tool name, environment, tier |
| ColdStart metric not appearing | capture_cold_start_metric=True not set, or namespace not matching filter |
Add param to log_metrics; check CloudWatch for ColdStart metric under the correct namespace |
Monitor MCP server availability alongside your CloudWatch metrics
CloudWatch shows you Lambda health from the inside. AliveMCP shows you MCP server reachability from the outside — continuous probes that detect when your endpoint is unreachable before clients do.
Join the waitlist →