Вы читаете журнал [info]ddima

 
 
02 Ноябрь 2009 @ 08:12
Логи, часть первая  
Несмотря на то, что я писал про логгирование раньше, сейчас снова приходится возвращаться к этой теме. Причин этому несколько. Во первых, те решения, которые были в свое время озвучены в статье на DTF не только подтвердили свою правильную работоспособность, но также еще и были заметно расширены за последние годы. Во вторых, регулярно возникают ситуации, при которых логи от игр или не являются информативными, или наоборот, сверхинформативны, что жрет дисковое пространство и производительность. Ну и наконец, третья причина в том, что теперь статьи, которые делались для DTF, переносятся в архив и становятся недоступны.

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

1. Любое логгирование должно делать через одно место :). В смысле, не через то самое место, а через общий менеджер, который управляет всеми поступающими сообщениями. В то же время, система логгирования должна давать каждому конкретному программисту удобство в реализации и просмотре именно своих протоколов во время отладки своих подсистем (раскрыто в третьем пункте).

2. Любое логгирование должно иметь минимум 3 уровня сообщений - info, warn, err. На практике бывает удобна цифра 5, в комментариях поясню, почему именно так.

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

4. У системы логгирования может быть несколько настраиваемых outputs. Могут быть как стандартные outputs (stdout, file, OutputDebugString, ram pool), так и пользователские выходы, форматы и настройки всех outputs должны легко конфигурироваться независимо от лог-менеджера.

5. Менеджер должен также поддерживать возможность получения логов из многопоточного приложения и на всякий случай предусмотреть ресурсию, когда процесс обработки сообщения в каком-то из outputs приводит к получению повторного сообщения в протокол.

6. Должна быть возможность вырезать ненужные протоколы из игры (функциями препроцессора или еще как-то).

7. Система логгирования не должна занимать много времени, чтобы не влиять критическим образом на производительность и синхронизацию многопоточных приложений, а также не должна влиять на ключевые подсистемы приложения.

Теперь со всем этим мы попробуем взлететь.

1. Общий менеджер логгирования. Требуется для того, чтобы существовали общие универсальные функции, которые могут и должны использовать все программисты компании. За существование в коде приложения вызовов вида:

FILE* s = fopen("mylog.txt", "wt");
fwrite(s, "Init done\n");

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

2. Удобные уровни протоколирования следующие:
Debug - отладочные сообщения. К ним относятся - все сообщения о производительности компонент, сообщения, которые возникают больше, чем раз в секунду, вспомогательные сообщения, которые требуются программисту в процессе отладки, инспекты переменных и т.п. По умолчанию ошибки уровня Debug в логи не выдаются.
Report (или Info) - рабочие сообщения компонент. Эти сообщения выдаются редко (не чаще, чем раз в несколько секунд, их основная задача - информировать файл протокола (а в некоторых случаях - и пользователя) о существенных изменениях в работе приложения. Примеры таких сообщений: loading map ###, user ### joined, game saved, print operation complete и т.п.
Warning - предупреждения, которые не являются критическими для системы в целом (отдельные функции либо сохраняют работоспособность, либо имеют корректный возврат), но которые информируют программиста, что что-то идет не так, как запланировано. В некоторых случаях бывает крайне полезно добавить в код библиотеки вспомогательный счетчик, который будет выдавать warning в протокол, а затем сообщать, что последущие сообщения этого же плана будут исключены. А то часты случаи того, что критическое падение производительности системы наступает из-за того, что незначительный warning начинает засирать протокол со скоростью несколько мегабайт в секунду.
Error - серьезные ошибки, которые как правило ведут к неработоспособности той или иной подсистемы. Сюда относятся - файловые ошибки, ошибки основных функций DirectX, серьезные рассогласования внутренней логики приложения.
Crtiical (или Fatal) - ошибки верхнего уровеня, система находится в нестабльном состоянии и готова упасть. Либо это критические ошибки, которые делают невозможным функционирование приложения в целом. Класс Fatal - это протоколы внутри UnhandledExceptionFilter, диагностика нехватки иои повреждения хипов, глобальные отказы ключевых подсистем. Категории Error и Fatal разнесены исключительно ради удобства реализации некоторых outputs, будет показано в комментариях к седьмому пункту.

3. Маркеры сообщений требуются для того, чтобы можно было удобно настраивать уровни и способы протоколирования. Допустим, программист отлаживает свою подсистему. Он может для нее выставить уровень протоколирования Debug, для остальных будет сохранен уровень Report. Он получает подробный протокол, не мешая другим пользователям и не засирая их логи. Кроме этого, крайне удобным оказывается маркер "Extern File". Почему он нужен - также будет показано ниже.

4. Настраиваемые outputs, или различные регистрируемые коллбеки сообщений позволяют конфигурировать выход протокола так, как это требуется приложению, причем в разные моменты времени могут существовать разные outputs. При возникновении новой записи в протокол происходит вызов всех зарегистрированных обработчиков с передачей следующей информации:
- глобальный инкрементальный номер сообщения;
- время возникновения сообщения;
- сам текст сообщения;
- уровень сообщения (Debug/Report/Warning/Error/Fatal)
- маркер сообщения;
- имя файла;
- номер строки;
- функция возникновения сообщения (опционально);
- идентификатор процесса и/или треда (зависит от платформы и наличия нужных данных);
- дополнительная информация или пользовательские данные колбека;

Обработчик outputs может выполнить необходимые действия по выводу данной сообщения в свой канал. Как сказано выше, такими каналами могут быть: стандартные каналы вывода (файл, stdout, OutputDebugString, ram pool) и пользовательские каналы вывода (отправка в TCP/IP, игровая консоль и т.п.).

5. С этим пунктом все просто - достаточно добавить внутреннюю синхронизацию в менеджер логов. Тогда у каждого конкретного output не будет возникать проблем с выводом типа [Mess[Message2]age1], даже если сообщения проходят из разных тредов. Рекурсивное протоколирование можно складывать во временный буфер небольшого размера или даже попросту игнорировать. У нас сделано так: в случае первого рекурсивного вызова output получает сообщение с дополнительным параметром LOG2 (повторный лог из обработчика), последующие рекурсивные вызовы лога попросту игнорируются.

6. Функции логгирования должны выглядеть как LogDebug(), LogReport() и т.д. В этом случае препроцессор может превратить ненужные в мастер-версии протоколы в (void(0)) и не тратить время на протоколирование. Для PC-версий приложений для мастера рекомендуется удалять уровень Debug, для консольных игр - весь протокол целиком, для других платформ - зависит от специфики приложения и платформы. Количество аргументов при этом должно быть постоянным, поэтому удобнее всего встроить форматирование строки вывода при помощи << прямо в вызов функции. Разумеется, если для вывода лога требуется дополнительная подготовительная обработка, ее лучше окаймить препроцессором вручную.

7. Этот пункт накладывает определенные ограничения на использование функций в логах. В частности, из-за критически длительного времени работы и влияния на хип не рекомендуется использовать в логах std::string. Для файловых протоколов также удобным является вариант, при котором сохранение всех записей производится просто в файловых поток, а ошибки уровня Fatal, предвещающие скорое падение программы, обязательно добавляют flush внутренних буферов. Это, с одной стороны, не создает серьезных нагрузок для простых сообщений (а то я видел примеры того, как любая запись в лог - это секвенция fopen()/fprintf()/fclose()), с другой стороны, гарантирует, что при возникновении критических ошибок вся информация будет сброшена на диск.

Итого, вывод сообщений может регулироваться следующими уровнями протоколов:
- (на уровне менеджера) - общая обрезка сообщений, чей уровень протоколирования слабее заданного (обычно имеет смысл резать только Debug);
- (на уровне менеджера) - пропуск сообщений с какими-то идентификаторами, с персональной настройкой уровня сообщений;
- (на уровне менеджера) - общая обрезка рекурсивных сообщений;
- (на уровне конкретного обработчика output) - обрезка сообщений, чьи уровни протоколирования или идентификаторы не соответствуют установкам.
- (на уровне конкретного обработчика output) - форматирование конкретных сообщений так, как это удобно для вывода.

Последний пункт в свое время привел к интересной реализации системы протоколирования для консольных утилит. Стандартный подписчик output в stdout можно сконфигурировать таким образом, что он выводит сообщения как "(message body)" для всех логов уровня report и выше для всех маркеров  != "Extern File", и выводит сообщения как "(file) line: (type) (message body)" для всех сообщений с маркером "Extern File". Тем самым мы получаем на экране протокол следующего вида:

1С builder system.
Loading file data.ini...
data.ini(10) Error: undefined keyword 'type'
data.ini(55) Warning: duplicated source reference "TM124_A1"
Building canceled

Здесь сообщения из data.ini имеют маркер "Extern File" и сконфигурированы для вывода как {short file}({line} {type}: {message}. Остальные маркеры сконфигурированы как {Message}. При этом получается красивый и привычный для пользователя протокол.

При этом локальная конфигурация вывода в stdout не мешает тому, чтобы в файл выводились собщения вида:

[1018] TOOL c:\tools\src\main.cpp (56): Report: 1С builder system.
[1201] TOOL c:\tools\src\main.cpp (92): Report: Loading file data.ini...
[1403] EXTFILE d:\game\data\data.ini(10): Error: undefined keyword 'type'
[1404] EXTFILE d:\game\data\data.ini(55): Warning: duplicated source reference "TM124_A1"
[2123] TOOL c:\tools\src\main.cpp (418): Error: Building canceled

То есть {timestamp} {marker} {long file}({line}): {type}: {message}

Если потребуется, то простым грепом можно вычленить из этого файла протокола всю интересующую нас информацию.

Но, к сожалению, хороший и мощный менеджер протоколирования никоим образом не сделает наполнение лога осмысленной информацией. О том, как это сделать и как поддерживать данные в актуальном виде - во второй части.
Метки:
 
 
Current Location: home
 
 
( 18 комментариев — Оставить комментарий )
Семен[info]sim0nsays on Ноябрь, 2, 2009 07:42 (UTC)
Добавлю трюки, которым научила родная компания.

1) Circular buffer для логов замечательно работает для релизных билдов (скорее только во время разработки, но если вы планируете получать от пользователей дампы во время крешей - то и в релизе). Жрет константное количество памяти, с разумным буфером истории почти всегда хватает.

2) Вместо того, чтобы вызывать printf и форматировать сообщение, можно запомниать указатель на строку и параметры, отчего логи становятся совсем легкими (строка, очевидно, константная и лежит в data segment).
Для продвинутых: написать debugger extension, которое вызывает само printf
Для совсем продвинутых: вместо строки использовать аннотацию.
Дядя Дима[info]ddima on Ноябрь, 2, 2009 07:49 (UTC)
1) Circular buffer отлично работает, согласен. Особенно для некоторых outputs типа ram pool, когда начальные сообщения в логе некритичны, а интересны именно последние Error'ы (главное, чтобы они были не наведенными предыдущими ошибками, которые уже затерты в буфере).

