Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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
45 changes: 45 additions & 0 deletions src/node_sqlite.cc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "env-inl.h"
#include "memory_tracker-inl.h"
#include "node.h"
#include "node_diagnostics_channel.h"
#include "node_errors.h"
#include "node_mem-inl.h"
#include "node_url.h"
Expand Down Expand Up @@ -974,6 +975,8 @@ bool DatabaseSync::Open() {
env()->isolate(), this, load_extension_ret, SQLITE_OK, false);
}

sqlite3_trace_v2(connection_, SQLITE_TRACE_STMT, TraceCallback, this);

return true;
}

Expand Down Expand Up @@ -2390,6 +2393,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
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
4 changes: 4 additions & 0 deletions src/node_sqlite.h
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ class DatabaseSync : public BaseObject {
const char* param2,
const char* param3,
const char* param4);
static int TraceCallback(unsigned int type,
void* user_data,
void* p,
void* x);
void FinalizeStatements();
void RemoveBackup(BackupJob* backup);
void AddBackup(BackupJob* backup);
Expand Down
145 changes: 145 additions & 0 deletions test/parallel/test-sqlite-trace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
'use strict';

const { skipIfSQLiteMissing } = require('../common');
skipIfSQLiteMissing();

const assert = require('node:assert');
const dc = require('node:diagnostics_channel');
const { DatabaseSync } = require('node:sqlite');
const { suite, it } = require('node:test');

suite('sqlite.db.query diagnostics channel', () => {
it('subscriber receives SQL string for exec() statements', (t) => {
const calls = [];
const db = new DatabaseSync(':memory:');
t.after(() => db.close());

const handler = (sql) => calls.push(sql);
dc.subscribe('sqlite.db.query', handler);
t.after(() => dc.unsubscribe('sqlite.db.query', handler));

db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1)');

assert.strictEqual(calls.length, 2);
assert.strictEqual(calls[0], 'CREATE TABLE t (x INTEGER)');
assert.strictEqual(calls[1], 'INSERT INTO t VALUES (1)');
});

it('subscriber receives SQL string for prepared INSERT statements', (t) => {
let calls = [];
const db = new DatabaseSync(':memory:');
t.after(() => db.close());

const handler = (sql) => calls.push(sql);
dc.subscribe('sqlite.db.query', handler);
t.after(() => dc.unsubscribe('sqlite.db.query', handler));

db.exec('CREATE TABLE t (x INTEGER)');
calls = []; // reset after setup

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

assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0], 'INSERT INTO t VALUES (42.0)');
});

it('subscriber receives SQL string for prepared SELECT statements', (t) => {
let calls = [];
const db = new DatabaseSync(':memory:');
t.after(() => db.close());

const handler = (sql) => calls.push(sql);
dc.subscribe('sqlite.db.query', handler);
t.after(() => dc.unsubscribe('sqlite.db.query', handler));

db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1)');
calls = []; // reset after setup

const stmt = db.prepare('SELECT x FROM t WHERE x = ?');
stmt.get(1);

assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0], 'SELECT x FROM t WHERE x = 1.0');
});

it('subscriber receives SQL string for prepared UPDATE statements', (t) => {
let calls = [];
const db = new DatabaseSync(':memory:');
t.after(() => db.close());

const handler = (sql) => calls.push(sql);
dc.subscribe('sqlite.db.query', handler);
t.after(() => dc.unsubscribe('sqlite.db.query', handler));

db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1)');
calls = []; // reset after setup

const stmt = db.prepare('UPDATE t SET x = ? WHERE x = ?');
stmt.run(2, 1);

assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0], 'UPDATE t SET x = 2.0 WHERE x = 1.0');
});

it('subscriber receives SQL string for prepared DELETE statements', (t) => {
let calls = [];
const db = new DatabaseSync(':memory:');
t.after(() => db.close());

const handler = (sql) => calls.push(sql);
dc.subscribe('sqlite.db.query', handler);
t.after(() => dc.unsubscribe('sqlite.db.query', handler));

db.exec('CREATE TABLE t (x INTEGER)');
db.exec('INSERT INTO t VALUES (1)');
calls = []; // reset after setup

const stmt = db.prepare('DELETE FROM t WHERE x = ?');
stmt.run(1);

assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0], 'DELETE FROM t WHERE x = 1.0');
});

it('no calls received after unsubscribe', (t) => {
const calls = [];
const db = new DatabaseSync(':memory:');
t.after(() => db.close());

const handler = (sql) => calls.push(sql);
dc.subscribe('sqlite.db.query', handler);

db.exec('CREATE TABLE t (x INTEGER)');
assert.strictEqual(calls.length, 1);

dc.unsubscribe('sqlite.db.query', handler);
db.exec('INSERT INTO t VALUES (1)');
assert.strictEqual(calls.length, 1); // No new calls after unsubscribe
});

it('falls back to source SQL when expansion fails', (t) => {
let calls = [];
const db = new DatabaseSync(':memory:', { limits: { length: 1000 } });
t.after(() => db.close());

const handler = (sql) => calls.push(sql);
dc.subscribe('sqlite.db.query', handler);
t.after(() => dc.unsubscribe('sqlite.db.query', handler));

db.exec('CREATE TABLE t (x TEXT)');
calls = []; // reset after setup

const stmt = db.prepare('INSERT INTO t VALUES (?)');

const longValue = 'a'.repeat(977);
stmt.run(longValue);

assert.strictEqual(calls.length, 1);
// Falls back to source SQL with unexpanded '?' placeholder
assert.strictEqual(calls[0], 'INSERT INTO t VALUES (?)');
});
});
Loading