.NET Serilog Observabilidade DevOps Kubernetes ELK Stack

Logging estruturado no .NET com Serilog: do básico ao ELK Stack em produção

Configure Serilog no .NET com sinks para arquivo, console e Elasticsearch. Enrichers, contexto estruturado, correlation ID.

N
Neryx Digital Architects
15 de dezembro de 2025
12 min de leitura
220 profissionais leram
Categoria: .NET Público: Times de plataforma e operação Etapa: Decisão

Console.WriteLine e _logger.LogInformation("Processando pedido " + orderId) são logging primitivo. Em produção, você precisa de logging estruturado: campos indexáveis, contexto rico, correlação entre serviços e pesquisa eficiente. Serilog é o padrão de facto no ecossistema .NET para isso.

Por que logging estruturado importa

A diferença fundamental:

# Logging textual — impossível pesquisar por campo
[2026-05-02 10:00:00] Pedido 3fa85f64 processado pelo usuário danilo@neryx.com.br em 245ms

# Logging estruturado — cada campo é pesquisável e indexável
{
  "timestamp": "2026-05-02T10:00:00.000Z",
  "level": "Information",
  "message": "Pedido processado",
  "orderId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "userId": "danilo@neryx.com.br",
  "durationMs": 245,
  "correlationId": "abc123",
  "service": "OrderService",
  "environment": "production"
}

Com logging estruturado, você pesquisa no Kibana: level:Error AND service:OrderService AND durationMs:>1000. Com logging textual, você reza para encontrar o erro em gigabytes de texto.

Instalação

dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console          # Console com formatação
dotnet add package Serilog.Sinks.File             # Arquivos com rolagem
dotnet add package Serilog.Sinks.Elasticsearch    # ELK Stack
dotnet add package Serilog.Enrichers.Environment  # MachineName, EnvironmentName
dotnet add package Serilog.Enrichers.Thread       # ThreadId
dotnet add package Serilog.Enrichers.Process      # ProcessId, ProcessName
dotnet add package Serilog.Enrichers.Span         # TraceId, SpanId (OpenTelemetry)

Configuração completa no Program.cs

// Program.cs — Serilog como primeiro passo (captura erros de startup)
Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Warning() // Bootstrap logger mínimo
    .WriteTo.Console()
    .CreateBootstrapLogger();

try
{
    var builder = WebApplication.CreateBuilder(args);

    builder.Host.UseSerilog((context, services, configuration) =>
    {
        configuration
            // Nível base por ambiente
            .MinimumLevel.Is(context.HostingEnvironment.IsProduction()
                ? LogEventLevel.Information
                : LogEventLevel.Debug)

            // Silenciar namespaces verbosos
            .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
            .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
            .MinimumLevel.Override("System", LogEventLevel.Warning)
            .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command",
                context.HostingEnvironment.IsProduction()
                    ? LogEventLevel.Warning  // Silencia SQL em produção
                    : LogEventLevel.Information) // Mostra SQL em desenvolvimento

            // Enrichers — enriquecem todo log automaticamente
            .Enrich.FromLogContext()              // Contexto pushado via LogContext.Push
            .Enrich.WithMachineName()             // servidor/pod
            .Enrich.WithEnvironmentName()         // Production/Development/Staging
            .Enrich.WithProcessId()
            .Enrich.WithThreadId()
            .Enrich.WithSpan()                    // OpenTelemetry TraceId/SpanId

            // Propriedades fixas para todos os logs
            .Enrich.WithProperty("Application", "OrderService")
            .Enrich.WithProperty("Version",
                Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown")

            // Sinks
            .WriteTo.Console(new CompactJsonFormatter()) // JSON compacto no console (para ECS/Datadog)
            .WriteTo.File(
                new CompactJsonFormatter(),
                path: "logs/log-.json",
                rollingInterval: RollingInterval.Day,
                retainedFileCountLimit: 7,
                fileSizeLimitBytes: 100 * 1024 * 1024) // 100MB por arquivo

            // Elasticsearch (produção)
            .WriteTo.Conditional(
                _ => context.HostingEnvironment.IsProduction(),
                wt => wt.Elasticsearch(new ElasticsearchSinkOptions(
                    new Uri(context.Configuration["Serilog:Elasticsearch:Uri"]!))
                {
                    AutoRegisterTemplate = true,
                    IndexFormat = "orderservice-{0:yyyy.MM.dd}",
                    NumberOfReplicas = 1,
                    NumberOfShards = 2,
                    BatchAction = ElasticOpType.Create
                }))

            // Lê configuração adicional do appsettings.json
            .ReadFrom.Configuration(context.Configuration)
            .ReadFrom.Services(services); // Injeta serviços nos enrichers
    });

    // ... resto da configuração

    var app = builder.Build();

    // Middleware de logging de requisições HTTP (Serilog nativo)
    app.UseSerilogRequestLogging(options =>
    {
        options.MessageTemplate =
            "HTTP {RequestMethod} {RequestPath} respondeu {StatusCode} em {Elapsed:0.0000}ms";

        // Enriquecer com dados da requisição e resposta
        options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
        {
            diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
            diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent);
            diagnosticContext.Set("UserId", httpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value);
            diagnosticContext.Set("TenantId", httpContext.Items["TenantId"]?.ToString());
            diagnosticContext.Set("CorrelationId", httpContext.Items["CorrelationId"]?.ToString());
        };

        // Ignorar health checks no log de requisições
        options.GetLevel = (httpContext, elapsed, ex) =>
        {
            if (httpContext.Request.Path.StartsWithSegments("/health"))
                return LogEventLevel.Verbose; // Verbose = não aparece (abaixo do mínimo)

            return ex is not null || httpContext.Response.StatusCode > 499
                ? LogEventLevel.Error
                : LogEventLevel.Information;
        };
    });

    await app.RunAsync();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Aplicação encerrada com falha no startup");
}
finally
{
    await Log.CloseAndFlushAsync(); // Garante que todos os logs são escritos antes de encerrar
}

