Files
coder/coderd/aibridgedserver/metrics_internal_test.go
T
Susana Ferreira c351280a37 feat: add Prometheus metrics for AI Governance cost control (#27490)
## Description

Adds Prometheus metrics for AI budget cost control, emitted by the
aibridged server under the `cost_control` subsystem (full names are
prefixed `coder_ai_gateway_`).

- `blocked_requests_total` (counter) — labels: `group_id`
- `blocked_users` (gauge) — labels: `group_id`
- `unpriced_requests_total` (counter) — labels: `provider`, `model`
- `enforcement_duration_seconds` (histogram) — labels: `outcome`

## Changes

- Add `GetOverBudgetUsersPerGroup` query (plus dbauthz/dbmetrics/dbmock
wiring) to count over-budget users per effective group.
- Add a background collector that refreshes the `blocked_users` gauge on
an interval, started only when Prometheus is enabled.
- Wire `Metrics` through the aibridged server, coderd API,
`cli/server.go`, and the enterprise AI gateway handler; recording is
nil-safe when metrics are unset.

Closes
https://linear.app/codercom/issue/AIGOV-296/add-prometheus-metrics-for-cost-control

> [!NOTE]
> Initially generated by Claude Opus 4.7, modified and reviewed by
@ssncferreira
2026-07-28 09:22:58 +01:00

154 lines
4.3 KiB
Go

package aibridgedserver
import (
"database/sql"
"testing"
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
promtest "github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
"github.com/coder/quartz"
)
func TestUpdateBlockedUsers(t *testing.T) {
t.Parallel()
groupA := uuid.New()
groupB := uuid.New()
// Each round is one updateBlockedUsers call and the rows its query returns.
// The final gauge state is asserted after the last round, so sequential
// rounds cover Reset clearing series that dropped to zero.
type round struct {
rows []database.GetOverBudgetUsersPerGroupRow
err error
}
tests := []struct {
name string
rounds []round
wantErr error
wantCount int
wantValues map[uuid.UUID]float64
}{
{
name: "SetsPerGroupGauges",
rounds: []round{{rows: []database.GetOverBudgetUsersPerGroupRow{
{GroupID: groupA, OverBudgetUsers: 3},
{GroupID: groupB, OverBudgetUsers: 1},
}}},
wantCount: 2,
wantValues: map[uuid.UUID]float64{groupA: 3, groupB: 1},
},
{
// groupB drops to zero in the second round, so its stale series
// is cleared by Reset.
name: "ResetClearsStaleSeries",
rounds: []round{
{rows: []database.GetOverBudgetUsersPerGroupRow{
{GroupID: groupA, OverBudgetUsers: 3},
{GroupID: groupB, OverBudgetUsers: 1},
}},
{rows: []database.GetOverBudgetUsersPerGroupRow{
{GroupID: groupA, OverBudgetUsers: 2},
}},
},
wantCount: 1,
wantValues: map[uuid.UUID]float64{groupA: 2},
},
{
name: "PropagatesDBError",
rounds: []round{{err: sql.ErrConnDone}},
wantErr: sql.ErrConnDone,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Given: a database returning the over-budget rows for each round.
db := dbmock.NewMockStore(gomock.NewController(t))
calls := make([]any, 0, len(tt.rounds))
for _, r := range tt.rounds {
calls = append(calls, db.EXPECT().GetOverBudgetUsersPerGroup(gomock.Any(), gomock.Any()).
Return(r.rows, r.err))
}
gomock.InOrder(calls...)
m := NewMetrics(prometheus.NewRegistry())
clk := quartz.NewMock(t)
// When: the gauge is updated once per round.
var err error
for range tt.rounds {
err = m.updateBlockedUsers(t.Context(), clk, db, codersdk.AIBudgetPeriodMonth)
}
// Then: the query error propagates, or the gauge holds the final
// per-group counts.
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantCount, promtest.CollectAndCount(m.BlockedUsers))
for group, want := range tt.wantValues {
require.Equal(t, want, promtest.ToFloat64(m.BlockedUsers.WithLabelValues(group.String())))
}
})
}
}
func TestStartBlockedUsersCollector(t *testing.T) {
t.Parallel()
t.Run("NilReceiverNoop", func(t *testing.T) {
t.Parallel()
// Given: a nil Metrics.
var m *Metrics
// When: the collector is started.
closeFn := m.StartBlockedUsersCollector(t.Context(), testutil.Logger(t), quartz.NewMock(t), nil, codersdk.AIBudgetPeriodMonth, time.Minute)
// Then: the returned closer is a no-op and does not panic.
require.NotPanics(t, closeFn)
})
t.Run("TicksAndStops", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
groupA := uuid.New()
// Given: a running collector on a mock clock.
db := dbmock.NewMockStore(gomock.NewController(t))
m := NewMetrics(prometheus.NewRegistry())
clk := quartz.NewMock(t)
db.EXPECT().GetOverBudgetUsersPerGroup(gomock.Any(), gomock.Any()).
Return([]database.GetOverBudgetUsersPerGroupRow{
{GroupID: groupA, OverBudgetUsers: 4},
}, nil).AnyTimes()
closeFn := m.StartBlockedUsersCollector(ctx, testutil.Logger(t), clk, db, codersdk.AIBudgetPeriodMonth, time.Minute)
defer closeFn()
// When: the ticker fires.
_, w := clk.AdvanceNext()
w.MustWait(ctx)
// Then: the gauge reflects the queried per-group count.
require.Eventually(t, func() bool {
return promtest.ToFloat64(m.BlockedUsers.WithLabelValues(groupA.String())) == 4.0
}, testutil.WaitShort, testutil.IntervalFast)
})
}