"2024-01-15 14:32:01 ERROR Something went wrong". Що сталося? З яким замовленням? Який користувач? Яка IP-адреса? Текстові логи — це сліпота. Serilog пропонує структуроване логування: кожен лог — це JSON-документ, де кожне поле — окрема колонка для фільтрації та пошуку.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'.
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
Мінімальна конфігурація 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(); // Обов'язково! Завершує запис буферизованих логів
}
Найкращий підхід — конфігурувати 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).
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, ...) | Критична помилка, що зупинила додаток. |
Ключова особливість 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 отримає вже готовий рядок і не зможе розібрати структуру.
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.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();
}
}
Для додавання специфічного контексту до всіх запитів:
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));
.WriteTo.Console(
outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] " +
"{Message:lj} " +
"{Properties:j}" +
"{NewLine}{Exception}",
theme: AnsiConsoleTheme.Code)
.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))
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 для фільтрації логів по будь-якому полю:
// 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
})
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);
};
});
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 підхід.