Перейти до вмісту

Конфігурація й логування

Звідки застосунок ASP.NET Core бере налаштування, де тримати секрети й як перевірити конфігурацію під час старту, як писати структурні логи й пов’язувати їх із трейсами та метриками. На цій темі перевіряють не лише API, а й досвід експлуатації: що ви робите, коли в продакшені «нічого не видно».

Перед цією темою: Впровадження залежностей.

Junior+

Як влаштована конфігурація: джерела, їхній порядок і ключі Section:Key?

Section titled “Як влаштована конфігурація: джерела, їхній порядок і ключі Section:Key?”
Відповідь

Що це

Конфігурація — словник рядків «ключ — значення». Застосунок читає його через IConfiguration, а наповнюють його провайдери, по одному на джерело.

Джерела за замовчуванням

WebApplication.CreateBuilder підключає їх у такому порядку, і кожне наступне перекриває попередні:

  1. appsettings.json — спільні налаштування.
  2. appsettings.{Environment}.json — відмінності середовища.
  3. User Secrets — лише в Development.
  4. Змінні середовища.
  5. Аргументи командного рядка.

Як користуватися

  • Вкладеність JSON перетворюється на ключ через двокрапку: config["Payments:TimeoutSeconds"].
  • У змінній середовища двокрапку замінюють на __: Payments__TimeoutSeconds=60.
  • GetValue<int>(...) приводить рядок до типу, GetConnectionString("Default") читає ключ ConnectionStrings:Default.

Типова помилка

Друкарська помилка в ключі: config["Payment:Timeout"] поверне null без винятку.

Як влаштовано

  1. builder.Configuration — це ConfigurationManager (.NET 6+): водночас будівник і готова конфігурація, читати її можна просто в Program.cs.
  2. Кожен провайдер завантажує свій словник. Читання ключа йде від останнього провайдера до першого до першого збігу.
  3. Ключі нечутливі до регістру. Елементи масиву — це ключі з індексами: Payments:Currencies:0.
  4. Командний рядок розуміє --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 навіть за заданої змінної середовища: командний рядок підключено останнім.

Junior+

Як працюють середовища й де зберігати секрети?

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 і закомітити. Усе, що лежить у репозиторії, вважайте публічним.

Як влаштовано

  1. Ім’я середовища береться з DOTNET_ENVIRONMENT або ASPNETCORE_ENVIRONMENT, з аргументу --environment чи з WebApplicationOptions.EnvironmentName. У WebApplication у .NET 7+ за обох змінних перемагає DOTNET_ENVIRONMENT.
  2. Середовище фіксується під час побудови хоста й під час роботи не змінюється.
  3. У Development CreateBuilder підключає User Secrets, а WebApplication сам показує сторінку винятків для розробника.
  4. 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 init
dotnet user-secrets set "Payments:ApiKey" "dev-key"
# Сервер або контейнер: двокрапку замінюють на __
export ASPNETCORE_ENVIRONMENT=Staging
export Payments__ApiKey="..."
Middle+

Чим відрізняються IOptions, IOptionsSnapshot і IOptionsMonitor і як валідувати налаштування?

Section titled “Чим відрізняються IOptions, IOptionsSnapshot і IOptionsMonitor і як валідувати налаштування?”
Відповідь

Що це

Options pattern — спосіб читати налаштування не рядками за ключем, а типізованим класом. Секція Payments з appsettings.json прив’язується до класу PaymentsOptions, і сервіс отримує його через DI.

Як користуватися

  1. Опишіть клас із властивостями { get; set; }.
  2. Зареєструйте його: builder.Services.Configure<PaymentsOptions>(builder.Configuration.GetSection("Payments")).
  3. Впровадьте IOptions<PaymentsOptions> і читайте .Value.

Який інтерфейс брати

Інтерфейс Коли бачить нові значення
IOptions<T> ніколи, прочитаний один раз
IOptionsSnapshot<T> на наступному запиті
IOptionsMonitor<T> одразу після зміни файлу

Типова помилка

Чекати, що IOptions<T> побачить правку appsettings.json без перезапуску. Для цього потрібен snapshot або monitor.

