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
Level | Description |
Global | Generic level that represents all levels. Useful when setting global configuration for all levels. |
Trace | Information that can be useful to back-trace certain events - mostly useful than debug logs. |
Debug | Informational events most useful for developers to debug application. Only applicable if NDEBUG is not defined (for non-VC++) or _DEBUG is defined (for VC++). |
Fatal | Very severe error event that will presumably lead the application to abort. |
Error | Error information but will continue application to keep running. |
Warning | Information representing errors in application but application will keep running. |
Info | Mainly useful to represent current progress of application. |
Verbose | Information that can be highly useful and vary with verbose logging level. Verbose logging is not applicable to hierarchical logging. |
Unknown | Only 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
classInline 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 name | Type | Description |
Enabled | bool | Determines whether or not corresponding level for logger is enabled. You may disable all logs by using el::Level::Global |
To_File | bool | Whether or not to write corresponding log to log file |
To_Standard_Output | bool | Whether or not to write logs to standard output e.g, terminal or command prompt |
Format | char* | Determines format/pattern of logging for corresponding level and logger. |
Filename | char* | Determines log file (full path) to write logs to for corresponding level and logger |
Subsecond_Precision | uint | Specifies subsecond precision (previously called 'milliseconds width'). Width can be within range (1-6) |
Performance_Tracking | bool | Determines 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_Size | size_t | If log file size of corresponding level is >= specified size, log file will be truncated. |
Log_Flush_Threshold | size_t | Specifies 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:
Specifier | Replaced By |
%logger | Logger ID |
%thread | Thread ID - Uses std::thread if available, otherwise GetCurrentThreadId() on windows |
%thread_name | Use Helpers::setThreadName to set name of current thread (where you run setThreadName from). See Thread Names sample |
%level | Severity level (Info, Debug, Error, Warning, Fatal, Verbose, Trace) |
%levshort | Severity level (Short version i.e, I for Info and respectively D, E, W, F, V, T) |
%vlevel | Verbosity level (Applicable to verbose logging) |
%datetime | Date and/or time - Pattern is customizable - see Date/Time Format Specifiers below |
%user | User currently running application |
%host | Computer 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) |
%msg | Actual 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 thehandle()
interface. This interface receives a pointer toLogDispatchData
object as argument and does the actual logging. For instance, in the example below, thehandle()
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
, ifELPP_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 instanceCHECK(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