2) Это все же требует определенной культуры программирования, версионирования и т.п., а сам протокол при отсутствии нужной информации делает практически бесполезным, поэтому я бы поостерегся такое рекомендовать :).
Семен[info]sim0nsays on Ноябрь, 2, 2009 08:11 (UTC)
1. Ага. Ну, на практике, не так сложно найти разумный разме буфера. Начать с 64 :)

2. Дык, для продвинутых же! Зато логами можно все усеивать практически.
yurymik[info]yurymik on Ноябрь, 2, 2009 08:25 (UTC)
Прям про нашу систему рассказ :-)

У нас только два существенных отличия:

1. пишем в лог id процесса/потока, которые вызвали Log()
2. все уровни логгирования доступны в релизе, достаточно подкрутить файл конфигурации. Система на лету подхватит и начнет писать в новый сет файлов.
Дядя Дима[info]ddima on Ноябрь, 2, 2009 08:33 (UTC)
> Прям про нашу систему рассказ :-)
Я, честно, не со зла ))))))

> У нас только два существенных отличия:
> 1. пишем в лог id процесса/потока, которые вызвали Log()
Согласен, эту информацию можно выдавать для outputs (подправил пункт 4 в описании). Т.е. не для конкретного обработчика вывода, а для всех outputs в целом - на уровне лог-менеджера.
У меня это не было указано, так как лог-менеджер кросс-платформенный и, как правило, либо эта информация отсутствует, либо в ней не возникает нужды.

