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 990c70d..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) @@ -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/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*. 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; } 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.