Você subiu o deploy, o usuário reclama que "está lento", e você abre o painel de monitoramento. Se a resposta for só um gráfico de CPU e memória, você tem monitoramento — mas não tem observabilidade. A diferença é que observabilidade permite responder: qual endpoint está lento? qual query específica está demorando? qual microsserviço está causando o problema?
OpenTelemetry é o padrão open source que unifica os três pilares da observabilidade — traces, métricas e logs — em uma única instrumentação que exporta para qualquer backend.
Os três pilares da observabilidade
Traces distribuídos: rastreiam uma requisição do início ao fim, atravessando múltiplos serviços. Mostram exatamente onde o tempo foi gasto — no banco, numa chamada HTTP externa, num processamento específico.
Métricas: valores numéricos agregados ao longo do tempo — latência p95/p99, taxa de erros, throughput, tamanho de filas. Ótimas para alertas e dashboards de saúde.
Logs estruturados: eventos com contexto estruturado (campos nomeados, não texto livre). Permitem filtrar por tenant_id, trace_id, usuario_id sem depender de regex.
Setup: pacotes necessários
# Pacotes principais
dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore
# Exportadores (escolha conforme seu stack)
dotnet add package OpenTelemetry.Exporter.Console # dev/debug
dotnet add package OpenTelemetry.Exporter.Jaeger # Jaeger (self-hosted)
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol # OTLP (Grafana, etc)
# Métricas
dotnet add package OpenTelemetry.Instrumentation.Runtime # métricas do runtime .NET
Configuração em Program.cs
var serviceName = "pedidos-api"; var serviceVersion = "1.0.0";
builder.Services.AddOpenTelemetry() .ConfigureResource(resource => resource .AddService(serviceName, serviceVersion: serviceVersion) .AddAttributes(new Dictionary<string, object> { [“deployment.environment”] = builder.Environment.EnvironmentName.ToLower(), [“host.name”] = Environment.MachineName })) .WithTracing(tracing => tracing .AddAspNetCoreInstrumentation(opt => { // Filtrar health checks do trace opt.Filter = ctx => !ctx.Request.Path.StartsWithSegments(“/health”); opt.RecordException = true; }) .AddHttpClientInstrumentation() .AddEntityFrameworkCoreInstrumentation(opt => { opt.SetDbStatementForText = true; // inclui a query SQL no trace }) .AddSource(serviceName) // para traces manuais (ActivitySource) .AddJaegerExporter(opt => { opt.AgentHost = builder.Configuration[“Jaeger:Host”] ?? “localhost”; opt.AgentPort = 6831; }) .AddOtlpExporter(opt => // para Grafana Tempo / AWS X-Ray OTLP { opt.Endpoint = new Uri(builder.Configuration[“Otlp:Endpoint”] ?? “http://localhost:4317”); })) .WithMetrics(metrics => metrics .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation() .AddRuntimeInstrumentation() .AddMeter(serviceName) // para métricas customizadas .AddPrometheusExporter()) // expõe /metrics para o Prometheus coletar .WithLogging(logging => logging .AddOtlpExporter());
Traces manuais: instrumentando código de negócio
A instrumentação automática cobre HTTP e banco — mas para lógica de negócio crítica você quer criar spans próprios.
public class PedidoService { // ActivitySource é thread-safe, crie uma instância estática por classe private static readonly ActivitySource _activitySource = new("pedidos-api");private readonly IProcessadorPagamento _pagamento; private readonly IEstoqueService _estoque; public async Task<PedidoConfirmadoResult> ProcessarPedidoAsync( Pedido pedido, CancellationToken ct) { // Span raiz para toda a operação using var activity = _activitySource.StartActivity("ProcessarPedido"); activity?.SetTag("pedido.id", pedido.Id.ToString()); activity?.SetTag("pedido.total", pedido.CalcularTotal()); activity?.SetTag("pedido.itens", pedido.Itens.Count); try { // Sub-span para verificação de estoque using (var estoqueActivity = _activitySource.StartActivity("VerificarEstoque")) { var estoqueOk = await _estoque.VerificarDisponibilidadeAsync(pedido.Itens, ct); estoqueActivity?.SetTag("estoque.disponivel", estoqueOk); if (!estoqueOk) { estoqueActivity?.SetStatus(ActivityStatusCode.Error, "Estoque insuficiente"); throw new EstoqueInsuficienteException(pedido.Id); } } // Sub-span para processamento de pagamento using (var pagamentoActivity = _activitySource.StartActivity("ProcessarPagamento")) { var resultado = await _pagamento.ProcessarAsync(pedido, ct); pagamentoActivity?.SetTag("pagamento.gateway", resultado.Gateway); pagamentoActivity?.SetTag("pagamento.codigo", resultado.CodigoTransacao); return new PedidoConfirmadoResult(pedido.Id, resultado.CodigoTransacao); } } catch (Exception ex) { activity?.SetStatus(ActivityStatusCode.Error, ex.Message); activity?.RecordException(ex); throw; } }
}
Métricas customizadas
public class PedidoMetrics { private readonly Counter<long> _pedidosConfirmados; private readonly Counter<long> _pedidosCancelados; private readonly Histogram<double> _tempoProcessamento; private readonly UpDownCounter<int> _pedidosPendentes;public PedidoMetrics(IMeterFactory meterFactory) { var meter = meterFactory.Create("pedidos-api"); _pedidosConfirmados = meter.CreateCounter<long>( "pedidos.confirmados.total", description: "Total de pedidos confirmados"); _pedidosCancelados = meter.CreateCounter<long>( "pedidos.cancelados.total", description: "Total de pedidos cancelados"); _tempoProcessamento = meter.CreateHistogram<double>( "pedidos.processamento.duracao", unit: "ms", description: "Duração do processamento de pedidos"); _pedidosPendentes = meter.CreateUpDownCounter<int>( "pedidos.pendentes.atual", description: "Pedidos aguardando processamento no momento"); } public void PedidoConfirmado(string canal) => _pedidosConfirmados.Add(1, new KeyValuePair<string, object?>("canal", canal)); public void PedidoCancelado(string motivo) => _pedidosCancelados.Add(1, new KeyValuePair<string, object?>("motivo", motivo)); public void RegistrarTempoProcessamento(double ms) => _tempoProcessamento.Record(ms); public void PedidoEntrou() => _pedidosPendentes.Add(1); public void PedidoSaiu() => _pedidosPendentes.Add(-1);}
// Registrar no DI builder.Services.AddSingleton<PedidoMetrics>(); // Usar no handler public class ConfirmarPedidoHandler : IRequestHandler<ConfirmarPedidoCommand, ConfirmarPedidoResult> { private readonly PedidoMetrics _metrics; // …
public async Task<ConfirmarPedidoResult> Handle(ConfirmarPedidoCommand req, CancellationToken ct) { var sw = Stopwatch.StartNew(); try { var result = await ProcessarAsync(req, ct); _metrics.PedidoConfirmado("api"); return result; } finally { sw.Stop(); _metrics.RegistrarTempoProcessamento(sw.Elapsed.TotalMilliseconds); } }
}
Logs estruturados com contexto de trace
Quando você usa OpenTelemetry + logging estruturado, o trace_id e span_id são automaticamente injetados em cada log — isso permite correlacionar um log de erro com o trace exato que o gerou.
// Serilog com sink OTLP (exporta logs para o mesmo backend dos traces) dotnet add package Serilog.AspNetCore dotnet add package Serilog.Sinks.OpenTelemetry// Program.cs Log.Logger = new LoggerConfiguration() .Enrich.FromLogContext() .Enrich.WithProperty(“service_name”, “pedidos-api”) .WriteTo.Console(new RenderedCompactJsonFormatter()) // JSON estruturado .WriteTo.OpenTelemetry(opt => { opt.Endpoint = “http://localhost:4317”; opt.ResourceAttributes = new Dictionary<string, object> { [“service.name”] = “pedidos-api” }; }) .CreateLogger();
// Uso nos handlers — campos nomeados, não interpolação de string _logger.LogInformation(“Pedido {PedidoId} confirmado para cliente {ClienteId} — total {Total:C}”, pedido.Id, pedido.ClienteId, pedido.CalcularTotal());
// O log gerado em JSON terá: // { “PedidoId”: ”…”, “ClienteId”: ”…”, “Total”: 299.90, // “TraceId”: “abc123”, “SpanId”: “def456”, … }
Stack de observabilidade self-hosted com Docker Compose
# docker-compose.observabilidade.yml services: jaeger: image: jaegertracing/all-in-one:1.57 ports: - "16686:16686" # UI do Jaeger - "6831:6831/udp" - "4317:4317" # OTLP gRPCprometheus: image: prom/prometheus:v2.51.0 ports: - “9090:9090” volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml
grafana: image: grafana/grafana:10.4.0 ports: - “3000:3000” environment: GF_SECURITY_ADMIN_PASSWORD: admin volumes: - grafana_data:/var/lib/grafana
volumes: grafana_data:
Com essa stack rodando localmente você tem: Jaeger em localhost:16686 para traces, Prometheus em localhost:9090 para métricas, e Grafana em localhost:3000 para dashboards unificados.
Observabilidade na AWS
Para quem usa AWS, o stack equivalente é: AWS X-Ray para traces (exportador OTLP compatível), CloudWatch Metrics para métricas e CloudWatch Logs para logs estruturados. O OpenTelemetry exporta para todos com o exportador OTLP — você troca o endpoint de destino sem mudar o código de instrumentação.
Conclusão
Observabilidade com OpenTelemetry transforma a forma como você diagnostica problemas em produção. Em vez de adivinhar onde está o gargalo, você tem o caminho exato da requisição, com tempos por etapa, queries executadas e contexto de negócio em cada span.
O investimento na instrumentação se paga na primeira vez que você precisa diagnosticar um problema de performance em produção sem acesso SSH ao servidor — que é exatamente o cenário de um sistema cloud-native moderno.
Se você quer implementar observabilidade em uma aplicação .NET existente ou está desenhando a stack de monitoramento de um novo projeto, a Neryx pode ajudar. Consultoria inicial gratuita.
Leitura complementar: