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:
Generated
+8
-2
@@ -20841,6 +20841,11 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actual": {
|
||||
"description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds, with the precise value available in\nActualMs. FeatureAgentRuntimeHours usage can trail by roughly one\nhour because the current hour is not emitted, plus the entitlement\nrefresh interval.",
|
||||
"type": "integer"
|
||||
},
|
||||
"actual_ms": {
|
||||
"description": "ActualMs is the precise usage backing Actual, in milliseconds, for\nfeatures measured in time. It has the same freshness as Actual.\nOnly FeatureAgentRuntimeHours sets this field.",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
@@ -20854,14 +20859,15 @@ const docTemplate = `{
|
||||
"type": "integer"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Limit is the maximum value the license grants for the feature, in the\nfeature's own unit. For FeatureAgentRuntimeHours, an enabled feature\nwith Limit omitted means the license grants unlimited runtime hours.",
|
||||
"type": "integer"
|
||||
},
|
||||
"soft_limit": {
|
||||
"description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours",
|
||||
"description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation; an unlimited allocation has no thresholds,\nso SoftLimit is omitted alongside the omitted Limit. Only\nFeatureAgentRuntimeHours sets this field.",
|
||||
"type": "integer"
|
||||
},
|
||||
"usage_period": {
|
||||
"description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours",
|
||||
"description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense). These dates are determined from the license that this\nentitlement comes from, see enterprise/coderd/license/license.go.\nOnly FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this\nfield.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.UsagePeriod"
|
||||
|
||||
Generated
+8
-2
@@ -18963,6 +18963,11 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actual": {
|
||||
"description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds, with the precise value available in\nActualMs. FeatureAgentRuntimeHours usage can trail by roughly one\nhour because the current hour is not emitted, plus the entitlement\nrefresh interval.",
|
||||
"type": "integer"
|
||||
},
|
||||
"actual_ms": {
|
||||
"description": "ActualMs is the precise usage backing Actual, in milliseconds, for\nfeatures measured in time. It has the same freshness as Actual.\nOnly FeatureAgentRuntimeHours sets this field.",
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
@@ -18976,14 +18981,15 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"limit": {
|
||||
"description": "Limit is the maximum value the license grants for the feature, in the\nfeature's own unit. For FeatureAgentRuntimeHours, an enabled feature\nwith Limit omitted means the license grants unlimited runtime hours.",
|
||||
"type": "integer"
|
||||
},
|
||||
"soft_limit": {
|
||||
"description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours",
|
||||
"description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation; an unlimited allocation has no thresholds,\nso SoftLimit is omitted alongside the omitted Limit. Only\nFeatureAgentRuntimeHours sets this field.",
|
||||
"type": "integer"
|
||||
},
|
||||
"usage_period": {
|
||||
"description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours",
|
||||
"description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense). These dates are determined from the license that this\nentitlement comes from, see enterprise/coderd/license/license.go.\nOnly FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this\nfield.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.UsagePeriod"
|
||||
|
||||
@@ -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