mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
Daily Active User Metrics (#3735)
* agent: add StatsReporter * Stabilize protoc
This commit is contained in:
@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
@ -23,6 +24,7 @@ func New() database.Store {
|
||||
mutex: &sync.RWMutex{},
|
||||
data: &data{
|
||||
apiKeys: make([]database.APIKey, 0),
|
||||
agentStats: make([]database.AgentStat, 0),
|
||||
organizationMembers: make([]database.OrganizationMember, 0),
|
||||
organizations: make([]database.Organization, 0),
|
||||
users: make([]database.User, 0),
|
||||
@ -78,6 +80,7 @@ type data struct {
|
||||
userLinks []database.UserLink
|
||||
|
||||
// New tables
|
||||
agentStats []database.AgentStat
|
||||
auditLogs []database.AuditLog
|
||||
files []database.File
|
||||
gitSSHKey []database.GitSSHKey
|
||||
@ -134,6 +137,64 @@ func (q *fakeQuerier) AcquireProvisionerJob(_ context.Context, arg database.Acqu
|
||||
}
|
||||
return database.ProvisionerJob{}, sql.ErrNoRows
|
||||
}
|
||||
func (*fakeQuerier) DeleteOldAgentStats(_ context.Context) error {
|
||||
// no-op
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) InsertAgentStat(_ context.Context, p database.InsertAgentStatParams) (database.AgentStat, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
stat := database.AgentStat{
|
||||
ID: p.ID,
|
||||
CreatedAt: p.CreatedAt,
|
||||
WorkspaceID: p.WorkspaceID,
|
||||
AgentID: p.AgentID,
|
||||
UserID: p.UserID,
|
||||
Payload: p.Payload,
|
||||
TemplateID: p.TemplateID,
|
||||
}
|
||||
q.agentStats = append(q.agentStats, stat)
|
||||
return stat, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) ([]database.GetTemplateDAUsRow, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
counts := make(map[time.Time]map[string]struct{})
|
||||
|
||||
for _, as := range q.agentStats {
|
||||
if as.TemplateID != templateID {
|
||||
continue
|
||||
}
|
||||
|
||||
date := as.CreatedAt.Truncate(time.Hour * 24)
|
||||
dateEntry := counts[date]
|
||||
if dateEntry == nil {
|
||||
dateEntry = make(map[string]struct{})
|
||||
}
|
||||
counts[date] = dateEntry
|
||||
|
||||
dateEntry[as.UserID.String()] = struct{}{}
|
||||
}
|
||||
|
||||
countKeys := maps.Keys(counts)
|
||||
sort.Slice(countKeys, func(i, j int) bool {
|
||||
return countKeys[i].Before(countKeys[j])
|
||||
})
|
||||
|
||||
var rs []database.GetTemplateDAUsRow
|
||||
for _, key := range countKeys {
|
||||
rs = append(rs, database.GetTemplateDAUsRow{
|
||||
Date: key,
|
||||
Amount: int64(len(counts[key])),
|
||||
})
|
||||
}
|
||||
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) ParameterValue(_ context.Context, id uuid.UUID) (database.ParameterValue, error) {
|
||||
q.mutex.Lock()
|
||||
|
17
coderd/database/dump.sql
generated
17
coderd/database/dump.sql
generated
@ -87,6 +87,16 @@ CREATE TYPE workspace_transition AS ENUM (
|
||||
'delete'
|
||||
);
|
||||
|
||||
CREATE TABLE agent_stats (
|
||||
id uuid NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
agent_id uuid NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
template_id uuid NOT NULL,
|
||||
payload jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id text NOT NULL,
|
||||
hashed_secret bytea NOT NULL,
|
||||
@ -372,6 +382,9 @@ CREATE TABLE workspaces (
|
||||
|
||||
ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY agent_stats
|
||||
ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY api_keys
|
||||
ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id);
|
||||
|
||||
@ -468,6 +481,10 @@ ALTER TABLE ONLY workspace_resources
|
||||
ALTER TABLE ONLY workspaces
|
||||
ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id);
|
||||
|
||||
CREATE INDEX idx_agent_stats_created_at ON agent_stats USING btree (created_at);
|
||||
|
||||
CREATE INDEX idx_agent_stats_user_id ON agent_stats USING btree (user_id);
|
||||
|
||||
CREATE INDEX idx_api_keys_user ON api_keys USING btree (user_id);
|
||||
|
||||
CREATE INDEX idx_audit_log_organization_id ON audit_logs USING btree (organization_id);
|
||||
|
1
coderd/database/migrations/000042_agent_stats.down.sql
Normal file
1
coderd/database/migrations/000042_agent_stats.down.sql
Normal file
@ -0,0 +1 @@
|
||||
DROP TABLE agent_stats;
|
16
coderd/database/migrations/000042_agent_stats.up.sql
Normal file
16
coderd/database/migrations/000042_agent_stats.up.sql
Normal file
@ -0,0 +1,16 @@
|
||||
CREATE TABLE agent_stats (
|
||||
id uuid NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
created_at timestamptz NOT NULL,
|
||||
user_id uuid NOT NULL,
|
||||
agent_id uuid NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
template_id uuid NOT NULL,
|
||||
payload jsonb NOT NULL
|
||||
);
|
||||
|
||||
-- We use created_at for DAU analysis and pruning.
|
||||
CREATE INDEX idx_agent_stats_created_at ON agent_stats USING btree (created_at);
|
||||
|
||||
-- We perform user grouping to analyze DAUs.
|
||||
CREATE INDEX idx_agent_stats_user_id ON agent_stats USING btree (user_id);
|
@ -324,6 +324,16 @@ type APIKey struct {
|
||||
IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"`
|
||||
}
|
||||
|
||||
type AgentStat struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
Payload json.RawMessage `db:"payload" json:"payload"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Time time.Time `db:"time" json:"time"`
|
||||
|
@ -22,6 +22,7 @@ type querier interface {
|
||||
DeleteAPIKeyByID(ctx context.Context, id string) error
|
||||
DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error
|
||||
DeleteLicense(ctx context.Context, id int32) (int32, error)
|
||||
DeleteOldAgentStats(ctx context.Context) error
|
||||
DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error
|
||||
GetAPIKeyByID(ctx context.Context, id string) (APIKey, error)
|
||||
GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error)
|
||||
@ -57,6 +58,7 @@ type querier interface {
|
||||
GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error)
|
||||
GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error)
|
||||
GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error)
|
||||
GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error)
|
||||
GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error)
|
||||
GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error)
|
||||
GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error)
|
||||
@ -99,6 +101,7 @@ type querier interface {
|
||||
GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]Workspace, error)
|
||||
GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error)
|
||||
InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error)
|
||||
InsertAgentStat(ctx context.Context, arg InsertAgentStatParams) (AgentStat, error)
|
||||
InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error)
|
||||
InsertDeploymentID(ctx context.Context, value string) error
|
||||
InsertFile(ctx context.Context, arg InsertFileParams) (File, error)
|
||||
|
@ -15,6 +15,104 @@ import (
|
||||
"github.com/tabbed/pqtype"
|
||||
)
|
||||
|
||||
const deleteOldAgentStats = `-- name: DeleteOldAgentStats :exec
|
||||
DELETE FROM AGENT_STATS WHERE created_at < now() - interval '30 days'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) DeleteOldAgentStats(ctx context.Context) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOldAgentStats)
|
||||
return err
|
||||
}
|
||||
|
||||
const getTemplateDAUs = `-- name: GetTemplateDAUs :many
|
||||
select
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
count(distinct(user_id)) as amount
|
||||
from
|
||||
agent_stats
|
||||
where template_id = $1
|
||||
group by
|
||||
date
|
||||
order by
|
||||
date asc
|
||||
`
|
||||
|
||||
type GetTemplateDAUsRow struct {
|
||||
Date time.Time `db:"date" json:"date"`
|
||||
Amount int64 `db:"amount" json:"amount"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getTemplateDAUs, templateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetTemplateDAUsRow
|
||||
for rows.Next() {
|
||||
var i GetTemplateDAUsRow
|
||||
if err := rows.Scan(&i.Date, &i.Amount); 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 insertAgentStat = `-- name: InsertAgentStat :one
|
||||
INSERT INTO
|
||||
agent_stats (
|
||||
id,
|
||||
created_at,
|
||||
user_id,
|
||||
workspace_id,
|
||||
template_id,
|
||||
agent_id,
|
||||
payload
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, user_id, agent_id, workspace_id, template_id, payload
|
||||
`
|
||||
|
||||
type InsertAgentStatParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"`
|
||||
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
|
||||
AgentID uuid.UUID `db:"agent_id" json:"agent_id"`
|
||||
Payload json.RawMessage `db:"payload" json:"payload"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertAgentStat(ctx context.Context, arg InsertAgentStatParams) (AgentStat, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertAgentStat,
|
||||
arg.ID,
|
||||
arg.CreatedAt,
|
||||
arg.UserID,
|
||||
arg.WorkspaceID,
|
||||
arg.TemplateID,
|
||||
arg.AgentID,
|
||||
arg.Payload,
|
||||
)
|
||||
var i AgentStat
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.CreatedAt,
|
||||
&i.UserID,
|
||||
&i.AgentID,
|
||||
&i.WorkspaceID,
|
||||
&i.TemplateID,
|
||||
&i.Payload,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteAPIKeyByID = `-- name: DeleteAPIKeyByID :exec
|
||||
DELETE
|
||||
FROM
|
||||
|
28
coderd/database/queries/agentstats.sql
Normal file
28
coderd/database/queries/agentstats.sql
Normal file
@ -0,0 +1,28 @@
|
||||
-- name: InsertAgentStat :one
|
||||
INSERT INTO
|
||||
agent_stats (
|
||||
id,
|
||||
created_at,
|
||||
user_id,
|
||||
workspace_id,
|
||||
template_id,
|
||||
agent_id,
|
||||
payload
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7) RETURNING *;
|
||||
|
||||
-- name: GetTemplateDAUs :many
|
||||
select
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
count(distinct(user_id)) as amount
|
||||
from
|
||||
agent_stats
|
||||
where template_id = $1
|
||||
group by
|
||||
date
|
||||
order by
|
||||
date asc;
|
||||
|
||||
-- name: DeleteOldAgentStats :exec
|
||||
DELETE FROM AGENT_STATS WHERE created_at < now() - interval '30 days';
|
Reference in New Issue
Block a user