Skip to content
pre-alpha — the TypeScript API may still shift. The SQL won't.

Generated queries carry their identity to runtime as a name field, the camelCase function name matching the symbol you import. A single instrument(client, ...hooks) call routes that name to OpenTelemetry spans, Sentry errors, PostHog events, and Datadog metrics; the recipes below show one hook per destination.

sqlfu’s observability story mostly falls out of making query naming a first-class concept. The instrumentation itself is small: one instrument(client, ...hooks) wrapper and a couple of reference hooks.

Every .sql file you check in becomes a generated wrapper whose emitted SqlQuery carries its camelCased function name as name. For sql/list-profiles.sql you get:

// sql/.generated/list-profiles.sql.ts (generated - do not edit)
const query: SqlQuery = {sql: ListProfilesSql, args: [], name: 'listProfiles'};

Nested directories fold into the same camelCase (sql/users/list-profiles.sqlname: 'usersListProfiles', also the exported function name). Function names can’t collide because distinct file paths produce distinct names.

Ad-hoc SQL (via client.sql`...`) has no name, but you can pass one explicitly:

client.run({sql: 'select 1', args: [], name: 'healthCheck'});
import {instrument} from 'sqlfu';
const client = instrument(
baseClient,
instrument.otel({tracer: myOtelTracer}),
instrument.onError(({context, error}) => myErrorReportingService.report(error)),
);

instrument(client, ...hooks) wraps a client so every all / run flows through the hooks in order. Sync clients take sync hooks; async clients take async hooks:

type SyncQueryExecutionHook = <TResult>(args: {
context: QueryExecutionContext; // {query, operation, system}
execute: () => TResult; // call the next hook / the underlying adapter
}) => TResult;
type AsyncQueryExecutionHook = <TResult>(args: {
context: QueryExecutionContext;
execute: () => Promise<TResult>;
}) => Promise<TResult>;

For a sync client, use normal try / catch. For an async client, make the hook async and await execute() inside that same shape. Helper hooks that work with either client kind, like instrument.otel() and instrument.onError(), return a paired QueryExecutionHook:

type QueryExecutionHook = {
sync: SyncQueryExecutionHook;
async: AsyncQueryExecutionHook;
};

instrument.otel and instrument.onError are reference implementations. QueryExecutionHook is the stable contract, not these helpers. Copy their bodies and edit them if your team has different conventions.

Emits one OTel span per query with:

  • db.query.summary: your query.name, when present
  • db.query.text: the parameterized SQL (values are in args, not interpolated into the text)
  • db.system.name: the adapter’s system (e.g. sqlite)

On throw: records the exception as a span event and sets span status to ERROR.

The tracer parameter is typed structurally (TracerLike), so there’s no peer dependency on @opentelemetry/api. Pass any object with a startActiveSpan(name, fn) method. The real OTel Tracer satisfies this by construction.

Calls report({context, error}) whenever a query throws or its promise rejects, then always rethrows. Errors in the reporter itself are swallowed so they can’t mask the original error.

instrument.onError(({context, error}) => {
console.error(`query ${context.query.name || 'sql'} failed:`, error);
});

Every driver error is a SqlfuError with a normalized .kind discriminator (unique_violation, missing_table, syntax, and so on). That makes it a natural bucketing dimension in your error reporter (tags: {'db.error.kind': error.kind}).

The test files under packages/sqlfu/test/observability/ are the authoritative copy-paste source for each integration. They exercise the real SDKs against captured transports and are kept passing in CI.

OpenTelemetry (+ Honeycomb / Grafana Tempo / New Relic / Datadog APM / any OTLP backend)

Section titled “OpenTelemetry (+ Honeycomb / Grafana Tempo / New Relic / Datadog APM / any OTLP backend)”
import {trace} from '@opentelemetry/api';
import {instrument} from 'sqlfu';
const tracer = trace.getTracer('my-service');
const client = instrument(baseClient, instrument.otel({tracer}));

Every OTLP-over-HTTP backend works by swapping the exporter URL. For Datadog APM specifically, either point @opentelemetry/exporter-trace-otlp-http at Datadog’s OTLP intake, or pass dd-trace’s OTel-compatible tracer directly. Either way the instrument.otel line stays identical.

