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
| Term | Meaning |
|---|---|
unit | Opaque string id from the library (not the database). One tx(), one batch(), or one autocommit statement. |
seq | 0-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". |
op | insert | update | delete | upsert | archive | restore | raw. |
rowCountMode | changed | matched | returned | unknown. |
rowCount | null exactly when the mode is unknown. |
keys | Primary-key records for a table named in writeObserver({ keys }). Omitted otherwise. |
source | client or raw. |
tenant | null, or the scope keys and values. |
settings | Setting 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 })
};| Field | Type | Notes |
|---|---|---|
id | string | Opaque id for this event. |
unit | string? | Set inside a transaction, a batch, or an autocommit write unit. An autocommit read omits it. |
seq | number? | 0-based inside that unit. Query seq does not change WriteEvent.seq. |
dialect | "postgres" | Only dialect in this train. |
table | string | null | Schema table name, or null for typed raw. |
op | string | Call name (find, insert, raw, …). |
summary | string | Low-cardinality label (SELECT tasks). SELECT, INSERT, UPDATE, DELETE, UPSERT, or SQL, plus the table when the statement has one. |
fingerprint | string | Stable fingerprint; toOtel puts it on db.query.summary. A write uses the fingerprint of the parameterised text. A read uses plan.fingerprint. |
text | string | Parameterised statement text. Literals become ?. A comment, a dollar quote, or an unbalanced quote is replaced by summary. |
durationMs | number | Wall time for the call. |
queueMs | number | null | Always null on Postgres: the driver does not report pool-queue wait. |
queryMs | number | Time in the driver. |
decodeMs | number | null | Decode time when measured. null when a write's SQL has no RETURNING. |
rowCount | number | null | null when the mode is unknown. A read is returned when the driver reported a count. |
rowCountMode | changed | matched | returned | unknown | From 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. |
tag | string? | 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. |
params | readonly unknown[]? | Only when params: true. Sensitive, hidden, and tenant values are [redacted]. |
stack | string? | 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";
};| Field | Type | Notes |
|---|---|---|
type | "write" | Discriminator. |
unit | string | Opaque unit id. |
seq | number | 0-based per unit. |
dialect | "postgres" | Only dialect in this train. |
table | string | null | Schema table, or null for typed raw. |
op | insert | update | delete | upsert | archive | restore | raw | |
rowCount | number | null | null when the mode is unknown. |
rowCountMode | changed | matched | returned | unknown | |
keys | readonly Record<string, unknown>[]? | Primary-key records when that table was named in writeObserver({ keys }). |
tenant | Record<string, unknown> | null | Scope keys and values, or null. |
settings | readonly string[] | Setting names from with(), in stored order. Never values. [] without with(). |
durationMs | number | Statement 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. |
tag | string? | From writeObserver({ tag }), or trusted: <reason> 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;
};| Field | Type | Notes |
|---|---|---|
type | "savepoint" | Discriminator. |
unit | string | Same unit as the surrounding transaction. |
name | string | Savepoint name. |
state | "released" | "rolled_back" | |
depth | number | 1 and up. |
OutcomeEvent
type OutcomeEvent = {
type: "outcome";
unit: string;
outcome: "committed" | "rolled_back" | "unknown";
writes: number; // surviving savepoint rollbacks
dbTxId?: string;
};| Field | Type | Notes |
|---|---|---|
type | "outcome" | Discriminator. Last event of a unit. |
unit | string | |
outcome | "committed" | "rolled_back" | "unknown" | unknown is a lost COMMIT (OKM1401). |
writes | number | Surviving writes after savepoint rollbacks. |
dbTxId | string? | When the driver reported one. |
Delivery
- A unit's events are delivered after its COMMIT or ROLLBACK returns, in
seqorder, then theoutcomeevent last. No statement-time delivery. - Writes inside a rolled-back savepoint are removed from the unit's surviving writes before
outcome; asavepointevent withrolled_backis emitted so tooling can follow. Those writes are never delivered. - 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 treatunknownas "maybe committed" and must be idempotent. The writes of anunknownunit keep the counts their statements reported before COMMIT. A connection lost before COMMIT is sent isrolled_back, neverunknown. - Autocommit: a single statement is a unit of one write;
outcomefollows its result. batch()on a driver with no per-statement signal: one unit, one outcome for the whole batch;seqstill orders the statements.- A process crash yields no event.
- 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. - 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()(fromwriteObserver()) resolves when all tracked handler promises have settled and never throws on handler errors. The helper ownsflush. - 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.
| Mode | When |
|---|---|
changed | insert, update, or delete whose SQL has no RETURNING. An upsert (ON CONFLICT) is always changed when a tag exists. |
returned | Any other op whose SQL ran RETURNING, including primary-key RETURNING added for keys. |
unknown | The driver reported no command tag. rowCount is null. |
matched | Reserved. 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:
| Attribute | Value |
|---|---|
db.system.name | postgresql |
db.namespace | public |
db.collection.name | table name |
db.operation.name | op |
db.query.summary | fingerprint |
error.type | error.kind, on failure |
db.response.status_code | SQLSTATE, on failure when the server sent one |
db.query.text | sanitised text, only when toOtel(event, { statement: true }) |
Parameters, tenant ids, and stacks are never attributes.