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, monitoring e xray — 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.