Когда на нашей рабочей системе происходит какая-либо ошибка, нам хочется, чтобы логи содержали всю необходимую информацию о том, из-за чего она произошла. На достаточно сложных системах это приводит к сбору большого количества данных: какие этапы обработки были выполнены, с какими аргументами вызывались те или иные методы, что вернули запросы к внешним системам и т. д. Проблема заключается в том, что эту информацию нам приходится собирать даже в случае, если никакой ошибки не произошло. А это приводит к росту объёма наших логов, за который нам приходится платить деньги.

Уровни логирования (error, warning, information, ...) здесь помогают мало. Обычно для приложения выставляется некий целевой уровень (например, information). Это означает, что все записи с уровнем выше или равным данному попадают в логи, а менее значимые выбрасываются. Но в тот момент, когда происходит ошибка, для нас наибольший интерес представляют именно записи debug-уровня, которые обычно и выбрасываются. Хорошо, если ошибка встречается довольно часто. В это случае можно временно опустить целевой уровень логирования системы, собрать информацию об ошибке, а потом вернуть уровень назад. Таким образом объём собираемых логов возрастёт только временно. Если же ошибка встречается достаточно редко, такой подход хоть и возможен, но не очень удобен, поскольку приводит к значительному росту объёма логов.

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

А теперь приступим.


Основная идея

Основная идея, позволяющая получить лучшее из двух миров, заключается в следующем. Вводится понятие блока записей в лог:

using(_logBlockFactory.CreateLogBlock())
{
    ...

    SomeOperations();

    ...
}

Все записи в лог, которые произошли внутри такого блока, не отправляются в хранилища логов немедленно, а ожидают вызова метода Dispose блока. Внутри вызова этого метода Dispose производится анализ этих записей. Если среди них есть записи уровня error или выше, то блок отправляет все записи в хранилища логов. Если же оказывается, что никаких сообщений об ошибках там нет, то блок просто выбрасывает все собранные записи.

Естественно, отметка времени каждой записи берётся в тот момент, когда она была сформирована, а не тогда, когда был вызван Dispose блока.

Возможные усовершенствования

Эту базовую идею можно усовершенствовать.

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

_logger.Mandatory.WriteInformation("Something");

Во-вторых, вам может потребоваться самостоятельно решать, стоит ли сбрасывать записи в хранилища логов или нет. Для этого можно использовать как простое свойство:

_logBlockFactory.CurrentBlock.WriteBlock = true;

так и придумать что-нибудь более сложное типа функции, которая будет просматривать записи внутри блока и на их основе принимать решение.

Можно даже поиграть уровнями логирования. Например, при наличии ошибки блок записывает в хранилища все записи любых уровней, а при отсутствии - только information и выше. Не знаю, насколько реально (и нужно) реализовать такой подход. Сегодня можно выставлять целевые уровни логирования для каждого хранилища отдельно. Так что, думаю, такой новый подход потребует слишком больших изменений в самой существующей концепции логирования.

Проблемы

Естественно, данная идея имеет и свои отрицательные стороны.

Если вам требуется, чтобы записи поступали в хранилища практически в реальном времени, то данный подход вам не подойдёт. Реальная запись в хранилища происходит только в момент вызова метода Dispose блока.

Поскольку записи теперь пишутся в хранилища пакетами, может оказаться, что они размещаются там не по порядку времени. Например, первый блок записал записи за 11:31:01, 11:31:02, 11:31:03. После этого завершился второй блок и так же записал записи за 11:31:01, 11:31:02, 11:31:03. Т. е. записи оказываются перемешанными по времени. Если ваше хранилище не поддерживает сортировку по времени (console, file, ...), это может быть проблемой. С другой стороны, думаю, что все промышленные хранилища (типа Azure Application Insights) поддерживают такую сортировку. Кроме того, записи из разных блоков наверняка будут иметь разный correlation id, что облегчит их отделение друг от друга.

Далее, поскольку записи заносятся в хранилища не сразу, в случае серьёзного сбоя приложения мы можем потерять часть записей (если метод Dispose блока вообще не будет вызван).

Определённую проблему представляет также семантика вложенных блоков. Если ошибка произошла во внутреннем блоке, как должен вести себя внешний блок? Что если ошибка произошла во внешнем блоке? Нужно куда-то писать записи из внутренних блоков? Всё это нужно детально продумать.

Заключение

Вот и вся идея, которая пришла мне в голову для решения проблемы распухания логов. Надеюсь, она будет полезна, и кто-нибудь попробует её реализовать. Удачи!

