morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
logger.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <atomic>
5#include <cstdint>
6#include <format>
7#include <functional>
8#include <mutex>
9#include <print>
10#include <string>
11#include <string_view>
12
13namespace morph::log {
14
15/// @brief Severity levels for the logging system.
16enum class LogLevel : std::uint8_t {
17 /// @brief Fine-grained diagnostic output.
18 debug = 0,
19 /// @brief General informational messages.
20 info = 1,
21 /// @brief Recoverable conditions worth noting.
22 warn = 2,
23 /// @brief Errors that should be investigated.
24 error = 3,
25 /// @brief Suppresses all output when used as the minimum level.
26 off = 4,
27};
28
29namespace detail {
30
31/// @brief Returns the human-readable name for a log level.
32constexpr std::string_view levelName(LogLevel level) noexcept {
33 switch (level) {
34 case LogLevel::debug:
35 return "DEBUG";
36 case LogLevel::info:
37 return "INFO ";
38 case LogLevel::warn:
39 return "WARN ";
40 case LogLevel::error:
41 return "ERROR";
42 case LogLevel::off:
43 return "OFF ";
44 default:
45 return "? ";
46 }
47}
48
49/// @brief Sink function type used internally for log output.
50using Logger = std::function<void(LogLevel, std::string_view)>;
51
52/// @brief Escapes newline and control characters so a message cannot forge log
53/// lines (log injection).
54///
55/// A message is a single logical record; the default sink emits it as one line.
56/// User-controlled text containing \n, \r, or other C0 control bytes could
57/// otherwise splice a forged `[ERROR] ...` line into the stream or corrupt a
58/// line-oriented log parser. This replaces CR/LF/TAB with their C-style escapes
59/// (\n, \r, \t) and any remaining control byte (< 0x20, or 0x7f DEL)
60/// with a \xHH escape, leaving printable text (including non-ASCII UTF-8
61/// continuation bytes `>= 0x80`) untouched. It is a cheap single pass with no
62/// per-byte escaping work on the common (clean) path (just one string copy).
63/// @param msg Raw message to sanitize.
64/// @return @p msg with control characters escaped; the same bytes if already clean.
65inline std::string sanitizeLogLine(std::string_view msg) {
66 auto needsEscape = [](unsigned char chr) { return chr < 0x20 || chr == 0x7f; };
67 std::size_t firstBad = std::string_view::npos;
68 for (std::size_t i = 0; i < msg.size(); ++i) {
69 if (needsEscape(static_cast<unsigned char>(msg[i]))) {
70 firstBad = i;
71 break;
72 }
73 }
74 if (firstBad == std::string_view::npos) {
75 return std::string{msg}; // clean: one copy, no per-char work
76 }
77 std::string out;
78 out.reserve(msg.size() + 8);
79 out.append(msg.substr(0, firstBad));
80 static constexpr std::string_view kHex = "0123456789abcdef";
81 for (std::size_t i = firstBad; i < msg.size(); ++i) {
82 const auto chr = static_cast<unsigned char>(msg[i]);
83 switch (chr) {
84 case '\n':
85 out += "\\n";
86 break;
87 case '\r':
88 out += "\\r";
89 break;
90 case '\t':
91 out += "\\t";
92 break;
93 default:
94 if (needsEscape(chr)) {
95 out += "\\x";
96 out.push_back(kHex[chr >> 4]);
97 out.push_back(kHex[chr & 0x0f]);
98 } else {
99 out.push_back(static_cast<char>(chr));
100 }
101 break;
102 }
103 }
104 return out;
105}
106
107struct LogState {
108 Logger sink = [](LogLevel lvl, std::string_view msg) {
109 std::println(stderr, "[{}] {}", levelName(lvl), sanitizeLogLine(msg));
110 };
111 // Atomic so the level check is a lock-free fast path: `logFormat` and `log`
112 // can reject a suppressed message without touching `mtx` or formatting it.
113 //
114 // `warn`, not `debug`: linking morph must not hand the framework a
115 // consumer's stderr. At `debug` -- the default until 0.1.0 -- a plain
116 // `morph_tests` run wrote 2,949 lines of dispatch tracing, and any
117 // application linking morph inherited the same firehose without asking for
118 // it. Deliberately one value in every build configuration rather than
119 // keying on NDEBUG: this is a default member initializer in a header, so a
120 // config-dependent one gives translation units compiled with different
121 // settings two definitions of `LogState` and of the inline `logState()`,
122 // which is an ODR violation the linker resolves silently and arbitrarily.
123 // `setLogLevel(LogLevel::debug)` is the one line that turns it all back on.
124 std::atomic<LogLevel> minLevel{LogLevel::warn};
125 std::mutex mtx; // guards `sink` (and serialises sink invocation)
126 // Records discarded because the sink, the lock, or formatting threw. The
127 // logging layer is noexcept, so a failure cannot be reported by
128 // propagating; this counter is what keeps it from being silent.
129 std::atomic<std::uint64_t> dropped{0};
130};
131
132inline LogState& logState() {
133 static LogState state;
134 return state;
135}
136
137/// @brief Internal emit entry point used by the public level helpers.
138///
139/// The sink is invoked while `mtx` is held, so sink calls are serialised. A sink
140/// must therefore not call back into `morph::log` (log, `setLogger`, or
141/// construct a `ScopedLoggerOverride`) — `std::mutex` is non-recursive and that
142/// would self-deadlock — and should not block for long.
143inline void log(LogLevel level, std::string_view msg) noexcept {
144 auto& state = logState();
145 if (level < state.minLevel.load(std::memory_order_relaxed)) {
146 return; // lock-free reject of suppressed messages
147 }
148 // Nothing here may escape. Emitting a log record must never be able to
149 // disrupt the code that asked for it -- least of all a destructor, where
150 // an escaping exception is `std::terminate`. Three things inside can
151 // throw: `std::mutex::lock` (`std::system_error`), the sink itself, and
152 // any allocation the sink performs (the default one builds a sanitised
153 // copy). All three are the logging layer's problem, not the caller's.
154 try {
155 std::scoped_lock const lock{state.mtx};
156 if (state.sink) {
157 state.sink(level, msg);
158 }
159 } catch (...) {
160 state.dropped.fetch_add(1, std::memory_order_relaxed);
161 }
162}
163
164} // namespace detail
165
166// ── Configuration ─────────────────────────────────────────────────────────────
167
168/// @brief Replaces the global log sink.
169///
170/// Thread-safe. The new sink is called for every message whose level meets the
171/// current minimum. Pass a no-op lambda to silence all output.
172/// @param logger New sink function.
173inline void setLogger(std::function<void(LogLevel, std::string_view)> logger) {
174 std::scoped_lock const lock{detail::logState().mtx};
175 detail::logState().sink = std::move(logger);
176}
177
178/// @brief Sets the minimum log level.
179///
180/// Messages below this level are silently dropped. Thread-safe.
181/// @param level Minimum level to emit.
182inline void setLogLevel(LogLevel level) { detail::logState().minLevel.store(level, std::memory_order_relaxed); }
183
184/// @brief Returns the current minimum log level. Thread-safe.
185/// @return The active minimum level.
186inline LogLevel getLogLevel() { return detail::logState().minLevel.load(std::memory_order_relaxed); }
187
188/// @brief Number of log records discarded because emitting them threw.
189///
190/// The logging helpers are `noexcept`, so a sink that throws, a lock
191/// acquisition that fails, or a formatting error cannot be reported by
192/// propagating to the caller. Each such record increments this counter
193/// instead, so the failure is observable rather than silent. It counts
194/// *failures*, not level-suppressed messages: a message below the minimum
195/// level is not a dropped record.
196///
197/// Monotonic for the life of the process, and never reset. Compare two reads
198/// to attribute drops to a region of code. Thread-safe.
199///
200/// @return The total number of records dropped since process start.
201[[nodiscard]] inline std::uint64_t droppedLogRecords() noexcept {
202 return detail::logState().dropped.load(std::memory_order_relaxed);
203}
204
205// ── Level helpers ─────────────────────────────────────────────────────────────
206
207namespace detail {
208
209/// @brief Formats @p fmt with @p args and forwards to `log()`.
210///
211/// The level is checked *before* formatting, so a suppressed call pays no
212/// `std::format` / allocation cost.
213template <typename... Args>
214void logFormat(LogLevel level, std::format_string<Args...> fmt, Args&&... args) noexcept {
215 if (level < logState().minLevel.load(std::memory_order_relaxed)) {
216 return;
217 }
218 // `std::format` allocates, so it can throw `std::bad_alloc`, and a user
219 // type's `formatter` may throw anything at all. `log()` guards its own
220 // body but cannot guard its argument, so the call is wrapped here.
221 try {
222 log(level, std::format(fmt, std::forward<Args>(args)...));
223 } catch (...) {
224 logState().dropped.fetch_add(1, std::memory_order_relaxed);
225 }
226}
227
228} // namespace detail
229
230/// @brief Logs @p msg at `LogLevel::debug`.
231inline void logDebug(std::string_view msg) noexcept { detail::log(LogLevel::debug, msg); }
232/// @brief Logs @p msg at `LogLevel::info`.
233inline void logInfo(std::string_view msg) noexcept { detail::log(LogLevel::info, msg); }
234/// @brief Logs @p msg at `LogLevel::warn`.
235inline void logWarn(std::string_view msg) noexcept { detail::log(LogLevel::warn, msg); }
236/// @brief Logs @p msg at `LogLevel::error`.
237inline void logError(std::string_view msg) noexcept { detail::log(LogLevel::error, msg); }
238
239/// @brief Logs a formatted message at `LogLevel::debug`.
240template <typename... Args>
241void logDebug(std::format_string<Args...> fmt, Args&&... args) noexcept {
242 detail::logFormat(LogLevel::debug, fmt, std::forward<Args>(args)...);
243}
244/// @brief Logs a formatted message at `LogLevel::info`.
245template <typename... Args>
246void logInfo(std::format_string<Args...> fmt, Args&&... args) noexcept {
247 detail::logFormat(LogLevel::info, fmt, std::forward<Args>(args)...);
248}
249/// @brief Logs a formatted message at `LogLevel::warn`.
250template <typename... Args>
251void logWarn(std::format_string<Args...> fmt, Args&&... args) noexcept {
252 detail::logFormat(LogLevel::warn, fmt, std::forward<Args>(args)...);
253}
254/// @brief Logs a formatted message at `LogLevel::error`.
255template <typename... Args>
256void logError(std::format_string<Args...> fmt, Args&&... args) noexcept {
257 detail::logFormat(LogLevel::error, fmt, std::forward<Args>(args)...);
258}
259
260// ── Scoped override (test fixture) ────────────────────────────────────────────
261
262/// @brief RAII helper that swaps the global logger and level for the lifetime
263/// of the object, and restores them in the destructor.
264///
265/// Designed for tests that want to capture log output without leaking the
266/// custom sink into other tests. Thread-safe construction and destruction —
267/// the global mutex is acquired briefly during each.
268///
269/// @code
270/// {
271/// std::vector<std::string> captured;
272/// morph::log::ScopedLoggerOverride guard{
273/// [&](morph::log::LogLevel, std::string_view msg) { captured.emplace_back(msg); },
274/// morph::log::LogLevel::debug,
275/// };
276/// // ... run code that logs ...
277/// } // previous sink + level restored here
278/// @endcode
280public:
281 /// @brief Snapshots the current sink + level so they can be restored.
282 ///
283 /// Use this when test code will install its own sink mid-test via
284 /// `setLogger()` / `setLogLevel()` and just wants automatic restoration.
286 // NOLINTBEGIN(cppcoreguidelines-prefer-member-initializer) — these must be
287 // read while holding the lock, and a member-initializer list would read
288 // the global state before the mutex is acquired. For `sink` (a
289 // std::function) that would be a genuine data race; `minLevel` is a
290 // std::atomic and would merely be stale. The reason both are taken here
291 // is that they must be captured as one *pair*: setLogger and setLogLevel
292 // are separate calls, and a snapshot straddling them would restore a
293 // combination that never existed.
294 std::scoped_lock const lock{detail::logState().mtx};
295 _savedSink = detail::logState().sink;
296 _savedLevel = detail::logState().minLevel;
297 // NOLINTEND(cppcoreguidelines-prefer-member-initializer)
298 }
299
300 /// @brief Installs @p sink and @p level; saves whatever was there before.
301 ///
302 /// @param sink New logger sink. Pass a no-op lambda to suppress output entirely.
303 /// @param level New minimum level. Defaults to `debug` so every message reaches @p sink.
304 explicit ScopedLoggerOverride(std::function<void(LogLevel, std::string_view)> sink,
305 LogLevel level = LogLevel::debug) {
306 // NOLINTBEGIN(cppcoreguidelines-prefer-member-initializer) — snapshot and
307 // install happen under one lock; a member-initializer list would read the
308 // previous state outside the mutex, racing a concurrent setLogger.
309 std::scoped_lock const lock{detail::logState().mtx};
310 _savedSink = std::move(detail::logState().sink);
311 _savedLevel = detail::logState().minLevel;
312 detail::logState().sink = std::move(sink);
313 detail::logState().minLevel = level;
314 // NOLINTEND(cppcoreguidelines-prefer-member-initializer)
315 }
316
317 /// @brief Restores the saved sink and level.
319 std::scoped_lock const lock{detail::logState().mtx};
320 detail::logState().sink = std::move(_savedSink);
321 detail::logState().minLevel = _savedLevel;
322 }
323
325 ScopedLoggerOverride& operator=(const ScopedLoggerOverride&) = delete;
327 ScopedLoggerOverride& operator=(ScopedLoggerOverride&&) = delete;
328
329private:
330 std::function<void(LogLevel, std::string_view)> _savedSink;
331 LogLevel _savedLevel{LogLevel::debug};
332};
333
334} // namespace morph::log
RAII helper that swaps the global logger and level for the lifetime of the object,...
Definition logger.hpp:279
ScopedLoggerOverride(std::function< void(LogLevel, std::string_view)> sink, LogLevel level=LogLevel::debug)
Installs sink and level; saves whatever was there before.
Definition logger.hpp:304
~ScopedLoggerOverride()
Restores the saved sink and level.
Definition logger.hpp:318
ScopedLoggerOverride()
Snapshots the current sink + level so they can be restored.
Definition logger.hpp:285