Привет, Хабр! На связи Андрей Алексеенко, техлид оператора рекламных данных (ОРД) «МедиаСкаут». В предыдущем посте мы начали рассказывать о том, как своевременно передаем информацию в единый реестр интернет-рекламы (ЕРИР) — показали настройку гарантированной доставки данных внешним сервисам. Сегодня предлагаю пойти дальше и обсудить, что мы сделали, чтобы вовремя находить, локализовать и устранять проблему, если по какой-то причине (а их множество, и потому разбирать нет смысла) доставка все же не случилась.

Речь о наблюдаемости за системой, которая держится на трех принципах: логирование, трассировка, метрики. В материале разберем механизм контекста структурированного логирования и посмотрим на примерах, как использовать EventId в .NET Core для классификации событий в логах, чтобы быстро понимать, где и что сломалось. Итак, поехали!

Структурированное логирование — must have при многомиллионных запросах 

Очень часто в своей практике я встречал строки кода подобного плана:

logger.LogInformation("Процесс запущен");
logger.LogInformation("Обработка данных");
logger.LogInformation("Процесс завершен");

И в лог-файл в этом случае записывалось примерно следующее — не связанные между собой текстовые сообщения:

2026-04-15 10:00:01 | INFO | Процесс запущен
2026-04-15 10:00:02 | INFO | Обрабатываем данные
2026-04-15 10:00:03 | ERROR | Ошибка отправки запроса в ЕРИР

По сути, ничего плохого в этом нет, но вот анализировать такие записи довольно сложно, особенно когда у вас многомиллионные запросы:

  • поиск по тексту медленный и неточный;

  • нет связи между сообщениями;

  • автоматизация разбора кастомных сообщений становится очень сложной задачей;

  • трудно, а порой и невозможно подсчитать статистику (например, число ошибок в день, месяц).

В таких случаях спасает структурированное логирование — запись логов в формате данных (обычно JSON), а не текста. Каждое сообщение содержит пары «ключ — значение»:

{

  "EventId": 201,

  "http.request.method": "POST",

  "invoice.id": "ACT-12345",

  "MessageTemplate": "Запрос отправлен"

}

Да, запись в лог становится громоздкой, прочитать ее сложнее, зато появляются значительные преимущества:

  • автоматическая агрегация данных (подсчет событий, группировка по статусам);

  • обогащение логов контекстом (значениями переменных);

  • самое важное — данные можно импортировать в специальные системы для анализа логов. Мы, например, используем ELK (Elasticsearch, Logstash, Kibana), поэтому в материале я буду делать отсылки к этой системе;

  • быстрый поиск и фильтрация по полям — этот пункт достоин отдельного обсуждения ниже.

Раньше, чтобы найти нужное в логах, использовали grep. Например, команда grep -E "ERROR|WARNING" webapi_log_01_04_2026.log показывала все ошибки и предупреждения. Или открывали файл в Notepad++ и искали вручную. Но даже такой подход уже удобнее, чем ничего: можно отфильтровать конкретные события. Например, команда grep -r '"EventId": 202' /path/to/logs/.log найдет все записи с событием 202 сразу во всех логах в папке.

Однако все начинает играть красками, если использовать такой инструмент, как Kibana:

На картинке показано, как можно отфильтровать логи по полю EventId. А на скриншоте ниже видно список запросов, где у пользователей были ошибки, — сразу понятно, в UI проблема или в Web API:

Благодаря этому мы можем оперативно подсказать пользователям, что у них ошибка в запросе. А сколько времени уйдет на анализ текстового лога, чтобы собрать подобную аналитику? Думаю, разница очевидна.

Как работает контекст логирования: передаем данные между слоями и ищем по цепочке событий  

Важная часть структурированного лога — контекст логирования. Его задача «прикрепить» дополнительные данные (например, ID акта, параметры запроса, тип операции) ко всем записям в лог внутри определенного блока кода. Эти данные автоматически добавляются к каждому сообщению, пока контекст активен. 

Посмотрим на примере — фрагменте кода из запроса, который мы отправили в ЕРИР:

