CodeGym /Courses /C++ SELF /Logging: info/warn/error levels, single format

Logging: info/warn/error levels, single format

C++ SELF
Level 67, Lesson 3
Available

1. Logging is not the same as std::cout

If until now you wrote programs “for yourself”, it's very easy to start printing everything to std::cout: “read an argument”, “entered if”, “exited loop”, “mom, I'm a programmer”. Does it work? Kind of. But as soon as you make a CLI utility, an unpleasant fact emerges: std::cout is often read not by humans, but by other programs.

Imagine your utility prints a list of tasks, and the user wants to redirect output to a file: app --list > tasks.txt. If you write “DEBUG: entered parse_args” into the same stream, the user's file will contain junk. They won't be happy. You won't be happy. Only chaos will be happy.

Therefore we introduce a discipline: the result goes to std::cout, and diagnostics (including logs) go to std::cerr. cerr exists exactly for this: it separates “normal output” from “messages about problems/process”. That's also why in real life many utilities write progress and warnings to stderr, and data to stdout.

You can remember a simple analogy. std::cout is “what you promised to the user by contract”. std::cerr is “how you grumble to yourself when something goes wrong”.

Small stream diagram (to fix the idea visually):

flowchart LR
    A["Your code"] -->|result| OUT["std::cout (stdout)"]
    A -->|diagnostics/logs| ERR["std::cerr (stderr)"]
    OUT --> F["redirection > file.txt"]
    ERR --> T["terminal / error log"]

2. Log levels: info, warn, error

When it comes to logs, beginners often want either “log nothing” or “log everything”. Both approaches usually end the same way: you still find nothing. In the first case — because there is no information. In the second — because there is too much information, and the needed line drowns in an ocean of “entered function”.

Therefore we take a minimal but useful set of levels:

  • info — neutral messages about program progress (started, parsed mode, began operation).
  • warn — something odd, but the program continues to work (for example, the user gave an option but the value is suspicious; or part of the input was ignored).
  • error — an error that prevents the action from being performed (bad arguments, unknown command, missing required parameter).

Important: level is not “styling”. It's the meaning. And meaning affects two decisions: where to print and what to do next.

Let's fix this with a small table (tables are not bullet lists; they try to save your eyes):

Level What the message is about Does the program continue? Typical stream
INFO
"What we are doing" Yes often std::cerr (diagnostics) or can be turned off
WARN
"Strange, but tolerable" Yes std::cerr
ERROR
"Cannot perform" Usually no (or skip the command in interactive mode) std::cerr

In this lecture we do not build a complex log filtering system, do not add “debug/trace”, do not write to files. Our goal is simpler: a single format and minimal structure, so your messages look like those of a grown-up program, not like a cry from the heart in cout.

3. Single log format: stability over beauty

If logs have no format, each time you want to find something by eye you waste time "guessing how I decided to print errors today". A format solves two practical tasks: quick visual scanning and quick text search (even with plain Ctrl+F).

We'll take a simple format:

[LEVEL] message

For example:

[INFO] mode=interactive
[WARN] unknown option ignored: --colour=neon
[ERROR] --name requires a value

Why square brackets — it's not mandatory. It could be “INFO:” or “(INFO)”. But when the format is fixed, you stop arguing with yourself and start writing code.

Another important point: the logger should not live all over the code. That is, don't manually write std::cerr << "[ERROR]" ... everywhere. Otherwise in a week half the messages will miss the ], a quarter will miss a newline, and one message will accidentally go to cout. We make a single function log(...), and the format lives in one place.

4. Micro-template switch: enum class → text

Now we need a convenient representation of levels. The simplest (and good) way is enum class. It is safer than "int-levels" because the compiler won't let you accidentally pass 42 instead of LogLevel::warn.

Let's start with the definition:

enum class LogLevel {
    info,
    warn,
    error
};

Now we want to get the text "INFO", "WARN", "ERROR". You could do this with if, but it will be less readable. Here switch fits perfectly: it was made for selecting on an enum.

Here is the minimal "canonical" template:

#include <string_view>

enum class LogLevel { info, warn, error };

std::string_view to_string(LogLevel lvl) {
    switch (lvl) {
    case LogLevel::info:  return "INFO";
    case LogLevel::warn:  return "WARN";
    case LogLevel::error: return "ERROR";
    }
    return "UNKNOWN";
}

Note the final return "UNKNOWN";. Formally it may seem unnecessary — we only have three values. But in practice a fallback branch is like a spare key. It's usually not needed until one day it suddenly is.

5. Writing log(...): one formatting point, one style

Now let's assemble the logger. A super-minimal version: print to std::cerr, because a log is diagnostics.

#include <iostream>
#include <string_view>

enum class LogLevel { info, warn, error };

std::string_view to_string(LogLevel lvl) {
    switch (lvl) {
    case LogLevel::info:  return "INFO";
    case LogLevel::warn:  return "WARN";
    case LogLevel::error: return "ERROR";
    }
    return "UNKNOWN";
}

void log(LogLevel lvl, std::string_view msg) {
    std::cerr << '[' << to_string(lvl) << "] " << msg << '\n';
}

This is already useful because:

  1. the format is in one place;
  2. you won't forget the level — it's a parameter;
  3. std::string_view allows passing both string literals and std::string without copies (provided the string lives long enough — but within a function call this is usually OK).

Small usage example:

log(LogLevel::info, "App started");      // [INFO] App started
log(LogLevel::warn, "Suspicious input"); // [WARN] Suspicious input
log(LogLevel::error, "Bad arguments");   // [ERROR] Bad arguments

And yes, you noticed: the output is shown in comments — it helps compare expectations with reality, especially for beginners.

6. std::ostringstream: when a string is awkward to "concatenate by hand"

A practical problem quickly appears with logs: a message often consists of pieces. For example:

  • "unknown option: " + arg
  • "mode=" + modeName
  • "cannot open file: " + path

Beginners usually go two ways. The first — concatenation with +, the second — many << directly to std::cerr. Both can be fine, but they have downsides.

Concatenation with + requires std::string, and if part of your data is not a string (for example, int argc), you start dancing with std::to_string. And it's easy to get a mess of parentheses and pluses.

Printing directly to std::cerr is also possible, but then the message format is scattered across code: part in the logger, part at the call site. You want to be able to "build the message" and then send it to log() in one call.

This is where std::ostringstream appears — a stream for output to a string. It's part of the stringstream family (along with std::stringstream, which you may have used).

Mini-example: build the string “like with <<”, not with +.

#include <ssstream>
#include <string>

std::string build_argc_message(int argc) {
    std::ostringstream oss;
    oss << "argc=" << argc;
    return oss.str();
}

Usage:

log(LogLevel::info, build_argc_message(argc)); // [INFO] argc=3

It looks a bit longer than std::to_string(argc), but this approach scales: adding pieces is convenient, types are formatted by the stream automatically, and you don't turn code into a "plus spider".

7. Embedding logging into a teaching CLI app

To keep examples concrete, let's imagine we are writing a small utility TaskBox (a placeholder name), which can work in two modes:

  • one-shot: taskbox --add "Buy milk"
  • interactive: taskbox --interactive, then commands via stdin

Today we won't rewrite the entire argument parser (that's in previous lectures), but we will show how logging integrates neatly into it.

Log parsing of --interactive without fanaticism

If we found --interactive, it's nice to know the program actually understood the mode.

#include <string_view>

bool is_interactive = false;

for (int i = 1; i < argc; ++i) {
    std::string_view arg = argv[i];
    if (arg == "--interactive") {
        is_interactive = true;
        log(LogLevel::info, "mode=interactive"); // [INFO] mode=interactive
    }
}

Why is this info and not warn? Because this is a normal working path; we just want to see it in diagnostics when debugging or when the user says “it's not working”.

Unknown option: is it error or warn?

This is a subtle point. If by contract you decided “any unknown option is an arguments error”, then it's an error and the program will exit. If you decided “we ignore unknown options and continue”, it's a warn.

In teaching CLIs it's usually better to be strict: unknown option — error. The user learns correct usage faster that way.

#include <sstream>
#include <string_view>

if (arg.starts_with("-")) {
    std::ostringstream oss;
    oss << "unknown option: " << arg;
    log(LogLevel::error, oss.str()); // [ERROR] unknown option: --kek
    return 2;
}

Note: ostringstream is useful here because arg is a string_view, and it's convenient to build the string without manual concatenation.

Interactive loop: command errors do not always mean "we die"

In interactive mode the logic is often: one command can be erroneous, but we don't want to close the whole program. Then error means “command error”, but not necessarily “terminate the process”.

#include <iostream>
#include <string>

std::string line;
while (std::getline(std::cin, line)) {
    if (line == "quit") break;

    if (line.empty()) {
        log(LogLevel::warn, "empty command"); // [WARN] empty command
        continue;
    }

    log(LogLevel::info, "command accepted"); // [INFO] command accepted
}

Here warn on an empty command is arguable, but useful for teaching: you see what happens when the user just presses Enter.

8. Practice: noise, info and minimum level

Right now we always print everything to std::cerr. This is fine for a teaching version. But in real utilities you often want to be able to disable info (for example, by default print only warn/error).

We won't introduce full-featured --verbose flags yet (that's part of a "rich CLI"), but we can lay down a small mechanism: a minimum level.

LogLevel g_min_level = LogLevel::info;

bool enabled(LogLevel lvl) {
    return static_cast<int>(lvl) >= static_cast<int>(g_min_level);
}

And in log(...):

void log(LogLevel lvl, std::string_view msg) {
    if (!enabled(lvl)) return;
    std::cerr << '[' << to_string(lvl) << "] " << msg << '\n';
}

One nuance here: we used static_cast<int> because enum class does not implicitly convert to int. That's fine, but remember: the order of values in the enum defines the meaning of the comparison, so the values info/warn/error should be in that order.

It's very easy, feeling the power of logs, to start inserting log(info, "...") on every line of code. In a day you'll get 500 lines per run, and most importantly — you stop perceiving them as signals.

A practical rule is simple: log events, not lines. An event is “mode selected”, “file read”, “received invalid command”, “failed to parse an argument”. A line is “entered if” (usually unnecessary).

If you want step-by-step execution, use a debugger for that. Logs are for understanding program behavior without poking into it by hand every time.

9. Common mistakes

Mistake #1: writing logs to std::cout and breaking "clean output".
This is the most common problem in CLI utilities: the user expects data on stdout (to redirect it to a file or another program), but instead gets a mix of "result" and "diagnostics". Keep the simple rule in mind: std::cout — for results, std::cerr — for logs and errors.

Mistake #2: not having a single format and creating a "zoo of messages".
When some messages look like Error: ..., some like [ERROR] ..., some like !!! ..., visual search becomes painful. The cure is simple: one function log() and the format lives inside it. At call sites you pass only meaning, not presentation.

Mistake #3: making log level a string instead of an enum class.
If the level is a string, you can easily type "ERORR" and not notice. If the level is an enum class LogLevel, the compiler forces you to choose only valid variants. And enum→text conversion is done via a small switch, which is more readable than any set of ifs.

Mistake #4: trying to concatenate messages with + and drowning in std::to_string.
String concatenation quickly becomes a mess, especially when you add numbers and different types. In such places std::ostringstream gives cleaner, more readable code: you write the message as you would print it to cout, then take oss.str().

Mistake #5: logging "everything" and devaluing logs.
When there are too many logs, they cease to be a diagnostic tool and turn into noise. Try to log key events, not every step of the algorithm. If you need step-by-step tracing — most often that's for the debugger, not for log(info, "...").

1
Task
C++ SELF, level 67, lesson 3
Locked
Unified log
Unified log
1
Task
C++ SELF, level 67, lesson 3
Locked
Log level parsing
Log level parsing
1
Task
C++ SELF, level 67, lesson 3
Locked
Commands with logging
Commands with logging
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION