Simple, Thread Safe Error Logging in C++
Why?
One you have written half of your first non-trivial application, you'll have discovered the desire for simple, omni-present, logging ability. Half way through your first library you'll likely have written one or imported one of the multitude of logging frameworks already out there.
It should be noted that this is an attempt to write an exceptions and error logging framework. If your need is for transactions logging and will be logging frequent events then that calls for different design decisions than will be made in this article.
The most popular logging frameworks, eg. log4cxx, implement many, many more than the necessary feature set and so a good argument can be made for a local - simplified - implementation. But what features are needed in a logging framework?
- Thread Safe
- Not only must the logging be thread safe, it must be thread consistent. That is to say all parts of a log message must appear continuously in the log output.
- Sane Syntax
- It must rely on a minimum of macros, magic numbers, templates and voodoo to preserve readability, maintainability and user sanity.
- Instantiation
- It must be possible to use the logging framework in shared object code, but delay instantiation of the actual logging engine to allow an application to define how the actual logging is done.
- Simple
- It must be easy to use and to share among the numerous projects which are sure to use it. As such the fewer linker knobs the better, header only would be ideal.
An ideal use case could look something like this.
#include "log.hpp"
int main()
{
if (1 != 2)
{
log(ERROR) << "This is an ERROR message.";
}
return 0;
}
First Step
To live up to our use case, we start by defining an enum to hold our list of log levels and a function log(const LOGLEVEL& lvl). To enable the use case log() must return a type which implements operator<<().
#ifndef _LOG_HPP_
#define _LOG_HPP_
#include <sstream>
enum LOGLEVEL {EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFORMATIONAL, DEBUG};
class Message
{
private:
LOGLEVEL _lvl;
std::stringstream _msg;
public:
Message(std::shared_ptr<const Engine> engine, const LOGLEVEL& lvl) :
_lvl(lvl), _msg() {}
template <typename T>
Message& operator<<(const T& rhs)
{
_msg << rhs;
return *this;
}
}; // Message
Message log(const LOGLEVEL& lvl)
{
return Message(lvl);
}
#endif // _LOG_HPP_
So far this will enable the desired syntax, but, of course, not yet do anything with this log messages.
Note that we cannot let the operator<<() send the messages to the log itself, since having this function doing that would allow one thread to print part of its message, then have another thread take over the stream and print parts of its message. To avoid this log interleaving we must print all the message in one go.
Second Step
To get to a useful logger we must no add a mechanism for writing our log. To that end we add an abstract log engine, and pass the log message to the engine when the log message is destroyed. We also add two new functions to set and to retrieve the logging engine.
#ifndef _LOG_HPP_
#define _LOG_HPP_
#include <sstream>
enum LOGLEVEL {EMERGENCY, ALERT, CRITICAL, ERROR, WARNING, NOTICE, INFORMATIONAL, DEBUG};
class Engine
{
private:
LOGLEVEL _lvl;
public:
Engine(const LOGLEVEL& lvl = DEBUG) : _lvl(lvl) {}
virtual ~Engine() {}
void log_level(const LOGLEVEL& lvl) {_lvl = lvl;}
LOGLEVEL& log_level() {return _lvl;}
const LOGLEVEL& log_level() const {return _lvl;}
virtual void log(const LOGLEVEL& /* lvl */, const std::string& /* msg */) const {}
}; // Engine
class Message
{
private:
std::shared_ptr<const Engine> _engine;
LOGLEVEL _lvl;
std::stringstream _msg;
public:
Message(std::shared_ptr<const Engine> engine, const LOGLEVEL& lvl) :
_engine(engine), _lvl(lvl), _msg() {}
~Message()
{
if (_engine && _engine->log_level() >= _lvl)
{
_engine->log(_lvl, _msg.str());
}
}
template <typename T>
Message& operator<<(const T& rhs)
{
_msg << rhs;
return *this;
}
}; // Message
std::shared_ptr<const Engine>& log_engine()
{
static std::shared_ptr<const Engine> engine;
return engine;
}
void log_engine(std::shared_ptr<const Engine> engine)
{
log_engine() = engine;
}
Message log(const LOGLEVEL& lvl)
{
return Message(log_engine(), lvl);
}
#endif // _LOG_HPP_
We now have a logging engine which defaults to doing nothing, but can be redefined by applications or libraries later. Notice, the design will only write logs when Message is destroyed, this is the price paid for a clean syntax.
Final Step
While we could include the logging framework in an application and use it now, it suffers from the fact that its classes will be instantiated in every translation unit, which limits its usefulness. Thankfully, we have a tool to tell the linker to only generate one instance. We will mark our singleton functions as extern inline.
...
}; // Message
extern inline std::shared_ptr<const Engine>& log_engine()
{
static std::shared_ptr<const Engine> engine;
return engine;
}
extern inline void log_engine(std::shared_ptr<const Engine> engine)
{
log_engine() = engine;
}
extern inline Message log(const LOGLEVEL& lvl)
{
return Message(log_engine(), lvl);
}
#endif // _LOG_HPP_
We can now include the logger in an application without fear of having multiple definitions of the logger.
The Logger in Use
Use the logging framework like this.
#include "log.hpp"
#include <cstdio>
class CerrLogEngine : public Engine
{
public:
CerrLogEngine(const LOGLEVEL& lvl) : Engine(lvl) {}
virtual void log(const LOGLEVEL& lvl, const std::string& msg) const
{
// C style to avoid importing iostream.
std::fprintf(stderr, "Log (%d) %s\n", lvl, msg.c_str());
}
}; // CerrLogEngine
int main()
{
// Set CerrLogEngine as the logging engine.
std::shared_ptr<const Engine> logengine = std::make_shared<const CerrLogEngine>(DEBUG);
log_engine(logengine);
if (1 != 2)
{
log(ERROR) << "This is an error message!";
}
{
log(INFORMATIONAL) << "This is an information message! "
<< "It needs a scope to make sure the Message destructor is run "
<< "and the log is written.";
}
log(ALERT) << "This message will be written when the application terminates.";
return 0;
}
NOTE that the call to fprintf is not explicitly locked since POSIX guarantees stdio operations act as if locked. See flockfile documentation. If you are using shared resources in the engine implementation these must be locked or atomic.