Query logging and errors with context¶
See the SQL that runs, and know exactly which query failed.
Logging every query¶
Pass onQuery in the engine options — it's called per statement, with the SQL
and the bound params:
import { createEngine } from "tempest-db-js";
const engine = createEngine("sqlite:///app.db", {
onQuery: ({ sql, params }) => {
console.debug(sql, params);
},
});
The hook fires for every session statement: execute, stream, and the
BEGIN/COMMIT/SAVEPOINT of transactions.
The logger never breaks a query
If your onQuery throws, the error is swallowed — logging never brings
execution down. Don't rely on it for business logic.
Tracing / metrics
onQuery is the place to measure latency (stamp time, correlate by SQL),
count queries per request, or feed a tracer.
Duration: onQueryEnd¶
onQuery fires before the statement runs, so it cannot time anything. Its other
half is onQueryEnd, which fires afterwards with what the driver took:
const engine = createEngine("postgresql://app@localhost/app", {
onQueryEnd: ({ sql, durationMs, rowCount, error }) => {
metrics.histogram("db.query.ms", durationMs, { failed: error !== undefined });
if (durationMs > 200) logger.warn({ sql, durationMs, rowCount }, "slow query");
},
});
| Field | What it carries |
|---|---|
sql / params |
the same ones onQuery announced |
durationMs |
the driver's wall-clock time |
rowCount |
rows returned (SELECT/RETURNING) or affected (writes) |
error |
the driver's error, when the statement failed |
A slow-query log in one line¶
createEngine(url, {
slowQueryMs: 200,
onQueryEnd: ({ sql, durationMs }) => logger.warn({ sql, durationMs }, "slow query"),
});
With slowQueryMs the hook is called only for statements crossing the threshold — the
cheapest slow-query log there is, with no APM agent.
It fires on failure too
A failing statement calls onQueryEnd with error set and rowCount: 0. The slow
statement that also fails is exactly the interesting one, and a hook that only
saw the happy path would miss it.
stream() is measured until the iteration ends
For a stream, the time spans from compilation to the last row consumed — which is
what answers "why is this page slow". rowCount carries how many rows came out.
Like onQuery, an error thrown inside onQueryEnd is swallowed: logging never breaks
the query.
Errors carry the failing SQL¶
When the driver rejects a statement, tempest-db-js throws QueryExecutionError —
with the SQL and params attached, instead of an opaque driver message:
import { QueryExecutionError, insert } from "tempest-db-js";
try {
session.execute(insert(User).values({ id: 1, name: "dup" }));
session.execute(insert(User).values({ id: 1, name: "dup" })); // duplicate PK
} catch (err) {
if (err instanceof QueryExecutionError) {
console.error(err.message); // includes "SQL: INSERT INTO ... params: [...]"
err.sql; // the exact SQL that failed
err.params; // the bound params, in order
err.cause; // the original driver error
}
}
The message carries a safe preview (long values truncated, blobs as
<N bytes>); the sql/params props hold the full content for you to log.
Server-side notices (onNotice)¶
PostgreSQL emits a NOTICE for perfectly ordinary things — CREATE TABLE IF NOT
EXISTS on a table that exists, DROP ... IF EXISTS on one that does not, a
constraint whose index it creates for you. Every migration runner hits this.
The postgres.js driver prints those notices with console.log by default, which
drops a nine-line object into your service's stdout, in the middle of its
structured log, on every boot. tempest-db-js silences them by default and
gives you the hook:
const engine = createEngine(url, {
onNotice: (notice) => logger.debug({ pg: notice }, "postgres notice"),
});
Silence is the default on purpose
Writing to the host process's stdout is the application's decision, not a
library's. Without onNotice the notice is dropped; with it, you choose the
level, the shape and the destination.
An error thrown inside onNotice is swallowed, like onQuery.
Driver options (driverOptions)¶
For what the typed layer does not model — postgres.js's connection, types,
transform, ssl, mysql2's own settings, node:sqlite's readOnly:
const engine = createEngine(url, {
pool: { size: 10 },
driverOptions: { ssl: "require", transform: { undefined: null } },
});
driverOptions is applied last and wins over everything the library derives
(pool and onNotice included) — it is an escape hatch, so it gets the last
word.
Why is this query slow? engine.explain¶
Timing says that it is slow; the plan says why. Instead of copying SQL out of a log into psql — and hoping the parameters match — wrap the block:
const report = await engine.explain(async (session) => {
await new BaseRepository(Order, session).paginate({ filters: { status: "open" }, page: 3 });
});
console.log(report.summary());
for (const plan of report.plans) {
console.log(plan.sql, plan.params, plan.summary());
}
The block gets a recording session: the plans come from the statements and the parameters the code actually used.
| PostgreSQL | SQLite | |
|---|---|---|
| Prefix | EXPLAIN (FORMAT JSON) |
EXPLAIN QUERY PLAN |
analyze: true |
EXPLAIN (FORMAT JSON, ANALYZE) |
error — it does not exist |
analyze: true executes the statement
That is how it measures. Which is why it is refused for anything that is not a read —
analyzing an UPDATE would apply it a second time. To explain only the reads of a
mixed block, pass { filter: isReadOnlyStatement }.
A development tool
The block runs and each statement gets an EXPLAIN afterwards — twice the round
trips. Use it in a test, in an investigation, on a debug endpoint; never on the hot
path.
Recap¶
createEngine(url, { onQuery })→ per-statement{ sql, params }hook.{ onNotice }→ server-side notices; without it, nothing is printed.- A throwing logger is swallowed — never breaks the query.
- Driver failure →
QueryExecutionErrorwithsql,params,cause. { driverOptions }passes through what the library does not model, applied last.