Conversation
ab97f6e to
18b85d2
Compare
There was a problem hiding this comment.
Pull request overview
This PR integrates Rust tracing-based logging into the Node.js driver by forwarding Rust log events across the N-API boundary and exposing a types.logLevels string enum to configure filtering from JavaScript/TypeScript.
Changes:
- Added a Rust global
tracingsubscriber layer that forwards events to per-client registered JS callbacks with level filtering. - Introduced
logLevelclient option andtypes.logLevelsenum (JS + TS typings), plus tests covering API surface and behavior. - Added end-user documentation and migration guide updates for the new logging system.
Reviewed changes
Copilot reviewed 17 out of 19 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
src/logging.rs |
New Rust forwarding layer + per-client callback registry + level filtering. |
lib/client.js |
Registers/unregisters Rust log callback based on options.logLevel. |
lib/client-options.js |
Adds logLevel option default + validation and JSDoc. |
lib/types/index.js |
Adds types.logLevels enum values. |
lib/types/index.d.ts |
Adds logLevels TypeScript enum. |
main.d.ts |
Adds logLevel?: types.logLevels to ClientOptions. |
src/tests/logging_tests.rs / src/tests/mod.rs |
Adds Rust test helpers to emit tracing events for JS tests. |
test/unit/logging-tests.js |
Unit tests for Rust->JS forwarding, multi-callback, and filtering. |
test/integration/supported/client-logging-tests.js |
Integration coverage for real Rust driver log events emitted via Client. |
test/unit-not-supported/api-tests.js |
Ensures types.logLevels exists in the public API surface. |
test/typescript/* |
Type-level coverage for types.logLevels and Client({ logLevel }). |
docs/src/logging.md / docs/src/migration_guide.md / docs/src/SUMMARY.md |
Documentation for logging and migration notes. |
Cargo.toml |
Adds tracing and tracing-subscriber dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
18b85d2 to
1589d55
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 20 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Initialized (and the global subscriber installed) on the first call to | ||
| /// `setup_logging`. | ||
| static CALLBACKS: OnceLock<RwLock<Vec<RegisteredCallback>>> = OnceLock::new(); | ||
|
|
||
| /// Return the callback registry, lazily installing the global tracing | ||
| /// subscriber the very first time this is called. | ||
| fn get_or_init_callbacks() -> &'static RwLock<Vec<RegisteredCallback>> { | ||
| CALLBACKS.get_or_init(|| { | ||
| let subscriber = Registry::default().with(JsForwardingLayer); | ||
| tracing::subscriber::set_global_default(subscriber).unwrap_or_else(|e| { | ||
| // This will fail only of there is already a global subscriber, which we should prevent from happening. | ||
| panic!("This is likely due to a bug in the driver: failed to initialized logger. {e}"); | ||
| }); |
There was a problem hiding this comment.
set_global_default() failure currently triggers a panic!(). Given the crate is configured with panic = "abort" (Cargo.toml), this would hard-abort the Node.js process if any other Rust code in the process has already installed a global tracing subscriber. This seems too risky for a logging feature; consider handling the error non-fatally (e.g., skip forwarding / return None / store a flag) and fix the message/typos ("only if", "initialize").
| /// Initialized (and the global subscriber installed) on the first call to | |
| /// `setup_logging`. | |
| static CALLBACKS: OnceLock<RwLock<Vec<RegisteredCallback>>> = OnceLock::new(); | |
| /// Return the callback registry, lazily installing the global tracing | |
| /// subscriber the very first time this is called. | |
| fn get_or_init_callbacks() -> &'static RwLock<Vec<RegisteredCallback>> { | |
| CALLBACKS.get_or_init(|| { | |
| let subscriber = Registry::default().with(JsForwardingLayer); | |
| tracing::subscriber::set_global_default(subscriber).unwrap_or_else(|e| { | |
| // This will fail only of there is already a global subscriber, which we should prevent from happening. | |
| panic!("This is likely due to a bug in the driver: failed to initialized logger. {e}"); | |
| }); | |
| /// Initialized on the first call to `setup_logging`. We also try to install | |
| /// the global subscriber at that time, but continue without JS log forwarding | |
| /// if another global subscriber has already been installed. | |
| static CALLBACKS: OnceLock<RwLock<Vec<RegisteredCallback>>> = OnceLock::new(); | |
| /// Return the callback registry, lazily attempting to install the global | |
| /// tracing subscriber the very first time this is called. | |
| fn get_or_init_callbacks() -> &'static RwLock<Vec<RegisteredCallback>> { | |
| CALLBACKS.get_or_init(|| { | |
| let subscriber = Registry::default().with(JsForwardingLayer); | |
| let _ = tracing::subscriber::set_global_default(subscriber); |
There was a problem hiding this comment.
We all love LLMs. One time they say A, the other time they say not A.
I would say, explicit error is better here
I have doubts. You say that you change the default logging level from (?) to "off". Why? |
You still need to add some logic to be able to see any logs (
Considering the above, I assumed that users when creating the logic will also set the desired log level. |
Yep. Let's default to WARN. |
| /// Generic parameters: `CalleeHandled = false`, `Weak = true` so that the | ||
| /// callback does not prevent the Node.js event-loop from exiting. | ||
| type LogCallback = ThreadsafeFunction< | ||
| /* T: */ FnArgs<(String, String, String, String)>, | ||
| /* Return: */ (), | ||
| /* CallJsBackArgs: */ FnArgs<(String, String, String, String)>, |
There was a problem hiding this comment.
❓ What is CallJsBackArgs and why it's the same as T? Why are they distinct?
| /// Monotonically increasing id counter for registered callbacks. | ||
| static NEXT_ID: AtomicU32 = AtomicU32::new(0); |
There was a problem hiding this comment.
⛏️ Maybe let's use AtomicU64 to have a larger pool of IDs?
| /// Registry of all currently-active per-client callbacks. | ||
| /// Initialized (and the global subscriber installed) on the first call to | ||
| /// `setup_logging`. | ||
| static CALLBACKS: OnceLock<RwLock<Vec<RegisteredCallback>>> = OnceLock::new(); |
There was a problem hiding this comment.
🤔 I don't get why we need both OnceLock and RwLock. RwLock is enough to provide interior mutability, while it can be initialized to RwLock::new(Vec::new)).
What OnceLock does give is once-only semantics for the global tracing subscriber initialization. Perhaps we can do without that, though?
There was a problem hiding this comment.
Ah, yes. You anyway initialize with RwLock::new(Vec::new()). In this case, you can replace OnceLock with Once, and use that Once for initializing the global subscriber only.
| /// Maps a Rust `tracing::Level` to the JS log-level strings. | ||
| fn level_to_js(level: &Level) -> &'static str { | ||
| match *level { | ||
| Level::TRACE => "trace", | ||
| Level::DEBUG => "debug", | ||
| Level::INFO => "info", | ||
| Level::WARN => "warning", | ||
| Level::ERROR => "error", | ||
| } | ||
| } |
There was a problem hiding this comment.
🔧 maybe: rust_level_to_js_level?
| /// Parses a JS-side log-level name. | ||
| /// Returns `None` for `"off"` or unrecognized strings. | ||
| fn parse_level(level: &str) -> Option<Level> { | ||
| match level { | ||
| "trace" => Some(Level::TRACE), | ||
| "debug" => Some(Level::DEBUG), | ||
| "info" => Some(Level::INFO), | ||
| "warning" => Some(Level::WARN), | ||
| "error" => Some(Level::ERROR), | ||
| _ => None, | ||
| } | ||
| } |
There was a problem hiding this comment.
maybe: parse_js_level_to_rust_level?
| /// Returns `None` for `"off"` or unrecognized strings. | ||
| fn parse_level(level: &str) -> Option<Level> { | ||
| match level { | ||
| "trace" => Some(Level::TRACE), | ||
| "debug" => Some(Level::DEBUG), | ||
| "info" => Some(Level::INFO), | ||
| "warning" => Some(Level::WARN), | ||
| "error" => Some(Level::ERROR), | ||
| _ => None, | ||
| } |
There was a problem hiding this comment.
🤔 Shouldn't this treat unknown strings as Result::Err, while off as Option::None?
| struct MessageVisitor { | ||
| message: String, | ||
| extras: String, | ||
| } |
There was a problem hiding this comment.
🔧 Missing documentation.
| self.extras | ||
| .push_str(&format!("{}={:?}", field.name(), value)); |
There was a problem hiding this comment.
🔧 This needlessly allocates. Instead of format!, use String's implementation of std::fmt::Write, with write!(fmt, string, "{}={:?}", field.name(), value).
| if field.name() == "message" { | ||
| self.message = format!("{:?}", value); |
There was a problem hiding this comment.
🔧 This needlessly allocates, while there already is a String created with String::new() in MessageVisitor::new(). Instead of format!, use String's implementation of std::fmt::Write, with write!(fmt, &mut self.message, "{:?}", value).
| fn record_str(&mut self, field: &Field, value: &str) { | ||
| if field.name() == "message" { | ||
| self.message = value.to_owned(); | ||
| } else { | ||
| if !self.extras.is_empty() { | ||
| self.extras.push_str(", "); | ||
| } | ||
| self.extras.push_str(&format!("{}={}", field.name(), value)); | ||
| } | ||
| } |
There was a problem hiding this comment.
ditto about needless allocations
wprzytula
left a comment
There was a problem hiding this comment.
For now, reviewed up till the second commit, inclusively.
| // At least one callback wants this event — extract the fields. | ||
| let level_str = level_to_js(event_level).to_owned(); | ||
| let target = meta.target().to_owned(); | ||
|
|
||
| let mut visitor = MessageVisitor::new(); | ||
| event.record(&mut visitor); | ||
|
|
||
| for cb in callbacks.iter() { | ||
| if *event_level <= cb.min_level { | ||
| cb.callback.call( | ||
| FnArgs { | ||
| data: ( | ||
| level_str.clone(), | ||
| target.clone(), |
There was a problem hiding this comment.
🔧 Small optimisation:
| // At least one callback wants this event — extract the fields. | |
| let level_str = level_to_js(event_level).to_owned(); | |
| let target = meta.target().to_owned(); | |
| let mut visitor = MessageVisitor::new(); | |
| event.record(&mut visitor); | |
| for cb in callbacks.iter() { | |
| if *event_level <= cb.min_level { | |
| cb.callback.call( | |
| FnArgs { | |
| data: ( | |
| level_str.clone(), | |
| target.clone(), | |
| // At least one callback wants this event — extract the fields. | |
| let level_str = level_to_js(event_level); | |
| let target = meta.target(); | |
| let mut visitor = MessageVisitor::new(); | |
| event.record(&mut visitor); | |
| for cb in callbacks.iter() { | |
| if *event_level <= cb.min_level { | |
| cb.callback.call( | |
| FnArgs { | |
| data: ( | |
| level_str.to_owned(), | |
| target.to_owned(), |
| /// Register a per-client logging callback. | ||
| /// | ||
| /// Each call adds a **new** callback to the global registry, allowing | ||
| /// multiple `Client` instances to receive Rust driver log events | ||
| /// independently. | ||
| /// | ||
| /// Returns a numeric `id` that must be passed to [`removeLogging`] when the | ||
| /// client shuts down, so the callback can be unregistered. | ||
| /// | ||
| /// Returns `None` (`null` on the JS side) when `min_level` is `"off"` or | ||
| /// unrecognized — no callback is registered in that case. | ||
| #[napi] | ||
| pub fn setup_logging(callback: LogCallback, min_level: String) -> Option<u32> { | ||
| let level = parse_level(&min_level)?; | ||
|
|
||
| let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); | ||
|
|
||
| let callbacks = get_or_init_callbacks(); | ||
| callbacks.write().unwrap().push(RegisteredCallback { | ||
| id, | ||
| callback, | ||
| min_level: level, | ||
| }); | ||
|
|
||
| Some(id) | ||
| } |
There was a problem hiding this comment.
❓ Assume there are two Clients, each with its own Rust Driver's Session, and both clients use logging. Will both clients receive logs originating from their own Session or from any Session?
IIUC, they will get logs from any Session. Therefore, setting the logging callbacks per Client makes no sense for me. There should only be a global callback optionally set. Otherwise, what's the use of separate per-Client logging settings if all Clients see all logs anyway?
This PR was created with Claude Opus and reviewed by me.
This PR introduces new API for logging. As discussed in person, we cannot keep the original interface, which we change with this PR.
Add configurable log forwarding from Rust driver to JS
Introduces a logging system that forwards internal driver log events to the
JS
Clientas'log'EventEmitter events, with configurable severityfiltering.
Highlights
logLevelclient option: Controls the minimum severity of forwardedevents. Set to one of
trace,debug,info,warning,error, oroff(default). Filtering happens on the native side before crossing theFFI boundary.
types.logLevelsenum: Provides type-safe access to log level valuesin both JS and TypeScript.
Clientregisters its own callback onconnect()and unregisters onshutdown(). Multiple clients can coexistwith independent log levels.
'log'event delivers(level, target, message, furtherInfo)as separate arguments, matchingthe
cassandra-driverevent signature.tracingsubscriber in Rust maintainsa registry of per-client NAPI callbacks.
setupLogging()returns anOption<u32>id (ornullforoff/invalid), used byremoveLogging()to unregister.
Documentation
arguments, event sources, multi-client behavior, and usage examples.
documenting differences from
cassandra-driver: logging off by default,verbosereplaced bytrace+debug,targetreplacesclassName,cross-client event visibility.
Tests
setupLogging/removeLogging: level filtering, all fivelevels delivered,
furtherInfowith extra fields, multiple callbacks,removal stops delivery.
connect(), query execution, andDDL operations.
logLevelsenum values in JS and TypeScript.Fixes: #16