mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# Which Problems Are Solved ZITADEL opens its Postgres connection pool via `pgxpool.NewWithConfig` (`internal/database/postgres/pg.go`) but never reads `pgxpool.Pool.Stat()`, so operators have no way to observe pool saturation (acquired/idle/total conns vs. `MaxConns`) or connection churn (acquire waits, canceled/empty acquires, idle/lifetime destroys, new connections) through ZITADEL's existing metrics pipeline. # How the Problems Are Solved - Adds `RegisterCounterObserver` (backed by an OTel `Int64ObservableCounter`) to the `Metrics` interface, its implementation, and the test mock (`backend/v3/instrumentation/metric.go`, `backend/v3/instrumentation/metrics/metric.go`, `backend/v3/instrumentation/metrics/mock.go`), mirroring the existing `RegisterValueObserver`/`RegisterUpDownSumObserver` — there was previously no observable instrument for cumulative counters. - Adds `database.RegisterPoolMetrics(ctx, pool)` (`internal/database/metrics.go`), which registers 5 observable gauges (`acquired_conns`, `constructing_conns`, `idle_conns`, `max_conns`, `total_conns`) and 7 observable counters (`acquire_count`, `acquire_duration_milliseconds`, `canceled_acquire_count`, `empty_acquire_count`, `max_idle_destroy_count`, `max_lifetime_destroy_count`, `new_conns_count`) sourced directly from `pgxpool.Pool.Stat()`, published under `zitadel.database.pool.*` metric names. - Wires the registration call into `cmd/start/start.go` right after the pool is created, so the running server process exposes these series on `/metrics`. # Additional Changes - Adds unit tests covering the new `RegisterCounterObserver` method (`backend/v3/instrumentation/metric_test.go`) and the pool metrics registration (`internal/database/metrics_test.go`). # Additional Context - Scope is intentionally limited to `cmd/start` (the long-running process that serves `/metrics`); one-shot commands (`setup`, `mirror`, `initialise`, `key`) are left untouched since an exporter running for a single command invocation wouldn't produce useful series. - https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool#Stat
85 lines
2.6 KiB
Go
85 lines
2.6 KiB
Go
package database
|
|
|
|
import (
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
sdk_metric "go.opentelemetry.io/otel/sdk/metric"
|
|
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
|
|
|
"github.com/zitadel/zitadel/backend/v3/instrumentation"
|
|
)
|
|
|
|
func Test_registerPoolMetrics(t *testing.T) {
|
|
cfg, err := pgxpool.ParseConfig("postgres://user:pass@localhost:5432/db")
|
|
require.NoError(t, err)
|
|
|
|
// pgxpool.NewWithConfig does not establish any connection, so this works
|
|
// without a reachable database.
|
|
pool, err := pgxpool.NewWithConfig(t.Context(), cfg)
|
|
require.NoError(t, err)
|
|
t.Cleanup(pool.Close)
|
|
|
|
reader := sdk_metric.NewManualReader()
|
|
provider := sdk_metric.NewMeterProvider(sdk_metric.WithReader(reader))
|
|
m := &instrumentation.Meter{Meter: provider.Meter(t.Name())}
|
|
|
|
registerPoolMetrics(t.Context(), m, pool)
|
|
|
|
var rm metricdata.ResourceMetrics
|
|
require.NoError(t, reader.Collect(t.Context(), &rm))
|
|
|
|
gauges := map[string]int64{
|
|
PoolAcquiredConns: 0,
|
|
PoolConstructingConns: 0,
|
|
PoolIdleConns: 0,
|
|
PoolMaxConns: int64(cfg.MaxConns),
|
|
PoolTotalConns: 0,
|
|
}
|
|
for name, want := range gauges {
|
|
t.Run(name, func(t *testing.T) {
|
|
got := findMetric(t, rm, name)
|
|
gauge, ok := got.Data.(metricdata.Gauge[int64])
|
|
require.True(t, ok, "expected a gauge, got %T", got.Data)
|
|
require.Len(t, gauge.DataPoints, 1)
|
|
assert.Equal(t, want, gauge.DataPoints[0].Value)
|
|
})
|
|
}
|
|
|
|
counters := []string{
|
|
PoolAcquireCount,
|
|
PoolAcquireDurationMs,
|
|
PoolCanceledAcquireCount,
|
|
PoolEmptyAcquireCount,
|
|
PoolMaxIdleDestroyCount,
|
|
PoolMaxLifetimeDestroyCount,
|
|
PoolNewConnsCount,
|
|
}
|
|
for _, name := range counters {
|
|
t.Run(name, func(t *testing.T) {
|
|
got := findMetric(t, rm, name)
|
|
sum, ok := got.Data.(metricdata.Sum[int64])
|
|
require.True(t, ok, "expected an observable counter (Sum[int64]), got %T", got.Data)
|
|
assert.True(t, sum.IsMonotonic)
|
|
require.Len(t, sum.DataPoints, 1)
|
|
// A freshly created, unused pool has not acquired or destroyed any connections yet.
|
|
assert.Equal(t, int64(0), sum.DataPoints[0].Value)
|
|
})
|
|
}
|
|
}
|
|
|
|
// findMetric locates a metric by name across all scopes collected from a reader.
|
|
func findMetric(t *testing.T, rm metricdata.ResourceMetrics, name string) metricdata.Metrics {
|
|
t.Helper()
|
|
for _, sm := range rm.ScopeMetrics {
|
|
if i := slices.IndexFunc(sm.Metrics, func(m metricdata.Metrics) bool { return m.Name == name }); i >= 0 {
|
|
return sm.Metrics[i]
|
|
}
|
|
}
|
|
t.Fatalf("metric %q not found in collected data", name)
|
|
return metricdata.Metrics{}
|
|
}
|