rexglue-skate3/src/core/logging.cpp
2026-05-18 22:41:13 +01:00

556 lines
16 KiB
C++

/**
* @file core/logging.cpp
* @brief Logging infrastructure implementation
*
* @copyright Copyright (c) 2026 Tom Clay <tomc@tctechstuff.com>
* All rights reserved.
*
* @license BSD 3-Clause License
* See LICENSE file in the project root for full license text.
*/
#include <algorithm>
#include <cctype>
#include <charconv>
#include <filesystem>
#include <mutex>
#include <unordered_map>
#include <vector>
#include <spdlog/sinks/rotating_file_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <toml++/toml.hpp>
#include <rex/cvar.h>
#include <rex/logging.h>
#include <rex/platform.h>
#if REX_PLATFORM_WIN32
#include <spdlog/sinks/msvc_sink.h>
#else
#include <spdlog/sinks/stdout_sinks.h>
#endif
REXCVAR_DEFINE_STRING(log_level, "info", "Log",
"Global log level: trace, debug, info, warn, error, critical, off")
.allowed({"trace", "debug", "info", "warn", "error", "critical", "off"});
REXCVAR_DEFINE_STRING(log_file, "", "Log", "Log file path (empty = auto sequential naming)");
REXCVAR_DEFINE_BOOL(log_verbose, false, "Log", "Enable verbose logging (sets level to trace)")
.debug_only();
REXCVAR_DEFINE_BOOL(log_noisy, false, "Log", "Enable noisy/high-frequency log macros");
REXCVAR_DEFINE_INT32(log_flush_interval, 0, "Log", "Periodic flush interval in seconds (0 = off)")
.range(0, 60)
.lifecycle(rex::cvar::Lifecycle::kInitOnly);
REXCVAR_DEFINE_INT32(log_max_file_size_mb, 5, "Log", "Max log file size in MB before rotation")
.range(1, 100)
.lifecycle(rex::cvar::Lifecycle::kInitOnly);
REXCVAR_DEFINE_INT32(log_max_files, 20, "Log", "Max number of rotated log files to keep")
.range(1, 100)
.lifecycle(rex::cvar::Lifecycle::kInitOnly);
namespace rex {
namespace {
std::vector<LogCategoryEntry> g_registry;
spdlog::sink_ptr g_console_sink;
spdlog::sink_ptr g_file_sink;
spdlog::sink_ptr g_early_sink;
std::vector<spdlog::sink_ptr> g_extra_sinks;
bool g_early_initialized = false;
bool g_initialized = false;
std::mutex g_mutex;
LogConfig g_config;
std::filesystem::path NextSequentialLogPath(const std::filesystem::path& logs_dir,
std::string_view app_name) {
std::filesystem::create_directories(logs_dir);
int max_seq = 0;
std::string prefix = std::string(app_name) + "_";
std::error_code ec;
for (const auto& entry : std::filesystem::directory_iterator(logs_dir, ec)) {
if (!entry.is_regular_file())
continue;
auto stem = entry.path().stem().string();
if (stem.starts_with(prefix)) {
auto num_str = stem.substr(prefix.size());
int num = 0;
auto [ptr, parse_ec] = std::from_chars(num_str.data(), num_str.data() + num_str.size(), num);
if (parse_ec == std::errc() && ptr == num_str.data() + num_str.size())
max_seq = std::max(max_seq, num);
}
}
return logs_dir / fmt::format("{}_{:03d}.log", app_name, max_seq + 1);
}
std::vector<spdlog::sink_ptr> BuildDefaultSinks() {
std::vector<spdlog::sink_ptr> sinks;
if (g_early_sink)
sinks.push_back(g_early_sink);
if (g_console_sink)
sinks.push_back(g_console_sink);
if (g_file_sink)
sinks.push_back(g_file_sink);
for (auto& s : g_extra_sinks)
sinks.push_back(s);
return sinks;
}
// Build sinks for a specific category (handles per-category sinks from config)
std::vector<spdlog::sink_ptr> BuildCategorySinks(const std::string& name) {
auto it = g_config.category_sinks.find(name);
if (it != g_config.category_sinks.end()) {
if (g_config.category_sinks_exclusive) {
// Replace default sinks entirely
return it->second;
}
// Additive: default sinks + category-specific sinks
auto sinks = BuildDefaultSinks();
for (auto& s : it->second)
sinks.push_back(s);
return sinks;
}
return BuildDefaultSinks();
}
// Resolve per-category level from config, or return default
spdlog::level::level_enum ResolveCategoryLevel(const std::string& name) {
auto it = g_config.category_levels.find(name);
if (it != g_config.category_levels.end())
return it->second;
return g_config.default_level;
}
// Create a logger and register it
std::shared_ptr<spdlog::logger> CreateCategoryLogger(const std::string& name) {
auto sinks = BuildCategorySinks(name);
auto logger = std::make_shared<spdlog::logger>(name, sinks.begin(), sinks.end());
logger->set_level(ResolveCategoryLevel(name));
logger->flush_on(g_config.flush_level);
spdlog::register_logger(logger);
return logger;
}
} // namespace
void InitLoggingEarly() {
std::lock_guard lock(g_mutex);
if (g_early_initialized || g_initialized)
return;
#if REX_PLATFORM_WIN32
auto sink = std::make_shared<spdlog::sinks::msvc_sink_mt>();
#else
auto sink = std::make_shared<spdlog::sinks::stdout_sink_mt>();
#endif
sink->set_level(spdlog::level::trace);
sink->set_pattern("[%l] [%n] %v");
g_early_sink = sink;
g_config.default_level = kDefaultLogLevel;
// Create loggers for any categories already registered during static init
for (auto& entry : g_registry) {
if (!entry.name.empty() && !entry.logger)
entry.logger = CreateCategoryLogger(entry.name);
}
// Set default logger to "core" if registered
for (auto& entry : g_registry) {
if (entry.name == "core") {
spdlog::set_default_logger(entry.logger);
break;
}
}
g_early_initialized = true;
}
void InitLogging(const LogConfig& config) {
std::lock_guard lock(g_mutex);
if (g_initialized) {
g_config = config;
for (auto& entry : g_registry) {
if (!entry.logger)
continue;
entry.logger->set_level(ResolveCategoryLevel(entry.name));
entry.logger->flush_on(config.flush_level);
if (g_config.category_levels.count(entry.name))
entry.has_explicit_level = true;
}
return;
}
g_config = config;
// Early sink handling:
// Windows: the early msvc_sink is the persistent debug channel for GUI
// apps and does not conflict with the stdout console sink, so keep it.
// Non-Windows: drop the early stdout sink unconditionally so file-only
// configs don't leak to stdout and console configs don't duplicate.
#if !REX_PLATFORM_WIN32
if (g_early_sink) {
for (auto& entry : g_registry) {
if (entry.logger)
std::erase(entry.logger->sinks(), g_early_sink);
}
g_early_sink.reset();
}
#endif
// Console sink (stdout, colored). Intended for console-subsystem processes.
if (config.log_to_console) {
auto sink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
sink->set_level(spdlog::level::trace);
sink->set_pattern(config.console_pattern);
g_console_sink = sink;
}
// File sink (rotating) with sequential naming fallback
std::string resolved_path;
if (config.log_file) {
resolved_path = config.log_file;
} else if (!config.app_name.empty()) {
auto log_dir = config.log_dir.empty() ? std::filesystem::current_path() / "logs"
: std::filesystem::path(config.log_dir);
resolved_path = NextSequentialLogPath(log_dir, config.app_name).string();
}
if (!resolved_path.empty()) {
auto sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
resolved_path, static_cast<size_t>(REXCVAR_GET(log_max_file_size_mb)) * 1024 * 1024,
static_cast<size_t>(REXCVAR_GET(log_max_files)), false);
sink->set_level(spdlog::level::trace);
sink->set_pattern(config.file_pattern);
g_file_sink = sink;
}
g_extra_sinks = config.extra_sinks;
// Rebuild all loggers with new sinks
for (auto& entry : g_registry) {
if (entry.name.empty())
continue;
if (entry.logger)
spdlog::drop(entry.name);
entry.logger = CreateCategoryLogger(entry.name);
if (g_config.category_levels.count(entry.name))
entry.has_explicit_level = true;
}
// Set default logger to "core"
for (auto& entry : g_registry) {
if (entry.name == "core") {
spdlog::set_default_logger(entry.logger);
break;
}
}
g_initialized = true;
// Periodic flush
int flush_interval = REXCVAR_GET(log_flush_interval);
if (flush_interval > 0)
spdlog::flush_every(std::chrono::seconds(flush_interval));
}
void InitLogging(const char* log_file, spdlog::level::level_enum level) {
LogConfig config;
config.log_file = log_file;
config.default_level = level;
InitLogging(config);
}
void ShutdownLogging() {
std::lock_guard lock(g_mutex);
if (!g_initialized && !g_early_initialized)
return;
for (auto& entry : g_registry)
if (entry.logger)
entry.logger->flush();
spdlog::shutdown();
g_registry.clear();
g_console_sink.reset();
g_file_sink.reset();
g_early_sink.reset();
g_extra_sinks.clear();
g_initialized = false;
g_early_initialized = false;
}
LogCategoryId RegisterLogCategory(const char* name) {
std::lock_guard lock(g_mutex);
// Check for duplicates
for (size_t i = 0; i < g_registry.size(); ++i) {
if (g_registry[i].name == name) {
return LogCategoryId{static_cast<uint16_t>(i)};
}
}
uint16_t id = static_cast<uint16_t>(g_registry.size());
LogCategoryEntry entry;
entry.name = name;
if (g_initialized || g_early_initialized) {
entry.logger = CreateCategoryLogger(name);
}
g_registry.push_back(std::move(entry));
return LogCategoryId{id};
}
LogCategoryId RegisterLogSubcategory(const char* name, LogCategoryId parent) {
std::lock_guard lock(g_mutex);
std::string parent_name;
if (parent.id < g_registry.size())
parent_name = g_registry[parent.id].name;
std::string full_name = parent_name + "." + name;
for (size_t i = 0; i < g_registry.size(); ++i) {
if (g_registry[i].name == full_name)
return LogCategoryId{static_cast<uint16_t>(i)};
}
uint16_t id = static_cast<uint16_t>(g_registry.size());
LogCategoryEntry entry;
entry.name = full_name;
entry.parent = parent;
if (g_initialized || g_early_initialized) {
entry.logger = CreateCategoryLogger(full_name);
if (g_config.category_levels.count(full_name))
entry.has_explicit_level = true;
}
g_registry.push_back(std::move(entry));
return LogCategoryId{id};
}
std::optional<LogCategoryId> FindCategory(const std::string& name) {
std::lock_guard lock(g_mutex);
for (size_t i = 0; i < g_registry.size(); ++i) {
if (g_registry[i].name == name) {
return LogCategoryId{static_cast<uint16_t>(i)};
}
}
return std::nullopt;
}
std::span<const LogCategoryEntry> GetAllCategories() {
// No lock: only safe to call from main thread or after init.
return {g_registry.data(), g_registry.size()};
}
spdlog::logger* GetLoggerRaw(LogCategoryId category) {
if (!g_initialized && !g_early_initialized)
InitLoggingEarly();
if (category.id < g_registry.size()) {
return g_registry[category.id].logger.get();
}
return nullptr;
}
std::shared_ptr<spdlog::logger> GetLogger(LogCategoryId category) {
if (!g_initialized && !g_early_initialized)
InitLoggingEarly();
if (category.id < g_registry.size()) {
return g_registry[category.id].logger;
}
return nullptr;
}
std::shared_ptr<spdlog::logger> GetLogger() {
return GetLogger(rex::log::core());
}
void SetCategoryLevel(LogCategoryId category, spdlog::level::level_enum level) {
if (category.id < g_registry.size()) {
if (auto& logger = g_registry[category.id].logger) {
logger->set_level(level);
}
}
}
void SetAllLevels(spdlog::level::level_enum level) {
for (auto& entry : g_registry) {
if (entry.logger)
entry.logger->set_level(level);
}
}
void SetRootLevel(LogCategoryId root, spdlog::level::level_enum level) {
SetCategoryLevel(root, level);
for (auto& entry : g_registry) {
if (entry.parent.has_value() && entry.parent->id == root.id && !entry.has_explicit_level &&
entry.logger)
entry.logger->set_level(level);
}
}
void RegisterLogLevelCallback() {
rex::cvar::RegisterChangeCallback("log_level", [](std::string_view, std::string_view value) {
if (auto level = ParseLogLevel(std::string(value))) {
for (size_t i = 0; i < g_registry.size(); ++i) {
auto& entry = g_registry[i];
if (!entry.parent.has_value() && entry.logger && !entry.has_explicit_level)
SetRootLevel(LogCategoryId{static_cast<uint16_t>(i)}, *level);
}
}
});
}
void AddSink(spdlog::sink_ptr sink) {
std::lock_guard lock(g_mutex);
g_extra_sinks.push_back(sink);
for (auto& entry : g_registry) {
if (entry.logger)
entry.logger->sinks().push_back(sink);
}
}
void AddSink(LogCategoryId category, spdlog::sink_ptr sink) {
std::lock_guard lock(g_mutex);
if (category.id < g_registry.size()) {
if (auto& logger = g_registry[category.id].logger) {
logger->sinks().push_back(sink);
}
}
}
void RemoveSink(spdlog::sink_ptr sink) {
std::lock_guard lock(g_mutex);
std::erase(g_extra_sinks, sink);
for (auto& entry : g_registry) {
if (entry.logger)
std::erase(entry.logger->sinks(), sink);
}
}
void RemoveSink(LogCategoryId category, spdlog::sink_ptr sink) {
std::lock_guard lock(g_mutex);
if (category.id < g_registry.size()) {
if (auto& logger = g_registry[category.id].logger) {
std::erase(logger->sinks(), sink);
}
}
}
void ReplaceConsoleSink(spdlog::sink_ptr sink) {
std::lock_guard lock(g_mutex);
for (auto& entry : g_registry) {
if (!entry.logger)
continue;
auto& sinks = entry.logger->sinks();
if (g_console_sink)
std::erase(sinks, g_console_sink);
if (sink)
sinks.push_back(sink);
}
g_console_sink = sink;
}
void SetConsolePattern(const std::string& pattern) {
if (g_console_sink)
g_console_sink->set_pattern(pattern);
}
void SetFilePattern(const std::string& pattern) {
if (g_file_sink)
g_file_sink->set_pattern(pattern);
}
// ==========================================================================
// CLI Helpers
// ==========================================================================
std::optional<spdlog::level::level_enum> ParseLogLevel(const std::string& level_str) {
static const std::unordered_map<std::string, spdlog::level::level_enum> level_map = {
{"trace", spdlog::level::trace}, {"debug", spdlog::level::debug},
{"info", spdlog::level::info}, {"warn", spdlog::level::warn},
{"warning", spdlog::level::warn}, {"error", spdlog::level::err},
{"err", spdlog::level::err}, {"critical", spdlog::level::critical},
{"off", spdlog::level::off},
};
std::string lower = level_str;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return std::tolower(c); });
auto it = level_map.find(lower);
if (it != level_map.end())
return it->second;
return std::nullopt;
}
spdlog::level::level_enum ParseLogLevelOr(const std::string& level_str,
spdlog::level::level_enum default_level) {
return ParseLogLevel(level_str).value_or(default_level);
}
LogConfig BuildLogConfig(const char* log_file, const std::string& cli_level,
const std::map<std::string, std::string>& category_levels) {
LogConfig config;
config.log_file = log_file;
// Build-type default
config.default_level = kDefaultLogLevel;
// Environment variable
if (const char* env_level = std::getenv("REX_LOG_LEVEL")) {
if (auto level = ParseLogLevel(env_level))
config.default_level = *level;
} else if (const char* env_level2 = std::getenv("SPDLOG_LEVEL")) {
if (auto level = ParseLogLevel(env_level2))
config.default_level = *level;
}
// CLI global level overrides environment
if (!cli_level.empty()) {
if (auto level = ParseLogLevel(cli_level))
config.default_level = *level;
}
// Per-category CLI levels (string-keyed)
for (const auto& [cat_name, level_str] : category_levels) {
if (level_str.empty())
continue;
if (auto level = ParseLogLevel(level_str)) {
config.category_levels[cat_name] = *level;
}
}
return config;
}
std::map<std::string, std::string> ParseCategoryLevelsFromConfig(
const std::filesystem::path& config_path) {
std::map<std::string, std::string> result;
if (!std::filesystem::exists(config_path))
return result;
try {
auto config = toml::parse_file(config_path.string());
auto* log_table = config["log"].as_table();
if (!log_table)
return result;
auto* levels_table = (*log_table)["levels"].as_table();
if (!levels_table)
return result;
for (const auto& [key, value] : *levels_table) {
if (value.is_string())
result[std::string(key)] = value.as_string()->get();
}
} catch (const toml::parse_error&) {}
return result;
}
} // namespace rex