Skip to content

feat: publish query, connection and transaction tracing channels (#73) - #192

Closed
okomarov wants to merge 34 commits into
mainfrom
feat/73-tracing-channels
Closed

okomarov wants to merge 34 commits into
mainfrom
feat/73-tracing-channels

Conversation

@okomarov

@okomarov okomarov commented Oct 24, 2026 •

Copy link
Copy Markdown

Closes #73.

What it adds

The driver publishes diagnostics_channel events, only while something subscribes. Names and fields follow the node-postgres tracing channels (brianc/node-postgres#3650); postgres:transaction and processID on the query client are additions.

Channel Type Context
postgres:query TracingChannel { query: { text, name }, client: { database, host, port, user, ssl, processID }, result?, error? }
postgres:connect TracingChannel { connection: { database, host, port, user, ssl }, error? }, one span per acquisition
postgres:pool:connect TracingChannel every wait for a connection: sql.reserve(), sql.begin() and plain pooled queries (a child of the query span, ending when the connection starts the query). { pool: { totalCount, idleCount, waitingCount, maxSize } }, then handoverTime, queuedBehind and client: { processID, reused } on success
postgres:transaction TracingChannel { client, options, savepoint, prepared, result?: { committed }, error? } for sql.begin() and sql.savepoint()
postgres:pool:release Channel { client: { processID }, error } when a reserved connection or a transaction gives its connection back
postgres:pool:remove Channel { client: { processID }, error } when a connection closes; error is undefined for a clean close

No payload contains parameter values. result and error keys appear only once set. A query that settles before its connection starts it fails the pool span first. query.text, query.name, the actual host, port, tls state and processID are filled in when the query is written to a connection.

start goes through start.runStores in the caller's synchronous context. The async events come from the query's own settlement rather than tracePromise, so cursors (both forms), forEach, COPY streams, describe(), simple(), unsafe(), file(), pipelined queries, retries and cancelled queries each settle exactly once. With no subscriber a query costs one hasSubscribers check and allocates nothing. resolve and reject in src/query.js stay single-expression arrows for dd-trace. The reference is the new "Tracing channels" section in docs/api.md.

How it was tested

  • tests/tracing.js runs in the Node and CJS suites against PostgreSQL: event order and context for each query path, transactions and savepoints, connect success, authentication failure and refusal, pool spans for leases and plain queries, pool releases, store propagation through bindStore, cancellation before await, retries, mid-cursor errors, pool ending, and no publishing without subscribers.
  • tests-startup/workerd-worker.js has a tracing-channels scenario at every compatibility date of the workerd matrix, and in the edge Preview.
  • Deno loads the module through node:diagnostics_channel; the new test file is left out of the Deno build.
  • A tracing job in test.yml runs npm run test:tracing on every pull request and gates the publish job: dd-trace and Sentry record the same spans as with Postgres.js 3.4.9.

APM and OpenTelemetry integrations had to patch the driver's source, which
breaks whenever the code shape moves and does not cover the fork's 4.x line.
The driver now publishes diagnostics_channel TracingChannels
postgres:query, postgres:connection and postgres:transaction, plus the plain
channels postgres:query:send and postgres:connection:close.

A query starts in Query.handle() through start.runStores in the caller's
synchronous context, so stores bound to the start channel are current for the
code that runs the query. The settlement events come from the query's own
resolve and reject, not from wrapping the Query in tracePromise: a cursor read
with for await never settles the Query itself, and the promise tracePromise
returns would reject unhandled. The cursor's replacement resolve and reject
publish too, and a COPY stream publishes when the copy is over, not when the
stream is handed out. Each query settles once, whatever path it takes.

The query text, parameters, connection id and server address are filled in
when the connection builds the query, because serializers depend on fetched
types. postgres:query:send is published at that point with the same context.
The connection span covers one acquisition, from the first attempt to the
hand-off or the failure that ends it. sql.begin() and sql.savepoint() publish
on the transaction channel.

With no subscriber a query costs one hasSubscribers check and allocates
nothing. resolve and reject stay single-expression arrows for dd-trace, and
the build, toBuffer and handler shapes it rewrites are unchanged. The Deno
build maps the module to node:diagnostics_channel and leaves the new test
file out, as Deno's std shim has neither.
The new tests subscribe to the channels and check the event order and the
context for a plain query, a failing one, cursors read with a callback and
with for await (to the end and on break), forEach, COPY in both directions,
describe, simple, unsafe, file, pipelined queries, a query cancelled before it
is sent, a reserved connection, a transaction with a savepoint and one that
rolls back, and a connection that succeeds, fails to authenticate and is
refused. They also check that a store bound to start is current where the
query and the transaction run, and that nothing is published while nothing
subscribes.

The workerd scenario runs at every compatibility date in the matrix. It
asserts that tracingChannel, runStores and bindStore exist there and that one
query publishes the connection and query events in order.
Adds the Tracing channels section to the API reference with the channel
list, the order of events, when each context field is set and a subscriber
example, and a line each in the README feature lists.
The channels rewrite code that dd-trace and Sentry hook into by shape, and
npm run test:tracing was only ever run by hand. It packs the fork, starts a
throwaway cluster and compares the spans against Postgres.js 3.4.9, so a shape
change that loses spans now fails a check instead of reaching a release.
…t is already settled

A transaction context was created with a result key set to undefined. Sentry
treats a result or error key present at end as a finished synchronous call and
closes the span there, so transaction and savepoint spans ended at once. The
key now appears only when the commit result is set.

A query cancelled before it was awaited was rejected without a trace, then
started one when awaited, and the driver dropped it before sending, so its
span never closed. A query that has already settled no longer starts a trace.

The connection and transaction contexts are now built by helpers in
tracing.js next to the query ones, so the payload field names live in one
source file.
A regression that loses dd-trace or Sentry spans should stop a publish like a
failing test does, so the publish job now waits for the tracing job.
…nels

Adds a check that no context carries a result or error key before it is set,
cancel-before-await with several connections, one connection and a pool that
must connect, a retried query, a server error and a throwing callback in the
middle of a cursor, cancellation from the queue of a reserved connection and
of a transaction, and queries rejected as the pool ends. The caller-context
test now runs the query inside a store of its own and asserts the bound
transform sees it.
…s published

asyncEnd also fires after an error, where result is absent, so the example reads
it with optional chaining. postgres:query:send is published only for queries that
had a postgres:query subscriber when they started.
…channels (#73)

The query, connect and pool payloads now use the field names of the
node-postgres tracing channels, so a subscriber written for one driver
reads the other with few changes.

postgres:query carries { query: { text, name }, client: { database, host,
port, user, ssl, processID } }. Parameter values are no longer published.
The client holds the configured values at start and the actual host, port,
tls state and backend pid once the query is written to a connection.
postgres:connection is now postgres:connect with { connection: { database,
host, port, user, ssl } }, and postgres:connection:close is now
postgres:pool:remove with { client: { processID }, error }.

postgres:pool:connect traces every lease acquisition (sql.reserve() and
sql.begin()) with the pool counts at start and { processID, reused } on
success. Its failures come from the place the lease already settles.
postgres:pool:release is published when a lease gives its connection back,
with the error the lease ended with: the failure that ended a transaction
before its connection was released, or the error that closed the
connection. A commit the server answered with a rollback is reported after
the release, so the release carries no error then.

postgres:transaction uses the same client object.
The existing tests read the node-postgres style fields. New tests cover
postgres:pool:connect with the pool counts at start and reused false then
true, a checkout that fails (refused connection, ended pool) with no client,
postgres:pool:release for a reserved connection (once, with the checkout's
process id) and for transactions that commit and that reject, a named
prepared statement, and the pool channels staying silent without a
subscriber. The workerd scenario reads the renamed connect channel.
…nels

Lists the tracing and plain channels, shows how they fit together, and gives
one field table per channel with when each field is set.
)

A plain pooled query now publishes postgres:pool:connect as a child of its
query span. The span starts when the pool routes the query and ends when the
connection makes it the active query, whether the connection was idle or the
query had to wait behind others, so postgres:connect for a connection the
routing opens nests inside it.

When a connection takes the query over it records handoverTime (epoch
milliseconds), queuedBehind (the queries already running or waiting on that
connection) and the client with reused false for a query that opened its
connection. handoverTime minus the start time is the wait for a connection,
and the rest is time behind other queries. A lease records the same fields
with queuedBehind 0.

A query that settles while its pool wait is still open fails the wait first,
from the query's own settlement, so ending the pool, a copy in progress,
cancellation, a failed connection attempt and a connection lost behind other
queries all end the span exactly once, before the query's asyncEnd.

postgres:query:send is removed: the pool span now shows the wait, and the
query context is still filled in when the query is built.
A query refused while it was being routed (the pool is ending, or a COPY holds
every connection) was rejected inside the pool span's run, so the span
published its error before its end. The routing now returns the refusal and
the handler rejects after run, which gives the order of tracePromise.
…checks

Checks the pool span for a query on an idle connection, on a connection it
opens (connect nested in the pool span through a bound store), sent ahead to a
busy connection (it ends after the earlier query's result), and drained from
the pool queue. Failures before the query starts are covered for an ending
pool, a COPY in progress, cancellation in the pool queue, a refused
connection, and a connection lost while the query waited behind others. Each
span settles once and before its query, and nothing is published when only
one of the query and pool channels has a subscriber.
…nnel

pool:connect now covers every wait for a connection. The page explains how
handoverTime and queuedBehind split the wait, notes the coarse clock on
Cloudflare Workers, and shows an OpenTelemetry span event at the handover.
…on is idle

A query that finds an idle connection becomes active inside the routing, so the
pool span settled before it published end. The settlement is held until end
has been published, which gives the order of tracePromise.
…s routed

A query that failed to build while the pool routed it onto an idle connection
settled its pool span twice, the second time with no context, and a build
failure on a busy connection published the pool span's error before its end.
While the routing is in progress the query's failure is now held, and after the
span's end the pool span is resolved if the query was activated or failed
otherwise, followed by the query's own failure. The state of the wait lives on
a private object, so an untraced query carries only trace and poolWait.

A lease now reports the queries its connection really had in flight, and a
queued query that a reconnect hands to connect() counts as having opened the
connection.
… on leases

Adds the build-failure cases on an idle and on a pipelining connection with
subscribers that dereference the context, a lease taken on a busy connection,
and statements inside begin and reserve publishing no plain pool span. The
lost-connection test now checks the pool span ends before its query, and the
tests that raced a delay against a sleeping query wait for the routing with
setImmediate instead.
handoverTime, queuedBehind and client are set once a connection took the call
over, which also covers a query that fails after that. The OpenTelemetry
example skips waits that never had a hand-over.
…the driver module asks for it

The edge worker reads an optional subscribe export of the generated driver
module and subscribes at module scope, with an AsyncLocalStorage bound to each
tracing channel's start so runStores does real work. Without the export
nothing changes.
ab_subscribe (none, query, all) makes prepare export the subscription mode to
the worker. The base copy's tracing module gets its own channel names, so the
no-op subscribers reach only the head build even when both are this branch;
with none nothing is rewritten and the job behaves as before.
…by a reconnect

A build error for a query routed onto a connection failed the pool span when
the query was pipelined behind others but resolved it on an idle or fresh
connection. Once a connection took the query over the pool span now resolves
whatever the route, so a build error fails only the query span.

A queued lease that a closed connection's reconnect served reported reused
true. connect() now flags the lease waiter and the acquisition reads the flag.
…ind the initial query

The busy-connection build failure now expects a resolved pool span. The
reconnect tests count the spans reporting reused false: the lease one is 0
without the flag on the waiter. The lease behind the initial query reports
queuedBehind 1; it was 0 before leases read the connection's in-flight count.
…used means

A lease span ends a microtask after the hand-over, so the time behind the
queries ahead of it falls inside its first statement. reused is false only for
the call that had to open the connection.
@okomarov

Copy link
Copy Markdown
Author

Superseded by #196, which rebuilds the tracing channels on the request lifecycle restructure.

@okomarov okomarov closed this Oct 26, 2026
@okomarov
okomarov deleted the feat/73-tracing-channels branch October 26, 2026 12:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish driver events through diagnostics_channel

1 participant