You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ga...@apache.org on 2026/08/15 13:10:19 UTC

(iceberg-cpp) branch main updated: feat(logging): add spdlog backend behind ICEBERG_SPDLOG (#726)

This is an automated email from the ASF dual-hosted git repository.

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new 31274f5f feat(logging): add spdlog backend behind ICEBERG_SPDLOG (#726)
31274f5f is described below

commit 31274f5fd1b5f6b34ca18f833e4b25ea76faf1cd
Author: kamcheungting-db <91...@users.noreply.github.com>
AuthorDate: Sat Aug 15 06:10:14 2026 -0700

    feat(logging): add spdlog backend behind ICEBERG_SPDLOG (#726)
    
    Part 5 of the logging stack (builds on #725). Adds the spdlog backend and the build option that selects it — the default in production builds.
    
    **What's here**
    - `SpdLogger` wraps `spdlog::logger` (synchronous), maps levels (fatal/critical → spdlog critical; the abort stays in the macro layer), and forwards source location. Default sink is a colored stderr sink.
    - New `ICEBERG_SPDLOG` CMake option and matching Meson `spdlog` feature option (enabled by default). When disabled, the build has no spdlog dependency and `CerrLogger` is the default.
    - `pattern` property is honored here via `spdlog set_pattern` (the cerr backend keeps its fixed layout); `level` works on both backends.
    - `SpdLogger` lives in `logging/spdlog_logger_internal.h` and is not installed — apps get it via the default logger.
    - Runtime `kFatal` macro paths now use the same fatal-handler and abort sequence as `ICEBERG_LOG_FATAL`.
    
    **Tests** — logging tests cover default-backend selection, level filtering and mapping including fatal→critical, source-location forwarding, the `pattern` property, and runtime fatal-handler routing.
    
    This pull request and its description were written by Isaac.
---
 CMakeLists.txt                                 |   1 +
 README.md                                      |   2 +
 cmake_modules/IcebergThirdpartyToolchain.cmake |   4 +-
 meson.options                                  |   7 ++
 src/iceberg/CMakeLists.txt                     |  19 +++-
 src/iceberg/logging/config.h.in                |  25 +++++
 src/iceberg/logging/log_macros.h               |  72 ++++++-------
 src/iceberg/logging/logger.cc                  |  14 ++-
 src/iceberg/logging/meson.build                |   8 ++
 src/iceberg/logging/spdlog_logger.cc           | 100 ++++++++++++++++++
 src/iceberg/logging/spdlog_logger_internal.h   |  66 ++++++++++++
 src/iceberg/meson.build                        |  19 ++--
 src/iceberg/test/CMakeLists.txt                |   3 +-
 src/iceberg/test/logger_test.cc                |  12 ++-
 src/iceberg/test/macros_test.cc                |  23 ++--
 src/iceberg/test/meson.build                   |   1 +
 src/iceberg/test/spdlog_logger_test.cc         | 139 +++++++++++++++++++++++++
 17 files changed, 450 insertions(+), 65 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7cbd5e55..0882be68 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/README.md b/README.md
index 1318933d..7206c459 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,7 @@ cmake --install build
 | `ICEBERG_BUILD_BUNDLE` | `ON` | Build the battery-included library |
 | `ICEBERG_BUILD_REST` | `ON` | Build REST catalog client |
 | `ICEBERG_BUILD_REST_INTEGRATION_TESTS` | `OFF` | Build REST catalog integration tests |
+| `ICEBERG_SPDLOG` | `ON` | Use spdlog as the default logging backend |
 | `ICEBERG_BUILD_HIVE` | `OFF` | Build Hive (HMS) catalog client |
 | `ICEBERG_BUILD_SQL_CATALOG` | `OFF` | Build SQL catalog client |
 | `ICEBERG_SQL_SQLITE` | `OFF` | Build the SQLite connector for the SQL catalog |
@@ -124,6 +125,7 @@ Meson-specific options (configured via `-D<option>=<value>`):
 |--------|---------|-------------|
 | `rest` | `enabled` | Build REST catalog client |
 | `rest_integration_test` | `disabled` | Build integration test for REST catalog |
+| `spdlog` | `enabled` | Use spdlog as the default logging backend |
 | `tests` | `enabled` | Build tests |
 
 ### Build Examples
diff --git a/cmake_modules/IcebergThirdpartyToolchain.cmake b/cmake_modules/IcebergThirdpartyToolchain.cmake
index 8b868320..d74f91f5 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 0448c61d..739a9095 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',
+    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 e48209d6..26ff8e05 100644
--- a/src/iceberg/CMakeLists.txt
+++ b/src/iceberg/CMakeLists.txt
@@ -17,6 +17,7 @@
 
 set(ICEBERG_INCLUDES "$<BUILD_INTERFACE:${PROJECT_BINARY_DIR}/src>"
                      "$<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/src>")
+
 set(ICEBERG_SOURCES
     arrow_c_data_guard_internal.cc
     arrow_c_data_util.cc
@@ -55,6 +56,7 @@ set(ICEBERG_SOURCES
     location_provider.cc
     logging/cerr_logger.cc
     logging/logger.cc
+    logging/spdlog_logger.cc
     manifest/manifest_adapter.cc
     manifest/manifest_entry.cc
     manifest/manifest_filter_manager.cc
@@ -157,29 +159,38 @@ list(APPEND
      ICEBERG_STATIC_BUILD_INTERFACE_LIBS
      "$<IF:$<BOOL:${NANOARROW_VENDORED}>,nanoarrow::nanoarrow_static,$<IF:$<TARGET_EXISTS: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
      "$<IF:$<BOOL:${NANOARROW_VENDORED}>,nanoarrow::nanoarrow_static,$<IF:$<TARGET_EXISTS:nanoarrow::nanoarrow_shared>,nanoarrow::nanoarrow_shared,nanoarrow::nanoarrow_static>>"
      nlohmann_json::nlohmann_json
-     spdlog::spdlog
      utf8proc::utf8proc
      ZLIB::ZLIB)
 list(APPEND
      ICEBERG_STATIC_INSTALL_INTERFACE_LIBS
      "$<IF:$<BOOL:${NANOARROW_VENDORED}>,iceberg::nanoarrow_static,$<IF:$<TARGET_EXISTS:nanoarrow::nanoarrow_static>,nanoarrow::nanoarrow_static,nanoarrow::nanoarrow_shared>>"
      "$<IF:$<BOOL:${NLOHMANN_JSON_VENDORED}>,iceberg::nlohmann_json,$<IF:$<TARGET_EXISTS:nlohmann_json::nlohmann_json>,nlohmann_json::nlohmann_json,nlohmann_json::nlohmann_json>>"
-     "$<IF:$<BOOL:${SPDLOG_VENDORED}>,iceberg::spdlog,spdlog::spdlog>"
      "$<IF:$<BOOL:${UTF8PROC_VENDORED}>,iceberg::utf8proc,utf8proc::utf8proc>")
 list(APPEND
      ICEBERG_SHARED_INSTALL_INTERFACE_LIBS
      "$<IF:$<BOOL:${NANOARROW_VENDORED}>,iceberg::nanoarrow_static,$<IF:$<TARGET_EXISTS:nanoarrow::nanoarrow_shared>,nanoarrow::nanoarrow_shared,nanoarrow::nanoarrow_static>>"
      "$<IF:$<BOOL:${NLOHMANN_JSON_VENDORED}>,iceberg::nlohmann_json,$<IF:$<TARGET_EXISTS:nlohmann_json::nlohmann_json>,nlohmann_json::nlohmann_json,nlohmann_json::nlohmann_json>>"
-     "$<IF:$<BOOL:${SPDLOG_VENDORED}>,iceberg::spdlog,spdlog::spdlog>"
      "$<IF:$<BOOL:${UTF8PROC_VENDORED}>,iceberg::utf8proc,utf8proc::utf8proc>")
 
+if(ICEBERG_SPDLOG)
+  set(ICEBERG_HAS_SPDLOG ON)
+  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
+       "$<IF:$<BOOL:${SPDLOG_VENDORED}>,iceberg::spdlog,spdlog::spdlog>")
+  list(APPEND ICEBERG_SHARED_INSTALL_INTERFACE_LIBS
+       "$<IF:$<BOOL:${SPDLOG_VENDORED}>,iceberg::spdlog,spdlog::spdlog>")
+endif()
+
+configure_file("${CMAKE_CURRENT_SOURCE_DIR}/logging/config.h.in"
+               "${CMAKE_CURRENT_BINARY_DIR}/logging/config.h")
+
 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 00000000..6933230a
--- /dev/null
+++ b/src/iceberg/logging/config.h.in
@@ -0,0 +1,25 @@
+/*
+ * 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; not installed.
+// ICEBERG_HAS_SPDLOG is defined when the spdlog backend is available.
+
+#cmakedefine ICEBERG_HAS_SPDLOG
diff --git a/src/iceberg/logging/log_macros.h b/src/iceberg/logging/log_macros.h
index cbf33dc3..0dc510f0 100644
--- a/src/iceberg/logging/log_macros.h
+++ b/src/iceberg/logging/log_macros.h
@@ -79,65 +79,61 @@ void LogToCurrent(LogLevel level, const std::source_location& location,
   }
 }
 
-/// \brief Runtime-level variant against the current logger: emit if enabled, then
-/// flush + abort when level == kFatal (using the same acquired logger).
+/// \brief Format once, emit if enabled, flush, run the fatal handler, and abort.
+///
+/// Formatting is independent of ShouldLog so the handler always receives the
+/// message. \p logger may be null.
+template <typename MakeMessage>
+[[noreturn]] void DispatchFatal(Logger* logger, const std::source_location& location,
+                                MakeMessage&& make_message) noexcept {
+  std::string message;
+  try {
+    message = std::forward<MakeMessage>(make_message)();
+  } catch (...) {
+    message = "<fmt error>";
+  }
+  if (logger != nullptr) {
+    if (logger->ShouldLog(LogLevel::kFatal)) {
+      Emit(*logger, LogLevel::kFatal, location, std::string(message));
+    }
+    logger->Flush();
+  }
+  if (auto handler = GetFatalHandler()) {
+    try {
+      handler(location, message);
+    } catch (...) {  // a throwing handler must not prevent the abort
+    }
+  }
+  std::abort();
+}
+
 template <typename MakeMessage>
 void LogToCurrentRuntime(LogLevel level, const std::source_location& location,
                          MakeMessage&& make_message) noexcept {
   const std::shared_ptr<Logger>& logger = CurrentLogger();
+  if (level == LogLevel::kFatal) {
+    DispatchFatal(logger.get(), location, std::forward<MakeMessage>(make_message));
+  }
   if (logger) {
     EmitIfEnabled(*logger, level, location, std::forward<MakeMessage>(make_message));
   }
-  if (level == LogLevel::kFatal) {
-    if (logger) logger->Flush();
-    std::abort();
-  }
 }
 
-/// \brief Runtime-level variant against an explicit logger: emit if enabled, then
-/// flush + abort when level == kFatal.
 template <typename MakeMessage>
 void LogToExplicitRuntime(Logger& logger, LogLevel level,
                           const std::source_location& location,
                           MakeMessage&& make_message) noexcept {
-  EmitIfEnabled(logger, level, location, std::forward<MakeMessage>(make_message));
   if (level == LogLevel::kFatal) {
-    logger.Flush();
-    std::abort();
+    DispatchFatal(&logger, location, std::forward<MakeMessage>(make_message));
   }
+  EmitIfEnabled(logger, level, location, std::forward<MakeMessage>(make_message));
 }
 
-/// \brief Fatal path: acquire the effective (scoped-or-default) logger ONCE, emit
-/// if enabled, flush that same logger, run any registered FatalHandler, then
-/// abort. Never returns.
-///
-/// The message is always formatted here (independent of ShouldLog) so the handler
-/// receives it even when the fatal record itself is filtered out. The handler runs
-/// after emit+flush and before abort; if it does not itself terminate the process,
-/// std::abort() still runs.
 template <typename MakeMessage>
 [[noreturn]] void LogFatal(const std::source_location& location,
                            MakeMessage&& make_message) noexcept {
-  std::string message;
-  try {
-    message = std::forward<MakeMessage>(make_message)();
-  } catch (...) {
-    message = "<fmt error>";
-  }
   auto logger = GetCurrentLogger();
-  if (logger) {
-    if (logger->ShouldLog(LogLevel::kFatal)) {
-      Emit(*logger, LogLevel::kFatal, location, std::string(message));
-    }
-    logger->Flush();
-  }
-  if (auto handler = GetFatalHandler()) {
-    try {
-      handler(location, message);
-    } catch (...) {  // a throwing handler must not prevent the abort
-    }
-  }
-  std::abort();
+  DispatchFatal(logger.get(), location, std::forward<MakeMessage>(make_message));
 }
 
 }  // namespace iceberg::internal
diff --git a/src/iceberg/logging/logger.cc b/src/iceberg/logging/logger.cc
index 77895e36..f1cb8488 100644
--- a/src/iceberg/logging/logger.cc
+++ b/src/iceberg/logging/logger.cc
@@ -27,6 +27,7 @@
 #include <utility>
 
 #include "iceberg/logging/cerr_logger.h"
+#include "iceberg/logging/spdlog_logger_internal.h"
 
 namespace iceberg {
 
@@ -42,11 +43,14 @@ class NoopLogger final : public Logger {
   bool IsNoop() const override { return true; }
 };
 
-/// \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<Logger> MakeDefaultLogger() { return std::make_shared<CerrLogger>(); }
+/// \brief Construct the configured process default logger.
+std::shared_ptr<Logger> MakeDefaultLogger() {
+#ifdef ICEBERG_HAS_SPDLOG
+  return std::make_shared<internal::SpdLogger>();
+#else
+  return std::make_shared<CerrLogger>();
+#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 901855a7..04aec7d3 100644
--- a/src/iceberg/logging/meson.build
+++ b/src/iceberg/logging/meson.build
@@ -15,6 +15,14 @@
 # specific language governing permissions and limitations
 # under the License.
 
+# Generate the internal, non-installed logging backend config.
+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)
+
+# Install only public logging headers.
 install_headers(
     [
         'cerr_logger.h',
diff --git a/src/iceberg/logging/spdlog_logger.cc b/src/iceberg/logging/spdlog_logger.cc
new file mode 100644
index 00000000..238e6fed
--- /dev/null
+++ b/src/iceberg/logging/spdlog_logger.cc
@@ -0,0 +1,100 @@
+/*
+ * 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/spdlog_logger_internal.h"
+
+#ifdef ICEBERG_HAS_SPDLOG
+
+#  include <memory>
+#  include <string>
+#  include <unordered_map>
+#  include <utility>
+
+#  include <spdlog/common.h>
+#  include <spdlog/sinks/stdout_color_sinks.h>
+
+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 level; the macro layer handles termination.
+      return spdlog::level::critical;
+    case LogLevel::kOff:
+      return spdlog::level::off;
+  }
+  return spdlog::level::off;
+}
+
+}  // namespace
+
+SpdLogger::SpdLogger(LogLevel level)
+    : SpdLogger(spdlog::logger("iceberg",
+                               std::make_shared<spdlog::sinks::stderr_color_sink_mt>()),
+                level) {}
+
+SpdLogger::SpdLogger(spdlog::logger logger, LogLevel level)
+    : logger_(std::move(logger)), level_(level) {
+  logger_.set_level(spdlog::level::trace);
+}
+
+Status SpdLogger::Initialize(
+    const std::unordered_map<std::string, std::string>& properties) {
+  if (auto it = properties.find(std::string(kPatternProperty)); it != properties.end()) {
+    logger_.set_pattern(it->second);
+  }
+  return Logger::Initialize(properties);
+}
+
+void SpdLogger::Log(LogMessage&& message) noexcept {
+  try {
+    spdlog::source_loc loc{message.location.file_name(),
+                           static_cast<int>(message.location.line()),
+                           message.location.function_name()};
+    // LogMessage is already formatted; use spdlog's raw-message overload.
+    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/spdlog_logger_internal.h b/src/iceberg/logging/spdlog_logger_internal.h
new file mode 100644
index 00000000..e2aa99c6
--- /dev/null
+++ b/src/iceberg/logging/spdlog_logger_internal.h
@@ -0,0 +1,66 @@
+/*
+ * 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/spdlog_logger_internal.h
+/// \brief Internal spdlog-backed logging sink.
+
+#include "iceberg/logging/config.h"
+
+#ifdef ICEBERG_HAS_SPDLOG
+
+#  include <atomic>
+
+#  include <spdlog/logger.h>
+
+#  include "iceberg/logging/logger.h"
+
+namespace iceberg::internal {
+
+class ICEBERG_EXPORT SpdLogger : public Logger {
+ public:
+  explicit SpdLogger(LogLevel level = LogLevel::kInfo);
+
+  /// \brief Construct with a synchronous logger using thread-safe sinks.
+  explicit SpdLogger(spdlog::logger logger, LogLevel level = LogLevel::kInfo);
+
+  Status Initialize(
+      const std::unordered_map<std::string, std::string>& 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:
+  spdlog::logger logger_;
+  std::atomic<LogLevel> level_;
+};
+
+}  // namespace iceberg::internal
+
+#endif  // ICEBERG_HAS_SPDLOG
diff --git a/src/iceberg/meson.build b/src/iceberg/meson.build
index 39e8b293..5e742f2d 100644
--- a/src/iceberg/meson.build
+++ b/src/iceberg/meson.build
@@ -63,6 +63,11 @@ configure_file(
     install_dir: get_option('includedir') / 'iceberg',
 )
 
+# Resolve spdlog before generating logging/config.h. The feature value controls
+# whether a missing dependency is allowed.
+spdlog_dep = dependency('spdlog', required: get_option('spdlog'))
+spdlog_enabled = spdlog_dep.found()
+
 subdir('logging')
 
 iceberg_include_dir = include_directories('..')
@@ -103,6 +108,7 @@ iceberg_sources = files(
     'location_provider.cc',
     'logging/cerr_logger.cc',
     'logging/logger.cc',
+    'logging/spdlog_logger.cc',
     'manifest/manifest_adapter.cc',
     'manifest/manifest_entry.cc',
     'manifest/manifest_filter_manager.cc',
@@ -228,7 +234,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 +248,11 @@ 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]
+
+if spdlog_enabled
+    iceberg_deps += spdlog_dep
+endif
 
 iceberg_lib = library(
     'iceberg',
diff --git a/src/iceberg/test/CMakeLists.txt b/src/iceberg/test/CMakeLists.txt
index df820aa4..7cf013c4 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/logger_test.cc b/src/iceberg/test/logger_test.cc
index 81d8e7f9..8c6c668f 100644
--- a/src/iceberg/test/logger_test.cc
+++ b/src/iceberg/test/logger_test.cc
@@ -27,7 +27,9 @@
 
 #include <gtest/gtest.h>
 
+#include "iceberg/logging/cerr_logger.h"
 #include "iceberg/logging/log_level.h"
+#include "iceberg/logging/spdlog_logger_internal.h"
 #include "iceberg/test/logging_test_helpers.h"
 #include "iceberg/test/matchers.h"
 
@@ -43,7 +45,15 @@ TEST(LoggerTest, NoopIsSharedImmortalAndSilent) {
   EXPECT_EQ(noop.get(), Logger::Noop().get());
 }
 
-TEST(LoggerTest, DefaultLoggerIsNeverNull) { EXPECT_NE(GetDefaultLogger(), nullptr); }
+TEST(LoggerTest, DefaultLoggerMatchesBuildConfiguration) {
+  auto logger = GetDefaultLogger();
+  ASSERT_NE(logger, nullptr);
+#ifdef ICEBERG_HAS_SPDLOG
+  EXPECT_NE(dynamic_cast<internal::SpdLogger*>(logger.get()), nullptr);
+#else
+  EXPECT_NE(dynamic_cast<CerrLogger*>(logger.get()), nullptr);
+#endif
+}
 
 TEST(LoggerTest, SetAndGetDefaultLogger) {
   auto capturing = std::make_shared<CapturingLogger>();
diff --git a/src/iceberg/test/macros_test.cc b/src/iceberg/test/macros_test.cc
index a92e0379..7fc5b8e5 100644
--- a/src/iceberg/test/macros_test.cc
+++ b/src/iceberg/test/macros_test.cc
@@ -136,19 +136,28 @@ TEST(MacrosDeathTest, FatalAbortsEvenWhenRuntimeDisabled) {
       "");
 }
 
-TEST(MacrosDeathTest, GenericRuntimeFatalEmitsThenAborts) {
-  // ICEBERG_LOG with a runtime kFatal level must also emit then abort.
-  EXPECT_DEATH({ ICEBERG_LOG(LogLevel::kFatal, "gfatal {}", 1); }, "gfatal 1");
+TEST(MacrosDeathTest, GenericRuntimeFatalRunsHandlerWhenSuppressed) {
+  EXPECT_DEATH(
+      {
+        SetDefaultLevel(LogLevel::kOff);
+        SetFatalHandler([](const std::source_location&, std::string_view message) {
+          std::cerr << "H[" << message << "]\n";
+        });
+        ICEBERG_LOG(LogLevel::kFatal, "gfatal {}", 1);
+      },
+      "H\\[gfatal 1\\]");
 }
 
-TEST(MacrosDeathTest, LogToFatalEmitsThenAborts) {
-  // ICEBERG_LOG_TO with kFatal must emit to the explicit logger then abort.
+TEST(MacrosDeathTest, LogToFatalRunsHandlerWhenSuppressed) {
   EXPECT_DEATH(
       {
-        CerrLogger sink(LogLevel::kTrace);
+        CerrLogger sink(LogLevel::kOff);
+        SetFatalHandler([](const std::source_location&, std::string_view message) {
+          std::cerr << "H[" << message << "]\n";
+        });
         ICEBERG_LOG_TO(sink, LogLevel::kFatal, "tofatal {}", 2);
       },
-      "tofatal 2");
+      "H\\[tofatal 2\\]");
 }
 
 // Regression guard: ICEBERG_LOG_FATAL must route through the active ScopedLogger
diff --git a/src/iceberg/test/meson.build b/src/iceberg/test/meson.build
index 09fa1206..f9549eef 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 00000000..6f6b2b57
--- /dev/null
+++ b/src/iceberg/test/spdlog_logger_test.cc
@@ -0,0 +1,139 @@
+/*
+ * 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/spdlog_logger_internal.h"
+
+#ifdef ICEBERG_HAS_SPDLOG
+
+#  include <memory>
+#  include <source_location>
+#  include <sstream>
+#  include <string>
+#  include <utility>
+
+#  include <gtest/gtest.h>
+#  include <spdlog/logger.h>
+#  include <spdlog/sinks/ostream_sink.h>
+
+#  include "iceberg/logging/log_level.h"
+#  include "iceberg/logging/logger.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& output,
+                                  LogLevel level = LogLevel::kInfo) {
+  auto sink = std::make_shared<spdlog::sinks::ostream_sink_mt>(output);
+  return internal::SpdLogger(spdlog::logger("test", std::move(sink)), level);
+}
+
+std::string Render(internal::SpdLogger& logger, std::ostringstream& output,
+                   LogMessage message) {
+  logger.Log(std::move(message));
+  logger.Flush();
+  return output.str();
+}
+
+}  // namespace
+
+TEST(SpdLoggerTest, LevelThreshold) {
+  internal::SpdLogger default_logger;
+  EXPECT_EQ(default_logger.level(), LogLevel::kInfo);
+  EXPECT_FALSE(default_logger.ShouldLog(LogLevel::kDebug));
+  EXPECT_TRUE(default_logger.ShouldLog(LogLevel::kError));
+
+  std::ostringstream output;
+  auto logger = MakeCapturing(output, LogLevel::kError);
+  EXPECT_EQ(logger.level(), LogLevel::kError);
+  EXPECT_FALSE(logger.ShouldLog(LogLevel::kWarn));
+
+  Log(logger, LogLevel::kWarn, "filtered");
+  logger.SetLevel(LogLevel::kTrace);
+  EXPECT_EQ(logger.level(), LogLevel::kTrace);
+  EXPECT_TRUE(logger.ShouldLog(LogLevel::kTrace));
+  Log(logger, LogLevel::kTrace, "visible");
+  logger.Flush();
+  const auto rendered = output.str();
+  EXPECT_EQ(rendered.find("filtered"), std::string::npos);
+  EXPECT_NE(rendered.find("visible"), std::string::npos);
+}
+
+TEST(SpdLoggerTest, ForwardsMessageVerbatim) {
+  std::ostringstream output;
+  auto logger = MakeCapturing(output);
+  const auto rendered =
+      Render(logger, output, MakeMessage(LogLevel::kInfo, "literal {not a placeholder}"));
+  EXPECT_NE(rendered.find("literal {not a placeholder}"), std::string::npos);
+}
+
+TEST(SpdLoggerTest, MapsCriticalAndFatalToCritical) {
+  std::ostringstream output;
+  auto logger = MakeCapturing(output);
+  auto status =
+      logger.Initialize({{std::string(kPatternProperty), std::string("%l %v")}});
+  ASSERT_TRUE(status.has_value());
+  logger.Log(MakeMessage(LogLevel::kCritical, "crit"));
+  const auto rendered = Render(logger, output, MakeMessage(LogLevel::kFatal, "fatal"));
+  EXPECT_NE(rendered.find("critical crit"), std::string::npos);
+  EXPECT_NE(rendered.find("critical fatal"), std::string::npos);
+}
+
+TEST(SpdLoggerTest, InitializeAppliesPatternAndLevel) {
+  std::ostringstream output;
+  auto logger = MakeCapturing(output);
+  auto status = logger.Initialize({{std::string(kPatternProperty), std::string("PFX %v")},
+                                   {std::string(kLevelProperty), std::string("error")}});
+  ASSERT_TRUE(status.has_value());
+  EXPECT_EQ(logger.level(), LogLevel::kError);
+  const auto rendered = Render(logger, output, MakeMessage(LogLevel::kError, "hello"));
+  EXPECT_NE(rendered.find("PFX hello"), std::string::npos);
+}
+
+TEST(SpdLoggerTest, ForwardsSourceLocationToSink) {
+  std::ostringstream output;
+  auto logger = MakeCapturing(output);
+  // %s, %#, and %! render the source file, line, and function.
+  auto status =
+      logger.Initialize({{std::string(kPatternProperty), std::string("%s:%# %! %v")}});
+  ASSERT_TRUE(status.has_value());
+
+  const auto here = std::source_location::current();
+  const std::string rendered = Render(logger, output,
+                                      LogMessage{.level = LogLevel::kError,
+                                                 .message = "located",
+                                                 .location = here,
+                                                 .attributes = {}});
+  const std::string expected_location =
+      "spdlog_logger_test.cc:" + std::to_string(here.line());
+  EXPECT_NE(rendered.find(expected_location), std::string::npos) << rendered;
+  EXPECT_NE(rendered.find("TestBody"), std::string::npos) << rendered;
+  EXPECT_NE(rendered.find("located"), std::string::npos) << rendered;
+}
+
+}  // namespace iceberg
+
+#endif  // ICEBERG_HAS_SPDLOG