Як влаштовано

  1. Configure<T> і AddOptions<T>().BindConfiguration("Payments") реєструють не сам об’єкт, а спосіб його налаштувати.
  2. Під час першого звернення IOptionsFactory<T> створює екземпляр і застосовує всі Configure, потім PostConfigure, потім валідатори.
  3. Результат кешується: в 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;
}
Валідатор із source generator (.NET 8+)
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; } = "";
}
Middle+

Як влаштоване логування в ASP.NET Core і чому повідомлення пишуть шаблоном, а не інтерполяцією?

Section titled “Як влаштоване логування в ASP.NET Core і чому повідомлення пишуть шаблоном, а не інтерполяцією?”
Відповідь

Що це

  • ILogger<T> — інтерфейс для запису логів, його отримують через DI.
  • Категорія — ім’я логера, зазвичай повне ім’я класу T. За нею фільтрують.
  • Рівень — важливість: Trace, Debug, Information, Warning, Error, Critical.
  • Провайдер — куди йде запис. CreateBuilder підключає Console, Debug, EventSource і EventLog у Windows.

Як користуватися

  1. Пишіть шаблоном: logger.LogInformation("Order {OrderId} created", id).
  2. Виняток передавайте першим аргументом: logger.LogError(ex, "Payment {PaymentId} failed", paymentId).
  3. Рівні задавайте в секції Logging:LogLevel файлу appsettings.json.

Типова помилка

Інтерполяція $"Order {id} created". У лог потрапить готовий рядок без поля OrderId, і знайти всі записи за замовленням буде складно.

Як влаштовано

  1. ILoggerFactory створює логер категорії, а той передає запис кожному провайдеру.
  2. Для пари «провайдер — категорія» обирається одне правило: правила цього провайдера важливіші за загальні, серед них перемагає найдовший префікс категорії, що збігається, а без збігів — Default.
  3. Аргументи підставляються в шаблон за порядком, а не за іменами. Імена плейсхолдерів стають іменами властивостей.
  4. Структурний провайдер (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();
Serilog: зовнішній пакет Serilog.AspNetCore
Serilog.AspNetCore
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" } }
]
}
}
Senior+

Як зробити логування швидким і спостережуваним: LoggerMessage, scopes, кореляція та OpenTelemetry?

Section titled “Як зробити логування швидким і спостережуваним: LoggerMessage, scopes, кореляція та OpenTelemetry?”
Відповідь

Що це

  • [LoggerMessage] — атрибут над partial-методом. Тіло методу пише генератор: перевірка рівня й запис без зайвих алокацій.
  • Scope — блок using (logger.BeginScope(...)), властивості якого додаються до всіх записів усередині.
  • Кореляція — спільний TraceId у всіх записах одного запиту, навіть якщо запит пройшов через кілька сервісів.
  • OpenTelemetry — стандарт і бібліотеки для трьох сигналів: логів, метрик і трейсів.

Як користуватися

  1. Часті повідомлення оголосіть методами з [LoggerMessage].
  2. Номер замовлення чи користувача покладіть у scope один раз, а не в кожне повідомлення.
  3. Підключіть OpenTelemetry й експорт за OTLP у ваш бекенд.

Типова помилка

Вигадувати власний CorrelationId, коли вже є TraceId. Виходять два непов’язані ідентифікатори.

Як влаштовано

  1. Генератор створює для методу структуру стану й викликає logger.Log лише після IsEnabled. Значущі аргументи не упаковуються.
  2. BeginScope кладе стан у стек на AsyncLocal, тому scope переживає await. Консольний провайдер виводить scopes за IncludeScopes.
  3. ASP.NET Core відкриває на запит Activity і scope з RequestId та RequestPath. Логер додає TraceId, SpanId і ParentId поточної Activity.
  4. 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);
}
OpenTelemetry: зовнішні пакети OpenTelemetry.*
// NuGet: OpenTelemetry.Extensions.Hosting, OpenTelemetry.Instrumentation.AspNetCore, OpenTelemetry.Instrumentation.Http, OpenTelemetry.Exporter.OpenTelemetryProtocol
using 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, код лишається тим самим.