Daily Active User Metrics (#3735)

* agent: add StatsReporter

* Stabilize protoc
This commit is contained in:
Ammar Bandukwala
2022-09-01 14:58:23 -05:00
committed by GitHub
parent e0cb52ceea
commit 30f8fd9b95
47 changed files with 2006 additions and 279 deletions

View File

@ -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()

View File

@ -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);

View File

@ -0,0 +1 @@
DROP TABLE agent_stats;

View 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);

View File

@ -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"`

View File

@ -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)

View File

@ -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

View 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';