> 2. все уровни логгирования доступны в релизе, достаточно подкрутить
> файл конфигурации. Система на лету подхватит и начнет писать в новый
> сет файлов.
Можно оставлять и абсолютно все уровни логгирования - это ничему не противоречит. Просто например для игровых консолей логи запрещены, поэтому тратить время на лишнюю обработку ну совсем не хочется.
Oleg[info]bishop3000 on Ноябрь, 3, 2009 08:29 (UTC)
Отличная статья!

Еще пара советов из личного опыта:
1. Если сохраняете в лог перечисляемый int или enum (т.н. "магические константы"), то не поленитесь перевести их в строку - это упростит чтение лога в разы. Что проще понять?
Map parsing error: 5
или
Map parsing error: 5 (too many trees in single map)

2. Я бы еще добавил уровень логгирования INSANE - туда можно выводить всё. Вход в каждую важную функцию, результаты этой функции, значения всех важных переменных и т.п.
Это очень помогает при отладке сложных приложений с множественными потоками или даже простых приложений, если что-то пошло не так за много строк до реальной видимой ошибки.
У меня бывали гигабайтные логи, которые помогали найти ошибку, которая случалась очень редко.

3. Согласен с sim0nsays про Circular buffer - отлично работают в релизной версии. Всегда есть где посмотреть что пошло не так и диск не заполняет.

4. Мы как-то делали еще в Лесте логгинг через пайпы, без диска - сообщения посылались по пайпу в другое приложение и оно уже запоминало и выводило его в UI. Это позволяет ускорить вывод лога в разы и выводить даже больше инфы, чем INSANE без особого ухудшения перформанса. Это может помочь отлаживать сложные ситуации, в которых много логов тормозит при записи в файл.


Дядя Дима[info]ddima on Ноябрь, 3, 2009 09:37 (UTC)
Спасибо, Олег!.
Впрочем, совет про строки вместо кодов - оно прямо таки противоречит учению Саймона про адреса строк вместо текстов (первый камент) :).

Про INSANE - честно говоря, я в затруднении. Я вот как раз занимаюсь второй частью статьи, и, честно говоря, не очень могу доходчиво рассмотреть разницу между уровнями Debug и Insane. На мой взгляд, Insane получается, когда программист разрешает Debug уровень не для своей компоненты, а для приложения в целом.

Про пайпы - да, согласен. Я пайпы в список стандартных outputs не вносил, но в рамках отдельной компании и ее тулсета, разумеется, это можно сделать.
Oleg[info]bishop3000 on Ноябрь, 3, 2009 10:11 (UTC)
>>оно прямо таки противоречит учению Саймона про адреса строк вместо текстов
>>(первый камент) :).

Я не стал писать про это в комменте, но я не люблю obfuscated logs. Да, они быстрее работают и меньше места занимают, но они добавляют доп. шаг при анализе - деобфускацию. А она не всегда возможна.
Вчера, например с этим столкнулся - анализировал дома один баг, получил логи, а они обфускейчены, а тулзы на работе для перевода в нужный вид. Вот и всё, пришлось отложить анализ...


>>На мой взгляд, Insane получается, когда программист разрешает Debug
>>уровень не для своей компоненты, а для приложения в целом.

Debug - это более упрощенный лог, чем INSANE. INSANE логгирует буквально всё. В функции на 10 строчек у меня бывает 2-3 вывода в лог INSANE. При этом может не быть ни одной DEBUG или только одна.
То есть, грубо говоря, DEBUG генерит 50Кб лога в секунду, а INSANE 1Mb, зато просмотрев лог INSANE всегда можно увидеть полную пошаговую последовательность выполнения программы.
Семен[info]sim0nsays on Ноябрь, 3, 2009 21:47 (UTC)
Ну, я надеюсь, все понимают, что шаг деобфускации должен быть автоматизирован, да?
Oleg[info]bishop3000 on Ноябрь, 4, 2009 07:07 (UTC)
>>Ну, я надеюсь, все понимают, что шаг деобфускации должен быть автоматизирован, да?

