Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions benchmark/sqlite/sqlite-trace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use strict';
const common = require('../common.js');
const sqlite = require('node:sqlite');
const dc = require('diagnostics_channel');
const assert = require('assert');

const bench = common.createBenchmark(main, {
n: [1e5],
mode: ['none', 'subscribed', 'unsubscribed'],
});

function main(conf) {
const { n, mode } = conf;

const db = new sqlite.DatabaseSync(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
const insert = db.prepare('INSERT INTO t VALUES (?)');

let subscriber;
if (mode === 'subscribed') {
subscriber = () => {};
dc.subscribe('sqlite.db.query', subscriber);
} else if (mode === 'unsubscribed') {
subscriber = () => {};
dc.subscribe('sqlite.db.query', subscriber);
dc.unsubscribe('sqlite.db.query', subscriber);
}
// mode === 'none': no subscription ever made

let result;
bench.start();
for (let i = 0; i < n; i++) {
result = insert.run(i);
}
bench.end(n);

if (mode === 'subscribed') {
dc.unsubscribe('sqlite.db.query', subscriber);
}

assert.ok(result !== undefined);
}
61 changes: 61 additions & 0 deletions doc/api/sqlite.md
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,66 @@ const totalPagesTransferred = await backup(sourceDb, 'backup.db', {
console.log('Backup completed', totalPagesTransferred);
```

## Diagnostics channel

<!-- YAML
added: REPLACEME
-->

The `node:sqlite` module publishes SQL trace events on the
[`diagnostics_channel`][] channel `sqlite.db.query`. This allows subscribers
to observe every SQL statement executed against any `DatabaseSync` instance
without modifying the database code itself. Tracing is zero-cost when there
are no subscribers.

### Channel `sqlite.db.query`

The message published to this channel is a {string} containing the expanded
SQL with bound parameter values substituted. If expansion fails, the source
SQL with unsubstituted placeholders is used instead.

```cjs
const dc = require('node:diagnostics_channel');
const { DatabaseSync } = require('node:sqlite');

function onQuery(sql) {
console.log(sql);
}

dc.subscribe('sqlite.db.query', onQuery);

const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
// Logs: CREATE TABLE t (x INTEGER)

const stmt = db.prepare('INSERT INTO t VALUES (?)');
stmt.run(42);
// Logs: INSERT INTO t VALUES (42.0)

dc.unsubscribe('sqlite.db.query', onQuery);
```

```mjs
import dc from 'node:diagnostics_channel';
import { DatabaseSync } from 'node:sqlite';

function onQuery(sql) {
console.log(sql);
}

dc.subscribe('sqlite.db.query', onQuery);

const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE t (x INTEGER)');
// Logs: CREATE TABLE t (x INTEGER)

const stmt = db.prepare('INSERT INTO t VALUES (?)');
stmt.run(42);
// Logs: INSERT INTO t VALUES (42.0)

dc.unsubscribe('sqlite.db.query', onQuery);
```

## `sqlite.constants`

<!-- YAML
Expand Down Expand Up @@ -1546,6 +1606,7 @@ callback function to indicate what type of operation is being authorized.
[`database.applyChangeset()`]: #databaseapplychangesetchangeset-options
[`database.createTagStore()`]: #databasecreatetagstoremaxsize
[`database.setAuthorizer()`]: #databasesetauthorizercallback
[`diagnostics_channel`]: diagnostics_channel.md
[`sqlite3_backup_finish()`]: https://www.sqlite.org/c3ref/backup_finish.html#sqlite3backupfinish
[`sqlite3_backup_init()`]: https://www.sqlite.org/c3ref/backup_finish.html#sqlite3backupinit
[`sqlite3_backup_step()`]: https://www.sqlite.org/c3ref/backup_finish.html#sqlite3backupstep
Expand Down
8 changes: 8 additions & 0 deletions lib/diagnostics_channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,19 @@ function markActive(channel) {
ObjectSetPrototypeOf(channel, ActiveChannel.prototype);
channel._subscribers = [];
channel._stores = new SafeMap();

// Notify native modules that this channel just got its first subscriber.
if (channel._index !== undefined)
dc_binding.notifyChannelActive(channel._index);
}

function maybeMarkInactive(channel) {
// When there are no more active subscribers or bound, restore to fast prototype.
if (!channel._subscribers.length && !channel._stores.size) {
// Notify native modules that this channel just lost its last subscriber.
if (channel._index !== undefined)
dc_binding.notifyChannelInactive(channel._index);

// eslint-disable-next-line no-use-before-define
ObjectSetPrototypeOf(channel, Channel.prototype);
channel._subscribers = undefined;
Expand Down
3 changes: 2 additions & 1 deletion src/base_object_types.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ namespace node {
#define UNSERIALIZABLE_BINDING_TYPES(V) \
V(http2_binding_data, http2::BindingData) \
V(http_parser_binding_data, http_parser::BindingData) \
V(quic_binding_data, quic::BindingData)
V(quic_binding_data, quic::BindingData) \
V(sqlite_binding_data, sqlite::BindingData)

// List of (non-binding) BaseObjects that are serializable in the snapshot.
// The first argument should match what the type passes to
Expand Down
28 changes: 28 additions & 0 deletions src/node_diagnostics_channel.cc
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,38 @@ void BindingData::Deserialize(Local<Context> context,
CHECK_NOT_NULL(binding);
}

void BindingData::SetChannelStatusCallback(uint32_t index,
ChannelStatusCallback cb) {
channel_status_callbacks_[index] = std::move(cb);
}

void BindingData::NotifyChannelActive(const FunctionCallbackInfo<Value>& args) {
Realm* realm = Realm::GetCurrent(args);
BindingData* binding = realm->GetBindingData<BindingData>();
if (binding == nullptr) return;
uint32_t index = args[0].As<v8::Uint32>()->Value();
auto it = binding->channel_status_callbacks_.find(index);
if (it != binding->channel_status_callbacks_.end()) it->second(true);
}

void BindingData::NotifyChannelInactive(
const FunctionCallbackInfo<Value>& args) {
Realm* realm = Realm::GetCurrent(args);
BindingData* binding = realm->GetBindingData<BindingData>();
if (binding == nullptr) return;
uint32_t index = args[0].As<v8::Uint32>()->Value();
auto it = binding->channel_status_callbacks_.find(index);
if (it != binding->channel_status_callbacks_.end()) it->second(false);
}

void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data,
Local<ObjectTemplate> target) {
Isolate* isolate = isolate_data->isolate();
SetMethod(
isolate, target, "getOrCreateChannelIndex", GetOrCreateChannelIndex);
SetMethod(isolate, target, "linkNativeChannel", LinkNativeChannel);
SetMethod(isolate, target, "notifyChannelActive", NotifyChannelActive);
SetMethod(isolate, target, "notifyChannelInactive", NotifyChannelInactive);
}

void BindingData::CreatePerContextProperties(Local<Object> target,
Expand All @@ -148,6 +174,8 @@ void BindingData::RegisterExternalReferences(
ExternalReferenceRegistry* registry) {
registry->Register(GetOrCreateChannelIndex);
registry->Register(LinkNativeChannel);
registry->Register(NotifyChannelActive);
registry->Register(NotifyChannelInactive);
}

Channel::Channel(Environment* env,
Expand Down
10 changes: 10 additions & 0 deletions src/node_diagnostics_channel.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS

#include <cinttypes>
#include <functional>
#include <string>
#include <unordered_map>
#include <vector>
Expand Down Expand Up @@ -53,6 +54,14 @@ class BindingData : public SnapshotableObject {
static void LinkNativeChannel(
const v8::FunctionCallbackInfo<v8::Value>& args);

using ChannelStatusCallback = std::function<void(bool is_active)>;
void SetChannelStatusCallback(uint32_t index, ChannelStatusCallback cb);

static void NotifyChannelActive(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void NotifyChannelInactive(
const v8::FunctionCallbackInfo<v8::Value>& args);

static void CreatePerIsolateProperties(IsolateData* isolate_data,
v8::Local<v8::ObjectTemplate> target);
static void CreatePerContextProperties(v8::Local<v8::Object> target,
Expand All @@ -63,6 +72,7 @@ class BindingData : public SnapshotableObject {

private:
InternalFieldInfo* internal_field_info_ = nullptr;
std::unordered_map<uint32_t, ChannelStatusCallback> channel_status_callbacks_;
};

class Channel : public BaseObject {
Expand Down
115 changes: 115 additions & 0 deletions src/node_sqlite.cc
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
#include "env-inl.h"
#include "memory_tracker-inl.h"
#include "node.h"
#include "node_diagnostics_channel.h"
#include "node_errors.h"
#include "node_external_reference.h"
#include "node_mem-inl.h"
#include "node_url.h"
#include "sqlite3.h"
Expand Down Expand Up @@ -63,6 +65,28 @@ using v8::TryCatch;
using v8::Uint8Array;
using v8::Value;

BindingData::BindingData(Realm* realm, Local<Object> wrap)
: BaseObject(realm, wrap) {
MakeWeak();
}

void BindingData::MemoryInfo(MemoryTracker* tracker) const {
tracker->TrackFieldWithSize("open_databases",
open_databases.size() * sizeof(DatabaseSync*),
"open_databases");
}

void BindingData::CreatePerContextProperties(Local<Object> target,
Local<Value> unused,
Local<Context> context,
void* priv) {
Realm* realm = Realm::GetCurrent(context);
realm->AddBindingData<BindingData>(target);
}

void BindingData::RegisterExternalReferences(
ExternalReferenceRegistry* registry) {}
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BindingData::RegisterExternalReferences() is empty, but this PR adds NODE_BINDING_EXTERNAL_REFERENCE(sqlite, ...). For snapshot builds, the sqlite binding needs to register all function callbacks that can be referenced from the snapshot (e.g., constructors/method callbacks like DatabaseSync::New, DatabaseSync::Exec, Backup, etc.). Also, the binding name sqlite must be added to EXTERNAL_REFERENCE_BINDING_LIST (in src/node_external_reference.h) so the registry actually invokes _register_external_reference_sqlite(); otherwise this hook is a no-op and snapshot builds may still miss sqlite references.

Suggested change
ExternalReferenceRegistry* registry) {}
ExternalReferenceRegistry* registry) {
registry->Register(CreatePerContextProperties);
}

Copilot uses AI. Check for mistakes.

#define CHECK_ERROR_OR_THROW(isolate, db, expr, expected, ret) \
do { \
int r_ = (expr); \
Expand Down Expand Up @@ -867,6 +891,9 @@ DatabaseSync::DatabaseSync(Environment* env,
enable_load_extension_ = allow_load_extension;
ignore_next_sqlite_error_ = false;

BindingData* binding = env->principal_realm()->GetBindingData<BindingData>();
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DatabaseSync registers itself in env->principal_realm()->GetBindingData<BindingData>(), but BindingData::CreatePerContextProperties() currently installs BindingData on the current Realm. If node:sqlite is loaded from a non-principal Realm (e.g., vm/ShadowRealm), principal_realm() may not have this BindingData, so open_databases won’t be tracked and trace enable/disable via diagnostics_channel will not work. Consider making sqlite BindingData truly per-Environment (always stored on env->principal_realm()), or consistently use the current Realm for both creation and lookup.

Suggested change
BindingData* binding = env->principal_realm()->GetBindingData<BindingData>();
BindingData* binding = env->context()->GetBindingData<BindingData>();

Copilot uses AI. Check for mistakes.
if (binding != nullptr) binding->open_databases.insert(this);

if (open) {
Open();
}
Expand All @@ -890,6 +917,10 @@ void DatabaseSync::DeleteSessions() {
}

DatabaseSync::~DatabaseSync() {
BindingData* binding =
env()->principal_realm()->GetBindingData<BindingData>();
if (binding != nullptr) binding->open_databases.erase(this);

FinalizeBackups();

if (IsOpen()) {
Expand Down Expand Up @@ -974,9 +1005,25 @@ bool DatabaseSync::Open() {
env()->isolate(), this, load_extension_ret, SQLITE_OK, false);
}

diagnostics_channel::Channel* ch =
diagnostics_channel::Channel::Get(env(), "sqlite.db.query");
if (ch != nullptr && ch->HasSubscribers()) {
sqlite3_trace_v2(connection_, SQLITE_TRACE_STMT, TraceCallback, this);
}

return true;
}

void DatabaseSync::EnableTracing() {
if (!IsOpen()) return;
sqlite3_trace_v2(connection_, SQLITE_TRACE_STMT, TraceCallback, this);
}

void DatabaseSync::DisableTracing() {
if (!IsOpen()) return;
sqlite3_trace_v2(connection_, 0, nullptr, nullptr);
}

void DatabaseSync::FinalizeBackups() {
for (auto backup : backups_) {
backup->Cleanup();
Expand Down Expand Up @@ -2391,6 +2438,48 @@ int DatabaseSync::AuthorizerCallback(void* user_data,
return int_result;
}

int DatabaseSync::TraceCallback(unsigned int type,
void* user_data,
void* p,
void* x) {
if (type != SQLITE_TRACE_STMT) {
return 0;
}

DatabaseSync* db = static_cast<DatabaseSync*>(user_data);
Environment* env = db->env();

diagnostics_channel::Channel* ch =
diagnostics_channel::Channel::Get(env, "sqlite.db.query");
if (ch == nullptr || !ch->HasSubscribers()) {
return 0;
}

Isolate* isolate = env->isolate();
HandleScope handle_scope(isolate);

char* expanded = sqlite3_expanded_sql(static_cast<sqlite3_stmt*>(p));
Local<Value> sql_string;
if (expanded != nullptr) {
bool ok = String::NewFromUtf8(isolate, expanded).ToLocal(&sql_string);
sqlite3_free(expanded);
if (!ok) {
return 0;
}
} else {
// Fallback to source SQL if expanded is unavailable
const char* source = sqlite3_sql(static_cast<sqlite3_stmt*>(p));
if (source == nullptr ||
!String::NewFromUtf8(isolate, source).ToLocal(&sql_string)) {
return 0;
}
}

ch->Publish(env, sql_string);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to get the query template and values as they were prior to generating the final SQL string? If at all possible, I think it would be valuable for Observability purposes to publish an event which includes: database instance, query template, query parameters, and final query string.


return 0;
}

StatementSync::StatementSync(Environment* env,
Local<Object> object,
BaseObjectPtr<DatabaseSync> db,
Expand Down Expand Up @@ -3733,7 +3822,31 @@ static void Initialize(Local<Object> target,
Local<Context> context,
void* priv) {
Environment* env = Environment::GetCurrent(context);
Realm* realm = env->principal_realm();
Isolate* isolate = env->isolate();

// Set up the per-Environment database registry.
BindingData::CreatePerContextProperties(target, unused, context, priv);

// Register a native callback on the sqlite.db.query diagnostic channel so
// that SQLite tracing is enabled/disabled as subscribers come and go.
auto* diag_binding =
realm->GetBindingData<diagnostics_channel::BindingData>();
auto* sqlite_bd = realm->GetBindingData<BindingData>();
if (diag_binding != nullptr && sqlite_bd != nullptr) {
uint32_t idx = diag_binding->GetOrCreateChannelIndex("sqlite.db.query");
BaseObjectPtr<BindingData> bd_ptr(sqlite_bd);
diag_binding->SetChannelStatusCallback(idx, [bd_ptr](bool is_active) {
BindingData* bd = bd_ptr.get();
if (bd == nullptr) return;
for (DatabaseSync* db : bd->open_databases) {
if (is_active)
db->EnableTracing();
else
db->DisableTracing();
}
});
}
Local<FunctionTemplate> db_tmpl =
NewFunctionTemplate(isolate, DatabaseSync::New);
db_tmpl->InstanceTemplate()->SetInternalFieldCount(
Expand Down Expand Up @@ -3812,3 +3925,5 @@ static void Initialize(Local<Object> target,
} // namespace node

NODE_BINDING_CONTEXT_AWARE_INTERNAL(sqlite, node::sqlite::Initialize)
NODE_BINDING_EXTERNAL_REFERENCE(
sqlite, node::sqlite::BindingData::RegisterExternalReferences)
Loading
Loading