Pular para o conteúdo
18 min de leitura

Tracing distribuído com OpenTelemetry: seguindo uma requisição por 8 serviços

Por Equipe Nebular ·

Aprenda a rastrear uma requisição por dezenas de serviços com traces, spans, propagação de contexto W3C, Collector e sampling usando OpenTelemetry.

Neste artigo

Imagine o cenário: um cliente reclama que a página de checkout está lenta. Você abre o painel de latência e confirma — a requisição leva 4,2 segundos para responder, quando deveria levar menos de 500 milissegundos. O problema é que essa única requisição atravessa oito serviços diferentes antes de devolver uma resposta: o gateway, o serviço de autenticação, o de carrinho, o de estoque, o de preços, o de pagamento, o de antifraude e, finalmente, o de notificação. Cada um roda em um processo diferente, muitas vezes em máquinas diferentes, escrito por times diferentes. Onde, exatamente, está a lentidão?

Com logs isolados por serviço, essa investigação vira arqueologia. Você abre oito abas de terminal, faz grep por um identificador que talvez nem exista em todos eles, tenta correlacionar timestamps de relógios que não estão perfeitamente sincronizados e, no fim, chuta. É aqui que o tracing distribuído muda o jogo. Em vez de olhar cada serviço isoladamente, você enxerga a requisição inteira como uma única linha do tempo, com cada etapa medida, aninhada e conectada às demais. Você vê que o gateway respondeu rápido, o carrinho respondeu rápido, mas o serviço de antifraude ficou 3,6 segundos esperando uma chamada externa. O problema deixa de ser um mistério e vira um fato observável.

Este guia é sobre como o OpenTelemetry — o padrão aberto e neutro de fornecedor para telemetria — resolve esse problema. Vamos construir o vocabulário do tracing (trace, span, contexto, propagação), entender a arquitetura do OpenTelemetry (API, SDK, Collector), enfrentar a decisão de custo mais importante (sampling) e ver como amarrar traces, logs e métricas para que uma investigação comece em qualquer um deles e termine na causa raiz. Ao final, você terá um modelo mental sólido e exemplos concretos de instrumentação para levar ao seu próprio sistema.

O vocabulário: trace, span e a árvore da requisição#

Antes de qualquer ferramenta, você precisa de dois conceitos. O primeiro é o span. Um span representa uma única unidade de trabalho com começo e fim — uma chamada HTTP recebida, uma query no banco, uma publicação em fila, uma chamada a um serviço externo. Todo span carrega um nome, um instante de início, uma duração, um conjunto de atributos e um status. Ele é a menor peça observável do seu sistema.

O segundo conceito é o trace. Um trace é a coleção de todos os spans gerados pelo processamento de uma única requisição lógica, do primeiro ponto de entrada até a última operação disparada por ela. Se aquele checkout tocou oito serviços e cada serviço fez, em média, cinco operações instrumentadas, o trace resultante pode ter quarenta spans ou mais. Todos eles compartilham o mesmo trace_id — um identificador único de 16 bytes gerado no ponto de entrada e carregado por toda a cadeia.

O que transforma uma pilha de spans em algo legível é a relação pai/filho. Cada span, além do seu próprio span_id, guarda o span_id do span que o originou. O span que recebeu a requisição no gateway é o pai; o span da chamada ao serviço de carrinho é seu filho; a query que o carrinho fez no Postgres é neto. Essa referência de parentesco desenha uma árvore, e é essa árvore que você vê renderizada como uma cascata (o famoso waterfall) na interface do seu backend de tracing. Cada barra é um span; barras aninhadas embaixo de outra são seus filhos; o comprimento da barra é a duração; o deslocamento horizontal mostra quando aquilo aconteceu em relação ao resto.

  • trace_id — identifica a requisição inteira; é o mesmo em todos os spans do trace.
  • span_id — identifica um span específico dentro do trace.
  • parent_span_id — aponta para o span que gerou este; ausente apenas no span raiz.
  • duração — diferença entre fim e início; é o que responde "onde está a lentidão".

Repare que a árvore não precisa ser puramente sequencial. Se o serviço de checkout dispara chamadas ao estoque e ao preço em paralelo, os dois spans filhos terão o mesmo pai e se sobreporão no tempo. Essa sobreposição é justamente o que você quer ver: ela prova que o paralelismo está funcionando. Barras que deveriam ser paralelas mas aparecem em sequência revelam um gargalo de concorrência que nenhum log jamais mostraria com essa clareza.

Atributos, events e status: enriquecendo o span#

Um span com apenas nome e duração já ajuda, mas a riqueza do tracing vem dos metadados. Os atributos são pares chave-valor que descrevem o contexto daquela operação: http.request.method, http.response.status_code, db.system, server.address, url.path. O OpenTelemetry mantém uma especificação de convenções semânticas — nomes padronizados para atributos comuns — e vale muito segui-la, porque é ela que permite que uma ferramenta calcule automaticamente coisas como "latência p95 por rota" sem que você configure nada. Se cada serviço nomeia o método HTTP de um jeito, essa agregação quebra.

Além dos atributos, um span pode carregar span events — marcadores pontuais com timestamp que registram algo que aconteceu dentro da duração do span, sem criar um span filho separado. Uma exceção capturada, um evento de "cache miss", o momento em que um lock foi adquirido: tudo isso vira event. Pense no span como o intervalo (uma linha do tempo) e nos events como pontos anotados sobre essa linha.

Por fim, todo span tem um status: Unset (o padrão), Ok (sucesso explicitamente afirmado) ou Error. Marcar o status como Error — e anexar a exceção como event — é o que faz aquele span aparecer destacado em vermelho na cascata. Numa cadeia de oito serviços, encontrar o span vermelho mais profundo da árvore geralmente aponta a origem real da falha, em vez do serviço da ponta que só propagou o erro para cima.

Veja como um span serializado se parece, de forma simplificada, quando exportado:

