/** * Logging with call-stack capture. * * Records carry a level, message, source location and — for severe enough * levels — a formatted `std::stacktrace`. Capturing a trace walks the stack and * reads debug information, so it is only done when the record's level is at or * above `options::stacktrace_from`, and the whole call is skipped when the * level is disabled. * * `std::stacktrace` is implemented by libstdc++ only. With GCC the module has * to link `stdc++exp` (the static library that implements it); the CMake target * takes care of that. File and line numbers in the trace come from debug * information, so build with `-g` (Debug or RelWithDebInfo) to see them; symbol * names work in any build. */ module; // libc++ (the OpenRA3 toolchain) has no , so this vendored copy adds // a native fallback for the frames: Windows CaptureStackBackTrace and POSIX // execinfo. These live in the global module fragment because they are C headers. #if defined(_WIN32) #define WIN32_LEAN_AND_MEAN #define NOMINMAX #include #elif (defined(__unix__) || defined(__APPLE__)) && !defined(__EMSCRIPTEN__) #include #endif export module ender.log; import std; export namespace ender::log { /** Severity of a record, ordered from most to least verbose. */ enum class level: std::uint8_t { trace = 0, debug, info, warn, error, critical, }; /** Short upper-case name of a level, for output. */ [[nodiscard]] inline auto to_string(const level severity) -> std::string_view { switch (severity) { case level::trace: return "TRACE"; case level::debug: return "DEBUG"; case level::info: return "INFO"; case level::warn: return "WARN"; case level::error: return "ERROR"; case level::critical: return "CRITICAL"; } return "?"; } /** Default timestamp rendered on a record, chrono format syntax. */ inline constexpr std::string_view default_time_format{"%Y-%m-%d %H:%M:%S"}; /** Logger configuration. */ struct options { /** Records below this level are dropped before anything is built. */ level minimum{level::info}; /** Capture a stack trace for records at this level and above. */ level stacktrace_from{level::error}; /** Maximum number of frames kept in a captured trace. */ std::size_t stacktrace_depth{16}; /** * Frames to drop from the top of a captured trace. * * The default drops `capture_stacktrace` and `emit`, which always exist * as frames. The level wrappers are inlined away in optimised builds, so * a fixed count cannot cover them; any leading frame that belongs to * this module is therefore stripped by name instead, which keeps the * caller visible whether or not the wrappers were inlined. */ std::size_t stacktrace_skip{2}; }; /** One log record. */ struct record { level severity{level::info}; std::string message{}; std::string file{}; std::uint32_t line{0}; std::string function{}; /** Formatted call stack; empty when it was not captured. */ std::string stacktrace{}; std::chrono::system_clock::time_point time{}; std::thread::id thread{}; [[nodiscard]] auto has_stacktrace() const -> bool { return !stacktrace.empty(); } }; namespace detail { /** * Render a time point with a runtime chrono format string. * * `std::format`'s format string is compile-time only, so the spec is * wrapped in a replacement field and fed to `std::vformat`; a bare spec * would be read as literal text rather than a chrono conversion. */ [[nodiscard]] inline auto format_time(const std::chrono::system_clock::time_point time, const std::string_view time_format) -> std::string { const auto moment = std::chrono::floor(time); const auto pattern = std::string{"{:"}.append(time_format).append("}"); return std::vformat(pattern, std::make_format_args(moment)); } /** * Render one record as a human-readable block: a header line and, when * present, the indented stack frames. Shared by the stream sinks. * * @param time_format A chrono format string applied to the record's * timestamp; defaults to the date and time of day. */ [[nodiscard]] inline auto format_record(const record &entry, const std::string_view time_format = default_time_format) -> std::string { const auto stamp = format_time(entry.time, time_format); auto text = std::format("[{}] {:<8} {}", stamp, to_string(entry.severity), entry.message); if (!entry.file.empty()) { text += std::format(" ({}:{})", entry.file, entry.line); } text += '\n'; if (entry.has_stacktrace()) { text += entry.stacktrace; } return text; } } /** Where records go. */ class sink { public: virtual ~sink() = default; /** Receive one record; called with the logger's mutex held. */ virtual auto write(const record &entry) -> void = 0; /** Flush any buffering. */ virtual auto flush() -> void {} }; /** Writes a human-readable line per record to a stream (stderr by default). */ class console_sink final: public sink { public: explicit console_sink(std::ostream &stream = std::cerr, std::string time_format = std::string{default_time_format}) : stream_(&stream), time_format_(std::move(time_format)) {} auto write(const record &entry) -> void override { *stream_ << detail::format_record(entry, time_format_); stream_->flush(); } private: std::ostream *stream_; std::string time_format_; }; /** Keeps every record in memory; useful for tests and in-game consoles. */ class memory_sink final: public sink { public: auto write(const record &entry) -> void override { const auto lock = std::scoped_lock{mutex_}; records_.push_back(entry); } [[nodiscard]] auto records() const -> std::vector { const auto lock = std::scoped_lock{mutex_}; return records_; } [[nodiscard]] auto size() const -> std::size_t { const auto lock = std::scoped_lock{mutex_}; return records_.size(); } auto clear() -> void { const auto lock = std::scoped_lock{mutex_}; records_.clear(); } private: mutable std::mutex mutex_; std::vector records_; }; /** Configuration for `file_sink`. */ struct file_options { /** Move an existing log file aside to an archive when the sink opens it. */ bool archive_on_open{true}; /** Flush after every record, so the tail survives a crash. */ bool flush_each_record{true}; /** Rotate once the active file would grow past this many bytes; 0 disables. */ std::size_t max_file_size{0}; /** Keep at most this many archives, dropping the oldest first; 0 keeps them all. */ std::size_t max_archives{0}; /** Timestamp format used on each record's header line (chrono syntax). */ std::string timestamp_format{std::string{default_time_format}}; /** Chrono format for the timestamp inserted into archive names. */ std::string archive_time_format{"%Y%m%d-%H%M%S"}; }; /** * Writes records to a file, archiving the previous one on open. * * `path` is the active file. When the sink opens it and the file already * holds data, that file is renamed to a timestamped archive first, so a run * never appends onto a previous run's log: every start begins a fresh file * and the old one is preserved as `.` (`.log` * when the active file has no extension), where the timestamp is rendered by * `file_options::archive_time_format` (default ``). The same * happens mid-run once the active file passes `file_options::max_file_size`. * `file_options::max_archives` bounds how many archives are kept. * * As with every sink, `write` is called with the logger's mutex held, so one * sink is safe to share; it is not safe for two processes to point at the * same file. */ class file_sink final: public sink { public: explicit file_sink(std::filesystem::path path, const file_options options = {}) : path_(std::move(path)), options_(options) { if (options_.archive_on_open && std::filesystem::exists(path_)) { if (std::filesystem::file_size(path_) > 0) { archive_current(); } else { std::filesystem::remove(path_); } } open(); } auto write(const record &entry) -> void override { const auto block = detail::format_record(entry, options_.timestamp_format); // Rotate before writing, but never rotate an empty file: that would // archive nothing and lose the record that is about to be written. if (options_.max_file_size > 0 && size_ > 0 && size_ + block.size() > options_.max_file_size) { archive_current(); open(); } stream_ << block; size_ += block.size(); if (options_.flush_each_record) stream_.flush(); } auto flush() -> void override { if (stream_.is_open()) stream_.flush(); } /** The active log file. */ [[nodiscard]] auto path() const -> const std::filesystem::path & { return path_; } /** Archives this sink created, oldest first. */ [[nodiscard]] auto archives() const -> const std::vector & { return archives_; } private: auto open() -> void { stream_.clear(); stream_.open(path_, std::ios::out | std::ios::trunc | std::ios::binary); size_ = 0; } auto archive_current() -> void { if (stream_.is_open()) stream_.close(); const auto stamp = detail::format_time(std::chrono::system_clock::now(), options_.archive_time_format); // Keep the extension last: `.`, falling // back to `.log` when the active file has none. const auto stem = path_.stem().string(); const auto extension = path_.has_extension() ? path_.extension().string() : std::string{".log"}; auto archive = path_.parent_path() / (stem + "." + stamp + extension); // Two rotations can land in the same second; disambiguate with a // counter rather than overwrite the earlier archive. for (auto counter = 1; std::filesystem::exists(archive); ++counter) { archive = path_.parent_path() / (stem + "." + stamp + "." + std::to_string(counter) + extension); } std::filesystem::rename(path_, archive); archives_.push_back(archive); prune_archives(); } auto prune_archives() -> void { if (options_.max_archives == 0) return; while (archives_.size() > options_.max_archives) { auto ignored = std::error_code{}; std::filesystem::remove(archives_.front(), ignored); archives_.erase(archives_.begin()); } } std::filesystem::path path_; file_options options_; std::ofstream stream_; std::size_t size_{0}; std::vector archives_; }; /* * is not portable: libc++ has never implemented it, and only * libstdc++ provides it here. Where it is missing, records still carry their * call site through std::source_location - they simply carry no stack, and * everything below degrades to an empty string rather than the module * refusing to compile. * * CMake decides this and passes it in, rather than the module testing * `__cpp_lib_stacktrace` itself: feature-test macros come from the standard * library's headers, and `import std;` does not export them, so probing for * one here silently reports "absent" even on libstdc++, which has it. */ #ifndef ENDERLOG_HAS_STACKTRACE #define ENDERLOG_HAS_STACKTRACE 0 #endif #if ENDERLOG_HAS_STACKTRACE /** True when a frame belongs to the logging module itself. */ [[nodiscard]] inline auto is_logger_frame(const std::stacktrace_entry &entry) -> bool { if (entry.description().find("ender::log") != std::string::npos) return true; return entry.source_file().find("ender.log.cppm") != std::string::npos; } /** * Render a trace as one indented line per frame. * * @param skip_logger_frames Drop leading frames belonging to this module, so * the first reported frame is the caller. This is what makes the * output stable across optimisation levels: in a release build the * level wrappers are inlined into the caller, so counting frames * alone would either over- or under-skip. */ [[nodiscard]] inline auto format_stacktrace(const std::stacktrace &trace, const bool skip_logger_frames = true) -> std::string { if (trace.empty()) return " \n"; auto first = std::size_t{0}; if (skip_logger_frames) { while (first < trace.size() && is_logger_frame(trace.at(first))) ++first; if (first >= trace.size()) first = 0; // never hide the whole trace } auto text = std::string{}; for (auto index = first; index < trace.size(); ++index) { const auto &entry = trace.at(index); auto description = entry.description(); if (description.empty()) description = ""; auto location = std::string{}; if (!entry.source_file().empty()) { location = std::format(" ({}:{})", entry.source_file(), entry.source_line()); } text += std::format(" #{:<3}{}{}\n", index - first, description, location); } return text; } /** Capture and render the current call stack, innermost frame first. */ [[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16) -> std::string { return format_stacktrace(std::stacktrace::current(skip, depth)); } #else /** * libc++ fallback: capture the current call stack with the platform's own * backtrace API and render one indented line per frame. * * On Windows a frame is reported as `module.dll+0xRVA` (a MinGW release build * has DWARF, not the PDB symbols dbghelp resolves, so a module+offset is the * practical answer). On POSIX `backtrace_symbols` is used, which names a * frame when the executable was linked with `-rdynamic`. */ #if defined(_WIN32) [[nodiscard]] inline auto symbolicate_frame(void *address) -> std::string { const auto value = reinterpret_cast(address); HMODULE module = nullptr; if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(address), &module)) { wchar_t wide[260] = L"?"; GetModuleFileNameW(module, wide, 260U); char narrow[260] = "?"; WideCharToMultiByte(CP_UTF8, 0, wide, -1, narrow, sizeof(narrow), nullptr, nullptr); const char *base = std::strrchr(narrow, '\\'); return std::format("{}+0x{:X}", base != nullptr ? base + 1 : narrow, value - reinterpret_cast(module)); } return std::format("0x{:X}", value); } #endif [[nodiscard]] inline auto capture_stacktrace(const std::size_t skip = 2, const std::size_t depth = 16) -> std::string { constexpr std::size_t max_frames = 64; #if defined(_WIN32) void *frames[max_frames] = {}; const auto want = static_cast(std::min(max_frames, skip + std::max(depth, 1U))); const USHORT count = CaptureStackBackTrace(static_cast(skip), want, frames, nullptr); auto text = std::string{}; for (USHORT index = 0; index < count; ++index) { text += std::format(" #{:<3}{}\n", index, symbolicate_frame(frames[index])); } return text; #elif (defined(__unix__) || defined(__APPLE__)) && !defined(__EMSCRIPTEN__) void *frames[max_frames] = {}; const int count = ::backtrace(frames, static_cast(std::min(max_frames, skip + std::max(depth, 1U)))); char **symbols = ::backtrace_symbols(frames, count); auto text = std::string{}; for (int index = static_cast(std::min(skip, static_cast(count))); index < count; ++index) { text += std::format(" #{:<3}{}\n", index - static_cast(skip), symbols != nullptr ? symbols[index] : std::format("0x{:X}", reinterpret_cast(frames[index]))); } if (symbols != nullptr) std::free(symbols); return text; #else (void) skip; (void) depth; return {}; #endif } #endif /** * The process-wide logger. * * `enabled` is an atomic read so hot paths can guard expensive message * construction; everything else takes the mutex. */ class logger { public: [[nodiscard]] static auto instance() -> logger & { static logger shared; return shared; } auto configure(const options &config) -> void { const auto lock = std::scoped_lock{mutex_}; options_ = config; minimum_.store(static_cast(config.minimum), std::memory_order_relaxed); } [[nodiscard]] auto configuration() const -> options { const auto lock = std::scoped_lock{mutex_}; return options_; } [[nodiscard]] auto enabled(const level severity) const -> bool { return static_cast(severity) >= minimum_.load(std::memory_order_relaxed); } auto add_sink(std::shared_ptr destination) -> void { const auto lock = std::scoped_lock{mutex_}; sinks_.push_back(std::move(destination)); } auto set_sinks(std::vector> destinations) -> void { const auto lock = std::scoped_lock{mutex_}; sinks_ = std::move(destinations); } auto dispatch(const record &entry) -> void { const auto lock = std::scoped_lock{mutex_}; for (const auto &destination: sinks_) { destination->write(entry); } } private: logger() { sinks_.push_back(std::make_shared()); } mutable std::mutex mutex_; options options_{}; std::atomic minimum_{static_cast(options{}.minimum)}; std::vector> sinks_; }; /** Apply a configuration to the process-wide logger. */ inline auto configure(const options &config) -> void { logger::instance().configure(config); } /** Current configuration of the process-wide logger. */ [[nodiscard]] inline auto current_options() -> options { return logger::instance().configuration(); } /** Route records to an additional sink. */ inline auto add_sink(std::shared_ptr destination) -> void { logger::instance().add_sink(std::move(destination)); } /** * Create a file sink, route records to it, and hand it back. * * The previous log at `path` is archived on open, so this never appends onto * an earlier run. * * @return The sink, so the caller can inspect the archives it creates. */ inline auto add_file_sink(std::filesystem::path path, const file_options &options = {}) -> std::shared_ptr { auto destination = std::make_shared(std::move(path), options); logger::instance().add_sink(destination); return destination; } /** Replace every sink. */ inline auto set_sinks(std::vector> destinations) -> void { logger::instance().set_sinks(std::move(destinations)); } /** True when a record at this level would be emitted. */ [[nodiscard]] inline auto enabled(const level severity) -> bool { return logger::instance().enabled(severity); } namespace detail { /** Build and dispatch one record. Not for direct use. */ inline auto emit(const level severity, std::string message, const std::source_location location) -> void { auto &target = logger::instance(); if (!target.enabled(severity)) return; const auto config = target.configuration(); auto entry = record{ .severity = severity, .message = std::move(message), .file = location.file_name(), .line = static_cast(location.line()), .function = location.function_name(), .time = std::chrono::system_clock::now(), .thread = std::this_thread::get_id(), }; if (severity >= config.stacktrace_from) { entry.stacktrace = capture_stacktrace(config.stacktrace_skip, config.stacktrace_depth); } target.dispatch(entry); } } /** * Emit a record. * * The source location defaults to the call site, so this reports exactly * where it was called from. */ inline auto log(const level severity, std::string message, const std::source_location location = std::source_location::current()) -> void { detail::emit(severity, std::move(message), location); } inline auto trace(std::string message, const std::source_location location = std::source_location::current()) -> void { detail::emit(level::trace, std::move(message), location); } inline auto debug(std::string message, const std::source_location location = std::source_location::current()) -> void { detail::emit(level::debug, std::move(message), location); } inline auto info(std::string message, const std::source_location location = std::source_location::current()) -> void { detail::emit(level::info, std::move(message), location); } inline auto warn(std::string message, const std::source_location location = std::source_location::current()) -> void { detail::emit(level::warn, std::move(message), location); } /** Emits at `error`, which captures a call stack by default. */ inline auto error(std::string message, const std::source_location location = std::source_location::current()) -> void { detail::emit(level::error, std::move(message), location); } inline auto critical(std::string message, const std::source_location location = std::source_location::current()) -> void { detail::emit(level::critical, std::move(message), location); } }