diff --git a/docs/db-diagnostics.md b/docs/db-diagnostics.md
index 263fcb5..c0f7840 100644
--- a/docs/db-diagnostics.md
+++ b/docs/db-diagnostics.md
@@ -21,7 +21,14 @@ psql "$EVOBGP_DATABASE_URL"
CLI на CP: `evobgp-api db report|vacuum|analyze|cleanup` (см. `internal/dbcli`).
-Миграция `000023` создаёт `pg_stat_statements`; в production может потребоваться `shared_preload_libraries` и перезапуск Postgres.
+Миграция `000023` создаёт `pg_stat_statements`; для сбора статистики **обязательно** preload и перезапуск Postgres:
+
+```text
+# postgresql.conf или command в compose
+shared_preload_libraries = 'pg_stat_statements'
+```
+
+После изменения — restart контейнера/сервиса Postgres. Без этого API `/v1/monitoring/postgres/queries` вернёт пустой список (`statements_available: false`), без 5xx.
## 1. Размеры таблиц и индексов
diff --git a/internal/pgmonitor/queries.go b/internal/pgmonitor/queries.go
index 660590b..5de5eb2 100644
--- a/internal/pgmonitor/queries.go
+++ b/internal/pgmonitor/queries.go
@@ -106,11 +106,7 @@ func (s *Service) fetchOverview(ctx context.Context) (Overview, error) {
}
}
- var ext bool
- _ = s.pool.QueryRow(ctx, `
- SELECT EXISTS (
- SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements')`).Scan(&ext)
- out.StatementsEnabled = ext
+ out.StatementsEnabled = s.statementsQueryable(ctx)
return out, nil
}
@@ -185,27 +181,75 @@ func (s *Service) TopQueries(ctx context.Context, limit int) (QueriesResponse, e
if snap, ok, err := s.loadSnapshot(ctx, "slow_queries", 15*time.Minute); err == nil && ok {
var items []QueryStat
if err := decodePayload(snap.Payload, &items); err == nil {
- return QueriesResponse{CollectedAt: snap.CollectedAt, Source: "snapshot", Items: items}, nil
+ return QueriesResponse{
+ CollectedAt: snap.CollectedAt,
+ Source: "snapshot",
+ Items: items,
+ StatementsAvailable: true,
+ }, nil
}
}
- if !s.statementsEnabled(ctx) {
- return QueriesResponse{CollectedAt: now, Source: "live", Items: nil}, nil
+ if !s.statementsQueryable(ctx) {
+ return queriesUnavailable(now), nil
}
items, err := queryTopStatements(ctx, s.pool, limit)
if err != nil {
+ if isPgStatStatementsUnavailable(err) {
+ s.markStatementsUnavailable()
+ return queriesUnavailable(now), nil
+ }
return QueriesResponse{}, err
}
- return QueriesResponse{CollectedAt: now, Source: "live", Items: items}, nil
+ return QueriesResponse{
+ CollectedAt: now,
+ Source: "live",
+ Items: items,
+ StatementsAvailable: true,
+ }, nil
}
-func (s *Service) statementsEnabled(ctx context.Context) bool {
- var ok bool
- _ = s.pool.QueryRow(ctx, `
- SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements')`).Scan(&ok)
+func queriesUnavailable(at time.Time) QueriesResponse {
+ return QueriesResponse{
+ CollectedAt: at,
+ Source: "unavailable",
+ Items: nil,
+ StatementsAvailable: false,
+ StatementsHint: statementsUnavailableHint,
+ }
+}
+
+const statementsUnavailableHint = "pg_stat_statements requires shared_preload_libraries and PostgreSQL restart (see docs/db-diagnostics.md)"
+
+// statementsQueryable returns true only when pg_stat_statements can be queried (not merely installed).
+func (s *Service) statementsQueryable(ctx context.Context) bool {
+ if s == nil || s.pool == nil {
+ return false
+ }
+ if v, ok := s.cache.get("stmt_queryable"); ok {
+ if b, ok := v.(bool); ok {
+ return b
+ }
+ }
+ ok := probePgStatStatements(ctx, s.pool)
+ s.cache.set("stmt_queryable", ok)
return ok
}
+func (s *Service) markStatementsUnavailable() {
+ s.cache.set("stmt_queryable", false)
+}
+
+func probePgStatStatements(ctx context.Context, pool *pgxpool.Pool) bool {
+ var dummy int64
+ err := pool.QueryRow(ctx, `
+ SELECT COALESCE(SUM(calls), 0)::bigint FROM pg_stat_statements LIMIT 1`).Scan(&dummy)
+ if err == nil {
+ return true
+ }
+ return !isPgStatStatementsUnavailable(err)
+}
+
func queryTopStatements(ctx context.Context, pool *pgxpool.Pool, limit int) ([]QueryStat, error) {
rows, err := pool.Query(ctx, `
SELECT queryid, left(query, 500), calls, total_exec_time, mean_exec_time, rows
@@ -214,7 +258,7 @@ func queryTopStatements(ctx context.Context, pool *pgxpool.Pool, limit int) ([]Q
ORDER BY mean_exec_time DESC
LIMIT $1`, limit)
if err != nil {
- if isUndefinedTable(err) {
+ if isPgStatStatementsUnavailable(err) {
return nil, nil
}
return nil, fmt.Errorf("pgmonitor: pg_stat_statements: %w", err)
@@ -231,12 +275,24 @@ func queryTopStatements(ctx context.Context, pool *pgxpool.Pool, limit int) ([]Q
return out, rows.Err()
}
-func isUndefinedTable(err error) bool {
+// isPgStatStatementsUnavailable reports extension missing or not loaded via shared_preload_libraries.
+func isPgStatStatementsUnavailable(err error) bool {
+ if err == nil {
+ return false
+ }
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
- return pgErr.Code == "42P01" || pgErr.Code == "42704"
+ switch pgErr.Code {
+ case "42P01", "42704", "55000":
+ return true
+ }
+ msg := strings.ToLower(pgErr.Message)
+ if strings.Contains(msg, "shared_preload_libraries") || strings.Contains(msg, "pg_stat_statements") {
+ return true
+ }
}
- return strings.Contains(err.Error(), "pg_stat_statements")
+ low := strings.ToLower(err.Error())
+ return strings.Contains(low, "shared_preload_libraries") || strings.Contains(low, "pg_stat_statements")
}
func isSafeIdent(name string) bool {
diff --git a/internal/pgmonitor/scheduler.go b/internal/pgmonitor/scheduler.go
index 688ed78..a3293ed 100644
--- a/internal/pgmonitor/scheduler.go
+++ b/internal/pgmonitor/scheduler.go
@@ -33,7 +33,7 @@ func StartScheduler(ctx context.Context, pool *pgxpool.Pool) {
c, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if err := s.AggregateSlowQueries(c, 30); err != nil {
- log.Printf("pgmonitor: slow queries: %v", err)
+ log.Printf("pgmonitor: slow queries snapshot: %v", err)
}
if err := s.EstimateTableBloat(c); err != nil {
log.Printf("pgmonitor: bloat: %v", err)
diff --git a/internal/pgmonitor/service_test.go b/internal/pgmonitor/service_test.go
index eeb0405..74b28a6 100644
--- a/internal/pgmonitor/service_test.go
+++ b/internal/pgmonitor/service_test.go
@@ -1,6 +1,11 @@
package pgmonitor
-import "testing"
+import (
+ "errors"
+ "testing"
+
+ "github.com/jackc/pgx/v5/pgconn"
+)
func TestClampLimit(t *testing.T) {
if clampLimit(0, 20, 100) != 20 {
@@ -31,3 +36,13 @@ func TestNewServiceNilPool(t *testing.T) {
t.Fatal("expected nil service")
}
}
+
+func TestIsPgStatStatementsUnavailable(t *testing.T) {
+ err := &pgconn.PgError{Code: "55000", Message: "pg_stat_statements must be loaded via shared_preload_libraries"}
+ if !isPgStatStatementsUnavailable(err) {
+ t.Fatal("55000")
+ }
+ if isPgStatStatementsUnavailable(errors.New("other")) {
+ t.Fatal("unrelated")
+ }
+}
diff --git a/internal/pgmonitor/snapshot.go b/internal/pgmonitor/snapshot.go
index 9c78bd1..84debea 100644
--- a/internal/pgmonitor/snapshot.go
+++ b/internal/pgmonitor/snapshot.go
@@ -66,11 +66,15 @@ func (s *Service) RefreshMetricsSnapshot(ctx context.Context) error {
// AggregateSlowQueries stores top statements snapshot.
func (s *Service) AggregateSlowQueries(ctx context.Context, limit int) error {
- if !s.statementsEnabled(ctx) {
+ if !s.statementsQueryable(ctx) {
return s.UpsertSnapshot(ctx, "slow_queries", []QueryStat{})
}
items, err := queryTopStatements(ctx, s.pool, clampLimit(limit, 20, 100))
if err != nil {
+ if isPgStatStatementsUnavailable(err) {
+ s.markStatementsUnavailable()
+ return s.UpsertSnapshot(ctx, "slow_queries", []QueryStat{})
+ }
return err
}
return s.UpsertSnapshot(ctx, "slow_queries", items)
diff --git a/internal/pgmonitor/types.go b/internal/pgmonitor/types.go
index 642dbab..9f2881d 100644
--- a/internal/pgmonitor/types.go
+++ b/internal/pgmonitor/types.go
@@ -71,9 +71,11 @@ type QueryStat struct {
// QueriesResponse for GET /monitoring/postgres/queries.
type QueriesResponse struct {
- CollectedAt time.Time `json:"collected_at"`
- Source string `json:"source"` // live | snapshot
- Items []QueryStat `json:"items"`
+ CollectedAt time.Time `json:"collected_at"`
+ Source string `json:"source"` // live | snapshot | unavailable
+ Items []QueryStat `json:"items"`
+ StatementsAvailable bool `json:"statements_available"`
+ StatementsHint string `json:"statements_hint,omitempty"`
}
// LockRow describes a lock / blocking session.
diff --git a/web/src/lib/components/monitoring/MonitoringPostgresTab.svelte b/web/src/lib/components/monitoring/MonitoringPostgresTab.svelte
index a3b12e2..2551a6c 100644
--- a/web/src/lib/components/monitoring/MonitoringPostgresTab.svelte
+++ b/web/src/lib/components/monitoring/MonitoringPostgresTab.svelte
@@ -281,12 +281,18 @@
Медленные запросы
Источник: {queries?.source ?? '—'}
- {#if overview && !overview.pg_stat_statements_enabled}
- · pg_stat_statements не включён
+ {#if queries?.statements_available === false || (overview && !overview.pg_stat_statements_enabled)}
+ · pg_stat_statements недоступен
{/if}
+ {#if queries?.statements_hint}
+
+ Нет статистики запросов
+ {queries.statements_hint}
+
+ {/if}
diff --git a/web/src/lib/monitoring/postgres.ts b/web/src/lib/monitoring/postgres.ts
index 82ab7ad..1518a93 100644
--- a/web/src/lib/monitoring/postgres.ts
+++ b/web/src/lib/monitoring/postgres.ts
@@ -48,6 +48,8 @@ export type PostgresQueriesResponse = {
collected_at: string;
source: string;
items: PostgresQueryRow[];
+ statements_available?: boolean;
+ statements_hint?: string;
};
export type PostgresLockRow = {