Guide · AWS CloudWatch · Lambda Telemetry
CloudWatch Embedded Metric Format (EMF) for MCP Tool Telemetry
CloudWatch Embedded Metric Format lets your MCP server Lambda functions publish custom per-tool metrics — latency, error count, payload size, tool-call throughput — by writing a specially formatted JSON structure to stdout, with zero additional API calls and zero impact on your handler's response latency. The Lambda runtime routes that stdout to CloudWatch Logs; the CloudWatch Logs service detects the EMF envelope and extracts the embedded metrics into the CloudWatch namespace you specify, ready to alarm on or dashboard alongside the standard Lambda metrics. You can then alarm on those custom metrics — for example, a McpToolErrors metric dimensioned by tool name — giving you per-tool alerting that is impossible with the aggregate AWS/Lambda Errors metric. For configuring alarms on these custom metrics, see CloudWatch Alarms for MCP servers; for Logs Insights queries over the same log streams, see CloudWatch Logs Insights for MCP server debugging.
TL;DR
Install @aws-lambda-powertools/metrics (which uses EMF under the hood). In each MCP tool handler, call metrics.addMetric('McpToolDuration', MetricUnit.Milliseconds, durationMs) and metrics.addDimension('Tool', toolName) before flushing with metrics.publishStoredMetrics(). CloudWatch receives a per-tool McpToolDuration metric in the McpServer namespace within seconds, with no async PutMetricData API calls and no added Lambda execution time waiting for a network response.
Why EMF instead of PutMetricData for MCP servers
The traditional approach to custom metrics from Lambda is to call cloudwatch.putMetricData() from your handler. This has a critical problem: the API call is synchronous with respect to your handler's response time — if you await it before returning the MCP tool result, you add 5–50ms of CloudWatch API latency to every tool call. If you don't await it and instead run it in a background promise, Lambda may freeze the execution environment before the network call completes, silently dropping the metric.
EMF solves both problems. Your handler writes a JSON object to stdout using a synchronous console.log() call — zero async operations, zero added latency, never dropped. Lambda flushes stdout to CloudWatch Logs synchronously before freezing the container. CloudWatch Logs detects the EMF structure in the log stream and extracts the metrics asynchronously on AWS's side. From your handler's perspective, metric publishing is a synchronous console.log that completes in microseconds.
| Approach | Handler latency impact | Metric delivery reliability | Per-invocation cost |
|---|---|---|---|
| PutMetricData (awaited) | 5–50ms added per invocation | High — explicit API call confirms delivery | $0.01 per 1,000 metric data points |
| PutMetricData (fire-and-forget) | Near-zero (does not await) | Low — container freeze before network send drops metrics silently | $0.01 per 1,000 metric data points |
| Embedded Metric Format (EMF) | Microseconds (synchronous console.log) | High — stdout is flushed synchronously before container freeze | Same as CloudWatch Logs ingestion (~$0.50/GB) |
The cost comparison depends on your volume. At low invocation rates (under 100K/month), PutMetricData and EMF cost roughly the same. At high rates, EMF is cheaper because you amortize metric extraction cost into the log ingestion you were already paying for, and you avoid the $0.01/1,000-datapoints PutMetricData charge for high-resolution metrics.
The EMF JSON structure
EMF is a JSON format with a special _aws key that contains the metadata CloudWatch needs to extract metrics. The rest of the JSON object contains the metric values and dimension values mixed together as flat key-value pairs. CloudWatch identifies which keys are metrics vs. properties using the Metrics array in the metadata.
// Raw EMF output — what gets written to stdout by the aws-embedded-metrics library
// You normally don't write this manually; use the library instead
{
"_aws": {
"Timestamp": 1728547200000,
"CloudWatchMetrics": [
{
"Namespace": "McpServer",
"Dimensions": [["Tool", "FunctionName"]],
"Metrics": [
{ "Name": "McpToolDuration", "Unit": "Milliseconds" },
{ "Name": "McpToolErrors", "Unit": "Count" },
{ "Name": "McpToolOutputSizeBytes", "Unit": "Bytes" }
]
}
]
},
"Tool": "s3_read",
"FunctionName": "mcp-server-prod",
"McpToolDuration": 142,
"McpToolErrors": 0,
"McpToolOutputSizeBytes": 4096,
"RequestId": "1a2b3c4d-5678-90ab-cdef-1234567890ab"
}
The Dimensions array specifies which combinations of keys become metric dimensions in CloudWatch. In this example, [["Tool", "FunctionName"]] means CloudWatch creates a metric data point with two dimensions: the tool name and the Lambda function name. You can include multiple dimension sets in one EMF log line to publish the same metric at different dimension granularities (e.g., per-tool and function-aggregate simultaneously).
Using AWS Lambda Powertools Metrics (recommended)
The @aws-lambda-powertools/metrics package is a thin wrapper over EMF that handles the _aws envelope construction, dimension management, and stdout flushing for you. It also integrates with the Powertools Logger to correlate metrics with log events by request ID.
// Install: npm install @aws-lambda-powertools/metrics
import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics';
import type { LambdaInterface } from '@aws-lambda-powertools/commons/types';
const metrics = new Metrics({
namespace: 'McpServer',
serviceName: 'mcp-server-prod',
// defaultDimensions are added to every metric emitted by this instance
});
class McpHandler implements LambdaInterface {
@metrics.logMetrics({ captureColdStartMetric: true, throwOnEmptyMetrics: false })
async handler(event: unknown, _context: AWSLambda.Context): Promise {
return mcpSdkDispatch(event, this.handleToolCall.bind(this));
}
async handleToolCall(toolName: string, input: unknown): Promise {
const start = Date.now();
let errorOccurred = false;
metrics.addDimension('Tool', toolName);
try {
const result = await this.executeToolLogic(toolName, input);
const duration = Date.now() - start;
metrics.addMetric('McpToolDuration', MetricUnit.Milliseconds, duration);
metrics.addMetric('McpToolErrors', MetricUnit.Count, 0);
metrics.addMetric('McpToolOutputSizeBytes', MetricUnit.Bytes,
JSON.stringify(result).length);
return result;
} catch (err) {
errorOccurred = true;
const duration = Date.now() - start;
metrics.addMetric('McpToolDuration', MetricUnit.Milliseconds, duration);
metrics.addMetric('McpToolErrors', MetricUnit.Count, 1);
throw err;
} finally {
// Clear the Tool dimension so it does not bleed into the next tool call
// in a warm container that handles multiple invocations
metrics.clearDimensions();
}
}
}
export const { handler } = new McpHandler();
The @metrics.logMetrics decorator automatically flushes the metric buffer to stdout at the end of each invocation via publishStoredMetrics(). Without the decorator, you must call metrics.publishStoredMetrics() manually before returning — if you forget, the metrics will not appear in CloudWatch.
Custom namespace and dimensions design for MCP servers
Choose your CloudWatch namespace and dimensions before you start emitting metrics — changing them later means historical metrics under the old namespace/dimensions are not queryable alongside new ones, making trend analysis impossible.
A recommended dimension schema for MCP server telemetry:
| Dimension | Example value | What it enables |
|---|---|---|
Tool | s3_read, dynamo_query | Per-tool latency and error rate trends; alarm on specific tool degradation |
FunctionName | mcp-server-prod | Cross-tool aggregate view; separate prod vs staging |
Environment | prod, staging | Filter out dev noise from production dashboards |
UpstreamService | S3, DynamoDB | Track which AWS service is the bottleneck across tool failures |
Avoid high-cardinality dimensions such as RequestId, UserId, or McpSessionId as dimensions on CloudWatch metrics. CloudWatch stores one time-series per unique dimension combination — high-cardinality dimensions create millions of time-series and incur significant CloudWatch Metrics storage costs. Use structured log fields for high-cardinality data and query them with Logs Insights instead.
// Correct: low-cardinality dimensions only (tool name is bounded by your tool count)
metrics.addDimension('Tool', toolName); // ~5-20 unique values
metrics.addDimension('FunctionName', 'mcp-server-prod'); // 1 value
metrics.addMetric('McpToolDuration', MetricUnit.Milliseconds, duration);
// WRONG: never use request ID or user ID as a CloudWatch metric dimension
// This creates a new CloudWatch time-series for every single invocation
// metrics.addDimension('RequestId', context.awsRequestId); // millions of values!
Multiple dimension sets — same metric at different granularities
EMF supports publishing the same metric value into multiple dimension combinations in a single log line. This lets you get both per-tool metrics and function-aggregate metrics without two separate log writes:
// Using the raw EMF library for advanced multi-dimension-set scenarios
import { createMetricsLogger, Unit } from 'aws-embedded-metrics';
export async function handler(event: unknown, context: AWSLambda.Context) {
const metricsLogger = createMetricsLogger();
// Tool-level granularity
metricsLogger.putDimensions({ Tool: 's3_read', FunctionName: 'mcp-server-prod' });
// Function-level aggregate (no Tool dimension)
metricsLogger.putDimensions({ FunctionName: 'mcp-server-prod' });
metricsLogger.putMetric('McpToolDuration', 142, Unit.Milliseconds);
metricsLogger.putMetric('McpToolErrors', 0, Unit.Count);
// One EMF log line creates two CloudWatch time-series:
// 1. Namespace=McpServer, Dimensions=[Tool=s3_read, FunctionName=mcp-server-prod]
// 2. Namespace=McpServer, Dimensions=[FunctionName=mcp-server-prod]
await metricsLogger.flush();
}
Alarming on EMF-derived custom metrics
Once EMF metrics are flowing, create CloudWatch alarms on them the same way you alarm on standard Lambda metrics — using the --namespace McpServer and the dimension combination you chose:
# Alarm: high error rate for a specific MCP tool
aws cloudwatch put-metric-alarm \
--alarm-name "mcp-s3_read-errors" \
--alarm-description "MCP s3_read tool error rate elevated" \
--namespace "McpServer" \
--metric-name "McpToolErrors" \
--dimensions Name=Tool,Value=s3_read Name=FunctionName,Value=mcp-server-prod \
--statistic Sum \
--period 60 \
--evaluation-periods 2 \
--threshold 5 \
--comparison-operator GreaterThanOrEqualToThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:us-east-1:123456789012:mcp-alerts
# Alarm: p99 latency degradation for a specific tool
aws cloudwatch put-metric-alarm \
--alarm-name "mcp-dynamo_query-latency" \
--alarm-description "MCP dynamo_query p99 latency above 2s" \
--namespace "McpServer" \
--metric-name "McpToolDuration" \
--dimensions Name=Tool,Value=dynamo_query Name=FunctionName,Value=mcp-server-prod \
--extended-statistic p99 \
--period 300 \
--evaluation-periods 2 \
--threshold 2000 \
--comparison-operator GreaterThanOrEqualToThreshold \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:us-east-1:123456789012:mcp-alerts
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Metrics not appearing in CloudWatch after deploying EMF | Metrics are flushed at invocation end — no invocations have been made yet, or publishStoredMetrics() is not being called (if using the library without the decorator) | Invoke the function once; verify the raw EMF JSON appears in CloudWatch Logs with aws logs filter-log-events --log-group-name /aws/lambda/mcp-server-prod --filter-pattern '_aws' |
| Metrics appear in CloudWatch Logs but not as CloudWatch Metrics | The EMF JSON is malformed — the _aws.CloudWatchMetrics[0].Metrics array references a metric key that does not exist as a sibling key in the top-level JSON, or the _aws key is nested inside another object instead of at the top level | Validate the raw EMF output: the _aws key must be at the root level of the JSON, and every metric name in Metrics[].Name must appear as a sibling key with a numeric value |
| CloudWatch metrics have unexpected high cardinality | A dimension is using a high-cardinality value (request ID, timestamp, session ID) causing CloudWatch to create millions of time-series | Audit dimensions with aws cloudwatch list-metrics --namespace McpServer and count unique dimension combinations; replace high-cardinality dimensions with bounded values (tool name, function name, environment) |
| Cold start metric is not captured | The Powertools captureColdStartMetric: true option was not set, or the decorator was applied to a method instead of the exported handler | Apply the decorator to the exported handler function; verify captureColdStartMetric: true in the decorator options |
| Metrics from multiple warm invocations are batched together | Powertools Metrics buffers metrics in memory across warm invocations — if you add a metric in one invocation and do not flush, the next invocation's metrics are added to the same buffer and flushed together with an inflated count | Always flush at the end of each handler invocation; use the @metrics.logMetrics decorator which flushes automatically; or call metrics.clearMetrics() at handler start to reset any leftover state from the previous invocation |