mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
feat(site): Add deployment-wide DAU chart (#5810)
This commit is contained in:
36
coderd/apidoc/docs.go
generated
36
coderd/apidoc/docs.go
generated
@ -648,6 +648,31 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/insights/daus": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Insights"
|
||||
],
|
||||
"summary": "Get deployment DAUs",
|
||||
"operationId": "get-deployment-daus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.DeploymentDAUsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/licenses": {
|
||||
"get": {
|
||||
"security": [
|
||||
@ -6149,6 +6174,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeploymentDAUsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DAUEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Entitlement": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
32
coderd/apidoc/swagger.json
generated
32
coderd/apidoc/swagger.json
generated
@ -558,6 +558,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/insights/daus": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Insights"],
|
||||
"summary": "Get deployment DAUs",
|
||||
"operationId": "get-deployment-daus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.DeploymentDAUsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/licenses": {
|
||||
"get": {
|
||||
"security": [
|
||||
@ -5486,6 +5507,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.DeploymentDAUsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.DAUEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.Entitlement": {
|
||||
"type": "string",
|
||||
"enum": ["entitled", "grace_period", "not_entitled"],
|
||||
|
@ -621,7 +621,10 @@ func New(options *Options) *API {
|
||||
r.Get("/", api.workspaceApplicationAuth)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/insights", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Get("/daus", api.deploymentDAUs)
|
||||
})
|
||||
r.Route("/debug", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
|
@ -323,6 +323,42 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) (
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetDeploymentDAUs(_ context.Context) ([]database.GetDeploymentDAUsRow, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
seens := make(map[time.Time]map[uuid.UUID]struct{})
|
||||
|
||||
for _, as := range q.agentStats {
|
||||
date := as.CreatedAt.Truncate(time.Hour * 24)
|
||||
|
||||
dateEntry := seens[date]
|
||||
if dateEntry == nil {
|
||||
dateEntry = make(map[uuid.UUID]struct{})
|
||||
}
|
||||
dateEntry[as.UserID] = struct{}{}
|
||||
seens[date] = dateEntry
|
||||
}
|
||||
|
||||
seenKeys := maps.Keys(seens)
|
||||
sort.Slice(seenKeys, func(i, j int) bool {
|
||||
return seenKeys[i].Before(seenKeys[j])
|
||||
})
|
||||
|
||||
var rs []database.GetDeploymentDAUsRow
|
||||
for _, key := range seenKeys {
|
||||
ids := seens[key]
|
||||
for id := range ids {
|
||||
rs = append(rs, database.GetDeploymentDAUsRow{
|
||||
Date: key,
|
||||
UserID: id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.GetTemplateAverageBuildTimeRow{}, err
|
||||
|
@ -40,6 +40,7 @@ type sqlcQuerier interface {
|
||||
// are included.
|
||||
GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error)
|
||||
GetDERPMeshKey(ctx context.Context) (string, error)
|
||||
GetDeploymentDAUs(ctx context.Context) ([]GetDeploymentDAUsRow, error)
|
||||
GetDeploymentID(ctx context.Context) (string, error)
|
||||
GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error)
|
||||
GetFileByID(ctx context.Context, id uuid.UUID) (File, error)
|
||||
|
@ -25,6 +25,46 @@ func (q *sqlQuerier) DeleteOldAgentStats(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const getDeploymentDAUs = `-- name: GetDeploymentDAUs :many
|
||||
SELECT
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
user_id
|
||||
FROM
|
||||
agent_stats
|
||||
GROUP BY
|
||||
date, user_id
|
||||
ORDER BY
|
||||
date ASC
|
||||
`
|
||||
|
||||
type GetDeploymentDAUsRow struct {
|
||||
Date time.Time `db:"date" json:"date"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetDeploymentDAUs(ctx context.Context) ([]GetDeploymentDAUsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getDeploymentDAUs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetDeploymentDAUsRow
|
||||
for rows.Next() {
|
||||
var i GetDeploymentDAUsRow
|
||||
if err := rows.Scan(&i.Date, &i.UserID); 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 getTemplateDAUs = `-- name: GetTemplateDAUs :many
|
||||
SELECT
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
|
@ -25,5 +25,16 @@ GROUP BY
|
||||
ORDER BY
|
||||
date ASC;
|
||||
|
||||
-- name: GetDeploymentDAUs :many
|
||||
SELECT
|
||||
(created_at at TIME ZONE 'UTC')::date as date,
|
||||
user_id
|
||||
FROM
|
||||
agent_stats
|
||||
GROUP BY
|
||||
date, user_id
|
||||
ORDER BY
|
||||
date ASC;
|
||||
|
||||
-- name: DeleteOldAgentStats :exec
|
||||
DELETE FROM agent_stats WHERE created_at < NOW() - INTERVAL '30 days';
|
||||
|
33
coderd/insights.go
Normal file
33
coderd/insights.go
Normal file
@ -0,0 +1,33 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// @Summary Get deployment DAUs
|
||||
// @ID get-deployment-daus
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Insights
|
||||
// @Success 200 {object} codersdk.DeploymentDAUsResponse
|
||||
// @Router /insights/daus [get]
|
||||
func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentConfig) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
resp, _ := api.metricsCache.DeploymentDAUs()
|
||||
if resp == nil || resp.Entries == nil {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, &codersdk.DeploymentDAUsResponse{
|
||||
Entries: []codersdk.DAUEntry{},
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
122
coderd/insights_test.go
Normal file
122
coderd/insights_test.go
Normal file
@ -0,0 +1,122 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestDeploymentInsights(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
AgentStatsRefreshInterval: time.Millisecond * 100,
|
||||
MetricsCacheRefreshInterval: time.Millisecond * 100,
|
||||
})
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Logger: slogtest.Make(t, nil),
|
||||
Client: agentClient,
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
}()
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
daus, err := client.DeploymentDAUs(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, &codersdk.DeploymentDAUsResponse{
|
||||
Entries: []codersdk.DAUEntry{},
|
||||
}, daus, "no DAUs when stats are empty")
|
||||
|
||||
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, res.Workspaces[0].LastUsedAt)
|
||||
|
||||
conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{
|
||||
Logger: slogtest.Make(t, nil).Named("tailnet"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
sshConn, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
_ = sshConn.Close()
|
||||
|
||||
wantDAUs := &codersdk.DeploymentDAUsResponse{
|
||||
Entries: []codersdk.DAUEntry{
|
||||
{
|
||||
|
||||
Date: time.Now().UTC().Truncate(time.Hour * 24),
|
||||
Amount: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventuallyf(t, func() bool {
|
||||
daus, err = client.DeploymentDAUs(ctx)
|
||||
require.NoError(t, err)
|
||||
return len(daus.Entries) > 0
|
||||
},
|
||||
testutil.WaitShort, testutil.IntervalFast,
|
||||
"deployment daus never loaded",
|
||||
)
|
||||
gotDAUs, err := client.DeploymentDAUs(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, gotDAUs, wantDAUs)
|
||||
|
||||
template, err = client.Template(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{})
|
||||
require.NoError(t, err)
|
||||
}
|
@ -27,6 +27,7 @@ type Cache struct {
|
||||
database database.Store
|
||||
log slog.Logger
|
||||
|
||||
deploymentDAUResponses atomic.Pointer[codersdk.DeploymentDAUsResponse]
|
||||
templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse]
|
||||
templateUniqueUsers atomic.Pointer[map[uuid.UUID]int]
|
||||
templateAverageBuildTime atomic.Pointer[map[uuid.UUID]database.GetTemplateAverageBuildTimeRow]
|
||||
@ -110,6 +111,28 @@ func convertDAUResponse(rows []database.GetTemplateDAUsRow) codersdk.TemplateDAU
|
||||
return resp
|
||||
}
|
||||
|
||||
func convertDeploymentDAUResponse(rows []database.GetDeploymentDAUsRow) codersdk.DeploymentDAUsResponse {
|
||||
respMap := make(map[time.Time][]uuid.UUID)
|
||||
for _, row := range rows {
|
||||
respMap[row.Date] = append(respMap[row.Date], row.UserID)
|
||||
}
|
||||
|
||||
dates := maps.Keys(respMap)
|
||||
slices.SortFunc(dates, func(a, b time.Time) bool {
|
||||
return a.Before(b)
|
||||
})
|
||||
|
||||
var resp codersdk.DeploymentDAUsResponse
|
||||
for _, date := range fillEmptyDays(dates) {
|
||||
resp.Entries = append(resp.Entries, codersdk.DAUEntry{
|
||||
Date: date,
|
||||
Amount: len(respMap[date]),
|
||||
})
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func countUniqueUsers(rows []database.GetTemplateDAUsRow) int {
|
||||
seen := make(map[uuid.UUID]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
@ -130,10 +153,19 @@ func (c *Cache) refresh(ctx context.Context) error {
|
||||
}
|
||||
|
||||
var (
|
||||
deploymentDAUs = codersdk.DeploymentDAUsResponse{}
|
||||
templateDAUs = make(map[uuid.UUID]codersdk.TemplateDAUsResponse, len(templates))
|
||||
templateUniqueUsers = make(map[uuid.UUID]int)
|
||||
templateAverageBuildTimes = make(map[uuid.UUID]database.GetTemplateAverageBuildTimeRow)
|
||||
)
|
||||
|
||||
rows, err := c.database.GetDeploymentDAUs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deploymentDAUs = convertDeploymentDAUResponse(rows)
|
||||
c.deploymentDAUResponses.Store(&deploymentDAUs)
|
||||
|
||||
for _, template := range templates {
|
||||
rows, err := c.database.GetTemplateDAUs(ctx, template.ID)
|
||||
if err != nil {
|
||||
@ -207,6 +239,11 @@ func (c *Cache) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) DeploymentDAUs() (*codersdk.DeploymentDAUsResponse, bool) {
|
||||
m := c.deploymentDAUResponses.Load()
|
||||
return m, m != nil
|
||||
}
|
||||
|
||||
// TemplateDAUs returns an empty response if the template doesn't have users
|
||||
// or is loading for the first time.
|
||||
func (c *Cache) TemplateDAUs(id uuid.UUID) (*codersdk.TemplateDAUsResponse, bool) {
|
||||
|
Reference in New Issue
Block a user