← voltar ao índice

· 13 min de leitura · por Tiago

OpenTelemetry no .NET: observabilidade completa

Introdução

Instalar Grafana, criar alguns dashboards e centralizar logs não torna uma aplicação automaticamente observável. Essas ferramentas ajudam a visualizar dados, mas a qualidade da investigação depende da telemetria produzida pelo software. Quando uma API fica lenta, uma fila acumula mensagens ou uma integração começa a falhar apenas para parte dos clientes, o time precisa responder perguntas que não estavam previstas quando o dashboard foi criado.

Observabilidade é a capacidade de compreender o estado interno de um sistema a partir dos sinais que ele produz. Em aplicações distribuídas, isso exige mais do que registrar mensagens de texto. É necessário saber qual requisição iniciou uma operação, por quais serviços ela passou, quanto tempo cada etapa consumiu, quais dependências foram chamadas, qual regra de negócio estava sendo executada e como o comportamento agregado mudou ao longo do tempo.

O OpenTelemetry resolve uma parte importante desse problema ao oferecer APIs, convenções, bibliotecas de instrumentação e um protocolo aberto para coletar logs, métricas e traces. No ecossistema .NET, ele aproveita APIs nativas como ILogger, Meter e ActivitySource. Isso permite instrumentar o código sem acoplá-lo diretamente ao Grafana, Azure Monitor, Datadog, New Relic, Elastic ou qualquer outro backend.

Neste artigo, vamos construir uma estratégia completa para uma API ASP.NET Core. O objetivo não é apenas fazer dados aparecerem em uma ferramenta. Vamos entender por que cada sinal existe, como correlacioná-los, onde o OpenTelemetry Collector entra na arquitetura, quais atributos são seguros, como evitar explosão de cardinalidade e como operar tudo isso no Kubernetes.

O problema que o OpenTelemetry realmente resolve

Antes de configurar pacotes, é importante separar três responsabilidades que frequentemente aparecem misturadas em projetos reais.

A aplicação produz telemetria. Ela sabe que um pedido foi criado, que uma regra de desconto foi aplicada ou que uma chamada HTTP representa a consulta de estoque. Esse contexto de negócio só existe dentro do código e não pode ser reconstruído com precisão por uma ferramenta externa.

O pipeline coleta, processa e transporta telemetria. Essa função pode ser executada pelo SDK dentro da aplicação e, principalmente, pelo OpenTelemetry Collector. O Collector recebe dados, aplica limites, adiciona atributos, agrupa lotes e os encaminha para um ou mais destinos.

O backend armazena, consulta e apresenta os dados. Prometheus, Tempo, Loki, Jaeger, Azure Monitor e plataformas comerciais pertencem a essa camada.

Essa separação importa porque reduz o acoplamento. Uma aplicação que envia traces diretamente para uma API proprietária precisa conhecer autenticação, endpoint, formato e comportamento daquele fornecedor. Quando o destino muda, todas as aplicações precisam ser alteradas e publicadas novamente. Com OTLP e um Collector intermediário, a aplicação conhece apenas um protocolo e um endpoint interno estável.

Uma arquitetura comum fica assim:

ASP.NET Core
  |  OTLP gRPC ou OTLP HTTP
  v
OpenTelemetry Collector
  |  processamento, lotes, retry e roteamento
  +--> backend de traces
  +--> backend de métricas
  +--> backend de logs

O OpenTelemetry não substitui os backends. Ele padroniza a geração e o transporte da telemetria, o que permite trocar ou combinar destinos sem reescrever a instrumentação da aplicação.

Logs, métricas e traces respondem perguntas diferentes

Os três sinais se complementam, mas não são intercambiáveis.

Métricas mostram comportamento agregado ao longo do tempo. Uma métrica pode informar que a latência no percentil 95 aumentou, que a taxa de erros passou de 1% ou que há 30 pedidos em processamento. Elas são eficientes para dashboards e alertas porque resumem grandes volumes de eventos em séries temporais.

