mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
* feat(metrics): scrape-time BusinessSamplerCollector for active users / queued / runtime gauges (MUL-2947)
Adds an opt-in prometheus.Collector that runs a fixed set of read-only
SQL queries on every /metrics scrape and exposes the results as gauges:
- multica_active_users{window=5m|1h|24h}
- multica_active_workspaces{window=...}
- multica_agent_task_queued{source}
- multica_agent_task_running{source,runtime_mode}
- multica_agent_task_stuck_total{source}
- multica_runtime_online{runtime_mode,provider}
- multica_runtime_heartbeat_age_seconds{runtime_mode} (histogram)
- multica_workspace_total
Plus a self-introspection histogram
multica_business_sampler_query_seconds{name=...} and a counter
multica_business_sampler_query_errors_total{name=...} so the sampler's
own behaviour is observable on /metrics.
Production-safety contract per the PR4 brief:
- every query runs in its own BEGIN READ ONLY tx with
SET LOCAL statement_timeout = '500ms' (configurable)
- the sampler takes a dedicated *pgxpool.Pool option so operators
can isolate it from business traffic
- successful results are cached for 5–10s (default 8s) to absorb
concurrent scrapes from multiple Prometheus replicas
- every SQL has a hard LIMIT 100 fallback
- all label values flow through the existing BusinessMetrics
NormalizeTaskSource / NormalizeRuntimeMode / NormalizeRuntimeProvider
whitelists, so a misbehaving runtime cannot inflate cardinality
- sampler is OPT-IN via RegistryOptions.BusinessSampler — existing
callers that only pass Pool keep their current behaviour and never
start hitting the DB on /metrics
Tests cover: emit shape, TTL cache (one DB call per N scrapes),
bounded cardinality under malicious labels, opt-out (no leakage), and
DB-hang isolation (unreachable host -> /metrics returns within 5s,
query_errors_total advances).
Refs MUL-2947 (depends on PR2 / MUL-2948, merged in #3695).
Co-authored-by: multica-agent <github@multica.ai>
* fix(metrics): address PR4 review — wire sampler in main.go, fix LIMIT bug, add live-DB statement_timeout test
Three fixes from 大彪's review on #3706:
1. main.go was building NewRegistry without the BusinessSampler option,
so the collector was effectively dead code in prod. Now constructs a
dedicated 2-conn pgxpool (newSamplerDBPool) from the same DATABASE_URL
when METRICS_ADDR is set, plumbs it into RegistryOptions.BusinessSampler,
and defers Close() at shutdown. A pool-build failure logs and disables
the sampler instead of taking down the server.
2. queryActiveUsers / queryActiveWorkspaces previously wrapped the
distinct-user/workspace subquery in a 'LIMIT 100', then COUNT(*)'d
the result — capping the active-user gauge at 100 regardless of
reality. Removed the inner LIMIT; the COUNT scalar is one row anyway,
and metric cardinality is bounded by the fixed samplerWindows
allow-list, not by the SQL shape.
3. The previous DB-hang test only exercised the acquire-fails path. Added
business_sampler_pgsleep_test.go which connects to a live Postgres
(skips cleanly when DATABASE_URL is not set), runs SELECT pg_sleep(2)
inside a sampler-style tx with SET LOCAL statement_timeout = '500ms',
and asserts:
- the call returns in well under 1.5 s (proving the server-side
cancellation, not just our caller-side context)
- query_errors_total{name=pg_sleep_canary} advances
- the duration histogram records the cancellation
Verified locally: 550 ms, SQLSTATE 57014 'canceling statement due to
statement timeout' — exactly the safety net the PR claims.
Refs MUL-2947 / PR #3706.
Co-authored-by: multica-agent <github@multica.ai>
* test(metrics): assert SQLSTATE 57014 on pg_sleep cancellation
The previous assertion only checked that the query was cut off in well
under the sleep duration, which a caller-side context cancellation
would also satisfy. Capturing the inner pgconn.PgError and asserting
Code == "57014" ("query_canceled") nails down that Postgres itself
cancelled the statement because of the SET LOCAL statement_timeout —
so a regression that drops the SET LOCAL line fails this test loudly
instead of silently passing on context cancellation.
Refs MUL-2947 / PR #3706 review nit.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
117 lines
4.3 KiB
Go
117 lines
4.3 KiB
Go
package metrics
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/prometheus/client_golang/prometheus/testutil"
|
|
)
|
|
|
|
// TestBusinessSamplerStatementTimeoutCutsHungQuery is the integration test
|
|
// that proves the safety net is real. It connects to a live Postgres,
|
|
// asks it to `pg_sleep(2)` inside a sampler-style transaction with
|
|
// SET LOCAL statement_timeout = '500ms', and asserts:
|
|
//
|
|
// 1. The query returns in well under the sleep duration (cancelled by
|
|
// the server, not by our caller-side context — the SET LOCAL is
|
|
// doing the work).
|
|
// 2. The Postgres error we caught carries SQLSTATE 57014
|
|
// ("query_canceled"). This is the canonical proof of statement_timeout
|
|
// firing, and it's the assertion that catches the regression where
|
|
// someone deletes the SET LOCAL line and the test would otherwise
|
|
// pass on a context-cancellation timeout instead.
|
|
// 3. The error counter for that named query advances.
|
|
// 4. The duration histogram records the cancellation latency, so
|
|
// dashboards can see it happen.
|
|
//
|
|
// Skips cleanly when no DATABASE_URL is set, mirroring the integration
|
|
// test pattern already used in cmd/server. Operators running CI without a
|
|
// reachable Postgres see "SKIP", not a failure.
|
|
func TestBusinessSamplerStatementTimeoutCutsHungQuery(t *testing.T) {
|
|
dbURL := os.Getenv("DATABASE_URL")
|
|
if dbURL == "" {
|
|
t.Skip("DATABASE_URL not set; skipping live-Postgres statement_timeout test")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dbURL)
|
|
if err != nil {
|
|
t.Skipf("could not connect to %s: %v", dbURL, err)
|
|
}
|
|
defer pool.Close()
|
|
if err := pool.Ping(ctx); err != nil {
|
|
t.Skipf("database not reachable at %s: %v", dbURL, err)
|
|
}
|
|
|
|
c := NewBusinessSamplerCollector(&BusinessSamplerOptions{
|
|
Pool: pool,
|
|
CacheTTL: time.Second,
|
|
QueryTimeout: 500 * time.Millisecond,
|
|
})
|
|
if c == nil {
|
|
t.Fatal("NewBusinessSamplerCollector returned nil for live pool")
|
|
}
|
|
|
|
conn, err := pool.Acquire(ctx)
|
|
if err != nil {
|
|
t.Fatalf("acquire conn for hung-query test: %v", err)
|
|
}
|
|
defer conn.Release()
|
|
|
|
const queryName = "pg_sleep_canary"
|
|
var capturedErr error
|
|
start := time.Now()
|
|
c.runQuery(ctx, conn, queryName, func(ctx context.Context, tx pgx.Tx) error {
|
|
// 2 s is comfortably longer than the 500 ms statement_timeout
|
|
// AND the 550 ms outer context deadline, so whichever layer
|
|
// fires first we still observe a cancelled query.
|
|
_, err := tx.Exec(ctx, "SELECT pg_sleep(2)")
|
|
capturedErr = err
|
|
return err
|
|
})
|
|
elapsed := time.Since(start)
|
|
|
|
// We give a generous upper bound (1.5 s) to absorb local-Postgres
|
|
// scheduler jitter and pgx round-trip overhead. The lower bound
|
|
// (>250 ms) confirms we *did* hit the timeout rather than the query
|
|
// returning instantly because pg_sleep was elided somewhere.
|
|
if elapsed >= 1500*time.Millisecond {
|
|
t.Fatalf("statement_timeout did not cut the hung query: elapsed %s", elapsed)
|
|
}
|
|
if elapsed <= 250*time.Millisecond {
|
|
t.Fatalf("query returned suspiciously fast (%s); SET LOCAL statement_timeout may not be in force", elapsed)
|
|
}
|
|
|
|
// SQLSTATE 57014 ("query_canceled") is the canonical proof that
|
|
// Postgres itself terminated the query because of statement_timeout.
|
|
// If a future refactor accidentally drops the SET LOCAL line, the
|
|
// query would still get cancelled — but by our caller-side context,
|
|
// not by Postgres, and this assertion would catch it.
|
|
if capturedErr == nil {
|
|
t.Fatal("expected pg_sleep to return an error; got nil")
|
|
}
|
|
var pgErr *pgconn.PgError
|
|
if !errors.As(capturedErr, &pgErr) {
|
|
t.Fatalf("expected *pgconn.PgError from pg_sleep cancellation; got %T: %v", capturedErr, capturedErr)
|
|
}
|
|
if pgErr.Code != "57014" {
|
|
t.Fatalf("expected SQLSTATE 57014 (query_canceled); got %q (%s)", pgErr.Code, pgErr.Message)
|
|
}
|
|
|
|
// One labelled error must have been recorded against the named query.
|
|
if got := testutil.ToFloat64(c.queryErrors.WithLabelValues(queryName)); got < 1 {
|
|
t.Fatalf("query_errors_total{name=%q} = %v, want >= 1", queryName, got)
|
|
}
|
|
|
|
// And one observation must have landed on the duration histogram.
|
|
if got := testutil.CollectAndCount(c.queryDuration); got < 1 {
|
|
t.Fatalf("query_seconds histogram saw 0 observations after pg_sleep cancellation")
|
|
}
|
|
}
|