Reference

Observer events

QueryEvent, WriteEvent, SavepointEvent, and OutcomeEvent shapes for okmodel/observe.

Types are exported from okmodel/observe. How to attach observers is Observability. The contract is additive inside okm.observe/1. Postgres only in this train.

A process crash yields no event: outcomes are only those this process observed. Anything that must survive a crash is an outbox table plus a relay.

Vocabulary

TermMeaning
unitOpaque string id from the library (not the database). One tx(), one batch(), or one autocommit statement.
seq0-based per unit, in the order statements were sent. A rolled-back savepoint leaves a gap: the surviving writes keep the seq they had.
dialect"postgres".
opinsert | update | delete | upsert | archive | restore | raw.
rowCountModechanged | matched | returned | unknown.
rowCountnull exactly when the mode is unknown.
keysPrimary-key records for a table named in writeObserver({ keys }). Omitted otherwise.
sourceclient or raw.
tenantnull, or the scope keys and values.
settingsSetting names from with(), in stored order. Never values. [] when the client has no with().

No event carries row values or bound parameters. keys carries only the primary-key columns of a table that opted in.

Registry

Postgres: native. Cost: transaction | batch. Proof passes (proof:writeObserver.unit).

Postgres: native. Cost: returning. Proof passes (proof:writeObserver.keys).

Postgres: native. Cost: mixed. Proof passes (proof:writeObserver.rowCount).

QueryEvent

One event per call after it settles, reads and writes. The connection prelude (set_config), BEGIN, COMMIT, and RESET ALL are library statements. They are part of the call and are not reported as their own events. With no observer the client does not build an event and does not read the clock.

type QueryEvent = {
  id: string;
  unit?: string; // inside a transaction, a batch, or an autocommit write
  seq?: number;
  dialect: "postgres";
  table: string | null;
  op: string;
  summary: string; // library-built, low cardinality, e.g. "SELECT tasks"
  fingerprint: string; // plan.fingerprint, the value inspect() already reports
  text: string; // parameterised; unsanitisable text becomes summary
  durationMs: number;
  queueMs: number | null; // null: Postgres does not report pool-queue wait
  queryMs: number;
  decodeMs: number | null; // null for a write whose SQL has no RETURNING
  rowCount: number | null;
  rowCountMode: "changed" | "matched" | "returned" | "unknown";
  route: { endpoint: string; role: "primary" | "replica" };
  error?: { kind: string; category: string; code: string; sqlstate?: string };
  tag?: string;
  limit?: { value: number; source: string };
  params?: readonly unknown[]; // only when queryObserver({ params: true })
  stack?: string; // only when queryObserver({ stack: true })
};
FieldTypeNotes
idstringOpaque id for this event.
unitstring?Set inside a transaction, a batch, or an autocommit write unit. An autocommit read omits it.
seqnumber?0-based inside that unit. Query seq does not change WriteEvent.seq.
dialect"postgres"Only dialect in this train.
tablestring | nullSchema table name, or null for typed raw.
opstringCall name (find, insert, raw, …).
summarystringLow-cardinality label (SELECT tasks). SELECT, INSERT, UPDATE, DELETE, UPSERT, or SQL, plus the table when the statement has one.
fingerprintstringStable fingerprint; toOtel puts it on db.query.summary. A write uses the fingerprint of the parameterised text. A read uses plan.fingerprint.
textstringParameterised statement text. Literals become ?. A comment, a dollar quote, or an unbalanced quote is replaced by summary.
durationMsnumberWall time for the call.
queueMsnumber | nullAlways null on Postgres: the driver does not report pool-queue wait.
queryMsnumberTime in the driver.
decodeMsnumber | nullDecode time when measured. null when a write's SQL has no RETURNING.
rowCountnumber | nullnull when the mode is unknown. A read is returned when the driver reported a count.
rowCountModechanged | matched | returned | unknownFrom the driver command tag.
route{ endpoint, role }role is replica when the router sent a read to a replica. A read on the primary, inside a transaction, or on a client with no replicas is primary.
error{ kind, category, code, sqlstate? }?No message and no row values.
tagstring?From queryObserver({ tag }).
limit{ value, source }?Limit used and where it came from (caller, limits.find, limits.page, or limits.groupBy). descendants and ancestors use limits.find. A to-many include default is not on the event (.inspect() lists limits.include). one() does not report the internal limit 2.
paramsreadonly unknown[]?Only when params: true. Sensitive, hidden, and tenant values are [redacted].
stackstring?Only when stack: true.

params and stack are omitted unless the observer opts in. No event carries row values. Tenant ids are not a separate field on QueryEvent.

Postgres: native. Cost: query|decode. Proof passes (proof:queryObserver.timing).

WriteEvent

Delivered to onWrite for each surviving write in a unit. Values never appear: settings is names only.

type WriteEvent = {
  type: "write";
  unit: string;
  seq: number;
  dialect: "postgres";
  table: string | null;
  op: "insert" | "update" | "delete" | "upsert" | "archive" | "restore" | "raw";
  rowCount: number | null;
  rowCountMode: "changed" | "matched" | "returned" | "unknown";
  keys?: readonly Record<string, unknown>[]; // primary key, only when that table opted in
  tenant: Readonly<Record<string, unknown>> | null;
  settings: readonly string[]; // names only, never values
  durationMs: number;
  tag?: string;
  source: "client" | "raw";
};
FieldTypeNotes
type"write"Discriminator.
unitstringOpaque unit id.
seqnumber0-based per unit.
dialect"postgres"Only dialect in this train.
tablestring | nullSchema table, or null for typed raw.
opinsert | update | delete | upsert | archive | restore | raw
rowCountnumber | nullnull when the mode is unknown.
rowCountModechanged | matched | returned | unknown
keysreadonly Record&lt;string, unknown&gt;[]?Primary-key records when that table was named in writeObserver({ keys }).
tenantRecord&lt;string, unknown&gt; | nullScope keys and values, or null.
settingsreadonly string[]Setting names from with(), in stored order. Never values. [] without with().
durationMsnumberStatement time (send to result). When the driver runs several statements as one batch with no per-statement signal, each write in that batch shares the wall time of the whole batch.
tagstring?From writeObserver({ tag }), or trusted: &lt;reason&gt; under trusted().
source"client" | "raw"

sql(), inspect(), and describe() plan a write and do not emit an event. That plan path builds observe metadata with settings: [] — it does not receive the client's with() pairs.

A typed raw(db, sql, params) write emits source: "raw", op: "raw", and table: null. A typed raw read emits no write event. onQuery sees a typed raw read or write as op: "raw" and table: null.

keys names schema tables. For each of those tables the write planner appends RETURNING of the primary key when the statement is a simple INSERT, UPDATE, or DELETE. The extra columns show in sql() and inspect(). describe() labels caller columns "caller" and appended primary-key columns "observer". A successful statement that opted in and could return the key sets keys (an empty array when no row came back). A failure omits keys. A table with no primary key, or a name that is not in the schema, is OKM1890 at connect and again when the write is planned.

Statements whose RETURNING is not terminal (a parent guard, a versioned close, an archive CTE) are not rewritten. They fill keys only when the caller already returns every primary-key column. A sortable lock statement is not a key statement. A first-or-create lookup does not attach keys.

SavepointEvent

type SavepointEvent = {
  type: "savepoint";
  unit: string;
  name: string;
  state: "released" | "rolled_back";
  depth: number;
};
FieldTypeNotes
type"savepoint"Discriminator.
unitstringSame unit as the surrounding transaction.
namestringSavepoint name.
state"released" | "rolled_back"
depthnumber1 and up.

OutcomeEvent

type OutcomeEvent = {
  type: "outcome";
  unit: string;
  outcome: "committed" | "rolled_back" | "unknown";
  writes: number; // surviving savepoint rollbacks
  dbTxId?: string;
};
FieldTypeNotes
type"outcome"Discriminator. Last event of a unit.
unitstring
outcome"committed" | "rolled_back" | "unknown"unknown is a lost COMMIT (OKM1401).
writesnumberSurviving writes after savepoint rollbacks.
dbTxIdstring?When the driver reported one.

Delivery

  1. A unit's events are delivered after its COMMIT or ROLLBACK returns, in seq order, then the outcome event last. No statement-time delivery.
  2. Writes inside a rolled-back savepoint are removed from the unit's surviving writes before outcome; a savepoint event with rolled_back is emitted so tooling can follow. Those writes are never delivered.
  3. Outcome is tri-state: committed (COMMIT acknowledged), rolled_back (explicit rollback, or a failed statement that ended the unit), unknown (COMMIT sent and the connection lost: outcome_unknown, OKM1401). Consumers treat unknown as "maybe committed" and must be idempotent. The writes of an unknown unit keep the counts their statements reported before COMMIT. A connection lost before COMMIT is sent is rolled_back, never unknown.
  4. Autocommit: a single statement is a unit of one write; outcome follows its result.
  5. batch() on a driver with no per-statement signal: one unit, one outcome for the whole batch; seq still orders the statements.
  6. A process crash yields no event.
  7. No global order across units. Units are per transaction or batch, not per client: two units running at once on one client keep their own events, seq, and outcome.
  8. A handler that throws or rejects is caught and sent to onObserverError(err, event); it never reaches the write path and never changes an outcome. Handlers are not awaited on the write path; returned promises are tracked; await observer.flush() (from writeObserver()) resolves when all tracked handler promises have settled and never throws on handler errors. The helper owns flush.
  9. Backpressure: the buffer is bounded per client (default 10,000 pending events). See Buffer overflow.

writeObserver({ ... }) buffers per unit and calls exactly one of committed | rolledBack | unknown once per unit. A unit with zero surviving writes calls nothing. An observer cannot veto or alter a write. tag is 1–64 characters from [A-Za-z0-9_.:/-]. Anything else is invalid at helper construction.

Row counts

rowCount is the command-tag count the driver reported. A missing or null tag is unknown with rowCount: null. An upsert stays changed even when RETURNING ran. Every other op is returned when the statement's SQL contained RETURNING, otherwise changed.

ModeWhen
changedinsert, update, or delete whose SQL has no RETURNING. An upsert (ON CONFLICT) is always changed when a tag exists.
returnedAny other op whose SQL ran RETURNING, including primary-key RETURNING added for keys.
unknownThe driver reported no command tag. rowCount is null.
matchedReserved. Not emitted.

Buffer overflow

The per-client pending-event buffer defaults to 10,000. When it overflows, the oldest unit's events are dropped and onObserverError receives OKM2200. The write path never waits on that delivery. Raise maxPending on writeObserver, or drain handlers with flush() more often.

For onQuery, the pending ceiling also defaults to 10,000 in-flight promises. Past that, the handler is not called and onObserverError receives the same OKM2200.

toOtel

toOtel(event) returns { name, attributes } and imports nothing from OpenTelemetry. name is summary. Attributes use the same names as okmodel/otel:

AttributeValue
db.system.namepostgresql
db.namespacepublic
db.collection.nametable name
db.operation.nameop
db.query.summaryfingerprint
error.typeerror.kind, on failure
db.response.status_codeSQLSTATE, on failure when the server sent one
db.query.textsanitised text, only when toOtel(event, { statement: true })

Parameters, tenant ids, and stacks are never attributes.