Top C++ Logging Libraries Compared: How to Choose the Best One - part 3

Top C++ Logging Libraries Compared: How to Choose the Best One - part 3

Easylogging++

Easylogging++ is a logging library (previously single-header). From my observation, it is heavily inspired by glog. It has about ~3.8k stars on github, however, last commit was about a year ago and with about 270 open issues. It is released under MIT license and free to use and change.

Notable features

  • Thread- and type-safe

  • Custom patterns

  • Conditional and occasional logging

  • Crash handling

  • Performance tracking

  • STL logging

  • Interface to 3rd party logging libraries - Boost, Qt, etc.

  • Extensible to log your own objects

Getting started

According to the official README the single source file should be compiled along with the user program and not compiled as a library. A CMake target can be created to compile as a library, but for the sake of simplicity I decided to follow the official documentation

Here is how my CMakeLists.txt would look like

set(PROJECT_NAME "el-log-example")
project(${PROJECT_NAME})

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -stdlib=libc++")

include(FetchContent)

FetchContent_Declare(easylogging GIT_REPOSITORY https://github.com/abumq/easyloggingpp.git GIT_TAG master)
FetchContent_MakeAvailable(easylogging)

FetchContent_Declare(cxxopts GIT_REPOSITORY https://github.com/jarro2783/cxxopts.git GIT_TAG master)
FetchContent_MakeAvailable(cxxopts)

file(GLOB el_example_SRC
     "*.h"
     "*.cpp"
)

list(APPEND el_example_SRC "${CMAKE_BINARY_DIR}/_deps/easylogging-src/src/easylogging++.cc")

add_executable(${PROJECT_NAME} ${el_example_SRC})

set(test OFF CACHE BOOL "Build all tests" FORCE)

target_include_directories(${PROJECT_NAME} PUBLIC "${CMAKE_BINARY_DIR}/_deps/easylogging-src/src")
target_include_directories(${PROJECT_NAME} PUBLIC "${CMAKE_BINARY_DIR}/_deps/cxxopts-src/include")

target_link_options(${PROJECT_NAME} PRIVATE -lc++ -lc++abi)

Since I already know that fetching is done using constant prefix, I added the easylogging++.cc file to compilation using the pattern I already knew.

Basic usage

So, in order to use the Easylogging++ library, it has to be initialized using macro...what? Aren't we in 2024 and modern C++? Why macro?

OK, nevermind.

So, let’s dive in to the actual example.

INITIALIZE_EASYLOGGINGPP

void basic_usage()
{
    LOG(INFO) << "This is the most basic usage";
}

The output for that would be:

Configuration

Options to the Easylogging++ library can be passed through command line, and using the START_EASYLOGGINGPP macro, but since I use the cxxopts library for command line parsing, I won’t cover that option.

Severity levels

Easylogging++ does not use hierarchical logging by default. Nevertheless, there is still an option to use hierarchical logging by using the LoggingFlag::HierarchicalLogging flag

There are 9 levels of logging levels (copied from the project README.md

LevelDescription
GlobalGeneric level that represents all levels. Useful when setting global configuration for all levels.
TraceInformation that can be useful to back-trace certain events - mostly useful than debug logs.
DebugInformational events most useful for developers to debug application. Only applicable if NDEBUG is not defined (for non-VC++) or _DEBUG is defined (for VC++).
FatalVery severe error event that will presumably lead the application to abort.
ErrorError information but will continue application to keep running.
WarningInformation representing errors in application but application will keep running.
InfoMainly useful to represent current progress of application.
VerboseInformation that can be highly useful and vary with verbose logging level. Verbose logging is not applicable to hierarchical logging.
UnknownOnly applicable to hierarchical logging and is used to turn off logging completely.

Configuration methods

There are 3 possible ways to configure the library

  • Config file

  • el::Configurations class

  • Inline configuration

Config file

The great advantage of a configuration file is its flexibility - runtime configuration can be changed without recompiling.

The configuration is done per severity level, and a base format of a configuration file section is as below

* LEVEL:
  CONFIGURATION NAME  = "VALUE" ## Comment
  ANOTHER CONFIG NAME = "VALUE"

The following table contains all available configuration options

Configuration nameTypeDescription
EnabledboolDetermines whether or not corresponding level for logger is enabled. You may disable all logs by using el::Level::Global
To_FileboolWhether or not to write corresponding log to log file
To_Standard_OutputboolWhether or not to write logs to standard output e.g, terminal or command prompt
Formatchar*Determines format/pattern of logging for corresponding level and logger.
Filenamechar*Determines log file (full path) to write logs to for corresponding level and logger
Subsecond_PrecisionuintSpecifies subsecond precision (previously called 'milliseconds width'). Width can be within range (1-6)
Performance_TrackingboolDetermines whether or not performance tracking is enabled. This does not depend on logger or level. Performance tracking always uses 'performance' logger unless specified
Max_Log_File_Sizesize_tIf log file size of corresponding level is >= specified size, log file will be truncated.
Log_Flush_Thresholdsize_tSpecifies number of log entries to hold until we flush pending log data

An example can be seen below

* GLOBAL:
   FORMAT               =  "%datetime %msg"
   FILENAME             =  "/tmp/logs/my.log"
   ENABLED              =  true
   TO_FILE              =  true
   TO_STANDARD_OUTPUT   =  true
   SUBSECOND_PRECISION  =  6
   PERFORMANCE_TRACKING =  true
   MAX_LOG_FILE_SIZE    =  2097152 
   LOG_FLUSH_THRESHOLD  =  100 
* DEBUG:
   FORMAT               = "%datetime{%d/%M} %func %msg"

The code that uses this approach would look as below:

void configuration_file_example()
{
    el::Configurations cfg{ "example_config.conf" }; // Replace with the path to config
    el::Loggers::reconfigureLogger("default", cfg);

    LOG(INFO) << "Using global options";
    LOG(DEBUG) << "Using debug options";
}

And the output will be

2024-12-07 14:31:01,399828 Using global options
07/12 void configuration_file_example() Using debug options

el::Configurations class

This is actually the same class that is used when reading configuration from file, but instead of parsing the config file, the configuration is embedded in the code itself. This is a less flexible method, as recompilation will be needed each time the config is changed, but will protect the user from writing a malformed config file. However, the format is passed as a raw string, without checking its validity, so one must verify that all the specifiers are written correctly. The list of valid specifiers is given in the table below:

SpecifierReplaced By
%loggerLogger ID
%threadThread ID - Uses std::thread if available, otherwise GetCurrentThreadId() on windows
%thread_nameUse Helpers::setThreadName to set name of current thread (where you run setThreadName from). See Thread Names sample
%levelSeverity level (Info, Debug, Error, Warning, Fatal, Verbose, Trace)
%levshortSeverity level (Short version i.e, I for Info and respectively D, E, W, F, V, T)
%vlevelVerbosity level (Applicable to verbose logging)
%datetimeDate and/or time - Pattern is customizable - see Date/Time Format Specifiers below
%userUser currently running application
%hostComputer name application is running on
%file*File name of source file (Full path) - This feature is subject to availability of __FILE__ macro of compiler
%fbase*File name of source file (Only base name)
%line*Source line number - This feature is subject to availability of __LINE__ macro of compile
%func*Logging function
%loc*Source filename and line number of logging (separated by colon)
%msgActual log message
%Escape character (e.g, %%level will write %level)

A custom format specifier can be created, as described here

An example of using el::Configurations class:

void configuration_class_example()
{
    el::Configurations defaultConf;
    defaultConf.setToDefault();

    defaultConf.set(el::Level::Info, el::ConfigurationType::Format, "%datetime %level %msg");

    el::Loggers::reconfigureLogger("default", defaultConf);
    LOG(INFO) << "Log using default file";

    defaultConf.setGlobally(el::ConfigurationType::Format, "%datetime %msg"); 
    el::Loggers::reconfigureLogger("default", defaultConf);

    LOG(INFO) << "Log using default file after reconfiguration";
}

And the output will be:

2024-12-07 14:53:22,346 INFO Log using default file
2024-12-07 14:53:22,346 Log using default file after reconfiguration
``

## Logging
So, after configuring the logger, we should see how we actually can use it. 
For logging, 2 macros provide the logger functionality
1. `LOG(LEVEL)`, which uses the default logger
2. `CLOG(LEVEL, ID)` uses the custom logger, where one should specify the logger ID. 
For example: 
```cplusplus
LOG(INFO) << "Info log written to the default logger";
CLOG(ERROR, "performance") << "Performance dropped and it is written to the performance logger";

From now on, I will use the LOG macro, but every example shown applies also to CLOG

Conditional logging

LOG_IF(condition, LEVEL) is used when the developer wants to log a message only if certain condition happens, for instance:

for (auto i = 0; i < 10; ++i)
{
    LOG_IF(i < 5, INFO) << "i is less than 5, " << i; 
}

Its output will be:

2024-12-07 21:41:57,496 INFO [default] i is less than 5, 0
2024-12-07 21:41:57,496 INFO [default] i is less than 5, 1
2024-12-07 21:41:57,496 INFO [default] i is less than 5, 2
2024-12-07 21:41:57,496 INFO [default] i is less than 5, 3
2024-12-07 21:41:57,496 INFO [default] i is less than 5, 4

Occasional and hit-count based logging

LOG_EVERY_N(n, LEVEL) will be written if it's hit n times, for example:

for (auto i = 0; i < 10; ++i)
{
    LOG_EVERY_N(5, INFO) << "Log every 5th time " << i;
}

Will output

2024-12-07 21:39:11,255 INFO [default] Log every 5th time
2024-12-07 21:39:11,255 INFO [default] Log every 5th time 4
2024-12-07 21:39:11,255 INFO [default] Log every 5th time 9

And LOG_AFTER_N will be written when the hit count is greater than n, for example:

for (auto i = 0; i < 10; ++i)
{
    LOG_AFTER_N(5, INFO) << "i > 5 " << i;
}

Will output:

2024-12-07 21:39:11,255 INFO [default] i > 5 5
2024-12-07 21:39:11,255 INFO [default] i > 5 6
2024-12-07 21:39:11,255 INFO [default] i > 5 7
2024-12-07 21:39:11,255 INFO [default] i > 5 8
2024-12-07 21:39:11,255 INFO [default] i > 5 9

printf-like logging

Luckily, with C++11 we got variadic templates, and the Easylogging++ printf-like logging is based on it. The logger object can be retrieved from the loggers registry using the getLogger() method, and it provides us with logging methods for each level. The only difference is that when using printf, each type has to be logged using a dedicated format-specifier, and Easylogging++ doesn't need that (as long as the object is printable), and uses a single %v format specifier. The following are the function signatures:

  • info(const char*, const T&, const Args&...)
  • warn(const char*, const T&, const Args&...)
  • error(const char*, const T&, const Args&...)
  • debug(const char*, const T&, const Args&...)
  • fatal(const char*, const T&, const Args&...)
  • trace(const char*, const T&, const Args&...)
  • verbose(int vlevel, const char*, const T&, const Args&...)

Easylogging++ also allows to log STL objects by defining the ELPP_STL_LOGGING macro at compile time. Example:

void printf_logging()
{
    decltype(auto) defaultLogger = el::Loggers::getLogger("default");

    std::vector<int> v;
    for (auto i = 0; i < 5; ++i)
    {
        v.push_back(i);
    }

    std::map<int, std::string> m = { { 1, "one" }, { 2, "two" }, { 3, "three" } };
    defaultLogger->warn("Printing vector: %v", v);
    defaultLogger->info("Printing map: %v", m);
}

This example will produce the following output:

2024-12-07 22:04:12,577 WARNING [default] Printing vector: [0, 1, 2, 3, 4]
2024-12-07 22:04:12,577 INFO [default] Printing map: [(1, one), (2, two), (3, three)]

Custom logging methods

Easylogging++ library allows to implement a custom method using log dispatcher API. For that, several things have to be done:

  • Implement class that derives from el::LogDispatchCallback interface, which provides the handle() interface. This interface receives a pointer to LogDispatchData object as argument and does the actual logging. For instance, in the example below, the handle() method implements sending the log message to a log server.
  • Register the class using the el::Helpers::installLogDispatchCallback API.

A complete example can be observed here

Registering and unregistering loggers

Each logger is identified using an unique ID in the loggers repository. A new logger can be registered using the same API as retrieving an existing logger using the getLogger() function from el::Loggers class. This function receives 2 parameters. The first is logger ID, and the second one is optional boolean that tells the repository whether to register a new logger if the ID does not exist already in the repository (defaults to true). If the second parameter is set to false, and the logger does not exist in the repository, the function will return nullptr. By default, Easylogging++ registers 4 loggers:

  • Internal logger
  • Default logger (ID: default)
  • Performance logger (ID: performance)
  • Syslog logger(ID: syslog, if ELPP_SYSLOG macro is defined)

A new logger can be registered in the following way: el::Logger* customLogger = el::Loggers::getLogger("custom");

Note: When a new logger is created, default configurations are used.

One can unregister the logger using the el::Loggers::unregisterLogger() API that takes the logger ID as a parameter. A list of currently registered loggers can be retrieved using el::Loggers::populateAllLoggerIds(std::vector<std::string>&) function. This should be a rare situation where such a list would be needed.

Additional features

Apart from the features already mentioned, below are several more interesting features

Performance tracking

In order to use this feature, the macro ELPP_PERFORMANCE_TRACKING should be enabled.

There are 3 defined macros (which, according to the README.md, differ from the way it was done in older versions, the observed version is 9.97.1)

  • TIMED_FUNC(obj-name)
  • TIMED_SCOPE(obj-name, block-name)
  • TIMED_BLOCK(obj-name, block-name)

Let's consider the example below

void performance_logging()
{
    using namespace std::literals;

    TIMED_FUNC(functionTimer);
    std::vector<int> v;

    for (auto i = 0; i < 1'000'000; ++i);

    for (auto i = 0; i < 10; ++i)
    {
        TIMED_SCOPE(scopeTimer, "single-iteration");
        v.push_back(i);
        std::this_thread::sleep_for(150ms);
    }    
}

The output would be

2024-12-14 20:29:41,498 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:29:41,654 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:29:41,810 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:29:41,967 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:29:42,123 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:29:42,279 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:29:42,432 INFO Executed [single-iteration] in [152 ms]
2024-12-14 20:29:42,588 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:29:42,745 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:29:42,901 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:29:42,901 INFO Executed [void performance_logging()] in [1559 ms]

As can be seen, both TIMED_FUNC and TIMED_SCOPE functions are used in this example, while TIMED_SCOPE prints out every iteration, and TIMED_FUNC prints only upon function exit.

One may ask himself - why the object-name is needed. The answer to that is that Easylogging++ provides an ability to add checkpoints. For example, if we consider the previous code snippet and would like to get intermediate results for the functionTimer object, we can consider the following code:

void performance_checkpoints()
{
    using namespace std::literals;

    TIMED_FUNC(functionTimer);
    std::vector<int> v;

    for (auto i = 0; i < 1'000'000; ++i);

    for (auto i = 0; i < 10; ++i)
    {
        TIMED_SCOPE(scopeTimer, "single-iteration");
        v.push_back(i);
        std::this_thread::sleep_for(150ms);
        if (i % 3 == 0)
        {
            PERFORMANCE_CHECKPOINT(functionTimer);
        }
    }    
}

This will produce the following output:

2024-12-14 20:39:48,703 INFO Performance checkpoint for block [void performance_checkpoints()] : [152 ms]
2024-12-14 20:39:48,703 INFO Executed [single-iteration] in [153 ms]
2024-12-14 20:39:48,863 INFO Executed [single-iteration] in [159 ms]
2024-12-14 20:39:49,020 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:39:49,177 INFO Performance checkpoint for block [void performance_checkpoints()] : [626 ms ([473 ms] from last checkpoint)]
2024-12-14 20:39:49,177 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:39:49,334 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:39:49,491 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:39:49,643 INFO Performance checkpoint for block [void performance_checkpoints()] : [1092 ms ([465 ms] from last checkpoint)]
2024-12-14 20:39:49,643 INFO Executed [single-iteration] in [152 ms]
2024-12-14 20:39:49,800 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:39:49,953 INFO Executed [single-iteration] in [153 ms]
2024-12-14 20:39:50,110 INFO Performance checkpoint for block [void performance_checkpoints()] : [1559 ms ([466 ms] from last checkpoint)]
2024-12-14 20:39:50,110 INFO Executed [single-iteration] in [156 ms]
2024-12-14 20:39:50,110 INFO Executed [void performance_checkpoints()] in [1559 ms]

As can be seen from the log, the checkpoint prints both the current elapsed time, and the time passed from the last checkpoint.

Checkpoint can also be tagged with an ID, by using PERFORMANCE_CHECKPOINT_WITH_ID(obj-name, "id") instead.

The TIMED_FUNC and TIMED_SCOPE have also a conditional variant, where the performance will be measured only if the condition provided resolves to true. This is achieved using the TIMED_FUNC_IF(obj-name, condition) and TIMED_SCOPE_IF(obj-name, block-name, condition) macros.

Log rotating

This is configured using Max_Log_File_Size parameter in the configuration file or loggerConf.setGlobally(el::ConfigurationType::MaxLogFileSize, "size"); (The "size" is enclosed with braces since the parameter is passed as string, but it should be a valid number. More info is provided in the following exampe

Crash handing

The feature is enabled by default, and can be suppressed by defining the ELPP_DISABLE_DEFAULT_CRASH_HANDLING macro at the compile time. The stack trace is not printed by default, to do so, the ELPP_FEATURE_CRASH_LOG macro should be defined at compile time. However, stack trace is available only on gcc. I compiled all code with clang, therefore I could not provide a working example, however an example can be seen here

Additional features

  • Easylogging++ is not thread-safe by default, however if one needs it to be thread-safe, a ELPP_THREAD_SAFE macro should be defined.
  • Easylogging++ provides a set of CHECK_ macros, so that one can check whether a certain condition is fullfilled, for instance CHECK(i == 5) << "i != 5";
  • Several more log types, such as perror, syslog, STL, Qt, Boost and wxWidgets objects logging

Logging a custom class

To log a custom class, the class has to inherit the el::Loggable class that provides the log() interface, let's see an example

class CustomLoggable : public el::Loggable
{
public: 
    CustomLoggable(const int integer, const float floating_point, const std::string str)
        : m_integer{ integer }
        , m_floating_point{ floating_point }
        , m_string{ str }
    {}

    void log(el::base::type::ostream_t& os) const override 
    {
        os << "Integer: [" << m_integer << "], float: [" << m_floating_point << "], string: [" << m_string << "]";
    }

private:
    int m_integer;
    float m_floating_point;
    std::string m_string;
};

void custom_object_logging()
{
    CustomLoggable cl{ 5, 3.14f, "Who am I?" };

    LOG(INFO) << cl;
}

As we can see, the log() interface is implemented by logging to a stream that provided as parameter. The output would be the following:

2024-12-15 09:12:25,957 INFO [default] Integer: [5], float: [3.14], string: [Who am I?]

A very nice feature that comes with custom logging abilities is making a class that does not inherit el::Loggable still loggable (with a limitation of being able to access the desired members) in the following way:

class CustomNonLoggable
{
public: 
    CustomNonLoggable(const int integer, const float floating_point, const std::string str)
        : m_integer{ integer }
        , m_floating_point{ floating_point }
        , m_string{ str }
    {}

    std::tuple<int, float, std::string> GetMembers() const
    {
        return { m_integer, m_floating_point, m_string };
    }

private:
    int m_integer;
    float m_floating_point;
    std::string m_string;
};

inline MAKE_LOGGABLE(CustomNonLoggable, cnl, os)
{
    auto [integer, floating_point, str] = cnl.GetMembers();

    os << "Integer: [" << integer << "], float: [" << floating_point << "], string: [" << str << "]";

    return os;
}

void third_party_logging()
{
    CustomNonLoggable cnl{ 5, 3.14f, "Am I non-loggable? "};

    LOG(INFO) << cnl;
}

Implementing the MAKE_LOGGABLE macro will do the trick.

Summary

It took me a while to cover that library. In general, it can be seen that the style is heavily inspired by glog, which I've covered here, however, it is much more feature-rich than glog. The main pros of this library is its rich features, extensibility and simplicity. The cons are that it is macro-based (personal opinion) and that it seems not maintained anymore (last commit about a year ago, ~270 open issues and ~29 open pull requests).

In the next post, I will cover plog