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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user