chore: add usage tracking package (#19095)

Not used in coderd yet, see stack.

Adds two new packages:
- `coderd/usage`: provides an interface for the "Collector" as well as a stub implementation for AGPL
- `enterprise/coderd/usage`: provides an interface for the "Publisher" as well as a Tallyman implementation

Relates to https://github.com/coder/internal/issues/814
This commit is contained in:
Dean Sheather
2025-08-16 01:31:00 +10:00
committed by GitHub
parent e92af2b050
commit a25d85631b
36 changed files with 2069 additions and 17 deletions
+155
View File
@@ -13519,6 +13519,161 @@ func (q *sqlQuerier) DisableForeignKeysAndTriggers(ctx context.Context) error {
return err
}
const insertUsageEvent = `-- name: InsertUsageEvent :exec
INSERT INTO
usage_events (
id,
event_type,
event_data,
created_at,
publish_started_at,
published_at,
failure_message
)
VALUES
($1, $2, $3, $4, NULL, NULL, NULL)
ON CONFLICT (id) DO NOTHING
`
type InsertUsageEventParams struct {
ID string `db:"id" json:"id"`
EventType string `db:"event_type" json:"event_type"`
EventData json.RawMessage `db:"event_data" json:"event_data"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// Duplicate events are ignored intentionally to allow for multiple replicas to
// publish heartbeat events.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
arg.EventType,
arg.EventData,
arg.CreatedAt,
)
return err
}
const selectUsageEventsForPublishing = `-- name: SelectUsageEventsForPublishing :many
WITH usage_events AS (
UPDATE
usage_events
SET
publish_started_at = $1::timestamptz
WHERE
id IN (
SELECT
potential_event.id
FROM
usage_events potential_event
WHERE
-- Do not publish events that have already been published or
-- have permanently failed to publish.
potential_event.published_at IS NULL
-- Do not publish events that are already being published by
-- another replica.
AND (
potential_event.publish_started_at IS NULL
-- If the event has publish_started_at set, it must be older
-- than an hour ago. This is so we can retry publishing
-- events where the replica exited or couldn't update the
-- row.
-- The parenthesis around @now::timestamptz are necessary to
-- avoid sqlc from generating an extra argument.
OR potential_event.publish_started_at < ($1::timestamptz) - INTERVAL '1 hour'
)
-- Do not publish events older than 30 days. Tallyman will
-- always permanently reject these events anyways. This is to
-- avoid duplicate events being billed to customers, as
-- Metronome will only deduplicate events within 34 days.
-- Also, the same parenthesis thing here as above.
AND potential_event.created_at > ($1::timestamptz) - INTERVAL '30 days'
ORDER BY potential_event.created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT 100
)
RETURNING id, event_type, event_data, created_at, publish_started_at, published_at, failure_message
)
SELECT id, event_type, event_data, created_at, publish_started_at, published_at, failure_message
FROM usage_events
ORDER BY created_at ASC
`
// Note that this selects from the CTE, not the original table. The CTE is named
// the same as the original table to trick sqlc into reusing the existing struct
// for the table.
// The CTE and the reorder is required because UPDATE doesn't guarantee order.
func (q *sqlQuerier) SelectUsageEventsForPublishing(ctx context.Context, now time.Time) ([]UsageEvent, error) {
rows, err := q.db.QueryContext(ctx, selectUsageEventsForPublishing, now)
if err != nil {
return nil, err
}
defer rows.Close()
var items []UsageEvent
for rows.Next() {
var i UsageEvent
if err := rows.Scan(
&i.ID,
&i.EventType,
&i.EventData,
&i.CreatedAt,
&i.PublishStartedAt,
&i.PublishedAt,
&i.FailureMessage,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateUsageEventsPostPublish = `-- name: UpdateUsageEventsPostPublish :exec
UPDATE
usage_events
SET
publish_started_at = NULL,
published_at = CASE WHEN input.set_published_at THEN $1::timestamptz ELSE NULL END,
failure_message = NULLIF(input.failure_message, '')
FROM (
SELECT
UNNEST($2::text[]) AS id,
UNNEST($3::text[]) AS failure_message,
UNNEST($4::boolean[]) AS set_published_at
) input
WHERE
input.id = usage_events.id
-- If the number of ids, failure messages, and set published ats are not the
-- same, do not do anything. Unfortunately you can't really throw from a
-- query without writing a function or doing some jank like dividing by
-- zero, so this is the best we can do.
AND cardinality($2::text[]) = cardinality($3::text[])
AND cardinality($2::text[]) = cardinality($4::boolean[])
`
type UpdateUsageEventsPostPublishParams struct {
Now time.Time `db:"now" json:"now"`
IDs []string `db:"ids" json:"ids"`
FailureMessages []string `db:"failure_messages" json:"failure_messages"`
SetPublishedAts []bool `db:"set_published_ats" json:"set_published_ats"`
}
func (q *sqlQuerier) UpdateUsageEventsPostPublish(ctx context.Context, arg UpdateUsageEventsPostPublishParams) error {
_, err := q.db.ExecContext(ctx, updateUsageEventsPostPublish,
arg.Now,
pq.Array(arg.IDs),
pq.Array(arg.FailureMessages),
pq.Array(arg.SetPublishedAts),
)
return 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