ADR-033: DB wrapper — per-query via WrapDSN, stats-only via WrapDB
Status: Accepted Date: 2026-04-21
Context
Phase 2c of the observability rollout adds a database/sql wrapper to
gyrum-go/pkg/observ.
The Phase 1 library already ships the JSON logger, redactor, error
builder, and the observ-level ambient tags (service, version,
commit, release_track). Phase 2a adds HTTP access middleware and
route-template propagation; Phase 2b adds metrics. Phase 2c adds the
matching DB-layer signals: per-query latency, error classification, and
connection-pool stats.
There are two shapes the wrapper could take:
WrapDB(db *sql.DB, name string)— stats-only. The caller passes an already-open*sql.DB; we polldb.Stats()on a ticker and log the pool numbers. Simple; no driver surgery. But*sql.DBoffers no public hook to observe queries — its internal connection pool hands outdriver.Conninstances with no interception point, andDB.Query/Execbypass anything a caller could decorate on the way in. So the per-query log story ends atdb.Stats().WrapDSN(driverName, dsn, name)— full per-query. We own the open, so we can route through a wrappeddriver.Connector(sql.OpenDB), which gives us everyQueryerContext/ExecerContextcall the pool dispatches. Every query, exec, and prepare is timed with latency and error category.
The library cannot assume one shape is always possible: consumers often
open their *sql.DB inside a framework or a migration library (goose,
golang-migrate, ORM), and at that point the driver stack is frozen and
a per-query wrapper is no longer achievable without forking the
framework. The library also cannot assume WrapDSN is sufficient: many
services already have an open *sql.DB in hand and still want pool
stats on the operator dashboard.
Decision
Ship both, with WrapDSN as the primary constructor and WrapDB
as a named stats-only fallback. The signatures make the asymmetry
visible at the call site.
// Primary: full per-query + pool stats.
func WrapDSN(driverName, dsn, name string) (*sql.DB, func() error, error)
// Fallback: pool stats only (driver stack is frozen by caller's sql.Open).
func WrapDB(db *sql.DB, name string) (*sql.DB, func() error)
Key implementation choices:
sql.OpenDBwith a per-DBdriver.Connector, notsql.Register. The old trick of registering a wrapped driver under a new name ("postgres-observ") survives today in several OSS wrappers but has two gotchas: the registration is global and irreversible, and thedb_nametag has to be threaded through the DSN (or a package-global fallback) to make it back to the per-operation log line. Adriver.Connectorcarries the wrapped state per-open-call and requires no registry mutation. This is the modern (Go 1.10+) idiom.No
Stmt-layer wrapping. TheConn-layerQueryerContext/ExecerContextpath covers every production-grade driver we ship (modernc.org/sqlite,lib/pq,pgx,mysql,go-sqlite3). Adding aStmtdecorator would double the surface without adding signal, and the redundant second timing path would make the "where did the latency come from" answer ambiguous (prepare-then-execute vs one-shot exec). A driver that only implements legacydriver.Conn.Prepare+driver.Stmtreturnsdriver.ErrSkipfrom the wrappedQueryContext, anddatabase/sqlfalls back to its own un-instrumented path — no crash, less visibility. We document this in the package doc.Three log messages, not one.
db.query(DEBUG, success),db.query.slow(WARN, success ≥OBSERV_DB_SLOW_MS), anddb.query.error(ERROR, witherr_category=upstream_timeoutforctx.Canceled/DeadlineExceeded, elsedb_error). Splitting by log level rather than a single "event" message makes the slow-query dashboard a plaincount by (service) ({msg="db.query.slow"})Loki query and the error breakdown a plaincount by (err_category) ({msg="db.query.error"}). One message with aseverityfield would collapse into a messier query shape.Pool stats is a shared detail. Both
WrapDSNandWrapDBstart a goroutine that emitsdb.pool.statsat DEBUG everyOBSERV_DB_STATS_MS(default 30s). The returned closer is idempotent and stops the goroutine cleanly;WrapDSN's closer also closes the underlying*sql.DB, whereasWrapDB's does not (ownership stays with whoever opened it).WrapDSNlogs are routed throughslog.Default(), notobserv.Logger(). The DB wrapper does not care which logger shape the service has configured; it just needs "the logger in effect". This keeps the wrapper testable with a swap ofslog.SetDefault(same pattern aspkg/respondand, per ADR-034, the retrofittedpkg/middleware.Recovery).Query text is truncated at 200 chars and whitespace-collapsed before logging. Full query text is a tracing concern; DEBUG logs should not carry 5KB of rendered SQL on every request. Consumers who need the full statement plug a tracer at the driver layer — that's where it belongs.
Consequences
- Two call sites, one mental model. A consumer reads the two
signatures side-by-side and picks: "I control the DSN →
WrapDSN; I don't →WrapDB, knowing I lose per-query visibility." The asymmetry is loud enough to be obvious and soft enough to be usable. Naming conventions carry the contract: same prefix, same return shape, different fidelity. - No global driver registry mutation. Services that import the
wrapper, plus a library that also opens the same driver under its
real name (migrate, a dashboard, a CLI tool), will not collide.
Each
WrapDSNcall owns its owndriver.Connector. - Stats poller can be tested without sleep-based flakes. The
interval is a package-level
atomic.Pointer[time.Duration]; tests swap it to 5ms, exercise the path, and restore. This is a seam, not a production hook — documented as for-tests-only. - The wrapper's transitive dep cost is zero.
database/sqlanddatabase/sql/driverare stdlib. Phase 2c therefore does NOT trigger the "move to a sub-package" decision ADR-030 made for metrics;db.golives at the top level ofpkg/observ. We revisit if a future wrapper (e.g. OpenTelemetry spans) brings a heavy transitive tree. WrapDB's silent per-query gap is the library's single most important "do not surprise the operator" call. The README and CHEATSHEET are both explicit:WrapDBdoes pool stats and nothing else. The naming (WrapDBvsWrapDSN) is the first line of defence against a consumer callingWrapDBand wondering why nodb.querylines appear on the dashboard.
Alternatives considered
- One function, variadic options.
WrapDB(db, name, opts...)that internally decided whether it had enough hooks to instrument per- query. Rejected — the call site then has to read option values to know what it gets, and the "this has no per-query visibility" signal is buried. The split signature makes the fidelity visible at the type-check. - Generic middleware registration (
RegisterHook(name, func)). Let consumers install arbitrary hooks into the wrapper. Rejected — YAGNI; no second consumer yet, and the single-function interception covers every requirement on the Phase 2 roadmap. The hook surface can be added later without breaking the current signature. - Ship
WrapDSNonly, tellWrapDBusers "open differently". Rejected — migrate/bootstrap paths and ORM-owned opens are not always ours to change, and telling an operator "your dashboard is blank because the scaffolding library opened the DB first" is a worse experience than "you get pool stats, here's why the per- query events are missing". - Wrap
Stmttoo. Rejected for the reason above — double coverage, ambiguous latency attribution, no driver in our stack that requires it. Recorded here so the decision is not revisited by default.
References
- Source:
pkg/observ/db.go - ADR-030 — Metrics as a sub-package of
pkg/observ(whydb.golives at the top level: no transitive dep cost). - ADR-016 — HTTP access middleware composition (same author-pattern: library primitives that opt into deeper fidelity as consumers allow).
- ADR-006 — Error taxonomy seven categories (where
upstream_timeout/db_errorcome from).