Естественно. Но от этого он не становится намного легче - нужно узнать про тулзу, найти тулзу, запустить, подождать и т.п.
Если ты не на работе, то тулзы может не быть.
Если ты исследуешь новый компонент, то можешь даже не знать про нее и ее надо искать.
Деобфускация и времени может немало занимать, если лог большой.

В общем, я согласен, что обфускация полезна в релизных логах, чтобы сократить размер, спрятать "особенности реализации" от юзера и улучшить скорость. Но в дебажных-то и спец. сборках с логами зачем логи обфускировать?
Семен[info]sim0nsays on Ноябрь, 4, 2009 07:12 (UTC)
Серьезно, это все легко преодолимые сложности при разумном тулсете.
В MS это debug extension, который сразу и всегда доступен в отладчике.

В дебаге, конечно, незачем, но не иметь же разные логи для релиза и дебага.

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

Oleg[info]bishop3000 on Ноябрь, 4, 2009 08:09 (UTC)
>>В дебаге, конечно, незачем, но не иметь же разные логи для релиза и дебага.

Я видел реализацию, в которой на уровне макросов логгирования это сделано - в релизе обфускейченный, в дебаге нет.

>>В MS это debug extension, который сразу и всегда доступен в отладчике.

Да, это было бы круто, у нас нет extension-ов для отладчиков. Да и тогда надо будет внедрять единую систему логов для всех компонентов, а у нас эт оочень сложно - все кто в лес кто по дрова пишет логи. Либо же для каждого лога писать свой extension, а это та же фигня, что и с деобфускацией.
В общем, у вас в MS с этим лучше дело обстоит судя по всему.
i_love_python[info]i_love_python on Ноябрь, 6, 2009 22:20 (UTC)
> а у нас эт оочень сложно - все кто в лес кто по дрова пишет логи.

У дяди Димы первый пункт гласит: все логи должны писаться через одно место.
Oleg[info]bishop3000 on Ноябрь, 6, 2009 22:25 (UTC)
Про одно место хорошо говорить, когда весь код пишется в одном месте и для одного проекта. А когда в компании несколько сотен разработчиков в нескольких странах и десятки проектов, то это уже не работает.
i_love_python[info]i_love_python on Ноябрь, 6, 2009 22:39 (UTC)
Как раз сегодня это обсуждалось у Алёны С++ про реюз кода, да. Круг замкнулся?
Oleg[info]bishop3000 on Ноябрь, 6, 2009 22:41 (UTC)
Ага. Там я тоже отметился комментарием с утра :)
Дядя Дима[info]ddima on Ноябрь, 7, 2009 13:47 (UTC)
Ну на самом деле - это тоже внедряемо, даже при распределенной разработке. Вообще в таком случае ИМХО крайне важно пусть даже с вазелином вталкивать свой "CRT-extension", который будет содержать в том числе и систему логгирования. И переводить при каждом удобном случае логи отдельных компонент в общую систему. Запрещать коммитить код с OutputDebugString(). Искать "log" и прочие зарактерные ключевые слова по репо. И т.п.
Oleg[info]bishop3000 on Ноябрь, 7, 2009 14:56 (UTC)
>>Вообще в таком случае ИМХО крайне важно пусть даже с вазелином вталкивать
>>свой "CRT-extension", который будет содержать в том числе и систему
>>логгирования.

В крупной компании "вталкивать с вазелином" снизу невозможно. Даже на уровне локального лида это невозможно - есть куча людей выше тебя или которые могут просто игнорировать твои "вталкивания".
Только сверху можно пропихнуть. А сверху это мало кому надо. У нас, например, даже в свой CRT extension не верят и не хотят на него тратить время и деньги.
( 18 комментариев — Оставить комментарий )