Readable logs in C++: practical techniques with logme

The primary goal of any logging library is not raw performance and not even API convenience. Its real purpose is to help produce readable logs in C++—logs that make it possible to quickly understand what is going on when something breaks.

That is why developers intentionally trade a bit of CPU time and system resources: a well-structured log saves far more time during debugging than it costs to generate.

So what actually makes logs readable? The library itself is just a tool. No matter how powerful it is, the decisive factor is the developer: how logging is configured and where messages are placed. Still, the capabilities of the library define how easy and effective this process can be.

In this article we will go through several practical aspects of log management using the logme library as an example.


Splitting information across multiple logs

The first thing worth discussing is channels (in some libraries these are called logger instances).

Separating information into different logs depending on the task is one of the simplest and at the same time most effective ways to improve readability.

For example, if you are building a proxy server that handles hundreds or thousands of requests, it makes sense to maintain a separate log per request. Errors or aggregated data can still go into a central log.

This approach gives immediate benefits:

  • it becomes much easier to analyze a specific request;
  • each individual log contains less “noise”;
  • problems can be localized faster.

From this, the importance of having multiple channels becomes obvious.


Integrating logs from third-party libraries

Consider a situation where you have TLS handling code that relies on a third-party library like openssl, and that library also writes logs.

Inside your own class methods everything is straightforward—you explicitly specify the channel. But what about external calls?

Passing the channel through every function quickly becomes inconvenient and clutters the code.

The solution is to set the channel at the thread level using LogmeThreadChannel(id). Then all functions executed within that thread, which do not explicitly specify a channel, will log to the correct destination.

void LibraryCall()
{
  LogmeI("message 1");
}

void MyObject::Method()
{
  LogmeThreadChannel(PCH);
  LogmeI(PCH, "message 1");

  LibraryCall();
}

Both messages will end up in the PCH channel, even if the external library knows nothing about it.


Using built-in data dumps

Working with binary data is common—for example in proxies or network services. Being able to inspect packet contents in logs is often extremely helpful.

Instead of writing custom code, you can use a built-in helper:

void HandlePacket(const void* data, size_t size)
{
  auto dump = Logme::DumpBuffer(data, size, 0);
  fLogmeI(MyChannel, "packet:\n{}", dump);
}

Example output:

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./. .

To avoid unnecessary overhead when logging is disabled, macros with the _Do suffix can be used:

void HandlePacket(const void* data, size_t size)
{
  fLogmeI_Do(
    MyChannel
    , auto dump = Logme::DumpBuffer(data, size, 0)
    , "packet:\n{}"
   , dump
 );
}

In this case the dump is generated only if the log entry is actually needed. A more detailed explanation of _Do macros is available in the article “Logging slows the system down even when it writes nothing”.


Naming threads

In multithreaded applications, thread identification is critical. However, numeric IDs are hard to read and do not convey meaning. In logme, threads can be named:

LogmeThreadName("ClientIO");

Instead of something like [:1234], the log will show [:ClientIO]. If the OutputFlags::ThreadTransition flag is enabled, the log will also include the history of thread name changes:

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] .

From this fragment you can clearly see that an H2 object was created in the context of thread 1D3C. When StartIO was called, the thread was renamed accordingly. After initialization, it became responsible for client I/O (ClientIO). When execution unwound, previous thread names were restored.

This makes it possible to track the full lifecycle and role of a thread while still preserving its numeric identifier.


Function tracing

The library provides macros for automatic tracing of function entry and exit, execution time, arguments, and return values. This allows you to get a detailed picture of program execution without manually logging every step.

A full description of these macros is beyond the scope of this article, but here is a simple example:

int MyProc(int i, const char* name)
{
  LogmePV(ARGS(i, name));
  return i + 1;
}
Output:
>> MyProc(): 123, abc
<< MyProc(): [2ms]

Shortening method names

Formatting flags often allow including the function name in log messages. This is useful, since it immediately shows the origin of a message.

However, fully qualified names are not always concise. If dozens or hundreds of messages come from the same namespace, they start to clutter the output.

For example:

namespace Connection
{
  namespace Protocol
  {
    namespace H2
    {
      void Func()
      {
        LogmeI(PCH, "blablabla");
      }
    }
  }
}

By default, the log might look like this:

2026-04-23 18:33:34:539 [:1234] Connection::Protocol::H2::Func(): blablabla
This is informative, but excessive—much of the text is repetitive and adds little value.

In logme, you can define shortening rules:

static const Logme::ShortenerPair shorteners[] =
{
  {"Connection::Protocol::H2", "[h2]"},
  {nullptr, nullptr}
};

PCH->SetShortenerPair(shorteners);

The result is much more compact:

2026-04-23 18:33:34:539 [:1234] [h2]::Func(): blablabla
Visual noise is reduced, while essential information remains clearly visible. Exactly what readable logs should achieve.

Conclusions

Readable logs in C++ do not appear by accident. Instead, they come from deliberate design decisions. A logging library only provides the building blocks. The final result depends on how they are used.

For example, channels help separate concerns. In addition, smooth integration of third-party code keeps logging consistent. Data should also be easy to inspect, especially when working with binary formats. At the same time, thread roles should be visible, not hidden behind numeric IDs. As a result, execution flow can be traced without extra effort. So redundant text can be reduced.

Therefore, the log is no longer a chaotic stream of messages. Instead, it becomes a practical diagnostic tool. In practice, this improves debugging speed and leads to a more stable system over time.

Leave a Reply