|
logman 0.1.0
Modern C++23 header-only logging manager wrapping spdlog with channels, listeners, and structured events
|
A header-only C++23 logging facade that wraps spdlog with a single LogManager registry. It gives you lazily-created named channels (net.http, db.postgres, …), per-channel and prefix-based level control, a callback-style listener hook for shipping records into your own pipeline as structured LogEvent values, and an opt-in JSON output format.
The underlying loggers are plain spdlog::logger shared pointers — anything you can do with spdlog you can still do here.
logman::get("net.http") lazily creates and caches a logger; calling get with the same name later returns the same instance.set_levels_by_prefix("net.", warn) tunes every existing and future channel under net.*. The same rule applies to env vars: LOGMAN_LEVEL_NET_HTTP=trace lights up net.http.* without code changes.std::function<void(const LogEvent&)> and receive structured records (timestamp, level, channel, message, thread id, PID, source location). Listeners run outside the sink lock and exceptions thrown from one listener can't break the pipeline for others.InitConfig::structured_json or LOGMAN_FORMAT=json. Requires <nlohmann/json.hpp> on the include path at compile time; otherwise the request is ignored with a one-line stderr warning.shared_mutex lookup on the first get() per channel, or projects that need to fully replace spdlog rather than wrap it.The free functions (logman::initialize, logman::get, logman::set_level, …) are thin shortcuts that forward to logman::LogManager::*. Use either style — same behaviour, same singleton underneath.
Why this example works:
initialize(level) sets up the singleton, installs a colour console sink, and parses LOGMAN_* env vars. It is std::call_once-guarded, so calling it again is a no-op (you must use shutdown() to reinitialise).get(name) returns a std::shared_ptr<spdlog::logger>. The first call for a new name takes a write lock to create the channel; subsequent calls take a shared lock and return the cached logger.set_levels_by_prefix updates every already-created channel whose name starts with the prefix and caches the rule so any channel created later under that prefix inherits it.The repository's only required dependency is spdlog v1.17.0, fetched automatically via CMake's FetchContent (and reused via find_package if already present, thanks to FIND_PACKAGE_ARGS). Tests pull GoogleTest 1.17.0; the JSON examples pull nlohmann/json 3.12.0. All optional.
If you vendor the repository inside your tree:
make install (or the equivalent cmake --install) produces an installable package only when spdlog is provided by find_package rather than fetched (the build emits a warning and disables install rules if it had to fetch spdlog itself, because a fetched spdlog cannot be re-exported). With a system spdlog:
Because the library is header-only, you can also add include/ (and the generated version.hpp / env_prefixes.hpp) to your compiler's include path and link spdlog yourself. CMake is still the expected workflow — there is no separate hand-rolled install script.
CMAKE_CXX_STANDARD 23 is required; cxx_std_23 is propagated via the logman INTERFACE target.spdlog::spdlog_header_only).__has_include(<nlohmann/json.hpp>); without it, the JSON adapter and JsonFormatter are excluded and logman::has_json_support is false.LOGMAN_BUILD_TESTS is ON)._getpid on Windows and getpid on __unix__/__APPLE__. The env-var scanner uses GetEnvironmentStringsW on Windows and the POSIX environ everywhere else. No other platform-specific code.Singleton that owns the channel map, the default console sink, and the listener sink. All public operations are accessed through static methods or the matching free functions. Thread-safe: a std::shared_mutex guards channel and level lookups.
get lazily registers the channel with spdlog under the same name and copies the manager's sinks into it, so the channel inherits the console sink, the listener sink, and any sinks you added with add_sink before the call.
POD config struct passed to LogManager::initialize(const InitConfig&). All fields have defaults; override only what you need.
initialize is std::call_once-guarded. To apply a different InitConfig in the same process (typically in tests), call LogManager::shutdown() first — or logman::detail::reset_log_manager_for_testing() from <logman/log_manager.hpp>.
The structured form of one log record, defined in <logman/log_event.hpp> and produced by ListenerSink:
Listeners receive a const LogEvent&. The struct is trivially copyable for storage.
The spdlog sink that powers add_listener. It formats each record with its own bare "%v" formatter, packages the result into a LogEvent, takes a snapshot of the listener list under a short lock, then dispatches outside the lock. Each call is guarded by a try/catch — exceptions are swallowed in release builds and logged to stderr in debug builds.
UpperLevelFormatter (L) — uppercase level name, right-aligned to 8 characters. Use the bare L token; the padding is applied inside the custom flag (doubling it produces extra spaces).
ChannelNameFormatter (n) — channel name in a 20-character column. Long dotted names are abbreviated segment-by-segment: alpha.beta.gamma.delta.epsilon → e.g. a.b.g.d.epsilon. If it cannot fit even after abbreviation, leading segments are dropped with a leading ..
Default pattern (composed automatically when InitConfig::pattern is empty):
To change it:
Setting a pattern reformats both the console sink and every already-registered channel. The listener sink keeps its own bare v formatter — LogEvent::message always carries just the formatted message body, not the prefix.
Why this works: set_levels_by_prefix updates every matching channel and records the rule in an internal table that get consults when creating new channels.
Common pitfalls:
std::exception (or anything else) does not stop the other listeners from running. The exception is logged to stderr in debug builds and silently dropped in release.message field is the formatted output of the bare v formatter — no timestamp, no channel, no level prefix.Why this works: add_sink appends the sink to the manager and to every channel that already exists. New channels created after the call also receive it because get() copies the current sink list when constructing the underlying spdlog::logger. remove_sink reverses both.
LOGMAN_LEVEL, LOGMAN_LEVEL_<NAMESPACE>, and LOGMAN_FORMAT are parsed once during initialize() when InitConfig::read_env is true (the default).
<NAMESPACE> is lowercased and underscores become dots, so ORG_FOO_MANAGER addresses org.foo.manager.*. Unknown level names or unknown FORMAT values emit a one-line stderr warning and are skipped — they never abort initialisation.
Demonstration (test-only — you would normally set these in the shell):
Applications integrating logman via FetchContent can register additional prefixes at configure time. LOGMAN_ is always included unconditionally.
Later prefixes override earlier ones at runtime, so an application using both LOGMAN_LEVEL=info and FOO_LEVEL=debug ends up with debug. You can also override env_prefixes at runtime:
Each console line is a single compact JSON object terminated by \n:
process_id, file, line, and function are included only when present (negative PID or empty file path → omitted). The schema is defined by to_json(nlohmann::json&, const LogEvent&) in <logman/log_event_json.hpp>.
LogEvent is independent of spdlog, so you can build and serialise one yourself — useful for tests or for replaying records from another source.
set_flush_on is recorded by the manager so newly-created channels inherit it.
logman does not throw on the hot path. The way it reports problems:
| Situation | Behaviour |
|---|---|
| Unknown level name in env var | One-line warning to std::cerr, env entry skipped |
Unknown LOGMAN_FORMAT value | Warning to std::cerr, defaults to text |
structured_json = true but <nlohmann/json.hpp> not on include path | Warning to std::cerr, falls back to text formatter |
| Listener throws an exception | Caught; logged to std::cerr in debug builds, silently dropped in release. Other listeners still run. |
set_level(channel, …) for an unknown channel | Returns false (no exception) |
remove_listener(id) / remove_sink(sink) for unknown id/sink | Returns false |
get_or_null(name) for unknown channel | Returns nullptr |
get(name) before initialize() | Auto-initialises with defaults, then proceeds |
Calling initialize() twice without shutdown() | Second call is a no-op (std::call_once) |
spdlog's own logging operations may still throw spdlog::spdlog_ex for sink-level failures (for example, file-sink IO errors). logman does not wrap those.
LogManager::initialize is guarded by std::call_once. To swap InitConfig at runtime, call LogManager::shutdown() first (and rebuild any sinks/listeners you owned). Tests should use logman::detail::reset_log_manager_for_testing().set_pattern does not affect JSON.** When InitConfig::structured_json was true at init time, the console sink uses JsonFormatter; set_pattern will replace the pattern used by the per-channel loggers, but the console sink keeps its JSON formatter until you replace the sink.get(name) snapshots sinks at creation time.** Sinks added with add_sink after a channel was first fetched are still propagated by add_sink itself (it iterates every existing logger). Sinks created via your own std::make_shared<spdlog::logger> outside the manager won't pick this up.LogEvent::file is non-empty only when spdlog received a source location (typically via the SPDLOG_* macros). Calls like lg->info("…") do not include it, so file, line, function will be defaults and the JSON adapter will omit them.process_id == -1** when the platform-specific PID probe is unavailable. The JSON adapter omits the field in that case.thread_id is spdlog's os::thread_id()**, not a std::thread::id. It is suitable for log correlation but not for joining a std::thread.set_levels_by_prefix("net.", warn) uses plain starts_with. Pass an exact prefix (usually ending in .) to avoid matching network_*._LEADING becomes .leading, TRAILING_ becomes trailing., an empty namespace becomes the empty string (which matches every channel). Be deliberate with naming.set_all_levels(lvl) overwrites per-channel and prefix tuning** for every existing channel and the default level. Newly-created channels still pick up cached prefix rules.set_level / set_levels_by_prefix / set_all_levels operate on channels. Sinks default to trace; tighten them with sink->set_level(...) if you want a per-sink filter (see the rotating-file example).NDEBUG undefined.LOGMAN_INSTALL flips off automatically if Dependencies.cmake fetched spdlog instead of finding it, because the fetched target can't be re-exported. Install spdlog via a system package or find_package if you need install rules.Only the most commonly used public symbols. Everything is under namespace logman.
| API | Purpose | Notes |
|---|---|---|
initialize(level) / initialize(InitConfig) | Bring up the singleton | std::call_once-guarded |
shutdown() | Tear down sinks, listeners, channels | Required before re-initialising |
get(name) | Get-or-create a channel | Returns std::shared_ptr<spdlog::logger> |
get_or_null(name) | Lookup without create | Returns nullptr if absent |
channels() | Snapshot of name → level map | For introspection / debugging |
set_level(name, lvl) | Tune one channel | Returns false if name unknown |
set_levels_by_prefix(pfx, lvl) | Tune every channel under a prefix (existing + future) | Cached for late-bound channels |
set_all_levels(lvl) | Force one level on every channel and the default | Overwrites prior tuning |
set_default_level(lvl) / default_level() | Default for newly-created channels | |
set_pattern(p) | Change spdlog pattern globally | Ignored by JSON console sink |
flush() / set_flush_on(lvl) | Manual flush / auto-flush trigger | |
add_sink(sink) / remove_sink(sink) | Attach/detach an spdlog sink to every channel | |
add_listener(fn) / remove_listener(id) / clear_listeners() | Manage LogEvent callbacks | Snapshot-then-invoke pattern |
listener_sink() | Access the underlying ListenerSink | For advanced wiring |
LogEvent | Structured record passed to listeners | Defined in <logman/log_event.hpp> |
InitConfig | Initialisation settings | All fields have sensible defaults |
JsonFormatter | spdlog formatter emitting one JSON object per line | Requires <nlohmann/json.hpp> |
UpperLevelFormatter / ChannelNameFormatter | Custom pattern flags (L, n) | Reusable in your own patterns |
has_json_support | constexpr bool — true iff JSON adapter is compiled in | |
default_env_prefixes | std::span<const std::string_view> of compile-time prefixes |
The LogManager:: static methods mirror the free functions one-to-one — pick whichever style you prefer.
All examples live under examples/ and are built when LOGMAN_BUILD_EXAMPLES is ON.
| Example | Demonstrates |
|---|---|
examples/basic_usage.cpp | Minimal initialize + get + each level call |
examples/multi_channel.cpp | Multiple channels and per-prefix level tuning |
examples/listener_callback.cpp | Registering a listener and reading LogEvent fields |
examples/env_levels.cpp | Driving levels from LOGMAN_LEVEL[_NAMESPACE] env vars |
examples/custom_sink.cpp | Attaching a rotating_file_sink_mt alongside the console |
examples/structured_json.cpp | Switching the console to JSON via InitConfig::structured_json |
examples/json_event.cpp | Hand-building a LogEvent and serialising it with nlohmann/json |
Build and run them all (the Makefile wraps the same CMake calls):
GoogleTest 1.17.0 is fetched automatically when LOGMAN_BUILD_TESTS is ON (the default when the project is built top-level).
Or via the convenience Makefile:
The tests cover channel registry semantics, prefix-based level tuning (including late-bound channels), listener add/remove/clear, listener exceptions, env-var parsing, multi-prefix override order, formatter padding/abbreviation, JSON serialisation (with and without optional fields), and thread-safety smoke tests (8 threads × 200 events; 16 threads racing for the same channel name).
CMake presets are available via CMakePresets.json (cmake --preset <name> / --list-presets).
O(1) average via std::unordered_map. The first get(name) for a new channel takes a write lock; repeat calls take a shared lock.spdlog::logger, so spdlog's compile-time level macros (SPDLOG_ACTIVE_LEVEL) still apply if you want to strip records at compile time.Do I need to link a library, or is it header-only? Header-only. Link logman::logman; CMake handles the rest. spdlog's header-only target is pulled in transitively.
Can I use it in multiple threads? Yes. The channel/level maps are guarded by a std::shared_mutex; the listener list has its own mutex and listeners are dispatched outside the sink lock. Loggers themselves are spdlog's multi-threaded variants (spdlog::logger with a console-color _mt sink by default).
What if nlohmann/json isn't installed? The umbrella header skips the JSON adapter via __has_include, logman::has_json_support is false, and asking for structured_json = true falls back to the text formatter after a one-line stderr warning. Nothing else breaks.
Can I disable the console sink? Set InitConfig::enable_console = false and add your own sinks via LogManager::add_sink.
How do I integrate with my own log viewer or telemetry pipeline? Use add_listener to receive every record as a LogEvent. Marshal those into your queue/IPC of choice. Listeners run on the calling thread, so push to a queue rather than blocking inside the callback.
Why are levels still printing after I called set_levels_by_prefix? set_levels_by_prefix is a plain starts_with check. "net" matches "network.api" too; prefer "net." to scope to the dotted namespace.
How do I reset state between tests? Use logman::detail::reset_log_manager_for_testing() — it drops the singleton state, clears listeners and sinks, and resets the once-flag so the next initialize() re-runs.
How do I bypass logman entirely and reach into spdlog? logman::get(name) returns the underlying std::shared_ptr<spdlog::logger>; do what you like with it. spdlog::default_logger() is set to the manager's "main" logger unless you turned that off with InitConfig::set_as_spdlog_default = false.
How do I build the API docs? make docs (or cmake -S . -B build-docs -DLOGMAN_BUILD_DOCS=ON && cmake --build build-docs --target logman_docs). Requires Doxygen.
Contributions to the library are welcome! If you encounter any issues or have suggestions for improvements, please feel free to submit a pull request or open an issue on the project's repository.
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.