Конфигурация и логирование
Откуда приложение ASP.NET Core берёт настройки, где держать секреты и как проверить конфигурацию при старте, как писать структурные логи и связывать их с трейсами и метриками. По этой теме проверяют не только API, но и опыт эксплуатации: что вы делаете, когда в проде «ничего не видно».
Перед этой темой: Внедрение зависимостей.
Как устроена конфигурация: источники, их порядок и ключи Section:Key?
Заголовок раздела «Как устроена конфигурация: источники, их порядок и ключи 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показывает провайдер каждого ключа.
Как работают окружения и где хранить секреты?
Заголовок раздела «Как работают окружения и где хранить секреты?»Что это
Окружение — строка, которую приложение читает при старте. Стандартные значения — 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 и как валидировать настройки?
Заголовок раздела «Чем отличаются 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 и почему сообщения пишут шаблоном, а не интерполяцией?
Заголовок раздела «Как устроено логирование в 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?
Заголовок раздела «Как сделать логирование быстрым и наблюдаемым: 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.