diff --git a/CMakeLists.txt b/CMakeLists.txt index 7cbd5e55e..0882be68f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,7 @@ option(ICEBERG_S3 "Build with S3 support" OFF) option(ICEBERG_SIGV4 "Build with SigV4 support" OFF) option(ICEBERG_BUNDLE_AWSSDK "Bundle AWS SDK for S3/SigV4 support" ON) option(ICEBERG_BUNDLE_THRIFT "Bundle Thrift (from Arrow) for Hive catalog" ON) +option(ICEBERG_SPDLOG "Use spdlog as the default logging backend" ON) option(ICEBERG_ENABLE_ASAN "Enable Address Sanitizer" OFF) option(ICEBERG_ENABLE_UBSAN "Enable Undefined Behavior Sanitizer" OFF) diff --git a/cmake_modules/IcebergThirdpartyToolchain.cmake b/cmake_modules/IcebergThirdpartyToolchain.cmake index 2faf1ee7e..14fd12973 100644 --- a/cmake_modules/IcebergThirdpartyToolchain.cmake +++ b/cmake_modules/IcebergThirdpartyToolchain.cmake @@ -869,7 +869,9 @@ resolve_nanoarrow_dependency() resolve_croaring_dependency() resolve_utf8proc_dependency() resolve_nlohmann_json_dependency() -resolve_spdlog_dependency() +if(ICEBERG_SPDLOG) + resolve_spdlog_dependency() +endif() if(ICEBERG_S3 OR ICEBERG_SIGV4) if(ICEBERG_SIGV4 AND NOT ICEBERG_BUILD_REST) diff --git a/meson.options b/meson.options index 0448c61df..3b9f8f3ff 100644 --- a/meson.options +++ b/meson.options @@ -51,6 +51,13 @@ option( value: 'disabled', ) +option( + 'spdlog', + type: 'feature', + description: 'Use spdlog as the default logging backend (CMake: ICEBERG_SPDLOG)', + value: 'enabled', +) + option('tests', type: 'feature', description: 'Build tests', value: 'enabled') option( diff --git a/src/iceberg/CMakeLists.txt b/src/iceberg/CMakeLists.txt index e48209d62..83e652bc3 100644 --- a/src/iceberg/CMakeLists.txt +++ b/src/iceberg/CMakeLists.txt @@ -17,6 +17,18 @@ set(ICEBERG_INCLUDES "$" "$") + +# Generate the logging backend config header. ALWAYS generated (not gated by +# ICEBERG_SPDLOG) so logging/logger.cc can include it in both ON and OFF builds; +# only the definedness of ICEBERG_HAS_SPDLOG varies. Generated into the build +# tree (already on ICEBERG_INCLUDES), included as "iceberg/logging/config.h", and +# NOT installed (it must never appear in a public/installed header). +if(ICEBERG_SPDLOG) + set(ICEBERG_HAS_SPDLOG ON) +endif() +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/logging/config.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/logging/config.h") + set(ICEBERG_SOURCES arrow_c_data_guard_internal.cc arrow_c_data_util.cc @@ -157,29 +169,37 @@ list(APPEND ICEBERG_STATIC_BUILD_INTERFACE_LIBS "$,nanoarrow::nanoarrow_static,$,nanoarrow::nanoarrow_static,nanoarrow::nanoarrow_shared>>" nlohmann_json::nlohmann_json - spdlog::spdlog utf8proc::utf8proc ZLIB::ZLIB) list(APPEND ICEBERG_SHARED_BUILD_INTERFACE_LIBS "$,nanoarrow::nanoarrow_static,$,nanoarrow::nanoarrow_shared,nanoarrow::nanoarrow_static>>" nlohmann_json::nlohmann_json - spdlog::spdlog utf8proc::utf8proc ZLIB::ZLIB) list(APPEND ICEBERG_STATIC_INSTALL_INTERFACE_LIBS "$,iceberg::nanoarrow_static,$,nanoarrow::nanoarrow_static,nanoarrow::nanoarrow_shared>>" "$,iceberg::nlohmann_json,$,nlohmann_json::nlohmann_json,nlohmann_json::nlohmann_json>>" - "$,iceberg::spdlog,spdlog::spdlog>" "$,iceberg::utf8proc,utf8proc::utf8proc>") list(APPEND ICEBERG_SHARED_INSTALL_INTERFACE_LIBS "$,iceberg::nanoarrow_static,$,nanoarrow::nanoarrow_shared,nanoarrow::nanoarrow_static>>" "$,iceberg::nlohmann_json,$,nlohmann_json::nlohmann_json,nlohmann_json::nlohmann_json>>" - "$,iceberg::spdlog,spdlog::spdlog>" "$,iceberg::utf8proc,utf8proc::utf8proc>") +# spdlog backend: linked and compiled only when ICEBERG_SPDLOG is ON. When OFF, +# the core library has no spdlog dependency and CerrLogger is the default sink. +if(ICEBERG_SPDLOG) + list(APPEND ICEBERG_SOURCES logging/internal/spdlog_logger.cc) + list(APPEND ICEBERG_STATIC_BUILD_INTERFACE_LIBS spdlog::spdlog) + list(APPEND ICEBERG_SHARED_BUILD_INTERFACE_LIBS spdlog::spdlog) + list(APPEND ICEBERG_STATIC_INSTALL_INTERFACE_LIBS + "$,iceberg::spdlog,spdlog::spdlog>") + list(APPEND ICEBERG_SHARED_INSTALL_INTERFACE_LIBS + "$,iceberg::spdlog,spdlog::spdlog>") +endif() + add_iceberg_lib(iceberg SOURCES ${ICEBERG_SOURCES} diff --git a/src/iceberg/logging/config.h.in b/src/iceberg/logging/config.h.in new file mode 100644 index 000000000..1b1e0d02c --- /dev/null +++ b/src/iceberg/logging/config.h.in @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#pragma once + +// Internal, build-generated configuration for the logging backend. +// This header is NOT installed and must only be included from .cc files +// (logger.cc, internal/spdlog_logger.cc) -- never from a public header. +// +// ICEBERG_HAS_SPDLOG is defined when the project is built with -DICEBERG_SPDLOG=ON +// and left undefined otherwise. Always test it with #ifdef / #ifndef, never #if +// (it carries no value). + +#cmakedefine ICEBERG_HAS_SPDLOG diff --git a/src/iceberg/logging/internal/spdlog_logger.cc b/src/iceberg/logging/internal/spdlog_logger.cc new file mode 100644 index 000000000..f3f61da4a --- /dev/null +++ b/src/iceberg/logging/internal/spdlog_logger.cc @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#include "iceberg/logging/internal/spdlog_logger.h" + +#ifdef ICEBERG_HAS_SPDLOG + +# include +# include +# include +# include + +# include +# include + +namespace iceberg::internal { + +namespace { + +spdlog::level::level_enum ToSpdLevel(LogLevel level) noexcept { + switch (level) { + case LogLevel::kTrace: + return spdlog::level::trace; + case LogLevel::kDebug: + return spdlog::level::debug; + case LogLevel::kInfo: + return spdlog::level::info; + case LogLevel::kWarn: + return spdlog::level::warn; + case LogLevel::kError: + return spdlog::level::err; + case LogLevel::kCritical: + case LogLevel::kFatal: + // spdlog has no "fatal"; the process abort is owned by the macro layer. + return spdlog::level::critical; + case LogLevel::kOff: + return spdlog::level::off; + } + return spdlog::level::off; +} + +/// \brief The built-in sink: a color stderr spdlog logger. +std::shared_ptr MakeDefaultSpdLogger() { + return std::make_shared( + "iceberg", std::make_shared()); +} + +} // namespace + +SpdLogger::SpdLogger(LogLevel level) : SpdLogger(MakeDefaultSpdLogger(), level) {} + +Status SpdLogger::Initialize( + const std::unordered_map& properties) { + if (auto it = properties.find(std::string(kPatternProperty)); it != properties.end()) { + logger_->set_pattern(it->second); + } + // Apply "level" via the base implementation. + return Logger::Initialize(properties); +} + +SpdLogger::SpdLogger(std::shared_ptr logger, LogLevel level) + : logger_(std::move(logger)), level_(level) { + // logger_ is non-null for the rest of this object's life, so Initialize/Log/Flush + // may dereference it unconditionally. Enforced by substitution rather than an + // assertion, which would vanish under NDEBUG and leave a release-build crash: a + // null argument falls back to the same stderr-backed logger the default + // constructor builds, so a caller mistake degrades to the default sink. + if (!logger_) { + logger_ = MakeDefaultSpdLogger(); + } + logger_->set_level(spdlog::level::trace); // filtering is done by ShouldLog +} + +void SpdLogger::Log(LogMessage&& message) noexcept { + try { + spdlog::source_loc loc{message.location.file_name(), + static_cast(message.location.line()), + message.location.function_name()}; + // Raw-message overload: the text is already formatted, so hand spdlog the bytes + // directly instead of running them back through fmt (which would re-parse and + // copy the whole message, allocating for long ones). It also means braces in the + // message can never be interpreted as format placeholders. + logger_->log(loc, ToSpdLevel(message.level), + spdlog::string_view_t{message.message.data(), message.message.size()}); + } catch (...) { + // Logging must never throw. + } +} + +void SpdLogger::Flush() noexcept { + try { + logger_->flush(); + } catch (...) { + } +} + +} // namespace iceberg::internal + +#endif // ICEBERG_HAS_SPDLOG diff --git a/src/iceberg/logging/internal/spdlog_logger.h b/src/iceberg/logging/internal/spdlog_logger.h new file mode 100644 index 000000000..c8712ca98 --- /dev/null +++ b/src/iceberg/logging/internal/spdlog_logger.h @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +#pragma once + +/// \file iceberg/logging/internal/spdlog_logger.h +/// \brief spdlog-backed logging sink. +/// +/// INTERNAL, NOT INSTALLED. Included only from .cc files (logger.cc and +/// spdlog_logger.cc). It pulls in the build-generated config.h itself and gates +/// its entire body on ICEBERG_HAS_SPDLOG, so it compiles to nothing unless the +/// project was built with ICEBERG_SPDLOG=ON. SpdLogger is not a +/// consumer-constructible public type -- applications obtain it via the default +/// logger or the "logger-impl"="spdlog" registry factory. + +#include "iceberg/logging/config.h" + +#ifdef ICEBERG_HAS_SPDLOG + +# include +# include + +# include + +# include "iceberg/logging/log_level.h" +# include "iceberg/logging/logger.h" + +namespace iceberg::internal { + +/// \brief Logger backed by spdlog (synchronous only in v1). +/// +/// Synchronous because spdlog::source_loc holds non-owning const char* that are +/// unsafe to forward into an async logger (spdlog #3227). +/// ICEBERG_EXPORT so the symbol is linkable from in-tree tests (and any +/// internal consumer) under -fvisibility=hidden / MSVC DLL builds. The header +/// is still not installed -- this is a binary-visibility detail, not public API. +class ICEBERG_EXPORT SpdLogger : public Logger { + public: + /// \brief Construct over a default stderr-backed spdlog logger. + explicit SpdLogger(LogLevel level = LogLevel::kInfo); + + /// \brief Construct over a caller-provided spdlog logger. + /// + /// The logger MUST be synchronous. Log() forwards spdlog::source_loc, which + /// borrows the std::source_location's const char* pointers; an async spdlog + /// logger would queue them past their lifetime (spdlog #3227 -> UB). This is a + /// caller contract -- spdlog exposes no reliable sync/async query to assert on. + explicit SpdLogger(std::shared_ptr logger, + LogLevel level = LogLevel::kInfo); + + /// \brief Apply the "pattern" property (spdlog set_pattern), then "level". + Status Initialize( + const std::unordered_map& properties) override; + + bool ShouldLog(LogLevel level) const noexcept override { + return level >= level_.load(std::memory_order_relaxed); + } + void Log(LogMessage&& message) noexcept override; + void SetLevel(LogLevel level) noexcept override { + level_.store(level, std::memory_order_relaxed); + } + LogLevel level() const noexcept override { + return level_.load(std::memory_order_relaxed); + } + void Flush() noexcept override; + + private: + std::shared_ptr logger_; + std::atomic level_; +}; + +} // namespace iceberg::internal + +#endif // ICEBERG_HAS_SPDLOG diff --git a/src/iceberg/logging/logger.cc b/src/iceberg/logging/logger.cc index 77895e36f..657687456 100644 --- a/src/iceberg/logging/logger.cc +++ b/src/iceberg/logging/logger.cc @@ -26,7 +26,13 @@ #include #include +// Build-generated, .cc-only (never from a public header). Defines +// ICEBERG_HAS_SPDLOG when built with -DICEBERG_SPDLOG=ON; tested with #ifdef. #include "iceberg/logging/cerr_logger.h" +#include "iceberg/logging/config.h" +#ifdef ICEBERG_HAS_SPDLOG +# include "iceberg/logging/internal/spdlog_logger.h" +#endif namespace iceberg { @@ -44,9 +50,15 @@ class NoopLogger final : public Logger { /// \brief Construct the process default logger for this build configuration. /// -/// Uses the always-available std::cerr sink. The spdlog backend (preferred when -/// compiled in) is wired into this factory in a later block. -std::shared_ptr MakeDefaultLogger() { return std::make_shared(); } +/// Prefers the spdlog backend when compiled in; otherwise the always-available +/// std::cerr logger. +std::shared_ptr MakeDefaultLogger() { +#ifdef ICEBERG_HAS_SPDLOG + return std::make_shared(); +#else + return std::make_shared(); +#endif +} /// \brief The process-global default-logger slot. struct DefaultSlot { diff --git a/src/iceberg/logging/meson.build b/src/iceberg/logging/meson.build index 901855a7c..833885724 100644 --- a/src/iceberg/logging/meson.build +++ b/src/iceberg/logging/meson.build @@ -15,6 +15,21 @@ # specific language governing permissions and limitations # under the License. +# Generate the .cc-only logging backend config header. ALWAYS generated (logger.cc +# includes it in both configurations); only the definedness of ICEBERG_HAS_SPDLOG +# varies with the `spdlog` feature option -- mirroring CMake's ICEBERG_SPDLOG. When +# disabled, CerrLogger is the default sink and spdlog is neither compiled nor +# linked. Generated into build/src/iceberg/logging/config.h (resolved via +# include_directories('..'), which exposes both the source and build trees); not +# installed. +logging_config_data = configuration_data() +if spdlog_enabled + logging_config_data.set('ICEBERG_HAS_SPDLOG', 1) +endif +configure_file(output: 'config.h', configuration: logging_config_data) + +# Public logging headers. The build-generated config.h and the internal +# SpdLogger header are intentionally NOT installed. install_headers( [ 'cerr_logger.h', diff --git a/src/iceberg/meson.build b/src/iceberg/meson.build index 39e8b2939..e499d1860 100644 --- a/src/iceberg/meson.build +++ b/src/iceberg/meson.build @@ -63,6 +63,12 @@ configure_file( install_dir: get_option('includedir') / 'iceberg', ) +# spdlog logging backend, mirroring CMake's ICEBERG_SPDLOG. Resolved before +# subdir('logging') because config.h's ICEBERG_HAS_SPDLOG depends on it. +spdlog_enabled = get_option('spdlog').allowed() + +# Generate iceberg/logging/config.h (must precede the library() that compiles +# the logging sources which include it). subdir('logging') iceberg_include_dir = include_directories('..') @@ -198,6 +204,12 @@ iceberg_sources = files( 'util/uuid.cc', ) +# The spdlog sink is compiled only when the backend is enabled; with it off the +# core library has no spdlog dependency and CerrLogger is the default sink. +if spdlog_enabled + iceberg_sources += files('logging/internal/spdlog_logger.cc') +endif + iceberg_data_sources = files( 'data/data_writer.cc', 'data/delete_filter.cc', @@ -228,7 +240,6 @@ croaring_needs_static = ( croaring_dep = dependency('croaring', static: croaring_needs_static) nanoarrow_dep = dependency('nanoarrow') nlohmann_json_dep = dependency('nlohmann_json') -spdlog_dep = dependency('spdlog') # utf8proc's header declares its functions __declspec(dllimport) on Windows unless # UTF8PROC_STATIC is defined, and the wrap does not propagate that define to consumers. # Define it whenever utf8proc is linked statically, so the header's declarations match @@ -243,13 +254,14 @@ if utf8proc_needs_static endif zlib_dep = dependency('zlib') -iceberg_deps = [ - nanoarrow_dep, - nlohmann_json_dep, - spdlog_dep, - utf8proc_dep, - zlib_dep, -] +iceberg_deps = [nanoarrow_dep, nlohmann_json_dep, utf8proc_dep, zlib_dep] + +# spdlog is looked up and linked only when the backend is enabled, so a +# -Dspdlog=disabled build needs neither the dependency nor the sink. +if spdlog_enabled + spdlog_dep = dependency('spdlog') + iceberg_deps += spdlog_dep +endif iceberg_lib = library( 'iceberg', diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt index f1e2a3a78..6e5f2fca2 100644 --- a/src/iceberg/test/CMakeLists.txt +++ b/src/iceberg/test/CMakeLists.txt @@ -97,7 +97,8 @@ add_iceberg_test(logging_test log_level_test.cc logger_test.cc macros_active_level_test.cc - macros_test.cc) + macros_test.cc + spdlog_logger_test.cc) add_iceberg_test(expression_test SOURCES diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build index 09fa12061..f9549eef3 100644 --- a/src/iceberg/test/meson.build +++ b/src/iceberg/test/meson.build @@ -70,6 +70,7 @@ iceberg_tests = { 'logger_test.cc', 'macros_active_level_test.cc', 'macros_test.cc', + 'spdlog_logger_test.cc', ), }, 'expression_test': { diff --git a/src/iceberg/test/spdlog_logger_test.cc b/src/iceberg/test/spdlog_logger_test.cc new file mode 100644 index 000000000..511830b6d --- /dev/null +++ b/src/iceberg/test/spdlog_logger_test.cc @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Internal/build-generated header is acceptable in a test TU (not installed). +#include "iceberg/logging/config.h" + +#ifdef ICEBERG_HAS_SPDLOG + +# include +# include +# include +# include +# include + +# include +# include +# include + +# include "iceberg/logging/internal/spdlog_logger.h" +# include "iceberg/logging/log_level.h" +# include "iceberg/logging/logger.h" +# include "iceberg/test/matchers.h" + +namespace iceberg { + +namespace { + +LogMessage MakeMessage(LogLevel level, std::string text) { + return LogMessage{.level = level, + .message = std::move(text), + .location = std::source_location::current(), + .attributes = {}}; +} + +internal::SpdLogger MakeCapturing(std::ostringstream& out, + LogLevel level = LogLevel::kTrace) { + auto sink = std::make_shared(out); + auto spd = std::make_shared("test", sink); + return internal::SpdLogger(spd, level); +} + +} // namespace + +TEST(SpdLoggerTest, DefaultLevelIsInfo) { + internal::SpdLogger logger; + EXPECT_EQ(logger.level(), LogLevel::kInfo); + EXPECT_FALSE(logger.ShouldLog(LogLevel::kDebug)); + EXPECT_TRUE(logger.ShouldLog(LogLevel::kError)); +} + +// SetLevel/level() round-trip, and the level actually gates emission. +TEST(SpdLoggerTest, SetLevelFiltersEmission) { + std::ostringstream out; + auto logger = MakeCapturing(out, LogLevel::kError); + EXPECT_EQ(logger.level(), LogLevel::kError); + EXPECT_FALSE(logger.ShouldLog(LogLevel::kWarn)); + + logger.SetLevel(LogLevel::kTrace); + EXPECT_EQ(logger.level(), LogLevel::kTrace); + EXPECT_TRUE(logger.ShouldLog(LogLevel::kTrace)); +} + +// A null spdlog logger is substituted with the default stderr-backed one at +// construction, so the object is always usable: Log/Flush must not crash and the +// level accessors keep working. (The substitution is unconditional -- not a DCHECK +// -- so this holds in release builds too.) +TEST(SpdLoggerTest, NullLoggerIsSubstitutedNotDereferenced) { + internal::SpdLogger logger(std::shared_ptr{}, LogLevel::kTrace); + EXPECT_EQ(logger.level(), LogLevel::kTrace); + // Would crash if logger_ had been left null. + logger.Log(MakeMessage(LogLevel::kError, "survives-null-ctor")); + logger.Flush(); + auto status = logger.Initialize({{std::string(kPatternProperty), std::string("%v")}}); + EXPECT_TRUE(status.has_value()); +} + +// The base Logger::Initialize parses "level"; an unrecognized value is an error. +TEST(SpdLoggerTest, InitializeRejectsInvalidLevel) { + std::ostringstream out; + auto logger = MakeCapturing(out); + auto status = + logger.Initialize({{std::string(kLevelProperty), std::string("not-a-level")}}); + ASSERT_FALSE(status.has_value()); + EXPECT_THAT(status, IsError(ErrorKind::kInvalidArgument)); +} + +TEST(SpdLoggerTest, ForwardsMessageToSink) { + std::ostringstream out; + auto logger = MakeCapturing(out); + logger.Log(MakeMessage(LogLevel::kError, "boom 42")); + logger.Flush(); + EXPECT_NE(out.str().find("boom 42"), std::string::npos); +} + +TEST(SpdLoggerTest, MessageBracesAreNotInterpreted) { + std::ostringstream out; + auto logger = MakeCapturing(out); + // A pre-formatted message containing braces must pass through verbatim. + logger.Log(MakeMessage(LogLevel::kInfo, "literal {not a placeholder}")); + logger.Flush(); + EXPECT_NE(out.str().find("literal {not a placeholder}"), std::string::npos); +} + +TEST(SpdLoggerTest, CriticalAndFatalBothEmit) { + std::ostringstream out; + auto logger = MakeCapturing(out); + logger.Log(MakeMessage(LogLevel::kCritical, "crit")); + logger.Log(MakeMessage(LogLevel::kFatal, "fatal-tag")); + logger.Flush(); + EXPECT_NE(out.str().find("crit"), std::string::npos); + EXPECT_NE(out.str().find("fatal-tag"), std::string::npos); +} + +TEST(SpdLoggerTest, PatternPropertyChangesLayout) { + std::ostringstream out; + auto logger = MakeCapturing(out); + auto status = + logger.Initialize({{std::string(kPatternProperty), std::string("PFX %v")}}); + ASSERT_TRUE(status.has_value()); + logger.Log(MakeMessage(LogLevel::kError, "hello")); + logger.Flush(); + EXPECT_NE(out.str().find("PFX hello"), std::string::npos); +} + +// The record's std::source_location must reach spdlog as source_loc: assert the +// file, line, and function fields render via the %s / %# / %! pattern flags. This +// is the forwarding that makes SpdLogger synchronous-only (source_loc borrows the +// location's const char*), so it needs explicit coverage. +TEST(SpdLoggerTest, ForwardsSourceLocationToSink) { + std::ostringstream out; + auto logger = MakeCapturing(out); + auto status = + logger.Initialize({{std::string(kPatternProperty), std::string("%s:%# %! %v")}}); + ASSERT_TRUE(status.has_value()); + + // Capture the location here (not inside MakeMessage) so the expected file/line + // belong to this call site. + const auto here = std::source_location::current(); + logger.Log(LogMessage{.level = LogLevel::kError, + .message = "located", + .location = here, + .attributes = {}}); + logger.Flush(); + + const std::string rendered = out.str(); + // %s renders the basename of the file. + EXPECT_NE(rendered.find("spdlog_logger_test.cc"), std::string::npos) << rendered; + EXPECT_NE(rendered.find(std::to_string(here.line())), std::string::npos) << rendered; + // %! renders the function name; gtest bodies are TestBody(). + EXPECT_NE(rendered.find("TestBody"), std::string::npos) << rendered; + EXPECT_NE(rendered.find("located"), std::string::npos) << rendered; +} + +} // namespace iceberg + +#endif // ICEBERG_HAS_SPDLOG