OpenTelemetry em .NET: seguindo uma requisição pelo sistema

Uma requisição que atravessa um controller, chama dois serviços internos e faz uma consulta ao banco deixa, sem instrumentação nenhuma, só o resultado final visível. O caminho que ela percorreu por dentro do sistema, quanto tempo cada etapa levou e onde exatamente algo desacelerou, tudo isso desaparece assim que a resposta é enviada, a menos que alguém tenha instrumentado esse caminho de propósito.

ActivitySource: a peça que já vem embutida no .NET

O .NET já inclui suporte nativo a tracing distribuído através de System.Diagnostics.ActivitySource, sem depender de nenhuma biblioteca externa só para criar os spans. Um span representa uma unidade de trabalho com início, fim e duração, e spans podem ser aninhados para representar a hierarquia de chamadas dentro de uma mesma requisição.

private static readonly ActivitySource ActivitySource = new("MinhaAplicacao.Pedidos");

public async Task<Pedido> ProcessarPedidoAsync(int pedidoId)
{
    using var activity = ActivitySource.StartActivity("ProcessarPedido");
    activity?.SetTag("pedido.id", pedidoId);

    var pedido = await _repository.BuscarAsync(pedidoId);
    using (ActivitySource.StartActivity("ValidarEstoque"))
    {
        await _estoqueService.ValidarAsync(pedido);
    }

    return pedido;
}

Cada StartActivity cria um span, automaticamente vinculado ao span pai ativo no contexto (nesse exemplo, ValidarEstoque aparece aninhado dentro de ProcessarPedido), formando a árvore de chamadas que representa o caminho real da requisição por dentro da aplicação.

Meter: a mesma lógica aplicada a métricas

Para números agregados ao longo do tempo (quantas requisições, qual latência média, quantos erros), o .NET expõe System.Diagnostics.Metrics.Meter, seguindo o mesmo princípio de instrumentação nativa sem acoplamento a um fornecedor específico de monitoramento:

private static readonly Meter Meter = new("MinhaAplicacao.Pedidos");
private static readonly Counter<long> PedidosProcessados =
    Meter.CreateCounter<long>("pedidos.processados");

PedidosProcessados.Add(1, new KeyValuePair<string, object?>("status", "sucesso"));

ActivitySource e Meter produzem os dados brutos. Nenhum dos dois, por conta própria, decide para onde esses dados vão. Essa decisão fica isolada na camada de exportação, configurada separadamente.

OTLP, não mais o exportador dedicado de Jaeger

Um ponto que já mudou desde versões mais antigas de tutorial sobre o assunto: o pacote OpenTelemetry.Exporter.Jaeger, dedicado especificamente a exportar para Jaeger, está descontinuado. A recomendação atual é usar o exportador OTLP padrão (OpenTelemetry.Exporter.OpenTelemetryProtocol), já que o Jaeger passou a aceitar OTLP nativamente. Isso simplifica a configuração: o mesmo exportador funciona com Jaeger, com outros backends compatíveis com OTLP, e com um Collector do OpenTelemetry no meio do caminho, sem precisar trocar de pacote ao trocar de backend de observabilidade.

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddSource("MinhaAplicacao.Pedidos")
        .AddAspNetCoreInstrumentation()
        .AddOtlpExporter())
    .WithMetrics(metrics => metrics
        .AddMeter("MinhaAplicacao.Pedidos")
        .AddOtlpExporter());

AddAspNetCoreInstrumentation() adiciona automaticamente spans para cada requisição HTTP recebida pela aplicação, sem precisar instrumentar manualmente o pipeline de middleware inteiro. AddOtlpExporter() envia tudo (traces e métricas) para o endpoint configurado, seja um Collector local, seja diretamente um backend compatível.

Vendor neutrality não é detalhe filosófico

A vantagem prática de instrumentar contra ActivitySource e Meter, exportando via OTLP, é poder trocar de backend de observabilidade (Jaeger, um Collector redirecionando para outro destino, uma plataforma comercial) alterando só a configuração de exportação, sem tocar em nenhuma linha de código que gera os spans e métricas. Isso evita o cenário comum de uma aplicação inteira instrumentada com SDK proprietário de um fornecedor específico, onde trocar de ferramenta de observabilidade significa reinstrumentar a aplicação do zero.

Seguir uma requisição de ponta a ponta é o objetivo final

O valor de tudo isso só aparece quando uma requisição lenta ou com erro precisa ser investigada: com tracing instrumentado corretamente, dá para abrir o trace daquela requisição específica e ver exatamente qual etapa levou mais tempo, ou onde a falha realmente aconteceu, em vez de reconstruir isso por hipótese a partir de log espalhado sem correlação entre si.