mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: report agent runtime hours usage in entitlements (#27985)
Populate `FeatureAgentRuntimeHours.Actual` on every entitlements refresh for licenses that grant the feature. A new `GetTotalUsageHBAgentRuntimeV1` query sums `runtime_ms` over the license's usage period, reading `usage_events` directly: `hb_agent_runtime_v1` is exactly one row per hourly bucket deployment-wide with `created_at` at the bucket start, enforced by the unique partial index introduced in #27983. The measurement reuses the shared `measureUsage` policy from #27984 through a new `AgentRuntimeMsFn` closure (usage publisher subject): failures publish the stable `LicenseAgentRuntimeUsageUnavailableErrorText` and log the cause. Usage is floored to whole hours, matching the unit of the `agent_runtime_hours_*` claims, and at most one warning is emitted per refresh: reaching the allocation supersedes the advisory soft limit. The dashboard renders the soft-limit advisory muted without a sales link and treats the runtime usage-unavailable text as a diagnostic. **Precise usage.** `Feature.ActualMs` (JSON `actual_ms`), set only for `agent_runtime_hours`, carries the exact stored milliseconds backing the floored `Actual` so clients can render fractional hours (e.g. `10.3`). It has the same freshness as `Actual`; the whole-hour warning thresholds are unchanged. **Unlimited licenses.** A license minted with the unlimited (`-1`) allocation decodes to an enabled feature with a nil `Limit` (#27984), so the warning write-back now guards the allocation dereference: no thresholds can exist for an unlimited license, so no runtime hours warning is ever emitted, while `Actual` is still measured and published. `Feature.Compare` is unchanged; for usage-period features the issued-at/end dates decide first, so a metered feature outranks an unlimited one only on an exact timestamp tie, an edge pinned by a `TestFeatureComparison` case and documented on `decodeAgentRuntimeHours`. **Grandfathered premium licenses.** Premium licenses without `agent_runtime_hours_*` claims are now granted the feature disabled with a zero limit over the license term, identical to an explicit `allocation: 0`: usage is measured and published for every Premium deployment, and chatd's pooled admission (#27902) caps concurrent agentic chats until a license with a positive allocation is added. The default carries a fixed early `UsagePeriod.IssuedAt` (2026-08-01, the same mechanism as the managed-agents default) so any license actually carrying the claims outranks it in the `AddFeature` merge regardless of the licenses' relative issue dates; the constant must stay earlier than the earliest legitimately issued claim-bearing license. Zero allocations (explicit or grandfathered) emit no deployment-wide warning banner: those deployments are steered by the in-page upgrade CTA and the concurrency cap. Enterprise licenses are unchanged. Part 3 of a 3-PR stack splitting up #27796 (see there for review history). Stack: #27983 → #27984 → this PR. Closes CODAGT-852.
This commit is contained in:
@@ -4949,6 +4949,13 @@ func (q *querier) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg databa
|
||||
return q.db.GetTotalUsageDCManagedAgentsV1(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg database.GetTotalUsageHBAgentRuntimeV1Params) (int64, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUsageEvent); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return q.db.GetTotalUsageHBAgentRuntimeV1(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceLicense); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -6532,6 +6532,14 @@ func (s *MethodTestSuite) TestUsageEvents() {
|
||||
}).Asserts(rbac.ResourceUsageEvent, policy.ActionRead)
|
||||
}))
|
||||
|
||||
s.Run("GetTotalUsageHBAgentRuntimeV1", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
db.EXPECT().GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()).Return(int64(1), nil)
|
||||
check.Args(database.GetTotalUsageHBAgentRuntimeV1Params{
|
||||
StartTime: time.Time{},
|
||||
EndTime: time.Time{},
|
||||
}).Asserts(rbac.ResourceUsageEvent, policy.ActionRead)
|
||||
}))
|
||||
|
||||
s.Run("ListUsageEventCreatedAtsByTypeSince", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
params := database.ListUsageEventCreatedAtsByTypeSinceParams{
|
||||
EventType: "hb_agent_runtime_v1",
|
||||
|
||||
+8
@@ -3129,6 +3129,14 @@ func (m queryMetricsStore) GetTotalUsageDCManagedAgentsV1(ctx context.Context, a
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg database.GetTotalUsageHBAgentRuntimeV1Params) (int64, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTotalUsageHBAgentRuntimeV1(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetTotalUsageHBAgentRuntimeV1").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTotalUsageHBAgentRuntimeV1").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUnexpiredLicenses(ctx)
|
||||
|
||||
Generated
+15
@@ -5865,6 +5865,21 @@ func (mr *MockStoreMockRecorder) GetTotalUsageDCManagedAgentsV1(ctx, arg any) *g
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTotalUsageDCManagedAgentsV1", reflect.TypeOf((*MockStore)(nil).GetTotalUsageDCManagedAgentsV1), ctx, arg)
|
||||
}
|
||||
|
||||
// GetTotalUsageHBAgentRuntimeV1 mocks base method.
|
||||
func (m *MockStore) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg database.GetTotalUsageHBAgentRuntimeV1Params) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTotalUsageHBAgentRuntimeV1", ctx, arg)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTotalUsageHBAgentRuntimeV1 indicates an expected call of GetTotalUsageHBAgentRuntimeV1.
|
||||
func (mr *MockStoreMockRecorder) GetTotalUsageHBAgentRuntimeV1(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTotalUsageHBAgentRuntimeV1", reflect.TypeOf((*MockStore)(nil).GetTotalUsageHBAgentRuntimeV1), ctx, arg)
|
||||
}
|
||||
|
||||
// GetUnexpiredLicenses mocks base method.
|
||||
func (m *MockStore) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+14
@@ -870,6 +870,20 @@ type sqlcQuerier interface {
|
||||
// the events that happened on and between the two dates. Both dates are
|
||||
// inclusive.
|
||||
GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error)
|
||||
// Gets the total Coder Agent runtime in milliseconds between two timestamps.
|
||||
// The start bound is inclusive and the end bound is exclusive.
|
||||
//
|
||||
// Unlike GetTotalUsageDCManagedAgentsV1 this reads usage_events directly
|
||||
// rather than the usage_events_daily rollup: hb_agent_runtime_v1 is exactly
|
||||
// one row per hourly bucket deployment-wide, with created_at at the bucket
|
||||
// start, enforced by the unique partial index
|
||||
// idx_usage_events_agent_runtime (which also keeps SUM from counting a
|
||||
// bucket twice and serves this query). The result is bucket-granular: a
|
||||
// bucket counts entirely against the period containing its start. See
|
||||
// enterprise/coderd/usage/generator.go for what a bucket holds. If a
|
||||
// usage_events retention policy ever lands, this must move to the daily
|
||||
// rollup and accept day-granularity bounds.
|
||||
GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg GetTotalUsageHBAgentRuntimeV1Params) (int64, error)
|
||||
GetUnexpiredLicenses(ctx context.Context) ([]License, error)
|
||||
GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAIBudgetOverride, error)
|
||||
GetUserAIProviderKeyByProviderID(ctx context.Context, arg GetUserAIProviderKeyByProviderIDParams) (UserAIProviderKey, error)
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||||
"github.com/coder/coder/v2/coderd/usage/usagetypes"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -10924,8 +10925,8 @@ func TestUsageEventsTrigger(t *testing.T) {
|
||||
require.Len(t, rows, 3)
|
||||
|
||||
// The same bucket under a different id is not an idempotent
|
||||
// re-insert but a duplicate that would double any aggregate summing
|
||||
// runtime_ms; the unique partial index
|
||||
// re-insert but a duplicate that would double the SUM in
|
||||
// GetTotalUsageHBAgentRuntimeV1; the unique partial index
|
||||
// idx_usage_events_agent_runtime rejects it loudly instead of the
|
||||
// (id) arbiter silently dropping it.
|
||||
err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
@@ -10989,6 +10990,87 @@ func TestUsageEventsTrigger(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTotalUsageHBAgentRuntimeV1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
// hb_agent_runtime_v1 events are one row per hourly bucket, created_at
|
||||
// set to the bucket start.
|
||||
hour := func(d, h int) time.Time {
|
||||
return time.Date(2025, 1, d, h, 0, 0, 0, time.UTC)
|
||||
}
|
||||
// The event type and payload are built from the producer's types rather
|
||||
// than hand-written literals, so a rename in usagetypes fails this test
|
||||
// instead of leaving the query silently summing a key nothing writes.
|
||||
insert := func(id string, runtimeMs int64, createdAt time.Time) {
|
||||
t.Helper()
|
||||
event := usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs}
|
||||
eventData, err := json.Marshal(event.Fields())
|
||||
require.NoError(t, err)
|
||||
err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
ID: id,
|
||||
EventType: string(event.EventType()),
|
||||
EventData: eventData,
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
total := func(start, end time.Time) int64 {
|
||||
t.Helper()
|
||||
got, err := db.GetTotalUsageHBAgentRuntimeV1(ctx, database.GetTotalUsageHBAgentRuntimeV1Params{
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return got
|
||||
}
|
||||
|
||||
// No events at all sums to zero rather than NULL.
|
||||
require.EqualValues(t, 0, total(hour(1, 0), hour(5, 0)))
|
||||
|
||||
insert("rt-d1h0", 1000, hour(1, 0))
|
||||
insert("rt-d1h12", 500, hour(1, 12))
|
||||
insert("rt-d1h18", 0, hour(1, 18))
|
||||
insert("rt-d2h0", 250, hour(2, 0))
|
||||
insert("rt-d4h0", 7, hour(4, 0))
|
||||
|
||||
// A multi-day range sums every bucket it covers.
|
||||
require.EqualValues(t, 1757, total(hour(1, 0), hour(5, 0)))
|
||||
|
||||
// The start bound is inclusive and the end bound is exclusive: a bucket
|
||||
// starting exactly at the end timestamp belongs to the next period.
|
||||
require.EqualValues(t, 1500, total(hour(1, 0), hour(2, 0)))
|
||||
require.EqualValues(t, 1750, total(hour(1, 0), hour(2, 1)))
|
||||
require.EqualValues(t, 250, total(hour(2, 0), hour(4, 0)))
|
||||
require.EqualValues(t, 0, total(hour(3, 0), hour(4, 0)))
|
||||
|
||||
// Bounds are exact timestamps rather than whole days: a period starting
|
||||
// mid-day excludes that day's earlier buckets.
|
||||
require.EqualValues(t, 757, total(hour(1, 12), hour(5, 0)))
|
||||
|
||||
// A non-UTC timestamp addresses the same instant. Sydney is UTC+11 in
|
||||
// January, so 23:00 on Jan 1 in Sydney is 12:00 on Jan 1 in UTC.
|
||||
locSydney, err := time.LoadLocation("Australia/Sydney")
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 750, total(
|
||||
time.Date(2025, 1, 1, 23, 0, 0, 0, locSydney),
|
||||
time.Date(2025, 1, 2, 12, 0, 0, 0, locSydney),
|
||||
))
|
||||
|
||||
// Other event types are never mixed in, even when they carry a
|
||||
// runtime_ms key: without the event_type filter this would add 9999.
|
||||
err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
ID: "seats-1",
|
||||
EventType: "hb_ai_seats_v1",
|
||||
EventData: []byte(`{"count": 1, "runtime_ms": 9999}`),
|
||||
CreatedAt: hour(1, 0),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 1757, total(hour(1, 0), hour(5, 0)))
|
||||
}
|
||||
|
||||
func TestGetTotalChatMessageRuntimeMsInRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Generated
+38
@@ -28685,6 +28685,44 @@ func (q *sqlQuerier) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg Get
|
||||
return total_count, err
|
||||
}
|
||||
|
||||
const getTotalUsageHBAgentRuntimeV1 = `-- name: GetTotalUsageHBAgentRuntimeV1 :one
|
||||
SELECT
|
||||
-- The first cast is necessary since you can't sum strings, and the second
|
||||
-- cast is necessary to make sqlc happy.
|
||||
COALESCE(SUM((event_data->>'runtime_ms')::bigint), 0)::bigint AS total_runtime_ms
|
||||
FROM
|
||||
usage_events
|
||||
WHERE
|
||||
event_type = 'hb_agent_runtime_v1'
|
||||
AND created_at >= $1::timestamptz
|
||||
AND created_at < $2::timestamptz
|
||||
`
|
||||
|
||||
type GetTotalUsageHBAgentRuntimeV1Params struct {
|
||||
StartTime time.Time `db:"start_time" json:"start_time"`
|
||||
EndTime time.Time `db:"end_time" json:"end_time"`
|
||||
}
|
||||
|
||||
// Gets the total Coder Agent runtime in milliseconds between two timestamps.
|
||||
// The start bound is inclusive and the end bound is exclusive.
|
||||
//
|
||||
// Unlike GetTotalUsageDCManagedAgentsV1 this reads usage_events directly
|
||||
// rather than the usage_events_daily rollup: hb_agent_runtime_v1 is exactly
|
||||
// one row per hourly bucket deployment-wide, with created_at at the bucket
|
||||
// start, enforced by the unique partial index
|
||||
// idx_usage_events_agent_runtime (which also keeps SUM from counting a
|
||||
// bucket twice and serves this query). The result is bucket-granular: a
|
||||
// bucket counts entirely against the period containing its start. See
|
||||
// enterprise/coderd/usage/generator.go for what a bucket holds. If a
|
||||
// usage_events retention policy ever lands, this must move to the daily
|
||||
// rollup and accept day-granularity bounds.
|
||||
func (q *sqlQuerier) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg GetTotalUsageHBAgentRuntimeV1Params) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, getTotalUsageHBAgentRuntimeV1, arg.StartTime, arg.EndTime)
|
||||
var total_runtime_ms int64
|
||||
err := row.Scan(&total_runtime_ms)
|
||||
return total_runtime_ms, err
|
||||
}
|
||||
|
||||
const insertUsageEvent = `-- name: InsertUsageEvent :exec
|
||||
INSERT INTO
|
||||
usage_events (
|
||||
|
||||
@@ -117,3 +117,28 @@ WHERE
|
||||
-- Parentheses are necessary to avoid sqlc from generating an extra
|
||||
-- argument.
|
||||
AND day BETWEEN date_trunc('day', (@start_date::timestamptz) AT TIME ZONE 'UTC')::date AND date_trunc('day', (@end_date::timestamptz) AT TIME ZONE 'UTC')::date;
|
||||
|
||||
-- name: GetTotalUsageHBAgentRuntimeV1 :one
|
||||
-- Gets the total Coder Agent runtime in milliseconds between two timestamps.
|
||||
-- The start bound is inclusive and the end bound is exclusive.
|
||||
--
|
||||
-- Unlike GetTotalUsageDCManagedAgentsV1 this reads usage_events directly
|
||||
-- rather than the usage_events_daily rollup: hb_agent_runtime_v1 is exactly
|
||||
-- one row per hourly bucket deployment-wide, with created_at at the bucket
|
||||
-- start, enforced by the unique partial index
|
||||
-- idx_usage_events_agent_runtime (which also keeps SUM from counting a
|
||||
-- bucket twice and serves this query). The result is bucket-granular: a
|
||||
-- bucket counts entirely against the period containing its start. See
|
||||
-- enterprise/coderd/usage/generator.go for what a bucket holds. If a
|
||||
-- usage_events retention policy ever lands, this must move to the daily
|
||||
-- rollup and accept day-granularity bounds.
|
||||
SELECT
|
||||
-- The first cast is necessary since you can't sum strings, and the second
|
||||
-- cast is necessary to make sqlc happy.
|
||||
COALESCE(SUM((event_data->>'runtime_ms')::bigint), 0)::bigint AS total_runtime_ms
|
||||
FROM
|
||||
usage_events
|
||||
WHERE
|
||||
event_type = 'hb_agent_runtime_v1'
|
||||
AND created_at >= @start_time::timestamptz
|
||||
AND created_at < @end_time::timestamptz;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/usage/usagetypes"
|
||||
)
|
||||
|
||||
// TestGetTotalUsageHBAgentRuntimeV1QueryEventType pins the event type and
|
||||
// payload extraction literals in the generated SQL to the Go producer.
|
||||
// Renaming either would make this read-only query silently return 0 (->> on
|
||||
// a missing key yields NULL, SUM skips NULLs, COALESCE reports 0), which is
|
||||
// indistinguishable from zero usage at every layer above it.
|
||||
func TestGetTotalUsageHBAgentRuntimeV1QueryEventType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Contains(t, getTotalUsageHBAgentRuntimeV1,
|
||||
string(usagetypes.UsageEventTypeHBAgentRuntimeV1))
|
||||
// The full extraction expression is pinned, not the bare key: the
|
||||
// query's result alias (total_runtime_ms) contains "runtime_ms", so a
|
||||
// bare-key assertion would keep passing after the ->> key was renamed.
|
||||
for field := range (usagetypes.HBAgentRuntime{}).Fields() {
|
||||
require.Contains(t, getTotalUsageHBAgentRuntimeV1,
|
||||
"event_data->>'"+field+"'")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user