Simple structured logging in C++

Make your logs searchable and insightful. Easily.

Author
Affiliation
Published

September 19, 2025

TL;DR Bringing structure to your logs is important: jump to the code.

Why?

Inspecting the state of running software is useful in general, and in particular when you are implementing an algorithm and you want to keep track of counters, timers, check invariants and so on. Usually what we do is to sprinkle some print statements here and there, reporting the values of the variables we are interested in, along with some messages.

The resulting output eventually becomes a huge mess.

Since we want to find the information we are looking for easily, a more structured approach would be helpful. With structured logging each log line is no longer an unruly mess of plain text, but rather a structured string, usually formatted as JSON. This allows for easy ingestion by other tools that then then can provide search and filtering functionality. An more lightweight alternative to JSON (which might require the inclusion of additional dependencies in your code) is the logfmt format (line breaks added for readability):

level=INFO msg="Building tree" time=1758260458
  prefix=12 repetition=499 tree_weight=3634.18
  failure_probability=0.013879 max_edge_weight=5.17821
  mean_edge_weight=3.63781

Each log line is a collection of space-separated key-value pairs. If our software prints logs formatted in this way, then we can use a tool such as hl to nicely format, filter, and query our logs. Here’s a screenshot of the formatted output you can get with hl with log lines organized like the one above.

Furthermore, if you want you can query for the lines with spectific values. For instance, a failure-probability less than 0.01:

What?

With the code below you can instrument your software as follows:

int main() {
  set_log_level(LogLevel::INFO);

  LOG_INFO("msg", "this is a message",
           "counter", 10,
           "measure", 32.541);
  LOG_DEBUG("msg",
            "a debug message, will only be shown if the global level allows it",
            "elapsed_time_s", 0.42351);

  return 0;
}

How?

There are several options available for structured logging in C++, all rich with features. If you want to have something more lightweight that does not bring another dependency in your project, though, the following might be useful.

First, we define log levels, and a way of setting the global log level:

enum LogLevel {
  ERROR,
  WARNING,
  INFO,
  DEBUG,
  TRACE
};

static LogLevel CURRENT_LEVEL = LogLevel::INFO;

static void set_log_level(LogLevel level) {
  CURRENT_LEVEL = level;
}

Then, we need a function to format values for logging. In particular, we define a general template for a function that takes a value and puts it into std::cout.

template<typename T>
static void log_format(T value) {
  std::cout << value;
}

This is sufficient for basically all the values we might need to log, except for strings, which need to be enclosed in double quotes. For that we provide a template specialization, both for strings and for string literals:

template<>
void log_format(std::string value) {
  std::cout << "\"" << value << "\"";
}
template<>
void log_format(const char * value) {
  std::cout << "\"" << value << "\"";
}

We also need to format log levels:

template<>
void log_format(LogLevel value) {
  switch (value) {
    case LogLevel::ERROR:   std::cout << "ERROR"; break;
    case LogLevel::WARNING: std::cout << "WARN";  break;
    case LogLevel::INFO:    std::cout << "INFO";  break;
    case LogLevel::DEBUG:   std::cout << "DEBUG"; break;
    case LogLevel::TRACE:   std::cout << "TRACE"; break;
  }
}

Now, to actually do the logging with an arbitrary number of arguments, we define a recursive template that uses the log_format function to format the values. This function takes a literal string key, and a value that can be formatted with log_format. Possibly, there are other key-value pairs as well.

template<typename V, typename... Others>
static void do_log(const char * k, V v, Others... others) {
  std::cout << " " << k << "=";
  log_format(v);
  do_log(others...);
}

As with all recursive things, we need to define a base case:

static void do_log() {
  std::cout << std::endl;
}

To wrap it up, we set up a function that takes an arbitrary number of key-value pairs and a log level, checks if we should log the statement, and if so outputs it by adding the timestamp as well:

template<typename V, typename... Others>
static void log(LogLevel level, const char * k, V v, Others... others) {
  if (level > CURRENT_LEVEL) {
    return;
  }

  std::chrono::duration since_epoch = std::chrono::system_clock::now().time_since_epoch();
  auto time = std::chrono::duration_cast<std::chrono::seconds>(since_epoch);
  do_log("level", level, "time", time.count(), k, v, others...);
}

For convenience, we can provide the following macros:

#define LOG_ERROR(args...) log(panna::LogLevel::ERROR, args)
#define LOG_WARN(args...)  log(panna::LogLevel::WARN, args)
#define LOG_INFO(args...)  log(panna::LogLevel::INFO, args)
#define LOG_DEBUG(args...) log(panna::LogLevel::DEBUG, args)
#define LOG_TRACE(args...) log(panna::LogLevel::TRACE, args)

All the code!

Here’s all the code to put in a header.

#pragma once
#include <string>
#include <iostream>
#include <chrono>

enum LogLevel {
  ERROR,
  WARNING,
  INFO,
  DEBUG,
  TRACE
};

static LogLevel CURRENT_LEVEL = LogLevel::INFO;

static void set_log_level(LogLevel level) {
  CURRENT_LEVEL = level;
}

template<typename T>
static void log_format(T value) {
  std::cout << value;
}


template<>
void log_format(std::string value) {
  std::cout << "\"" << value << "\"";
}
template<>
void log_format(const char * value) {
  std::cout << "\"" << value << "\"";
}
template<>
void log_format(LogLevel value) {
  switch (value) {
    case LogLevel::ERROR:   std::cout << "ERROR"; break;
    case LogLevel::WARNING: std::cout << "WARN"; break;
    case LogLevel::INFO:    std::cout << "INFO"; break;
    case LogLevel::DEBUG:   std::cout << "DEBUG"; break;
    case LogLevel::TRACE:   std::cout << "TRACE"; break;
  }
}

static void do_log() {
  std::cout << std::endl;
}

template<typename V, typename... Others>
static void do_log(const char * k, V v, Others... others) {
  std::cout << " " << k << "=";
  log_format(v);
  do_log(others...);
}

template<typename V, typename... Others>
static void log(LogLevel level, const char * k, V v, Others... others) {
  if (level > CURRENT_LEVEL) {
    return;
  }

  std::chrono::duration since_epoch = std::chrono::system_clock::now().time_since_epoch();
  auto time = std::chrono::duration_cast<std::chrono::seconds>(since_epoch);
  do_log("level", level, "time", time.count(), k, v, others...);
}

#define LOG_ERROR(args...) log(panna::LogLevel::ERROR, args)
#define LOG_WARN(args...)  log(panna::LogLevel::WARN, args)
#define LOG_INFO(args...)  log(panna::LogLevel::INFO, args)
#define LOG_DEBUG(args...) log(panna::LogLevel::DEBUG, args)
#define LOG_TRACE(args...) log(panna::LogLevel::TRACE, args)

Integration with hl

With hl we can format and query the logs produced by the code above. The simplest thing is to simply pipe the output of your code into hl:

your-binary | hl -P

where -P disables the pager.

Most likely you want to save the log to a file to query it later. This can be easily accomplished by putting tee into the pipeline:

your-binary | tee logfile.log | hl -P

By doing this you can both see the log live while the software executes, and inspect it again (using hl logfile.log) after the fact.

Wrap up

Is this full-featured? Definitely not.

Is this very fast? Most likely not, so don’t put it into tight loops.

Is it useful? I think so ☺️