``json { "name": "POST /checkout", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7", "parent_span_id": "a2fb4a1d1a96d312", "start_time": "2026-09-07T14:22:03.481Z", "end_time": "2026-09-07T14:22:07.692Z", "status": { "code": "ERROR", "message": "upstream timeout" }, "attributes": { "http.request.method": "POST", "url.path": "/checkout", "http.response.status_code": 504, "server.address": "antifraude.internal" }, "events": [ { "name": "exception", "time": "2026-09-07T14:22:07.690Z", "attributes": { "exception.type": "TimeoutError" } } ] } ``

Note o parent_span_id presente e o status.code em ERROR: só com esses dois campos você já sabe que este span não é a raiz e que ele foi o ponto onde a coisa desandou. A duração de mais de quatro segundos, calculada de start_time a end_time, confirma o gargalo.

Propagação de contexto: como o trace atravessa a fronteira do serviço#

Aqui está o coração do tracing distribuído, e o ponto que mais confunde quem está começando. Dentro de um único processo, propagar o contexto do span é fácil: o SDK guarda o span atual em um armazenamento associado à execução (uma thread-local, um contexto assíncrono) e sabe automaticamente quem é o pai do próximo span. O desafio aparece na fronteira entre serviços. Quando o serviço de checkout faz uma chamada HTTP ao serviço de estoque, ele cruza um limite de processo e de rede. Se nada for feito, o estoque começará um trace novo, sem saber que faz parte de um trace maior. A árvore se parte.

A solução é a propagação de contexto: injetar o trace_id e o span_id atuais nos cabeçalhos da requisição que sai, e extraí-los do outro lado. O padrão que consolidou isso é o W3C Trace Context, com o cabeçalho traceparent. Seu formato é fixo e compacto:

`` traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 ``

Os quatro campos, separados por hífen, são: a versão (00), o trace_id de 16 bytes, o span_id do span que está fazendo a chamada (que virará o parent_span_id no serviço destino) e as trace flags (01 significa "amostrado", ou seja, este trace deve ser gravado). Existe ainda um cabeçalho companheiro, o tracestate, que carrega informações específicas de fornecedor sem quebrar a interoperabilidade.

O que torna o OpenTelemetry poderoso é que a instrumentação automática de bibliotecas HTTP cuida disso para você: o cliente HTTP injeta o traceparent na saída, o servidor HTTP o extrai na entrada e reconstrói a relação pai/filho por cima da rede. Você não escreve esse código. Mas você precisa entendê-lo, porque quando um trace aparece quebrado — dois traces separados que deveriam ser um só — a causa quase sempre é propagação: um proxy que apagou o cabeçalho, uma fila de mensagens onde ninguém injetou o contexto no envelope da mensagem, um cliente HTTP que não foi instrumentado.

  • HTTP — o traceparent viaja como cabeçalho; instrumentação de cliente e servidor cuida da injeção e extração.
  • Mensageria — em filas (Kafka, RabbitMQ, NATS) o contexto vai nos headers da mensagem; sem isso, o consumidor abre um trace órfão.
  • gRPC — a propagação usa a metadata do protocolo, também coberta pela instrumentação oficial.

A arquitetura do OpenTelemetry: API, SDK e Collector#

O OpenTelemetry é deliberadamente dividido em camadas, e entender essa divisão evita muita confusão. A primeira camada é a API: um conjunto mínimo de interfaces (criar span, adicionar atributo, propagar contexto) contra o qual seu código e as bibliotecas se escrevem. A API por si só não faz nada — é uma implementação vazia por padrão. Isso é intencional: uma biblioteca de terceiros pode instrumentar seu código contra a API do OpenTelemetry sem forçar nenhuma dependência pesada em quem a usa. Se a aplicação não configurar telemetria, as chamadas viram no-ops baratas.

A segunda camada é o SDK: a implementação de verdade da API. É o SDK que efetivamente cria spans com timestamps, aplica o sampling, agrupa spans em lotes e os exporta. Você configura o SDK uma vez, na inicialização da aplicação, e a partir daí todo o código que usa a API passa a produzir telemetria real. Essa separação API/SDK é o que torna o padrão neutro de fornecedor: troque o exporter e você troca o destino da telemetria sem tocar em uma linha do código de negócio.

Dentro da instrumentação, há duas abordagens complementares. A instrumentação automática usa agentes ou bibliotecas de integração que envolvem frameworks populares (servidores HTTP, drivers de banco, clientes de fila) e geram spans sem que você escreva código. É o jeito de conseguir cobertura ampla rapidamente. A instrumentação manual é você criando spans explicitamente ao redor da lógica de domínio que importa — "calcular frete", "validar cupom" — que nenhum agente automático conhece. Na prática, um sistema maduro usa as duas: a automática dá a espinha dorsal da árvore, a manual adiciona os spans de negócio que explicam o porquê.

A terceira peça, e talvez a mais estratégica, é o OpenTelemetry Collector. Em vez de cada serviço exportar telemetria direto para o backend, os serviços exportam para o Collector, um processo intermediário que recebe, processa e reencaminha. Sua arquitetura interna são três estágios em pipeline:

  • receivers — pontos de entrada que aceitam telemetria em vários formatos (OTLP, Jaeger, Prometheus).
  • processors — transformam os dados no caminho: agrupam em lotes, aplicam limite de memória, removem atributos sensíveis, fazem o tail sampling.
  • exporters — enviam o resultado adiante para um ou mais backends.

O Collector desacopla suas aplicações do backend de observabilidade. Quer trocar de Jaeger para Tempo? Muda o exporter do Collector, não os 8 serviços. Precisa remover um atributo com dado pessoal antes que ele saia da sua rede? Um processor faz isso em um lugar só. Veja um pipeline mínimo:

```yaml receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318

processors: batch: timeout: 5s send_batch_size: 1024 memory_limiter: check_interval: 1s limit_mib: 512 attributes/scrub: actions:

  • key: user.email

action: delete

  • key: http.request.header.authorization

action: delete

exporters: otlp/tempo: endpoint: tempo:4317 tls: insecure: true

service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, attributes/scrub, batch] exporters: [otlp/tempo] ```

Repare no processor attributes/scrub: ele apaga user.email e o cabeçalho de autorização antes que a telemetria saia da rede. Esse é o lugar certo para impor higiene de dados sensíveis de forma centralizada, sem confiar que cada um dos oito serviços lembrou de fazer isso.

Sampling: a decisão de custo que define sua conta#

Se você instrumentar tudo e gravar todos os traces de um sistema com tráfego real, dois problemas aparecem rápido: o volume de dados explode o custo de armazenamento e a maior parte desses traces é rigorosamente idêntica e sem interesse — milhares de requisições de 20 milissegundos que deram 200 OK. O sampling é a decisão de quais traces manter. E existem duas famílias com trade-offs opostos.

O head sampling decide no início do trace, no ponto de entrada, se aquele trace será gravado — antes de saber como ele vai terminar. A decisão é tomada com base no trace_id (por exemplo, "grave 10% dos traces") e propagada via a flag sampled no traceparent, de modo que todos os serviços da cadeia respeitem a mesma escolha e você nunca tenha um trace pela metade. A vantagem é o custo baixo e a simplicidade: a maior parte da telemetria descartada nunca chega nem a ser gerada plenamente. A desvantagem é fatal para investigação: como a decisão é cega ao futuro, você pode descartar justamente o trace de 4 segundos que terminou em erro, porque no início ele parecia igual aos outros.

O tail sampling inverte isso. A decisão é tomada no fim, depois que todos os spans do trace chegaram, o que exige bufferizá-los — tipicamente no Collector. Agora você pode aplicar regras inteligentes: "mantenha 100% dos traces com erro", "mantenha 100% dos traces acima de 1 segundo", "mantenha 5% do resto". Você guarda o que interessa e descarta o ruído. O preço é operacional: o Collector precisa segurar os spans em memória por uma janela de tempo e ser capaz de reunir, num mesmo nó, todos os spans de um trace — o que complica a escala horizontal. Um resumo:

  • Head sampling — decide cedo, barato, propagável pela flag; risco de perder o trace raro e importante.
  • Tail sampling — decide tarde, vê o trace inteiro, mantém erros e lentidão; custa memória e coordenação no Collector.
  • Regra prática — comece com head sampling generoso; migre para tail sampling no Collector quando o custo doer e você precisar reter erros de forma confiável.

Não existe resposta única. Sistemas de baixo volume podem gravar 100% (sem sampling) tranquilamente. Sistemas de alto volume quase sempre acabam em tail sampling justamente para não pagar por um oceano de traces triviais enquanto preservam cada exceção.

Correlação: o trace_id que costura tudo#

Traces são maravilhosos para responder "onde está a lentidão", mas eles não substituem logs nem métricas — eles se correlacionam com eles. A correlação acontece por um fio condutor simples e poderoso: o trace_id no log. Se cada linha de log que sua aplicação emite incluir o trace_id (e o span_id) do contexto ativo no momento, então investigar deixa de ser pular entre ferramentas desconexas.

O fluxo fica natural. Você vê no backend de tracing que o span do serviço de antifraude está vermelho. Copia o trace_id. Cola na busca de logs e, em vez dos milhares de logs daquele serviço, aparecem exatamente as linhas emitidas durante aquela requisição, na ordem certa, de todos os serviços que ela tocou. O log te dá o detalhe textual — a mensagem de exceção, o valor que causou o problema — que o span resumiu. O trace te deu o onde; o log te dá o o quê.

A ponte na outra direção também vale: das métricas para os traces. Um alerta dispara porque a latência p99 de uma rota estourou o orçamento. Métricas são baratas e agregadas — elas te dizem que existe um problema e qual a magnitude, mas não te mostram um caso concreto. Ferramentas modernas implementam exemplars: junto do ponto de métrica agregado, guardam o trace_id de uma requisição representativa daquele bucket. Você clica no pico do gráfico e cai direto no trace que o causou. Assim, os três sinais — métricas para saber que há um problema, traces para saber onde, logs para saber o quê — formam um caminho de investigação contínuo, e o trace_id é a linha que os costura.

Instrumentação na prática#

Chega de teoria. Veja como fica a instrumentação manual em Python, criando um span de negócio e enriquecendo-o. A configuração do SDK e a instrumentação automática do framework HTTP ficam na inicialização; aqui está o pedaço de domínio:

```python from opentelemetry import trace

tracer = trace.get_tracer("checkout.service")

def validar_cupom(codigo: str, valor_carrinho: float) -> bool: # Cria um span filho do span ativo (a requisição HTTP em curso). with tracer.start_as_current_span("validar_cupom") as span: span.set_attribute("cupom.codigo", codigo) span.set_attribute("carrinho.valor", valor_carrinho) try: regra = buscar_regra_do_cupom(codigo) # gera seu próprio span filho if regra is None: span.add_event("cupom.inexistente") return False valido = valor_carrinho >= regra.valor_minimo span.set_attribute("cupom.valido", valido) return valido except Exception as erro: # Registra a exceção como event e marca o status como erro. span.record_exception(erro) span.set_status(trace.Status(trace.StatusCode.ERROR)) raise ```

Três coisas merecem atenção aqui. Primeiro, start_as_current_span cria o span já ancorado ao contexto ativo — se essa função rodou durante o tratamento de uma requisição HTTP instrumentada automaticamente, o span nasce como filho daquela, e a árvore se forma sozinha. Segundo, os atributos que você adiciona são o que torna o span útil na investigação: o código do cupom e o valor do carrinho estarão ali quando você precisar entender por que aquela validação falhou. Terceiro, o bloco except faz o certo: registra a exceção e marca o status, sem engolir o erro — ele é relançado com raise.

O equivalente idiomático em Go, para quem tem o backend nessa linguagem, segue o mesmo desenho — contexto explícito, defer span.End() e verificação de erro sem atalhos:

```go func ValidarCupom(ctx context.Context, codigo string, valor float64) (bool, error) { // O span filho herda o trace via ctx; End() garante o fechamento. ctx, span := tracer.Start(ctx, "validar_cupom") defer span.End()

span.SetAttributes( attribute.String("cupom.codigo", codigo), attribute.Float64("carrinho.valor", valor), )

regra, err := buscarRegra(ctx, codigo) if err != nil { span.RecordError(err) span.SetStatus(codes.Error, "falha ao buscar regra") return false, err } valido := valor >= regra.ValorMinimo span.SetAttributes(attribute.Bool("cupom.valido", valido)) return valido, nil } ```

O detalhe crucial em Go é o ctx. É ele que carrega o span ativo através das chamadas de função, e é por isso que ele precisa ser o primeiro parâmetro em toda função instrumentada e ser repassado adiante. Esqueça de propagar o ctx para buscarRegra e o span dela vira órfão dentro do próprio processo — a mesma quebra de árvore que a falta de traceparent causa entre serviços, só que interna.

Boas práticas: nomeação, cardinalidade e dados sensíveis#

Instrumentar demais e errado pode ser tão ruim quanto não instrumentar. Três disciplinas separam um tracing útil de um caro e inutilizável.

A primeira é a nomeação de span. O nome de um span deve ter baixa cardinalidade — deve descrever a classe de operação, não a instância específica. Nomeie o span de GET /users/{id}, nunca de GET /users/42891. Se você colocar o ID no nome, cada requisição gera um "tipo" de span diferente, e a ferramenta se torna incapaz de agregar "quanto tempo, em média, leva buscar um usuário". A identidade específica (o 42891) vai como atributo, não no nome. Atributos podem ter alta cardinalidade; nomes de span, não.

A segunda é justamente cuidar da cardinalidade dos atributos com os quais você conta métricas ou sobre os quais indexa. Um atributo com valores praticamente ilimitados — um ID de sessão, um timestamp completo, um endereço de e-mail — usado como dimensão de agregação explode o custo de indexação e memória do backend. Guarde-o como atributo simples do span (para busca pontual) se precisar, mas nunca o use como chave de agrupamento de alta frequência.

A terceira, e inegociável, é nunca vazar dado sensível no telemetria. Spans e atributos frequentemente acabam parando em um backend de terceiros, visível a um time inteiro. Senhas, tokens, números de cartão, CPF, corpo de requisição com dado pessoal — nada disso pode virar atributo. A defesa tem duas linhas: não instrumentar esses campos na origem, e, como rede de segurança, um processor de scrub no Collector (como o attributes/scrub do exemplo) que apaga chaves sensíveis antes da exportação. Confiar apenas na disciplina de cada serviço não basta; a política central no Collector é o que garante a higiene mesmo quando alguém esquece.

Sobre backends, vale saber que o OpenTelemetry termina onde eles começam. O padrão coleta, processa e exporta; ele não armazena nem visualiza traces. Quem faz isso são os backends de tracing. O Jaeger é um dos mais tradicionais, projetado especificamente para armazenamento e visualização de traces, com sua interface de cascata clássica. O Grafana Tempo é uma alternativa que otimiza custo ao armazenar traces em object storage barato e delegar a busca por trace_id, integrando-se de forma natural com logs (Loki) e métricas (Prometheus/Mimir) na mesma tela. A beleza da arquitetura neutra é que essa escolha é do lado do exporter: você instrumenta uma vez, contra a API do OpenTelemetry, e o destino vira uma linha de configuração no Collector.

Comece pequeno e concreto. Ligue a instrumentação automática em um serviço, configure um Collector com o exporter para o backend que você já tem, e injete o trace_id nos seus logs. Só isso já transforma a próxima investigação de lentidão: em vez de oito abas de terminal e um chute, você terá uma cascata que aponta o span vermelho mais profundo da árvore — e, com ele, o serviço, a operação e a linha de log exata onde a sua requisição de 4 segundos foi morrer. O tracing distribuído não adivinha a causa raiz; ele a torna, finalmente, visível.

Leituras relacionadas

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly