Конфігурація й логування
Звідки застосунок ASP.NET Core бере налаштування, де тримати секрети й як перевірити конфігурацію під час старту, як писати структурні логи й пов’язувати їх із трейсами та метриками. На цій темі перевіряють не лише API, а й досвід експлуатації: що ви робите, коли в продакшені «нічого не видно».
Перед цією темою: Впровадження залежностей.
Як влаштована конфігурація: джерела, їхній порядок і ключі Section:Key?
Section titled “Як влаштована конфігурація: джерела, їхній порядок і ключі Section:Key?”Що це
Конфігурація — словник рядків «ключ — значення». Застосунок читає його через IConfiguration, а наповнюють його провайдери, по одному на джерело.
Джерела за замовчуванням
WebApplication.CreateBuilder підключає їх у такому порядку, і кожне наступне перекриває попередні:
appsettings.json— спільні налаштування.appsettings.{Environment}.json— відмінності середовища.- User Secrets — лише в Development.
- Змінні середовища.
- Аргументи командного рядка.
Як користуватися
- Вкладеність JSON перетворюється на ключ через двокрапку:
config["Payments:TimeoutSeconds"]. - У змінній середовища двокрапку замінюють на
__:Payments__TimeoutSeconds=60. GetValue<int>(...)приводить рядок до типу,GetConnectionString("Default")читає ключConnectionStrings:Default.
Типова помилка
Друкарська помилка в ключі: config["Payment:Timeout"] поверне null без винятку.
Як влаштовано
builder.Configuration— цеConfigurationManager(.NET 6+): водночас будівник і готова конфігурація, читати її можна просто вProgram.cs.- Кожен провайдер завантажує свій словник. Читання ключа йде від останнього провайдера до першого до першого збігу.
- Ключі нечутливі до регістру. Елементи масиву — це ключі з індексами:
Payments:Currencies:0. - Командний рядок розуміє
--Key=value,--Key value,/Key valueіKey=value.
Коли що обирати
| Що налаштовуємо | Де зберігати |
|---|---|
| Несекретні значення за замовчуванням | appsettings.json у репозиторії |
| Відмінності стенда | appsettings.{Environment}.json або змінні середовища |
| Значення конкретного деплою, секрети | змінні середовища, сховище секретів |
| Разовий запуск, налагодження | аргументи командного рядка |
Пастки
- Власне джерело після
CreateBuilder.builder.Configuration.AddJsonFile(...)стає в кінець списку й перекриває змінні середовища та командний рядок. - Масиви зливаються за індексами. Якщо в базовому файлі три елементи, а у файлі середовища два, третій залишиться з базового.
Правило
Після власного джерела повторно викличте AddEnvironmentVariables() і AddCommandLine(args), щоб деплой і далі міг усе перекрити.
Під капотом
ConfigurationManagerбудує провайдер одразу під час додавання джерела. Тому значення доступні ще доBuild().- Провайдер — словник рядків без урахування регістру. Типи з’являються лише під час прив’язки:
ConfigurationBinderрозбирає рядки черезTypeConverterі рефлексію. - Файлові провайдери з
reloadOnChange: trueстежать за файлом, перечитують його й сигналізують черезIChangeToken. Шаблон за замовчуванням вмикає це дляappsettings*.json.
Ціна
- Читання за ключем — прохід провайдерами з кінця. У гарячому коді значення один раз прив’язують до об’єкта через Options, а не читають
config["..."]на кожен запит. - У контейнерах і на мережевих дисках події файлової системи ненадійні. Тоді вмикають
DOTNET_USE_POLLING_FILE_WATCHER=true: опитування раз на чотири секунди.
Компроміси
- У .NET 10
nullу JSON прив’язується якnull, а порожній масив[]— як порожній масив. Ранішеnullперетворювався на порожній рядок, і під час міграції поведінка змінюється. - Прив’язка через рефлексію дає попередження trimming і несумісна з Native AOT. Генератор прив’язки (.NET 8+) вмикають властивістю
EnableConfigurationBindingGenerator.
Як перевірити в продакшені
((IConfigurationRoot)config).GetDebugView() показує для кожного ключа значення й провайдер, з якого воно прийшло. Перевантаження з processValue маскує секрети; без нього не виводьте результат назовні.
Підсумок: конфігурація — стек словників рядків, і порядок провайдерів разом із моментом прив’язки визначає, що побачить код.
{ "Payments": { "TimeoutSeconds": 30, "Currencies": [ "UAH", "EUR" ] }, "ConnectionStrings": { "Default": "Host=localhost;Database=shop" }}var builder = WebApplication.CreateBuilder(args);
// Значення вже зібрані з усіх джерел, читати можна до Build()int timeout = builder.Configuration.GetValue<int>("Payments:TimeoutSeconds");
var app = builder.Build();
app.MapGet("/config", (IConfiguration config) => new{ Timeout = timeout, // 30, а з Payments__TimeoutSeconds=60 — 60 FirstCurrency = config["Payments:Currencies:0"], // "UAH" HasDb = config.GetConnectionString("Default") is not null, // ключ ConnectionStrings:Default Typo = config["Payment:Timeout"] // null: друкарська помилка, винятку немає});
app.Run();Запуск dotnet run --Payments:TimeoutSeconds=90 дасть 90 навіть за заданої змінної середовища: командний рядок підключено останнім.
- Що переможе:
appsettings.Production.jsonчи змінна середовища? — Змінна середовища: її провайдер додано пізніше. - Чому в змінних середовища пишуть
__, а не:? — Bash і частина платформ не приймають:в іменах;__перетворюється на:під час читання. - Що буде, якщо додати власний JSON-файл після
CreateBuilder? — Він опиниться останнім і перекриє навіть командний рядок. - Як перевизначити другий елемент масиву із середовища? — Ключем з індексом:
Payments__Currencies__1=USD. - Як дізнатися, звідки прийшло значення? —
GetDebugView()вIConfigurationRootпоказує провайдер кожного ключа.
Як працюють середовища й де зберігати секрети?
Section titled “Як працюють середовища й де зберігати секрети?”Що це
Середовище — рядок, який застосунок читає під час старту. Стандартні значення — Development, Staging і Production; якщо нічого не задано, це Production.
Як користуватися
- Локально середовище задає
launchSettings.json, на сервері — зміннаASPNETCORE_ENVIRONMENT. - У коді перевіряють
builder.Environment.IsDevelopment()абоapp.Environment.IsProduction(). - Налаштування стенда кладуть в
appsettings.{Environment}.json.
Де зберігати секрети
| Де | Чим |
|---|---|
| Машина розробника | User Secrets: dotnet user-secrets set |
| Сервер, контейнер | змінні середовища, секрети оркестратора |
| Хмара | Azure Key Vault, AWS Secrets Manager, HashiCorp Vault |
Типова помилка
Покласти пароль в appsettings.Development.json і закомітити. Усе, що лежить у репозиторії, вважайте публічним.
Як влаштовано
- Ім’я середовища береться з
DOTNET_ENVIRONMENTабоASPNETCORE_ENVIRONMENT, з аргументу--environmentчи зWebApplicationOptions.EnvironmentName. УWebApplicationу .NET 7+ за обох змінних перемагаєDOTNET_ENVIRONMENT. - Середовище фіксується під час побудови хоста й під час роботи не змінюється.
- У Development
CreateBuilderпідключає User Secrets, аWebApplicationсам показує сторінку винятків для розробника. - User Secrets лежать у
secrets.jsonу профілі користувача:%APPDATA%\Microsoft\UserSecrets\<id>у Windows,~/.microsoft/usersecrets/<id>у Linux і macOS.<id>— цеUserSecretsIdз.csproj, його додаєdotnet user-secrets init.
Коли що обирати
| Варіант | Коли |
|---|---|
| User Secrets | лише локальна розробка, файл не шифрується |
| Змінні середовища | простий деплой, контейнери |
Файли секретів (AddKeyPerFile) |
секрети Docker і Kubernetes, змонтовані в каталог |
| Key Vault та аналоги | хмара: managed identity, аудит, ротація |
Пастки
- Регістр імені файлу. У Linux файлова система чутлива до регістру: за середовища
Productionфайлappsettings.production.jsonне підхопиться. launchSettings.jsonне публікується. На сервері без змінної середовище мовчки станеProduction.
Правило
У репозиторії — лише несекретні значення за замовчуванням. Усе, що відрізняється між стендами або є секретним, надходить ззовні.
Під капотом
- Ім’я середовища — ключ конфігурації хоста
environment. Його задають змінніDOTNET_іASPNETCORE_або--environment, але неappsettings.json: файли читаються вже після вибору середовища. - Провайдер Key Vault (
Azure.Extensions.AspNetCore.Configuration.Secrets) завантажує секрети під час старту й замінює--в імені на:: секретPayments--ApiKeyстане ключемPayments:ApiKey. Перечитує він їх, лише якщо заданоReloadInterval. AddKeyPerFileперетворює кожен файл каталогу на ключ, а__в імені файлу — на:.
Ціна
- Змінні середовища доступні всім, хто бачить процес: вони потрапляють у дампи, у
/procу Linux і вdocker inspect. - Сховище — мережевий виклик під час старту й зовнішня залежність: недоступний Key Vault означає, що застосунок не стартує.
Компроміси
- Кожен
if (env.IsProduction())— код, який уперше виконається просто в продакшені. Відмінності виносять у конфігурацію, а не в розгалуження. DefaultAzureCredentialзручний: локально бере обліковий запис розробника, в Azure — managed identity. Для продакшену документація радить конкретнийManagedIdentityCredential: менше спроб і передбачуваніша відмова.- Найкращий секрет — відсутній: managed identity для бази даних і хмарних сервісів прибирає пароль з конфігурації зовсім.
Як перевірити в продакшені
- Під час старту хост пише в лог
Hosting environment: Production— це перше, що дивляться за «дивних» налаштувань. - Сканер секретів у CI, наприклад gitleaks або secret scanning у GitHub, ловить паролі до злиття.
Підсумок: надійний секрет — той, якого немає в конфігурації: ідентичність замість пароля, сховище замість файлу.
var builder = WebApplication.CreateBuilder(args);
// Секрети Docker/Kubernetes: файл /run/secrets/Payments__ApiKey стане ключем Payments:ApiKey.// Джерело додано останнім, тому воно перекриває й змінні середовища.builder.Configuration.AddKeyPerFile("/run/secrets", optional: true);
var app = builder.Build();
if (!app.Environment.IsDevelopment()){ app.UseExceptionHandler("/error"); // у Development замість нього — сторінка винятків app.UseHsts();}
app.MapGet("/env", (IHostEnvironment env, IConfiguration config) => new{ env.EnvironmentName, // "Development" HasApiKey = !string.IsNullOrEmpty(config["Payments:ApiKey"]) // сам секрет не виводимо});
app.Run();# Локально: секрет зберігається поза репозиторіємdotnet user-secrets initdotnet user-secrets set "Payments:ApiKey" "dev-key"
# Сервер або контейнер: двокрапку замінюють на __export ASPNETCORE_ENVIRONMENT=Stagingexport Payments__ApiKey="..."- Яке середовище буде, якщо нічого не задати? —
Production. - Чи шифруються User Secrets? — Ні, це звичайний JSON у профілі користувача, лише для розробки.
- Чим поганий секрет у змінній середовища? — Його видно в дампах і метаданих контейнера, немає аудиту й ротації.
- Як Key Vault зберігає ієрархічні ключі? — Через
--:Payments--ApiKeyперетворюється наPayments:ApiKey. - Що робити із секретом, який потрапив у git? — Відкликати й перевипустити: переписування історії не рятує.
Чим відрізняються IOptions, IOptionsSnapshot і IOptionsMonitor і як валідувати налаштування?
Section titled “Чим відрізняються IOptions, IOptionsSnapshot і IOptionsMonitor і як валідувати налаштування?”Що це
Options pattern — спосіб читати налаштування не рядками за ключем, а типізованим класом. Секція Payments з appsettings.json прив’язується до класу PaymentsOptions, і сервіс отримує його через DI.
Як користуватися
- Опишіть клас із властивостями
{ get; set; }. - Зареєструйте його:
builder.Services.Configure<PaymentsOptions>(builder.Configuration.GetSection("Payments")). - Впровадьте
IOptions<PaymentsOptions>і читайте.Value.
Який інтерфейс брати
| Інтерфейс | Коли бачить нові значення |
|---|---|
IOptions<T> |
ніколи, прочитаний один раз |
IOptionsSnapshot<T> |
на наступному запиті |
IOptionsMonitor<T> |
одразу після зміни файлу |
Типова помилка
Чекати, що IOptions<T> побачить правку appsettings.json без перезапуску. Для цього потрібен snapshot або monitor.
Як влаштовано
Configure<T>іAddOptions<T>().BindConfiguration("Payments")реєструють не сам об’єкт, а спосіб його налаштувати.- Під час першого звернення
IOptionsFactory<T>створює екземпляр і застосовує всіConfigure, потімPostConfigure, потім валідатори. - Результат кешується: в
IOptions<T>назавжди, вIOptionsSnapshot<T>— на scope запиту, вIOptionsMonitor<T>— до зміни конфігурації.
Коли що обирати
| Інтерфейс | Час життя | Кому підходить |
|---|---|---|
IOptions<T> |
singleton | усім, якщо значення не змінюються без рестарту |
IOptionsSnapshot<T> |
scoped | обробникам запиту; є іменовані опції |
IOptionsMonitor<T> |
singleton | singleton-сервісам і реакції через OnChange |
Валідація
ValidateDataAnnotations()— атрибути[Required],[Range],[Url].Validate(o => ..., "message")— правило лямбдою, наприклад зв’язок двох полів.IValidateOptions<T>— окремий клас, якщо правилу потрібні сервіси.ValidateOnStart()(.NET 6+) — перевірка під час запуску, а не на першому запиті.
Пастки
IOptionsSnapshot<T>— scoped-сервіс, у singleton його не впровадити.- Вкладені об’єкти атрибутами не перевіряються без
[ValidateObjectMembers]і[ValidateEnumeratedItems](.NET 8+). - Друкарська помилка в імені секції — не помилка: прив’яжеться об’єкт зі значеннями за замовчуванням. Упіймає її лише
[Required].
Правило
Для кожної секції: AddOptions<T>().BindConfiguration(...).ValidateDataAnnotations().ValidateOnStart().
Під капотом
OptionsMonitor<T>підписаний на change token конфігурації. Під час перезавантаження він видаляє екземпляр із кешу, створює новий через фабрику й викликає підписниківOnChange.- Якщо новий екземпляр невалідний, старе значення не зберігається:
CurrentValueкидаєOptionsValidationExceptionпід час кожного звернення, доки файл не виправлять. IOptionsSnapshot<T>створює екземпляр заново в кожному scope: прив’язка через рефлексію й валідація виконуються на кожен запит.
Ціна
- Snapshot — зайві алокації й CPU на кожен запит. У гарячому шляху його замінюють на monitor і читають
CurrentValue. ValidateDataAnnotationsі прив’язка працюють через рефлексію: вони повільніші й дають попередження під час trimming і Native AOT.
Компроміси
[OptionsValidator]над порожнімpartial-класом генеруєIValidateOptions<T>під час компіляції. Його реєструють як singleton, іValidateDataAnnotationsбільше не потрібен.- Перезавантаження на льоту зручне для рівнів логів і прапорців. Для рядка підключення чи розміру пулу воно небезпечне: об’єкт, створений під час старту, старе значення не забуде.
- Валідація під час старту робить деплой «гучним»: процес не підніметься, але й не віддаватиме 500 на кожному запиті.
Як перевірити в продакшені
З ValidateOnStart невалідна конфігурація валить хост під час запуску з OptionsValidationException і повним списком помилок. Це видно в логах деплою та в проваленій readiness-пробі.
Підсумок: monitor — для перезавантажуваних налаштувань, генератор — для валідації без рефлексії, ValidateOnStart — щоб помилка виявилася під час деплою.
using System.ComponentModel.DataAnnotations;using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<PaymentsOptions>() .BindConfiguration("Payments") .ValidateDataAnnotations() .Validate(o => o.TimeoutSeconds < 120, "TimeoutSeconds must be less than 120") .ValidateOnStart(); // помилка виявиться під час старту, а не на першому запиті
var app = builder.Build();
app.MapGet("/snapshot", (IOptionsSnapshot<PaymentsOptions> o) => o.Value.BaseUrl); // перераховано для цього запитуapp.MapGet("/monitor", (IOptionsMonitor<PaymentsOptions> o) => o.CurrentValue.TimeoutSeconds); // поточне значення
app.Run();
public sealed class PaymentsOptions{ [Required, Url] public string BaseUrl { get; set; } = "";
[Range(1, 300)] public int TimeoutSeconds { get; set; } = 30;}using System.ComponentModel.DataAnnotations;using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOptions<PaymentsOptions>().BindConfiguration("Payments").ValidateOnStart();// Замість ValidateDataAnnotations(): валідатор згенеровано під час компіляції, без рефлексіїbuilder.Services.AddSingleton<IValidateOptions<PaymentsOptions>, PaymentsOptionsValidator>();
var app = builder.Build();app.Run();
[OptionsValidator]public partial class PaymentsOptionsValidator : IValidateOptions<PaymentsOptions> { }
public sealed class PaymentsOptions{ [Required, Url] public string BaseUrl { get; set; } = "";}- Чи можна впровадити
IOptionsSnapshot<T>у singleton? — Ні, він scoped; у singleton берутьIOptionsMonitor<T>. - Коли спрацьовує валідація без
ValidateOnStart? — Під час першого звернення до.ValueабоCurrentValue. - Що таке іменовані опції? — Кілька екземплярів одного типу під різними іменами:
Get("Primary")у snapshot або monitor. - Чи перевіряються вкладені об’єкти атрибутами? — Ні, потрібні
[ValidateObjectMembers]і[ValidateEnumeratedItems]. - Що станеться з
IOptionsMonitor<T>після запису невалідного файлу? — Старе значення не збережеться,CurrentValueпочне кидатиOptionsValidationException.
Як влаштоване логування в ASP.NET Core і чому повідомлення пишуть шаблоном, а не інтерполяцією?
Section titled “Як влаштоване логування в ASP.NET Core і чому повідомлення пишуть шаблоном, а не інтерполяцією?”Що це
ILogger<T>— інтерфейс для запису логів, його отримують через DI.- Категорія — ім’я логера, зазвичай повне ім’я класу
T. За нею фільтрують. - Рівень — важливість:
Trace,Debug,Information,Warning,Error,Critical. - Провайдер — куди йде запис.
CreateBuilderпідключає Console, Debug, EventSource і EventLog у Windows.
Як користуватися
- Пишіть шаблоном:
logger.LogInformation("Order {OrderId} created", id). - Виняток передавайте першим аргументом:
logger.LogError(ex, "Payment {PaymentId} failed", paymentId). - Рівні задавайте в секції
Logging:LogLevelфайлуappsettings.json.
Типова помилка
Інтерполяція $"Order {id} created". У лог потрапить готовий рядок без поля OrderId, і знайти всі записи за замовленням буде складно.
Як влаштовано
ILoggerFactoryстворює логер категорії, а той передає запис кожному провайдеру.- Для пари «провайдер — категорія» обирається одне правило: правила цього провайдера важливіші за загальні, серед них перемагає найдовший префікс категорії, що збігається, а без збігів —
Default. - Аргументи підставляються в шаблон за порядком, а не за іменами. Імена плейсхолдерів стають іменами властивостей.
- Структурний провайдер (JSON-консоль, Serilog, OpenTelemetry) зберігає властивості окремо, разом із вихідним шаблоном.
Коли що обирати
| Задача | Рішення |
|---|---|
| Контейнер, логи збирає агент | консоль у JSON: AddJsonConsole() |
| Файли, Seq, Elastic, багато sinks | Serilog: Serilog.AspNetCore |
| Логи разом із трейсами й метриками | експорт через OpenTelemetry |
Serilog
- Sink — куди писати: Console, File, Seq, Elasticsearch (
Elastic.Serilog.Sinks), OpenTelemetry. - Enricher — що додати до кожної події:
FromLogContext,WithMachineName,WithThreadId. {@Order}розкладає об’єкт на поля,{$Order}пише йогоToString().UseSerilogRequestLogging()пише одну подію на запит замість кількох від ASP.NET Core.
Пастки
- Із Serilog рівні задає
Serilog:MinimumLevel, аLogging:LogLevelбільше не діє. - Аналізатор CA2254 попереджає, якщо шаблон не константа.
Під капотом
LogInformation(string message, params object?[] args)створює масив і упаковує значущі аргументи до перевірки рівня. Навіть за вимкненогоDebugвикликLogDebugалокує.- Шаблон розбирається один раз і кешується. Інтерпольований рядок форматується в місці виклику, навіть якщо запис потім відфільтрують.
- Перевантажень з обробником інтерпольованих рядків (interpolated string handler) у
LoggerExtensionsнемає, тому$"..."завжди форматується заздалегідь.
Ціна
- Кожна подія — CPU на форматування, алокації, трафік і місце у сховищі. Зберігання й індексація в Elastic часто коштують більше за обчислення.
- Висока кардинальність властивостей роздуває індекс. Деструктурують лише DTO, а не сутності EF з навігаційними властивостями.
Компроміси
Microsoft.Extensions.Loggingз експортом в OpenTelemetry — менше залежностей, логи й трейси в одному протоколі.- Serilog — багатші sinks і форматування, але це другий конвеєр із власною конфігурацією рівнів. В OTLP він теж уміє писати:
Serilog.Sinks.OpenTelemetry. - Sink, що пише синхронно в мережу, гальмує запити. Повільні sinks обгортають у
Serilog.Sinks.Asyncі стежать за переповненням буфера.
Як перевірити в продакшені
- Агрегація у сховищі за шаблоном повідомлення й категорією показує найбільш «галасливі» рядки коду.
- Алокації від логування видно в профілювальнику алокацій (dotnet-trace) і в BenchmarkDotNet з
[MemoryDiagnoser].
Підсумок: шаблон — це і структура, і ключ групування, а інтерполяція платить за форматування, навіть коли лог не потрібен.
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Orders": "Debug" }, "Console": { "FormatterName": "json" } }}var builder = WebApplication.CreateBuilder(args);var app = builder.Build();
app.MapPost("/orders/{id:int}", (int id, ILoggerFactory factory) =>{ ILogger log = factory.CreateLogger("Orders.Api"); // префікс "Orders" → рівень Debug
// Добре: OrderId і Total — окремі поля, шаблон один на всі замовлення log.LogInformation("Order {OrderId} created, total {Total}", id, 99.5m);
// Погано: готовий рядок без полів, форматується навіть за вимкненого рівня log.LogInformation($"Order {id} created, total {99.5m}");
log.LogDebug("Reserved stock for {OrderId}", id); // потрапить у лог: для "Orders" увімкнено Debug return Results.Created($"/orders/{id}", null);});
app.Run();using Serilog;
Log.Logger = new LoggerConfiguration() .WriteTo.Console() .CreateBootstrapLogger(); // ловить помилки старту до побудови хоста
try{ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog((services, lc) => lc .ReadFrom.Configuration(builder.Configuration) // рівні й sinks із секції "Serilog" .ReadFrom.Services(services) .Enrich.FromLogContext()); // властивості зі scopes і LogContext
var app = builder.Build(); app.UseSerilogRequestLogging(); // одна подія на запит app.MapGet("/", () => "Hello"); app.Run();}catch (Exception ex){ Log.Fatal(ex, "Host terminated unexpectedly");}finally{ Log.CloseAndFlush(); // дописати буфери sinks перед виходом}{ "Serilog": { "MinimumLevel": { "Default": "Information", "Override": { "Microsoft.AspNetCore": "Warning" } }, "WriteTo": [ { "Name": "Console" }, { "Name": "File", "Args": { "path": "logs/app-.log", "rollingInterval": "Day" } } ] }}- Як обирається рівень для категорії
Microsoft.AspNetCore.Routing? — Правило провайдера важливіше за загальне, далі найдовший префікс категорії, що збігається. - Чому аргументи підставляються за порядком, а не за іменем? — Шаблон розбирається позиційно, імена стають лише іменами властивостей.
- Чим
{@Order}відрізняється від{Order}у Serilog? —@розкладає об’єкт на поля, без нього пишетьсяToString(). - Чому після підключення Serilog перестали діяти рівні з
Logging? — Фільтрує сам Serilog, рівні задаються вSerilog:MinimumLevel. - Як правильно залогувати виняток? — Передати його першим аргументом
LogError(ex, ...), а не вставлятиex.Messageу текст.
Як зробити логування швидким і спостережуваним: LoggerMessage, scopes, кореляція та OpenTelemetry?
Section titled “Як зробити логування швидким і спостережуваним: LoggerMessage, scopes, кореляція та OpenTelemetry?”Що це
[LoggerMessage]— атрибут надpartial-методом. Тіло методу пише генератор: перевірка рівня й запис без зайвих алокацій.- Scope — блок
using (logger.BeginScope(...)), властивості якого додаються до всіх записів усередині. - Кореляція — спільний
TraceIdу всіх записах одного запиту, навіть якщо запит пройшов через кілька сервісів. - OpenTelemetry — стандарт і бібліотеки для трьох сигналів: логів, метрик і трейсів.
Як користуватися
- Часті повідомлення оголосіть методами з
[LoggerMessage]. - Номер замовлення чи користувача покладіть у scope один раз, а не в кожне повідомлення.
- Підключіть OpenTelemetry й експорт за OTLP у ваш бекенд.
Типова помилка
Вигадувати власний CorrelationId, коли вже є TraceId. Виходять два непов’язані ідентифікатори.
Як влаштовано
- Генератор створює для методу структуру стану й викликає
logger.Logлише післяIsEnabled. Значущі аргументи не упаковуються. BeginScopeкладе стан у стек наAsyncLocal, тому scope переживаєawait. Консольний провайдер виводить scopes заIncludeScopes.- ASP.NET Core відкриває на запит
Activityі scope зRequestIdтаRequestPath. Логер додаєTraceId,SpanIdіParentIdпоточноїActivity. HttpClientпередає заголовокtraceparent, і наступний сервіс продовжує той самий трейс.
Коли що обирати
| Сигнал | На яке питання відповідає |
|---|---|
Метрики (Meter) |
скільки й як швидко: RPS, затримки, частка помилок |
Трейси (Activity) |
де витрачається час конкретного запиту |
Логи (ILogger) |
що саме сталося і з якими даними |
Пастки
- Метод із
[LoggerMessage]має бутиpartialі повертатиvoid. Виняток передають параметром і не згадують у шаблоні. ActivitySource.StartActivityповертаєnull, якщо джерело ніхто не слухає, тому звертаються черезactivity?..- Scope зі словником — алокація на кожен виклик, у гарячому циклі його не відкривають.
Правило
Метрики — для алертів, трейси — для пошуку вузького місця, логи — для деталей; пов’язує їх TraceId.
Під капотом
- Згенерований метод передає в
ILogger.Logтипізовану структуру стану. Якщо рівень вимкнено, не створюється ні масив, ні рядок. Activity.Currentзберігається вAsyncLocalі тече черезawaitразом зExecutionContext. Заголовокtraceparentнесеtrace-id,parent-idі прапорець sampled.- У .NET 10 пропагатор за замовчуванням — W3C: baggage іде в заголовку
baggage, а не в нестандартномуCorrelation-Context.
Ціна
- Телеметрія — це CPU на серіалізацію, пам’ять під буфери експортера й трафік. Експортер OpenTelemetry надсилає пакетами у фоні й у разі переповнення черги відкидає дані, а не блокує запит.
- Сховище оплачується гігабайтами. Трейси семплюють (
ParentBasedSamplerіTraceIdRatioBasedSampler),Debugу продакшені вимикають, а детальні логи буферизують і випускають лише за помилки (AddPerIncomingRequestBuffer, .NET 9+).
Компроміси
- Агент Elastic APM інструментує все сам, але прив’язує до вендора. OpenTelemetry — стандарт: бекенд змінюється адресою експорту, а Elastic приймає OTLP і випускає власний дистрибутив EDOT .NET.
- Агент Elastic APM .NET з версії 1.23 сам підхоплює спани з
ActivitySource, тому ручне інструментування черезActivityне прив’язує до агента. - Висока кардинальність тегів метрик (id користувача, URL з параметрами) роздуває кількість часових рядів. Такі значення кладуть у трейси й логи.
Як перевірити в продакшені
dotnet-counters monitor --counters Microsoft.AspNetCore.Hosting,System.Runtimeпоказує метрики працюючого процесу без SDK і перезапуску.dotnet-traceіз провайдеромMicrosoft-Extensions-Loggingзнімає логи процесу, навіть якщо сховище логів недоступне.
Підсумок: швидкий лог не працює до перевірки рівня, а спостережуваність — це спільний TraceId і бюджет на обсяг телеметрії.
using System.Diagnostics;
var builder = WebApplication.CreateBuilder(args);builder.Logging.AddJsonConsole(o => o.IncludeScopes = true); // scopes і TraceId у кожному записі
var app = builder.Build();var source = new ActivitySource("Orders");
app.MapPost("/orders/{id:int}", (int id, ILogger<Program> logger) =>{ using var activity = source.StartActivity("ProcessOrder"); // спан; null, якщо ніхто не слухає activity?.SetTag("order.id", id);
using (logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = id })) { OrderLog.Processing(logger, id, "card"); // рівень перевіряється до будь-якої роботи } return Results.Accepted();});
app.Run();
static partial class OrderLog{ [LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "Processing order {OrderId} via {Method}")] public static partial void Processing(ILogger logger, int orderId, string method);}// NuGet: OpenTelemetry.Extensions.Hosting, OpenTelemetry.Instrumentation.AspNetCore, OpenTelemetry.Instrumentation.Http, OpenTelemetry.Exporter.OpenTelemetryProtocolusing OpenTelemetry;using OpenTelemetry.Metrics;using OpenTelemetry.Resources;using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry() .ConfigureResource(r => r.AddService("orders-api")) .WithTracing(t => t .AddSource("Orders") // наш ActivitySource .AddAspNetCoreInstrumentation() .AddHttpClientInstrumentation()) .WithMetrics(m => m .AddMeter("Microsoft.AspNetCore.Hosting") // вбудовані метрики ASP.NET Core (.NET 8+) .AddMeter("System.Runtime")) // GC і пул потоків (.NET 9+) .WithLogging() // ILogger → OTLP разом із TraceId і SpanId .UseOtlpExporter(); // адреса з OTEL_EXPORTER_OTLP_ENDPOINT
var app = builder.Build();app.MapGet("/", () => "Hello");app.Run();Змінити бекенд (Elastic, Jaeger, Grafana) — означає змінити адресу OTLP, код лишається тим самим.
- Чому
[LoggerMessage]швидший заLogInformation? — Рівень перевіряється до роботи, немає масивуparamsі упакування, шаблон розібрано під час компіляції. - Звідки в логах
TraceId, якщо ви його не писали? — Логер додаєTraceId,SpanIdіParentIdпоточноїActivity(ActivityTrackingOptions). - Чому
StartActivityповернувnull? — Джерело ніхто не слухає: OpenTelemetry не підписаний на нього черезAddSource. - Чим head-based семплювання відрізняється від tail-based? — Head вирішує на початку трейсу в застосунку, tail — після його завершення в колекторі й може зберегти всі трейси з помилками.
- Як пов’язати логи Serilog із трейсами? — Serilog сам зберігає
TraceIdіSpanIdактивноїActivity, їх лишається вивести в sink.