Как сделать логи читаемыми: практические приёмы на примере logme
Основное назначение библиотек логирования — вовсе не максимальная производительность и даже не удобство интерфейса. Их ключевая задача — помогать создавать читаемые логи, с которыми можно эффективно разбираться при возникновении проблем в работе программы.
Именно поэтому разработчики осознанно жертвуют частью процессорного времени и ресурсов системы: хорошо структурированный лог экономит на порядке больше времени при диагностике, чем стоит его генерация.
Что же делает логи читаемыми? Сама библиотека — лишь инструмент. Каким бы мощным он ни был, решающую роль играет разработчик: как он конфигурирует логирование и где именно добавляет сообщения. Тем не менее, именно возможности библиотеки определяют, насколько удобно и качественно это можно сделать.
В этой статье разберём несколько практических аспектов управления логами на примере библиотеки logme.
Распределение информации по отдельным логам
Первое, о чём стоит сказать — это каналы (в некоторых библиотеках аналогом являются инстансы логгера).
Разделение информации по разным логам в зависимости от задач — одно из самых простых и одновременно самых эффективных улучшений читаемости.
Например, если у вас есть прокси-сервер, обрабатывающий сотни или тысячи запросов, разумно вести отдельный лог для каждого запроса. При этом ошибки или агрегированная информация могут отправляться в центральный лог.
Такая организация даёт сразу несколько преимуществ:
- проще анализировать конкретный запрос;
- меньше “шума” в каждом отдельном логе;
- быстрее локализуются проблемы.
Отсюда очевидна ценность возможности создавать множество каналов.
Встраивание логов из сторонних библиотек
Рассмотрим ситуацию: есть код обработки TLS-соединения, и внутри него используется сторонняя библиотека, которая тоже пишет в лог.
Пока мы внутри методов своего класса — всё просто: мы явно указываем канал. Но как быть с внешними функциями?
Передавать канал во все вызовы — неудобно и перегружает код.
Решение — установка канала на уровне потока через LogmeThreadChannel(id). Тогда все функции, выполняемые в этом потоке и не указывающие канал явно, будут писать в нужное место.
void LibraryCall()
{
LogmeI("message 1");
}
void MyObject::Method()
{
LogmeThreadChannel(PCH);
LogmeI(PCH, "message 1");
LibraryCall();
}
Оба сообщения попадут в канал PCH, даже если библиотека ничего о нём не знает.
Использование встроенного дампа данных
Часто приходится работать с бинарными данными — например, при разработке прокси или сетевых сервисов. Возможность увидеть содержимое пакета в логах бывает крайне полезной.
Вместо написания собственного кода можно использовать встроенный хелпер:
void HandlePacket(const void* data, size_t size)
{
auto dump = Logme::DumpBuffer(data, size, 0);
fLogmeI(MyChannel, "packet:\n{}", dump);
}
Пример вывода:
2026-04-23 18:33:34:539 [:1234] HandlePacket(): packet 16 03 01 07 16 01 00 07 12 03 03 d3 d1 e3 f4 5b | ...............[ c0 a7 1c e9 b9 7a 42 41 93 54 29 a0 3b 8f 98 9b | .....zBA.T).;... 0e 33 3c b4 c4 06 e1 54 f0 d8 bc 20 e4 ee 60 d5 | .3<....T... ..`. 95 0f 4a 24 c2 e2 1e d0 78 9f ac ed 93 c9 19 ca | ..J$....x....... 16 5b c4 e7 99 ff 4a e0 eb 6e c0 2f 00 20 aa aa | .[....J..n./. ..
Чтобы избежать этого, используются макросы с суффиксом _Do:void HandlePacket(const void* data, size_t size)
{
fLogmeI_Do(
MyChannel
, auto dump = Logme::DumpBuffer(data, size, 0)
, "packet:\n{}"
, dump
);
}
В этом случае код генерации выполнится только если лог действительно нужен. Более подробно о Do макро можно прочитать в статье «Логирование тормозит систему даже когда ничего не пишет«
Именование потоков
При многопоточности идентификатор потока крайне важен. Но числовые ID плохо читаются и мало что говорят о роли потока. В logme можно задавать имена потоков:
LogmeThreadName("ClientIO");
Теперь вместо цифрового идентификатора потока такого как [:1234] мы увидим: [:ClientIO]. А при наличии бита OutputFlags::ThreadTransition в логе будет также история присваивания потоку имен:
2026-04-23 18:33:34:635 [:1D3C] [h2]::H2() ... 2026-04-23 18:33:34:635 [:1D3C -> StartIO] [h2]::StartIO(): Starting I/O 2026-04-23 18:33:34:635 [:StartIO] [proto]::RunServerIO(): Starting server I/O 2026-04-23 18:33:34:635 [:StartIO -> ClientIO] [h2]::DoIO(): >> IO(cli) ... 2026-04-23 18:35:56:151 [:ClientIO -> StartIO] . 2026-04-23 18:35:56:151 [:StartIO -> 1D3C] .
По приведенному отрывкам из лога видно, что в контексте потока 1D3C создался объект H2. Когда у него был вызван метод StartIO, он вызвал LogmeThreadName("StartIO"). После выполнения кода инициализации, этот поток далее используется для I/O с клиентом и было вызвано LogmeThreadName("ClientIO"). Когда произошел выход из функций, был выполнен возврат к предыдущим именам потока. Таким образом можно проследить всю цепочку и узнать какой численный идентификатор у потока, при этом всегда можно по логу увидеть роль потока.
Трассировка функций
Библиотека предоставляет макросы для автоматической трассировки входа и выхода из функций, времени выполнения, аргументов и возвращаемых значений. Эти возможности позволяют получить подробную картину выполнения программы без ручного логирования каждого шага. Подробное описание использования этих макро можно найти в этой статье. В рамках этой статьи приведем лишь небольшой пример использования этих макро:
int MyProc(int i, const char* name)
{
LogmePV(ARGS(i, name));
return i + 1;
}
>> MyProc(): 123, abc
<< MyProc(): [2ms]
Укорачивание имён методов
Флаги форматирования часто позволяют добавлять в лог имя функции, из которой пришло сообщение. Это удобно — можно сразу понять источник, не прибегая к поиску по коду.
Однако полные имена функций не всегда лаконичны. Если в логе десятки или сотни сообщений из одного пространства имён, они начинают засорять вывод и усложнять чтение.
Например, пусть у нас есть код:
namespace Connection
{
namespace Protocol
{
namespace H2
{
void Func()
{
LogmeI(PCH, "blablabla");
}
}
}
}
По умолчанию в логе мы увидим:
2026-04-23 18:33:34:539 [:1234] Connection::Protocol::H2::Func(): blablablaТакая запись информативна, но избыточна — значительная часть строки повторяется и не несёт новой информации.
В logme можно задать правила сокращения имён:
static const Logme::ShortenerPair shorteners[] =
{
{"Connection::Protocol::H2", "[h2]"},
{nullptr, nullptr}
};
PCH->SetShortenerPair(shorteners);
После этого лог станет компактнее и заметно проще для восприятия:
2026-04-23 18:33:34:539 [:1234] [h2]::Func(): blablablaВыводы
Читаемые логи — это не побочный эффект, а результат осознанного проектирования. Библиотека лишь предоставляет инструменты, но именно разработчик определяет, насколько эффективно они будут использованы.
На практике наибольший вклад в читаемость дают:
- разделение логов по каналам;
- прозрачная интеграция стороннего кода;
- удобное представление данных (например, дампы);
- понятные имена потоков;
- автоматическая трассировка;
- сокращение лишнего текста.
Если всё это использовать в комплексе, лог превращается из “потока текста” в полноценный инструмент диагностики. А это уже напрямую влияет на скорость разработки и стабильность системы.