Repository navigation
[C API] Add logging support (logger handle) - #408
yuejiaointel wants to merge 7 commits into
Conversation
Builds on the initial logger handle API definition: - fix compile errors; add CallbackSink (base_sink + own formatter) so svs_logger_set_pattern applies to custom loggers and is thread-safe - validate custom logger ops version/struct_size - add svs_logger_free; implement get_level, set_pattern, get_pattern - svs_set_default_logger(NULL) restores the SVS default logger - new logger starts with no output (no env read / file reopen) - output changes apply in place to all users of a logger (dist_sink) - default pattern %v: callback receives the bare message - tests: c_api_logging.cpp; C99 consumer uses SVS_INIT_LOGGING_OPS
691b59a to
9591762
Compare
| /// (svs_logger_set_pattern) applies. The trailing end-of-line added by the formatter is | ||
| /// stripped before calling the user callback. | ||
| /// The base_sink mutex serializes calls to the user callback. | ||
| class CallbackSink final : public spdlog::sinks::base_sink<std::mutex> { |
There was a problem hiding this comment.
Why the new sink class defined rather than existing callback_sink from spdlog/sink/callback_sink.h?
There was a problem hiding this comment.
Good point — switched to callback_sink_mt. A custom callback already gets level and its own self context, so it can format the line itself; it now receives the bare message, same as VecSim. svs_logger_set_pattern still applies to stdout/stderr/file outputs (documented in svs_c.h).
Custom loggers now use spdlog::sinks::callback_sink_mt with a lambda that forwards the bare message payload, level and self to the user callback, replacing the CallbackSink class. The logger pattern applies to stdout, stderr and file outputs only; custom callbacks format messages themselves.
| /// (svs_logger_set_kind, svs_logger_set_custom, svs_logger_set_level, | ||
| /// svs_logger_set_pattern, svs_logger_free) are not thread-safe. Do not call them | ||
| /// concurrently with each other or with other functions using the same handle. | ||
| SVS_API svs_logger_h svs_logger_create(svs_error_h out_err /*=NULL*/); |
There was a problem hiding this comment.
IMHO, an empty logger makes no sense for user experience.
I would propose to define 2 other creators instead:
/// @brief create a logger
SVS_API svs_logger_h svs_logger_create(svs_logging_kind_t kind, const char* path, svs_error_h out_err);
/// @brief create a custom logger
SVS_API svs_logger_h svs_logger_create_custom(svs_logging_i user_logger, svs_error_h out_err);And remove svs_logger_set_kind() and svs_logger_set_custom()
In such case, svs_logger::set_output() is not needed anymore - if user want to configure another output - it can be done by constructing another svs_logger_h;
There was a problem hiding this comment.
Agreed — replaced with svs_logger_create(kind, path) and svs_logger_create_custom(user_logger); removed svs_logger_set_kind, svs_logger_set_custom and set_output().
rfsaliev
left a comment
There was a problem hiding this comment.
Please move logger definitions out-of svs_c.cpp
| struct svs_logger { | ||
| // The handle keeps this one spdlog logger for its whole life. Indexes and the SVS | ||
| // global default logger hold references to it, so output, level and pattern changes | ||
| // made through the handle apply to them immediately. | ||
| svs::logging::logger_ptr impl; | ||
| // Single sink of `impl`; forwards to the current output sink. Replacing the output | ||
| // swaps the sink held here (dist_sink::set_sinks locks the sink mutex). | ||
| std::shared_ptr<spdlog::sinks::dist_sink_mt> output; | ||
| // Current pattern; returned by svs_logger_get_pattern(). | ||
| std::string pattern = svs::c_runtime::default_log_pattern; | ||
|
|
||
| svs_logger() | ||
| : output{std::make_shared<spdlog::sinks::dist_sink_mt>()} { | ||
| // A new logger has no output until svs_logger_set_kind/set_custom is called. | ||
| output->set_sinks({svs::logging::null_sink()}); | ||
| impl = std::make_shared<spdlog::logger>(svs::c_runtime::logger_name, output); | ||
| impl->set_level(spdlog::level::warn); | ||
| impl->set_pattern(pattern); | ||
| } | ||
|
|
||
| // Replace the output sink in place, keeping the current level and pattern. | ||
| void set_output(svs::logging::sink_ptr sink) { | ||
| // dist_sink only passes its formatter to sub-sinks present at set_pattern time, | ||
| // so give the new sink the current pattern before installing it. | ||
| sink->set_pattern(pattern); | ||
| output->set_sinks({std::move(sink)}); | ||
| } | ||
| }; |
There was a problem hiding this comment.
To make svs_c.cpp cleaner, I would move-out this class definition to logger.hpp.
You may just keep the declaration here like:
| struct svs_logger { | |
| // The handle keeps this one spdlog logger for its whole life. Indexes and the SVS | |
| // global default logger hold references to it, so output, level and pattern changes | |
| // made through the handle apply to them immediately. | |
| svs::logging::logger_ptr impl; | |
| // Single sink of `impl`; forwards to the current output sink. Replacing the output | |
| // swaps the sink held here (dist_sink::set_sinks locks the sink mutex). | |
| std::shared_ptr<spdlog::sinks::dist_sink_mt> output; | |
| // Current pattern; returned by svs_logger_get_pattern(). | |
| std::string pattern = svs::c_runtime::default_log_pattern; | |
| svs_logger() | |
| : output{std::make_shared<spdlog::sinks::dist_sink_mt>()} { | |
| // A new logger has no output until svs_logger_set_kind/set_custom is called. | |
| output->set_sinks({svs::logging::null_sink()}); | |
| impl = std::make_shared<spdlog::logger>(svs::c_runtime::logger_name, output); | |
| impl->set_level(spdlog::level::warn); | |
| impl->set_pattern(pattern); | |
| } | |
| // Replace the output sink in place, keeping the current level and pattern. | |
| void set_output(svs::logging::sink_ptr sink) { | |
| // dist_sink only passes its formatter to sub-sinks present at set_pattern time, | |
| // so give the new sink the current pattern before installing it. | |
| sink->set_pattern(pattern); | |
| output->set_sinks({std::move(sink)}); | |
| } | |
| }; | |
| // Defined in logger.hpp | |
| struct svs_logger; |
There was a problem hiding this comment.
Done — moved to logger.hpp.
| inline svs::logging::Level to_logging_level(svs_log_level_t level) { | ||
| switch (level) { | ||
| case SVS_LOG_LEVEL_TRACE: | ||
| return svs::logging::Level::Trace; | ||
| case SVS_LOG_LEVEL_DEBUG: | ||
| return svs::logging::Level::Debug; | ||
| case SVS_LOG_LEVEL_INFO: | ||
| return svs::logging::Level::Info; | ||
| case SVS_LOG_LEVEL_WARN: | ||
| return svs::logging::Level::Warn; | ||
| case SVS_LOG_LEVEL_ERROR: | ||
| return svs::logging::Level::Error; | ||
| case SVS_LOG_LEVEL_CRITICAL: | ||
| return svs::logging::Level::Critical; | ||
| case SVS_LOG_LEVEL_OFF: | ||
| return svs::logging::Level::Off; | ||
| default: | ||
| return svs::logging::Level::Info; | ||
| } | ||
| } |
There was a problem hiding this comment.
I would move this function to logger.hpp as well...
There was a problem hiding this comment.
Done — moved to_logging_level to logger.hpp.
A logger handle now gets its output at creation and keeps it for life: svs_logger_create(kind, path) for the built-in outputs (none, stdout, stderr, file append/truncate) and svs_logger_create_custom(user_logger) for a user callback. svs_logger_set_kind, svs_logger_set_custom and the dist_sink-based in-place output swap are removed; to log somewhere else, create another logger. Level and pattern setters are unchanged and still apply to everything already using the logger. struct svs_logger, to_logging_level() and the sink construction move from svs_c.cpp to logger.hpp. Docs, the C consumer program and the logging tests follow the new API.
…ix docs - Remove the svs_index_builder_set_logger declaration: it has no definition in this change, so any caller would fail to link. Per-index loggers come in a follow-up once the core orchestrators accept a logger. - Remove SVS_LOGGING_KIND_CUSTOM: custom loggers are created with svs_logger_create_custom, so the kind had no use and svs_logger_create only rejected it. The remaining values keep their numbering; out-of-range kinds still return SVS_ERROR_INVALID_ARGUMENT. - svs_set_default_logger docs: indexes capture the default logger current when they are built or loaded and keep using it; a later default change does not affect them, so a custom callback and its self must outlive such indexes. NULL rebuilds the built-in default from SVS_LOG_SINK/SVS_LOG_LEVEL. - svs_c.cpp: drop the redundant forward declaration of svs_logger and the duplicate <svs/core/logging.h> include (both come from logger.hpp). - Tests: check the exact bare message "Number of syncs: 40" instead of scanning for a '[' prefix; add "Default Logger Change Does Not Affect Built Index" (dynamic index keeps logging to the captured logger after svs_set_default_logger(NULL)); kind value 5 is now rejected.
rfsaliev
left a comment
There was a problem hiding this comment.
LGFM, just few questions according to API definitions and corner cases.
| /// Maps a C API log level to the SVS logging level. Unknown values map to Info. | ||
| inline svs::logging::Level to_logging_level(svs_log_level_t level) { | ||
| switch (level) { | ||
| case SVS_LOG_LEVEL_TRACE: | ||
| return svs::logging::Level::Trace; | ||
| case SVS_LOG_LEVEL_DEBUG: | ||
| return svs::logging::Level::Debug; | ||
| case SVS_LOG_LEVEL_INFO: | ||
| return svs::logging::Level::Info; | ||
| case SVS_LOG_LEVEL_WARN: | ||
| return svs::logging::Level::Warn; | ||
| case SVS_LOG_LEVEL_ERROR: | ||
| return svs::logging::Level::Error; | ||
| case SVS_LOG_LEVEL_CRITICAL: | ||
| return svs::logging::Level::Critical; | ||
| case SVS_LOG_LEVEL_OFF: | ||
| return svs::logging::Level::Off; | ||
| default: | ||
| return svs::logging::Level::Info; | ||
| } | ||
| } |
There was a problem hiding this comment.
IMHO, the "Unknown values" case should be documented for svs_logger_set_level() in svs_c.h.
But I would recommend to throw a std::invalid_argument for the case - it will allow user to early identify issues in the code which calls to SVS API.
For now, user gets "silent" behavior for wrong calls.
There was a problem hiding this comment.
Done — invalid levels now return SVS_ERROR_INVALID_ARGUMENT; documented on svs_logger_set_level.
| INVALID_ARGUMENT_IF(logger == nullptr, "Logger must not be null"); | ||
| EXPECT_ARG_NOT_NULL(pattern); | ||
| auto new_pattern = std::string(pattern); | ||
| logger->impl->set_pattern(new_pattern); |
There was a problem hiding this comment.
What will happen if new_pattern.empty() (or *pattern == '\0')?
If such case is unacceptable, then I would guard this with:
| logger->impl->set_pattern(new_pattern); | |
| INVALID_ARGUMENT_IF(new_pattern.empty(), "Pattern should not be empty");` | |
| logger->impl->set_pattern(new_pattern); |
There was a problem hiding this comment.
Good catch — an empty pattern would drop the message text; added your guard.
|
|
||
| /// @brief Set format pattern for a logger | ||
| /// @param logger The logger handle | ||
| /// @param pattern The format pattern to set, in spdlog syntax (e.g. "[index A] %v") |
There was a problem hiding this comment.
Does it make sense to add a link to the spdlog documentation?
There was a problem hiding this comment.
Yes — added a link to spdlog's pattern syntax.
…ble (#407) Adds an optional per-index logger to the orchestrator front doors, so bindings (first: the C API, #408) can give each index its own logger. VecSim already does this by calling the lower-level builders directly. ## Change A trailing `svs::logging::logger_ptr logger = svs::logging::get()` parameter is added to: - `svs::Vamana::build` - `svs::Vamana::assemble` (path-based overload) - `svs::DynamicVamana::build` - `svs::DynamicVamana::assemble` (path-based overload; the logger goes after `debug_load_from_static`) Each one forwards it to `auto_build` / `auto_assemble` / `auto_dynamic_build` / `auto_dynamic_assemble`, which already accept a logger. The default is the same value those functions used before, so existing callers are unchanged. Not changed: the stream-based `assemble` overloads, because they end in a `DataLoaderArgs&&...` pack. This is a minimal subset of #128. ## Testing - New tests: "Vamana Per-Index Logger" and "DynamicVamana Per-Index Logger" (`tests/svs/orchestrators/`). They cover a custom logger vs. the global default, for both build and assemble. - `[managers][vamana],[managers][dynamic_vamana]`: all pass (353 assertions, 6 cases). `[logging]`: all pass (602 assertions, 10 cases). Build with no warnings. - Python bindings were not rebuilt locally; CI will cover them. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Completes the C API logger-handle design from
dev/c_api_logger_handle(initial API definition by Rafik Saliev, kept as the first commit). The C API user creates ansvs_logger_h, choosing its output at creation (svs_logger_create(kind, path): none/stdout/stderr/file, orsvs_logger_create_custom(user_logger): a C callbacklog(self, level, message)), sets level and pattern, and uses it as SVS's global logger (svs_set_default_logger). Indexes capture the default logger current when they are built or loaded and keep using it.A per-index logger (
svs_index_builder_set_logger) is not part of this PR; it comes in a follow-up after #407 (optional logger onsvs::Vamana/svs::DynamicVamanabuild/assemble) and a nightly that includes it. The draft's declaration without a body was removed here so callers cannot link against it.Changes on top of the initial definition
Bug fixes, no design change:
shared_ptr).callback_sink_mtforwarding the bare message (the draft's sharedstaticformatter was not thread-safe).version/struct_size), the same way as the custom allocator.svs_logger_free; implementedget_level,set_patternandget_pattern.svs_set_default_loggerdocuments that already-built indexes keep their logger.SVS_LOGGING_KIND_CUSTOM: custom loggers are created withsvs_logger_create_custom, so the kind had no use (out-of-range kinds returnSVS_ERROR_INVALID_ARGUMENT).Design choices that differ from the initial definition (for discussion)
Where a choice was needed, I followed how VecSim/Redis already integrates SVS logging (
VectorSimilarity/src/VecSim/algorithms/svs/svs.h).svs_set_default_logger(NULL)→ errorreset_to_default())svs_logger_create()readSVS_LOG_SINK/SVS_LOG_LEVELon every call (withfile:Xeach create truncated X)svs_logger_create(kind, path)/svs_logger_create_custom(user_logger)(per review); no env readset_kind/set_customcould change a logger's output after indexes used it (inconsistently)%+([time] [svs_c] [level] msg)%v(bare message) for all kindsset_pattern("%+")restoresSVS_ERROR_INVALID_ARGUMENTlike invalid kindCode locations: D1
svs_c.cppsvs_set_default_logger; D2svs_c.cppsvs_logger_create/svs_logger_create_custom,logger.hppmake_sink/make_custom_sink; D4logger.hppdefault_log_pattern. Custom loggers use spdlogcallback_sink_mtand receive the bare message (pattern applies to stdout/stderr/file only). Logger types/helpers live inlogger.hpp.Known gap: changing or resetting the default logger does not affect indexes already built or loaded, so a custom callback and its
selfmust stay valid while such indexes exist (documented and tested).Testing (local)
.github/scripts/build-c-api-bindings.sh+test-c-api-unit.sh): 14/14 pass. The newc_api_logging.cpphas 383 assertions.svs-manylinux228): build OK, 14/14 pass.test-c-api-bindings.sh): pass. The C99 consumer compilesSVS_INIT_LOGGING_OPS.svs_set_default_logger(NULL)stops new indexes from using it, file logger writes the log file.c_api_index.cpp:1241(allocator kind 999, UB) and leaks inc_api_dynamic_index.cpp:44-49.-std=c99 -Wpedantic -Werrorfails on__VA_OPT__insvs_c.h(filtered-search macro).🤖 Generated with Claude Code