partial void PrepareRequest(HttpClient client, HttpRequestMessage request, string url)
{
    var logFields = new Dictionary<string, object>
    {
        ["http.request.method"] = request.Method,
        ["http.request.url"] = url,
        ["http.request.path"] = request.RequestUri.LocalPath
    };

    if (request.Method == HttpMethod.Post || request.Method == HttpMethod.Put)
    {
        if (request.Content != null)
        {
            logFields.Add(LogFields.HttpRequestBody,
                request.Content.ReadAsStringAsync().GetAwaiter().GetResult());
        }
    }

    httpRequestScope = logger.BeginScope(logFields);
    stopwatch = new Stopwatch();
    stopwatch.Start();
}

Перед отправкой в контекст логирования добавили параметры запроса, «прикрепили» следующее:

var logFields = new Dictionary<string, object>
{
    ["http.request.method"] = request.Method,
    ["http.request.url"] = url,
    ["http.request.path"] = request.RequestUri!.LocalPath
};
…
_httpRequestScope = _logger.BeginScope(logFields);

Основной инструмент — метод BeginScope() у ILogger<T>, он возвращает объект IDisposable. Пока этот объект не удален Dispose(), все логи внутри блока получают данные из контекста. Таким образом мы получаем средство для передачи любых значений в лог между самыми разными вызовами. Более того, можем создавать новый контекст, и если у предыдущего не был вызван Dispose(), в лог будут попадать данные из обоих контекстов. 

Но важно понимать и учитывать, что увеличение данных в контексте может привести к увеличению потребления памяти в момент пиковых нагрузок на систему. Поэтому рекомендую добавлять в контекст логирования только простые типы данных, что гарантирует минимальное потребление памяти.

Но не всегда есть возможность писать в лог минимум информации. Например, у себя в логах мы сохраняем весь трафик с ЕРИР (для нас и наших клиентов очень важно фиксировать, что и когда мы отправили и какой ответ получили на случай возникновения вопросов между нами, клиентом и ЕРИР) — это довольно-таки большой объем. И тут контекст логирования прекрасно справляется, данные в памяти долго не задерживаются, уборщик мусора действует на отлично. Вот пример метрик по памяти в момент нагрузки на систему, примерно 300 rps:

К сожалению, не могу сказать точно, сколько именно из общего объема заняло логирование, но что можно сказать однозначно: контекст не мешает, а избавляет от множества рутинных задач. 

Преимущества

Я бы выделил три основных преимущества:

  • автоматическое добавление полей, благодаря которому не нужно вручную передавать ID между методами;

  • если открыть новый контекст внутри существующего, поля объединяются;

  • есть ограничение по области, поэтому, когда контекст закрывается (Dispose()), новые поля уже не добавляются.

Вот как это работает на примере все того же запроса в ЕРИР: 

1. Создаем словарь с данными контекста:

var logFields = new Dictionary<string, object>

{

    ["InvoiceID"] = "INV-789",

    ["UserID"] = "U-456"

};

2. Запускаем контекст с помощью BeginScope():

using (_logger.BeginScope(logFields))

{

    // Весь код внутри этого блока будет логироваться с полями из logFields

    _logger.LogInformation("Начало обработки акта");




    // ... какой‑то код ...




    _logger.LogWarning("Обнаружено предупреждение: не заполнены параметры");




    // ... еще код ...




    _logger.LogError("Ошибка: не удалось отправить акт в ЕРИР");

}

3. Смотрим, что происходит внутри блока using.
Каждый вызов _logger (например, LogInformation, LogError) автоматически включает поля из logFields. Так мы получаем записи в лог-файл:

[
  {
    "Timestamp": "2026-03-15T10:05:01",
    "Level": "INFO",
    "Message": "Начало обработки акта",
    "InvoiceID": "INV-789",
    "UserID": "U-456",
    "Environment": "Development"
  },
  {
    "Timestamp": "2026-06-15T10:05:10",
    "Level": "WARNING",
    "Message": "Обнаружено предупреждение: не заполнены параметры ",
    "InvoiceID": "INV-789",
    "UserID": "U-456",
    "Environment": "Development"
  },
  {
    "Timestamp": "2026-03-15T10:05:25",
    "Level": "ERROR",
    "Message": " Ошибка: не удалось отправить акт в ЕРИР ",
    "InvoiceID": "INV-789",
    "UserID": "U-456",
    "Environment": "Development"
  }
]

