Skip to content

database: add per-query Prometheus metrics - #379

Merged
ashutosh-narkar merged 1 commit into
open-policy-agent:mainfrom
yi-chen-roger:ychen/database-query-metrics
Aug 10, 2026
Merged

database: add per-query Prometheus metrics#379
ashutosh-narkar merged 1 commit into
open-policy-agent:mainfrom
yi-chen-roger:ychen/database-query-metrics

Conversation

@yi-chen-roger

Copy link
Copy Markdown
Contributor

Summary

Adds per-query Prometheus metrics for the Database layer (CockroachDB/postgres/mysql), which currently has none. Build-time DB failures today only surface as BundleBuilderTaskFailureRateHigh{error_type=service} (in a consumer) with no further detail on which query or operation failed.

New metrics, following the existing gitsync/http/worker pattern in internal/metrics:

  • ocp_database_query_count_total{operation, state} — counter, state is SUCCESS/FAILED
  • ocp_database_query_duration_seconds{operation} — histogram, operation parsed from the SQL statement's first keyword (select/insert/update/...)

Design

The instrumentation is wired in at the driver.Connector level (internal/database/instrumented.go), not by touching any of the ~50 methods in database.go — every one of them already funnels through sql.OpenDB(connector) for postgres/CockroachDB/mysql (sqlite's sql.Open path is left alone; it's the in-memory/dev default, not a production case).

The tricky part of this kind of wrapping is driver.Conn's several optional interfaces (QueryerContext, ExecerContext, Pinger, ConnPrepareContext, ConnBeginTx, SessionResetter, NamedValueChecker, Validator) — database/sql detects which ones a Conn implements to decide its execution path, and a wrapper that doesn't preserve the same set of capabilities silently changes behavior (e.g. falling back to a slower prepare-then-exec path) without erroring.

An earlier version of this PR tried to solve this by constructing a different wrapper type per detected capability combination. TestInstrumentedConn_ForwardsUnrelatedOptionalInterfaces caught a real bug in that approach (driver.Pinger support silently dropped), which led to redesigning around the pattern used by github.com/XSAM/otelsql (not taken as a dependency — it's OTel-native, this stays Prometheus-native to match the rest of internal/metrics): the wrapper unconditionally implements every optional interface, and each method checks at call time whether the wrapped conn actually supports it, returning driver.ErrSkip when it doesn't — the sentinel database/sql defines for exactly this case, so it falls back exactly as it would without any wrapping. This avoids the combinatorial-type problem entirely: there's one wrapper type, and no capability can be silently dropped.

PrepareContext/BeginTx fallback logic (used when the wrapped conn doesn't implement the optional context-aware version) is adapted from otelsql, which itself credits it to database/sql's own internal ctxutil.go.

Wiring

Database.WithMetrics(*metrics.Metrics) mirrors the existing WithLogger/WithAuthorizer pattern. Service.WithMetrics now also propagates to s.database, mirroring how it already does for WithLogger.

Also adds DatabaseMetrics to the public MetricsConfig schema (internal/config/config.go, regenerated schema.json/config/schema.json) for parity with GitSyncMetrics/WorkerMetrics/HTTPMetrics, so operators can fine-tune or disable it the same way.

Test plan

  • TestInstrumentedConn_FallsBackWhenUnsupported — wrapping a conn with neither QueryerContext nor ExecerContext returns driver.ErrSkip, not a panic or a silent no-op.
  • TestInstrumentedConn_ForwardsUnrelatedOptionalInterfacesdriver.Pinger support is preserved through the wrapper (the regression test for the bug the earlier design had).
  • TestInstrumentedConn_RecordsMetrics — a successful query and a failed exec both show up correctly in ocp_database_query_count_total via a real prometheus.Registry.
  • TestInstrumentedConn_BeginTxFallback — the ctxutil.go-derived fallback path propagates errors correctly and rejects unsupported non-default isolation levels.
  • go test ./internal/database/... ./internal/metrics/... ./internal/config/... — all pass except the pre-existing postgres/mysql/cockroachdb testcontainers-based subtests, which fail in my sandbox with "rootless Docker not found" — confirmed this also fails identically on unmodified main, so it's an environment limitation, not something this PR broke. Would appreciate a maintainer or CI run to confirm this actually instruments real CockroachDB/postgres/mysql connections end-to-end, since that's the one thing my sandbox couldn't verify directly.

@yi-chen-roger
yi-chen-roger marked this pull request as draft July 22, 2026 21:02
@yi-chen-roger
yi-chen-roger marked this pull request as ready for review July 30, 2026 21:12
@yi-chen-roger
yi-chen-roger force-pushed the ychen/database-query-metrics branch from 1ac5bda to ace10f6 Compare July 31, 2026 02:49
@ashutosh-narkar
ashutosh-narkar force-pushed the ychen/database-query-metrics branch from 3bf0781 to ae28a4b Compare August 4, 2026 18:28

@patrick-east patrick-east left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code changes LGTM, just a couple things I'd recommend cleaning up in the comments

Comment thread internal/database/instrumented.go Outdated
Comment on lines +33 to +39
// instrumentedConn wraps a driver.Conn to record query metrics. Unlike an
// earlier version of this file, it does NOT try to detect which optional
// driver.Conn interfaces (QueryerContext, ExecerContext, Pinger, ...) the
// wrapped conn implements and construct a matching wrapper type — that
// approach requires exhaustively enumerating every combination of ~8
// optional interfaces, and it's easy to silently drop one (as a test here
// caught for driver.Pinger).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't need commentary from previous iterations included in the comments. It seems like the only take away that is important here is that instrumentedConn must implement all of the driver.Conn interfaces.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

Comment thread internal/database/instrumented_test.go Outdated

func TestInstrumentedConn_ForwardsUnrelatedOptionalInterfaces(t *testing.T) {
// Pinger is never touched by our wrapper; verify it's still forwarded
// (the earlier, type-switch-based design silently dropped this).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar here, we don't need comments about previous issues

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated

@ashutosh-narkar ashutosh-narkar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes look good. We should add some tests to exercise the metrics package.

yi-chen-roger added a commit to yi-chen-roger/opa-control-plane that referenced this pull request Aug 6, 2026
- trim commentary about the earlier design iteration from comments in
  instrumented.go/instrumented_test.go, keeping just the load-bearing
  takeaway (patrick-east)
- add pkg/metrics tests covering database query metrics: nil-safety,
  per-flag disable behavior, count/duration recording, DatabaseOperation
  parsing, and namespace-prefixed metric names (ashutosh-narkar)
@yi-chen-roger
yi-chen-roger force-pushed the ychen/database-query-metrics branch from 70c4f80 to 49e2a85 Compare August 6, 2026 04:51
Adds per-query Prometheus metrics for the Database layer
(CockroachDB/postgres/mysql), which currently has none. Build-time DB
failures today only surface as
BundleBuilderTaskFailureRateHigh{error_type=service} (in a consumer)
with no further detail on which query or operation failed.

New metrics, following the existing gitsync/http/worker pattern in
internal/metrics:
- ocp_database_query_count_total{operation, state} - counter, state
  is SUCCESS/FAILED
- ocp_database_query_duration_seconds{operation} - histogram,
  operation parsed from the SQL statement's first keyword
  (select/insert/update/...)

The instrumentation is wired in at the driver.Connector level
(internal/database/instrumented.go), not by touching any of the ~50
methods in database.go - every one of them already funnels through
sql.OpenDB(connector) for postgres/CockroachDB/mysql (sqlite's
sql.Open path is left alone; it's the in-memory/dev default, not a
production case).

The wrapper unconditionally implements every optional driver.Conn
interface (QueryerContext, ExecerContext, Pinger, ConnPrepareContext,
ConnBeginTx, SessionResetter, NamedValueChecker, Validator), and each
method checks at call time whether the wrapped conn actually supports
it, returning driver.ErrSkip when it doesn't - the sentinel
database/sql defines for exactly this case, so it falls back exactly
as it would without any wrapping. This avoids silently dropping a
capability the wrapped conn supports (e.g. falling back to a slower
prepare-then-exec path without erroring), which a wrapper covering
only some interfaces would risk.

Database.WithMetrics(*metrics.Metrics) mirrors the existing
WithLogger/WithAuthorizer pattern. Service.WithMetrics now also
propagates to s.database, mirroring how it already does for
WithLogger.

Also adds DatabaseMetrics to the public MetricsConfig schema
(internal/config/config.go, regenerated schema.json/config/schema.json)
for parity with GitSyncMetrics/WorkerMetrics/HTTPMetrics, so operators
can fine-tune or disable it the same way.

Signed-off-by: Yi Chen <yi.chen.roger@gmail.com>
@yi-chen-roger
yi-chen-roger force-pushed the ychen/database-query-metrics branch from 49e2a85 to 91877de Compare August 6, 2026 05:19
}
}

func TestDatabaseOperation(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how this test fits into what we're implementing here. Please remove if not needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DatabaseOperation is new logic added by this PR (parses the SQL keyword for the operation label) — this test covers its parsing edge cases (unrecognized/empty/multi-keyword input) directly, separate from the end-to-end recording test.

@ashutosh-narkar ashutosh-narkar left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the tests @yi-chen-roger. The changes lgtm. I'll let @patrick-east give the final sign-off.

@ashutosh-narkar
ashutosh-narkar merged commit cdcd32b into open-policy-agent:main Aug 10, 2026
9 checks passed
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.

3 participants