appsettings.json — configuração por ambiente

// appsettings.json (base)
{
  "Serilog": {
    "Elasticsearch": {
      "Uri": "http://elasticsearch:9200"
    }
  }
}

// appsettings.Development.json — logs detalhados em dev
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Debug",
      "Override": {
        "Microsoft.EntityFrameworkCore.Database.Command": "Information"
      }
    }
  }
}

// appsettings.Production.json — apenas o necessário
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "System": "Warning"
      }
    }
  }
}

Logging com contexto estruturado

// ❌ Interpolação de string — não é estruturado, não é indexável
_logger.LogInformation($"Pedido {orderId} do usuário {userId} processado em {duration}ms");

// ✅ Message template — cada propriedade é um campo estruturado
_logger.LogInformation(
    "Pedido {OrderId} do usuário {UserId} processado em {DurationMs}ms",
    orderId, userId, duration);

// ✅ Com objeto complexo — use @ para serializar como JSON
_logger.LogInformation("Pedido criado: {@Order}", order);

// ✅ Destruturing explícito (serialização controlada)
_logger.LogInformation("Pagamento processado: {Payment}",
    new { order.Id, order.Total, order.Status }); // Apenas os campos relevantes

LogContext: enriquecer logs dentro de um escopo

// Serviço que processa pedidos — adiciona contexto para todos os logs dentro do escopo
public class OrderProcessingService
{
    private readonly ILogger<OrderProcessingService> _logger;

    public async Task ProcessOrderAsync(Guid orderId, Guid tenantId)
    {
        // Tudo logado dentro deste using terá OrderId e TenantId
        using var scope = LogContext.PushProperty("OrderId", orderId);
        using var tenantScope = LogContext.PushProperty("TenantId", tenantId);

        _logger.LogInformation("Iniciando processamento do pedido");

        try
        {
            await ValidateStockAsync(orderId);
            await ProcessPaymentAsync(orderId);
            await NotifyCustomerAsync(orderId);

            _logger.LogInformation("Pedido processado com sucesso");
        }
        catch (Exception ex)
        {
            // Este log terá OrderId e TenantId automaticamente
            _logger.LogError(ex, "Falha no processamento do pedido");
            throw;
        }
    }
}

