From cd94d7d99bd1726eb8533dabc23ce142b00da8e3 Mon Sep 17 00:00:00 2001 From: JCW Date: Wed, 29 Apr 2026 15:44:36 +0100 Subject: [PATCH] Add Logger from clio and implement json logging --- CMakeLists.txt | 1 + cmake/XrplCore.cmake | 1 + include/xrpl/basics/Logger.h | 507 ++++++++++++++ include/xrpl/basics/StructuredLogging.h | 271 ++++++++ src/libxrpl/basics/Logger.cpp | 622 ++++++++++++++++++ src/tests/libxrpl/basics/Logger.cpp | 278 ++++++++ .../libxrpl/basics/StructuredLogging.cpp | 173 +++++ 7 files changed, 1853 insertions(+) create mode 100644 include/xrpl/basics/Logger.h create mode 100644 include/xrpl/basics/StructuredLogging.h create mode 100644 src/libxrpl/basics/Logger.cpp create mode 100644 src/tests/libxrpl/basics/Logger.cpp create mode 100644 src/tests/libxrpl/basics/StructuredLogging.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 80ff8fec13..9187034856 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,6 +94,7 @@ find_package(secp256k1 REQUIRED) find_package(SOCI REQUIRED) find_package(SQLite3 REQUIRED) find_package(xxHash REQUIRED) +find_package(spdlog REQUIRED) target_link_libraries( xrpl_libs diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake index 9b1dc74049..1783b57221 100644 --- a/cmake/XrplCore.cmake +++ b/cmake/XrplCore.cmake @@ -69,6 +69,7 @@ target_link_libraries( secp256k1::secp256k1 xrpl.libpb xxHash::xxhash + spdlog::spdlog $<$:antithesis-sdk-cpp> ) diff --git a/include/xrpl/basics/Logger.h b/include/xrpl/basics/Logger.h new file mode 100644 index 0000000000..3a5598aef8 --- /dev/null +++ b/include/xrpl/basics/Logger.h @@ -0,0 +1,507 @@ +#pragma once + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// We forward declare spdlog types to avoid including the spdlog headers in this header file. +namespace spdlog { + +class logger; // NOLINT(readability-identifier-naming) +class formatter; // NOLINT(readability-identifier-naming) + +namespace sinks { +class sink; // NOLINT(readability-identifier-naming) +} // namespace sinks + +} // namespace spdlog + +struct BenchmarkLoggingInitializer; +class LoggerFixture; +struct LogServiceInitTests; +struct LogFileRotationTests; + +namespace xrpl { + +/** + * @brief Skips evaluation of expensive argument lists if the given logger is disabled for the + * required severity level. + * + * Note: Currently this introduces potential shadowing (unlikely). + */ +#ifndef COVERAGE_ENABLED +#define LOG(x) \ + if (auto xrpl_pump__ = x; not xrpl_pump__) \ + { \ + } \ + else \ + xrpl_pump__ +#else +#define LOG(x) x +#endif + +/** + * @brief Custom severity levels for @ref util::Logger. + */ +enum class Severity { + TRC, + DBG, + NFO, + WRN, + ERR, + FTL, +}; + +/** + * @brief The default log format pattern for non-JSON mode. + * + * Matches the legacy Logs::format output: + * 2024-Jan-15 12:34:56.789123 UTC General:NFO hello world + * + * '%K' is a custom flag that outputs the three-letter severity code + * (TRC, DBG, NFO, WRN, ERR, FTL). + */ +inline constexpr char const* kDEFAULT_LOG_FORMAT = "%Y-%b-%d %H:%M:%S.%f UTC %n:%K %v"; + +struct LoggingConfiguration +{ + std::string format{kDEFAULT_LOG_FORMAT}; + bool enableConsole; + std::optional directory; + bool isAsync; + Severity defaultSeverity; + bool jsonMode{false}; +}; + +/** + * @brief A simple thread-safe logger for the channel specified + * in the constructor. + * + * This is cheap to copy and move. Designed to be used as a member variable or + * otherwise. See @ref LogService::init() for setup of the logging core and + * severity levels for each channel. + */ +class Logger +{ + std::shared_ptr logger_; + + friend class LogService; // to expose the Pump interface + + /** + * @brief Helper that pumps data into a log record via `operator<<`. + */ + class Pump final + { + std::shared_ptr logger_; + Severity const severity_; + std::source_location const sourceLocation_; + std::string stream_; + bool const enabled_; + bool const jsonMode_; + std::string parameters_; // accumulated JSON parameter fragments + + public: + ~Pump(); + + Pump( + std::shared_ptr logger, + Severity sev, + std::source_location const& loc, + bool jsonMode); + + Pump(Pump&&) = delete; + Pump(Pump const&) = delete; + Pump& + operator=(Pump const&) = delete; + Pump& + operator=(Pump&&) = delete; + + /** + * @brief Appends data that has an @c xrpl::to_string overload. + * + * This covers all XRP Ledger domain types such as @c Number, + * @c Currency, @c AccountID, @c XRPAmount, @c IOUAmount, @c Asset, etc. + * + * @tparam T Type of data to pump (must satisfy detail::HasToString) + * @param data The data to pump + * @return Reference to itself for chaining + */ + template + requires(detail::HasToString) + [[maybe_unused]] Pump& + operator<<(T&& data) + { + if (enabled_) + stream_ += to_string(data); + return *this; + } + + /** + * @brief Appends any fmt-formattable data into the output string. + * + * Fallback for types that do not have an @c xrpl::to_string overload + * but can be formatted by @c fmt::format (e.g. arithmetic types, + * @c std::string, @c std::string_view). + * + * @tparam T Type of data to pump + * @param data The data to pump + * @return Reference to itself for chaining + */ + template + requires(!detail::HasToString) + [[maybe_unused]] Pump& + operator<<(T&& data) + { + if (enabled_) + fmt::format_to(std::back_inserter(stream_), "{}", std::forward(data)); + return *this; + } + + /** + * @brief Captures a structured log parameter. + * + * The parameter value is always appended to the output string. + * In JSON mode, the parameter is also accumulated into the + * parameters string for the "values" object emitted in the + * destructor. + * + * @tparam T Type of the parameter value + * @param p The parameter to capture + * @return Reference to itself for chaining + */ + template + [[maybe_unused]] Pump& + operator<<(xrpl::log::Parameter&& p) + { + if (!enabled_) + return *this; + + // Append the raw string representation to the output stream + if constexpr (detail::HasToString) + { + stream_ += to_string(p.value()); + } + else + { + fmt::format_to(std::back_inserter(stream_), "{}", p.value()); + } + + if (jsonMode_) + { + // Also build up the parameters for the "values" object + if (!parameters_.empty()) + parameters_ += ','; + fmt::format_to(std::back_inserter(parameters_), "\"{}\":", p.name()); + detail::appendJsonValue(parameters_, p.value()); + } + + return *this; + } + + /** + * @return true if logger is enabled; false otherwise + */ + operator bool() const + { + return enabled_; + } + }; + +public: + /** + * @brief Construct a new Logger object that produces loglines for the + * specified channel. + * + * See @ref LogService::init() for general setup and configuration of + * severity levels per channel. + * + * @param channel The channel this logger will report into. + */ + Logger(std::string_view const channel); + + Logger(Logger const&) = default; + ~Logger(); + + Logger(Logger&&) = default; + Logger& + operator=(Logger const&) = default; + + Logger& + operator=(Logger&&) = default; + + /** + * @brief Interface for logging at Severity::TRC severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] Pump + trace(std::source_location const& loc = std::source_location::current()) const; + + /** + * @brief Interface for logging at Severity::DBG severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] Pump + debug(std::source_location const& loc = std::source_location::current()) const; + + /** + * @brief Interface for logging at Severity::NFO severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] Pump + info(std::source_location const& loc = std::source_location::current()) const; + + /** + * @brief Interface for logging at Severity::WRN severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] Pump + warn(std::source_location const& loc = std::source_location::current()) const; + + /** + * @brief Interface for logging at Severity::ERR severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] Pump + error(std::source_location const& loc = std::source_location::current()) const; + + /** + * @brief Interface for logging at Severity::FTL severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] Pump + fatal(std::source_location const& loc = std::source_location::current()) const; + +private: + Logger(std::shared_ptr logger); +}; + +/** + * @brief Base state management class for the logging service. + * + * This class manages the global state and core functionality for the logging system, + * including initialization, sink management, and logger registration. + */ +class LogServiceState +{ +protected: + friend struct ::LogServiceInitTests; + friend class ::LoggerFixture; + friend struct ::LogFileRotationTests; + friend class Logger; + + /** + * @brief Initialize the logging core with specified parameters. + * + * @param isAsync Whether logging should be asynchronous + * @param defaultSeverity The default severity level for new loggers + * @param sinks Vector of spdlog sinks to use for output + */ + static void + init( + bool isAsync, + Severity defaultSeverity, + std::vector> const& sinks, + bool jsonMode = false); + + /** + * @brief Whether the LogService is initialized or not + * + * @return true if the LogService is initialized + */ + [[nodiscard]] static bool + initialized(); + + /** + * @brief Whether the LogService has any sink. If there is no sink, logger will not log messages + * anywhere. + * + * @return true if the LogService has at least one sink + */ + [[nodiscard]] static bool + hasSinks(); + + /** + * @brief Reset the logging service to uninitialized state. + */ + static void + reset(); + + /** + * @brief Register a new logger for the specified channel. + * + * Creates and registers a new spdlog logger instance for the given channel + * with the specified or default severity level. + * + * @param channel The name of the logging channel + * @param severity Optional severity level override; uses default if not specified + * @return Shared pointer to the registered spdlog logger + */ + static std::shared_ptr + registerLogger(std::string_view channel, std::optional severity = std::nullopt); + + /** + * @brief Replace the current sinks with a new set of sinks. + * + * @param sinks Vector of new spdlog sinks to replace the current ones + */ + static void + replaceSinks(std::vector> const& sinks); + + /** + * @brief Creates a pattern formatter with custom flags (e.g. '%K' for + * severity) and UTC timestamps. + * + * @param pattern The spdlog pattern string + * @return A unique_ptr to the configured pattern_formatter + */ + static std::unique_ptr + makeFormatter(std::string const& pattern); + +protected: + static bool isAsync_; // NOLINT(readability-identifier-naming) + static Severity defaultSeverity_; // NOLINT(readability-identifier-naming) + static std::vector> + sinks_; // NOLINT(readability-identifier-naming) + static bool initialized_; // NOLINT(readability-identifier-naming) + static bool jsonMode_; // NOLINT(readability-identifier-naming) + static std::optional logDir_; // NOLINT(readability-identifier-naming) + static std::string format_; // NOLINT(readability-identifier-naming) +}; + +/** + * @brief A global logging service. + * + * Used to initialize and setup the logging core as well as a globally available + * entrypoint for logging into the `General` channel as well as raising alerts. + */ +class LogService : public LogServiceState +{ +public: + LogService() = delete; + + /** + * @brief Global log core initialization from a @ref LoggingConfiguration + * + * @param config The configuration to use + * @return Void on success, error message on failure + */ + [[nodiscard]] static Expected + init(LoggingConfiguration const& config); + + /** + * @brief Shutdown spdlog to guarantee output is not lost + */ + static void + shutdown(); + + /** + * @brief Close and reopen the log file. + * + * This assists in interoperating with external log management tools + * such as logrotate(8). If no file logging is configured, this is a no-op. + * + * @return A human-readable status message + */ + [[nodiscard]] static std::string + rotate(); + + /** + * @brief Globally accessible General logger at Severity::TRC severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] static Logger::Pump + trace(std::source_location const& loc = std::source_location::current()); + + /** + * @brief Globally accessible General logger at Severity::DBG severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] static Logger::Pump + debug(std::source_location const& loc = std::source_location::current()); + + /** + * @brief Globally accessible General logger at Severity::NFO severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] static Logger::Pump + info(std::source_location const& loc = std::source_location::current()); + + /** + * @brief Globally accessible General logger at Severity::WRN severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] static Logger::Pump + warn(std::source_location const& loc = std::source_location::current()); + + /** + * @brief Globally accessible General logger at Severity::ERR severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] static Logger::Pump + error(std::source_location const& loc = std::source_location::current()); + + /** + * @brief Globally accessible General logger at Severity::FTL severity + * + * @param loc The source location of the log message + * @return The pump to use for logging + */ + [[nodiscard]] static Logger::Pump + fatal(std::source_location const& loc = std::source_location::current()); + +private: + /** + * @brief Parses the sinks from a @ref LoggingConfiguration + * + * @param config The configuration to parse sinks from + * @return A vector of sinks on success, error message on failure + */ + [[nodiscard]] static Expected>, std::string> + getSinks(LoggingConfiguration const& config); + + struct FileLoggingParams + { + std::string logDir; + }; + + friend struct ::BenchmarkLoggingInitializer; + friend class ::LoggerFixture; + + [[nodiscard]] + static std::shared_ptr + createFileSink(FileLoggingParams const& params, std::string const& format); +}; + +}; // namespace xrpl diff --git a/include/xrpl/basics/StructuredLogging.h b/include/xrpl/basics/StructuredLogging.h new file mode 100644 index 0000000000..a1e2378b53 --- /dev/null +++ b/include/xrpl/basics/StructuredLogging.h @@ -0,0 +1,271 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace xrpl { + +namespace detail { + +/** + * @brief Concept that detects whether @c to_string(t) is a valid expression + * returning something convertible to @c std::string. + * + * Because this concept lives inside @c namespace @c xrpl, unqualified lookup + * will find every @c xrpl::to_string overload (e.g. for @c Number, + * @c Currency, @c AccountID, @c XRPAmount, @c IOUAmount, @c Asset, etc.). + * ADL further extends the search to any namespace associated with @p T. + */ +template +concept HasToString = requires(std::remove_cvref_t const& t) { + { to_string(t) } -> std::convertible_to; +}; + +/** + * @brief Append a value formatted as a JSON fragment to @p dest. + * + * - @c bool \u2192 unquoted @c true / @c false + * - integral (non-bool) \u2192 unquoted number + * - floating-point \u2192 unquoted number + * - types with @c to_string \u2192 quoted string via @c to_string + * - everything else \u2192 quoted string obtained via @c fmt::format + * + * @tparam T The value type. + * @param dest The string to append the JSON fragment to. + * @param value The value to format. + */ +template +void +appendJsonValue(std::string& dest, T const& value) +{ + if constexpr (std::is_same_v) + { + dest += value ? "true" : "false"; + } + else if constexpr (std::is_integral_v) + { + dest += std::to_string(value); + } + else if constexpr (std::is_floating_point_v) + { + dest += std::to_string(value); + } + else if constexpr (HasToString) + { + fmt::format_to(std::back_inserter(dest), "\"{}\"", to_string(value)); + } + else + { + fmt::format_to(std::back_inserter(dest), "\"{}\"", value); + } +} + +} // namespace detail + +/** + * @brief Builds a JSON-structured spdlog format pattern string. + * + * This class produces a pattern string suitable for passing to + * @c spdlog::pattern_formatter. Each field maps a JSON key to an spdlog + * pattern flag (e.g. @c %t for thread ID, @c %n for channel name). + * + * The pattern ends with @c %%v so that the log message (a partial JSON + * fragment) is spliced in at the end. + * + * Composability is lightweight: construct from an existing pattern string + * (e.g. obtained from another logger's pattern) and append more fields. + * No map copying — just string concatenation. + * + * Common spdlog pattern flags: + * - @c %%v — the log message text + * - @c %%t — thread ID + * - @c %%n — logger/channel name + * - @c %%l — log level (e.g. "info", "warning") + * - @c %%Y-%%m-%%d %%H:%%M:%%S.%%e — date/time with milliseconds + * - @c %%s — source file name + * - @c %%# — source line number + * - @c %%P — process ID + * + * Example usage: + * @code + * // Build a fresh pattern + * JsonLoggingPatternBuilder builder; + * builder.add("timestamp", "%Y-%m-%d %H:%M:%S.%e"); + * builder.add("channel", "%n"); + * builder.add("level", "%l"); + * std::string pattern = builder.build(); + * // Produces: {"timestamp":"%Y-%m-%d %H:%M:%S.%e","channel":"%n","level":"%l" %v } + * + * // Extend an existing pattern from another logger + * JsonLoggingPatternBuilder extended(pattern); + * extended.add("source", "%s:%#"); + * std::string newPattern = extended.build(); + * // Produces: {"timestamp":"%Y-%m-%d %H:%M:%S.%e","channel":"%n","level":"%l","source":"%s:%#" + * %v } + * @endcode + */ +class JsonLoggingPatternBuilder +{ + // Accumulated "key":"value" fragments, comma-separated. + // e.g. "\"timestamp\":\"%Y-%m-%d\",\"channel\":\"%n\"" + std::string fields_; + + static constexpr std::string_view kSUFFIX = ", \"message\": %v }"; + + /** + * @brief Extract the fields portion from an existing pattern string. + * + * Strips the leading '{' and trailing ' %%v }' suffix, leaving just + * the comma-separated field fragments. + */ + static std::string + extractFields(std::string_view pattern) + { + // Strip leading '{' + if (!pattern.empty() && pattern.front() == '{') + pattern.remove_prefix(1); + + // Strip trailing ' %v }' + if (auto pos = pattern.rfind(kSUFFIX); pos != std::string_view::npos) + pattern = pattern.substr(0, pos); + + return std::string(pattern); + } + +public: + /** @brief Construct an empty builder. */ + JsonLoggingPatternBuilder() = default; + + /** + * @brief Construct from an existing pattern string. + * + * Extracts the fields from the pattern so that new fields can be + * appended. This is a lightweight string copy — no map involved. + * + * @param existingPattern A pattern previously produced by build(). + */ + explicit JsonLoggingPatternBuilder(std::string_view existingPattern) + : fields_(extractFields(existingPattern)) + { + } + + /** + * @brief Add a field to the JSON pattern. + * + * The value is an spdlog pattern flag or any literal text that spdlog + * will expand at log time. + * + * @param key The JSON field name (e.g. "timestamp", "thread"). + * @param value The spdlog pattern string (e.g. "%%Y-%%m-%%d", "%%t"). + * @return Reference to this builder for chaining. + */ + JsonLoggingPatternBuilder& + add(std::string_view key, std::string_view value) + { + if (!fields_.empty()) + fields_ += ','; + fmt::format_to(std::back_inserter(fields_), "\"{}\":\"{}\"", key, value); + return *this; + } + + /** + * @brief Add a typed field to the JSON pattern. + * + * Uses @ref detail::appendJsonValue to format the value as a JSON + * fragment (unquoted for numbers/bools, quoted for everything else). + * + * @tparam T The value type (bool, integral, floating-point, or streamable). + * @param key The JSON field name. + * @param value The value to embed. + * @return Reference to this builder for chaining. + */ + template + requires(!std::is_convertible_v) + JsonLoggingPatternBuilder& + add(std::string_view key, T const& value) + { + if (!fields_.empty()) + fields_ += ','; + fmt::format_to(std::back_inserter(fields_), "\"{}\":", key); + detail::appendJsonValue(fields_, value); + return *this; + } + + /** + * @brief Build the final spdlog-compatible pattern string. + * + * Produces a compact JSON object where each value contains the raw + * spdlog pattern flags, terminated with %%v for the message body: + * @code + * {"level":"%l","channel":"%n" %v } + * @endcode + * + * @return The pattern string to pass to spdlog::pattern_formatter or + * LoggingConfiguration::format. + */ + [[nodiscard]] std::string + build() const + { + return "{" + fields_ + std::string(kSUFFIX); + } +}; + +namespace log { + +/** + * @brief Holds a named log parameter with its value. + * + * Constructed via the @ref param helper function. Designed to be passed + * into Logger::Pump (the integration with Pump will be added later). + * + * @tparam T The value type. + */ +template +class Parameter +{ + std::string name_; + T value_; + +public: + Parameter(std::string_view name, T const& value) : name_(name), value_(value) + { + } + + /** @brief Get the parameter name. */ + [[nodiscard]] std::string_view + name() const + { + return name_; + } + + /** @brief Get the parameter value. */ + [[nodiscard]] T const& + value() const + { + return value_; + } +}; + +/** + * @brief Create a named log parameter. + * + * @tparam T The value type (deduced). + * @param name The parameter name (JSON key). + * @param value The parameter value. + * @return A Parameter holding the name and value. + */ +template +[[nodiscard]] Parameter> +param(std::string_view name, T&& value) +{ + return Parameter>{name, value}; +} + +} // namespace log + +} // namespace xrpl diff --git a/src/libxrpl/basics/Logger.cpp b/src/libxrpl/basics/Logger.cpp new file mode 100644 index 0000000000..6f002f34b6 --- /dev/null +++ b/src/libxrpl/basics/Logger.cpp @@ -0,0 +1,622 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +bool LogServiceState::isAsync_{true}; +Severity LogServiceState::defaultSeverity_{Severity::NFO}; +std::vector LogServiceState::sinks_{}; +bool LogServiceState::initialized_{false}; +bool LogServiceState::jsonMode_{false}; +std::optional LogServiceState::logDir_{}; +std::string LogServiceState::format_{}; + +namespace { + +spdlog::level::level_enum +toSpdlogLevel(Severity sev) +{ + switch (sev) + { + case Severity::TRC: + return spdlog::level::trace; + case Severity::DBG: + return spdlog::level::debug; + case Severity::NFO: + return spdlog::level::info; + case Severity::WRN: + return spdlog::level::warn; + case Severity::ERR: + return spdlog::level::err; + case Severity::FTL: + return spdlog::level::critical; + } + return spdlog::level::info; +} + +std::string_view +toString(Severity sev) +{ + static constexpr std::array kLABELS = { + "TRC", + "DBG", + "NFO", + "WRN", + "ERR", + "FTL", + }; + + return kLABELS.at(static_cast(sev)); +} + +} // namespace + +/** + * @brief Custom formatter that filters out critical messages + * + * This formatter only processes and formats messages with severity level less than critical. + * Critical messages will be handled separately. + */ +class NonCriticalFormatter : public spdlog::formatter +{ +public: + NonCriticalFormatter(std::unique_ptr wrappedFormatter) + : wrapped_formatter_(std::move(wrappedFormatter)) + { + } + + void + format(spdlog::details::log_msg const& msg, spdlog::memory_buf_t& dest) override + { + // Only format messages with severity less than critical + if (msg.level != spdlog::level::critical) + { + wrapped_formatter_->format(msg, dest); + } + } + + [[nodiscard]] std::unique_ptr + clone() const override + { + return std::make_unique(wrapped_formatter_->clone()); + } + +private: + std::unique_ptr wrapped_formatter_; +}; + +/** + * @brief Custom spdlog flag formatter that outputs the three-letter severity + * codes used by the legacy logging system: TRC, DBG, NFO, WRN, ERR, FTL. + * + * Registered as the '%K' custom flag in pattern formatters created by + * @ref createPatternFormatter. + */ +class SeverityFlagFormatter final : public spdlog::custom_flag_formatter +{ +public: + void + format( + spdlog::details::log_msg const& msg, + std::tm const& /*tm_time*/, + spdlog::memory_buf_t& dest) override + { + static constexpr std::array kLABELS = { + "TRC", + "DBG", + "NFO", + "WRN", + "ERR", + "FTL", + "OFF", + }; + + auto const idx = static_cast(msg.level); + auto const label = idx < kLABELS.size() ? kLABELS[idx] : "???"; + dest.append(label.data(), label.data() + label.size()); + } + + [[nodiscard]] std::unique_ptr + clone() const override + { + return std::make_unique(); + } +}; + +/** + * @brief Creates a spdlog pattern_formatter with our custom '%K' severity flag + * and UTC timestamps. + * + * @param pattern The spdlog pattern string (should use '%K' for severity) + * @return A unique_ptr to the configured pattern_formatter + */ +static std::unique_ptr +createPatternFormatter(std::string const& pattern) +{ + auto formatter = std::make_unique( + pattern, spdlog::pattern_time_type::utc, spdlog::details::os::default_eol); + formatter->add_flag('K'); + formatter->set_pattern(pattern); + return formatter; +} + +/** + * @brief Truncates a log message to the maximum allowed length, appending + * an ellipsis if truncation occurs. + * + * Matches the legacy Logs::format behaviour with maximumMessageCharacters = 12 * 1024. + * + * @param message The message to potentially truncate (modified in-place) + */ +static void +truncateMessage(std::string& message) +{ + static constexpr std::size_t kMAX_MESSAGE_CHARS = 12 * 1024; + if (message.size() > kMAX_MESSAGE_CHARS) + { + message.resize(kMAX_MESSAGE_CHARS - 3); + message += "..."; + } +} + +/** + * @brief Scrubs sensitive fields from a log message by replacing their values + * with asterisks. + * + * Ported from the legacy Logs::format scrubber. Looks for JSON-like tokens + * such as "seed", "secret", "master_key", etc., finds the value enclosed in + * double quotes immediately after the token, and replaces the value characters + * with '*'. + * + * @param output The log message to scrub (modified in-place) + */ +static void +scrubSecrets(std::string& output) +{ + auto scrubber = [&output](char const* token) { + auto first = output.find(token); + + // If we have found the specified token, then attempt to isolate the + // sensitive data (it's enclosed by double quotes) and mask it off: + if (first != std::string::npos) + { + first = output.find('\"', first + std::strlen(token)); + + if (first != std::string::npos) + { + auto last = output.find('\"', ++first); + + if (last == std::string::npos) + last = output.size(); + + output.replace(first, last - first, last - first, '*'); + } + } + }; + + scrubber("\"seed\""); + scrubber("\"seed_hex\""); + scrubber("\"secret\""); + scrubber("\"master_key\""); + scrubber("\"master_seed\""); + scrubber("\"master_seed_hex\""); + scrubber("\"passphrase\""); +} + +/** + * @brief Initializes console logging. + * + * @param logToConsole A boolean indicating whether to log to console. + * @param format A string representing the log format. + * @return Vector of sinks for console logging. + */ +static std::vector +createConsoleSinks(bool logToConsole, std::string const& format) +{ + std::vector sinks; + + if (logToConsole) + { + auto consoleSink = std::make_shared(); + consoleSink->set_level(spdlog::level::trace); + consoleSink->set_formatter( + std::make_unique(createPatternFormatter(format))); + sinks.push_back(std::move(consoleSink)); + } + + // Always add stderr sink for fatal logs + auto stderrSink = std::make_shared(); + stderrSink->set_level(spdlog::level::critical); + stderrSink->set_formatter(createPatternFormatter(format)); + sinks.push_back(std::move(stderrSink)); + + return sinks; +} + +/** + * @brief Initializes file logging. + * + * @param config The configuration object containing log settings. + * @param dirPath The directory path where log files will be stored. + * @return File sink for logging. + */ +spdlog::sink_ptr +LogService::createFileSink(FileLoggingParams const& params, std::string const& format) +{ + std::filesystem::path const dirPath(params.logDir); + auto fileSink = [&]() -> std::shared_ptr { + auto const logPath = (dirPath / "xrpld.log").string(); + return std::make_shared(logPath, /*truncate=*/false); + }(); + + fileSink->set_level(spdlog::level::trace); + fileSink->set_formatter(createPatternFormatter(format)); + + return fileSink; +} + +void +LogServiceState::init( + bool isAsync, + Severity defaultSeverity, + std::vector const& sinks, + bool jsonMode) +{ + if (initialized_) + { + throw std::logic_error("LogServiceState is already initialized"); + } + + isAsync_ = isAsync; + defaultSeverity_ = defaultSeverity; + sinks_ = sinks; + jsonMode_ = jsonMode; + initialized_ = true; + + spdlog::apply_all([](std::shared_ptr logger) { + logger->set_level(toSpdlogLevel(defaultSeverity_)); + }); + + if (isAsync) + { + static constexpr size_t kQUEUE_SIZE = 8192; + static constexpr size_t kTHREAD_COUNT = 1; + spdlog::init_thread_pool(kQUEUE_SIZE, kTHREAD_COUNT); + } +} + +bool +LogServiceState::initialized() +{ + return initialized_; +} + +bool +LogServiceState::hasSinks() +{ + return not sinks_.empty(); +} + +void +LogServiceState::reset() +{ + if (not initialized()) + { + throw std::logic_error("LogService is not initialized"); + } + isAsync_ = true; + defaultSeverity_ = Severity::NFO; + sinks_.clear(); + jsonMode_ = false; + logDir_.reset(); + format_.clear(); + initialized_ = false; +} + +std::shared_ptr +LogServiceState::registerLogger(std::string_view channel, std::optional severity) +{ + if (not initialized_) + { + throw std::logic_error("LogService is not initialized"); + } + + std::string const channelStr{channel}; + + std::shared_ptr existingLogger = spdlog::get(channelStr); + if (existingLogger != nullptr) + { + if (severity.has_value()) + existingLogger->set_level(toSpdlogLevel(*severity)); + return existingLogger; + } + + std::shared_ptr logger; + if (isAsync_) + { + logger = std::make_shared( + channelStr, + sinks_.begin(), + sinks_.end(), + spdlog::thread_pool(), + spdlog::async_overflow_policy::block); + } + else + { + logger = std::make_shared(channelStr, sinks_.begin(), sinks_.end()); + } + + logger->set_level(toSpdlogLevel(severity.value_or(defaultSeverity_))); + logger->flush_on(spdlog::level::err); + + spdlog::register_logger(logger); + + return logger; +} + +Expected, std::string> +LogService::getSinks(LoggingConfiguration const& config) +{ + std::string const format = config.format; + + std::vector allSinks = createConsoleSinks(config.enableConsole, format); + + if (config.directory.has_value()) + { + std::filesystem::path const dirPath{config.directory.value()}; + if (not std::filesystem::exists(dirPath)) + { + if (std::error_code error; not std::filesystem::create_directories(dirPath, error)) + { + return Unexpected{fmt::format( + "Couldn't create logs directory '{}': {}", dirPath.string(), error.message())}; + } + } + + FileLoggingParams const params{ + .logDir = config.directory.value(), + }; + allSinks.push_back(createFileSink(params, format)); + } + return allSinks; +} + +Expected +LogService::init(LoggingConfiguration const& config) +{ + auto const sinksMaybe = getSinks(config); + if (!sinksMaybe.has_value()) + { + return Unexpected{sinksMaybe.error()}; + } + + logDir_ = config.directory; + format_ = config.format; + + LogServiceState::init( + config.isAsync, config.defaultSeverity, std::move(sinksMaybe).value(), config.jsonMode); + + // Register and set the "General" channel as the default logger. + // All other channels are created dynamically at runtime via Logger(channel). + spdlog::set_default_logger(registerLogger("General")); + + LOG(LogService::info()) << "Default log level = " << toString(defaultSeverity_); + return {}; +} + +void +LogService::shutdown() +{ + if (initialized_ && isAsync_) + { + // We run in async mode in production, so we need to make sure all logs are flushed before + // shutting down + spdlog::shutdown(); + } +} + +std::string +LogService::rotate() +{ + if (!logDir_.has_value()) + { + return "Log file rotation is not possible because file logging is not configured."; + } + + FileLoggingParams const params{.logDir = logDir_.value()}; + + auto newFileSink = createFileSink(params, format_); + + // Replace any existing file sink with the new one + for (auto& sink : sinks_) + { + if (dynamic_cast(sink.get()) != nullptr) + { + sink = newFileSink; + break; + } + } + + // Update all registered loggers with the new sinks + spdlog::apply_all([](std::shared_ptr logger) { logger->sinks() = sinks_; }); + + return "The log file was closed and reopened."; +} + +Logger::Pump +LogService::trace(std::source_location const& loc) +{ + return Logger(spdlog::default_logger()).trace(loc); +} + +Logger::Pump +LogService::debug(std::source_location const& loc) +{ + return Logger(spdlog::default_logger()).debug(loc); +} + +Logger::Pump +LogService::info(std::source_location const& loc) +{ + return Logger(spdlog::default_logger()).info(loc); +} + +Logger::Pump +LogService::warn(std::source_location const& loc) +{ + return Logger(spdlog::default_logger()).warn(loc); +} + +Logger::Pump +LogService::error(std::source_location const& loc) +{ + return Logger(spdlog::default_logger()).error(loc); +} + +Logger::Pump +LogService::fatal(std::source_location const& loc) +{ + return Logger(spdlog::default_logger()).fatal(loc); +} + +void +LogServiceState::replaceSinks(std::vector> const& sinks) +{ + sinks_ = sinks; + spdlog::apply_all([](std::shared_ptr logger) { logger->sinks() = sinks_; }); +} + +std::unique_ptr +LogServiceState::makeFormatter(std::string const& pattern) +{ + return createPatternFormatter(pattern); +} + +Logger::Logger(std::string_view const channel) : logger_(LogServiceState::registerLogger(channel)) +{ +} + +Logger::~Logger() +{ + // One reference is held by logger_ and the other by spdlog registry + static constexpr size_t kLAST_LOGGER_REF_COUNT = 2; + + if (logger_ == nullptr) + { + return; + } + + if (logger_.use_count() == kLAST_LOGGER_REF_COUNT) + { + spdlog::drop(logger_->name()); + } +} + +Logger::Pump::Pump( + std::shared_ptr logger, + Severity sev, + std::source_location const& loc, + bool jsonMode) + : logger_(std::move(logger)) + , severity_(sev) + , sourceLocation_(loc) + , enabled_(logger_ != nullptr && logger_->should_log(toSpdlogLevel(sev))) + , jsonMode_(jsonMode) +{ + if (enabled_ && jsonMode_) + { + // Open the quoted message value + stream_ += "\""; + } +} + +Logger::Pump::~Pump() +{ + if (enabled_) + { + spdlog::source_loc const sourceLocation{ + sourceLocation_.file_name(), static_cast(sourceLocation_.line()), nullptr}; + + if (jsonMode_) + { + // Close the quoted message value + stream_ += "\""; + + // Append the parameters object if any were captured + if (!parameters_.empty()) + { + stream_ += ", \"values\": {"; + stream_ += parameters_; + stream_ += "}"; + } + } + + // Apply legacy safeguards: truncate oversized messages and scrub secrets + truncateMessage(stream_); + scrubSecrets(stream_); + + logger_->log(sourceLocation, toSpdlogLevel(severity_), stream_); + } +} + +Logger::Pump +Logger::trace(std::source_location const& loc) const +{ + return {logger_, Severity::TRC, loc, LogServiceState::jsonMode_}; +} +Logger::Pump +Logger::debug(std::source_location const& loc) const +{ + return {logger_, Severity::DBG, loc, LogServiceState::jsonMode_}; +} +Logger::Pump +Logger::info(std::source_location const& loc) const +{ + return {logger_, Severity::NFO, loc, LogServiceState::jsonMode_}; +} +Logger::Pump +Logger::warn(std::source_location const& loc) const +{ + return {logger_, Severity::WRN, loc, LogServiceState::jsonMode_}; +} +Logger::Pump +Logger::error(std::source_location const& loc) const +{ + return {logger_, Severity::ERR, loc, LogServiceState::jsonMode_}; +} +Logger::Pump +Logger::fatal(std::source_location const& loc) const +{ + return {logger_, Severity::FTL, loc, LogServiceState::jsonMode_}; +} + +Logger::Logger(std::shared_ptr logger) : logger_(std::move(logger)) +{ +} + +} // namespace xrpl diff --git a/src/tests/libxrpl/basics/Logger.cpp b/src/tests/libxrpl/basics/Logger.cpp new file mode 100644 index 0000000000..2bbacb0edc --- /dev/null +++ b/src/tests/libxrpl/basics/Logger.cpp @@ -0,0 +1,278 @@ +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include + +using namespace xrpl; + +class LoggerFixture : public ::testing::Test +{ +protected: + std::ostringstream output_; + + void + initLogging(bool jsonMode, std::string const& pattern = "%v", Severity severity = Severity::TRC) + { + LoggingConfiguration config{ + .format = pattern, + .enableConsole = false, + .directory = std::nullopt, + .isAsync = false, + .defaultSeverity = severity, + .jsonMode = jsonMode, + }; + auto const result = LogService::init(config); + ASSERT_TRUE(result); + + // Replace the (empty) sinks with our ostream sink so we can + // capture output in tests. + auto sink = std::make_shared(output_); + sink->set_level(spdlog::level::trace); + sink->set_formatter(LogServiceState::makeFormatter(pattern)); + LogServiceState::replaceSinks({sink}); + } + + void + TearDown() override + { + spdlog::drop_all(); + if (LogService::initialized()) + LogService::reset(); + } +}; + +// -- Plain text mode --------------------------------------------------------- + +TEST_F(LoggerFixture, plain_text_simple_message) +{ + initLogging(false); + Logger logger("TestChannel"); + logger.info() << "hello world"; + EXPECT_EQ(output_.str(), "hello world\n"); +} + +TEST_F(LoggerFixture, plain_text_multiple_values) +{ + initLogging(false); + Logger logger("TestChannel2"); + logger.info() << "count=" << 42 << " active=" << true; + EXPECT_EQ(output_.str(), "count=42 active=true\n"); +} + +TEST_F(LoggerFixture, plain_text_with_parameter) +{ + initLogging(false); + Logger logger("TestChannel3"); + logger.info() << "tx " << log::param("hash", "ABC"); + // In plain text mode, parameter value is just streamed + EXPECT_EQ(output_.str(), "tx ABC\n"); +} + +// -- JSON mode --------------------------------------------------------------- + +TEST_F(LoggerFixture, json_mode_simple_message) +{ + initLogging(true); + Logger logger("JsonChannel"); + logger.info() << "hello world"; + // In JSON mode, message is quoted + EXPECT_EQ(output_.str(), "\"hello world\"\n"); +} + +TEST_F(LoggerFixture, json_mode_with_parameters) +{ + initLogging(true); + Logger logger("JsonChannel2"); + logger.info() << "processing " << log::param("tx_hash", std::string("ABC123")) + << " amount=" << log::param("amount", 42); + // Message body is quoted, then values object appended + EXPECT_EQ( + output_.str(), + "\"processing ABC123 amount=42\", " + "\"values\": {\"tx_hash\":\"ABC123\",\"amount\":42}\n"); +} + +TEST_F(LoggerFixture, json_mode_no_parameters) +{ + initLogging(true); + Logger logger("JsonChannel3"); + logger.info() << "simple message"; + // No values object when no parameters + EXPECT_EQ(output_.str(), "\"simple message\"\n"); +} + +TEST_F(LoggerFixture, json_mode_with_pattern) +{ + std::string pattern = + JsonLoggingPatternBuilder().add("level", "%l").add("channel", "%n").build(); + initLogging(true, pattern); + Logger logger("Overlay"); + logger.info() << "peer connected"; + EXPECT_EQ( + output_.str(), + "{\"level\":\"info\",\"channel\":\"Overlay\"," + " \"message\": \"peer connected\" }\n"); +} + +TEST_F(LoggerFixture, json_mode_bool_parameter) +{ + initLogging(true); + Logger logger("JsonChannel4"); + logger.info() << "status " << log::param("active", true); + EXPECT_EQ(output_.str(), "\"status true\", \"values\": {\"active\":true}\n"); +} + +// -- Severity filtering ------------------------------------------------------ + +TEST_F(LoggerFixture, severity_filtering) +{ + initLogging(false, "%v", Severity::WRN); + + Logger logger("FilterChannel"); + logger.info() << "should not appear"; + EXPECT_TRUE(output_.str().empty()); + + logger.warn() << "should appear"; + EXPECT_EQ(output_.str(), "should appear\n"); +} + +// -- xrpl::to_string integration -------------------------------------------- + +TEST_F(LoggerFixture, text_mode_xrp_amount) +{ + initLogging(false); + Logger logger("AmountChannel"); + logger.info() << "balance: " << XRPAmount{1000}; + EXPECT_EQ(output_.str(), "balance: 1000\n"); +} + +TEST_F(LoggerFixture, json_mode_xrp_amount) +{ + initLogging(true); + Logger logger("AmountChannel"); + logger.info() << "balance " << XRPAmount{500}; + EXPECT_EQ(output_.str(), "\"balance 500\"\n"); +} + +TEST_F(LoggerFixture, json_mode_xrp_amount_parameter) +{ + initLogging(true); + Logger logger("AmountChannel"); + logger.info() << "tx" << log::param("fee", XRPAmount{10}); + EXPECT_EQ(output_.str(), "\"tx10\", \"values\": {\"fee\":\"10\"}\n"); +} + +TEST_F(LoggerFixture, text_mode_number) +{ + initLogging(false); + Logger logger("NumberChannel"); + logger.info() << "result: " << Number{42}; + EXPECT_EQ(output_.str(), "result: 42\n"); +} + +TEST_F(LoggerFixture, json_mode_number) +{ + initLogging(true); + Logger logger("NumberChannel"); + logger.info() << "value " << Number{25, -3}; + EXPECT_EQ(output_.str(), "\"value 0.025\"\n"); +} + +TEST_F(LoggerFixture, json_mode_number_parameter) +{ + initLogging(true); + Logger logger("NumberChannel"); + logger.info() << "calc" << log::param("rate", Number{100}); + EXPECT_EQ(output_.str(), "\"calc100\", \"values\": {\"rate\":\"100\"}\n"); +} + +// -- Legacy format matching --------------------------------------------------- + +TEST_F(LoggerFixture, default_format_matches_legacy) +{ + // Use the default format (kDEFAULT_LOG_FORMAT) which includes %K for severity + initLogging(false, kDEFAULT_LOG_FORMAT); + Logger logger("General"); + logger.info() << "hello world"; + auto const line = output_.str(); + + // The full output must be: + // YYYY-Mon-DD HH:MM:SS.ffffff UTC General:NFO hello world\n + std::regex const expected( + R"(\d{4}-[A-Z][a-z]{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6} UTC General:NFO hello world\n)"); + EXPECT_TRUE(std::regex_match(line, expected)) << "got: " << line; +} + +TEST_F(LoggerFixture, severity_codes_in_default_format) +{ + initLogging(false, "%n:%K %v"); + Logger logger("Test"); + + logger.trace() << "t"; + logger.debug() << "d"; + logger.info() << "i"; + logger.warn() << "w"; + logger.error() << "e"; + logger.fatal() << "f"; + + EXPECT_EQ( + output_.str(), + "Test:TRC t\n" + "Test:DBG d\n" + "Test:NFO i\n" + "Test:WRN w\n" + "Test:ERR e\n" + "Test:FTL f\n"); +} + +// -- Secret scrubbing --------------------------------------------------------- + +TEST_F(LoggerFixture, scrubs_seed) +{ + initLogging(false); + Logger logger("Scrub"); + logger.info() << R"({"seed":"sEdTM1uX8pu2do5XvTnutH6HsouMaM2"})"; + // 31 chars in the seed value → 31 asterisks + EXPECT_EQ(output_.str(), "{\"seed\":\"*******************************\"}\n"); +} + +TEST_F(LoggerFixture, scrubs_master_key) +{ + initLogging(false); + Logger logger("Scrub2"); + logger.info() << R"({"master_key":"SOME_SECRET_VALUE"})"; + EXPECT_EQ(output_.str(), "{\"master_key\":\"*****************\"}\n"); +} + +TEST_F(LoggerFixture, scrubs_passphrase) +{ + initLogging(false); + Logger logger("Scrub3"); + logger.info() << R"({"passphrase":"my_secret_pass"})"; + EXPECT_EQ(output_.str(), "{\"passphrase\":\"**************\"}\n"); +} + +// -- Message truncation ------------------------------------------------------- + +TEST_F(LoggerFixture, truncates_oversized_message) +{ + initLogging(false); + Logger logger("Trunc"); + // 12 * 1024 = 12288 max chars; create a message larger than that + static constexpr std::size_t kMAX = 12 * 1024; + std::string const bigMessage(13000, 'x'); + logger.info() << bigMessage; + + // Expected: (kMAX - 3) 'x' chars + "..." + newline + std::string expected(kMAX - 3, 'x'); + expected += "...\n"; + EXPECT_EQ(output_.str(), expected); +} diff --git a/src/tests/libxrpl/basics/StructuredLogging.cpp b/src/tests/libxrpl/basics/StructuredLogging.cpp new file mode 100644 index 0000000000..096ab4dc59 --- /dev/null +++ b/src/tests/libxrpl/basics/StructuredLogging.cpp @@ -0,0 +1,173 @@ +#include + +#include +#include + +#include + +#include + +using namespace xrpl; + +// -- detail::appendJsonValue ------------------------------------------------- + +TEST(AppendJsonValue, bool_true) +{ + std::string dest; + detail::appendJsonValue(dest, true); + EXPECT_EQ(dest, "true"); +} + +TEST(AppendJsonValue, bool_false) +{ + std::string dest; + detail::appendJsonValue(dest, false); + EXPECT_EQ(dest, "false"); +} + +TEST(AppendJsonValue, integral_positive) +{ + std::string dest; + detail::appendJsonValue(dest, 42); + EXPECT_EQ(dest, "42"); +} + +TEST(AppendJsonValue, integral_negative) +{ + std::string dest; + detail::appendJsonValue(dest, -7); + EXPECT_EQ(dest, "-7"); +} + +TEST(AppendJsonValue, integral_zero) +{ + std::string dest; + detail::appendJsonValue(dest, 0); + EXPECT_EQ(dest, "0"); +} + +TEST(AppendJsonValue, string_quoted) +{ + std::string dest; + std::string val = "hello"; + detail::appendJsonValue(dest, val); + EXPECT_EQ(dest, "\"hello\""); +} + +TEST(AppendJsonValue, appends_to_existing) +{ + std::string dest = "prefix:"; + detail::appendJsonValue(dest, 99); + EXPECT_EQ(dest, "prefix:99"); +} + +// -- JsonLoggingPatternBuilder ----------------------------------------------- + +TEST(JsonLoggingPatternBuilder, empty_build) +{ + JsonLoggingPatternBuilder builder; + EXPECT_EQ(builder.build(), "{, \"message\": %v }"); +} + +TEST(JsonLoggingPatternBuilder, single_string_field) +{ + JsonLoggingPatternBuilder builder; + builder.add("level", "%l"); + EXPECT_EQ(builder.build(), "{\"level\":\"%l\", \"message\": %v }"); +} + +TEST(JsonLoggingPatternBuilder, multiple_string_fields) +{ + JsonLoggingPatternBuilder builder; + builder.add("level", "%l"); + builder.add("channel", "%n"); + EXPECT_EQ(builder.build(), "{\"level\":\"%l\",\"channel\":\"%n\", \"message\": %v }"); +} + +TEST(JsonLoggingPatternBuilder, typed_fields) +{ + JsonLoggingPatternBuilder builder; + builder.add("enabled", true); + builder.add("count", 5); + EXPECT_EQ(builder.build(), "{\"enabled\":true,\"count\":5, \"message\": %v }"); +} + +TEST(JsonLoggingPatternBuilder, chaining) +{ + JsonLoggingPatternBuilder builder; + builder.add("a", "1").add("b", "2").add("c", "3"); + EXPECT_EQ(builder.build(), "{\"a\":\"1\",\"b\":\"2\",\"c\":\"3\", \"message\": %v }"); +} + +TEST(JsonLoggingPatternBuilder, from_existing_pattern) +{ + JsonLoggingPatternBuilder original; + original.add("level", "%l").add("channel", "%n"); + auto const pattern = original.build(); + + JsonLoggingPatternBuilder extended(pattern); + extended.add("source", "%s:%#"); + EXPECT_EQ( + extended.build(), + "{\"level\":\"%l\",\"channel\":\"%n\",\"source\":\"%s:%#\", \"message\": %v }"); +} + +// -- log::Parameter / log::param --------------------------------------------- + +TEST(LogParameter, string_param) +{ + auto const p = log::param("tx_hash", std::string("ABC123")); + EXPECT_EQ(p.name(), "tx_hash"); + EXPECT_EQ(p.value(), "ABC123"); +} + +TEST(LogParameter, int_param) +{ + auto const p = log::param("count", 42); + EXPECT_EQ(p.name(), "count"); + EXPECT_EQ(p.value(), 42); +} + +TEST(LogParameter, bool_param) +{ + auto const p = log::param("active", true); + EXPECT_EQ(p.name(), "active"); + EXPECT_EQ(p.value(), true); +} + +// -- detail::HasToString concept --------------------------------------------- + +TEST(HasToString, xrp_amount_satisfies_concept) +{ + static_assert(detail::HasToString); +} + +TEST(HasToString, number_satisfies_concept) +{ + static_assert(detail::HasToString); +} + +TEST(HasToString, builtin_types_without_adl) +{ + // Built-in types have no associated namespace for ADL, so unless + // ToString.h is explicitly included they do not satisfy HasToString. + // They are handled by the fmt::format fallback path instead. + static_assert(!detail::HasToString); + static_assert(!detail::HasToString); +} + +// -- appendJsonValue with to_string types ------------------------------------ + +TEST(AppendJsonValue, xrp_amount_quoted) +{ + std::string dest; + detail::appendJsonValue(dest, XRPAmount{1000}); + EXPECT_EQ(dest, "\"1000\""); +} + +TEST(AppendJsonValue, number_quoted) +{ + std::string dest; + detail::appendJsonValue(dest, Number{25, -3}); + EXPECT_EQ(dest, "\"0.025\""); +}