Комментарии (14)


  1. MeGaZip
    29.12.2023 12:01
    -1

    Идея отличная.


    1. Zrgk
      29.12.2023 12:01

      Согласен, идея интересная


  1. silenceman
    29.12.2023 12:01
    -2

    Про vector слышали?

    Там все это можно организовать его средствами.


  1. NikolayPyanikov
    29.12.2023 12:01
    +1

    На мой взгляд основная проблема большого количества данных это не оптимальный способ передачи и хранения этой инфы. Сейчас логи это обычные строки. Причём с очень большим дублированием. Есть очевидный и очень простой способ значительно повысить производительность работы с логам и снизить затраты на хранение - заменить неизменяемые части шаблонами, а изменяемые хранить не в виде строк, а в виде бинарных данных, параметров этого шаблона. Например лог вида "Service Abc is started." сейчас занимает 23 байта при передаче в utf8 и в 2 раза больше в памяти. Если использовать шаблон "Service _ is started." То при хранении и передаче это будет какой то ID шаблона например 2 байта + параметр: строка Abc - 3 байта в utf8. Всего 5 байт. Шаблон может включать сам текст и метаданные для того что бы склеить их в привычные строки только перед их просмотром. Профит - меньше памяти, меньше трафика, быстрее поиск.

    Кажется технологии работы с логам застряли где-то в 90х прошлого столетия.


    1. 9982th
      29.12.2023 12:01
      +1

      Проблему с очень большим дублированием решает gzip. Если использовать logrotate, то сжатие будет буквально из коробки, при этом будут доступны все привычные способы анализа текстовых файлов. Хранением логов в бинарной форме занимается journald и мне совершенно не кажется, что он быстрее.


    1. lex899
      29.12.2023 12:01
      +1

      Лог вида "Service Abc is started." можно заменить на "h" и получить всего 1 байт, но потеряется основное преимущество лог файла - человекочитаемость. Анализатор логов от одной серии железяк неудобен и со скрипом работает под win11, от другой - размещен в web, а на третьей пакет логов шифруется и может быть разобран только производителем. Туда же идут нечитаемые timestamp и ошибки вида "error 38".


      1. NikolayPyanikov
        29.12.2023 12:01
        +1

        Помимо дублирующейся информации логи хранят ещё и обычную. В этом случае это название сервиса Abc. Так что не понятна ваша мысль.

        Если хотите можно хранить логи в денормализованном виде для лёгкости чтения. Но собирать и передавать их так нет большого смысла.


  1. NikolayPyanikov
    29.12.2023 12:01

    gzip можно использовать для передачи любых данных по сети, в том числе и для предложенного мной подхода. Но в моем случае данных будет в любом случае меньше. С другой стороны gzip будет потреблять довольно много CPU и память будет тратиться куда больше. Идея же очень простая - не дублировать тонных бесполезной инфы в логах а писать только полезное, выполнив минимальную "нормализацию" и это будет лучше и по CPU и по памяти на стороне где мы пишем логи (ни какой конкатенации строк, ни какого трафика памяти, как обычно делают логеры), и деморализацию перед прочтением


  1. E_STRICT
    29.12.2023 12:01
    +1

    В Монологе есть хендлер, который реализует подобный подход.


  1. gun_dose
    29.12.2023 12:01

    Есть такая пословица "Кабы знал, где упадёшь, соломки бы подстелил". Так вот предложенное решение - это как раз та соломка, которую надо заранее стелить там, где упадёшь (вернее где упадёт приложение). Хотя решение довольно интересное, и на практике иногда действительно знаешь, где упадёшь, так что иногда вполне может пригодиться.


  1. lex899
    29.12.2023 12:01
    +2

    Ещё одна проблема - не факт что Dispose вообще будет вызван (UB, краш, забыли).


  1. Apollon_Diamed
    29.12.2023 12:01

    Идея интересная, но как мне кажется Sentry делает то же самое


  1. qid00000000
    29.12.2023 12:01
    +1

    Предположим, что мы смогли реализовать подобный функционал и решили проблему с порядком дат. Также, представим ситуацию, что произошла ошибка, программа стала собирать батч и пришел OOM Killer / ошибка сегментации / админ, случайно нажавший kill -9, тогда получается логов программы нет. В лучшем случае будет неконсистентность данных или упустим что то важное.

    Тут скорее всего, лучше дать и уникальный идентификатор запроса, вместо формирования пакета (exim например так делает), и писать его в логи, а логи писать потоком куда-то ещё. А ответственность за то, чтобы решать - сохранить логи или нет, дать другой программе (на основе идентификатора формировать пакеты и принимать решение сохранять ли их).

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

    В общем, идея автора интересная, но не жизнеспособная как по мне.


  1. qw1
    29.12.2023 12:01

    На детальных уровнях логгирования приложение может медленно работать из-за фрагментов типа

    if (_log.IsDebugEnabled)
    {
       _log.Debug("Input data: " + string.Join(...));
    }
    

    А тут автор предлагает по сути всё время держать уровень DEBUG