Sessão 036 — CloudWatch: Custom Metrics com EMF (Embedded Metrics Format)
Dependências: session-018-ecs-observabilidade-firelens-xray, session-024-lambda-observabilidade-xray-insights
Objetivo
Ao final desta sessão, você conseguirá emitir métricas customizadas de uma função Lambda usando o formato EMF (via print() para stdout em JSON estruturado, sem API call separada), criar um dashboard CloudWatch que plota essas métricas, explicar por que EMF é preferível a put-metric-data em termos de latência e custo, e usar o AWS Lambda Powertools para simplificar a emissão.
Contexto
[FATO] O Embedded Metrics Format (EMF) é uma especificação JSON do CloudWatch que instrui o serviço CloudWatch Logs a extrair automaticamente métricas customizadas de log events estruturados. Em vez de fazer uma chamada de API separada ao CloudWatch, a função escreve um JSON especialmente formatado no stdout — o Lambda Logs Agent captura isso e encaminha ao CloudWatch Logs, que extrai as métricas de forma assíncrona.
[CONSENSO] EMF é a abordagem canônica para métricas customizadas em Lambda por três razões: (1) não bloqueia a execução da função (assíncrono via logs); (2) elimina o custo de PutMetricData por API call; (3) o JSON EMF também fica disponível como log event no CloudWatch Logs Insights para debugging.
Conceitos principais
1. Estrutura do documento EMF
[FATO] Um documento EMF válido é um JSON com a chave _aws como metadado obrigatório, mais os valores das métricas e dimensões como campos no nível raiz:
{
"_aws": {
"Timestamp": 1574109732004,
"CloudWatchMetrics": [
{
"Namespace": "MyApp/Payments",
"Dimensions": [["service", "environment"]],
"Metrics": [
{ "Name": "ProcessingLatency", "Unit": "Milliseconds", "StorageResolution": 60 },
{ "Name": "SuccessfulPayments", "Unit": "Count", "StorageResolution": 60 }
]
}
]
},
"service": "payment-processor",
"environment": "production",
"ProcessingLatency": 45.3,
"SuccessfulPayments": 1,
"orderId": "ord-abc-123"
}
[FATO] Regras estruturais do EMF:
- _aws.Timestamp: epoch em milissegundos (obrigatório)
- _aws.CloudWatchMetrics: array de MetricDirective (obrigatório)
- Cada MetricDirective deve ter Namespace, Dimensions (array de arrays de strings), e Metrics (array de MetricDefinition)
- Máximo de 100 métricas por documento EMF
- Máximo de 30 dimensões por DimensionSet
- Valores de métrica: número ou array de números (máximo 100 elementos)
- Campos extras além das métricas/dimensões (como orderId acima) são preservados no log mas não viram métricas — servem como contexto para Logs Insights
[FATO] Unidades válidas: Seconds, Microseconds, Milliseconds, Bytes, Kilobytes, Megabytes, Gigabytes, Terabytes, Bits, Kilobits, Megabits, Gigabits, Terabits, Percent, Count, Bytes/Second, Kilobytes/Second, Megabytes/Second, Gigabytes/Second, Terabytes/Second, Bits/Second, Count/Second, None
2. EMF vs. PutMetricData: comparação de custo e latência
[FATO] Existem dois mecanismos de ingestão de métricas customizadas no CloudWatch:
EMF (via CloudWatch Logs) PutMetricData API
────────────────────────────────────────────────────────────────────
Execução Assíncrona — print() retorna Síncrona — bloqueia
imediatamente até resposta da API
Latência na função Nenhuma adicionada Latência de rede
(tipicamente 10-50ms)
Custo de escrita Custo de logs ingestion $0.01 por 1.000 API calls
($0.50/GB — us-east-1) (independente do volume)
Custo de métrica Igual: $0.30/métrica/mês Igual: $0.30/métrica/mês
(primeiras 10.000) (primeiras 10.000)
Permissão requerida logs:PutLogEvents cloudwatch:PutMetricData
Dados de contexto Log event completo disponível Apenas dados da métrica
no Logs Insights
Limite de batch 100 métricas por blob EMF 20 métricas por
PutMetricData call
[CONSENSO] Para funções Lambda com alta taxa de invocações, EMF é financeiramente superior porque você não paga por API call — paga apenas pela ingestão de logs (que já ocorreria de qualquer forma). A break-even point é baixa: qualquer função com mais de ~1.000 invocações/dia normalmente economiza com EMF.
[OPINIÃO — AWS Well-Architected Serverless Lens] EMF é a abordagem recomendada para métricas customizadas em workloads serverless por eliminar chamadas síncronas que aumentam a duração faturável da função.
3. High-resolution metrics
[FATO] O campo StorageResolution no MetricDefinition define a granularidade de armazenamento:
StorageResolution = 60 → Standard resolution: CloudWatch armazena em
granularidade de 1 minuto
(default, menor custo)
StorageResolution = 1 → High resolution: CloudWatch armazena em
granularidade de 1 segundo
(útil para anomaly detection em tempo real,
alertas sub-minuto)
[FATO] High-resolution metrics são cobradas da mesma forma que standard em termos de custo de métrica ($0.30/métrica/mês), mas consomem mais armazenamento interno. Alarms em high-resolution metrics podem ter período mínimo de 10 ou 30 segundos.
4. Unique metric = nome + namespace + dimensões
[FATO] O CloudWatch define uma métrica única pela combinação de: metric_name + namespace + {dimension_key: dimension_value, ...}. Esta distinção é crítica para entender o custo:
Métrica A: Namespace="MyApp", Name="Latency", {service="payment"}
Métrica B: Namespace="MyApp", Name="Latency", {service="checkout"}
→ São 2 métricas distintas, cobradas separadamente.
Armadilha de alta cardinalidade:
Namespace="MyApp", Name="Latency", {requestId="abc-123"}
Namespace="MyApp", Name="Latency", {requestId="def-456"}
→ Cada requestId único cria uma nova métrica!
1M de requisições/dia = potencialmente 1M de métricas novas/dia
= custo explosivo ($0.30 × 1.000.000 = $300.000/mês)
[FATO] Dimensões de alta cardinalidade (requestId, userId, sessionId) nunca devem ser usadas como dimensões. Use-as como campos de metadata no EMF (ficam no log, pesquisáveis via Logs Insights, sem custo de métrica).
5. AWS Lambda Powertools — Metrics utility
[FATO] O módulo aws_lambda_powertools.metrics é uma abstração sobre EMF que: valida o schema, serializa o JSON, faz flush no decorator @log_metrics, e impede criação acidental de métricas com dimensões de alta cardinalidade.
Comportamento do Metrics (singleton compartilhado entre módulos):
- Acumula métricas em memória durante a invocação
- Flush automático no fim do handler (via @log_metrics)
- Flush automático ao atingir 100 métricas (limite EMF)
- Valida unidades, namespace, dimensões na emissão
EphemeralMetrics (não-singleton):
- Instância isolada — não compartilha estado
- Útil para multi-tenant ou métricas com dimensões completamente distintas
Exemplo prático
Cenário: API de pagamentos — métricas de negócio e operacionais
CDK Python — Lambda + Layer Powertools + Dashboard
from aws_cdk import (
Stack, Duration, RemovalPolicy,
aws_lambda as lambda_,
aws_logs as logs,
aws_cloudwatch as cw,
aws_cloudwatch_actions as cw_actions,
aws_sns as sns,
)
from constructs import Construct
class PaymentMetricsStack(Stack):
def __init__(self, scope: Construct, construct_id: str, **kwargs):
super().__init__(scope, construct_id, **kwargs)
# ── Lambda Powertools Layer (ARN oficial por região/versão) ───
# Consulte: https://docs.aws.amazon.com/powertools/python/latest/
powertools_layer = lambda_.LayerVersion.from_layer_version_arn(
self, "PowertoolsLayer",
layer_version_arn=(
f"arn:aws:lambda:{self.region}:017000801446:"
"layer:AWSLambdaPowertoolsPythonV3-python312-x86_64:31"
),
)
# ── Função Lambda ─────────────────────────────────────────────
payment_fn = lambda_.Function(
self, "PaymentProcessor",
function_name="payment-processor",
runtime=lambda_.Runtime.PYTHON_3_12,
handler="handler.lambda_handler",
code=lambda_.Code.from_asset("lambda/payment"),
timeout=Duration.seconds(30),
memory_size=256,
layers=[powertools_layer],
environment={
"POWERTOOLS_SERVICE_NAME": "payment-processor",
"POWERTOOLS_METRICS_NAMESPACE": "MyApp/Payments",
"ENVIRONMENT": "production",
"LOG_LEVEL": "INFO",
},
log_retention=logs.RetentionDays.ONE_WEEK,
)
# ── Dashboard CloudWatch ──────────────────────────────────────
dashboard = cw.Dashboard(
self, "PaymentDashboard",
dashboard_name="payment-metrics",
)
# Widget 1: Taxa de sucesso vs falha
dashboard.add_widgets(
cw.GraphWidget(
title="Payment Success vs Failure Rate",
width=12,
left=[
cw.Metric(
namespace="MyApp/Payments",
metric_name="SuccessfulPayments",
dimensions_map={"service": "payment-processor", "environment": "production"},
statistic="Sum",
period=Duration.minutes(1),
color="#2ca02c",
),
cw.Metric(
namespace="MyApp/Payments",
metric_name="FailedPayments",
dimensions_map={"service": "payment-processor", "environment": "production"},
statistic="Sum",
period=Duration.minutes(1),
color="#d62728",
),
],
),
# Widget 2: Latência de processamento (p50, p95, p99)
cw.GraphWidget(
title="Processing Latency (ms)",
width=12,
left=[
cw.Metric(
namespace="MyApp/Payments",
metric_name="ProcessingLatency",
dimensions_map={"service": "payment-processor", "environment": "production"},
statistic="p50",
period=Duration.minutes(1),
label="p50",
color="#1f77b4",
),
cw.Metric(
namespace="MyApp/Payments",
metric_name="ProcessingLatency",
dimensions_map={"service": "payment-processor", "environment": "production"},
statistic="p95",
period=Duration.minutes(1),
label="p95",
color="#ff7f0e",
),
cw.Metric(
namespace="MyApp/Payments",
metric_name="ProcessingLatency",
dimensions_map={"service": "payment-processor", "environment": "production"},
statistic="p99",
period=Duration.minutes(1),
label="p99",
color="#d62728",
),
],
),
)
# Widget 3: Valor total processado
dashboard.add_widgets(
cw.GraphWidget(
title="Payment Value Processed (USD)",
width=12,
left=[
cw.Metric(
namespace="MyApp/Payments",
metric_name="PaymentValueUSD",
dimensions_map={"service": "payment-processor", "environment": "production"},
statistic="Sum",
period=Duration.minutes(5),
),
],
),
# Widget 4: Cold starts
cw.GraphWidget(
title="Cold Starts",
width=12,
left=[
cw.Metric(
namespace="MyApp/Payments",
metric_name="ColdStart",
dimensions_map={
"function_name": "payment-processor",
"service": "payment-processor",
},
statistic="Sum",
period=Duration.minutes(5),
),
],
),
)
# ── Alarm em falhas ───────────────────────────────────────────
alarm_topic = sns.Topic(self, "PaymentAlarmTopic")
cw.Alarm(
self, "HighFailureRateAlarm",
alarm_name="payment-high-failure-rate",
metric=cw.Metric(
namespace="MyApp/Payments",
metric_name="FailedPayments",
dimensions_map={"service": "payment-processor", "environment": "production"},
statistic="Sum",
period=Duration.minutes(5),
),
threshold=10,
evaluation_periods=2,
comparison_operator=cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treat_missing_data=cw.TreatMissingData.NOT_BREACHING,
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
Lambda handler — EMF via Powertools e EMF manual
# lambda/payment/handler.py
import json
import os
import time
from typing import Any
from aws_lambda_powertools import Logger, Metrics
from aws_lambda_powertools.metrics import MetricUnit, MetricResolution
from aws_lambda_powertools.utilities.typing import LambdaContext
# Inicializado globalmente (singleton compartilhado)
# Namespace e service vêm de env vars:
# POWERTOOLS_METRICS_NAMESPACE="MyApp/Payments"
# POWERTOOLS_SERVICE_NAME="payment-processor"
logger = Logger()
metrics = Metrics()
# Dimensão adicional compartilhada por todas as métricas
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
metrics.set_default_dimensions(environment=ENVIRONMENT)
@metrics.log_metrics(capture_cold_start_metric=True)
@logger.inject_lambda_context
def lambda_handler(event: dict, context: LambdaContext) -> dict:
"""
@metrics.log_metrics:
- Serializa e faz flush do EMF blob no stdout ao final do handler
- Captura cold start em blob EMF separado (dimensão function_name)
- Se ocorrer exceção, o flush ainda é executado
"""
start_time = time.perf_counter()
order_id = event.get("orderId", "unknown")
amount_usd = float(event.get("amountUSD", 0))
currency = event.get("currency", "USD")
try:
# Simula processamento
result = _process_payment(order_id, amount_usd, currency)
# ── Métricas de negócio ──────────────────────────────────────
metrics.add_metric(
name="SuccessfulPayments",
unit=MetricUnit.Count,
value=1,
)
metrics.add_metric(
name="PaymentValueUSD",
unit=MetricUnit.Count, # CloudWatch não tem "Currency"
value=amount_usd,
)
# ── Metadata de alta cardinalidade (NO log, NOT a dimension) ──
# orderId vai para o log event mas NÃO cria nova métrica por invocação
metrics.add_metadata(key="orderId", value=order_id)
metrics.add_metadata(key="currency", value=currency)
metrics.add_metadata(key="processor", value=result.get("processor"))
return {"statusCode": 200, "body": json.dumps(result)}
except ValueError as e:
# Erro de validação do pedido
metrics.add_metric(name="FailedPayments", unit=MetricUnit.Count, value=1)
metrics.add_metadata(key="errorType", value="ValidationError")
metrics.add_metadata(key="errorMessage", value=str(e))
metrics.add_metadata(key="orderId", value=order_id)
logger.error("Payment validation failed", extra={"orderId": order_id, "error": str(e)})
return {"statusCode": 400, "body": json.dumps({"error": str(e)})}
except Exception as e:
metrics.add_metric(name="FailedPayments", unit=MetricUnit.Count, value=1)
metrics.add_metadata(key="errorType", value=type(e).__name__)
metrics.add_metadata(key="orderId", value=order_id)
logger.exception("Unexpected payment error", extra={"orderId": order_id})
return {"statusCode": 500, "body": json.dumps({"error": "Internal error"})}
finally:
# Latência sempre emitida, independente de sucesso/falha
elapsed_ms = (time.perf_counter() - start_time) * 1000
metrics.add_metric(
name="ProcessingLatency",
unit=MetricUnit.Milliseconds,
value=elapsed_ms,
resolution=MetricResolution.High, # StorageResolution=1: sub-minuto
)
def _process_payment(order_id: str, amount: float, currency: str) -> dict:
if amount <= 0:
raise ValueError(f"Invalid amount: {amount}")
if currency not in ("USD", "EUR", "BRL"):
raise ValueError(f"Unsupported currency: {currency}")
time.sleep(0.02) # simula chamada externa
return {"orderId": order_id, "status": "approved", "processor": "stripe"}
EMF manual sem Powertools (para entender o mecanismo)
import json
import time
def emit_emf_manually(metric_name: str, value: float, unit: str,
namespace: str, dimensions: dict) -> None:
"""
Emite uma métrica EMF via print() — sem nenhuma dependência externa.
O Lambda Logs Agent captura o stdout e envia ao CloudWatch Logs.
CloudWatch Logs extrai a métrica automaticamente.
"""
emf_document = {
"_aws": {
"Timestamp": int(time.time() * 1000), # epoch em milissegundos
"CloudWatchMetrics": [
{
"Namespace": namespace,
"Dimensions": [list(dimensions.keys())], # array de arrays
"Metrics": [
{"Name": metric_name, "Unit": unit, "StorageResolution": 60}
],
}
],
},
metric_name: value,
**dimensions, # dimensões no nível raiz
}
# Uma linha por documento EMF (sem quebra de linha interna)
print(json.dumps(emf_document))
CLI — Criar métricas manualmente e criar dashboard
# 1. Emitir uma métrica via PutMetricData (para comparação com EMF)
aws cloudwatch put-metric-data \
--namespace "MyApp/Payments" \
--metric-name "ManualTestMetric" \
--value 42 \
--unit Count \
--dimensions service=payment-processor,environment=production
# 2. Verificar se as métricas EMF foram criadas
aws cloudwatch list-metrics \
--namespace "MyApp/Payments" \
--query 'Metrics[*].{Name:MetricName,Dims:Dimensions}'
# 3. Obter estatísticas de latência (últimos 15 minutos)
aws cloudwatch get-metric-statistics \
--namespace "MyApp/Payments" \
--metric-name "ProcessingLatency" \
--dimensions Name=service,Value=payment-processor \
Name=environment,Value=production \
--start-time "$(date -u -d '15 minutes ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-15M '+%Y-%m-%dT%H:%M:%SZ')" \
--end-time "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
--period 60 \
--statistics Average Minimum Maximum p95 p99 \
--extended-statistics p95 p99
# 4. Criar alarm em taxa de erros alta
aws cloudwatch put-metric-alarm \
--alarm-name "payment-high-failure-rate" \
--alarm-description "More than 10 payment failures in 5 minutes" \
--namespace "MyApp/Payments" \
--metric-name "FailedPayments" \
--dimensions Name=service,Value=payment-processor Name=environment,Value=production \
--statistic Sum \
--period 300 \
--evaluation-periods 2 \
--threshold 10 \
--comparison-operator GreaterThanOrEqualToThreshold \
--treat-missing-data notBreaching
# 5. Verificar JSON EMF gerado pelo Lambda nos logs
LOG_GROUP="/aws/lambda/payment-processor"
LATEST_STREAM=$(aws logs describe-log-streams \
--log-group-name "$LOG_GROUP" \
--order-by LastEventTime \
--descending \
--limit 1 \
--query 'logStreams[0].logStreamName' \
--output text)
aws logs get-log-events \
--log-group-name "$LOG_GROUP" \
--log-stream-name "$LATEST_STREAM" \
--limit 20 \
--query 'events[*].message' \
--output text | grep -A5 '"_aws"' | head -50
# 6. Consultar métricas e orderId via Logs Insights
# (os campos de metadata ficam pesquisáveis mesmo não sendo dimensões)
aws logs start-query \
--log-group-name "/aws/lambda/payment-processor" \
--start-time "$(date -u -d '1 hour ago' +%s 2>/dev/null || date -u -v-1H +%s)" \
--end-time "$(date -u +%s)" \
--query-string '
fields @timestamp, orderId, ProcessingLatency, errorType
| filter ispresent(FailedPayments)
| sort @timestamp desc
| limit 20
'
Armadilhas comuns
1. Dimensões de alta cardinalidade = explosão de custo
[FATO] Cada combinação única de (namespace + metric_name + dimensions) é uma métrica distinta cobrada a $0.30/mês (para as primeiras 10.000). Usar requestId, userId ou sessionId como dimensão com 1M de valores únicos/mês gera 1M de novas métricas = $300.000/mês. Use add_metadata() para dados de alta cardinalidade — ficam no log, não criam métricas.
2. EMF em múltiplas linhas não é processado
[FATO] O documento EMF deve ser um único objeto JSON em uma única linha no stdout. Se o JSON estiver pretty-printed (com quebras de linha), o CloudWatch Logs não reconhece o formato e descarta a extração de métricas silenciosamente. O Powertools garante isso; no modo manual, use json.dumps(doc) sem indent.
3. Métricas ou dimensões definidas fora do handler (escopo global)
[CONSENSO] Dimensões ou métricas adicionadas no escopo global da função (import time) só são aplicadas durante o cold start. Nas invocações seguintes, o estado global persiste entre chamadas na mesma instância, mas não é reinicializado. O Powertools avisa sobre isso. Dimensões permanentes devem ser configuradas via set_default_dimensions().
4. Falta de flush em exceção sem o decorator
[FATO] Se você usa metrics.flush_metrics() manualmente (sem o decorator @log_metrics), uma exceção não capturada em um bloco intermediário pode deixar métricas não emitidas. Sempre use try/finally ou use o decorator, que garante flush mesmo em caso de exceção.
5. StorageResolution=1 (high-resolution) sem necessidade real
[CONSENSO] High-resolution metrics permitem alarms com período de 10/30 segundos, mas não custam mais em métricas — o custo extra é marginal em armazenamento. O problema é que alarms em high-resolution metrics consomem mais leituras de avaliação, aumentando levemente o custo de alarm. Use high-resolution apenas quando a granularidade sub-minuto for realmente necessária para o SLA.
6. Namespace com alta granularidade cria silos de visibilidade
[CONSENSO] Usar namespaces muito específicos (ex: MyApp/Payments/OrderType/Subscription) fragmenta as métricas em silos difíceis de correlacionar no dashboard. Use um namespace por serviço/aplicação e use dimensões para segmentar — as dimensões são filtráveis no console e nas queries.
Exercício de reflexão
Você tem uma função Lambda que processa eventos de uma fila SQS. Para cada mensagem, você quer monitorar:
- MessageProcessed (Count) — por tipo de mensagem (messageType: order, refund, notification)
- ProcessingDuration (Milliseconds) — latência do processamento
- O messageId original para correlação com logs
-
Como você estruturaria as dimensões para
MessageProcessedconsiderando que há 3 tipos de mensagem (order,refund,notification)? Quantas métricas distintas isso cria no CloudWatch? Qual seria o problema se você usassemessageIdcomo dimensão? -
Escreva o trecho de código Python com Powertools que emite
MessageProcessedeProcessingDurationcom a dimensãomessageType, mantendomessageIdcomo metadata (não dimensão). Use@metrics.log_metrics. -
O EMF blob gerado pelo Powertools é também um log event em CloudWatch Logs. Escreva uma query Logs Insights que lista os últimos 10
messageIdde mensagens do tiporefundcomProcessingDuration > 500ms.
Recursos para aprofundar
- [FATO] EMF Specification (estrutura JSON, limites, schema): https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html
- [FATO] Embedding metrics within logs (overview): https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html
- [FATO] Powertools for AWS Lambda (Python) — Metrics: https://docs.aws.amazon.com/powertools/python/latest/core/metrics/
- [FATO] CloudWatch Pricing (custom metrics, ingestion): https://aws.amazon.com/cloudwatch/pricing/