4. Завершаем контекст. Когда выполнение выходит из блока using, контекст автоматически закрывается (вызывается Dispose()). Все последующие логи уже не будут содержать InvoiuceID, UserID и так далее.

Что нам дал контекст логирования:

  • Быстрое понимание сути проблемы. В логе сразу видны все важные параметры (кто, что, где).

  • Упрощенная фильтрация. Можно искать все ошибки для конкретного акта (InvoiceID: "INV-789") или пользователя.

  • Меньше ручного труда. Не нужно передавать ID через десятки методов — контекст делает это автоматически.

  • Гибкость. Легко добавлять новые поля без изменения логики приложения.

  • Аналитика. В системах типа Kibana можно строить дашборды по полям из контекста (например, «количество ошибок по типам запросов»).

На этом можно было бы поставить точку, но, как и везде, не обходится без подводных камней.

Подводные камни

Вот с чем можно столкнуться по ходу работы:

1. Контекст может потеряться между асинхронными операциями или сброситься при переключении потоков (например, после await). Пример:

using (_logger.BeginScope("RequestId: {RequestId}", requestId))

{

    await SomeAsyncOperation().ConfigureAwait(false); // Контекст сбросится

    _logger.LogInformation("Это сообщение потеряет RequestId");

}

Основная причина: отказ от восстановления предыдущего контекста синхронизации после await, так что лучше избегать ConfigureAwait(false) в коде логирования.

2. В некоторых средах (например, консольных приложениях) SynchronizationContext отсутствует или сбрасывается, что нарушает цепочку распространения контекста.

3. BeginScope работает корректно в асинхронном коде благодаря механизму AsyncLocal<T>, но довольно легко можно создать ситуацию, при которой контекст логирования не будет использоваться. Рассмотрим простой пример, как работает механизм сохранения контекста логирования:

  • при вызове BeginScope создается новая область логирования;

  • контекст сохраняется в AsyncLocal<T>;

  • при выполнении await ExecutionContext захватывает и сохраняет этот контекст;

  • после await контекст восстанавливается автоматически.

Пример корректной работы:

using (_logger.BeginScope("RequestId: {RequestId}", requestId))

{

    await SomeAsyncOperation(); // Контекст сохранится

    _logger.LogInformation("Сообщение с RequestId"); // RequestId будет в логе

}

Однако если использовать Task.Run(…) для запуска потоков, контекст логирования потеряется. Task.Run запускает код в потоке из пула потоков (ThreadPool), что приводит к следующим проблемам:

  • поток пула имеет свой ExecutionContext, который не содержит сохраненного контекста логирования;

  • Task.Run не захватывает текущий ExecutionContext по умолчанию;

  • контекст AsyncLocal<T> привязан к логическому потоку выполнения, а не к физическому потоку.

Пример потери контекста:

using (_logger.BeginScope("RequestId: {RequestId}", "123"))

{

    await Task.Run(() =>

    {

        _logger.LogInformation("Это сообщение НЕ получит RequestId!"); // Контекст потерян

    });

}

Самый простой вариант, как этого избежать, это явно передавать ILogger внутрь Run и создавать контекст внутри метода:

var currentLogger = _logger;

await Task.Run(() =>

{

    using (currentLogger.BeginScope("RequestId: {RequestId}", "123"))

    {

        currentLogger.LogInformation("Теперь RequestId доступен");

    }

});

Еще вместо Task.Run(…) можно использовать ValueTask, но все же лучше избегать подобных ситуаций и использовать именно асинхронные версии функций.

  1. Не забывайте очищать контекст логирования, так как при большом rps можно быстро получить утечку памяти.

  2. Используйте библиотеки с поддержкой асинхронного контекста, например NLog или Serilog.

  3. Старайтесь выделять время для проверки записи данных в лог еще на этапе разработки, потому что на проде уже будет поздно.

Классифицируем события в .NET Core с помощью EventId для быстрой навигации по инцидентам

EventId — это числовой идентификатор, который присваивается определенному типу события (записи в логе) в приложении. Он служит своего рода меткой, позволяющей быстро классифицировать, фильтровать и анализировать записи в логах.

Вернемся к простой форме логов. 