Traces mostram o caminho de uma operação individual. Um trace pode revelar que uma requisição levou 2,4 segundos porque aguardou 1,8 segundo em uma integração externa e realizou uma consulta SQL repetida quatro vezes. Em sistemas distribuídos, os spans registram a relação causal e temporal entre serviços.

Logs registram eventos com detalhes úteis para investigação. Um log estruturado pode dizer que o pedido 8f08... falhou porque a regra de crédito retornou uma condição específica. Quando o log possui o mesmo TraceId do trace, é possível sair de uma visão agregada, abrir uma requisição lenta e consultar todas as mensagens relacionadas àquela execução.

Um erro comum é tentar colocar tudo em logs. Isso aumenta custos, dificulta alertas e obriga o time a reconstruir agregações durante cada consulta. Outro erro é colocar identificadores únicos em métricas para tentar obter o mesmo detalhe de logs e traces. Essa escolha cria uma nova série temporal para cada identificador e pode comprometer o backend de métricas.

Uma estratégia saudável usa métricas para detectar, traces para localizar e logs para explicar.

Criando uma API ASP.NET Core instrumentada

Vamos usar uma API de pedidos como exemplo. Ela recebe uma solicitação, consulta um serviço de estoque e registra métricas de negócio.

Crie o projeto e instale os pacotes principais:

dotnet new webapi -n Logby.Orders.Api
cd Logby.Orders.Api

dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.Runtime

OpenTelemetry.Extensions.Hosting integra o ciclo de vida dos providers ao host do .NET. O exporter OTLP envia os sinais usando o protocolo padrão do OpenTelemetry. As instrumentações de ASP.NET Core e HttpClient criam spans e métricas para requisições recebidas e chamadas de saída. A instrumentação de runtime expõe informações como garbage collection, heap, threads e outras medidas do processo gerenciado.

Evite reproduzir esses comandos sem fixar versões ao levá-los para a documentação interna do time, a menos que exista uma política clara de atualização de dependências. Em uma aplicação real, as versões devem ser centralizadas, revisadas e travadas no repositório, por exemplo com Directory.Packages.props. O comando sem versão instala a versão estável resolvida no momento da execução, o que é conveniente para um exemplo, mas insuficiente para builds reproduzíveis.

O Program.cs abaixo configura os três sinais e usa o mesmo recurso para identificar o serviço:

using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

const string serviceName = "Logby.Orders.Api";
const string serviceVersion = "1.0.0";

var builder = WebApplication.CreateBuilder(args);

var resourceBuilder = ResourceBuilder
    .CreateDefault()
    .AddService(
        serviceName: serviceName,
        serviceVersion: serviceVersion);

builder.Services.AddSingleton(
    new OrdersTelemetry(serviceName, serviceVersion));

builder.Services.AddHttpClient("inventory", client =>
{
    client.BaseAddress = new Uri(
        builder.Configuration["Inventory:BaseUrl"]
        ?? "http://inventory-api");
    client.Timeout = TimeSpan.FromSeconds(3);
});

builder.Services
    .AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(
            serviceName: serviceName,
            serviceVersion: serviceVersion))
    .WithTracing(tracing => tracing
        .AddSource(serviceName)
        .AddAspNetCoreInstrumentation(options =>
        {
            options.RecordException = true;
            options.Filter = context =>
                !context.Request.Path.StartsWithSegments("/health");
        })
        .AddHttpClientInstrumentation(options =>
        {
            options.RecordException = true;
        })
        .AddOtlpExporter())
    .WithMetrics(metrics => metrics
        .AddMeter(serviceName)
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddRuntimeInstrumentation()
        .AddOtlpExporter());

builder.Logging.AddOpenTelemetry(options =>
{
    options.SetResourceBuilder(resourceBuilder);
    options.IncludeFormattedMessage = true;
    options.IncludeScopes = true;
    options.ParseStateValues = true;
    options.AddOtlpExporter();
});

builder.Services.AddScoped<OrderService>();
builder.Services.AddHealthChecks();

var app = builder.Build();

app.MapHealthChecks("/health");

app.MapPost("/orders", async (
    CreateOrderRequest request,
    OrderService service,
    CancellationToken cancellationToken) =>
{
    var result = await service.CreateAsync(request, cancellationToken);
    return Results.Created($"/orders/{result.Id}", result);
});

app.Run();

public sealed record CreateOrderRequest(
    string ProductId,
    int Quantity,
    string Channel,
    string CustomerSegment);

public sealed record OrderResult(Guid Id, decimal Total);

O recurso identifica quem produziu a telemetria. service.name, service.version e service.instance.id parecem detalhes administrativos, mas são essenciais para consultas confiáveis. Sem um nome estável, spans podem aparecer agrupados sob valores genéricos. Sem versão, fica difícil relacionar um aumento de erros a um deploy. Sem uma instância, a investigação de um problema localizado em um pod específico perde precisão.

AddAspNetCoreInstrumentation cria telemetria para requisições recebidas. O filtro remove o endpoint de health check para evitar que sondagens frequentes dominem os dados. Esse filtro deve ser usado com cuidado. Excluir health checks reduz ruído, mas também remove evidências de lentidão ou falhas nesse endpoint. Em ambientes críticos, uma alternativa é manter as métricas e filtrar apenas os traces.

AddHttpClientInstrumentation acompanha chamadas realizadas por HttpClient. Isso é muito mais seguro do que criar manualmente um span para toda requisição HTTP, pois a biblioteca conhece propagação de contexto, códigos de status, exceções e convenções semânticas.

AddSource(serviceName) e AddMeter(serviceName) habilitam a coleta da instrumentação manual que criaremos a seguir. Esquecer essas linhas é um dos erros mais frustrantes na adoção do OpenTelemetry. O código cria Activity e registra medidas sem lançar exceção, mas o provider ignora os dados porque não está inscrito naquela fonte.

Os exporters usam configurações padronizadas do ambiente. No Kubernetes, por exemplo, OTEL_EXPORTER_OTLP_ENDPOINT pode apontar para o Service do Collector. Assim, o mesmo binário funciona em desenvolvimento, homologação e produção sem recompilação.

Instrumentando regras de negócio com ActivitySource e Meter

A instrumentação automática conhece HTTP, runtime e algumas bibliotecas, mas não entende o significado do seu domínio. Ela sabe que ocorreu um POST /orders, porém não sabe se o pedido veio do marketplace, se utilizou uma regra promocional ou se falhou durante a reserva de estoque.

Para adicionar esse contexto, crie uma classe que mantenha uma única instância de ActivitySource, Meter e seus instrumentos:

using System.Diagnostics;
using System.Diagnostics.Metrics;

public sealed class OrdersTelemetry : IDisposable
{
    public ActivitySource ActivitySource { get; }
    public Meter Meter { get; }
    public Counter<long> OrdersCreated { get; }
    public Histogram<double> ProcessingDuration { get; }
    public UpDownCounter<long> OrdersInProgress { get; }

    public OrdersTelemetry(string serviceName, string serviceVersion)
    {
        ActivitySource = new ActivitySource(serviceName, serviceVersion);
        Meter = new Meter(serviceName, serviceVersion);

        OrdersCreated = Meter.CreateCounter<long>(
            name: "orders.created",
            unit: "{order}",
            description: "Quantidade de pedidos criados com sucesso");

        ProcessingDuration = Meter.CreateHistogram<double>(
            name: "orders.processing.duration",
            unit: "ms",
            description: "Tempo total para processar um pedido");

        OrdersInProgress = Meter.CreateUpDownCounter<long>(
            name: "orders.processing.active",
            unit: "{order}",
            description: "Quantidade atual de pedidos em processamento");
    }

    public void Dispose()
    {
        ActivitySource.Dispose();
        Meter.Dispose();
    }
}

ActivitySource e Meter devem ser reutilizados. Criá-los a cada requisição adiciona trabalho desnecessário e pode causar comportamento difícil de diagnosticar. Neste exemplo, a classe é singleton e o host gerencia seu descarte.

