Compare commits

...

28 Commits

Author SHA1 Message Date
Jingchen
09009c2533 Merge branch 'develop' into a1q123456/spd-log-from-clio 2026-05-06 17:42:38 +01:00
JCW
d0e6c9961b Fix the issue for windows 2026-05-05 16:26:15 +01:00
JCW
bfd2022e98 Fix clang-tidy error 2026-05-05 15:25:01 +01:00
JCW
f3df114918 Escape json key and values 2026-05-05 15:14:03 +01:00
JCW
13bdcee55d Fix errors 2026-05-05 14:16:01 +01:00
JCW
319a298b45 Fix error 2026-05-05 13:51:42 +01:00
JCW
676bc9fb5b Fix 2026-05-05 13:25:34 +01:00
JCW
09851d906c Fix clang-tidy erros 2026-05-05 11:52:12 +01:00
JCW
0edd5174a1 Fix tests 2026-05-05 11:38:55 +01:00
Jingchen
3f7b2ae1c3 Remove unneeded include directive in Logger.cpp
Removed unnecessary include for unistd.h
2026-05-02 11:50:21 +01:00
JCW
c9cf78f421 Improve test coverage 2026-05-01 22:05:06 +01:00
JCW
3f1cb056c2 Fix errors 2026-05-01 16:01:00 +01:00
Jingchen
b70c1f9e62 Merge branch 'develop' into a1q123456/spd-log-from-clio 2026-05-01 13:49:54 +01:00
JCW
418f67077c Fix clang-tidy erros 2026-05-01 13:47:51 +01:00
JCW
2e76861945 Fix clang-tidy errors 2026-05-01 13:37:57 +01:00
JCW
9135171f74 Improve test coverage 2026-05-01 13:28:06 +01:00
JCW
ed15220901 fix clang-tidy errors 2026-05-01 13:21:29 +01:00
JCW
ed7a936e1f Fix errors 2026-05-01 12:31:54 +01:00
JCW
f1305dc629 Fix clang tidy errors 2026-05-01 10:37:36 +01:00
JCW
5c01da5244 Improve test coverage 2026-05-01 10:22:58 +01:00
JCW
640c90ee35 Fix clang-tidy errors 2026-04-30 22:02:13 +01:00
JCW
6897a853bb Cleanup the code 2026-04-30 17:28:30 +01:00
JCW
a3f8e8f32a Add functionality to extend the context data 2026-04-30 17:27:19 +01:00
JCW
d1f6d4f339 Optimisation 2026-04-30 12:24:56 +01:00
JCW
06193bde5d Performance improvements 2026-04-30 12:24:55 +01:00
JCW
cd94d7d99b Add Logger from clio and implement json logging 2026-04-30 12:24:55 +01:00
JCW
17c7398f5d Update conan lock 2026-04-30 12:24:55 +01:00
JCW
a41704b61c Add spdlog to xrpld 2026-04-30 12:24:55 +01:00
10 changed files with 2779 additions and 7 deletions

View File

@@ -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

View File

@@ -69,6 +69,7 @@ target_link_libraries(
secp256k1::secp256k1
xrpl.libpb
xxHash::xxhash
spdlog::spdlog
$<$<BOOL:${voidstar}>:antithesis-sdk-cpp>
)

View File

@@ -4,6 +4,7 @@
"zlib/1.3.1#cac0f6daea041b0ccf42934163defb20%1774439233.809",
"xxhash/0.8.3#681d36a0a6111fc56e5e45ea182c19cc%1765850149.987",
"sqlite3/3.51.0#66aa11eabd0e34954c5c1c061ad44abe%1774467355.988",
"spdlog/1.17.0#bcbaaf7147bda6ad24ffbd1ac3d7142c%1768312128.781",
"soci/4.0.3#fe32b9ad5eb47e79ab9e45a68f363945%1774450067.231",
"snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1765850147.878",
"secp256k1/0.7.1#481881709eb0bdd0185a12b912bbe8ad%1770910500.329",
@@ -19,6 +20,7 @@
"jemalloc/5.3.0#c671e612af76700db5957c9857978a1c%1776700030.961",
"gtest/1.17.0#5224b3b3ff3b4ce1133cbdd27d53ee7d%1768312129.152",
"grpc/1.78.1#b1a9e74b145cc471bed4dc64dc6eb2c1%1774467387.342",
"fmt/12.1.0#50abab23274d56bb8f42c94b3b9a40c7%1763984116.926",
"ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1765850143.772",
"date/3.0.4#862e11e80030356b53c2c38599ceb32b%1765850143.772",
"c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650%1774439234.681",

View File

@@ -35,6 +35,7 @@ class Xrpl(ConanFile):
"openssl/3.6.2",
"secp256k1/0.7.1",
"soci/4.0.3",
"spdlog/1.17.0",
"zlib/1.3.1",
]
@@ -109,6 +110,7 @@ class Xrpl(ConanFile):
"secp256k1/*:shared": False,
"snappy/*:shared": False,
"soci/*:shared": False,
"spdlog/*:shared": False,
"soci/*:with_sqlite3": True,
"soci/*:with_boost": True,
"xxhash/*:shared": False,
@@ -213,6 +215,7 @@ class Xrpl(ConanFile):
"protobuf::libprotobuf",
"soci::soci",
"secp256k1::secp256k1",
"spdlog::spdlog",
"sqlite3::sqlite",
"xxhash::xxhash",
"zlib::zlib",

View File

@@ -0,0 +1,587 @@
#pragma once
#include <xrpl/basics/Expected.h>
#include <xrpl/basics/StructuredLogging.h>
#include <fmt/format.h>
#include <cstddef>
#include <iterator>
#include <memory>
#include <optional>
#include <source_location>
#include <string>
#include <string_view>
#include <vector>
// 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
class LoggerFixture;
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; !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";
/**
* @brief Build the default JSON log format pattern.
*
* Contains the same fields as @ref kDEFAULT_LOG_FORMAT (timestamp, channel,
* severity) plus the trailing message placeholder:
* @code
* {"timestamp":"2024-Jan-15 12:34:56.789123 UTC","channel":"General","severity":"NFO",
* "message": "hello world" }
* @endcode
*/
inline std::string
defaultJsonLogFormat()
{
using sv = std::string_view;
return buildJsonPattern(
"",
log::param("timestamp", sv("%Y-%b-%d %H:%M:%S.%f UTC")),
log::param("channel", sv("%n")),
log::param("severity", sv("%K")));
}
struct LoggingConfiguration
{
bool enableConsole{};
std::optional<std::string> directory{std::nullopt};
bool isAsync{};
Severity defaultSeverity{Severity::DBG};
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<spdlog::logger> logger_;
std::string contextParams_; // inherited parameter fragments (JSON or text)
bool jsonMode_ = false; // captured at construction from LogServiceState
friend class LogService; // to expose the Pump interface
/**
* @brief Helper that pumps data into a log record via `operator<<`.
*/
class Pump final
{
spdlog::logger* logger_;
Severity const severity_;
std::source_location const sourceLocation_;
fmt::memory_buffer stream_;
bool const enabled_;
bool const jsonMode_;
std::string_view contextParams_; // points into Logger's string (no copy)
std::string messageParams_; // per-message params (JSON mode only)
public:
~Pump();
Pump(
spdlog::logger* logger,
Severity sev,
std::source_location const& loc,
bool jsonMode,
std::string_view contextParams = {});
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 <typename T>
requires(detail::HasToString<T>)
[[maybe_unused]] Pump&
operator<<(T&& data)
{
if (enabled_)
{
auto const s = to_string(data);
stream_.append(s.data(), s.data() + s.size());
}
return *this;
}
/**
* @brief Appends any fmt-formattable data into the output buffer.
*
* 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 <typename T>
requires(!detail::HasToString<T>)
[[maybe_unused]] Pump&
operator<<(T&& data)
{
if (enabled_)
fmt::format_to(fmt::appender(stream_), "{}", std::forward<T>(data));
return *this;
}
/**
* @brief Captures a structured log parameter.
*
* The parameter value is always appended to the output buffer.
* 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 <typename T>
[[maybe_unused]] Pump&
operator<<(xrpl::log::Parameter<T> p)
{
if (enabled_)
{
// Append the raw string representation to the output buffer
if constexpr (detail::HasToString<T>)
{
auto const s = to_string(p.value());
stream_.append(s.data(), s.data() + s.size());
}
else
{
fmt::format_to(fmt::appender(stream_), "{}", std::move(p.value()));
}
if (jsonMode_)
{
detail::appendJsonField(messageParams_, p.name(), 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);
/**
* @brief Construct a child Logger that inherits context from a parent.
*
* The child uses a new channel and carries all of the parent's context
* parameters plus any additional parameters supplied here. Every log
* line produced by the child will automatically include these
* parameters in the JSON @c values object.
*
* @tparam Ts Value types of the extra parameters.
* @param parent The parent Logger whose context is inherited.
* @param channel The channel name for this child logger.
* @param params Extra context parameters to attach.
*/
template <typename... Ts>
Logger(Logger const& parent, std::string_view channel, log::Parameter<Ts> const&... params)
: Logger(channel)
{
contextParams_ = parent.contextParams_;
(appendContextParam(params), ...);
}
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<spdlog::logger> logger);
/** @brief Serialise a single parameter into contextParams_. */
template <typename T>
void
appendContextParam(log::Parameter<T> const& p)
{
if (jsonMode_)
{
detail::appendJsonField(contextParams_, p.name(), p.value());
}
else
{
detail::appendTextField(contextParams_, p.name(), p.value());
}
}
};
/**
* @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 class ::LoggerFixture;
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<std::shared_ptr<spdlog::sinks::sink>> 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<spdlog::logger>
registerLogger(std::string_view channel, std::optional<Severity> 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<std::shared_ptr<spdlog::sinks::sink>> 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<spdlog::formatter>
makeFormatter(std::string const& pattern);
/**
* @brief Creates a formatter that suppresses critical-level messages.
*
* Wraps the given formatter so that only messages below critical severity
* are formatted. Critical messages produce no output through this
* formatter (they are routed to a separate stderr sink instead).
*
* @param wrappedFormatter The underlying formatter to delegate to
* @return A unique_ptr to the NonCriticalFormatter
*/
static std::unique_ptr<spdlog::formatter>
makeNonCriticalFormatter(std::unique_ptr<spdlog::formatter> wrappedFormatter);
/**
* @brief Returns the active log format pattern.
*
* Automatically determined by the logging mode:
* text mode uses @ref kDEFAULT_LOG_FORMAT, JSON mode uses
* @ref defaultJsonLogFormat().
*/
static std::string const&
format()
{
return format_;
}
protected:
static bool isAsync_; // NOLINT(readability-identifier-naming)
static Severity defaultSeverity_; // NOLINT(readability-identifier-naming)
static std::vector<std::shared_ptr<spdlog::sinks::sink>>
sinks_; // NOLINT(readability-identifier-naming)
static bool initialized_; // NOLINT(readability-identifier-naming)
static bool jsonMode_; // NOLINT(readability-identifier-naming)
static std::optional<std::string> 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<void, std::string>
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::vector<std::shared_ptr<spdlog::sinks::sink>>, std::string>
getSinks(LoggingConfiguration const& config, std::string const& format);
struct FileLoggingParams
{
std::string logDir;
};
friend class ::LoggerFixture;
[[nodiscard]]
static std::shared_ptr<spdlog::sinks::sink>
createFileSink(FileLoggingParams const& params, std::string const& format);
};
}; // namespace xrpl

View File

@@ -0,0 +1,320 @@
#pragma once
#include <fmt/format.h>
#include <concepts>
#include <iterator>
#include <string>
#include <string_view>
#include <type_traits>
#include <utility>
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 <typename T>
concept HasToString = requires(std::remove_cvref_t<T> const& t) {
{ to_string(t) } -> std::convertible_to<std::string>;
};
/**
* @brief Escape a string for safe embedding in a JSON value.
*
* Escapes backslash, double-quote, and control characters (U+0000-U+001F)
* according to RFC 8259 section 7.
*/
inline void
appendEscapedJsonString(std::string& dest, std::string_view sv)
{
dest.reserve(dest.size() + sv.size() + 2);
dest += '"';
for (char const c : sv)
{
switch (c)
{
case '"':
dest += "\\\"";
break;
case '\\':
dest += "\\\\";
break;
case '\b':
dest += "\\b";
break;
case '\f':
dest += "\\f";
break;
case '\n':
dest += "\\n";
break;
case '\r':
dest += "\\r";
break;
case '\t':
dest += "\\t";
break;
default:
if (static_cast<unsigned char>(c) < 0x20)
{
fmt::format_to(
std::back_inserter(dest), "\\u{:04x}", static_cast<unsigned int>(c));
}
else
{
dest += c;
}
break;
}
}
dest += '"';
}
/**
* @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 <typename T>
void
appendJsonValue(std::string& dest, T const& value)
{
if constexpr (std::is_same_v<T, bool>)
{
dest += value ? "true" : "false";
}
else if constexpr (std::is_integral_v<T>)
{
dest += std::to_string(value);
}
else if constexpr (std::is_floating_point_v<T>)
{
dest += std::to_string(value);
}
else if constexpr (HasToString<T>)
{
appendEscapedJsonString(dest, to_string(value));
}
else
{
appendEscapedJsonString(dest, fmt::format("{}", value));
}
}
/**
* @brief Append a @c "key":value JSON field to @p dest.
*
* Inserts a comma separator when @p dest already contains fields.
* The value is formatted by @ref appendJsonValue.
*/
template <typename T>
void
appendJsonField(std::string& dest, std::string_view key, T const& value)
{
if (!dest.empty())
dest += ',';
appendEscapedJsonString(dest, key);
dest += ':';
appendJsonValue(dest, value);
}
/**
* @brief Append a @c key=value plain-text field to @p dest.
*
* Inserts a space separator when @p dest already contains fields.
*/
template <typename T>
void
appendTextField(std::string& dest, std::string_view key, T const& value)
{
if (!dest.empty())
dest += ' ';
if constexpr (HasToString<T>)
{
fmt::format_to(std::back_inserter(dest), "{}={}", key, to_string(value));
}
else
{
fmt::format_to(std::back_inserter(dest), "{}={}", key, value);
}
}
/** @brief Internal builder used by @ref buildJsonPattern.
*
* Not part of the public API — use @ref buildJsonPattern instead.
*/
class JsonLoggingPatternBuilder
{
std::string fields_;
static constexpr std::string_view kMESSAGE_FIELD = "\"message\": %v";
static std::string
extractFields(std::string_view pattern)
{
if (!pattern.empty() && pattern.front() == '{')
pattern.remove_prefix(1);
// Strip the trailing message field: `"message": %v }` or `"message": %v }`
if (auto pos = pattern.rfind(kMESSAGE_FIELD); pos != std::string_view::npos)
{
// Also strip the leading ", " separator if present
auto end = pos;
if (end >= 2 && pattern.substr(end - 2, 2) == ", ")
end -= 2;
pattern = pattern.substr(0, end);
}
return std::string(pattern);
}
public:
JsonLoggingPatternBuilder() = default;
explicit JsonLoggingPatternBuilder(std::string_view existingPattern)
: fields_(extractFields(existingPattern))
{
}
/** @brief Add a field. Delegates to @ref appendJsonField. */
template <typename T>
JsonLoggingPatternBuilder&
add(std::string_view key, T const& value)
{
appendJsonField(fields_, key, value);
return *this;
}
[[nodiscard]] std::string
build() const
{
std::string result = "{";
result += fields_;
if (!fields_.empty())
result += ", ";
result += kMESSAGE_FIELD;
result += " }";
return result;
}
};
} // namespace detail
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 <typename T>
class Parameter
{
std::string name_;
T value_;
public:
Parameter(std::string_view name, T value) : name_(name), value_(std::move(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 Get the parameter value. */
[[nodiscard]] T&
value() &
{
return value_;
}
/** @brief Get the parameter value. */
[[nodiscard]] T&&
value() &&
{
return std::move(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 <typename T>
[[nodiscard]] Parameter<std::decay_t<T>>
param(std::string_view name, T&& value)
{
return Parameter<std::decay_t<T>>{name, value};
}
} // namespace log
/**
* @brief Build or extend a JSON log pattern from @ref log::Parameter objects.
*
* Each parameter's name becomes a JSON key and its value becomes the
* corresponding JSON value in the spdlog pattern string. String-like
* values are treated as spdlog pattern flags (e.g. @c "%%n", @c "%%l");
* numeric and boolean values are embedded as literals.
*
* @code
* // Build from scratch (empty existing pattern)
* auto pattern = buildJsonPattern("",
* log::param("channel", std::string_view("%n")),
* log::param("level", std::string_view("%l")));
*
* // Extend an existing pattern with extra fields
* auto extended = buildJsonPattern(pattern,
* log::param("trace_id", traceId));
* @endcode
*
* @tparam Ts Value types of the parameters.
* @param existingPattern A pattern previously produced by build() or an
* empty string to start fresh.
* @param params The parameters to add as JSON fields.
* @return The spdlog-compatible JSON pattern string.
*/
template <typename... Ts>
[[nodiscard]] std::string
buildJsonPattern(std::string_view existingPattern, log::Parameter<Ts> const&... params)
{
// NOLINTNEXTLINE(misc-const-correctness)
detail::JsonLoggingPatternBuilder builder(existingPattern);
(builder.add(params.name(), params.value()), ...);
return builder.build();
}
} // namespace xrpl

View File

@@ -0,0 +1,683 @@
#include <xrpl/basics/Logger.h>
#include <xrpl/basics/Expected.h>
#include <fmt/format.h>
#include <spdlog/async.h>
#include <spdlog/async_logger.h>
#include <spdlog/common.h>
#include <spdlog/details/log_msg.h>
#include <spdlog/details/os.h>
#include <spdlog/formatter.h>
#include <spdlog/logger.h>
#include <spdlog/pattern_formatter.h>
#include <spdlog/sinks/basic_file_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>
#include <array>
#include <cstddef>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <memory>
#include <optional>
#include <source_location>
#include <stdexcept>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
#include <vector>
namespace xrpl {
bool LogServiceState::isAsync_{true};
Severity LogServiceState::defaultSeverity_{Severity::NFO};
std::vector<spdlog::sink_ptr> LogServiceState::sinks_{};
bool LogServiceState::initialized_{false};
bool LogServiceState::jsonMode_{false};
std::optional<std::string> 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; // LCOV_EXCL_LINE
}
std::string_view
toString(Severity sev)
{
static constexpr std::array<std::string_view, 6> kLABELS = {
"TRC",
"DBG",
"NFO",
"WRN",
"ERR",
"FTL",
};
return kLABELS.at(static_cast<int>(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<spdlog::formatter> 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<formatter>
clone() const override
{
return std::make_unique<NonCriticalFormatter>(wrapped_formatter_->clone());
}
private:
std::unique_ptr<spdlog::formatter> 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<std::string_view, 7> kLABELS = {
"TRC",
"DBG",
"NFO",
"WRN",
"ERR",
"FTL",
"OFF",
};
auto const idx = static_cast<std::size_t>(msg.level);
auto const label = idx < kLABELS.size() ? kLABELS[idx] : "???";
dest.append(label.data(), label.data() + label.size());
}
[[nodiscard]] std::unique_ptr<custom_flag_formatter>
clone() const override
{
return std::make_unique<SeverityFlagFormatter>();
}
};
/**
* @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<spdlog::pattern_formatter>
createPatternFormatter(std::string const& pattern)
{
auto formatter = std::make_unique<spdlog::pattern_formatter>(
pattern, spdlog::pattern_time_type::utc, spdlog::details::os::default_eol);
formatter->add_flag<SeverityFlagFormatter>('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(fmt::memory_buffer& message)
{
static constexpr std::size_t kMAX_MESSAGE_CHARS = 12 * 1024;
if (message.size() > kMAX_MESSAGE_CHARS)
{
message.resize(kMAX_MESSAGE_CHARS - 3);
static constexpr char kELLIPSIS[] = "...";
message.append(kELLIPSIS, kELLIPSIS + 3);
}
}
/**
* @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(fmt::memory_buffer& output)
{
// Fast path: if there's no double-quote anywhere in the message,
// none of the JSON-like tokens can possibly match.
std::string_view const view{output.data(), output.size()};
if (view.find('"') == std::string_view::npos)
return;
// We need string operations (find/replace) so convert temporarily.
// This is only reached for messages that contain at least one '"'.
std::string tmp{view};
auto scrubber = [&tmp](char const* token) {
auto first = tmp.find(token);
if (first != std::string::npos)
{
first = tmp.find('\"', first + std::strlen(token));
if (first != std::string::npos)
{
auto last = tmp.find('\"', ++first);
if (last == std::string::npos)
last = tmp.size();
tmp.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\"");
// Copy the scrubbed result back into the buffer
output.clear();
output.append(tmp.data(), tmp.data() + tmp.size());
}
/**
* @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<spdlog::sink_ptr>
createConsoleSinks(bool logToConsole, std::string const& format)
{
std::vector<spdlog::sink_ptr> sinks;
if (logToConsole)
{
auto consoleSink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
consoleSink->set_level(spdlog::level::trace);
consoleSink->set_formatter(
std::make_unique<NonCriticalFormatter>(createPatternFormatter(format)));
sinks.push_back(std::move(consoleSink));
}
// Always add stderr sink for fatal logs
auto stderrSink = std::make_shared<spdlog::sinks::stderr_color_sink_mt>();
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<spdlog::sinks::sink> {
auto const logPath = (dirPath / "xrpld.log").string();
return std::make_shared<spdlog::sinks::basic_file_sink_mt>(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<spdlog::sink_ptr> 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<spdlog::logger> 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 !sinks_.empty();
}
void
LogServiceState::reset()
{
if (!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<spdlog::logger>
LogServiceState::registerLogger(std::string_view channel, std::optional<Severity> severity)
{
if (!initialized_)
{
throw std::logic_error("LogService is not initialized");
}
std::string const channelStr{channel};
std::shared_ptr<spdlog::logger> existingLogger = spdlog::get(channelStr);
if (existingLogger != nullptr)
{
if (severity.has_value())
existingLogger->set_level(toSpdlogLevel(*severity));
return existingLogger;
}
std::shared_ptr<spdlog::logger> logger;
if (isAsync_)
{
logger = std::make_shared<spdlog::async_logger>(
channelStr,
sinks_.begin(),
sinks_.end(),
spdlog::thread_pool(),
spdlog::async_overflow_policy::block);
}
else
{
logger = std::make_shared<spdlog::logger>(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::vector<spdlog::sink_ptr>, std::string>
LogService::getSinks(LoggingConfiguration const& config, std::string const& format)
{
std::vector<spdlog::sink_ptr> allSinks = createConsoleSinks(config.enableConsole, format);
if (config.directory.has_value())
{
std::filesystem::path const dirPath{config.directory.value()};
if (!std::filesystem::exists(dirPath))
{
if (std::error_code error; !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<void, std::string>
LogService::init(LoggingConfiguration const& config)
{
// Format is fully determined by the logging mode.
format_ = config.jsonMode ? defaultJsonLogFormat() : std::string(kDEFAULT_LOG_FORMAT);
auto const sinksMaybe = getSinks(config, format_);
if (!sinksMaybe.has_value())
{
return Unexpected{sinksMaybe.error()};
}
logDir_ = config.directory;
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<spdlog::sinks::basic_file_sink_mt*>(sink.get()) != nullptr)
{
sink = newFileSink;
break;
}
}
// Update all registered loggers with the new sinks
spdlog::apply_all([](std::shared_ptr<spdlog::logger> 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<std::shared_ptr<spdlog::sinks::sink>> const& sinks)
{
sinks_ = sinks;
spdlog::apply_all([](std::shared_ptr<spdlog::logger> logger) { logger->sinks() = sinks_; });
}
std::unique_ptr<spdlog::formatter>
LogServiceState::makeFormatter(std::string const& pattern)
{
return createPatternFormatter(pattern);
}
std::unique_ptr<spdlog::formatter>
LogServiceState::makeNonCriticalFormatter(std::unique_ptr<spdlog::formatter> wrappedFormatter)
{
return std::make_unique<NonCriticalFormatter>(std::move(wrappedFormatter));
}
Logger::Logger(std::string_view const channel)
: logger_(LogServiceState::registerLogger(channel)), jsonMode_(LogServiceState::jsonMode_)
{
}
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; // LCOV_EXCL_LINE
}
if (logger_.use_count() == kLAST_LOGGER_REF_COUNT)
{
spdlog::drop(logger_->name());
}
}
Logger::Pump::Pump(
spdlog::logger* logger,
Severity sev,
std::source_location const& loc,
bool jsonMode,
std::string_view contextParams)
: logger_(logger)
, severity_(sev)
, sourceLocation_(loc)
, enabled_(logger_ != nullptr && logger_->should_log(toSpdlogLevel(sev)))
, jsonMode_(jsonMode)
, contextParams_(contextParams)
{
}
Logger::Pump::~Pump()
{
using namespace std::literals;
if (enabled_)
{
spdlog::source_loc const sourceLocation{
sourceLocation_.file_name(), static_cast<int>(sourceLocation_.line()), nullptr};
// Apply legacy safeguards on the raw message BEFORE JSON wrapping.
// This lets scrubSecrets short-circuit (no '"' in typical messages).
truncateMessage(stream_);
scrubSecrets(stream_);
if (jsonMode_)
{
// Wrap the scrubbed message: "<message>", plus optional values object
fmt::memory_buffer wrapped;
wrapped.push_back('"');
wrapped.append(stream_.data(), stream_.data() + stream_.size());
wrapped.push_back('"');
bool const hasContext = !contextParams_.empty();
bool const hasMessage = !messageParams_.empty();
if (hasContext || hasMessage)
{
static constexpr auto kVALUES_OPEN = ", \"values\": {"sv;
wrapped.append(kVALUES_OPEN);
wrapped.append(
contextParams_.data(), contextParams_.data() + contextParams_.size());
if (hasContext && hasMessage)
wrapped.push_back(',');
wrapped.append(
messageParams_.data(), messageParams_.data() + messageParams_.size());
wrapped.push_back('}');
}
logger_->log(
sourceLocation,
toSpdlogLevel(severity_),
std::string_view{wrapped.data(), wrapped.size()});
}
else
{
if (!contextParams_.empty())
{
// Prepend [key=val ...] context to the message
fmt::memory_buffer buf;
buf.push_back('[');
buf.append(contextParams_.data(), contextParams_.data() + contextParams_.size());
static constexpr auto kCLOSE = "] "sv;
buf.append(kCLOSE);
buf.append(stream_.data(), stream_.data() + stream_.size());
logger_->log(
sourceLocation,
toSpdlogLevel(severity_),
std::string_view{buf.data(), buf.size()});
}
else
{
logger_->log(
sourceLocation,
toSpdlogLevel(severity_),
std::string_view{stream_.data(), stream_.size()});
}
}
}
}
Logger::Pump
Logger::trace(std::source_location const& loc) const
{
return {logger_.get(), Severity::TRC, loc, LogServiceState::jsonMode_, contextParams_};
}
Logger::Pump
Logger::debug(std::source_location const& loc) const
{
return {logger_.get(), Severity::DBG, loc, LogServiceState::jsonMode_, contextParams_};
}
Logger::Pump
Logger::info(std::source_location const& loc) const
{
return {logger_.get(), Severity::NFO, loc, LogServiceState::jsonMode_, contextParams_};
}
Logger::Pump
Logger::warn(std::source_location const& loc) const
{
return {logger_.get(), Severity::WRN, loc, LogServiceState::jsonMode_, contextParams_};
}
Logger::Pump
Logger::error(std::source_location const& loc) const
{
return {logger_.get(), Severity::ERR, loc, LogServiceState::jsonMode_, contextParams_};
}
Logger::Pump
Logger::fatal(std::source_location const& loc) const
{
return {logger_.get(), Severity::FTL, loc, LogServiceState::jsonMode_, contextParams_};
}
Logger::Logger(std::shared_ptr<spdlog::logger> logger)
: logger_(std::move(logger)), jsonMode_(LogServiceState::jsonMode_)
{
}
} // namespace xrpl

View File

@@ -0,0 +1,905 @@
#include <xrpl/basics/Logger.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/StructuredLogging.h>
#include <xrpl/protocol/XRPAmount.h>
#include <fmt/format.h>
#include <gtest/gtest.h>
#include <spdlog/async_logger.h>
#include <spdlog/common.h>
#include <spdlog/sinks/basic_file_sink.h>
#include <spdlog/sinks/ostream_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>
#include <cstddef>
#include <filesystem>
#include <memory>
#include <optional>
#include <regex>
#include <sstream>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
using namespace xrpl;
class LoggerFixture : public ::testing::Test
{
protected:
std::ostringstream output_;
// Regex fragment matching the UTC timestamp produced by spdlog
static constexpr std::string_view kTS_RE =
R"(\d{4}-[A-Z][a-z]{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6} UTC)";
void
initLogging(bool jsonMode, Severity severity = Severity::TRC, bool isAsync = false)
{
LoggingConfiguration const config{
.enableConsole = false,
.directory = std::nullopt,
.isAsync = isAsync,
.defaultSeverity = severity,
.jsonMode = jsonMode,
};
auto const result = LogService::init(config);
ASSERT_TRUE(result);
// Replace the (empty) sinks with our ostream sink using the
// effective format chosen by LogService (text or JSON).
auto sink = std::make_shared<spdlog::sinks::ostream_sink_mt>(output_);
sink->set_level(spdlog::level::trace);
sink->set_formatter(LogServiceState::makeFormatter(LogServiceState::format()));
LogServiceState::replaceSinks({sink});
}
/// Escape special regex characters in a literal string.
static std::string
escapeRegex(std::string_view s)
{
std::string result;
for (char const c : s)
{
if (std::string_view(R"(\^$.|?*+()[]{}-)").find(c) != std::string_view::npos)
result += '\\';
result += c;
}
return result;
}
/// Assert that the captured output matches the expected text-mode line.
/// Format: <timestamp> channel:sev message\n
void
expectText(std::string_view channel, std::string_view sev, std::string_view message)
{
auto const& line = output_.str();
auto const re =
fmt::format("{} {}:{} {}\r?\n?", kTS_RE, channel, sev, escapeRegex(message));
EXPECT_TRUE(std::regex_match(line, std::regex(re))) << "got: " << line;
}
/// Assert that the captured output matches the expected JSON-mode line.
/// Format: {"timestamp":"...","channel":"...","severity":"...",
/// "message": <msgPart> }\n
void
expectJson(std::string_view channel, std::string_view sev, std::string_view msgPart)
{
auto const& line = output_.str();
auto const re = fmt::format(
R"(\{{"timestamp":"{}","channel":"{}","severity":"{}")"
R"(, "message": {} \}}\r?\n?)",
kTS_RE,
channel,
sev,
escapeRegex(msgPart));
EXPECT_TRUE(std::regex_match(line, std::regex(re))) << "got: " << line;
}
/// Access the current sinks vector (friend access).
static std::vector<std::shared_ptr<spdlog::sinks::sink>> const&
sinks()
{
return LogServiceState::sinks_;
}
/// Register a logger with an optional severity override (friend access).
static std::shared_ptr<spdlog::logger>
registerLogger(std::string_view channel, std::optional<Severity> severity = std::nullopt)
{
return LogServiceState::registerLogger(channel, severity);
}
/// Whether the LogService has any sinks (friend access).
static bool
hasSinks()
{
return LogServiceState::hasSinks();
}
/// Reset the LogService (friend access).
static void
resetService()
{
LogServiceState::reset();
}
/// Create a file sink via LogService::createFileSink (friend access).
static spdlog::sink_ptr
createFileSink(std::string const& logDir)
{
LogService::FileLoggingParams const params{.logDir = logDir};
return LogService::createFileSink(params, LogServiceState::format());
}
/// Install two sinks: one with NonCriticalFormatter, one critical-only.
/// Returns {non-critical output, critical output}.
static std::pair<std::ostringstream*, std::ostringstream*>
installSplitSinks(std::ostringstream& ncOut, std::ostringstream& critOut)
{
auto ncSink = std::make_shared<spdlog::sinks::ostream_sink_mt>(ncOut);
ncSink->set_level(spdlog::level::trace);
ncSink->set_formatter(
LogServiceState::makeNonCriticalFormatter(
LogServiceState::makeFormatter(LogServiceState::format())));
auto critSink = std::make_shared<spdlog::sinks::ostream_sink_mt>(critOut);
critSink->set_level(spdlog::level::critical);
critSink->set_formatter(LogServiceState::makeFormatter(LogServiceState::format()));
LogServiceState::replaceSinks({ncSink, critSink});
return {&ncOut, &critOut};
}
/// Install a single sink wrapped with NonCriticalFormatter.
static void
installNonCriticalSink(std::ostringstream& out)
{
auto sink = std::make_shared<spdlog::sinks::ostream_sink_mt>(out);
sink->set_level(spdlog::level::trace);
sink->set_formatter(
LogServiceState::makeNonCriticalFormatter(
LogServiceState::makeFormatter(LogServiceState::format())));
LogServiceState::replaceSinks({sink});
}
/// Install a single sink with a custom formatter.
static void
installSinkWithFormatter(std::ostringstream& out, std::unique_ptr<spdlog::formatter> fmt)
{
auto sink = std::make_shared<spdlog::sinks::ostream_sink_mt>(out);
sink->set_level(spdlog::level::trace);
sink->set_formatter(std::move(fmt));
LogServiceState::replaceSinks({sink});
}
/// Clone a NonCriticalFormatter (friend access to makeNonCriticalFormatter).
static std::unique_ptr<spdlog::formatter>
cloneNonCriticalFormatter()
{
auto original = LogServiceState::makeNonCriticalFormatter(
LogServiceState::makeFormatter(LogServiceState::format()));
return original->clone();
}
void
TearDown() override
{
spdlog::shutdown();
if (LogService::initialized())
LogService::reset();
}
};
// -- Plain text mode ---------------------------------------------------------
TEST_F(LoggerFixture, plain_text_simple_message)
{
initLogging(false);
Logger const logger("TestChannel");
logger.info() << "hello world";
expectText("TestChannel", "NFO", "hello world");
}
TEST_F(LoggerFixture, plain_text_multiple_values)
{
initLogging(false);
Logger const logger("TestChannel2");
logger.info() << "count=" << 42 << " active=" << true;
expectText("TestChannel2", "NFO", "count=42 active=true");
}
TEST_F(LoggerFixture, plain_text_with_parameter)
{
initLogging(false);
Logger const logger("TestChannel3");
logger.info() << "tx " << log::param("hash", "ABC");
// In plain text mode, parameter value is just streamed
expectText("TestChannel3", "NFO", "tx ABC");
}
// -- JSON mode ---------------------------------------------------------------
TEST_F(LoggerFixture, json_mode_simple_message)
{
initLogging(true);
Logger const logger("JsonChannel");
logger.info() << "hello world";
expectJson("JsonChannel", "NFO", "\"hello world\"");
}
TEST_F(LoggerFixture, json_mode_with_parameters)
{
initLogging(true);
Logger const logger("JsonChannel2");
logger.info() << "processing " << log::param("tx_hash", std::string("ABC123"))
<< " amount=" << log::param("amount", 42);
expectJson(
"JsonChannel2",
"NFO",
"\"processing ABC123 amount=42\", "
"\"values\": {\"tx_hash\":\"ABC123\",\"amount\":42}");
}
TEST_F(LoggerFixture, json_mode_no_parameters)
{
initLogging(true);
Logger const logger("JsonChannel3");
logger.info() << "simple message";
expectJson("JsonChannel3", "NFO", "\"simple message\"");
}
TEST_F(LoggerFixture, json_mode_bool_parameter)
{
initLogging(true);
Logger const logger("JsonChannel4");
logger.info() << "status " << log::param("active", true);
expectJson("JsonChannel4", "NFO", "\"status true\", \"values\": {\"active\":true}");
}
// -- Severity filtering ------------------------------------------------------
TEST_F(LoggerFixture, severity_filtering)
{
initLogging(false, Severity::WRN);
Logger const logger("FilterChannel");
logger.info() << "should not appear";
EXPECT_TRUE(output_.str().empty());
logger.warn() << "should appear";
expectText("FilterChannel", "WRN", "should appear");
}
// -- xrpl::to_string integration --------------------------------------------
TEST_F(LoggerFixture, text_mode_xrp_amount)
{
initLogging(false);
Logger const logger("AmountChannel");
logger.info() << "balance: " << XRPAmount{1000};
expectText("AmountChannel", "NFO", "balance: 1000");
}
TEST_F(LoggerFixture, json_mode_xrp_amount)
{
initLogging(true);
Logger const logger("AmountChannel");
logger.info() << "balance " << XRPAmount{500};
expectJson("AmountChannel", "NFO", "\"balance 500\"");
}
TEST_F(LoggerFixture, json_mode_xrp_amount_parameter)
{
initLogging(true);
Logger const logger("AmountChannel");
logger.info() << "tx" << log::param("fee", XRPAmount{10});
expectJson("AmountChannel", "NFO", "\"tx10\", \"values\": {\"fee\":\"10\"}");
}
TEST_F(LoggerFixture, text_mode_number)
{
initLogging(false);
Logger const logger("NumberChannel");
logger.info() << "result: " << Number{42};
expectText("NumberChannel", "NFO", "result: 42");
}
TEST_F(LoggerFixture, json_mode_number)
{
initLogging(true);
Logger const logger("NumberChannel");
logger.info() << "value " << Number{25, -3};
expectJson("NumberChannel", "NFO", "\"value 0.025\"");
}
TEST_F(LoggerFixture, json_mode_number_parameter)
{
initLogging(true);
Logger const logger("NumberChannel");
logger.info() << "calc" << log::param("rate", Number{100});
expectJson("NumberChannel", "NFO", "\"calc100\", \"values\": {\"rate\":\"100\"}");
}
// -- Severity codes -----------------------------------------------------------
TEST_F(LoggerFixture, severity_codes_in_default_format)
{
initLogging(false);
Logger const logger("Test");
logger.trace() << "t";
logger.debug() << "d";
logger.info() << "i";
logger.warn() << "w";
logger.error() << "e";
logger.fatal() << "f";
// Each line has the full default format; build a regex for all six.
auto const line = [&](std::string_view sev, std::string_view msg) {
return fmt::format("{} Test:{} {}\r?\n?", kTS_RE, sev, msg);
};
std::string re;
re += line("TRC", "t");
re += line("DBG", "d");
re += line("NFO", "i");
re += line("WRN", "w");
re += line("ERR", "e");
re += line("FTL", "f");
EXPECT_TRUE(std::regex_match(output_.str(), std::regex(re))) << "got: " << output_.str();
}
// -- NonCriticalFormatter ------------------------------------------------------
TEST_F(LoggerFixture, non_critical_formatter_passes_non_critical)
{
initLogging(false);
std::ostringstream ncOutput;
installNonCriticalSink(ncOutput);
Logger const logger("NCTest");
logger.info() << "hello";
// Non-critical message should appear
EXPECT_FALSE(ncOutput.str().empty()) << "Non-critical message was suppressed";
EXPECT_NE(ncOutput.str().find("hello"), std::string::npos);
}
TEST_F(LoggerFixture, non_critical_formatter_suppresses_critical)
{
initLogging(false);
std::ostringstream ncOutput;
installNonCriticalSink(ncOutput);
Logger const logger("NCTest2");
logger.fatal() << "critical message";
// Critical (fatal) message should be suppressed
EXPECT_TRUE(ncOutput.str().empty())
<< "Critical message was not suppressed: " << ncOutput.str();
}
TEST_F(LoggerFixture, non_critical_formatter_splits_output)
{
initLogging(false);
std::ostringstream stdoutOutput;
std::ostringstream stderrOutput;
installSplitSinks(stdoutOutput, stderrOutput);
Logger const logger("SplitTest");
logger.info() << "normal message";
logger.fatal() << "fatal message";
// stdout sink: should have the info message but NOT the fatal message
EXPECT_NE(stdoutOutput.str().find("normal message"), std::string::npos)
<< "stdout: " << stdoutOutput.str();
EXPECT_EQ(stdoutOutput.str().find("fatal message"), std::string::npos)
<< "stdout should not have fatal: " << stdoutOutput.str();
// stderr sink: should have the fatal message but NOT the info message
EXPECT_NE(stderrOutput.str().find("fatal message"), std::string::npos)
<< "stderr: " << stderrOutput.str();
EXPECT_EQ(stderrOutput.str().find("normal message"), std::string::npos)
<< "stderr should not have info: " << stderrOutput.str();
}
TEST_F(LoggerFixture, non_critical_formatter_all_non_critical_levels)
{
initLogging(false);
std::ostringstream ncOutput;
installNonCriticalSink(ncOutput);
Logger const logger("LevelTest");
logger.trace() << "t";
logger.debug() << "d";
logger.info() << "i";
logger.warn() << "w";
logger.error() << "e";
// All five non-critical levels should appear
auto const& out = ncOutput.str();
EXPECT_NE(out.find("TRC"), std::string::npos) << out;
EXPECT_NE(out.find("DBG"), std::string::npos) << out;
EXPECT_NE(out.find("NFO"), std::string::npos) << out;
EXPECT_NE(out.find("WRN"), std::string::npos) << out;
EXPECT_NE(out.find("ERR"), std::string::npos) << out;
// Now log a fatal and verify it does NOT appear
logger.fatal() << "f";
EXPECT_EQ(out.find("FTL"), std::string::npos)
<< "FTL should not appear in NonCriticalFormatter output: " << out;
}
TEST_F(LoggerFixture, non_critical_formatter_clone_succeeds)
{
initLogging(false);
auto cloned = cloneNonCriticalFormatter();
EXPECT_NE(cloned, nullptr);
}
TEST_F(LoggerFixture, console_enabled_creates_stdout_and_stderr_sinks)
{
// With enableConsole = true, init should create both a stdout sink
// (wrapped in NonCriticalFormatter) and a stderr sink (critical only).
LoggingConfiguration const config{
.enableConsole = true,
.directory = std::nullopt,
.isAsync = false,
.defaultSeverity = Severity::TRC,
.jsonMode = false,
};
auto const result = LogService::init(config);
ASSERT_TRUE(result);
// enableConsole=true → stdout sink + stderr sink = 2 sinks
EXPECT_EQ(sinks().size(), 2u);
// First sink should be a stdout_color_sink_mt
EXPECT_NE(dynamic_cast<spdlog::sinks::stdout_color_sink_mt*>(sinks()[0].get()), nullptr)
<< "First sink should be stdout_color_sink_mt";
// Second sink should be a stderr_color_sink_mt
EXPECT_NE(dynamic_cast<spdlog::sinks::stderr_color_sink_mt*>(sinks()[1].get()), nullptr)
<< "Second sink should be stderr_color_sink_mt";
}
TEST_F(LoggerFixture, console_disabled_creates_only_stderr_sink)
{
// With enableConsole = false, init should only create the stderr sink.
LoggingConfiguration const config{
.enableConsole = false,
.directory = std::nullopt,
.isAsync = false,
.defaultSeverity = Severity::TRC,
.jsonMode = false,
};
auto const result = LogService::init(config);
ASSERT_TRUE(result);
// enableConsole=false → stderr sink only = 1 sink
EXPECT_EQ(sinks().size(), 1u);
EXPECT_NE(dynamic_cast<spdlog::sinks::stderr_color_sink_mt*>(sinks()[0].get()), nullptr)
<< "Only sink should be stderr_color_sink_mt";
}
TEST_F(LoggerFixture, create_file_sink_returns_correct_type_and_path)
{
initLogging(false);
// Use the system temp directory (already exists) so no directory
// creation or cleanup is needed.
auto const dir = std::filesystem::temp_directory_path();
auto sink = createFileSink(dir.string());
// Should be a basic_file_sink_mt
auto* fileSink = dynamic_cast<spdlog::sinks::basic_file_sink_mt*>(sink.get());
ASSERT_NE(fileSink, nullptr) << "createFileSink should return a basic_file_sink_mt";
// The filename should point to <dir>/xrpld.log
auto const expectedPath = (dir / "xrpld.log").string();
EXPECT_EQ(fileSink->filename(), expectedPath);
}
TEST_F(LoggerFixture, init_fails_for_invalid_directory)
{
// __FILE__ is a regular file on every platform, so creating a
// subdirectory under it always fails.
auto const badDir = std::filesystem::path(__FILE__) / "impossible_log_dir";
LoggingConfiguration const config{
.enableConsole = false,
.directory = badDir.string(),
.isAsync = false,
.defaultSeverity = Severity::TRC,
.jsonMode = false,
};
auto const result = LogService::init(config);
EXPECT_FALSE(result) << "init should fail for an invalid directory";
}
// -- Async logging ------------------------------------------------------------
TEST_F(LoggerFixture, async_mode_creates_async_logger)
{
initLogging(false, Severity::TRC, true);
Logger const logger("AsyncTest");
auto const spdlogger = spdlog::get("AsyncTest");
ASSERT_NE(spdlogger, nullptr);
EXPECT_NE(dynamic_cast<spdlog::async_logger*>(spdlogger.get()), nullptr)
<< "Logger should be async_logger when isAsync=true";
}
TEST_F(LoggerFixture, sync_mode_creates_sync_logger)
{
initLogging(false);
Logger const logger("SyncTest");
auto const spdlogger = spdlog::get("SyncTest");
ASSERT_NE(spdlogger, nullptr);
EXPECT_EQ(dynamic_cast<spdlog::async_logger*>(spdlogger.get()), nullptr)
<< "Logger should be a sync logger when isAsync=false";
}
// -- Double initialisation guard ----------------------------------------------
TEST_F(LoggerFixture, double_init_throws)
{
initLogging(false);
// Second init should throw because LogServiceState is already initialized
LoggingConfiguration const config{};
EXPECT_THROW(auto _ = LogService::init(config), std::logic_error);
}
TEST_F(LoggerFixture, reset_before_init_throws)
{
// reset() without prior init should throw
EXPECT_THROW(resetService(), std::logic_error);
}
TEST_F(LoggerFixture, has_sinks_after_init)
{
EXPECT_FALSE(hasSinks()) << "No sinks before init";
initLogging(false);
EXPECT_TRUE(hasSinks()) << "Should have sinks after init";
}
TEST_F(LoggerFixture, has_no_sinks_after_reset)
{
initLogging(false);
EXPECT_TRUE(hasSinks());
resetService();
EXPECT_FALSE(hasSinks()) << "Sinks should be cleared after reset";
}
TEST_F(LoggerFixture, register_logger_before_init_throws)
{
// registerLogger without prior init should throw
EXPECT_THROW(registerLogger("Uninitialized"), std::logic_error);
}
// -- registerLogger re-registration -------------------------------------------
TEST_F(LoggerFixture, register_existing_logger_returns_same_instance)
{
initLogging(false);
// First registration creates the logger
auto const first = registerLogger("DuplicateChannel");
ASSERT_NE(first, nullptr);
// Second registration should return the same logger instance
auto const second = registerLogger("DuplicateChannel");
EXPECT_EQ(first.get(), second.get());
}
TEST_F(LoggerFixture, register_existing_logger_updates_severity)
{
initLogging(false);
// Create logger at default severity (TRC → spdlog::level::trace)
auto const logger = registerLogger("SevChannel");
ASSERT_NE(logger, nullptr);
EXPECT_EQ(logger->level(), spdlog::level::trace);
// Re-register with a severity override
auto const same = registerLogger("SevChannel", Severity::ERR);
EXPECT_EQ(same.get(), logger.get()) << "Should return the same instance";
EXPECT_EQ(logger->level(), spdlog::level::err) << "Level should be updated to ERR";
}
TEST_F(LoggerFixture, register_existing_logger_without_severity_keeps_level)
{
initLogging(false);
// Create logger with an explicit severity
auto const logger = registerLogger("KeepChannel", Severity::WRN);
ASSERT_NE(logger, nullptr);
EXPECT_EQ(logger->level(), spdlog::level::warn);
// Re-register without severity — level should remain unchanged
auto const same = registerLogger("KeepChannel");
EXPECT_EQ(same.get(), logger.get());
EXPECT_EQ(logger->level(), spdlog::level::warn) << "Level should remain WRN";
}
// -- LogService static convenience methods ------------------------------------
TEST_F(LoggerFixture, log_service_trace)
{
initLogging(false);
LogService::trace() << "trace msg";
expectText("General", "TRC", "trace msg");
}
TEST_F(LoggerFixture, log_service_debug)
{
initLogging(false);
LogService::debug() << "debug msg";
expectText("General", "DBG", "debug msg");
}
TEST_F(LoggerFixture, log_service_info)
{
initLogging(false);
// init already logs "Default log level = TRC" on the General channel,
// so clear and re-test.
output_.str({});
LogService::info() << "info msg";
expectText("General", "NFO", "info msg");
}
TEST_F(LoggerFixture, log_service_warn)
{
initLogging(false);
output_.str({});
LogService::warn() << "warn msg";
expectText("General", "WRN", "warn msg");
}
TEST_F(LoggerFixture, log_service_error)
{
initLogging(false);
output_.str({});
LogService::error() << "error msg";
expectText("General", "ERR", "error msg");
}
TEST_F(LoggerFixture, log_service_fatal)
{
initLogging(false);
output_.str({});
LogService::fatal() << "fatal msg";
expectText("General", "FTL", "fatal msg");
}
// -- LogService::rotate -------------------------------------------------------
TEST_F(LoggerFixture, rotate_returns_message_when_no_file_logging)
{
initLogging(false);
auto const msg = LogService::rotate();
EXPECT_EQ(msg, "Log file rotation is not possible because file logging is not configured.");
}
TEST_F(LoggerFixture, rotate_replaces_file_sink)
{
auto const tmpDir = std::filesystem::temp_directory_path();
LoggingConfiguration const config{
.enableConsole = false,
.directory = tmpDir.string(),
.isAsync = false,
.defaultSeverity = Severity::TRC,
.jsonMode = false,
};
auto const result = LogService::init(config);
ASSERT_TRUE(result);
auto const msg = LogService::rotate();
EXPECT_EQ(msg, "The log file was closed and reopened.");
// The file sink should still be a basic_file_sink_mt after rotation
bool hasFileSink = false;
for (auto const& s : sinks())
{
if (dynamic_cast<spdlog::sinks::basic_file_sink_mt*>(s.get()) != nullptr)
{
hasFileSink = true;
break;
}
}
EXPECT_TRUE(hasFileSink) << "File sink should still exist after rotation";
}
// -- LogService::shutdown -----------------------------------------------------
TEST_F(LoggerFixture, shutdown_sync_mode_is_noop)
{
initLogging(false);
// shutdown() should not throw or crash in sync mode
LogService::shutdown();
// We can still log after shutdown in sync mode
Logger const logger("ShutdownSync");
logger.info() << "still works";
EXPECT_NE(output_.str().find("still works"), std::string::npos);
}
TEST_F(LoggerFixture, shutdown_async_mode_flushes)
{
initLogging(false, Severity::TRC, true);
Logger const logger("ShutdownAsync");
logger.info() << "before shutdown";
LogService::shutdown();
EXPECT_NE(output_.str().find("before shutdown"), std::string::npos)
<< "Message should be flushed by shutdown: " << output_.str();
}
// -- Pattern builder function -------------------------------------------------
TEST_F(LoggerFixture, build_json_pattern_from_scratch)
{
auto pattern = buildJsonPattern(
"",
log::param("channel", std::string_view("%n")),
log::param("level", std::string_view("%l")));
EXPECT_EQ(pattern, R"({"channel":"%n","level":"%l", "message": %v })");
}
TEST_F(LoggerFixture, build_json_pattern_extends_existing)
{
auto const base = buildJsonPattern("", log::param("channel", std::string_view("%n")));
auto const extended = buildJsonPattern(base, log::param("trace_id", std::string("abc123")));
EXPECT_EQ(extended, R"({"channel":"%n","trace_id":"abc123", "message": %v })");
}
// -- Logger context inheritance -----------------------------------------------
TEST_F(LoggerFixture, child_logger_inherits_context_params)
{
initLogging(true);
Logger const parent("Parent");
Logger const child(parent, "Child", log::param("peer_id", std::string("abc")));
child.info() << "hello";
expectJson("Child", "NFO", "\"hello\", \"values\": {\"peer_id\":\"abc\"}");
}
TEST_F(LoggerFixture, child_logger_merges_context_and_message_params)
{
initLogging(true);
Logger const parent("Parent");
Logger const child(parent, "Child", log::param("peer_id", std::string("abc")));
child.info() << "event" << log::param("count", 42);
expectJson("Child", "NFO", "\"event42\", \"values\": {\"peer_id\":\"abc\",\"count\":42}");
}
TEST_F(LoggerFixture, grandchild_logger_accumulates_context)
{
initLogging(true);
Logger const root("Root");
Logger const child(root, "Child", log::param("trace_id", std::string("t1")));
Logger const grandchild(child, "Grandchild", log::param("span_id", std::string("s1")));
grandchild.info() << "deep";
expectJson(
"Grandchild", "NFO", "\"deep\", \"values\": {\"trace_id\":\"t1\",\"span_id\":\"s1\"}");
}
TEST_F(LoggerFixture, context_params_in_text_mode)
{
initLogging(false);
Logger const parent("Parent");
Logger const child(parent, "Child", log::param("peer_id", std::string("abc")));
child.info() << "hello";
// In text mode, context params are shown as [key=val] prefix
expectText("Child", "NFO", "[peer_id=abc] hello");
}
TEST_F(LoggerFixture, context_params_text_mode_multiple)
{
initLogging(false);
Logger const root("Root");
Logger const child(root, "Child", log::param("trace_id", std::string("t1")));
Logger const grandchild(child, "GC", log::param("span_id", std::string("s1")));
grandchild.info() << "deep";
expectText("GC", "NFO", "[trace_id=t1 span_id=s1] deep");
}
// -- Secret scrubbing ---------------------------------------------------------
TEST_F(LoggerFixture, scrubs_seed)
{
initLogging(false);
Logger const logger("Scrub");
logger.info() << R"({"seed":"sEdTM1uX8pu2do5XvTnutH6HsouMaM2"})";
// 31 chars in the seed value → 31 asterisks
expectText("Scrub", "NFO", R"({"seed":"*******************************"})");
}
TEST_F(LoggerFixture, scrubs_master_key)
{
initLogging(false);
Logger const logger("Scrub2");
logger.info() << R"({"master_key":"SOME_SECRET_VALUE"})";
expectText("Scrub2", "NFO", R"({"master_key":"*****************"})");
}
TEST_F(LoggerFixture, scrubs_passphrase)
{
initLogging(false);
Logger const logger("Scrub3");
logger.info() << R"({"passphrase":"my_secret_pass"})";
expectText("Scrub3", "NFO", R"({"passphrase":"**************"})");
}
TEST_F(LoggerFixture, scrubs_seed_json_mode)
{
initLogging(true);
Logger const logger("ScrubJson");
logger.info() << R"({"seed":"sEdTM1uX8pu2do5XvTnutH6HsouMaM2"})";
// In JSON mode the message is wrapped in quotes, but scrubbing still works
expectJson("ScrubJson", "NFO", "\"{\"seed\":\"*******************************\"}\"");
}
TEST_F(LoggerFixture, scrubs_master_key_json_mode)
{
initLogging(true);
Logger const logger("ScrubJson2");
logger.info() << R"({"master_key":"SOME_SECRET_VALUE"})";
expectJson("ScrubJson2", "NFO", "\"{\"master_key\":\"*****************\"}\"");
}
TEST_F(LoggerFixture, scrubs_seed_without_closing_quote)
{
initLogging(false);
Logger const logger("ScrubNoClose");
// The value has an opening quote after "seed": but no closing quote.
// The scrubber should treat everything to end-of-string as the secret.
logger.info() << R"({"seed":"sEdTM1uX8pu2do5XvTnutH)";
// 22 chars in "sEdTM1uX8pu2do5XvTnutH" → 22 asterisks
expectText("ScrubNoClose", "NFO", R"({"seed":"**********************)");
}
// -- Message truncation -------------------------------------------------------
TEST_F(LoggerFixture, truncates_oversized_message)
{
initLogging(false);
Logger const 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;
// The message body is truncated; full line includes timestamp prefix.
std::string truncatedMsg(kMAX - 3, 'x');
truncatedMsg += "...";
expectText("Trunc", "NFO", truncatedMsg);
}
TEST_F(LoggerFixture, truncates_oversized_message_json)
{
initLogging(true);
Logger const logger("Trunc");
static constexpr std::size_t kMAX = 12 * 1024;
std::string const bigMessage(13000, 'x');
logger.info() << bigMessage << log::param("key", std::string("value"));
// Message body is truncated; values object is preserved in full.
std::string msgPart = "\"";
msgPart += std::string(kMAX - 3, 'x');
msgPart += "...\", \"values\": {\"key\":\"value\"}";
expectJson("Trunc", "NFO", msgPart);
}

View File

@@ -0,0 +1,270 @@
#include <xrpl/basics/StructuredLogging.h>
#include <xrpl/basics/Number.h>
#include <xrpl/protocol/XRPAmount.h>
#include <gtest/gtest.h>
#include <string>
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 const 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");
}
// -- detail::appendEscapedJsonString ------------------------------------------
TEST(AppendEscapedJsonString, plain_string)
{
std::string dest;
detail::appendEscapedJsonString(dest, "hello");
EXPECT_EQ(dest, "\"hello\"");
}
TEST(AppendEscapedJsonString, escapes_double_quote)
{
std::string dest;
detail::appendEscapedJsonString(dest, R"(say "hi")");
auto const expected = R"("say \"hi\"")";
EXPECT_EQ(dest, expected);
}
TEST(AppendEscapedJsonString, escapes_backslash)
{
std::string dest;
detail::appendEscapedJsonString(dest, R"(a\b)");
EXPECT_EQ(dest, R"("a\\b")");
}
TEST(AppendEscapedJsonString, escapes_control_characters)
{
std::string dest;
detail::appendEscapedJsonString(dest, "line1\nline2\ttab");
EXPECT_EQ(dest, R"("line1\nline2\ttab")");
}
TEST(AppendEscapedJsonString, escapes_all_named_controls)
{
std::string dest;
detail::appendEscapedJsonString(dest, "\b\f\r");
EXPECT_EQ(dest, R"("\b\f\r")");
}
TEST(AppendEscapedJsonString, escapes_low_control_char_as_unicode)
{
std::string dest;
std::string const input(1, '\x01');
detail::appendEscapedJsonString(dest, input);
EXPECT_EQ(dest, R"("\u0001")");
}
TEST(AppendEscapedJsonString, empty_string)
{
std::string dest;
detail::appendEscapedJsonString(dest, "");
EXPECT_EQ(dest, R"("")");
}
// -- appendJsonValue with escaping -------------------------------------------
TEST(AppendJsonValue, string_with_quotes_escaped)
{
std::string dest;
std::string const val = R"(say "hi")";
detail::appendJsonValue(dest, val);
auto const expected = R"("say \"hi\"")";
EXPECT_EQ(dest, expected);
}
TEST(AppendJsonValue, string_with_backslash_escaped)
{
std::string dest;
std::string const val = R"(path\to\file)";
detail::appendJsonValue(dest, val);
EXPECT_EQ(dest, R"("path\\to\\file")");
}
TEST(AppendJsonValue, string_with_newline_escaped)
{
std::string dest;
std::string const val = "line1\nline2";
detail::appendJsonValue(dest, val);
EXPECT_EQ(dest, R"("line1\nline2")");
}
// -- appendJsonField with key escaping ----------------------------------------
TEST(AppendJsonField, key_with_quotes_escaped)
{
std::string dest;
detail::appendJsonField(dest, R"(my"key)", 42);
auto const expected = R"("my\"key":42)";
EXPECT_EQ(dest, expected);
}
TEST(AppendJsonField, key_with_backslash_escaped)
{
std::string dest;
detail::appendJsonField(dest, R"(a\b)", true);
EXPECT_EQ(dest, R"("a\\b":true)");
}
// -- buildJsonPattern --------------------------------------------------------
TEST(BuildJsonPattern, no_params)
{
auto const pattern = buildJsonPattern("");
EXPECT_EQ(pattern, "{\"message\": %v }");
}
TEST(BuildJsonPattern, single_string_field)
{
auto const pattern = buildJsonPattern("", log::param("level", std::string_view("%l")));
EXPECT_EQ(pattern, "{\"level\":\"%l\", \"message\": %v }");
}
TEST(BuildJsonPattern, multiple_string_fields)
{
auto const pattern = buildJsonPattern(
"",
log::param("level", std::string_view("%l")),
log::param("channel", std::string_view("%n")));
EXPECT_EQ(pattern, "{\"level\":\"%l\",\"channel\":\"%n\", \"message\": %v }");
}
TEST(BuildJsonPattern, typed_fields)
{
auto const pattern = buildJsonPattern("", log::param("enabled", true), log::param("count", 5));
EXPECT_EQ(pattern, "{\"enabled\":true,\"count\":5, \"message\": %v }");
}
TEST(BuildJsonPattern, many_fields)
{
auto const pattern = buildJsonPattern(
"",
log::param("a", std::string_view("1")),
log::param("b", std::string_view("2")),
log::param("c", std::string_view("3")));
EXPECT_EQ(pattern, "{\"a\":\"1\",\"b\":\"2\",\"c\":\"3\", \"message\": %v }");
}
TEST(BuildJsonPattern, extends_existing_pattern)
{
auto const base = buildJsonPattern(
"",
log::param("level", std::string_view("%l")),
log::param("channel", std::string_view("%n")));
auto const extended = buildJsonPattern(base, log::param("source", std::string_view("%s:%#")));
EXPECT_EQ(
extended, "{\"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<XRPAmount>);
}
TEST(HasToString, number_satisfies_concept)
{
static_assert(detail::HasToString<Number>);
}
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<int>);
static_assert(!detail::HasToString<double>);
}
// -- 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\"");
}

View File

@@ -432,13 +432,13 @@ TEST(AccountSet, TransferRate)
// Test data: {rate to set, expected TER, expected stored rate}
std::vector<TestCase> const testData = {
{1.0, tesSUCCESS, 1.0},
{1.1, tesSUCCESS, 1.1},
{2.0, tesSUCCESS, 2.0},
{2.1, temBAD_TRANSFER_RATE, 2.0}, // > 2.0 is invalid
{0.0, tesSUCCESS, 1.0}, // 0 clears the rate (default = 1.0)
{2.0, tesSUCCESS, 2.0},
{0.9, temBAD_TRANSFER_RATE, 2.0}, // < 1.0 is invalid
{.set = 1.0, .code = tesSUCCESS, .get = 1.0},
{.set = 1.1, .code = tesSUCCESS, .get = 1.1},
{.set = 2.0, .code = tesSUCCESS, .get = 2.0},
{.set = 2.1, .code = temBAD_TRANSFER_RATE, .get = 2.0}, // > 2.0 is invalid
{.set = 0.0, .code = tesSUCCESS, .get = 1.0}, // 0 clears the rate (default = 1.0)
{.set = 2.0, .code = tesSUCCESS, .get = 2.0},
{.set = 0.9, .code = temBAD_TRANSFER_RATE, .get = 2.0}, // < 1.0 is invalid
};
TxTest env;