Структуроване логування з Serilog в ASP.NET Core
Структуроване логування з 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
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Seq
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
dotnet add package Serilog.Enrichers.Process
Конфігурація в Program.cs
Мінімальна конфігурація Serilog у Program.cs:
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, а не в коді. Це дозволяє змінювати рівні логування без перебілду:
{
"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"
}
}
}
{
"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 рівнів у порядку зростання критичності:
| Рівень | Метод | Коли використовувати |
|---|---|---|
Verbose | Log.Verbose(...) | Дуже деталізований debugging. Завжди вимкнений у production. |
Debug | Log.Debug(...) | Деталі для debugging: значення змінних, умови. Вимкнений у production. |
Information | Log.Information(...) | Нормальний хід виконання: запит прийнято, замовлення створено. |
Warning | Log.Warning(...) | Підозріла ситуація: дублікований запит, deprecated API. |
Error | Log.Error(ex, ...) | Помилка, що не зупинила виконання: помилка зовнішнього API. |
Fatal | Log.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
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):
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
Для додавання специфічного контексту до всіх запитів:
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));
}
}
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 (розробка)
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] " +
"{Message:lj} " +
"{Properties:j}" +
"{NewLine}{Exception}",
theme: AnsiConsoleTheme.Code)
Sink: File (файловий журнал)
.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 — безкоштовний структурований логгер з веб-інтерфейсом для розробки та невеликих команд:
{
"Serilog": {
"WriteTo": [
{
"Name": "Seq",
"Args": {
"serverUrl": "http://localhost:5341",
"apiKey": "опціонально",
"batchPostingLimit": 1000,
"period": "0:0:2"
}
}
]
}
}
docker run --name seq -d --restart unless-stopped \
-e ACCEPT_EULA=Y \
-p 5341:5341 \
-p 80:80 \
datalust/seq:latest
Seq надає потужний UI для фільтрації логів по будь-якому полю:
Sink: 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"
}
Можна кастомізувати:
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. Структуроване логування у сервісах
Правила хорошого логування
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; // Прокидаємо вгору — це не бізнес-помилка
}
}
}
Практичні завдання
Завдання 1.1. Налаштуйте Serilog у новому ASP.NET Core проєкті: Console sink для Debug-рівня (Development), File sink для Information-рівня (Production). Додайте Enrichers: WithMachineName, WithThreadId, FromLogContext.
Завдання 1.2. Використайте LogContext.PushProperty() у контролері для додавання RequestId до всіх логів у межах одного запиту.
Завдання 2.1. Запустіть Seq у Docker. Додайте Seq sink до конфігурації. Реалізуйте сервіс UserService з трьома методами, де кожен логує структуровані дані з різними рівнями (Info, Warning, Error).
Завдання 2.2. Налаштуйте UseSerilogRequestLogging() так, щоб запити зі статусом > 400 логувались як Warning, а > 500 — як Error. Додайте поле UserId до request-логів для аутентифікованих запитів.
Завдання 3.1. Реалізуйте CorrelationIdEnricher, що читає заголовок X-Correlation-ID з поточного HTTP-запиту та додає його до всіх логів. Якщо заголовок відсутній — генерує новий Guid. Також додайте цей CorrelationId до заголовків HTTP-відповіді.
Завдання 3.2. Напишіть тест для кастомного Enricher, використовуючи SerilogLoggerFactory та TestCorrelationLogger.
Резюме
Serilog трансформує логування з пошуку по тексту у структурований пошук по даних:
Structured Logging
Enrichers
Sinks
appsettings.json
Посилання:
Обробка помилок з ErrorOr та Result Pattern в ASP.NET Core
Result Pattern та бібліотека ErrorOr: типи успіху та помилок, Match/Switch, інтеграція з Minimal API та Problem Details, без виключень для бізнес-логіки.
CQRS та Mediator з MediatR в ASP.NET Core
Повний огляд MediatR: патерн Mediator, CQRS, Requests та Handlers, Pipeline Behaviors для валідації та логування, Notifications та event-driven підхід.