O Counter representa um valor que apenas aumenta. Ele é apropriado para pedidos criados, mensagens publicadas e falhas observadas. O Histogram registra uma distribuição de valores, o que permite calcular percentis de duração. O UpDownCounter pode aumentar e diminuir, por isso representa operações atualmente em execução.

Escolher o instrumento errado altera a semântica. Um contador não deveria receber valores negativos. Um histogram não deveria ser usado como simples contador de eventos. A ferramenta pode até aceitar os dados, mas consultas, alertas e agregações deixam de representar corretamente o sistema.

Agora implemente o serviço de negócio:

using System.Diagnostics;
using System.Net.Http.Json;
using OpenTelemetry.Trace;

public sealed class OrderService(
    OrdersTelemetry telemetry,
    IHttpClientFactory httpClientFactory,
    ILogger<OrderService> logger)
{
    public async Task<OrderResult> CreateAsync(
        CreateOrderRequest request,
        CancellationToken cancellationToken)
    {
        var startedAt = Stopwatch.GetTimestamp();
        var channelTag = new KeyValuePair<string, object?>(
            "order.channel",
            request.Channel);

        using var activity = telemetry.ActivitySource.StartActivity(
            name: "orders.create",
            kind: ActivityKind.Internal);

        if (activity?.IsAllDataRequested == true)
        {
            activity.SetTag("order.channel", request.Channel);
            activity.SetTag("customer.segment", request.CustomerSegment);
            activity.SetTag("product.id", request.ProductId);
        }

        telemetry.OrdersInProgress.Add(1, channelTag);

        try
        {
            var inventoryClient = httpClientFactory.CreateClient("inventory");
            var response = await inventoryClient.PostAsJsonAsync(
                "/reservations",
                new
                {
                    request.ProductId,
                    request.Quantity
                },
                cancellationToken);

            response.EnsureSuccessStatusCode();

            var total = CalculateTotal(request.Quantity);
            var order = new OrderResult(Guid.NewGuid(), total);

            telemetry.OrdersCreated.Add(1, channelTag);
            logger.OrderCreated(order.Id, request.Channel, order.Total);

            activity?.SetStatus(ActivityStatusCode.Ok);
            return order;
        }
        catch (Exception exception)
        {
            activity?.SetStatus(
                ActivityStatusCode.Error,
                exception.Message);
            activity?.AddException(exception);

            logger.OrderCreationFailed(
                exception,
                request.Channel,
                request.ProductId);

            throw;
        }
        finally
        {
            var elapsed = Stopwatch.GetElapsedTime(startedAt);

            telemetry.ProcessingDuration.Record(
                elapsed.TotalMilliseconds,
                channelTag);

            telemetry.OrdersInProgress.Add(-1, channelTag);
        }
    }

    private static decimal CalculateTotal(int quantity)
    {
        const decimal unitPrice = 49.90m;
        return quantity * unitPrice;
    }
}

public static partial class OrderLogMessages
{
    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "Pedido {OrderId} criado pelo canal {Channel} com total {Total}")]
    public static partial void OrderCreated(
        this ILogger logger,
        Guid orderId,
        string channel,
        decimal total);

    [LoggerMessage(
        EventId = 1002,
        Level = LogLevel.Error,
        Message = "Falha ao criar pedido pelo canal {Channel} para o produto {ProductId}")]
    public static partial void OrderCreationFailed(
        this ILogger logger,
        Exception exception,
        string channel,
        string productId);
}

O span orders.create representa uma operação de negócio dentro do span HTTP criado automaticamente. Essa camada adicional permite analisar o tempo consumido pela regra de criação separadamente do restante do pipeline ASP.NET Core.

A verificação de IsAllDataRequested evita calcular e anexar atributos detalhados quando o sampler decidiu que aquele span não será coletado com todos os dados. Para valores simples, a diferença pode ser pequena. Para serializações, transformações ou consultas adicionais, o impacto no caminho crítico pode ser significativo.

