Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions coderd/coderdtest/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,64 @@ var _ usage.Inserter = (*UsageInserter)(nil)

type UsageInserter struct {
sync.Mutex
events []usagetypes.DiscreteEvent
discreteEvents []usagetypes.DiscreteEvent
heartbeatEvents []usagetypes.HeartbeatEvent
seenHeartbeats map[string]struct{}
Comment on lines +17 to +18
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just have map[eventtype]event as a single field? seems like it mostly accomplishes the same thing

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figured order mattered.

}

func NewUsageInserter() *UsageInserter {
return &UsageInserter{
events: []usagetypes.DiscreteEvent{},
discreteEvents: []usagetypes.DiscreteEvent{},
seenHeartbeats: map[string]struct{}{},
heartbeatEvents: []usagetypes.HeartbeatEvent{},
}
}

func (u *UsageInserter) InsertDiscreteUsageEvent(_ context.Context, _ database.Store, event usagetypes.DiscreteEvent) error {
u.Lock()
defer u.Unlock()
u.events = append(u.events, event)
u.discreteEvents = append(u.discreteEvents, event)
return nil
}

func (u *UsageInserter) GetEvents() []usagetypes.DiscreteEvent {
func (u *UsageInserter) InsertHeartbeatUsageEvent(_ context.Context, _ database.Store, id string, event usagetypes.HeartbeatEvent) error {
u.Lock()
defer u.Unlock()
eventsCopy := make([]usagetypes.DiscreteEvent, len(u.events))
copy(eventsCopy, u.events)
if _, seen := u.seenHeartbeats[id]; seen {
return nil
}

u.seenHeartbeats[id] = struct{}{}
u.heartbeatEvents = append(u.heartbeatEvents, event)
return nil
}

func (u *UsageInserter) GetHeartbeatEvents() []usagetypes.HeartbeatEvent {
u.Lock()
defer u.Unlock()
eventsCopy := make([]usagetypes.HeartbeatEvent, len(u.heartbeatEvents))
copy(eventsCopy, u.heartbeatEvents)
return eventsCopy
}

func (u *UsageInserter) GetDiscreteEvents() []usagetypes.DiscreteEvent {
u.Lock()
defer u.Unlock()
eventsCopy := make([]usagetypes.DiscreteEvent, len(u.discreteEvents))
copy(eventsCopy, u.discreteEvents)
return eventsCopy
}

func (u *UsageInserter) TotalEventCount() int {
u.Lock()
defer u.Unlock()
return len(u.discreteEvents) + len(u.heartbeatEvents)
}

func (u *UsageInserter) Reset() {
u.Lock()
defer u.Unlock()
u.events = []usagetypes.DiscreteEvent{}
u.seenHeartbeats = map[string]struct{}{}
u.discreteEvents = []usagetypes.DiscreteEvent{}
u.heartbeatEvents = []usagetypes.HeartbeatEvent{}
}
7 changes: 7 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 15 additions & 6 deletions coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 38 additions & 0 deletions coderd/database/migrations/000444_usage_events_ai_seats.down.sql
Original file line number Diff line number Diff line change
@@ -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;
50 changes: 50 additions & 0 deletions coderd/database/migrations/000444_usage_events_ai_seats.up.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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
);
1 change: 1 addition & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions coderd/database/querier_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading