diff --git a/aicontext/features/integrations/native-collector/feature.md b/aicontext/features/integrations/native-collector/feature.md index d7c56a14e..a75ab8905 100644 --- a/aicontext/features/integrations/native-collector/feature.md +++ b/aicontext/features/integrations/native-collector/feature.md @@ -1,6 +1,6 @@ # Feature: Native Collector C++ API -> Owner: integrations | Last reviewed: 2026-07-01 | Canonical: yes +> Owner: integrations | Last reviewed: 2026-07-02 | Canonical: yes > Scope: The public, developer-facing C++ RAII API over the native collector C ABI, plus its `find_package` packaging, console example, and migration story from the C++/CLI wrapper. --- @@ -81,7 +81,7 @@ The wrapper is a **thin convenience layer**: it adds no wire behavior. Every reg - **Runtime alert attach to a built-in sensor (agent example).** The HsmAgent raises the built-in **Total CPU** sensor to **Error** on top of its default Warning: it grabs the sensor via `hsm_collector_add_default_sensor(TOTAL_CPU)` (idempotent by path — `AddAllComputerSensors` already created it), builds an `EmaMean > 50` alert with the fluent `CreateAlert(AlertKind::Bar).If(EmaMean, GreaterThan, "50").AsSensorError().ThenNotify(…).WithIcon(Error).WithConfirmationPeriod(5min)`, and `AttachAlert`s it before Start. `AttachAlert` rebuilds the sensor's registration in place, so the extra alert rides the AddOrUpdate. **No catalog/golden/managed change** — the shared default stays Warning@50%; this agent additionally Errors@50%. (`EmaMean`=213 is the bar's smoothed-mean property.) Pinned by `native_total_cpu_error_alert_attaches`. - **Native wrapper DLL (`src/wrapper/`, drop-in replacement for the C++/CLI wrapper).** The legacy `hsm_wrapper::DataCollectorProxy` ABI is reimplemented over `hsm::collector`: `DataCollectorImpl` holds a `Collector`, lifecycle/`Initialize*Monitoring`/`Create*`/file/service-state forward to the native API, the static `AlertsBuilder` DSL accumulates plain data converted to a native alert (`Collector::CreateAlert` + `Sensor::AttachAlert`) at sensor creation, and the former managed func-sensor delegate bridge collapses (the native `Function`/`ValuesFunctionSensor` take a `std::function` directly). `Start`/`StartAsync` call `InstallWindowsMetricSources()` (live values, Windows) + `UseHttpTransport()` (real server) before the native `Start`. Built by `src/wrapper/CMakeLists.txt` (`add_subdirectory` of the native collector with `HSM_COLLECTOR_HTTP=ON`) into `HSMCppWrapper.dll`; `pch.h` force-defines `HSMWRAPPER_API=__declspec(dllexport)`. The public headers under `src/wrapper/include/` are unchanged, so a consumer relinks with zero source edits. Behavioral residue vs the managed wrapper is in `docs/native-collector-migration.md`; smoke-driven in-process by `WrapperConsole` (`src/wrapper/WrapperConsole/WrapperSmoke.cpp`), built + run in the `hsm-wrapper-build` CI lane. **Incremental-migration escape hatch:** `DataCollectorProxy::Native()` returns the wrapper's underlying `hsm::collector::Collector&`, so new sensors can be created directly against the native API on the same collector/connection while old sensors keep using the wrapper; the DLL re-exports the `extern "C"` C ABI (a CMake-generated `.def` from the frozen `hsm_collector.h`, `_t` typedefs filtered) so a consumer links only `HSMCppWrapper` — no second copy of the collector runtime. When the last wrapper call-site is gone, the proxy is swapped for a directly-owned `Collector` and the wrapper deleted (no big-bang). **Handoff:** the DLL is delivered to consumers as a versioned zip (both x64 configs + libcurl/zlib runtime + headers + a swap-recipe `MANIFEST.md`) attached to a `wrapper-v*` GitHub Release by the `hsm-wrapper-release` workflow — a stable cross-host download for the GitLab-hosted aggregator; layout mirrors their vendored tree so it unzips over the checkout. See `src/wrapper/packaging/` and the "Getting the native wrapper bundle" section of `docs/native-collector-migration.md`. - **TLS / transport.** `CollectorOptions::allow_untrusted_server_certificate` and `allow_plaintext_transport` map to the C options of the same name; they take effect once `UseHttpTransport()` installs the libcurl transport (`HSM_COLLECTOR_HTTP` build). Default builds use the in-memory recording sender (no network), which is what lets the console example and tests run with no server. -- **Logger hookup.** `SetLogger(std::function)`. Errors pass through the deduplicator (`exception_deduplicator_window_ms` / `max_deduplicated_messages`); a window of 0 logs every message. A throwing logger is swallowed. +- **Logging (managed-parity).** Two sinks, both fed by the single `LogMessage` path: a **callback** via `SetLogger(std::function)` (C ABI `hsm_collector_set_logger`), and a **built-in rolling file logger** via `EnableFileLogging(directory, min_level = LogLevel::Info)` (C ABI `hsm_collector_enable_file_logging`) — the parity replacement for the managed NLog default sink. The file logger writes `/DataCollector_.txt` (every level ≥ `min_level`) plus `/DataCollector_error_.txt` (errors only), format `yyyy-MM-dd HH:mm:ss|LEVEL| message`, rolling by UTC date, on a background thread (Enqueue never blocks the caller; the destructor drains the queue before joining so shutdown logs are not lost). Both sinks receive every message; the file applies its own min-level filter. Install before Start; a second `EnableFileLogging` swaps the sink, an empty directory clears it. **Events:** lifecycle `DataCollector -> Starting/Running/Stopping/Stopped` (Info, via `NotifyLifecycle`); `Registered N sensor(s) on connect` (Info) / `Failed to register …` (Error) at the Start `/commands` POST; `Failed to send N value(s): HTTP …` (Error, **deduplicated** — the dispatcher's re-enqueue retry is itself silent, matching the queue); plus the pre-existing metric-source-read / top-CPU / stop-time-drop errors. **Errors** pass through the deduplicator (`exception_deduplicator_window_ms` / `max_deduplicated_messages`; window 0 logs every message). A throwing callback is swallowed. **Parity delta vs the managed collector:** per-sensor `New sensor added {path}` (Info) and per-value `value rejected` (Debug) are deliberately *not* emitted — they would fire the user callback under the collector lock (registration) or need sensor→collector plumbing (value path); the `Registered N sensor(s)` batch line plus the sensor's own status/telemetry cover those cases. - **Threading expectations.** `Start`/`Stop` single-threaded; sensor `AddValue` is thread-safe; callbacks run on the scheduler thread and must not re-enter a lifecycle method. `StartAsync`/`StopAsync` wrap the sync calls in `std::async` (note `std::future`'s destructor blocks until the task completes). - **Shutdown semantics.** `Stop` is bounded (it will not hang on a dead transport — pending data is dropped rather than blocking host restart). `Dispose` is terminal and idempotent; after it, `Status()` is `Disposed` and further lifecycle/registration calls are rejected. @@ -113,6 +113,8 @@ Tracked in full in `docs/native-collector-migration.md`. Headlines: - `native_wrapper_value_path_posts_through` — the value-add path forwards 1:1. - `native_wrapper_lifetime_move_semantics` — move + Dispose correctness. - `native_wrapper_callbacks_bridge_std_function` — lifecycle listener, logger, function sensor, and metric-source factory all fire through their trampolines. +- `native_logging_emits_lifecycle_events` — a `SetLogger` callback receives the Info lifecycle transitions (`-> Starting/Running/Stopping/Stopped`) across Start/Stop. +- `native_file_logger_writes_to_dated_file` — `EnableFileLogging` writes a `DataCollector_.txt` whose content carries the logged line + level tag (flushed deterministically by the destructor's drain+join, no sleep). - `native_rate_options_parity` — RateOptions default cadence (1 min), M1/M5 empty-Description vs bare-null, and the full registration surface (TTL/unit/display-unit/aggregate/EMA) lowered onto a rate sensor. - `native_file_from_path_derives_name_extension_and_content` — `SendFile(path)` overrides the creation-time name/extension with the file's own stem/extension and posts the content; `native_file_from_path_missing_file_returns_not_found_and_sends_nothing` — a missing file returns `NOT_FOUND` and enqueues nothing. - `rate_options_contract.hsmtest` — cross-language pin: the same options through `RateSensorOptions.ToApi()` (managed) and `hsm_collector_create_rate_sensor_with_options` (native) produce byte-identical registration text; includes the unset-`unit` ⇒ ValueInSecond(3000) default case. diff --git a/src/native/collector/CMakeLists.txt b/src/native/collector/CMakeLists.txt index 0b6dadfc3..5d6a5a987 100644 --- a/src/native/collector/CMakeLists.txt +++ b/src/native/collector/CMakeLists.txt @@ -360,6 +360,8 @@ set(NATIVE_REGRESSION_TESTS native_scheduler_on_error_isolates_throwing_callback native_logger_deduplicates_repeated_errors_within_window native_logger_zero_window_logs_every_error + native_logging_emits_lifecycle_events + native_file_logger_writes_to_dated_file native_retry_meeting_full_queue_is_dropped_not_evicting_queued_values native_retry_below_capacity_is_always_redelivered cpu_select_top_n_selects_busiest_above_threshold diff --git a/src/native/collector/include/hsm_collector/collector.hpp b/src/native/collector/include/hsm_collector/collector.hpp index 92550d5f5..b45dec567 100644 --- a/src/native/collector/include/hsm_collector/collector.hpp +++ b/src/native/collector/include/hsm_collector/collector.hpp @@ -242,6 +242,17 @@ namespace hsm::collector loggers_.push_back(std::move(holder)); } + /// Install the built-in rolling file logger (parity with the managed NLog default): writes + /// `/DataCollector_.txt` (levels >= `min_level`) plus a `_error_` file + /// (errors only), asynchronously, rolling by date. Complements SetLogger - both the callback + /// and the file receive every message. Call before Start(); an empty `directory` clears it. + void EnableFileLogging(const std::string& directory, LogLevel min_level = LogLevel::Info) + { + Check( + hsm_collector_enable_file_logging(handle_, directory.c_str(), static_cast(min_level)), + "Failed to enable file logging."); + } + /// Install (or, with an empty std::function, clear) the metric-source factory. As with /// SetLogger, call once before Start(): a replaced/cleared holder is retained until /// destruction (the factory may be in use on the scheduler thread), with no reclamation. diff --git a/src/native/collector/include/hsm_collector/hsm_collector.h b/src/native/collector/include/hsm_collector/hsm_collector.h index a44debd6d..a2ed6099f 100644 --- a/src/native/collector/include/hsm_collector/hsm_collector.h +++ b/src/native/collector/include/hsm_collector/hsm_collector.h @@ -309,6 +309,15 @@ typedef enum hsm_log_level_t HSM_ENUM_INT32 typedef void (*hsm_log_callback_t)(hsm_log_level_t level, const char* message, void* user_data); hsm_result_t hsm_collector_set_logger(hsm_collector_t* collector, hsm_log_callback_t callback, void* user_data); +/* Built-in rolling file logger (parity with the managed NLog default sink). Writes + /DataCollector_.txt (every level >= min_level) and + /DataCollector_error_.txt (errors only), asynchronously, + rolling when the UTC date changes; line format "yyyy-MM-dd HH:mm:ss|LEVEL| message". + Complements set_logger: both the callback (if any) and the file receive every + message (the file applies its own min_level filter). Call before Start. */ +hsm_result_t hsm_collector_enable_file_logging( + hsm_collector_t* collector, const char* directory, hsm_log_level_t min_level); + hsm_result_t hsm_collector_create_int_sensor( hsm_collector_t* collector, const char* path, diff --git a/src/native/collector/src/hsm_collector.cpp b/src/native/collector/src/hsm_collector.cpp index 54244edea..78d74a8d5 100644 --- a/src/native/collector/src/hsm_collector.cpp +++ b/src/native/collector/src/hsm_collector.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -80,6 +81,24 @@ namespace Disposed, }; + const char* StateName(CollectorState state) + { + switch (state) + { + case CollectorState::Stopped: + return "Stopped"; + case CollectorState::Starting: + return "Starting"; + case CollectorState::Running: + return "Running"; + case CollectorState::Stopping: + return "Stopping"; + case CollectorState::Disposed: + return "Disposed"; + } + return "Unknown"; + } + hsm_collector_status_t ToPublicStatus(CollectorState state) { switch (state) @@ -748,6 +767,146 @@ namespace bool stop_ = false; }; + // Built-in rolling file logger (parity with the managed NLog default sink). A background thread + // drains a queue and appends formatted lines to /DataCollector_.txt (every level at + // or above min_level) and /DataCollector_error_.txt (errors only), rolling when the + // UTC date changes. Enqueue never blocks the caller (the file write happens on the worker); the + // destructor drains the remaining queue before joining so shutdown logs are not lost. The worker + // touches only this object (files/queue), never the collector, so it is free of the teardown races + // the collector's own threads have to guard against. + class FileLogger + { + public: + FileLogger(std::string directory, hsm_log_level_t min_level) + : directory_(std::move(directory)), min_level_(min_level) + { + std::error_code ec; + std::filesystem::create_directories(directory_, ec); // best-effort; the file open() is the real gate + worker_ = std::thread([this] { Run(); }); + } + + FileLogger(const FileLogger&) = delete; + FileLogger& operator=(const FileLogger&) = delete; + + ~FileLogger() + { + { + std::lock_guard guard(mutex_); + stop_ = true; + } + cv_.notify_all(); + if (worker_.joinable()) + worker_.join(); + } + + // Non-blocking: stamps wall-clock time (logging uses real time, not the collector's swappable + // clock) and hands off to the worker. Dropped silently once stopping. + void Enqueue(hsm_log_level_t level, const std::string& message) + { + { + std::lock_guard guard(mutex_); + if (stop_) + return; + queue_.push_back(Entry{ level, message, UnixTimeMilliseconds() }); + } + cv_.notify_one(); + } + + private: + struct Entry + { + hsm_log_level_t level; + std::string message; + int64_t unix_ms; + }; + + void Run() + { + std::string open_date; + std::ofstream all_file; + std::ofstream err_file; + + std::unique_lock lock(mutex_); + for (;;) + { + cv_.wait(lock, [this] { return stop_ || !queue_.empty(); }); + + std::deque batch; + batch.swap(queue_); + const bool stopping = stop_; + lock.unlock(); + + for (const auto& e : batch) + { + const std::string date = FormatUtc(e.unix_ms, "%Y-%m-%d"); + if (date != open_date) + { + open_date = date; + all_file.close(); + err_file.close(); + all_file.open(directory_ + "/DataCollector_" + date + ".txt", std::ios::app); + err_file.open(directory_ + "/DataCollector_error_" + date + ".txt", std::ios::app); + } + + const std::string line = + FormatUtc(e.unix_ms, "%Y-%m-%d %H:%M:%S") + "|" + LevelName(e.level) + "| " + e.message + "\n"; + if (e.level >= min_level_ && all_file.is_open()) + all_file << line; + if (e.level == HSM_LOG_LEVEL_ERROR && err_file.is_open()) + err_file << line; + } + + if (all_file.is_open()) + all_file.flush(); + if (err_file.is_open()) + err_file.flush(); + + lock.lock(); + if (stopping && queue_.empty()) + break; + } + } + + static const char* LevelName(hsm_log_level_t level) + { + switch (level) + { + case HSM_LOG_LEVEL_DEBUG: + return "DEBUG"; + case HSM_LOG_LEVEL_INFO: + return "INFO"; + default: + return "ERROR"; + } + } + + // UTC strftime of a unix-ms instant; a sentinel on an out-of-range second (mirrors the wire ISO + // helper) so a bad clock never yields a "0000-" file name. + static std::string FormatUtc(int64_t unix_ms, const char* fmt) + { + std::time_t secs = static_cast(unix_ms / 1000); + std::tm tm{}; +#if defined(_WIN32) + const bool ok = gmtime_s(&tm, &secs) == 0; +#else + const bool ok = gmtime_r(&secs, &tm) != nullptr; +#endif + if (!ok) + return "0001-01-01"; + char buf[40]; + const size_t n = std::strftime(buf, sizeof(buf), fmt, &tm); + return std::string(buf, n); + } + + std::string directory_; + hsm_log_level_t min_level_; + std::mutex mutex_; + std::condition_variable cv_; + std::deque queue_; + bool stop_ = false; + std::thread worker_; + }; + // .NET shortest round-trip ("R") double text — the canonical payload contract // (tests/conformance/README.md). std::to_chars produces the shortest digits, but its // fixed/scientific choice differs from .NET (e.g. 1e5: to_chars "1e+05", .NET "100000"), @@ -2206,6 +2365,9 @@ namespace if (!response.IsSuccess()) LogError("Failed to register " + std::to_string(sensors.size()) + " sensor(s) on Start: " + (response.error.empty() ? "non-2xx response" : response.error)); + else + LogMessage( + HSM_LOG_LEVEL_INFO, "Registered " + std::to_string(sensors.size()) + " sensor(s) on connect."); } #endif @@ -2521,6 +2683,23 @@ namespace log_user_data_ = user_data; } + // Install the built-in rolling file sink (parity with the managed NLog default). Complements + // SetLogger: both the callback (if any) and the file receive every message. Call before Start. + // A second call swaps the sink; an empty directory clears it. The replaced sink is flushed and + // joined outside the lock so a swap never stalls concurrent logging. + void EnableFileLogging(const std::string& directory, hsm_log_level_t min_level) + { + std::unique_ptr logger = + directory.empty() ? nullptr : std::make_unique(directory, min_level); + std::unique_ptr old; + { + std::lock_guard guard(logger_mutex_); + old = std::move(file_logger_); + file_logger_ = std::move(logger); + } + // `old`'s destructor (drain + join) runs here, outside logger_mutex_. + } + hsm_result_t CreateSensor( const char* path, hsm_sensor_type_t type, @@ -3387,6 +3566,7 @@ namespace void NotifyLifecycle(CollectorState state) { const auto status = ToPublicStatus(state); + LogMessage(HSM_LOG_LEVEL_INFO, std::string("DataCollector -> ") + StateName(state)); std::vector listeners; { @@ -3402,16 +3582,21 @@ namespace { hsm_log_callback_t callback = nullptr; void* user_data = nullptr; + FileLogger* file_logger = nullptr; { std::lock_guard guard(logger_mutex_); callback = log_callback_; user_data = log_user_data_; + // Raw ptr is safe: the collector joins every thread that can call LogMessage before + // file_logger_ is destroyed at teardown, so it cannot be freed under this call. + file_logger = file_logger_.get(); } - if (callback == nullptr) - return; - - InvokeIsolated([&] { callback(level, message.c_str(), user_data); }); + // Both sinks receive every message (the file logger applies its own min-level filter). + if (callback != nullptr) + InvokeIsolated([&] { callback(level, message.c_str(), user_data); }); + if (file_logger != nullptr) + InvokeIsolated([&] { file_logger->Enqueue(level, message); }); } // Error routing entry point: validation drops, loop errors and shutdown discards all @@ -4456,6 +4641,15 @@ namespace } } + // Managed-parity send diagnostic (BaseHandlers "Failed to send data ... Code = ..."): + // report a failed batch with the HTTP status. Deduplicated, so a sustained outage collapses + // to one line per window (the batch is re-enqueued by the dispatcher — the retry itself is + // not logged, matching the queue's silent re-enqueue). + if (!response.IsSuccess()) + LogError( + "Failed to send " + std::to_string(batch.size()) + " value(s): HTTP " + + std::to_string(response.status_code) + (response.error.empty() ? "" : " " + response.error)); + return response.IsSuccess(); } #endif @@ -4561,6 +4755,7 @@ namespace std::mutex logger_mutex_; hsm_log_callback_t log_callback_ = nullptr; void* log_user_data_ = nullptr; + std::unique_ptr file_logger_; // built-in file sink (EnableFileLogging); guarded by logger_mutex_ std::unordered_map dedup_; // [[maybe_unused]] marks options stored to mirror CollectorOptions but consumed only by @@ -5319,6 +5514,15 @@ hsm_result_t hsm_collector_set_logger(hsm_collector_t* collector, hsm_log_callba return HSM_RESULT_OK; } +hsm_result_t hsm_collector_enable_file_logging(hsm_collector_t* collector, const char* directory, hsm_log_level_t min_level) +{ + if (collector == nullptr || directory == nullptr) + return HSM_RESULT_INVALID_ARGUMENT; + + collector->impl->EnableFileLogging(directory, min_level); + return HSM_RESULT_OK; +} + // Test-only seam — deliberately NOT declared in the public header. The native unit tests // forward-declare these to drive the scheduler's clock without real-time sleeps (the // injectable clock seam, issue #1095 §13). Install before Start; advance while running. diff --git a/src/native/collector/tests/hsm_collector_tests.cpp b/src/native/collector/tests/hsm_collector_tests.cpp index 1a88dcaf0..e5630b063 100644 --- a/src/native/collector/tests/hsm_collector_tests.cpp +++ b/src/native/collector/tests/hsm_collector_tests.cpp @@ -5,6 +5,7 @@ #include "../src/hsm_http_retry.hpp" #include #include +#include #include #include #include @@ -3490,6 +3491,69 @@ namespace Require(logs.size() == 3, "a zero window logs every error immediately with no deduplication"); } + void NativeLoggingEmitsLifecycleEvents() + { + std::vector> logs; + auto collector = CreateCollector(); + hsm_collector_set_logger( + collector.value, + [](hsm_log_level_t level, const char* message, void* user_data) { + static_cast>*>(user_data)->emplace_back(level, message); + }, + &logs); + + Require(hsm_collector_start(collector.value) == HSM_RESULT_OK, "start failed"); + Require(hsm_collector_stop(collector.value) == HSM_RESULT_OK, "stop failed"); + + const auto has_info = [&](const std::string& needle) { + for (const auto& entry : logs) + if (entry.first == HSM_LOG_LEVEL_INFO && entry.second.find(needle) != std::string::npos) + return true; + return false; + }; + Require(has_info("-> Starting"), "Start should log the Starting lifecycle transition at Info"); + Require(has_info("-> Running"), "Start should log the Running lifecycle transition at Info"); + Require(has_info("-> Stopping"), "Stop should log the Stopping lifecycle transition at Info"); + Require(has_info("-> Stopped"), "Stop should log the Stopped lifecycle transition at Info"); + } + + void NativeFileLoggerWritesToDatedFile() + { + const std::filesystem::path dir = std::filesystem::temp_directory_path() / "hsm_filelog_smoke"; + std::filesystem::remove_all(dir); + + { + auto collector = CreateCollector(); + Require( + hsm_collector_enable_file_logging(collector.value, dir.string().c_str(), HSM_LOG_LEVEL_INFO) == HSM_RESULT_OK, + "enable file logging failed"); + hsm_collector_test_log_error(collector.value, "file-logger-smoke-line"); + // The collector is destroyed here: the file logger drains its queue and joins, so the file + // is fully written by the time we read it below (no sleep needed). + } + + std::string content; + bool found = false; + for (const auto& entry : std::filesystem::directory_iterator(dir)) + { + const std::string name = entry.path().filename().string(); + if (name.rfind("DataCollector_", 0) == 0 && name.find("_error_") == std::string::npos) + { + std::ifstream in(entry.path()); + std::stringstream ss; + ss << in.rdbuf(); + content = ss.str(); + found = true; + break; + } + } + std::filesystem::remove_all(dir); + + Require(found, "a DataCollector_.txt file should have been written"); + Require(content.find("file-logger-smoke-line") != std::string::npos, "the logged message is not in the file"); + Require(content.find("ERROR") != std::string::npos, "the level tag is missing from the file line"); + } + void NativeLifecycleListenerCanRegisterAnotherListener() { auto collector = CreateCollector(); @@ -5012,6 +5076,8 @@ namespace { "native_lifecycle_listener_can_register_another_listener", [](const std::string&) { NativeLifecycleListenerCanRegisterAnotherListener(); } }, { "native_logger_deduplicates_repeated_errors_within_window", [](const std::string&) { NativeLoggerDeduplicatesRepeatedErrorsWithinWindow(); } }, { "native_logger_zero_window_logs_every_error", [](const std::string&) { NativeLoggerZeroWindowLogsEveryError(); } }, + { "native_logging_emits_lifecycle_events", [](const std::string&) { NativeLoggingEmitsLifecycleEvents(); } }, + { "native_file_logger_writes_to_dated_file", [](const std::string&) { NativeFileLoggerWritesToDatedFile(); } }, { "native_scheduler_clock_seam_drives_periodic_posts", [](const std::string&) { NativeSchedulerClockSeamDrivesPeriodicPosts(); } }, { "native_scheduler_on_error_isolates_throwing_callback", [](const std::string&) { NativeSchedulerOnErrorIsolatesThrowingCallback(); } }, { "native_version_matches_macro", [](const std::string&) { NativeVersionMatchesMacro(); } },