Full recipe with failing-query + composed error reporter: opentelemetry.test.ts.

import * as Sentry from '@sentry/node';
import {instrument} from 'sqlfu';
Sentry.init({dsn: process.env.SENTRY_DSN});
const client = instrument(baseClient,
instrument.onError(({context, error}) => {
Sentry.captureException(error, {
tags: {
'db.query.summary': context.query.name || 'sql',
'db.system.name': context.system,
},
extra: {'db.query.text': context.query.sql},
});
}),
);

Sentry groups errors by tag, so db.query.summary becomes the per-query bucket.

Full recipe: sentry.test.ts.

PostHog handles product-analytics events and error tracking in the same SDK, so one hook covers both. Success emits db_query with timing; failure emits the same event and additionally calls captureException:

import {PostHog} from 'posthog-node';
import {instrument} from 'sqlfu';
const posthog = new PostHog(process.env.POSTHOG_KEY!, {host: 'https://us.i.posthog.com'});
const client = instrument(baseClient,
({context, execute}) => {
const start = Date.now();
const distinctId = currentUserId();
const baseProps = {
'db.query.summary': context.query.name || 'sql',
'db.system.name': context.system,
operation: context.operation,
};
try {
const value = execute();
posthog.capture({
distinctId,
event: 'db_query',
properties: {...baseProps, duration_ms: Date.now() - start, outcome: 'success'},
});
return value;
} catch (error) {
posthog.capture({
distinctId,
event: 'db_query',
properties: {...baseProps, duration_ms: Date.now() - start, outcome: 'error'},
});
posthog.captureException(error, distinctId, {...baseProps, 'db.query.text': context.query.sql});
throw error;
}
},
);

PostHog doesn’t have a separate “metrics” API. Numeric properties on events are your metrics (avg(duration_ms) WHERE event = 'db_query').

Full recipe: posthog.test.ts.

For Datadog APM traces, use the OpenTelemetry recipe above with Datadog’s OTel-compatible tracer or OTLP intake. For metrics (query counts and timings grouped by db.query.summary), use DogStatsD:

import {StatsD} from 'hot-shots';
import {instrument} from 'sqlfu';
const statsd = new StatsD({host: 'localhost', port: 8125});
const client = instrument(
baseClient,
({context, execute}) => {
const start = Date.now();
const tags = [
`db.query.summary:${context.query.name || 'sql'}`,
`db.system.name:${context.system}`,
`operation:${context.operation}`,
];
try {
const value = execute();
statsd.timing('db.query.duration', Date.now() - start, tags);
statsd.increment('db.query.count', tags);
return value;
} catch (error) {
statsd.increment('db.query.count', [...tags, 'outcome:error']);
throw error;
}
},
);

Full recipe: datadog.test.ts.

client.raw(sql) is not uniquely identified. raw interpolates values into the SQL text, so per-call distinctness depends on parameter values rather than on a stable name. If you need named observability on dynamic SQL, assemble a SqlQuery directly:

client.run({sql, args, name: 'myQuery'});

iterate and transaction pass through unchanged. Queries issued inside a transaction still fire hooks because the tx client is re-instrumented on entry. Transactions themselves don’t get their own spans. If you want transaction-level spans, wrap client.transaction(...) calls yourself using your tracer.

Composition order is outer-to-inner. instrument(client, a, b, c) means a wraps b wraps c wraps the underlying call. If you put instrument.otel first, the OTel span covers everything including any error-reporter work. You can of course wrap yourself if you prefer:

const myClient = instrument(instrument(baseClient, innerHook), outerHook)

All available from sqlfu:

  • instrument: callable, plus .otel and .onError
  • SyncQueryExecutionHook, SyncQueryExecutionHookArgs, AsyncQueryExecutionHook, AsyncQueryExecutionHookArgs
  • QueryExecutionHook, QueryExecutionHookArgs, QueryExecutionContext, QueryOperation
  • QueryErrorReport
  • TracerLike, SpanLike