// Extensão para adicionar múltiplas propriedades de uma vez
public static class LogContextExtensions
{
    public static IDisposable PushOrderContext(this ILogger logger, Order order)
    {
        return LogContext.Push(
            new PropertyEnricher("OrderId", order.Id),
            new PropertyEnricher("OrderStatus", order.Status),
            new PropertyEnricher("CustomerId", order.CustomerId),
            new PropertyEnricher("OrderTotal", order.Total)
        );
    }
}

Enricher customizado: dados do tenant

public class TenantEnricher : ILogEventEnricher
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public TenantEnricher(IHttpContextAccessor httpContextAccessor)
        => _httpContextAccessor = httpContextAccessor;

    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
    {
        var context = _httpContextAccessor.HttpContext;
        if (context is null) return;

        // Adiciona TenantId se disponível no contexto HTTP
        if (context.Items.TryGetValue("TenantId", out var tenantId) && tenantId is not null)
        {
            logEvent.AddPropertyIfAbsent(
                propertyFactory.CreateProperty("TenantId", tenantId.ToString()));
        }

        // Adiciona CorrelationId
        if (context.Items.TryGetValue("CorrelationId", out var correlationId) && correlationId is not null)
        {
            logEvent.AddPropertyIfAbsent(
                propertyFactory.CreateProperty("CorrelationId", correlationId.ToString()));
        }
    }
}

// Registrar no DI e no Serilog
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<TenantEnricher>();

// No UseSerilog:
.Enrich.With<TenantEnricher>() // Via serviço registrado no DI

Stack ELK com Docker Compose para desenvolvimento

# docker-compose.logging.yml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
    ports:
      - "9200:9200"
    volumes:
      - elasticsearch_data:/usr/share/elasticsearch/data
    healthcheck:
      test: curl -s http://localhost:9200/_cluster/health | grep -v red
      interval: 10s
      retries: 10

  kibana:
    image: docker.elastic.co/kibana/kibana:8.13.0
    ports:
      - "5601:5601"
    environment:
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200
    depends_on:
      elasticsearch:
        condition: service_healthy

volumes:
  elasticsearch_data:
# Subir o stack de logging
docker compose -f docker-compose.logging.yml up -d

# Acessar Kibana
open http://localhost:5601

# Criar index pattern: orderservice-* (via Kibana UI ou API)
curl -X POST http://localhost:5601/api/index_patterns/index_pattern \
  -H "Content-Type: application/json" \
  -H "kbn-xsrf: true" \
  -d '{"index_pattern": {"title": "orderservice-*", "timeFieldName": "@timestamp"}}'

Integração com OpenTelemetry

// Serilog + OpenTelemetry: TraceId/SpanId em todos os logs
// Isso correlaciona logs com traces no Jaeger/Grafana Tempo

dotnet add package Serilog.Enrichers.Span
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore

// Program.cs
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddOtlpExporter()); // Exporta para Jaeger/Grafana Tempo

// No Serilog:
.Enrich.WithSpan() // Adiciona TraceId e SpanId de cada span ativo

// Resultado: cada log terá
// "TraceId": "4bf92f3577b34da6a3ce929d0e0e4736",
// "SpanId": "00f067aa0ba902b7"
// Que você pode cruzar com traces no Jaeger!

Checklist de logging para produção

  • Nunca interpolação de string — use message templates com propriedades nomeadas
  • MinimumLevel por namespace — silenciar Microsoft e System em produção
  • Bootstrap logger — captura erros antes do DI estar configurado
  • CloseAndFlushAsync no finally — garante que logs não são perdidos no shutdown
  • Ignorar health checks — evita poluir logs com 100 requisições/segundo de probes
  • CorrelationId em todo log — rastreabilidade entre serviços
  • Não logar dados sensíveis — senhas, tokens, CPF, cartão de crédito
  • Destruturing controlado@ para objetos, mas com cuidado no tamanho

Observabilidade é o que separa times que reagem a problemas em produção dos que os antecipam. Se você precisa de uma estratégia de logging e observabilidade para sua stack .NET, fale com a Neryx.

Quer sair do modo reativo e priorizar o que mais importa?

O diagnóstico de maturidade ajuda a transformar sintomas operacionais em um plano mais claro de evolução.

Avaliar maturidade

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.