2026-04-15 10:00:01 | INFO | Процесс запущен

2026-04-15 10:00:02 | INFO | Обрабатываем данные

2026-04-15 10:00:03 | ERROR | Ошибка отправки запроса в ЕРИР

Здесь нет классификатора событий. Чтобы найти ошибки отправки запросов в ЕРИР, нужно выполнить поиск по фрагменту текста, что долго и не очень точно (могут попасться похожие фразы).

EventId же помогает назначить уникальный номер каждому событию. 

{

  "Timestamp": "2026-04-15T10:05:22",

  "Level": "ERROR",

  "EventId": 500,

  "Message": "Не удалось отправить запрос в ЕРИР"

}

Так для поиска достаточно отфильтровать логи по EventId = 500.

Чтобы использовать EventId в .NET Core:

1. Определяем идентификаторы. Создаем статический класс для хранения всех EventId (кому как удобнее, но на мой взгляд такие идентификаторы должны храниться в одном месте, чтобы избежать дублирования и накладок):

public static class ErirEventIds

{

    public static readonly EventId ReqeustSendingSuccess = new EventId(1001, "Запрос успешно отправлен в ЕРИР");

    public static readonly EventId ReqeustSendingFailed = new EventId(1002, "Не удалось отправить запрос в ЕРИР");

}

2. Записываем логи с EventId. Используем перегрузку метода логирования, которая принимает EventId:

_logger.LogInformation(ErirEventIds.ReqeustSendingSuccess, “”);

Когда логи содержат EventId, их легко находить и анализировать в системах типа Kibana, Grafana или даже в Excel. Так из набора текстов они превращаются в структурированные данные, что помогает:

  • быстрее находить проблемы;

  • строить дашборды и графики;

  • автоматизировать мониторинг;

  • улучшить читаемость для всей команды.

Структурированное логирование на практике

Для закрепления материала перейдем к практической части. Я подготовил небольшой проект на GitHub на основе стандартного шаблона по WEBAPI.

Для настройки в конфигурационном файле appsettings.Development.json в свойстве path укажите, куда записывать лог. Теперь можно переходить к логированию.

В проект добавлен пакет mediatr для взаимодействия с бизнес-логикой приложения:

В строке 23 создается контекст логирования и в него добавлен ключ path, далее мы делаем вызов через mediatr к нашему запросу. Обратите внимание, что в контроллере нет записи в лог — только подготовленный контекст.

Выполняем логирование в обработчике запроса — и теперь посмотрим, что у нас записалось в лог-файл:

Теперь посмотрим на две строки (я отформатировал их для удобства чтения), по EventId вы легко найдете нашу запись. Обратите внимание на то, как отработал контекст логирования: в контроллере мы присвоили значение, записали его в обработчике запроса mediatr, а данные из контекста контроллера записались в обработчике запроса! При том что контроллер ничего не знает об обработчике — ровно, как и обработчик о контроллере

Благодаря этому можно добавлять свои ключи для отслеживания нужных данных и построения цепочек прохождения — от момента, когда клиент нажимает кнопку «Отправить», до записи в СУБД. Поэтому при работе с каждым актом мы всегда добавляем идентификатор в контекст логирования еще на старте редактирования. Все, что будет писаться в лог, будет содержать его идентификатор. Так мы легко можем найти все, что происходило именно с этим актом. 

Итоги

Вот что мы получили после внедрения структурированных логов:

  • можем полноценно делать аналитику: строить графики и дашборды, настроить оповещения;

  • быстро находим локализацию проблемы через поиск по EventId и фильтрацию по контекстным полям;

  • стандартизировали имена полей во всей инфраструктуре с помощью EventId;

  • обогащаем контекст тем, что передаем данные между модулями без ручного кодирования.

А еще asp.net core добавил два важных поля — TraceId и SpanId. Это ниточки, которые позволяют собрать всю цепочку записей в одну последовательность. А мы получаем в руки мощный инструмент фиксации событий в нашем приложении: настраивая фильтры в Kibana по EventId и дополнительным полям, легко можем находить сбои в системе, раскручивая цепочки вызовов, приведшие нас в проблемную точку. 

В следующей части подробнее поговорим как раз о трассировке и как она связывается с логами. Не переключайтесь!