Observability
Turbine reports every statement it runs through query events ($on) and every line it logs through one logger. Optional per-minute metrics ($observe) feed a local dashboard, and an OpenTelemetry recipe builds traces from the same hooks. None of it needs an extra dependency.
Query events, db.$on('query')#
Every statement emits an event after it runs, whether it succeeded or failed. Unsubscribe with $off.
db.$on('query', (event) => {
if (event.duration > 200) {
console.warn(`slow: ${event.model}.${event.action} took ${event.duration.toFixed(1)}ms`);
}
});interface QueryEvent {
sql: string; // the generated SQL text
params: unknown[]; // bound parameter values (redacted by default, see below)
duration: number; // wall-clock ms (performance.now)
model: string; // table name, e.g. 'users'
action: string; // operation, e.g. 'findMany'
rows: number; // rows returned / affected
timestamp: Date;
error?: Error; // present when the query failed
strategy?: 'auto-batched'; // the 'auto' relation strategy re-planned this query
tag?: string; // the enclosing db.$tag() label, see below
batch?: 'pipeline' | 'transaction'; // ran inside pipeline() or $transaction([...])
comment?: string; // the sqlComment it was sent with; sql stays commentless
retried?: true; // the failed first attempt of a read Turbine re-sent
}- A listener that throws never fails the query. With
logging: true, the error is logged asquery-listener-error. $onis a passive tap. Middleware wraps a call and can transform its result.
What emits an event#
| Entry point | model | action |
|---|---|---|
db.users.findMany() and every other model method | the table, e.g. 'users' | the method, e.g. 'findMany' |
db.raw and tx.raw tagged templates | '$raw' | 'raw' |
db.sql tagged template (await, .one(), .scalar()) | '$raw' | 'sql' |
tx.rawQuery(text, params) | '$raw' | 'rawQuery' |
prisma-compat $queryRaw, $executeRaw and the Unsafe forms | '$raw' | the method name, e.g. '$queryRaw' |
each statement of db.pipeline(...) | the statement's table | its method, with batch: 'pipeline' |
each statement of db.$transaction([...]) | the statement's table | its method, with batch: 'transaction' |
'$raw'is exported asRAW_QUERY_MODEL.BEGIN,COMMITand savepoints emit no event.- A pipeline sends its statements in one round trip, so each event's
durationis an even share of the batch time. - A read that Turbine re-sent after a lost connection emits two events: the failed attempt with
retried: true, then the retry.
Attributing queries to a feature, db.$tag()#
model and action say what ran, not why. Wrap a code path in $tag and every event inside it carries the label:
const order = await db.$tag('checkout', async () => {
const cart = await db.carts.findUnique({ where: { id: cartId } });
await db.raw`SELECT pg_advisory_xact_lock(${cart.id})`;
return db.orders.create({ data: { cartId: cart.id } });
});
// all three events carry tag: 'checkout'- The scope follows async context, so awaited helpers, transactions and concurrent requests keep their own tags. An inner
$tagreplaces the outer one. $tagreturns what the callback returns.- A label is 1 to 128 characters (
MAX_QUERY_TAG_LENGTH), or it throwsValidationError(E003). - Name a code path, not a request: a label lands on every event, and with
sqlComment.tagin the SQL too.
Seeing the parameter values, logQueryParams#
Params are redacted by default: each entry of event.params is '[REDACTED]'. Turn real values on with logQueryParams:
const db = turbine({
connectionString: process.env.DATABASE_URL,
logQueryParams: process.env.NODE_ENV !== 'production',
});
db.$on('query', (e) => console.log(e.sql, e.params));
// SELECT * FROM "users" WHERE "email" = $1 LIMIT 1 [ 'alice@example.com' ]Only event.params changes. When logQueryParams is unset, errorMessages: 'verbose' also reveals params.
Log output, logger#
Every line Turbine writes goes through one logger, console by default, with each line prefixed [turbine]. Pass your own:
import type { TurbineLogCode, TurbineLogEntry, TurbineLogger } from 'turbine-orm';
const logger: TurbineLogger = {
warn(message: string, entry: TurbineLogEntry) {
if ((entry.code as TurbineLogCode) === 'unordered-page') return; // known and accepted
appLog.warn(message, entry);
},
error: (message, entry) => appLog.error(message, entry),
};
const db = turbine({ connectionString: process.env.DATABASE_URL, logger });The entry carries a stable code, plus table, operation or error when relevant. Match on code; the message text can change.
debug,info,warnanderrorare all optional. A missing method drops that level, sologger: {}silences Turbine.- A logger method that throws is ignored.
consoleand winston take the message first and work as is. pino and bunyan take the object first, so wrap them:warn: (message, entry) => pinoLog.warn(entry, message).- No log line carries a bound value, whatever
logQueryParamssays. - A once-per-process warning is deduplicated across clients.
logging: trueadds lifecycle lines atdebuglevel.introspect()andgenerate()take the sameloggeroption.
Log codes#
| Codes | Level | When |
|---|---|---|
unknown-config-key, plan-cache-mode-ignored, prepared-statements-on-pooler, connection-option-ignored, parser-overwrite | warn | Client construction, once per process. Always on. |
pool-error | error | A pool connection failed with no $on('poolError') listener. Always on. |
query-listener-error, pool-listener-error, replica-close-error | error | A listener threw, or a replica pool failed to close. With logging: true. |
external-pool, pool-created, replicas-configured, connection-verified, pipeline, raw-sql, typed-sql, transaction-committed, transaction-rolled-back, listen, notify, disconnect-skipped, pool-disconnected | debug | Lifecycle. With logging: true. |
listen-reconnecting, listen-lost | warn, error | A $listen connection is reconnecting, or lost for good. Not logged when you pass onError. |
untyped-columns, unordered-page, auto-strategy, deep-with, flatten-blocked, redundant-sort-term, unknown-query-option, unindexed-relation, flatten-fallback, relation-payload-json | warn | Per-query advice, each once. While diagnostics advice is on. |
unlimited-find-many | warn | A findMany with no limit, when warnOnUnlimited is on. |
temporal-infinity | warn | A stored infinity was read while temporalInfinity is unset. |
cache-mismatch | error | The sampled SQL-cache self-check found a mismatch; the query throws InvariantError. |
stream-idle-timeout | warn | A cursor stream sat idle past its timeout and was closed. |
sql-comment-dropped | warn | A sqlComment.dynamic key or value was left out. |
prisma-compat-unknown-option, prisma-compat-native-spelling | warn | prisma-compat option advice. While diagnostics advice is on. |
prisma-map-stale, prisma-compat-junction-collision | warn | The prisma-compat map is out of date, or two junction models claim one table. |
powdb-link-collision, powdb-link-drift, powdb-introspect-no-primary-key | warn | PowDB link DDL and introspection. |
introspect-cross-schema-fk, introspect-relation-renamed, introspect-relation-shadows-json, generate-relation-shadows-column | warn | Introspection and code generation. |
A row that names no condition logs in every mode. Every engine, PowDB included, logs through the client's logger.
Diagnostics, diagnostics#
diagnostics controls per-query advice and a self-check of Turbine's SQL cache.
const db = turbine({
connectionString: process.env.DATABASE_URL,
diagnostics: 'production',
});| Mode | Advice | Cache self-check |
|---|---|---|
'development' | on | every cache hit |
'production' | off | off |
When unset, the mode is 'production' if NODE_ENV is 'production' or there is no process (an edge runtime), else 'development'. It is read once, at construction. Set it explicitly where NODE_ENV is unset, such as a worker, or that runtime runs in development mode.
An object overrides one part:
diagnostics: { mode: 'production', advice: true }
diagnostics: { mode: 'production', cacheCheck: { sample: 0.001 } }cacheCheckis'every-hit','off'or{ sample }withsamplein(0, 1]. A mismatch is a Turbine bug: the query throwsInvariantErrorbefore anything is sent.- An invalid mode, key or sample throws
ValidationError(E003) at construction. - When
cacheCheckis unset,TURBINE_DISABLE_CACHE_CHECK=1turns the check off, andTURBINE_CACHE_CHECK_SAMPLEsets a sample rate in production mode. - Construction warnings (a misspelled config key, prepared statements behind a pooler) fire in every mode.
SQL comments, sqlComment#
sqlComment appends sqlcommenter key/values to each statement, so pg_stat_activity, server logs and database APM tools can tell which service, model or feature sent it.
const db = turbine({
connectionString: process.env.DATABASE_URL,
sqlComment: {
static: { application: 'billing' }, // the same pairs on every statement
model: true, // the table and the method
tag: true, // the label of the innermost db.$tag()
},
});
await db.$tag('checkout', () => db.users.findUnique({ where: { id: 1 } }));SELECT "users".* FROM "users" WHERE "id" = $1 LIMIT 1 /*action='findUnique',application='billing',model='users',tag='checkout'*/| Option | Adds |
|---|---|
static | The same key/values on every statement. |
model | model (the table) and action (the method). |
tag | tag, the innermost db.$tag() label, when there is one. |
dynamic | A function called per statement in your async context, returning key/values or undefined. See Carrying the trace into the database. |
- Values are URL-encoded, so no value can end the comment. Keys are lower-case letters, digits and underscores, starting with a letter.
- A bad
staticvalue, a value over 256 characters or an unknown option throwsValidationError(E003) at construction. A baddynamicvalue is left out and logged once assql-comment-dropped; the query still runs. - Model-method statements, pipelines and transaction batches carry comments. Raw SQL and transaction control do not.
- Events keep
sqlwithout the comment and put it ine.comment. Cache hit rates do not change. - A statement carrying any
dynamicvalue is sent unnamed, so it is parsed and planned every time.static,modelandtagcomments keep prepared statements. dynamicis refused on MySQL (UnsupportedFeatureError, E017), andsqlCommenton PowDB.
Metrics, db.$observe()#
$observe aggregates query events per minute and writes them to a _turbine_metrics table. Point it at a separate database:
const handle = await db.$observe({
connectionString: process.env.TURBINE_OBSERVE_URL!, // metrics DB
flushIntervalMs: 60_000, // default: 60s
retentionDays: 30, // default: 30
});
// on shutdown
await handle.stop(); // final flush, then closes the metrics pool- Each flush writes count, avg, p50, p95, p99 and errors per
model:actionand minute. Rows merge additively, so several app instances can share a table. - It uses its own one-connection pool and creates the table on start. Rows older than
retentionDaysare pruned on each flush. - Flush errors are swallowed, and the timer does not keep the process alive.
- Calling
$observeagain replaces the previous one. - A batch holds only model, action, counts and latencies: no SQL text and no parameter values. Nothing is collected until you call
$observe.
CREATE TABLE IF NOT EXISTS _turbine_metrics (
id BIGSERIAL PRIMARY KEY,
bucket TIMESTAMPTZ NOT NULL, -- minute bucket
model TEXT NOT NULL,
action TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
avg_ms REAL NOT NULL DEFAULT 0,
p50_ms REAL NOT NULL DEFAULT 0,
p95_ms REAL NOT NULL DEFAULT 0,
p99_ms REAL NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(bucket, model, action)
);Custom sinks#
Pass a sink to send the aggregates elsewhere. With a sink, connectionString is optional.
interface MetricsFlushBatch {
rows: Array<{
bucket: Date; // the minute bucket
model: string; // table name
action: string; // operation
count: number;
avg: number;
p50: number;
p95: number;
p99: number;
errors: number;
}>;
}
interface ObserveSink {
init?(): Promise<void>; // once at startup
flush(batch: MetricsFlushBatch): Promise<void>; // each aggregate batch
stop?(): Promise<void>; // on shutdown
}HttpJsonSink POSTs each batch as JSON to an HTTP endpoint. A failed request drops that batch without retrying.
import { HttpJsonSink } from 'turbine-orm';
const handle = await db.$observe({
sink: new HttpJsonSink({
url: 'https://metrics.internal/ingest',
headers: { authorization: `Bearer ${process.env.METRICS_TOKEN}` },
// fetchFunction: customFetch, // optional override
}),
});Dashboard, npx turbine observe#
A local, read-only dashboard over _turbine_metrics: latency over time and a breakdown per model.action.
npx turbine observe --metrics-url postgres://...
TURBINE_OBSERVE_URL=postgres://... npx turbine observe --port 5000 --no-openIt binds 127.0.0.1:4984 and reads metrics only, never your application database. It shares Studio's security model: a non-loopback host needs --allow-remote, the printed URL holds a one-time ticket, and a foreign Host header gets 403.
OpenTelemetry#
Turbine imports nothing from @opentelemetry/*. $use opens a span per operation and $on('query') records a span per SQL statement. Install @opentelemetry/api and an SDK; the recipe needs the context manager the SDK registers.
import { context, trace, SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { RAW_QUERY_MODEL } from 'turbine-orm';
import { db } from './db.js';
const DB_SYSTEM = 'postgresql'; // 'mysql', 'sqlite' or 'microsoft.sql_server' on the other engines
const tracer = trace.getTracer('turbine-orm');
// One span per Turbine operation. The statements it runs become its children.
db.$use((params, next) =>
tracer.startActiveSpan(`${params.model}.${params.action}`, { kind: SpanKind.INTERNAL }, async (span) => {
try {
return await next(params);
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
}),
);
// One span per SQL statement, back-dated from the event's duration.
db.$on('query', (e) => {
const end = e.timestamp.getTime();
const table = e.model === RAW_QUERY_MODEL ? undefined : e.model;
const span = tracer.startSpan(
table ? `${e.action} ${table}` : e.action,
{
kind: SpanKind.CLIENT,
startTime: end - e.duration,
attributes: {
'db.system.name': DB_SYSTEM,
'db.operation.name': e.action,
'db.query.text': e.sql,
...(table ? { 'db.collection.name': table } : {}),
...(e.tag ? { 'turbine.tag': e.tag } : {}),
...(e.batch ? { 'turbine.batch': e.batch } : {}),
...(e.retried ? { 'turbine.retried': true } : {}),
},
},
context.active(),
);
if (e.error) {
span.setAttribute('error.type', e.error.name);
span.setStatus({ code: SpanStatusCode.ERROR, message: e.error.message });
}
span.end(end);
});A request that creates an order produces this tree:
POST /orders your span
orders.create INTERNAL, from $use
create orders CLIENT, from $on('query')The statement span is CLIENT and the operation span INTERNAL, so a backend counts each database call once. The operation span is wider: it also covers SQL generation and row parsing.
Why the tree comes out right#
A query event is delivered in the async context of the code that awaited the statement, so context.active() in the listener is the span active at that await. You pass nothing through Turbine.
$use wraps model methods only. These statements get no operation span and nest directly under whatever span is active where you await them:
| Statement | db.operation.name | turbine.batch |
|---|---|---|
db.pipeline(...) | each statement's method | pipeline |
db.$transaction([...]) | each statement's method | transaction |
findManyStream, findManyStreamBatches | the stream method | none |
db.raw, db.sql, db.rawUnsafe, tx.raw, tx.rawQuery | the entry point, such as raw or sql | none |
- A lazy
db.sqlquery runs where it is awaited, not where it was built. - A transaction adds no span of its own. Wrap it in your own span if you want one.
- With
relationLoadStrategy: 'batched'or the'auto'fallback, every follow-up statement is a child of the one operation span. Take the table fromdb.query.text, since a follow-up carries its parent query's model.
What the attributes mean#
The db.* and error.type names follow the stable OpenTelemetry database semantic conventions (v1.33.0 and later, where db.system became db.system.name and db.statement became db.query.text). Pin the version your backend expects. The turbine.* attributes are Turbine's own.
| Attribute | Value |
|---|---|
db.system.name | The DB_SYSTEM you set for your engine. |
db.operation.name | The Turbine method, such as findMany, create or raw. |
db.collection.name | The table. Absent for raw SQL. |
db.query.text | The SQL text with placeholders ($1, $2). |
error.type | The error class name, such as UniqueConstraintError. Set on failure only. |
turbine.tag | The enclosing db.$tag() label. |
turbine.batch | pipeline or transaction for statements that ran as part of a batch. |
turbine.retried | true on the failed first attempt of a read that Turbine re-sent after the server had closed its connection. |
- Duration covers the driver round trip and the wait for a pooled connection. Each span ends at the event's
timestamp. - Pipeline spans share the batch time evenly; see What emits an event.
- Parameter values never reach
db.query.text, except withdb.rawUnsafe, where you write the text. The recipe does not recorde.params; the conventions treatdb.query.parameter.<key>as opt-in. - A retried read shows as two statement spans. Alert on the operation span to count failed calls, not attempts.
recordExceptionsends the error's message and stack to your backend. SeeerrorMessages.
Carrying the trace into the database#
To link a statement in pg_stat_activity or the server log back to its trace, add the active span's W3C traceparent as a sqlComment:
import { trace, TraceFlags } from '@opentelemetry/api';
import type { SqlCommentOptions } from 'turbine-orm';
const sqlComment: SqlCommentOptions = {
dynamic: () => {
const ctx = trace.getActiveSpan()?.spanContext();
// Unsampled: add nothing, so the statement keeps its prepared statement.
if (!ctx || (ctx.traceFlags & TraceFlags.SAMPLED) === 0) return undefined;
return { traceparent: `00-${ctx.traceId}-${ctx.spanId}-${ctx.traceFlags.toString(16).padStart(2, '0')}` };
},
};
const db = turbine({ sqlComment });SELECT "orders".* FROM "orders" WHERE "id" = $1 LIMIT 1 /*traceparent='00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'*/- A sampled statement is sent unnamed and planned every time, which costs tens of microseconds per statement. Returning
undefinedfor unsampled spans keeps the rest prepared. db.query.textand the event'ssqlstay commentless.pg_stat_statementskeeps one row per statement, since a comment is not part of its query id.- Raw SQL carries no comment,
dynamicis refused on MySQL, andsqlCommenton PowDB.
See also#
- Studio: the local database UI with the same security model.
- Middleware: the active counterpart to
$on. - Typed Errors: what lands in
event.error.