mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore: add tallyman events for ai seat tracking (#22689)
AI seat tracking inserted as heartbeat into usage table.
This commit is contained in:
@@ -6805,6 +6805,13 @@ func (q *querier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg databa
|
||||
return q.db.UpsertWorkspaceAppAuditSession(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UsageEventExistsByID(ctx context.Context, id string) (bool, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUsageEvent); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return q.db.UsageEventExistsByID(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) ValidateGroupIDs(ctx context.Context, groupIDs []uuid.UUID) (database.ValidateGroupIDsRow, error) {
|
||||
// This check is probably overly restrictive, but the "correct" check isn't
|
||||
// necessarily obvious. It's only used as a verification check for ACLs right
|
||||
|
||||
@@ -5123,6 +5123,12 @@ func (s *MethodTestSuite) TestUsageEvents() {
|
||||
check.Args(params).Asserts(rbac.ResourceUsageEvent, policy.ActionCreate)
|
||||
}))
|
||||
|
||||
s.Run("UsageEventExistsByID", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
id := uuid.NewString()
|
||||
db.EXPECT().UsageEventExistsByID(gomock.Any(), id).Return(true, nil)
|
||||
check.Args(id).Asserts(rbac.ResourceUsageEvent, policy.ActionRead)
|
||||
}))
|
||||
|
||||
s.Run("SelectUsageEventsForPublishing", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
|
||||
now := dbtime.Now()
|
||||
db.EXPECT().SelectUsageEventsForPublishing(gomock.Any(), now).Return([]database.UsageEvent{}, nil)
|
||||
|
||||
@@ -4792,6 +4792,14 @@ func (m queryMetricsStore) UpsertWorkspaceAppAuditSession(ctx context.Context, a
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) UsageEventExistsByID(ctx context.Context, id string) (bool, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UsageEventExistsByID(ctx, id)
|
||||
m.queryLatencies.WithLabelValues("UsageEventExistsByID").Observe(time.Since(start).Seconds())
|
||||
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UsageEventExistsByID").Inc()
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m queryMetricsStore) ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (database.ValidateGroupIDsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.ValidateGroupIDs(ctx, groupIds)
|
||||
|
||||
@@ -8945,6 +8945,21 @@ func (mr *MockStoreMockRecorder) UpsertWorkspaceAppAuditSession(ctx, arg any) *g
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertWorkspaceAppAuditSession", reflect.TypeOf((*MockStore)(nil).UpsertWorkspaceAppAuditSession), ctx, arg)
|
||||
}
|
||||
|
||||
// UsageEventExistsByID mocks base method.
|
||||
func (m *MockStore) UsageEventExistsByID(ctx context.Context, id string) (bool, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UsageEventExistsByID", ctx, id)
|
||||
ret0, _ := ret[0].(bool)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UsageEventExistsByID indicates an expected call of UsageEventExistsByID.
|
||||
func (mr *MockStoreMockRecorder) UsageEventExistsByID(ctx, id any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageEventExistsByID", reflect.TypeOf((*MockStore)(nil).UsageEventExistsByID), ctx, id)
|
||||
}
|
||||
|
||||
// ValidateGroupIDs mocks base method.
|
||||
func (m *MockStore) ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (database.ValidateGroupIDsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+15
-6
@@ -620,28 +620,35 @@ CREATE FUNCTION aggregate_usage_event() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Check for supported event types and throw error for unknown types
|
||||
IF NEW.event_type NOT IN ('dc_managed_agents_v1') THEN
|
||||
-- Check for supported event types and throw error for unknown types.
|
||||
IF NEW.event_type NOT IN ('dc_managed_agents_v1', 'hb_ai_seats_v1') THEN
|
||||
RAISE EXCEPTION 'Unhandled usage event type in aggregate_usage_event: %', NEW.event_type;
|
||||
END IF;
|
||||
|
||||
INSERT INTO usage_events_daily (day, event_type, usage_data)
|
||||
VALUES (
|
||||
-- Extract the date from the created_at timestamp, always using UTC for
|
||||
-- consistency
|
||||
date_trunc('day', NEW.created_at AT TIME ZONE 'UTC')::date,
|
||||
NEW.event_type,
|
||||
NEW.event_data
|
||||
)
|
||||
ON CONFLICT (day, event_type) DO UPDATE SET
|
||||
usage_data = CASE
|
||||
-- Handle simple counter events by summing the count
|
||||
-- Handle simple counter events by summing the count.
|
||||
WHEN NEW.event_type IN ('dc_managed_agents_v1') THEN
|
||||
jsonb_build_object(
|
||||
'count',
|
||||
COALESCE((usage_events_daily.usage_data->>'count')::bigint, 0) +
|
||||
COALESCE((NEW.event_data->>'count')::bigint, 0)
|
||||
)
|
||||
-- Heartbeat events: keep the max value seen that day
|
||||
WHEN NEW.event_type IN ('hb_ai_seats_v1') THEN
|
||||
jsonb_build_object(
|
||||
'count',
|
||||
GREATEST(
|
||||
COALESCE((usage_events_daily.usage_data->>'count')::bigint, 0),
|
||||
COALESCE((NEW.event_data->>'count')::bigint, 0)
|
||||
)
|
||||
)
|
||||
END;
|
||||
|
||||
RETURN NEW;
|
||||
@@ -2655,7 +2662,7 @@ CREATE TABLE usage_events (
|
||||
publish_started_at timestamp with time zone,
|
||||
published_at timestamp with time zone,
|
||||
failure_message text,
|
||||
CONSTRAINT usage_event_type_check CHECK ((event_type = 'dc_managed_agents_v1'::text))
|
||||
CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text])))
|
||||
);
|
||||
|
||||
COMMENT ON TABLE usage_events IS 'usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service.';
|
||||
@@ -3710,6 +3717,8 @@ CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree
|
||||
|
||||
CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
|
||||
|
||||
CREATE INDEX idx_usage_events_ai_seats ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_ai_seats_v1'::text);
|
||||
|
||||
CREATE INDEX idx_usage_events_select_for_publishing ON usage_events USING btree (published_at, publish_started_at, created_at);
|
||||
|
||||
CREATE INDEX idx_user_deleted_deleted_at ON user_deleted USING btree (deleted_at);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
DROP INDEX IF EXISTS idx_usage_events_ai_seats;
|
||||
|
||||
-- Remove hb_ai_seats_v1 rows so the original constraint can be restored.
|
||||
DELETE FROM usage_events WHERE event_type = 'hb_ai_seats_v1';
|
||||
DELETE FROM usage_events_daily WHERE event_type = 'hb_ai_seats_v1';
|
||||
|
||||
-- Restore original constraint.
|
||||
ALTER TABLE usage_events
|
||||
DROP CONSTRAINT usage_event_type_check,
|
||||
ADD CONSTRAINT usage_event_type_check CHECK (event_type IN ('dc_managed_agents_v1'));
|
||||
|
||||
-- Restore the original aggregate function without hb_ai_seats_v1 support.
|
||||
CREATE OR REPLACE FUNCTION aggregate_usage_event()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.event_type NOT IN ('dc_managed_agents_v1') THEN
|
||||
RAISE EXCEPTION 'Unhandled usage event type in aggregate_usage_event: %', NEW.event_type;
|
||||
END IF;
|
||||
|
||||
INSERT INTO usage_events_daily (day, event_type, usage_data)
|
||||
VALUES (
|
||||
date_trunc('day', NEW.created_at AT TIME ZONE 'UTC')::date,
|
||||
NEW.event_type,
|
||||
NEW.event_data
|
||||
)
|
||||
ON CONFLICT (day, event_type) DO UPDATE SET
|
||||
usage_data = CASE
|
||||
WHEN NEW.event_type IN ('dc_managed_agents_v1') THEN
|
||||
jsonb_build_object(
|
||||
'count',
|
||||
COALESCE((usage_events_daily.usage_data->>'count')::bigint, 0) +
|
||||
COALESCE((NEW.event_data->>'count')::bigint, 0)
|
||||
)
|
||||
END;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Expand the CHECK constraint to allow hb_ai_seats_v1.
|
||||
ALTER TABLE usage_events
|
||||
DROP CONSTRAINT usage_event_type_check,
|
||||
ADD CONSTRAINT usage_event_type_check CHECK (event_type IN ('dc_managed_agents_v1', 'hb_ai_seats_v1'));
|
||||
|
||||
-- Partial index for efficient lookups of AI seat heartbeat events by time.
|
||||
-- This will be used for the admin dashboard to see seat count over time.
|
||||
CREATE INDEX idx_usage_events_ai_seats
|
||||
ON usage_events (event_type, created_at)
|
||||
WHERE event_type = 'hb_ai_seats_v1';
|
||||
|
||||
-- Update the aggregate function to handle hb_ai_seats_v1 events.
|
||||
-- Heartbeat events replace the previous value for the same time period.
|
||||
CREATE OR REPLACE FUNCTION aggregate_usage_event()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check for supported event types and throw error for unknown types.
|
||||
IF NEW.event_type NOT IN ('dc_managed_agents_v1', 'hb_ai_seats_v1') THEN
|
||||
RAISE EXCEPTION 'Unhandled usage event type in aggregate_usage_event: %', NEW.event_type;
|
||||
END IF;
|
||||
|
||||
INSERT INTO usage_events_daily (day, event_type, usage_data)
|
||||
VALUES (
|
||||
date_trunc('day', NEW.created_at AT TIME ZONE 'UTC')::date,
|
||||
NEW.event_type,
|
||||
NEW.event_data
|
||||
)
|
||||
ON CONFLICT (day, event_type) DO UPDATE SET
|
||||
usage_data = CASE
|
||||
-- Handle simple counter events by summing the count.
|
||||
WHEN NEW.event_type IN ('dc_managed_agents_v1') THEN
|
||||
jsonb_build_object(
|
||||
'count',
|
||||
COALESCE((usage_events_daily.usage_data->>'count')::bigint, 0) +
|
||||
COALESCE((NEW.event_data->>'count')::bigint, 0)
|
||||
)
|
||||
-- Heartbeat events: keep the max value seen that day
|
||||
WHEN NEW.event_type IN ('hb_ai_seats_v1') THEN
|
||||
jsonb_build_object(
|
||||
'count',
|
||||
GREATEST(
|
||||
COALESCE((usage_events_daily.usage_data->>'count')::bigint, 0),
|
||||
COALESCE((NEW.event_data->>'count')::bigint, 0)
|
||||
)
|
||||
)
|
||||
END;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
INSERT INTO usage_events (
|
||||
id,
|
||||
event_type,
|
||||
event_data,
|
||||
created_at,
|
||||
publish_started_at,
|
||||
published_at,
|
||||
failure_message
|
||||
)
|
||||
VALUES
|
||||
-- Unpublished hb_ai_seats_v1 event.
|
||||
(
|
||||
'ai-seats-event1',
|
||||
'hb_ai_seats_v1',
|
||||
'{"count":3}',
|
||||
'2023-06-01 00:00:00+00',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL
|
||||
);
|
||||
@@ -907,6 +907,7 @@ type sqlcQuerier interface {
|
||||
// was started. This means that a new row was inserted (no previous session) or
|
||||
// the updated_at is older than stale interval.
|
||||
UpsertWorkspaceAppAuditSession(ctx context.Context, arg UpsertWorkspaceAppAuditSessionParams) (bool, error)
|
||||
UsageEventExistsByID(ctx context.Context, id string) (bool, error)
|
||||
ValidateGroupIDs(ctx context.Context, groupIds []uuid.UUID) (ValidateGroupIDsRow, error)
|
||||
ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (ValidateUserIDsRow, error)
|
||||
}
|
||||
|
||||
@@ -8315,6 +8315,80 @@ func TestUsageEventsTrigger(t *testing.T) {
|
||||
require.WithinDuration(t, time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC), rows[1].Day, time.Second)
|
||||
})
|
||||
|
||||
t.Run("HeartbeatAISeats", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
db, _, sqlDB := dbtestutil.NewDBWithSQLDB(t)
|
||||
|
||||
// Insert a heartbeat event.
|
||||
err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
ID: "hb-1",
|
||||
EventType: "hb_ai_seats_v1",
|
||||
EventData: []byte(`{"count": 10}`),
|
||||
CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows := getDailyRows(ctx, sqlDB)
|
||||
require.Len(t, rows, 1)
|
||||
require.Equal(t, "hb_ai_seats_v1", rows[0].EventType)
|
||||
require.JSONEq(t, `{"count": 10}`, string(rows[0].UsageData))
|
||||
|
||||
// Insert a higher count on the same day — should take the max.
|
||||
err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
ID: "hb-2",
|
||||
EventType: "hb_ai_seats_v1",
|
||||
EventData: []byte(`{"count": 50}`),
|
||||
CreatedAt: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows = getDailyRows(ctx, sqlDB)
|
||||
require.Len(t, rows, 1)
|
||||
require.JSONEq(t, `{"count": 50}`, string(rows[0].UsageData))
|
||||
|
||||
// Insert a lower count on the same day — should keep the max (50).
|
||||
err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
ID: "hb-3",
|
||||
EventType: "hb_ai_seats_v1",
|
||||
EventData: []byte(`{"count": 25}`),
|
||||
CreatedAt: time.Date(2025, 1, 1, 18, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows = getDailyRows(ctx, sqlDB)
|
||||
require.Len(t, rows, 1)
|
||||
require.JSONEq(t, `{"count": 50}`, string(rows[0].UsageData))
|
||||
|
||||
// Insert on a different day.
|
||||
err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
ID: "hb-4",
|
||||
EventType: "hb_ai_seats_v1",
|
||||
EventData: []byte(`{"count": 5}`),
|
||||
CreatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows = getDailyRows(ctx, sqlDB)
|
||||
require.Len(t, rows, 2)
|
||||
require.JSONEq(t, `{"count": 50}`, string(rows[0].UsageData))
|
||||
require.JSONEq(t, `{"count": 5}`, string(rows[1].UsageData))
|
||||
|
||||
// Also insert a dc_managed_agents_v1 on the same first day to
|
||||
// verify different event types get separate daily rows.
|
||||
err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
|
||||
ID: "dc-1",
|
||||
EventType: "dc_managed_agents_v1",
|
||||
EventData: []byte(`{"count": 7}`),
|
||||
CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
rows = getDailyRows(ctx, sqlDB)
|
||||
require.Len(t, rows, 3)
|
||||
})
|
||||
|
||||
t.Run("UnknownEventType", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -18845,6 +18845,19 @@ func (q *sqlQuerier) UpdateUsageEventsPostPublish(ctx context.Context, arg Updat
|
||||
return err
|
||||
}
|
||||
|
||||
const usageEventExistsByID = `-- name: UsageEventExistsByID :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM usage_events WHERE id = $1
|
||||
)::bool
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) UsageEventExistsByID(ctx context.Context, id string) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, usageEventExistsByID, id)
|
||||
var column_1 bool
|
||||
err := row.Scan(&column_1)
|
||||
return column_1, err
|
||||
}
|
||||
|
||||
const getUserLinkByLinkedID = `-- name: GetUserLinkByLinkedID :one
|
||||
SELECT
|
||||
user_links.user_id, user_links.login_type, user_links.linked_id, user_links.oauth_access_token, user_links.oauth_refresh_token, user_links.oauth_expiry, user_links.oauth_access_token_key_id, user_links.oauth_refresh_token_key_id, user_links.claims
|
||||
|
||||
@@ -15,6 +15,11 @@ VALUES
|
||||
(@id, @event_type, @event_data, @created_at, NULL, NULL, NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- name: UsageEventExistsByID :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM usage_events WHERE id = @id
|
||||
)::bool;
|
||||
|
||||
-- name: SelectUsageEventsForPublishing :many
|
||||
WITH usage_events AS (
|
||||
UPDATE
|
||||
|
||||
Reference in New Issue
Block a user