Популярні бібліотеки

Структуроване логування з Serilog в ASP.NET Core

Глибокий огляд Serilog: structured logging, enrichers, sinks (File, Seq, Elasticsearch), конфігурація через appsettings.json та інтеграція з ASP.NET Core pipeline.

Структуроване логування з Serilog в ASP.NET Core

Уявіть: сервер в продакшені веде себе дивно. Ви відкриваєте логи і бачите: "2024-01-15 14:32:01 ERROR Something went wrong". Що сталося? З яким замовленням? Який користувач? Яка IP-адреса? Текстові логи — це сліпота. Serilog пропонує структуроване логування: кожен лог — це JSON-документ, де кожне поле — окрема колонка для фільтрації та пошуку.

1. Проблема текстового логування

Чому Console.WriteLine та ILogger замало?

Стандартний ILogger у ASP.NET Core підтримує базове логування, але з ним все одно важко відповісти на конкретні питання без пошуку по тексту:

// Стандартне текстове логування
[14:32:01] INFO  Processing order for user john@example.com
[14:32:02] ERROR Failed to process payment: timeout after 30s
[14:32:02] INFO  Order 12345 failed

Щоб знайти всі замовлення користувача john@example.com — потрібен grep. Щоб порахувати кількість помилок за останню годину — ручний підрахунок або складний regex. Щоб фільтрувати за типом помилки — неможливо без парсингу тексту.

Structured Logging (структуроване логування) зберігає кожне повідомлення як документ з типізованими полями:

{
  "@t": "2024-01-15T14:32:02.123Z",
  "@mt": "Failed to process payment for order {OrderId}: {Reason}",
  "@l": "Error",
  "OrderId": 12345,
  "Reason": "Timeout after 30s",
  "UserId": 42,
  "UserEmail": "john@example.com",
  "MachineName": "prod-server-01",
  "Application": "OrdersApi"
}

Тепер будь-яке питання — це запит: WHERE OrderId = 12345, або WHERE UserEmail = '...' AND Level = 'Error'.


2. Встановлення Serilog

Пакети для ASP.NET Core

Встановлення пакетів

Serilog складається з основного пакету та окремих «sink»-пакетів для різних цілей:

dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Settings.Configuration

Конфігурація в Program.cs

Мінімальна конфігурація Serilog у Program.cs:

Program.cs — базова конфігурація Serilog
using Serilog;

// Налаштовуємо Serilog ДО побудови хоста
// щоб логувати навіть помилки старту
Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateBootstrapLogger();   // Тимчасовий логер для старту

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

    // Замінюємо вбудований ILogger на Serilog
    builder.Host.UseSerilog((context, services, configuration) =>
        configuration
            .ReadFrom.Configuration(context.Configuration)  // з appsettings.json
            .ReadFrom.Services(services)                    // DI сервіси
            .Enrich.FromLogContext());

    // ... реєстрація сервісів

    var app = builder.Build();

    // Middleware для логування HTTP-запитів
    app.UseSerilogRequestLogging();

    app.Run();
}
catch (Exception ex)
{
    Log.Fatal(ex, "Додаток аварійно завершився під час запуску.");
}
finally
{
    Log.CloseAndFlush();  // Обов'язково! Завершує запис буферизованих логів
}

3. Конфігурація через appsettings.json

Найкращий підхід — конфігурувати Serilog через appsettings.json, а не в коді. Це дозволяє змінювати рівні логування без перебілду:

appsettings.json — повна конфігурація Serilog
{
  "Serilog": {
    "Using": [
      "Serilog.Sinks.Console",
      "Serilog.Sinks.File"
    ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft":               "Warning",
        "Microsoft.EntityFrameworkCore": "Warning",
        "System":                  "Warning",
        "Microsoft.AspNetCore.Hosting": "Information"
      }
    },
    "WriteTo": [
      {
        "Name": "Console",
        "Args": {
          "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
          "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
        }
      },
      {
        "Name": "File",
        "Args": {
          "path":            "logs/app-.log",
          "rollingInterval": "Day",
          "retainedFileCountLimit": 7,
          "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
        }
      }
    ],
    "Enrich": [
      "FromLogContext",
      "WithMachineName",
      "WithThreadId",
      "WithProcessId"
    ],
    "Properties": {
      "Application": "MyApp",
      "Environment": "Production"
    }
  }
}
appsettings.Development.json — перевизначення для локального середовища
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Debug",
      "Override": {
        "Microsoft.EntityFrameworkCore.Database.Command": "Information"
      }
    },
    "WriteTo": [
      {
        "Name": "Console"
      },
      {
        "Name": "Seq",
        "Args": {
          "serverUrl": "http://localhost:5341"
        }
      }
    ]
  }
}

Секція Override — критично важлива. Вона дозволяє приглушити «шумні» системні неймспейси (наприклад, Microsoft.EntityFrameworkCore логує кожен SQL-запит на рівні Debug).


4. Рівні логування

Serilog підтримує 6 рівнів у порядку зростання критичності:

РівеньМетодКоли використовувати
VerboseLog.Verbose(...)Дуже деталізований debugging. Завжди вимкнений у production.
DebugLog.Debug(...)Деталі для debugging: значення змінних, умови. Вимкнений у production.
InformationLog.Information(...)Нормальний хід виконання: запит прийнято, замовлення створено.
WarningLog.Warning(...)Підозріла ситуація: дублікований запит, deprecated API.
ErrorLog.Error(ex, ...)Помилка, що не зупинила виконання: помилка зовнішнього API.
FatalLog.Fatal(ex, ...)Критична помилка, що зупинила додаток.

Структуровані повідомлення: Message Templates

Ключова особливість Serilog — Message Templates (шаблони повідомлень). Фігурні дужки {Name} — це не форматування рядка, а іменовані властивості:

Правильне та неправильне логування
// ❌ НЕПРАВИЛЬНО: інтерполяція рядка — втрачаємо структуру
_logger.LogInformation($"Order {orderId} created for user {userId}");

// ✅ ПРАВИЛЬНО: Message Template — зберігаємо структуру
_logger.LogInformation(
    "Order {OrderId} created for user {UserId}",
    orderId, userId);

// Результат у Seq/Elasticsearch:
// { "OrderId": 12345, "UserId": 42, "Message": "Order 12345 created for user 42" }
// Тепер можна фільтрувати: OrderId = 12345

Різниця критична: при інтерполяції $"..." Serilog отримає вже готовий рядок і не зможе розібрати структуру.


5. Enrichers: автоматичний контекст

Enrichers (збагачувачі) автоматично додають властивості до кожного лог-запису без необхідності передавати їх вручну:

Вбудовані Enrichers

Program.cs — налаштування Enrichers в коді
builder.Host.UseSerilog((ctx, cfg) => cfg
    .Enrich.FromLogContext()       // Дані з LogContext.PushProperty() 
    .Enrich.WithMachineName()     // Ім'я сервера: { MachineName: "prod-01" }
    .Enrich.WithThreadId()        // ID потоку: { ThreadId: 8 }
    .Enrich.WithProcessId()       // PID процесу: { ProcessId: 4521 }
    .Enrich.WithEnvironmentName() // { EnvironmentName: "Production" }
    .Enrich.WithProperty("Application", "OrdersApi")  // Фіксована властивість
    .Enrich.WithProperty("Version", "2.1.0")
    .ReadFrom.Configuration(ctx.Configuration));

Результат: кожен лог-запис автоматично отримує:

{
  "MachineName": "prod-server-01",
  "ThreadId": 8,
  "ProcessId": 4521,
  "Application": "OrdersApi",
  "Version": "2.1.0"
}

LogContext: динамічний контекст

LogContext.PushProperty() дозволяє тимчасово додавати властивості у поточний контекст (Scope):

Services/OrderService.cs — LogContext
public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public OrderService(ILogger<OrderService> logger) => _logger = logger;

    public async Task<Order> ProcessOrderAsync(CreateOrderCommand cmd)
    {
        // Додаємо OrderId до всіх логів у цьому блоці
        using (LogContext.PushProperty("OrderId",   cmd.OrderId))
        using (LogContext.PushProperty("CustomerId", cmd.CustomerId))
        {
            _logger.LogInformation("Починаємо обробку замовлення.");

            var paymentResult = await ProcessPaymentAsync(cmd);

            if (paymentResult.IsSuccess)
                _logger.LogInformation("Оплату підтверджено.");
            else
                _logger.LogWarning("Оплату відхилено: {Reason}", paymentResult.Reason);

            // Усі логи вище матимуть OrderId та CustomerId
        }
        // Тут властивості вже не додаються

        return new Order();
    }
}

Кастомний Enricher

Для додавання специфічного контексту до всіх запитів:

Enrichers/RequestContextEnricher.cs
using Serilog.Core;
using Serilog.Events;

public class RequestContextEnricher : ILogEventEnricher
{
    private readonly IHttpContextAccessor _httpContextAccessor;

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

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

        // Додаємо TraceId для кореляції запитів
        logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
            "TraceId", httpContext.TraceIdentifier));

        // IP-адреса запиту
        logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
            "ClientIp", httpContext.Connection.RemoteIpAddress?.ToString()));

        // ID аутентифікованого користувача
        var userId = httpContext.User?.FindFirst("sub")?.Value;
        if (userId is not null)
            logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(
                "UserId", userId));
    }
}
Program.cs — реєстрація кастомного Enricher
builder.Services.AddHttpContextAccessor();

builder.Host.UseSerilog((ctx, services, cfg) => cfg
    .Enrich.With(new RequestContextEnricher(
        services.GetRequiredService<IHttpContextAccessor>()))
    .ReadFrom.Configuration(ctx.Configuration));

6. Sinks: куди відправляти логи

Sink: Console (розробка)

Налаштування Console Sink
.WriteTo.Console(
    outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] " +
                    "{Message:lj} " +
                    "{Properties:j}" +
                    "{NewLine}{Exception}",
    theme: AnsiConsoleTheme.Code)

Sink: File (файловий журнал)

Налаштування File Sink
.WriteTo.File(
    path:             "logs/app-.log",
    rollingInterval:  RollingInterval.Day,    // Новий файл щодня
    retainedFileCountLimit: 30,               // Зберігати лише 30 файлів
    fileSizeLimitBytes: 10 * 1024 * 1024,     // Максимум 10 MB на файл
    rollOnFileSizeLimit: true,                // Новий файл при досягненні ліміту
    shared: true,                             // Безпечно для кількох процесів
    flushToDiskInterval: TimeSpan.FromSeconds(1))

Sink: Seq (локальна аналітика)

Seq — безкоштовний структурований логгер з веб-інтерфейсом для розробки та невеликих команд:

appsettings.Development.json — Seq
{
  "Serilog": {
    "WriteTo": [
      {
        "Name": "Seq",
        "Args": {
          "serverUrl": "http://localhost:5341",
          "apiKey": "опціонально",
          "batchPostingLimit": 1000,
          "period": "0:0:2"
        }
      }
    ]
  }
}
Запуск Seq у Docker
docker run --name seq -d --restart unless-stopped \
    -e ACCEPT_EULA=Y \
    -p 5341:5341 \
    -p 80:80 \
    datalust/seq:latest

Seq надає потужний UI для фільтрації логів по будь-якому полю:

Seq Query Language
# Всі помилки за останню годину
@Level = 'Error' and @Timestamp > Now() - 1h
# Всі запити конкретного користувача
UserId = 42
# Повільні запити (> 500ms)
ElapsedMilliseconds > 500

Sink: Elasticsearch (великі обсяги)

Налаштування Elasticsearch
// dotnet add package Serilog.Sinks.Elasticsearch
.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(
    new Uri("http://localhost:9200"))
{
    IndexFormat             = "app-logs-{0:yyyy.MM}",
    AutoRegisterTemplate    = true,
    OverwriteTemplate       = true,
    DetectElasticsearchVersion = true,
    NumberOfReplicas        = 1,
    NumberOfShards          = 2
})

7. Request Logging Middleware

app.UseSerilogRequestLogging() автоматично логує кожен HTTP-запит зі структурованими полями:

{
  "@t": "2024-01-15T14:32:01Z",
  "@mt": "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms",
  "RequestMethod": "POST",
  "RequestPath": "/api/orders",
  "StatusCode": 201,
  "Elapsed": 45.3,
  "SourceContext": "Serilog.AspNetCore.RequestLoggingMiddleware"
}

Можна кастомізувати:

Program.cs — кастомний Request Logging
app.UseSerilogRequestLogging(options =>
{
    // Рівень логування залежно від статус-коду
    options.GetLevel = (httpContext, elapsed, ex) =>
        ex is not null || httpContext.Response.StatusCode > 499
            ? LogEventLevel.Error
            : httpContext.Response.StatusCode > 399
                ? LogEventLevel.Warning
                : LogEventLevel.Information;

    // Додаємо кастомні властивості до кожного request-лога
    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {
        diagnosticContext.Set("RequestHost",   httpContext.Request.Host.Value);
        diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
        diagnosticContext.Set("UserAgent",
            httpContext.Request.Headers.UserAgent.ToString());

        if (httpContext.User.Identity?.IsAuthenticated == true)
            diagnosticContext.Set("UserId",
                httpContext.User.FindFirst("sub")?.Value);
    };
});

8. Структуроване логування у сервісах

Правила хорошого логування

Services/PaymentService.cs — приклад правильного логування
public class PaymentService
{
    private readonly ILogger<PaymentService> _logger;
    private readonly IPaymentGateway         _gateway;

    public PaymentService(
        ILogger<PaymentService> logger,
        IPaymentGateway gateway)
    {
        _logger  = logger;
        _gateway = gateway;
    }

    public async Task<PaymentResult> ProcessAsync(PaymentRequest request)
    {
        // ✅ Information: важливі бізнес-події
        _logger.LogInformation(
            "Починаємо обробку платежу {PaymentId} для замовлення {OrderId}. " +
            "Сума: {Amount} {Currency}",
            request.PaymentId, request.OrderId,
            request.Amount, request.Currency);

        try
        {
            var result = await _gateway.ChargeAsync(request);

            if (result.IsSuccess)
            {
                // ✅ Information: успішна операція
                _logger.LogInformation(
                    "Платіж {PaymentId} успішно оброблено. " +
                    "TransactionId: {TransactionId}",
                    request.PaymentId, result.TransactionId);
            }
            else
            {
                // ✅ Warning: бізнес-відмова (не помилка системи)
                _logger.LogWarning(
                    "Платіж {PaymentId} відхилено. " +
                    "Код: {DeclineCode}. Причина: {Reason}",
                    request.PaymentId, result.DeclineCode, result.Reason);
            }

            return result;
        }
        catch (HttpRequestException ex)
        {
            // ✅ Error: системна помилка (зовнішній сервіс недоступний)
            _logger.LogError(ex,
                "Помилка з'єднання з платіжним шлюзом для {PaymentId}. " +
                "Endpoint: {GatewayUrl}",
                request.PaymentId, _gateway.BaseUrl);

            throw; // Прокидаємо вгору — це не бізнес-помилка
        }
    }
}

Практичні завдання


Резюме

Serilog трансформує логування з пошуку по тексту у структурований пошук по даних:

Structured Logging

Кожен лог — JSON-документ з іменованими полями. Фільтрація та агрегація без regex.

Enrichers

Автоматичний контекст (UserId, TraceId, MachineName) до кожного запису без ручної передачі.

Sinks

Гнучка маршрутизація: Console для розробки, File для архіву, Seq/Elasticsearch для аналітики.

appsettings.json

Зміна рівнів логування без перебілду — через конфігурацію або навіть у runtime.

Посилання: