Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.4.1] - 2026-11-10

Documentation, and the comments and example that had outlived their milestone.
No API, behaviour or build change.

### Added

- README: *Why not spdlog directly* puts what a consumer gets against what it
gives up, and says when spdlog itself is the better choice. *Where spdlog is
allowed to appear* splits the boundary into headers, CMake targets, process
state and package config -- including the one that was never written down: a
package built with the backend runs `find_dependency(spdlog)` even when the
consumer links only `logforge::core`. *What the spdlog backend uses* lists the
API surface and what it leaves alone, so a replacement backend is a known
amount of work. *Replacing the backend* is the checklist, with the part a
swap does not fix -- dependencies that log through spdlog's global logger.
- `docs/LOGGING.md`: the backend contract states that `log()` is called
concurrently and that `enabled()` must agree with what `log()` writes, and
explains why the destructor clears logforge's default but not spdlog's.

### Changed

- The first README bullet named `log(level, text, size)`, which is not the
interface, and said the core "includes nothing", which reads as "no standard
headers". Both now describe the code as it is.
- The README no longer says a backend swap changes nothing but where a line
goes: the body is unchanged, but a pattern's `%l` is spdlog's `warning` where
`to_string(Level::warn)` is `warn`.

### Fixed

- `spdlog_backend.hpp` claimed a public surface free of a specific spdlog
version, while `native()` and `native_shared()` name `spdlog::logger`. The
comment now says what the forward declaration actually buys.
- `examples/backend_configuration.cpp` installed its backend with
`set_default()` and cleared it by hand, instead of the `install_as_default()`
the documentation recommends since 1.4.0.
- `CMakeLists.txt` still described the backend as "added in a later milestone"
and as joining the export in one.

## [1.4.0] - 2026-11-09

### Added
Expand Down
10 changes: 5 additions & 5 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.16)

project(logforge
VERSION 1.4.0
VERSION 1.4.1
DESCRIPTION "A small dependency-free logging bridge with a spdlog backend"
LANGUAGES CXX)

Expand All @@ -12,7 +12,7 @@ include(GNUInstallDirs)
#
# 1. options
# 2. logforge::core -- the interface every producer talks to (header only)
# 3. logforge::spdlog -- the backend (added in a later milestone)
# 3. logforge::spdlog -- the spdlog backend (static, optional)
# 4. tests / examples this repository builds for itself
# 5. install / export rules
#
Expand Down Expand Up @@ -290,9 +290,9 @@ endif()
# Install and package
#
# logforge::core is header only, so exporting it is a matter of shipping the
# header tree plus a config file. The backend target joins the same export in a
# later milestone; consumers still only ever name logforge::core or
# logforge::spdlog.
# header tree plus a config file. The backend travels in the same export
# because its usage requirements name spdlog::spdlog; consumers still only ever
# name logforge::core or logforge::spdlog.
# ---------------------------------------------------------------------------
if(LOGFORGE_INSTALL)
include(CMakePackageConfigHelpers)
Expand Down
117 changes: 107 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,22 @@ A small logging bridge: one tiny interface that every producer talks to, and a
backend that receives finished text and writes it somewhere. Ships with a
spdlog backend and a renderer for `{}`-style format strings.

- **The interface is text.** `log(level, text, size)` and `enabled(level)`.
Nothing about formatting, sinks or spdlog crosses it, so it never has to
change and a backend can be replaced without touching a single call site.
- **C++11, no dependencies.** The core is header only and includes nothing.
- **The interface is text.** `Logger` has two virtuals -- `enabled(level)` and
`log(const Record &)` -- and the record carries the message as
`(const char *, size_t)`. Nothing about formatting, sinks or spdlog crosses a
core header, so call sites survive a backend change.
- **C++11, no dependencies.** The core is header only and includes no
third-party header: only `<cstddef>`, `<cstdio>`, `<cstring>`, `<string>` and
`<atomic>`.
- **Rendering never throws.** A malformed format specifier comes out verbatim
instead of raising `format_error`, because a logging call inside an event
handler is the last place that may unwind.
- **Backends are optional.** `logforge::core` alone is enough to compile a
producer; the spdlog backend is a separate target you link only if you want it.
producer; the spdlog backend is a separate target you link only if you want
it. A package built with the backend enabled still asks CMake for spdlog at
configure time -- see [Where spdlog is allowed to appear](#where-spdlog-is-allowed-to-appear).

> Status: **1.4.0.** The 1.0.0 API is frozen; a breaking change from here needs a
> Status: **1.4.1.** The 1.0.0 API is frozen; a breaking change from here needs a
> major version.

Full guide: [docs/LOGGING.md](docs/LOGGING.md) -- the interface, where a line
Expand Down Expand Up @@ -51,6 +56,94 @@ a link-time choice instead of an `#include` in a public header.
| `logforge::core` | INTERFACE | nothing | You produce log lines, or you write your own backend |
| `logforge::spdlog` | STATIC | spdlog | You want rotating files, console colours and UDP without writing a sink |

## Why not spdlog directly

spdlog is a complete logger: fmt formatting, a dozen sinks, asynchronous
logging, backtrace, a registry of named loggers. logforge is a **front end plus
one backend**, and the front end is what a code base spread over several
binaries usually lacks. It does not compete with spdlog on features; it decides
where the boundary sits.

| You get | You give up |
|---|---|
| Call sites depend on `logforge::core` only; a backend swap is a link-time and startup-time change | spdlog's API at the call site: `logger->info(...)`, `spdlog::set_level()`, named-logger lookup |
| Producers never compile spdlog or fmt; `<logforge/spdlog_backend.hpp>` forward declares `spdlog::logger` | fmt's format language: no positional arguments, alignment or fill, chrono types, or compile-time format checking |
| Rendering never throws; a malformed specifier comes out verbatim | the same mistake is silent at run time instead of a compile error or an exception |
| Message text supports `{}`, `{:4}`, `{:x}`, `{:X}`, `{:04x}`, `{:.2f}`, `{:7.2f}`, `{{`/`}}` | everything else fmt offers |
| Thread-scoped routing (`ScopedBinding`), so a per-object logger reaches third-party code | spdlog's registry model; there is no `get_logger("x")` here |
| A `Config` struct, `open()` with a per-sink `Report`, `create_directories`, `install_as_default()` | everything else spdlog configures at run time: async queues, periodic flush, the error handler, backtrace, per-sink levels and patterns, most sink types |
| One `std::string` per enabled statement, rendered in the front end | spdlog's formatting into a reusable buffer; this library ships no benchmark, so do not read the table as a performance claim |

Keep spdlog directly when the program needs what is in the right column: an
async logger with a queue, per-module named loggers, syslog or daily files, or a
sink of your own. `native()` exists so that using one of those does not force
the rest of the code to know about spdlog.

## Where spdlog is allowed to appear

Four layers, and only one of them leaks on purpose:

| Layer | Rule |
|---|---|
| Headers | No core header includes spdlog. `<logforge/spdlog_backend.hpp>` forward declares `spdlog::logger` and nothing else, which keeps a producer's translation units free of spdlog and fmt. |
| CMake targets | `logforge::core` is INTERFACE with no dependencies. `logforge::spdlog` is STATIC and links `spdlog::spdlog` PUBLIC, because `native()` returns a `spdlog::logger *` and the caller needs the complete type. |
| Process state | `install_as_default()` writes logforge's default *and* spdlog's (`spdlog::set_default_logger()`), so a dependency that logs through spdlog's globals lands on the same sinks. That is a deliberate side effect, not an implementation detail. |
| Package config | A package built with `LOGFORGE_BUILD_SPDLOG=ON` runs `find_dependency(spdlog CONFIG)` at configure time. A consumer that links only `logforge::core` still needs spdlog *installed*, unless the package was built with `-DLOGFORGE_BUILD_SPDLOG=OFF`. |

So the claim is "no spdlog header required", not "no spdlog type anywhere": the
two `native*` functions name `spdlog::logger`, which is the price of an escape
hatch.

## What the spdlog backend uses

The whole spdlog API surface behind `logforge::spdlog`, so that a replacement
backend is a known amount of work rather than an unknown one.

| Area | Used |
|---|---|
| Logger object | `spdlog::logger` built from a `sink_ptr` list; `set_level()`, `set_pattern()`, `flush()` |
| Dispatch | `logger->log(source_loc, level, string_view_t)` -- the string-view overload, so text the renderer already produced is never parsed by fmt a second time |
| Sinks | `stdout_color_sink_mt`, `basic_file_sink_mt`, `rotating_file_sink_mt`, `udp_sink_mt` with `udp_sink_config` |
| Types | `spdlog::level::level_enum`, `source_loc`, `string_view_t`, `sink_ptr` |
| Global state | `set_default_logger()`, `default_logger()` |
| Text | spdlog's pattern language (`%Y`, `%l`, `%s`, `%#`, `%n`, `%v`), and the `what()` of its exceptions, which `Report` stores verbatim |
| Build | `find_package(spdlog 1.10 CONFIG)` or FetchContent v1.17.0; `ws2_32` on Windows, which the UDP sink needs |

Not used: asynchronous logging and its thread pool, periodic flush, the error
handler, backtrace, the registry's named lookup, per-sink levels or patterns,
any sink outside the four above, and fmt's formatting at dispatch time.

## Replacing the backend

Call sites do not change. Start-up code does, and so does the amount of
behaviour the new backend has to reproduce.

1. Implement `logforge::Logger` and link `logforge::core` only -- not
`logforge::spdlog`. The contract is in
[docs/LOGGING.md](docs/LOGGING.md#7-writing-a-backend): `log()` is
synchronous, must not keep the borrowed text, may be called from several
threads at once, and must not log into itself.
2. Install it with `logforge::set_default(&backend)`, or bind it per thread with
`ScopedBinding`. `install_as_default()` is spdlog-specific; a new backend does
not need an equivalent.
3. Delete the `SpdlogBackend` uses from start-up: `Config`, `open()`, `Report`,
`flush()`, `set_pattern()`, and every call of `native()` or `native_shared()`.
4. If the process must not depend on spdlog at all, install logforge with
`-DLOGFORGE_BUILD_SPDLOG=OFF`, so the package config stops requiring spdlog
at configure time.

To produce the same lines, a new backend needs: console, file, rotation and UDP
destinations; a timestamp -- `Record` carries none, so the backend takes it in
`log()`; the `"component: "` prefix on the body; level filtering with `off`
always silenced; `create_directories`; and a `flush()`.

**The part a replacement does not fix.** `install_as_default()` also sets
spdlog's default logger, which is how a dependency calling `spdlog::info()`
reaches the file. Dropping `logforge::spdlog` does not drop spdlog from the
process: either keep the dependency and install a logger built on the new
backend through `spdlog::set_default_logger()`, or accept that those lines land
on spdlog's built-in console logger.

## Requirements

- CMake 3.16 or newer
Expand Down Expand Up @@ -115,7 +208,9 @@ void Pad::dispatch() {
#include <logforge/spdlog_backend.hpp>

logforge::SpdlogBackend::Config config;
config.name = "pipeline"; // what %n prints, and the registry's name
config.level = logforge::Level::info;
config.console = true; // a colour sink on stdout
config.file_path = "logs/pipeline.log"; // empty: no file
config.max_file_bytes = 10 * 1024 * 1024; // 0: one file, no rotation
config.max_files = 5;
Expand Down Expand Up @@ -162,16 +257,18 @@ logger as the `shared_ptr` spdlog's registry takes, for a program that installs
it by hand.

A record's `component` becomes a `"component: "` prefix on the message body, the
same text `StderrLogger` produces, so changing backends changes where a line goes
and how the pattern decorates it -- not what it says. Source location goes to
spdlog as a `source_loc`, which is what makes `%s`/`%g`/`%#` work.
same text `StderrLogger` produces, so changing backends does not change the
message body. The pattern still decorates it in the backend's own spelling --
spdlog prints `%l` as `warning`, while `to_string(Level::warn)` is `warn`.
Source location goes to spdlog as a `source_loc`, which is what makes
`%s`/`%g`/`%#` work.

## Options

| Option | Default (top level) | Meaning |
|---|---|---|
| `LOGFORGE_BUILD_TESTS` | `ON` | Build the unit tests |
| `LOGFORGE_BUILD_SPDLOG` | `ON` | Build the `logforge::spdlog` backend |
| `LOGFORGE_BUILD_SPDLOG` | `ON` | Build the `logforge::spdlog` backend. `OFF` also drops spdlog from the package config, so a consumer needs no spdlog at all |
| `LOGFORGE_FETCH_SPDLOG` | `OFF` | Download spdlog instead of finding an installed one |
| `LOGFORGE_INSTALL` | `ON` | Generate install and `find_package` rules |
| `LOGFORGE_REQUIRE_TESTS` | `OFF` | Fail configure instead of skipping when GoogleTest is missing |
Expand Down
26 changes: 23 additions & 3 deletions docs/LOGGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ Three properties come out of that order:
path may pass an expensive expression without guarding it.
- **Rendering happens once**, in the front end, and the backend receives text.
A backend never parses a format string.
- **A backend only writes.** That is the whole job, which is why replacing one is
a link-time decision rather than a refactor.
- **A backend only writes.** That is the whole job, which is why a replacement
changes start-up code and one library target rather than every call site.

## 2. The interface

Expand Down Expand Up @@ -253,7 +253,14 @@ logs through spdlog's global logger). Doing only the first is the classic
half-install: the file is missing the lines of everything that went through
spdlog, and nothing reports it. A backend that is installed and then destroyed
clears logforge's default again, so the process does not keep a dangling pointer
while it shuts down.
while it shuts down. spdlog's registry holds a `shared_ptr`, so it keeps the
logger alive and stays safe to call; that is why the destructor does not try to
unregister anything there.

`Config::level` sets both this backend's `enabled()` and spdlog's own level, so
the two filters agree. Calling `native()->set_level()` afterwards changes only
spdlog's, and both filters then apply -- which is the honest outcome of using
the escape hatch, and visible in the log the first time a line goes missing.

`backend.native()` returns the `spdlog::logger`, which is the escape hatch for
named loggers, custom sinks and spdlog's backtrace buffer. `backend.flush()`
Expand Down Expand Up @@ -316,6 +323,12 @@ public:
- `enabled()` must be cheap and thread safe: it runs on every statement.
- `log()` must not outlive its arguments, and must not recurse into the same
backend from inside `log()` -- the sinks that do not deadlock will interleave.
- `log()` may be called from several threads at once, on a backend that was
installed once. A backend that shares a `FILE*`, a buffer or a timestamp
cache has to lock it; `StderrLogger` relies on the C library's own lock for
this, and the spdlog backend gets it from spdlog's `_mt` sinks.
- `enabled()` and `log()` must agree. A backend that drops what `enabled()`
accepted is invisible until somebody notices the missing line.
- `Level::off` is a filter setting, not a severity: `enabled(Level::off)` must be
false whatever the configured minimum is.

Expand Down Expand Up @@ -407,3 +420,10 @@ void Bridge::forward(const TheirRecord &record) {
record.component.c_str(), record.file, record.line);
}
```

### From this backend to a different one

Call sites keep working; the work is in start-up and in what the new backend has
to reproduce. The checklist, the behaviour to match and the one thing a swap
does not fix -- dependencies that log through spdlog's global logger -- are in
the README under *Replacing the backend*.
3 changes: 1 addition & 2 deletions examples/backend_configuration.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ int main(int argc, char **argv) {
}

logforge::SpdlogBackend backend(config);
logforge::set_default(&backend);
backend.install_as_default();

LOGFORGE_LOG_INFO("pipeline started");
LOGFORGE_LOG_WARN("latency {:.1f} ms", 12.5);
Expand All @@ -41,7 +41,6 @@ int main(int argc, char **argv) {
// The backend owns its sinks; flushing before the object goes away is only
// strictly needed because this program exits right after.
backend.flush();
logforge::set_default(0);

return 0;
}
10 changes: 6 additions & 4 deletions include/logforge/spdlog_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
#include <memory>
#include <string>

// spdlog is behind this file, not in it: forward declaring the logger keeps
// spdlog's headers (and its fmt) out of every translation unit that only wants to
// install a backend, and keeps the public surface free of a specific spdlog
// version.
// spdlog is behind this file, not in it: the header forward declares
// spdlog::logger instead of including it, so a translation unit that only
// installs a backend compiles no spdlog and no fmt header, and no spdlog version
// is baked into the ABI. The type still appears in native() and native_shared()
// -- that is the escape hatch, and a caller that uses them includes spdlog
// itself.
namespace spdlog {
class logger;
}
Expand Down
Loading
Loading