Decisions

ADR-033: DB wrapper — per-query via `WrapDSN`, stats-only via `WrapDB`

Phase 2c of the observability rollout adds a `database/sql` wrapper to [`gyrum-go/pkg/observ`](https://github.com/gyrum-labs/gyrum-go/tree/main/pkg/observ). The Phase 1 library already ships the JSON logger, redactor,…

#033

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:

  1. WrapDB(db *sql.DB, name string) — stats-only. The caller passes an already-open *sql.DB; we poll db.Stats() on a ticker and log the pool numbers. Simple; no driver surgery. But *sql.DB offers no public hook to observe queries — its internal connection pool hands out driver.Conn instances with no interception point, and DB.Query/Exec bypass anything a caller could decorate on the way in. So the per-query log story ends at db.Stats().

  2. WrapDSN(driverName, dsn, name) — full per-query. We own the open, so we can route through a wrapped driver.Connector (sql.OpenDB), which gives us every QueryerContext/ExecerContext call 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:

  1. sql.OpenDB with a per-DB driver.Connector, not sql.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 the db_name tag has to be threaded through the DSN (or a package-global fallback) to make it back to the per-operation log line. A driver.Connector carries the wrapped state per-open-call and requires no registry mutation. This is the modern (Go 1.10+) idiom.

  2. No Stmt-layer wrapping. The Conn-layer QueryerContext/ExecerContext path covers every production-grade driver we ship (modernc.org/sqlite, lib/pq, pgx, mysql, go-sqlite3). Adding a Stmt decorator 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 legacy driver.Conn.Prepare + driver.Stmt returns driver.ErrSkip from the wrapped QueryContext, and database/sql falls back to its own un-instrumented path — no crash, less visibility. We document this in the package doc.

  3. Three log messages, not one. db.query (DEBUG, success), db.query.slow (WARN, success ≥ OBSERV_DB_SLOW_MS), and db.query.error (ERROR, with err_category=upstream_timeout for ctx.Canceled/DeadlineExceeded, else db_error). Splitting by log level rather than a single "event" message makes the slow-query dashboard a plain count by (service) ({msg="db.query.slow"}) Loki query and the error breakdown a plain count by (err_category) ({msg="db.query.error"}). One message with a severity field would collapse into a messier query shape.

  4. Pool stats is a shared detail. Both WrapDSN and WrapDB start a goroutine that emits db.pool.stats at DEBUG every OBSERV_DB_STATS_MS (default 30s). The returned closer is idempotent and stops the goroutine cleanly; WrapDSN's closer also closes the underlying *sql.DB, whereas WrapDB's does not (ownership stays with whoever opened it).

  5. WrapDSN logs are routed through slog.Default(), not observ.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 of slog.SetDefault (same pattern as pkg/respond and, per ADR-034, the retrofitted pkg/middleware.Recovery).

  6. 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 WrapDSN call owns its own driver.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/sql and database/sql/driver are stdlib. Phase 2c therefore does NOT trigger the "move to a sub-package" decision ADR-030 made for metrics; db.go lives at the top level of pkg/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: WrapDB does pool stats and nothing else. The naming (WrapDB vs WrapDSN) is the first line of defence against a consumer calling WrapDB and wondering why no db.query lines 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 WrapDSN only, tell WrapDB users "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 Stmt too. 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 (why db.go lives 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_error come from).