From 32adcb5f93e87ddd6c0b82571e9b2fac96eddd4f Mon Sep 17 00:00:00 2001 From: leid Date: Tue, 10 Nov 2026 15:38:54 +0800 Subject: [PATCH 1/3] docs: say why logforge exists next to spdlog, and where the line is The README described what the library does but never answered the question a reader arrives with: why not call spdlog directly. The answer was spread over a bullet list, the target table and comments in a header. - README: "Why not spdlog directly" puts what you get against what you give up, and says when spdlog 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 not documented at all: a package built with the backend runs find_dependency(spdlog) even for a core-only consumer. "What the spdlog backend uses" lists the API surface, and what it does not use, so a replacement backend is a known amount of work. "Replacing the backend" is the checklist, plus the part a swap does not fix: libraries that log through spdlog's global logger. - The first bullet named log(level, text, size), which is not the interface, and "includes nothing", which read as "no standard headers". Both now say what the code says. The Status line moves to 1.4.1. - LOGGING.md: the backend contract gains the two rules a backend author can otherwise violate silently -- log() is called concurrently, and enabled() must agree with what log() writes. The install_as_default() paragraph says why the destructor clears logforge's default but not spdlog's, and Config::level says the two level filters are set to the same value. Co-Authored-By: BaoCode --- README.md | 117 +++++++++++++++++++++++++++++++++++++++++++----- docs/LOGGING.md | 26 +++++++++-- 2 files changed, 130 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 78fe774..dba1825 100644 --- a/README.md +++ b/README.md @@ -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 ``, ``, ``, `` and + ``. - **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 @@ -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; `` 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. `` 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 @@ -115,7 +208,9 @@ void Pad::dispatch() { #include 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; @@ -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 | diff --git a/docs/LOGGING.md b/docs/LOGGING.md index 19efcdc..8f4896e 100644 --- a/docs/LOGGING.md +++ b/docs/LOGGING.md @@ -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 @@ -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()` @@ -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. @@ -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*. From afd05f1f54ec058e4a00d84e164ab4264eb18660 Mon Sep 17 00:00:00 2001 From: leid Date: Tue, 10 Nov 2026 15:38:58 +0800 Subject: [PATCH 2/3] chore(backend): correct the comments and example that outlived their milestone Four places still described a version of this library that no longer exists. - spdlog_backend.hpp promised a public surface free of a specific spdlog version, and the two native* functions name spdlog::logger in their signatures. The comment now says what the forward declaration actually buys: no spdlog or fmt header in a consumer's translation unit, and no version in the ABI. - backend_configuration.cpp installed its backend with set_default() and cleared it by hand, which is what install_as_default() and the destructor have done since 1.4.0. The example now shows the recommended path. - CMakeLists.txt still announced the backend as "added in a later milestone", and told readers the backend joins the export in one. Co-Authored-By: BaoCode --- CMakeLists.txt | 8 ++++---- examples/backend_configuration.cpp | 3 +-- include/logforge/spdlog_backend.hpp | 10 ++++++---- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 990c70d..f625400 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 # @@ -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) diff --git a/examples/backend_configuration.cpp b/examples/backend_configuration.cpp index 96dc004..3b72ad8 100644 --- a/examples/backend_configuration.cpp +++ b/examples/backend_configuration.cpp @@ -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); @@ -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; } diff --git a/include/logforge/spdlog_backend.hpp b/include/logforge/spdlog_backend.hpp index 39f61fb..57db3bf 100644 --- a/include/logforge/spdlog_backend.hpp +++ b/include/logforge/spdlog_backend.hpp @@ -8,10 +8,12 @@ #include #include -// 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; } From 691068e157e952bbf332cf7facadc944c806af13 Mon Sep 17 00:00:00 2001 From: leid Date: Tue, 10 Nov 2026 15:40:49 +0800 Subject: [PATCH 3/3] chore(release): 1.4.1 Documentation only: no API, behaviour or build change. The README now answers why the library exists next to spdlog and where the boundary is, the backend contract states the two rules a backend author could break in silence, and the comments and example that still described earlier milestones are corrected. Co-Authored-By: BaoCode --- CHANGELOG.md | 40 +++++++++++++++++++++++++++ CMakeLists.txt | 2 +- release-notes/v1.4.1.md | 60 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 release-notes/v1.4.1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2a051..eccf26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index f625400..f23d6e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/release-notes/v1.4.1.md b/release-notes/v1.4.1.md new file mode 100644 index 0000000..85b4e55 --- /dev/null +++ b/release-notes/v1.4.1.md @@ -0,0 +1,60 @@ +# logforge 1.4.1 + +The README learns to answer the question a reader arrives with, and four +comments stop describing a version of the library that no longer exists. + +Nothing here changes the API, the behaviour or the build. A 1.4.0 consumer +needs no edits. + +## Why this release + +The library explained what it does -- one interface, a text record, a backend -- +but not why it exists next to spdlog, which is the first thing somebody +choosing a logging layer asks. The pieces of the answer were scattered across a +bullet list, the target table and comments in a header, and one part of it was +not written down anywhere: a package built with the backend asks CMake for +spdlog even when the consumer links only `logforge::core`. + +## What the README now says + +Four sections, in the order the questions come up: + +- **Why not spdlog directly.** What you get against what you give up: call + sites that survive a backend swap, no spdlog or fmt header in a producer, a + renderer that never throws -- against fmt's format language, the sinks and + modes this library does not have, and the absence of a registry. It ends with + when to keep spdlog, because a comparison that never says so is an + advertisement. +- **Where spdlog is allowed to appear.** Headers, CMake targets, process state + and package config, layer by layer. `install_as_default()` changing spdlog's + global logger is a deliberate side effect, and the package config asking for + spdlog unconditionally is the price of shipping the backend. +- **What the spdlog backend uses.** The whole API surface -- logger, the + string-view dispatch overload, four sinks, the level enum, `source_loc`, + `string_view_t`, the pattern language -- and what it does not use: async + logging, periodic flush, the error handler, backtrace, the registry. That + turns "write another backend" into an estimate. +- **Replacing the backend.** Four steps, the behaviour to reproduce, and the + one thing a swap does not fix: a dependency that logs through spdlog's global + logger does not move because logforge's backend moved. + +## What else 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 match the code. +- `docs/LOGGING.md` states two rules a backend author could previously violate + in silence: `log()` is called from several threads, and `enabled()` must + agree with what `log()` writes. +- `spdlog_backend.hpp` no longer promises a public surface without a spdlog + type, which `native()` and `native_shared()` contradict. It says what the + forward declaration buys instead. +- `examples/backend_configuration.cpp` uses `install_as_default()`, the path + the documentation recommends, instead of installing and clearing the default + by hand. +- Two `CMakeLists.txt` comments from milestones 0.4 and 1.0 are gone. + +## What to do + +Nothing. Read the README's *Replacing the backend* before you write a second +backend, and `LOGGING.md` section 7 before you implement one.