Writing a logging system

No matter what game we are making, robust logging is essential. It is an essential tool for the developer (and for more technical players), assisting both during development and after a game is release.

My custom C++ engine, Genesis, has had a logging from the earliest days, but it has received some upgrades recently to make it easier to use. I thought it might be helpful to other developers to write an article about the considerations which go into writing such a system, as well as share some code snippets.

Where to log to?

For the bare minimum, we want to log text to the terminal and to a file. But it is also useful to e.g. show a message box if we’re logging a warning or an error, and if you’re running Visual Studio getting any log messages printed to the debug window is also useful.

Additional wishes could include logging into an in-game window or console, or to log messages from more than one process.

To make this system flexible, each one of these is a ILogTarget. Each log target has its own bit of logic, and the logging system simply iterates through all registered targets whenever it is asked to log something.

For example, if we wanted to log to a file, we could set up something like this:

FileLogger::FileLogger(const std::filesystem::path& filePath)
{
  m_File.open(filePath, std::fstream::out | std::fstream::trunc);
}

FileLogger::~FileLogger()
{
  if (m_File.is_open())
  {
    m_File.close();
  }
}

void FileLogger::Log(const std::string& text)
{
  if (m_File.is_open())
  {
    m_File << text << std::endl;
    m_File.flush();
  }
}

Most log targets are registered as soon as possible after main(), while others can be registered further down the line if they depend on other systems.

Log levels

Not every message in a log is equal: some are purely informational, such as an entry showing the player’s progress through the game, while others report critical failures which are followed by the process terminating.

As such, I recommend at least three log levels: Informational, Warning and Error. A fourth one, Debug, can be useful for systems which you are only occasionally interested in and generate too much output. This Debug level can be activated via e.g. a command line switch, but otherwise any Debug log messages are silent.

Multithreading

Additionally, care needs to be taken with handling multiple threads. Many simpler games might be single threaded, but once you go past a certain level of complexity there is no guarantee that your logging function will only be called from the main thread. Care must then be taken to make the logging system thread safe, as otherwise your program will either crash or you’ll end up with corrupt logs.

Deciding on the basic API

Logging should be as fuss-free as possible: you don’t want to have to write a lot of code to log a message. The easier it is to use, the more likely you are to make extensive use of logging and to be able to backtrack an issue.

My current implementation is a straightforward monostate object and it mimics the stream interface most people are familiar with. As an example:

Log::Warning() << "SDL failed to initialize: " << SDL_GetError();

I find this significantly better than using the older string format functions (sprintf and it’s many variants), particularly with multiplatform code, where Windows has a different set of functions (e.g. _snprintf_s) and where you might have to deal with wide strings.

Bringing all together

Taking all the above into the account, the Genesis’ Log system’s declaration looks something like this:

// Contains any number of ILogTargets, which are responsible for actually
// logging the message in various ways.
// This class is thread safe.

using LogTargetSharedPtr = std::shared_ptr<ILogTarget>;

class Log
{
public:
  enum class Level
  {
    Info,
    Warning,
    Error,

    Count
  };

  class Stream
  {
  public:
    Stream(Level level);
    ~Stream();

    template <typename T> Stream& operator<<(T const& value)
    {
      m_Collector << value;
      return *this;
    }

  private:
    Level m_Level;
    std::ostringstream m_Collector;
  };

  static Stream Info();
  static Stream Warning();
  static Stream Error();

  static void AddLogTarget(LogTargetSharedPtr pLogTarget);
  static void RemoveLogTarget(LogTargetSharedPtr pLogTarget);

private:
  using LogTargetList = std::list<LogTargetSharedPtr>;

  static void LogInternal(const std::string& text, Log::Level level);

  static std::mutex m_Mutex;
  static LogTargetList m_Targets;
};


// ILogTarget
// Any ILogTarget must implement Log().
class ILogTarget
{
public:
  virtual ~ILogTarget() {}
  virtual void Log(const std::string& text, Log::Level level) = 0;

protected:
  static const std::string& GetPrefix(Log::Level level);
};

It’s quite straightforward, with the exception of the odd Log::Stream inner class. This is the definition:

Log::Stream::Stream(Log::Level level)
{
  m_Level = level;
}

Log::Stream::~Stream()
{
  Log::LogInternal(m_Collector.str(), m_Level);
}

This lets us override operator<<, placing our text in a temporary collector stream. When the object is destroyed, it calls LogInternal(), which has a mutex to prevent concurrent asset. Implementing operator<< in this way ensures that each line of the log is atomic.

The full code

You can find the full source here:

Feel free to use it in your own projects.

Solace-10 Written by:

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *