Em maio de 2019, dois projetos da Cloud Native Computing Foundation anunciaram fusão. OpenTracing, criado em 2016 por Ben Sigelman (Google, depois LightStep) com base no paper Dapper de 2010, oferecia API neutra para tracing distribuído. OpenCensus, lançado pelo Google em 2018, oferecia API neutra para métricas e tracing juntos. Os dois competiam pelo mesmo espaço, e a fragmentação era custosa. A fusão produziu OpenTelemetry — usualmente abreviado como OTel —, que em 2026 é o padrão de facto para instrumentação em qualquer linguagem moderna.
Observabilidade como disciplina nasceu antes da fusão. O termo veio da teoria de controle (Rudolf Kálmán, 1960), mas foi Charity Majors, Liz Fong-Jones e Cindy Sridharan que o popularizaram em engenharia de software por volta de 2018, argumentando que monitoring tradicional (alertar em métricas pré-definidas) era insuficiente para sistemas complexos. A disciplina exige três pilares: logs (eventos discretos textuais — conceito 07), metrics (valores agregados ao longo do tempo) e traces (cadeias causais de operações em um request distribuído). Os três se reforçam quando correlacionados, e ficam meio-úteis isolados.
Observabilidade é o cross-cutting concern mais transversal de todos. Toda operação de domínio, todo middleware, todo cliente externo, todo job — todos podem ser instrumentados. Tentar adicionar essa instrumentação à mão em cada lugar é, no melhor caso, ruído gigantesco no código de domínio; no pior, esquecimento de pontos críticos. A solução madura é tratar observabilidade como aspect: middleware HTTP gera span automaticamente, cliente HTTP propaga trace context, auto-instrumentação cobre frameworks comuns, e o desenvolvedor adiciona spans manuais apenas onde precisa granularidade extra.
Este conceito articula o vocabulário OTel mínimo, distingue o que vem de auto-instrumentação do que precisa ser explícito, e mostra a forma idiomática em três linguagens. Os tópicos de coleta, backend e visualização (Jaeger, Tempo, Prometheus, Grafana) ficam para o módulo 10 — aqui o foco é instrumentar, não operar.
O vocabulário OTel — span, trace, baggage
Um span é a unidade básica de tracing — uma
operação delimitada no tempo, com início, fim, atributos, e
pai opcional. Um span pode ser uma request HTTP, uma query
SQL, uma chamada de método de domínio, um job. Cada span tem
ID único (span_id), e um conjunto de spans que
compartilham origem comum forma um trace,
identificado por trace_id.
A árvore de spans dentro de um trace mostra a cadeia causal:
span "POST /pedidos" tem como filho o span "validate", que tem
como filho "load customer from db", "save order to db", "publish
event". Em um sistema de microsserviços, o trace continua entre
serviços — o cliente HTTP propaga trace_id e
span_id via cabeçalho W3C
traceparent (RFC do W3C, 2020), e o servidor
receptor anexa novos spans à mesma árvore. O resultado é uma
visão hierárquica de toda a operação, atravessando processos.
Atributos são pares chave/valor anexados a
span. http.method=POST,
http.status_code=201,
db.system=postgresql,
customer.id=.... Atributos com nomes padrão
seguem as semantic conventions da OTel — vocabulário
compartilhado que permite análises consistentes entre
serviços e ferramentas. Atributos custom aparecem ao lado
sem cerimônia.
Baggage é dado contextual propagado com
o trace, mas distinto dos atributos do span. Útil para fluxo
de informação que precisa atravessar serviços sem virar
argumento de cada chamada — tenant_id,
feature_flag_variant, experiment_arm.
Baggage tem cuidado especial: o que está em baggage costuma
ser exposto a todos os serviços a jusante, então não deve
conter dado sensível.
Um span event é um marcador no tempo dentro de um span — algo que aconteceu mas não vale span próprio. Útil para registrar transições de estado curtas ("cache hit", "validation failed", "retry attempt") sem poluir a árvore.
Os três pilares — quando usar cada um
A confusão recorrente é "log, métrica e trace registram a mesma coisa de jeito diferente". Não. Cada um responde a uma pergunta distinta, e tentar usar um para o trabalho do outro sai caro.
Log responde: "o que aconteceu neste evento específico?". É discreto, textual, com contexto. Bom para diagnóstico de incidente em casos individuais. Mau para resposta agregada ("quantos requests falharam na última hora?") — você consegue, mas é caro.
Métrica responde: "como está se comportando o sistema agregado?". É numérica, pré-agregada, cardinalidade controlada. Boa para alertas, dashboards, SLOs. Má para diagnóstico ponto-a-ponto — por construção, você perdeu o contexto individual.
Trace responde: "como esta request específica atravessou o sistema?". É estruturado, hierárquico, conecta spans entre serviços. Bom para entender latência e cadeia de causa em sistemas distribuídos. Mau para alertas em valores agregados — você não pode dizer "alerte se a latência média passar de X" só com traces.
Sistemas observáveis usam os três conectados: logs
carregam trace_id para que você navegue de log
a trace; métricas têm exemplars que apontam para um
trace específico que produziu aquele valor; traces têm
atributos que viram filtros em métricas. OpenTelemetry foi
desenhado para esse modelo unificado — daí o nome
"Telemetry", em singular.
Auto-instrumentação — o caso 80%
A maior parte da instrumentação que um sistema precisa não
precisa ser escrita manualmente. Frameworks comuns —
ASP.NET Core, FastAPI, Express, Spring, gRPC,
net/http, requests, httpx,
aiohttp, EF Core, SQLAlchemy, JDBC,
database/sql — têm bibliotecas de
auto-instrumentação OTel que criam spans automaticamente para
cada operação relevante.
Em .NET, basta adicionar o nuget
OpenTelemetry.Instrumentation.AspNetCore e
registrar — todo request HTTP vira span automaticamente. Em
Python, opentelemetry-instrumentation-fastapi faz
o mesmo. Em Go, a comunidade prefere instrumentar manualmente
via SDK porque é mais explícito (e Go não tem mecanismo
runtime para "monkey-patch" como Python). Em Java, o
Java agent de OTel auto-instrumenta dezenas de
bibliotecas só com a flag -javaagent:opentelemetry-javaagent.jar
— sem mudar uma linha de código.
// .NET 10 — registro de auto-instrumentação OTel
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("catalog-api"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation() // HTTP server spans
.AddHttpClientInstrumentation() // HTTP client spans
.AddSqlClientInstrumentation() // EF Core / SqlClient spans
.AddOtlpExporter()) // exporta para coletor
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation() // GC, threads, working set
.AddOtlpExporter());
Esse setup mínimo já cobre 80% do que um sistema típico precisa observar. Cada request HTTP entra com span, cada query SQL gera span filho, cada chamada externa via HttpClient também. Se o sistema downstream também tem OTel, o trace continua atravessando processos.
Instrumentação manual — os 20% que importam
Auto-instrumentação cobre o transporte. Não cobre o domínio. O span "POST /pedidos" gerado automaticamente diz "essa request durou 47ms"; o que ela fez, qual operação de domínio executou, quantos itens, qual cliente — só aspect manual conta. A regra de produção é: o span do framework é a raiz, e dentro dele você adiciona spans manuais para operações de domínio significativas.
// Python — span manual em service de domínio
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
class PedidoService:
async def criar(self, cmd: CriarPedidoCmd) -> Pedido:
with tracer.start_as_current_span("pedido.criar") as span:
span.set_attribute("cliente.id", cmd.cliente_id)
span.set_attribute("pedido.item_count", len(cmd.itens))
try:
pedido = await self._repo.salvar(Pedido.from_cmd(cmd))
except Exception as e:
span.record_exception(e)
span.set_status(trace.Status(trace.StatusCode.ERROR))
raise
span.set_attribute("pedido.id", str(pedido.id))
span.add_event("pedido.persisted", {
"pedido.total": float(pedido.total),
})
return pedido
Note três detalhes idiomáticos. Primeiro:
start_as_current_span faz o span virar
"current" — qualquer span criado depois (auto ou manual) vira
filho dele. Segundo: atributos vão na operação
em si (cliente.id, pedido.id);
events ficam para transições no meio da operação. Terceiro:
record_exception + set_status(ERROR)
é o padrão para marcar span como falho — só lançar a exceção
sem isso deixa o span "ok" no UI.
Métricas — os quatro tipos canônicos
OpenTelemetry define quatro tipos principais de instrumento de métrica. A escolha entre eles muda significativamente a qualidade do que se consegue medir.
Counter — valor que só sobe (ou reseta). Bom para "quantos pedidos foram criados", "quantos requests retornaram 5xx". Você nunca decrementa um counter; backend calcula taxa derivada (req/s) automaticamente.
UpDownCounter — valor que sobe e desce. Bom para "quantas conexões abertas no pool agora", "quantos mensagens na fila". Diferente de gauge porque você incrementa e decrementa, não seta valor absoluto.
Histogram — distribuição de valores. Bom para "duração de request", "tamanho de payload", "tempo entre eventos". Backend agrega em buckets e calcula percentis (P50, P95, P99) — esse é o tipo de métrica que sustenta SLO.
Gauge / Observable — valor instantâneo, observado periodicamente. Bom para "uso de CPU", "memória heap", "número de threads". Diferente de UpDownCounter porque você não rastreia mudanças — só consulta o valor atual quando o backend pergunta.
// Go — definir e usar métricas com OTel
import "go.opentelemetry.io/otel/metric"
var (
pedidosCriados metric.Int64Counter
pedidoDuration metric.Float64Histogram
filaDepth metric.Int64UpDownCounter
)
func initMetrics(meter metric.Meter) error {
var err error
pedidosCriados, err = meter.Int64Counter("pedidos.criados",
metric.WithDescription("Total de pedidos criados"),
metric.WithUnit("{pedido}"))
if err != nil { return err }
pedidoDuration, err = meter.Float64Histogram("pedido.duration",
metric.WithDescription("Tempo para criar pedido"),
metric.WithUnit("ms"))
return err
}
func CriarPedido(ctx context.Context, cmd CriarPedidoCmd) (*Pedido, error) {
started := time.Now()
p, err := repo.Salvar(ctx, Pedido{...})
pedidoDuration.Record(ctx, float64(time.Since(started).Milliseconds()),
metric.WithAttributes(
attribute.String("status", statusOf(err)),
))
if err == nil {
pedidosCriados.Add(ctx, 1, metric.WithAttributes(
attribute.String("tipo", string(p.Tipo)),
))
}
return p, err
}
Cardinalidade — onde está o tropeço caro
A armadilha mais cara em métricas é cardinalidade alta. Cada
combinação única de atributos vira uma série temporal
separada no backend. Adicionar atributo
customer.id a uma métrica em um sistema com 10
milhões de clientes ativos pode transformar uma série única em
10 milhões de séries — Prometheus, Datadog, qualquer backend
que cobra por série temporal vai gerar fatura assustadora.
A regra prática: atributos de métrica devem ter cardinalidade
baixa. http.method tem cardinalidade ~5.
http.status_code tem ~50. environment
tem 3. pedido.tipo tem 4. Esses são bons candidatos.
customer.id, request_id,
email, url.path com IDs em path —
todos esses são alta cardinalidade e não devem virar
atributo de métrica.
Onde colocar dado de alta cardinalidade? Em traces, com naturalidade — cada span já é único, e atributos de span não criam séries temporais. Quando você precisa correlacionar métrica agregada com request específica, o mecanismo é exemplar — uma métrica histogram pode ter exemplars que apontam para trace_id de uma request que caiu naquele bucket. Esse é o pilar onde alta cardinalidade é cidadã de primeira classe.
"Cardinality explosion" — fatura de Datadog/Prometheus
triplicando do dia para a noite. A causa quase sempre é a
mesma: alguém adicionou um atributo de alta cardinalidade a
uma métrica importante. customer.id em
http.server.duration, ou request.id
em orders.created. Diagnóstico: o backend
mostra "active series" subindo em ordem de magnitude.
Correção: remover o atributo da métrica e — se a informação
é necessária — colocar em trace ou em log estruturado, não
em métrica. Lição: revisão de código de qualquer adição
de atributo a métrica precisa perguntar "qual é a
cardinalidade desse valor em produção?".
Propagação de contexto entre serviços
Em sistemas distribuídos, o trace só tem valor se atravessa
os processos. A spec do W3C "Trace Context" (REC, fevereiro
de 2020) define o cabeçalho HTTP traceparent que
carrega trace_id e span_id entre
serviços, e o cabeçalho tracestate para
vendor-specific data. OpenTelemetry SDK em qualquer linguagem
respeita esses cabeçalhos automaticamente — o cliente HTTP
injeta na ida, o servidor extrai na chegada.
Para sistemas que usam fila (Kafka, RabbitMQ, SQS), a propagação acontece via headers/atributos da mensagem. OTel tem instrumentações para os principais brokers. O detalhe importante: produtores de mensagem precisam injetar contexto explicitamente; consumidores precisam extrair antes de processar. Sem isso, o trace para na fila — você tem visão até o produtor, depois "pula" para um trace novo no consumidor, sem conexão.
gRPC propaga trace context nativamente via metadata. Bibliotecas como Apollo Federation (GraphQL) também. A regra geral: se o transporte tem mecanismo de header, OTel sabe propagar; se não tem (eventos custom em PubSub proprietário), você precisa configurar manualmente.
O mesmo span manual, três SDKs
Para fixar a equivalência, considere o cenário canônico: adicionar um span manual a uma operação de domínio com atributos específicos e marcação de erro.
// .NET usa ActivitySource (do System.Diagnostics) que OTel reconhece
private static readonly ActivitySource ActivitySource = new("Catalog.Pedidos");
public async Task<Pedido> Criar(CriarPedidoCmd cmd, CancellationToken ct)
{
using var activity = ActivitySource.StartActivity("pedido.criar");
activity?.SetTag("cliente.id", cmd.ClienteId);
activity?.SetTag("pedido.item_count", cmd.Itens.Count);
try
{
var p = await _repo.SalvarAsync(new Pedido(cmd), ct);
activity?.SetTag("pedido.id", p.Id);
activity?.AddEvent(new ActivityEvent("pedido.persisted",
tags: new ActivityTagsCollection { ["pedido.total"] = p.Total }));
return p;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
.NET tem ActivitySource nativo na stdlib desde
5.0; OTel SDK lê Activities sem precisar de API
paralela. Resultado: instrumentação que sobrevive mesmo se
OTel sair (Activities continuam funcionando para diagnóstico).
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer(__name__)
class PedidoService:
async def criar(self, cmd: CriarPedidoCmd) -> Pedido:
with tracer.start_as_current_span("pedido.criar") as span:
span.set_attribute("cliente.id", cmd.cliente_id)
span.set_attribute("pedido.item_count", len(cmd.itens))
try:
p = await self._repo.salvar(Pedido.from_cmd(cmd))
except Exception as e:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
span.set_attribute("pedido.id", str(p.id))
span.add_event("pedido.persisted", {"pedido.total": float(p.total)})
return p
Python OTel usa context manager (with) para
escopo de span. start_as_current_span torna o
span "current" — qualquer span criado depois vira filho.
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
var tracer = otel.Tracer("catalog/pedidos")
func CriarPedido(ctx context.Context, cmd CriarPedidoCmd) (*Pedido, error) {
ctx, span := tracer.Start(ctx, "pedido.criar")
defer span.End()
span.SetAttributes(
attribute.String("cliente.id", cmd.ClienteID),
attribute.Int("pedido.item_count", len(cmd.Itens)),
)
p, err := repo.Salvar(ctx, Pedido{ClienteID: cmd.ClienteID, Itens: cmd.Itens})
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
span.SetAttributes(attribute.String("pedido.id", p.ID))
span.AddEvent("pedido.persisted",
trace.WithAttributes(attribute.Float64("pedido.total", p.Total)))
return p, nil
}
Go é o mais explícito: span é retornado, defer
span.End() garante encerramento. Context entra e
sai modificado — o trace_id flui pelo context.
Sampling — não tudo precisa virar trace
Em sistemas de tráfego alto, traçar 100% das requests é proibitivo — backend de tracing fica caro, performance da aplicação degrada (mesmo sendo overhead pequeno por trace, milhões de traces somam). A solução é sampling: decidir quais traces gravar e quais descartar.
Head-based sampling — decisão tomada no
início do trace (no primeiro serviço). Se "não trace", todo
o trace é descartado, em todos os serviços a jusante. Simples
de implementar, propaga via flag em traceparent,
mas toma decisão sem saber o resultado da request — se a
request falhou, talvez você quisesse o trace.
Tail-based sampling — decisão tomada no fim do trace, geralmente no coletor (OTel Collector tem processor de tail sampling). Mais inteligente — decide com base em latência, status, atributos —, mas exige buffer de todo trace no coletor por alguns segundos antes de decidir. Mais caro de operar.
Probabilistic sampling — fixar um percentual (1%, 10%, 100%) e amostrar uniformemente. É o default em produção: 1–10% costuma ser suficiente para diagnóstico agregado. Soma com regra "100% se erro" via tail sampling em sistemas críticos.
Anti-padrões frequentes
Span explosion. Criar span para cada iteração de loop de mil elementos: você ganha mil spans filhos, todos quase idênticos, ruído enorme. Use span event ou métrica counter no lugar.
Atributo de alta cardinalidade em métrica. Já mencionado, mas vale repetir — é a fonte mais comum de surpresa de fatura em backend de telemetria. Alta cardinalidade vai em trace, não em métrica.
Trace que para na fila. Produtor publica mensagem sem injetar contexto, consumidor não extrai. Fix: sempre injetar contexto em mensagens, sempre extrair antes de processar. Bibliotecas modernas de OTel já fazem isso para Kafka/RabbitMQ/SQS.
Esquecer span.End() em Go. Sem
defer span.End(), o span fica "aberto" e nunca é
enviado para o coletor — você perde a operação no UI. Em
Python/.NET o context manager / using cuida; em Go é
manual.
Marcar erro só pela exception, sem
set_status. Em Python e Go,
record_exception/RecordError sozinho
não marca o span como erro — tem que chamar
set_status(ERROR). Em .NET, SetStatus
também é necessário. Sem isso, o UI mostra o span "ok" mesmo
tendo gravado a exceção.
Antes de adicionar instrumentação manual, pergunte: "essa operação merece span próprio porque tem latência variável e causa relevante para o negócio?". Operações curtas e deterministas raramente merecem (ruído). Operações que chamam externos, transacionam, ou consultam dados quase sempre merecem. Para tudo que está entre, span event ou métrica costuma ser melhor escolha. Sênior não instrumenta tudo — instrumenta o que vai ser olhado em incidente.
Por que importa para a sua carreira
Em 2026, instrumentação OTel não é mais "bom ter" — é expectativa baseline em qualquer sistema sério. Sêniores que entregam sistemas com instrumentação madura (auto-instrumentação configurada, spans manuais nas operações de domínio, atributos com cardinalidade controlada, propagação cross-service funcionando) entregam autonomia operacional ao time. Em entrevista de design, "como você instrumentaria esse sistema?" é pergunta direta para vagas backend pleno-sênior, e a resposta forte cita os três pilares, distingue auto de manual, menciona cardinalidade como restrição, e fala de propagação W3C. Em revisão de código, perguntar "qual a cardinalidade desse atributo?" antes de aprovar adição a métrica é serviço prestado ao time inteiro — uma fatura economizada vale meses de salário.
Como praticar
- Stack OTel local end-to-end. Suba localmente (docker-compose) Jaeger + Prometheus + Grafana + OTel Collector. Em uma das três linguagens, monte uma API com 2-3 endpoints e instrumente: auto-instrumentação para HTTP/SQL, spans manuais para operações de domínio, métricas counter e histogram para criação de pedido. Execute requests e navegue Jaeger → trace → exemplar → métrica → de volta a trace. Esse exercício faz você ver os três pilares conectados de fato.
- Trace que atravessa fila. Adicione ao sistema acima um produtor e um consumidor de fila (Redis pub/sub, RabbitMQ, ou Kafka local). Garanta que o trace iniciado em request HTTP atravesse a fila para o consumidor — verificando no Jaeger que aparece como árvore única, não dois traces independentes. Documente o que precisou configurar em cada lado para a propagação funcionar.
- Cardinality audit. Pegue um sistema seu instrumentado (ou um aberto que você usa) e liste todas as métricas com seus atributos. Para cada atributo, estime a cardinalidade em produção. Identifique pelo menos um atributo com cardinalidade explosiva e proponha a correção — geralmente removê-lo da métrica e movê-lo para spans. Esse é o tipo de revisão que evita surpresa de fatura.
Referências para aprofundar
- paper Dapper, a Large-Scale Distributed Systems Tracing Infrastructure — Benjamin Sigelman et al., Google (2010).
- docs OpenTelemetry — The Documentation.
- docs OpenTelemetry Semantic Conventions.
- docs W3C Trace Context (Recommendation, 2020).
- livro Observability Engineering — Charity Majors, Liz Fong-Jones, George Miranda (O'Reilly, 2022).
- livro Mastering Distributed Tracing — Yuri Shkuro (Packt, 2019).
- livro OpenTelemetry — Distributed Tracing in Practice — Reese Lee, Rob Ferguson (O'Reilly, 2024).
- livro Site Reliability Engineering — Betsy Beyer et al., Google (O'Reilly, 2016).
- artigo Logs and Metrics and Graphs, Oh My — Cindy Sridharan (blog Medium, 2017).
- artigo So You Want to Build an Observability Tool — Charity Majors (blog Honeycomb).
- artigo Cardinality Explosion: A Real-World Problem — Tom Wilkie (Grafana Labs blog, 2019+).
- vídeo OpenTelemetry: The Vision and the Reality — Ted Young, Morgan McLean (KubeCon, 2023).