O identificador do produto foi colocado no span, não na métrica. Um span representa uma execução individual e aceita atributos de cardinalidade mais alta, desde que o volume, a privacidade e o custo tenham sido avaliados. A métrica usa apenas order.channel, que deve possuir um conjunto pequeno e controlado de valores, como web, marketplace e mobile.

Os métodos gerados por LoggerMessage preservam logging estruturado e evitam parte das alocações associadas à formatação tradicional. A exceção é recebida em um parâmetro próprio. Registrar apenas exception.Message elimina o stack trace e impede que exporters preencham corretamente campos específicos de exceção.

Como os logs são emitidos enquanto existe uma Activity ativa, o SDK adiciona automaticamente TraceId e SpanId. Essa correlação permite abrir um trace e localizar as mensagens do mesmo fluxo sem incluir manualmente esses identificadores no template.

Cardinalidade: o problema que cresce silenciosamente

Cardinalidade é a quantidade de combinações únicas de atributos de uma métrica. Considere uma métrica orders.created com duas dimensões: channel possui 3 valores e status possui 4 valores. O total potencial é 12 séries.

Agora adicione customer.id, com 200 mil clientes. O potencial passa para 2,4 milhões de séries. Se também for incluído order.id, cada pedido gera uma combinação nova. O backend precisa manter índices, memória e armazenamento para séries que talvez tenham apenas uma amostra.

Esse problema é perigoso porque a aplicação continua funcionando. O dano aparece como aumento de custos, consultas lentas, uso excessivo de memória ou descarte de séries. Em alguns ambientes, dados controlados pelo usuário podem até ser usados para provocar cardinalidade extrema.

Evite usar estes valores como atributos de métricas:

  • IDs de usuário, pedido, produto, sessão ou requisição
  • URLs completas com parâmetros dinâmicos
  • mensagens de exceção
  • endereços de e-mail
  • nomes livres enviados pelo usuário
  • TraceId e SpanId

A correlação entre métricas e traces deve usar exemplars quando o backend oferecer suporte. Um exemplar associa algumas medições a traces específicos sem transformar cada trace em uma dimensão da série temporal.

Também não basta confiar apenas no limite do SDK. Limites reduzem o impacto de um erro, mas podem agrupar novas combinações em uma série de overflow e esconder o detalhe que o time esperava consultar. O melhor controle é o desenho consciente das dimensões.

Uma regra prática é perguntar: consigo listar todos os valores possíveis desse atributo e estabelecer um limite razoável? Para order.channel, provavelmente sim. Para customer.id, não.

Configurando o OpenTelemetry Collector

O Collector é organizado em receivers, processors, exporters e pipelines.

Receivers recebem dados. Processors transformam, limitam ou agrupam. Exporters enviam para destinos. Pipelines conectam esses componentes para cada sinal. Declarar um receiver no arquivo não o ativa. Ele precisa aparecer em pelo menos um pipeline.

O exemplo abaixo recebe OTLP por gRPC e HTTP, protege a memória, envia em lotes e encaminha os três sinais para um backend compatível com OTLP:

extensions:
  health_check:
    endpoint: 0.0.0.0:13133

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

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 400
    spike_limit_mib: 100

  batch:
    timeout: 5s
    send_batch_size: 1024

exporters:
  otlphttp/backend:
    endpoint: ${env:OBSERVABILITY_OTLP_ENDPOINT}
    headers:
      Authorization: Bearer ${env:OBSERVABILITY_API_TOKEN}
    sending_queue:
      enabled: true
      num_consumers: 4
      queue_size: 2048
    retry_on_failure:
      enabled: true
      initial_interval: 1s
      max_interval: 30s
      max_elapsed_time: 300s

service:
  extensions:
    - health_check

  pipelines:
    traces:
      receivers:
        - otlp
      processors:
        - memory_limiter
        - batch
      exporters:
        - otlphttp/backend

    metrics:
      receivers:
        - otlp
      processors:
        - memory_limiter
        - batch
      exporters:
        - otlphttp/backend

    logs:
      receivers:
        - otlp
      processors:
        - memory_limiter
        - batch
      exporters:

← voltar ao índice