Faz umas duas semanas que eu vivo dentro de um único ticket. Aquele tipo de tarefa que no Jira parece ter três linhas — "implementar observabilidade" — e que na prática abre um buraco que vai até o núcleo da terra. Se você trabalha com sistemas rodando em ECS atrás de um framework corporativo (no meu caso, o AppSpace) e alguém te pediu pra "só ligar o OpenTelemetry", este texto é pra você. Vou tentar poupar você de pelo menos metade dos tropeços que eu dei.
Não é um artigo sobre o que é o OpenTelemetry — já tem gente boa escrevendo isso há anos. É sobre o que ninguém te conta: a parte chata de fazer isso funcionar dentro de uma ECS Task provisionada via CDK, com um Collector ADOT no meio, IAM restritivo, rede fechada e um monte de padrão corporativo que você só descobre lendo o CDK de outro time.
Por que isso importa (e por que não é só "mais um log bonito")
Antes de qualquer coisa, vale alinhar o motivo. O sistema que estou instrumentando — vou chamar aqui só de "o sistema", porque o nome real não vem ao caso — tinha um problema clássico: quando algo dava errado em produção, a gente descobria pelo usuário reclamando, não pelo monitoramento. Log espalhado, sem correlação entre request e erro, sem noção de latência por endpoint. Cada incidente virava uma caça ao tesouro no CloudWatch Logs Insights, torcendo pra achar o request_id certo.
OpenTelemetry não resolve isso sozinho, mas resolve o problema de fundo: dá um vocabulário único pra métricas, logs e traces, e permite que tudo isso saia da aplicação já correlacionado. Trace com span, span com atributo, atributo batendo com o log da mesma requisição. Quando funciona, você troca "vasculhar log" por "abrir uma trace e ver exatamente onde travou".
O trade-off é que você importa complexidade de infraestrutura pra dentro do seu deploy. E é aí que mora o trabalho de verdade.
O desenho: onde entra o ADOT Collector
Duas formas de instrumentar algo rodando em ECS: mandar telemetria direto pro backend (CloudWatch, X-Ray, seja lá o que for) de dentro da própria aplicação, ou rodar um Collector como sidecar dentro da Task e deixar ele cuidar do roteamento. Eu fui na segunda opção, e recomendo — não é modismo, é prático.
Com o ADOT Collector (a distribuição da AWS pro OpenTelemetry Collector) rodando como container sidecar na mesma Task Definition, a aplicação Python só precisa falar OTLP com localhost. Ela nem sabe que existe CloudWatch ou X-Ray do outro lado. O Collector é quem decide pra onde manda cada sinal, faz batching, retry, e absorve instabilidade de rede sem travar a aplicação.
┌─────────────────────────── ECS Task ───────────────────────────┐
│ │
│ ┌────────────────┐ OTLP/gRPC :4317 ┌──────────────────┐ │
│ │ App (Python) │ ───────────────────>│ ADOT Collector │ │
│ │ FastAPI + OTel │ │ (sidecar) │ │
│ │ SDK │ └────────┬─────────┘│
│ └────────────────┘ │ │
│ │ │
└───────────────────────────────────────────────────────┼─────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
CloudWatch Logs CloudWatch Metrics X-Ray
No nosso caso, quem provisiona a Task Definition não somos nós diretamente — é o framework interno AppSpace, via CDK. Isso muda bastante o jogo, porque em vez de eu escrever o TaskDefinition na unha, eu preencho um construct de alto nível e ele monta tudo por baixo dos panos. Bom pra padronização, ruim quando você quer entender o que está acontecendo de verdade (e você vai querer, na primeira vez que algo não subir).
Habilitando o Collector no AppSpace CDK
O AppSpace expõe observabilidade via uma flag no construct principal do serviço. Simplificando (os nomes reais das props internas variam por versão do framework, mas a ideia é essa):
# infra/service_stack.py
from appspace_cdk import (
AppSpaceEcsService,
CdkMonitoringTechnology,
CdkOtelCollectorSize,
OtelCollectorConfig,
MonitoringConfig,
)
app_service = AppSpaceEcsService(
self, "AppService",
service_name="backend",
image=app_image,
environment={
"APP_ENV": "production",
},
# aqui é onde a mágica (e a dor de cabeça) começa
monitoring=MonitoringConfig(
technology=CdkMonitoringTechnology.OPEN_TELEMETRY,
otel_collector=OtelCollectorConfig(
size=CdkOtelCollectorSize.SMALL,
# exportadores habilitados no sidecar
exporters=["cloudwatch-logs", "cloudwatch-metrics", "xray"],
),
),
)
Duas coisas que me morderam aqui:
1. CdkMonitoringTechnology.OPEN_TELEMETRY não é aditivo, é substitutivo. Se o serviço já tinha um jeito antigo de mandar métrica (o AppSpace tem uma opção legada baseada em CloudWatch Agent puro), trocar pra OPEN_TELEMETRY desliga o antigo. Não existe "rodar os dois em paralelo pra comparar" sem gambiarra. Eu tive que aceitar isso e validar em ambiente de não-produção antes de subir pra valer, porque não dá pra correr o risco de ficar cego em produção no meio da troca.
2. CdkOtelCollectorSize não é sobre a Task, é sobre o container do Collector. Isso pareceu óbvio depois, mas na hora eu passei um tempo achando que tinha errado o dimensionamento da Task inteira. O enum define CPU/memória reservados só pro sidecar:
| Size | CPU (unidades) | Memória |
|---|---|---|
| SMALL | 128 | 256 MB |
| MEDIUM | 256 | 512 MB |
| LARGE | 512 | 1024 MB |
Comecei com SMALL porque o volume de tráfego do sistema é baixo (é ferramenta interna, não é produto de consumidor). Fiquei de olho no CollectorMemoryUsage e no CollectorCPUUsage que o próprio Collector expõe como métrica interna — se aparecer throttling de CPU ou o processo reiniciando por OOM, sobe pra MEDIUM. Regra prática que uso: se você tem mais de um serviço com volume razoável de traces (spans grandes, muitos atributos), não economize aqui — o Collector fazendo batching mal dimensionado vira gargalo silencioso, ele só começa a dropar dado sem avisar direito.
Instrumentando o lado Python
Do lado da aplicação, a ideia é a mesma de sempre: SDK do OpenTelemetry, auto-instrumentação onde dá, manual onde importa. Pra FastAPI + SQLAlchemy (que é a nossa stack), ficou assim:
# observability.py
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
def setup_tracing(app, engine, service_name: str):
resource = Resource.create({
"service.name": service_name,
"service.namespace": "meu-sistema",
"deployment.environment": "production",
})
provider = TracerProvider(resource=resource)
# localhost porque o Collector é sidecar na mesma Task
exporter = OTLPSpanExporter(endpoint="http://localhost:4317", insecure=True)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
FastAPIInstrumentor.instrument_app(app)
SQLAlchemyInstrumentor().instrument(engine=engine)
Ponto que eu subestimei: a auto-instrumentação do SQLAlchemy gera span por query, o que é ótimo pra achar N+1, mas em endpoint que faz muita query pequena (tem uns endpoints que fazem fan-out pra várias tabelas), o volume de spans explode rápido. Acabei adicionando um attributes limit no processor e sendo mais seletivo com o que vira span manual versus o que deixo automático — nem tudo precisa virar trace, às vezes uma métrica de contador resolve com bem menos custo.
Pra métricas customizadas (contagem de eventos de negócio, por exemplo, não infra), uso o MeterProvider separado, exportando pro mesmo Collector via OTLP, e deixo o Collector decidir se isso vira métrica no CloudWatch ou fica só pra debug local — em dev, aponto pro Collector standalone rodando via docker compose, sem tocar em nada de AWS.
IAM: a parte que ninguém documenta direito
Aqui foi onde eu perdi mais tempo, sinceramente. O Collector, pra exportar de verdade pro CloudWatch e pro X-Ray, precisa de permissão — e como ele roda dentro da Task, quem carrega essas permissões é a Task Role (não a Execution Role, erro clássico que eu mesmo cometi na primeira tentativa: fiquei um bom tempo vendo AccessDenied sem entender por que, porque tinha colocado a policy na role errada).
Politica mínima que funcionou pra nós:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CloudWatchLogsExport",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogStreams"
],
"Resource": "arn:aws:logs:*:*:log-group:/appspace/meu-sistema/*"
},
{
"Sid": "CloudWatchMetricsExport",
"Effect": "Allow",
"Action": ["cloudwatch:PutMetricData"],
"Condition": {
"StringEquals": {
"cloudwatch:namespace": "AppSpace/MeuSistema"
}
},
"Resource": "*"
},
{
"Sid": "XRayExport",
"Effect": "Allow",
"Action": [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords",
"xray:GetSamplingRules",
"xray:GetSamplingTargets"
],
"Resource": "*"
}
]
}
Detalhe que fez diferença: restringir o logs:* a um prefixo de log group (/appspace/meu-sistema/*) em vez de liberar geral. Isso não é só boa prática de segurança — é padrão corporativo aqui, e sem isso o pipeline de aprovação de infra trava na revisão. Toda Resource: "*" que sobe vira pergunta em code review, e com razão.
cloudwatch:PutMetricData não aceita restrição de Resource de verdade (é sempre *), então o controle de escopo é via Condition no namespace. Sem isso, teoricamente qualquer serviço com essa policy poderia escrever métrica em qualquer namespace da conta — o que numa conta compartilhada como a nossa é o tipo de coisa que auditoria não deixa passar.
Rede: o Collector precisa falar com o mundo lá fora
Isso é fácil de esquecer porque, em desenvolvimento local, tudo roda solto e ninguém percebe. Em produção, a Task roda em subnet privada, sem IP público, e o ADOT Collector precisa alcançar os endpoints de API do CloudWatch Logs, CloudWatch Metrics e X-Ray. Duas formas de resolver:
- NAT Gateway, se a subnet já tem saída pra internet por outro motivo (mais simples, mas gera custo de NAT e trafega por fora da rede da AWS até... voltar pra AWS, o que é meio sem sentido pra chamadas que são serviço-a-serviço da própria AWS);
- VPC Endpoints (Interface Endpoints, via PrivateLink) pra
logs,monitoringexray— foi o caminho que escolhemos, porque o tráfego nunca sai da rede da AWS, e o custo é previsível (por hora + por GB, sem surpresa de NAT em pico de tráfego).
vpc.add_interface_endpoint(
"LogsEndpoint",
service=ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS,
)
vpc.add_interface_endpoint(
"MonitoringEndpoint",
service=ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH,
)
vpc.add_interface_endpoint(
"XRayEndpoint",
service=ec2.InterfaceVpcEndpointAwsService.XRAY,
)
O que me pegou aqui não foi o endpoint em si, foi o Security Group. Cada Interface Endpoint tem seu próprio SG, e ele precisa liberar entrada na porta 443 vindo do Security Group da Task. Esqueci isso na primeira tentativa e fiquei com o Collector tentando exportar, dando timeout, e a aplicação (que não sabe nada disso, porque só fala com localhost) seguindo tranquila, sem erro nenhum visível pro lado de fora. Foi só olhando o log interno do próprio Collector — que, ironicamente, precisei mandar temporariamente pro stdout do container pra debugar — que achei o problema.
Padrão corporativo: chato, mas é o que evita o caos depois
Vou resumir rápido porque essa parte é mais sobre disciplina do que sobre tecnologia:
- Nomenclatura de log group segue
/appspace/<sistema>/<ambiente>/<componente>— sem isso, o time de plataforma não consegue aplicar política de retenção automaticamente via tag, porque a automação deles casa por prefixo. - Retenção é definida centralizadamente: 30 dias pra ambiente de não-produção, 90 dias pra produção, sem exceção manual — se você cria o log group direto (em vez de deixar o AppSpace criar), corre o risco de ele nascer com retenção "never expire" e ninguém perceber até a conta de custo de CloudWatch Logs Storage aparecer estranha no fim do mês.
- Trace sampling: nada de mandar 100% dos spans pro X-Ray em produção sem necessidade. Usamos sampling probabilístico (10% como base, com regra de sempre amostrar 100% em request com erro) — isso é configurado no próprio Collector, não na aplicação, o que é conveniente porque dá pra ajustar sem re-deploy do serviço.
Nenhuma dessas regras é sofisticada. Mas cada uma delas existe porque algum time, antes do meu, teve um problema real por não seguir. Vale a pena perguntar pro time de plataforma antes de assumir que seu jeito é o certo.
O que eu levo disso
Se tivesse que resumir em uma frase pra quem vai começar essa jornada agora: a parte "fácil" é instrumentar o código, a parte que consome o seu tempo de verdade é a costura entre CDK, IAM e rede — e ela é exatamente a parte que não aparece em nenhum tutorial de "getting started" do OpenTelemetry, porque cada empresa resolve isso de um jeito diferente.
Ainda tenho um ponto em aberto: o dimensionamento do Collector em SMALL está segurando por enquanto, mas pretendo revisitar isso quando o volume de uso crescer — e devo escrever sobre isso quando tiver dado real de produção rodando por umas semanas, não só teoria.
Se você está no meio dessa mesma batalha, me conta o que te pegou — tenho quase certeza que não fui o único a cair na pegadinha da Task Role versus Execution Role.
Observação: nomes de sistemas, serviços, variáveis e trechos de código citados neste artigo foram alterados/fictícios e não representam o projeto real ou o cliente envolvido. O objetivo é ilustrar os conceitos e desafios técnicos, sem expor informações de trabalho.
