mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add hourly hb_agent_runtime_v1 usage events for Coder Agent runtime (#27312)
closes CODAGT-839 closes CODAGT-843 closes CODAGT-773 ## Summary Adds a new heartbeat usage event type, `hb_agent_runtime_v1`, measuring the total agent-loop runtime of Coder Agents (chats) per UTC hour, plus a reconciler that generates one event per hour with self-healing backfill over a trailing 7-day window. Events flow to Tallyman through the existing publisher unchanged. This measures the new Coder Agents (the `chats` tables), not the deprecated Tasks counted by `dc_managed_agents_v1`. Independent of #27508, which fixes the dead ai-seats cron registration. Both PRs carry the identical `usage_event` create permission hunk for the usage-publisher subject (this feature's generator and the ai-seats cron each need it for heartbeat inserts), so they can land in either order and the overlap merges cleanly. > [!WARNING] > **Do not include this in a release until Tallyman accepts `hb_agent_runtime_v1`.** The publisher marks permanently rejected events as done-forever, and the generator then sees those buckets as complete locally, so their usage would be silently and permanently lost. ## Details Each event's payload is `{"runtime_ms": N}`: the sum of `chat_messages.runtime_ms` for messages created in the hour bucket `[H, H+1)`, across all chats (sub-agents, API-created, archived, and soft-deleted messages included). Events use deterministic IDs (`hb_agent_runtime_v1:<bucket start>`) with `created_at` set to the bucket start, so concurrent replicas race safely via `ON CONFLICT (id) DO NOTHING` without locking, and daily rollups attribute backfilled hours to the correct day. Idle hours produce zero-valued events. A bucket becomes eligible 5 minutes after it closes; hours missing for longer than the 7-day window are forfeited, which can only undercount. Note that this makes `usage_events.created_at` explicitly the *event occurrence time* rather than the row insertion time; the two only diverge for backfilled events. It already behaved as the occurrence timestamp (it drives the daily rollup day and is shipped to Tallyman/Metronome as the event timestamp), and the migration now documents this with a `COMMENT ON COLUMN`, which also surfaces as a Go doc comment on `UsageEvent.CreatedAt`. The new `usage.Generator` runs unconditionally in enterprise builds; the `publish_usage_data` license flag continues to gate egress only, so air-gapped deployments still fill their local ledger. The `aggregate_usage_event()` trigger sums `runtime_ms` per day into `usage_events_daily` (unlike `hb_ai_seats_v1`, which takes the daily max). `InsertHeartbeatUsageEvent` now takes an explicit `createdAt` so generators can backfill historical buckets; the cron passes `clock.Now()` to preserve its existing behavior. ## Tallyman follow-up <details> <summary>Prompt for the Tallyman-repo agent</summary> > **Task**: Add support for the new Coder usage event type `hb_agent_runtime_v1` so Tallyman accepts, validates, and forwards it to Metronome. > > **Background**: coder/coder PR (this PR) adds hourly heartbeat events measuring Coder Agent runtime. Events arrive via the existing `/api/v1/events/ingest` endpoint with: `event_type: "hb_agent_runtime_v1"`, `event_data: {"runtime_ms": <int64 >= 0>}`, deterministic `id` of the form `hb_agent_runtime_v1:2026-07-15_14:00:00` (UTC hour bucket start), and `created_at` set to the bucket start (may be up to ~8 days in the past due to backfill; within Metronome's 34-day dedup window). Zero-value events are normal (idle hours). > > **Work**: > 1. Update Tallyman's vendored/imported `coderd/usage/usagetypes` (or equivalent) to the coder/coder commit that adds `UsageEventTypeHBAgentRuntimeV1` and `HBAgentRuntime`. > 2. Ensure ingestion validation accepts the type (`Valid()` switches) and rejects negative `runtime_ms`. > 3. Ensure Metronome forwarding maps the event with transaction ID derived from the event `id` as for existing types, passing `runtime_ms` through as the property for a SUM-aggregated billable metric ("Coder Agent Hours" = `SUM(runtime_ms) / 3,600,000`). > 4. Do NOT permanently reject unknown-but-well-formed future `hb_*` types if avoidable; at minimum confirm current behavior for unknown types (temporary vs permanent rejection) and report it. > 5. Tests: ingest accept/validate, dedup by ID, Metronome payload mapping. > > **Constraint**: this must be deployed to tallyman-prod **before** any coder/coder release containing the event generator; coderd treats permanent rejections as terminal per event. </details>
This commit is contained in:
@@ -10717,6 +10717,62 @@ func TestUsageEventsTrigger(t *testing.T) {
|
||||
require.Len(t, rows, 3)
|
||||
})
|
||||
|
||||
t.Run("HeartbeatAgentRuntime", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||
|
||||
insert := func(id, eventType, eventData string, createdAt time.Time) {
|
||||
t.Helper()
|
||||
err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
ID: id,
|
||||
EventType: eventType,
|
||||
EventData: []byte(eventData),
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
requireDaily := func(wantUsageData ...string) {
|
||||
t.Helper()
|
||||
rows := getDailyRows(ctx, sqlDB)
|
||||
require.Len(t, rows, len(wantUsageData))
|
||||
for i, want := range wantUsageData {
|
||||
require.JSONEq(t, want, string(rows[i].UsageData))
|
||||
}
|
||||
}
|
||||
|
||||
day1 := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
day2 := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
insert("hb_agent_runtime_v1:2025-01-01_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 1000}`, day1)
|
||||
requireDaily(`{"runtime_ms": 1000}`)
|
||||
|
||||
// Unlike hb_ai_seats_v1, hourly runtime events are summed per day.
|
||||
insert("hb_agent_runtime_v1:2025-01-01_12:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 500}`, day1.Add(12*time.Hour))
|
||||
requireDaily(`{"runtime_ms": 1500}`)
|
||||
|
||||
// Zero-valued events (idle hours) do not change the sum.
|
||||
insert("hb_agent_runtime_v1:2025-01-01_18:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 0}`, day1.Add(18*time.Hour))
|
||||
requireDaily(`{"runtime_ms": 1500}`)
|
||||
|
||||
insert("hb_agent_runtime_v1:2025-01-02_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 250}`, day2)
|
||||
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
|
||||
|
||||
// Re-inserting a bucket must not double-count it. The daily rollup
|
||||
// sums runtime_ms, so idempotency rests on the aggregate trigger
|
||||
// being AFTER INSERT: Postgres does not fire it for rows suppressed
|
||||
// by ON CONFLICT (id) DO NOTHING. Concurrent replicas and backfill
|
||||
// re-runs both take this path.
|
||||
insert("hb_agent_runtime_v1:2025-01-01_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 1000}`, day1)
|
||||
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
|
||||
|
||||
// A different event type on the same day gets its own daily row.
|
||||
insert("hb-seats-1", "hb_ai_seats_v1", `{"count": 3}`, day2)
|
||||
rows := getDailyRows(ctx, sqlDB)
|
||||
require.Len(t, rows, 3)
|
||||
})
|
||||
|
||||
t.Run("UnknownEventType", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -10750,6 +10806,117 @@ func TestUsageEventsTrigger(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetTotalChatMessageRuntimeMsInRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||
|
||||
rangeStart := time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC)
|
||||
rangeEnd := rangeStart.Add(time.Hour)
|
||||
|
||||
total, err := db.GetTotalChatMessageRuntimeMsInRange(ctx, database.GetTotalChatMessageRuntimeMsInRangeParams{
|
||||
StartTime: rangeStart,
|
||||
EndTime: rangeEnd,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, total)
|
||||
|
||||
user := dbgen.User(t, db, database.User{})
|
||||
org := dbgen.Organization(t, db, database.Organization{})
|
||||
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: org.ID})
|
||||
_ = dbgen.ChatProvider(t, db, database.ChatProvider{
|
||||
Provider: "openai",
|
||||
DisplayName: "OpenAI",
|
||||
})
|
||||
mc := dbgen.ChatModelConfig(t, db, database.ChatModelConfig{
|
||||
Model: "test-model",
|
||||
ContextLimit: 8192,
|
||||
})
|
||||
chat1 := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: mc.ID,
|
||||
})
|
||||
chat2 := dbgen.Chat(t, db, database.Chat{
|
||||
OrganizationID: org.ID,
|
||||
OwnerID: user.ID,
|
||||
LastModelConfigID: mc.ID,
|
||||
})
|
||||
|
||||
insertMessage := func(chatID uuid.UUID, runtimeMs int64, createdAt time.Time, deleted bool) {
|
||||
t.Helper()
|
||||
msg := dbgen.ChatMessage(t, db, database.ChatMessage{
|
||||
ChatID: chatID,
|
||||
CreatedBy: uuid.NullUUID{UUID: user.ID, Valid: true},
|
||||
ModelConfigID: uuid.NullUUID{UUID: mc.ID, Valid: true},
|
||||
Role: database.ChatMessageRoleAssistant,
|
||||
RuntimeMs: sql.NullInt64{Int64: runtimeMs, Valid: true},
|
||||
})
|
||||
_, err := sqlDB.ExecContext(ctx, "UPDATE chat_messages SET created_at = $1, deleted = $2 WHERE id = $3", createdAt, deleted, msg.ID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Counted: on the inclusive start boundary, in the middle (across two
|
||||
// chats), soft-deleted, and just before the exclusive end boundary.
|
||||
insertMessage(chat1.ID, 1, rangeStart, false)
|
||||
insertMessage(chat2.ID, 2, rangeStart.Add(30*time.Minute), false)
|
||||
insertMessage(chat1.ID, 4, rangeStart.Add(45*time.Minute), true)
|
||||
insertMessage(chat1.ID, 8, rangeEnd.Add(-time.Second), false)
|
||||
// Not counted: before the range, on the exclusive end boundary, and a
|
||||
// NULL runtime (runtime 0 is stored as NULL).
|
||||
insertMessage(chat1.ID, 16, rangeStart.Add(-time.Second), false)
|
||||
insertMessage(chat1.ID, 32, rangeEnd, false)
|
||||
insertMessage(chat1.ID, 0, rangeStart.Add(10*time.Minute), false)
|
||||
|
||||
total, err = db.GetTotalChatMessageRuntimeMsInRange(ctx, database.GetTotalChatMessageRuntimeMsInRangeParams{
|
||||
StartTime: rangeStart,
|
||||
EndTime: rangeEnd,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 15, total)
|
||||
}
|
||||
|
||||
func TestListUsageEventCreatedAtsByTypeSince(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, _ := dbtestutil.NewDB(t)
|
||||
|
||||
since := time.Date(2025, 3, 10, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
insertEvent := func(id, eventType string, eventData string, createdAt time.Time) {
|
||||
t.Helper()
|
||||
err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
ID: id,
|
||||
EventType: eventType,
|
||||
EventData: []byte(eventData),
|
||||
CreatedAt: createdAt,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Matching type: one before since (excluded), one exactly at since
|
||||
// (included), one after (included).
|
||||
insertEvent("rt-old", "hb_agent_runtime_v1", `{"runtime_ms": 1}`, since.Add(-time.Hour))
|
||||
insertEvent("rt-at", "hb_agent_runtime_v1", `{"runtime_ms": 2}`, since)
|
||||
insertEvent("rt-new", "hb_agent_runtime_v1", `{"runtime_ms": 3}`, since.Add(time.Hour))
|
||||
// Different type after since: excluded.
|
||||
insertEvent("seats-new", "hb_ai_seats_v1", `{"count": 1}`, since.Add(time.Hour))
|
||||
|
||||
createdAts, err := db.ListUsageEventCreatedAtsByTypeSince(ctx, database.ListUsageEventCreatedAtsByTypeSinceParams{
|
||||
EventType: "hb_agent_runtime_v1",
|
||||
Since: since,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, createdAts, 2)
|
||||
normalized := make([]time.Time, len(createdAts))
|
||||
for i, ts := range createdAts {
|
||||
normalized[i] = ts.UTC()
|
||||
}
|
||||
require.ElementsMatch(t, []time.Time{since, since.Add(time.Hour)}, normalized)
|
||||
}
|
||||
|
||||
func TestListTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user