Структурированные журналы
Структурированный журнал сохраняет не только готовую человеческую строку, но и именованные значения события. Для HTTP API полезно знать метод, путь, итоговый статус и контекст обращения. Это позволяет искать несколько событий одного запроса, даже когда сервер одновременно обслуживает много клиентов. В этом уроке добавим общий журнал вокруг выполнения handler.
Состояние lesson-11/start уже содержит Problem Details и общую обработку исключений. В after появляется Infrastructure/RequestJournal.cs, его регистрация и настройки консольного провайдера. Каталог, фильтры и предварительная проверка не изменяются. Приложение и журнал при подготовке не запускались; ниже показана схема ожидаемых событий без выдуманных времён или идентификаторов.
Шаблон сообщения и значения
Вместо склеивания строк используем шаблон с именами свойств:
app.Logger.LogInformation("HTTP {Method} {Path} completed with {StatusCode}",
context.Request.Method, context.Request.Path.Value,
context.Response.StatusCode);
Method, Path и StatusCode обозначают отдельные величины. Конкретный провайдер может показать их обычной строкой или сохранить как поля. Этот способ позволяет искать события по статусу, не разбирая текст вручную. Позиция переданных значений соответствует позиции placeholder, поэтому следует согласовывать порядок аргументов с шаблоном.
Интерполяция $"HTTP {method}" сначала строит обычную строку и теряет эту структуру для соответствующего события. Она допустима для некоторых текстовых задач, но здесь хуже отражает намерение. Мы хотим передать значения отдельно и сохранить постоянное название события, а не создавать новый шаблон для каждого пути.
Уровень Information используется для штатного завершения обращения. Не каждый 404 является аварией процесса: отсутствующий курс может быть обычным пользовательским действием. Ошибки инфраструктуры и неожиданные исключения имеют другой диагностический смысл. Выбор уровня должен помогать искать важное, иначе журнал превращается в одинаково громкий поток всего происходящего.
Описание logging Microsoft служит источником для шаблонов, категорий, уровней и областей. Наши названия событий и выбор полей задаются учебным приложением. Framework не знает, какое поле каталога является секретным или полезным для расследования.
Область одного запроса
Полный файл нового компонента содержит область журналирования и отдельные ветви завершения:
namespace CatalogApi;
public static class RequestJournal
{
public static void UseRequestJournal(this WebApplication app)
{
app.Use(async (context, next) =>
{
using var scope = app.Logger.BeginScope(new Dictionary<string, object>
{
["RequestId"] = context.TraceIdentifier
});
try
{
await next(context);
app.Logger.LogInformation("HTTP {Method} {Path} completed with {StatusCode}",
context.Request.Method, context.Request.Path.Value,
context.Response.StatusCode);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
app.Logger.LogInformation("HTTP request was cancelled by the client");
throw;
}
catch (Exception)
{
app.Logger.LogError("HTTP handler failed before a complete response");
throw;
}
});
}
}
BeginScope связывает нижележащие события с RequestId, взятым из TraceIdentifier текущего контекста. using ограничивает время жизни области этим обращением. Внутри неё вызывается следующий компонент, то есть сам endpoint и его зависимости. Если они пишут события через совместимый logger, провайдер может добавить ту же область.
Идентификатор нужен для сопоставления, но не является паролем, подписью запроса или доказательством пользователя. Его нельзя использовать вместо авторизации. В нашей модели он создаётся инфраструктурой HTTP, а не слепо копируется из произвольного пользовательского заголовка. Внешние correlation ID требуют отдельного правила длины, допустимого формата и доверия к источнику.
После успешного возвращения next записывается статус. Если же handler выбросил исключение, middleware пишет отдельное сообщение и снова выбрасывает его. Внешний UseExceptionHandler затем выбирает публичный ответ. Поэтому мы не записываем в этой ветви ложное «completed with 200»: статус до обработки исключения может ещё не отражать окончательный исход.
Компонент также отдельно рассматривает OperationCanceledException при отменённом RequestAborted. Это штатная возможность, когда клиент прекратил ожидание. Исключение передаётся дальше, но журнал получает отличимое событие. Все отмены подряд нельзя обозначать клиентскими: если собственный таймаут другого ресурса отменил независимый token, причина может быть иной.
Порядок регистрации и вывод области
В Program.cs до Build добавлена настройка:
builder.Logging.AddSimpleConsole(options => options.IncludeScopes = true);
Она включает показ областей в выбранном простом консольном провайдере. Наличие BeginScope и видимость его полей в конкретном выходе — разные действия: провайдер должен поддерживать и отображать их. Для другого сервиса журналов формат и настройка могут отличаться, поэтому мы не обещаем один буквальный вид строки во всех окружениях.
После построения приложения журнал подключён ниже общего обработчика ошибок:
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRequestJournal();
app.MapCatalog();
Так исключение из handler проходит через request journal и затем попадает к внешней границе ошибок. Status-code middleware тоже находится снаружи и может заполнить тело после возврата. Наш журнал описывает выполненную нижнюю операцию, а не претендует на регистрацию каждого байта окончательного сетевого ответа.
Для успешного чтения JavaScript ожидается одно штатное событие с методом GET, путём /api/courses/javascript, статусом 200 и областью текущего запроса. Для неизвестного ID штатное событие имеет 404. Для неожиданного сбоя появляется сообщение о неудаче handler, а общий exception handler может добавить собственное диагностическое событие.
Что в журнал не попадает
Мы не записываем всё тело JSON, query-строку, cookies, Authorization или строку подключения. Запрос каталога пока прост, но общий компонент будет обслуживать будущие действия с пользовательскими данными. Механическое логирование каждого входа способно превратить журнал в лишнее хранилище секретов и персональной информации.
Сам путь тоже может содержать данные ресурса. В рабочем приложении следует оценить его модель и при необходимости сохранять шаблон endpoint вместо буквального пути. В этой песочнице идентификатор курса является публичной строкой, поэтому выбранный пример допустим для объяснения. Нельзя переносить это решение на адрес, содержащий токен восстановления доступа.
Журнал не заменяет результат операции и не доказывает доставку ответа клиенту. Сообщение «handler completed» означает завершение соответствующей части обработки. Соединение может оборваться позднее. Тем более отдельное событие без измерения не позволяет утверждать, что endpoint быстрый или обслужил определённое число клиентов.
Событие следует связывать с действием, которое действительно произошло. Пока приложение лишь читает курс, сообщение «курс обновлён» было бы ложным независимо от успешного статуса. В будущей операции записи полезно отдельно фиксировать факт сохранения и завершение HTTP-обработки: клиентское соединение может исчезнуть между ними. Такое различие помогает расследовать неопределённый результат, а не заставляет журнал обещать доставку ответа.
По этой же причине стабильность шаблона важнее красивой длинной фразы. Именованные поля позволяют сменить текст сообщения без потери основных значений, если схема событий согласована. Однако бесконтрольное переименование полей тоже способно сломать запросы диагностики, поэтому структура журнала является отдельной внутренней договорённостью команды.
После урока события получают понятную структуру и контекст, а исключения не изображаются успешным завершением. В следующем уроке подробнее рассмотрим отмену обращения и передадим её через контракт repository, сохраняя различие отменённого ожидания и отменённого предметного действия.