Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
PR comments
  • Loading branch information
deansheather committed Aug 15, 2025
commit 3afa02e651444ee616472e668f14663b9bee79e3
4 changes: 2 additions & 2 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@ coderd/schedule/autostop.go @deansheather @DanielleMaywood

# Usage tracking code requires intimate knowledge of Tallyman and Metronome, as
# well as guidance from revenue.
coderd/usage/ @deansheather
enterprise/coderd/usage/ @deansheather
coderd/usage/ @deansheather @spikecurtis
enterprise/coderd/usage/ @deansheather @spikecurtis
1 change: 1 addition & 0 deletions coderd/database/check_constraint.go

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

32 changes: 29 additions & 3 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,24 @@ var (
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()

subjectUsageTracker = rbac.Subject{
Type: rbac.SubjectTypeUsageTracker,
FriendlyName: "Usage Tracker",
ID: uuid.Nil.String(),
Roles: rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleIdentifier{Name: "usage-tracker"},
DisplayName: "Usage Tracker",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceUsageEvent.Type: {policy.ActionCreate, policy.ActionRead, policy.ActionUpdate},
}),
Org: map[string][]rbac.Permission{},
User: []rbac.Permission{},
},
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
)

// AsProvisionerd returns a context with an actor that has permissions required
Expand Down Expand Up @@ -579,10 +597,18 @@ func AsPrebuildsOrchestrator(ctx context.Context) context.Context {
return As(ctx, subjectPrebuildsOrchestrator)
}

// AsFileReader returns a context with an actor that has permissions required
// for reading all files.
func AsFileReader(ctx context.Context) context.Context {
return As(ctx, subjectFileReader)
}

// AsUsageTracker returns a context with an actor that has permissions required
// for creating, reading, and updating usage events.
func AsUsageTracker(ctx context.Context) context.Context {
return As(ctx, subjectUsageTracker)
}

var AsRemoveActor = rbac.Subject{
ID: "remove-actor",
}
Expand Down Expand Up @@ -3952,7 +3978,7 @@ func (q *querier) InsertTemplateVersionWorkspaceTag(ctx context.Context, arg dat
}

func (q *querier) InsertUsageEvent(ctx context.Context, arg database.InsertUsageEventParams) error {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceUsageEvent); err != nil {
return err
}
return q.db.InsertUsageEvent(ctx, arg)
Expand Down Expand Up @@ -4315,7 +4341,7 @@ func (q *querier) RevokeDBCryptKey(ctx context.Context, activeKeyDigest string)

func (q *querier) SelectUsageEventsForPublishing(ctx context.Context, arg time.Time) ([]database.UsageEvent, error) {
// ActionUpdate because we're updating the publish_started_at column.
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceUsageEvent); err != nil {
return nil, err
}
return q.db.SelectUsageEventsForPublishing(ctx, arg)
Expand Down Expand Up @@ -4803,7 +4829,7 @@ func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg da
}

func (q *querier) UpdateUsageEventsPostPublish(ctx context.Context, arg database.UpdateUsageEventsPostPublishParams) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceUsageEvent); err != nil {
return err
}
return q.db.UpdateUsageEventsPostPublish(ctx, arg)
Expand Down
27 changes: 17 additions & 10 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5668,25 +5668,32 @@ func (s *MethodTestSuite) TestUserSecrets() {
}

func (s *MethodTestSuite) TestUsageEvents() {
s.Run("InsertUsageEvent", s.Subtest(func(db database.Store, check *expects) {
check.Args(database.InsertUsageEventParams{
s.Run("InsertUsageEvent", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
Copy link
Member

Choose a reason for hiding this comment

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

Mocked! Nice! Been slowly converting them all 👍

params := database.InsertUsageEventParams{
ID: "1",
EventType: database.UsageEventTypeDcManagedAgentsV1,
EventType: "dc_managed_agents_v1",
EventData: []byte("{}"),
CreatedAt: dbtime.Now(),
}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
}
db.EXPECT().InsertUsageEvent(gomock.Any(), params).Return(nil)
check.Args(params).Asserts(rbac.ResourceUsageEvent, policy.ActionCreate)
}))

s.Run("SelectUsageEventsForPublishing", s.Subtest(func(db database.Store, check *expects) {
check.Args(dbtime.Now()).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
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)
check.Args(now).Asserts(rbac.ResourceUsageEvent, policy.ActionUpdate)
}))

s.Run("UpdateUsageEventsPostPublish", s.Subtest(func(db database.Store, check *expects) {
check.Args(database.UpdateUsageEventsPostPublishParams{
Now: dbtime.Now(),
s.Run("UpdateUsageEventsPostPublish", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
now := dbtime.Now()
params := database.UpdateUsageEventsPostPublishParams{
Now: now,
IDs: []string{"1", "2"},
FailureMessages: []string{"error", "error"},
SetPublishedAts: []bool{false, false},
}).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
}
db.EXPECT().UpdateUsageEventsPostPublish(gomock.Any(), params).Return(nil)
check.Args(params).Asserts(rbac.ResourceUsageEvent, policy.ActionUpdate)
}))
}
19 changes: 6 additions & 13 deletions coderd/database/dump.sql

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

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE usage_events;
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
CREATE TYPE usage_event_type AS ENUM (
'dc_managed_agents_v1'
);

COMMENT ON TYPE usage_event_type IS 'The usage event type with version. "dc" means "discrete" (e.g. a single event, for counters), "hb" means "heartbeat" (e.g. a recurring event that contains a total count of usage generated from the database, for gauges).';

CREATE TABLE usage_events (
id TEXT PRIMARY KEY,
event_type usage_event_type NOT NULL,
-- We use a TEXT column with a CHECK constraint rather than an enum because of
-- the limitations with adding new values to an enum and using them in the
-- same transaction.
event_type TEXT NOT NULL CONSTRAINT usage_event_type_check CHECK (event_type IN ('dc_managed_agents_v1')),
Comment on lines +3 to +6
Copy link
Member

Choose a reason for hiding this comment

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

Adding enum values is actually easy now:

ALTER TYPE notification_method ADD VALUE IF NOT EXISTS 'inbox';

Removing them though is a problem:

-- The migration is about an enum value change
-- As we can not remove a value from an enum, we can let the down migration empty
-- In order to avoid any failure, we use ADD VALUE IF NOT EXISTS to add the value

(Although we never run down migrations so....)


Making it an enum generates the proper Golang enum

Copy link
Member Author

Choose a reason for hiding this comment

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

Spike asked me to not do this in a prior comment because you can't add a value and then reference it in the same transaction unless you're on pg 17. We run all migrations in a single transaction (for good reason, to avoid partial upgrades), which means we can never reference enum types in future transactions.

event_data JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
publish_started_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
Expand All @@ -16,11 +13,13 @@ CREATE TABLE usage_events (

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.';
COMMENT ON COLUMN usage_events.id IS 'For "discrete" event types, this is a random UUID. For "heartbeat" event types, this is a combination of the event type and a truncated timestamp.';
COMMENT ON COLUMN usage_events.event_type IS 'The usage event type with version. "dc" means "discrete" (e.g. a single event, for counters), "hb" means "heartbeat" (e.g. a recurring event that contains a total count of usage generated from the database, for gauges).';
COMMENT ON COLUMN usage_events.event_data IS 'Event payload. Determined by the matching usage struct for this event type.';
COMMENT ON COLUMN usage_events.publish_started_at IS 'Set to a timestamp while the event is being published by a Coder replica to the usage collector service. Used to avoid duplicate publishes by multiple replicas. Timestamps older than 1 hour are considered expired.';
COMMENT ON COLUMN usage_events.published_at IS 'Set to a timestamp when the event is successfully (or permanently unsuccessfully) published to the usage collector service. If set, the event should never be attempted to be published again.';
COMMENT ON COLUMN usage_events.failure_message IS 'Set to an error message when the event is temporarily or permanently unsuccessfully published to the usage collector service.';

CREATE INDEX idx_usage_events_created_at ON usage_events (created_at);
CREATE INDEX idx_usage_events_publish_started_at ON usage_events (publish_started_at);
CREATE INDEX idx_usage_events_published_at ON usage_events (published_at);
-- Create an index with all three fields used by the
-- SelectUsageEventsForPublishing query.
CREATE INDEX idx_usage_events_select_for_publishing
ON usage_events (published_at, publish_started_at, created_at);
9 changes: 0 additions & 9 deletions coderd/database/modelmethods.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/hex"
"sort"
"strconv"
"strings"
"time"

"github.com/google/uuid"
Expand Down Expand Up @@ -637,11 +636,3 @@ func (m WorkspaceAgentVolumeResourceMonitor) Debounce(
func (s UserSecret) RBACObject() rbac.Object {
return rbac.ResourceUserSecret.WithID(s.ID).WithOwner(s.UserID.String())
}

func (e UsageEventType) IsDiscrete() bool {
return e.Valid() && strings.HasPrefix(string(e), "dc_")
}

func (e UsageEventType) IsHeartbeat() bool {
return e.Valid() && strings.HasPrefix(string(e), "hb_")
}
61 changes: 3 additions & 58 deletions coderd/database/models.go

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

28 changes: 19 additions & 9 deletions coderd/database/queries.sql.go

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

Loading