Skip to content

Commit 16d8cc4

Browse files
authored
feat(site): Add deployment-wide DAU chart (#5810)
1 parent e7b8318 commit 16d8cc4

File tree

26 files changed

+568
-31
lines changed

26 files changed

+568
-31
lines changed

coderd/apidoc/docs.go

+36
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+32
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

+4-1
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,10 @@ func New(options *Options) *API {
621621
r.Get("/", api.workspaceApplicationAuth)
622622
})
623623
})
624-
624+
r.Route("/insights", func(r chi.Router) {
625+
r.Use(apiKeyMiddleware)
626+
r.Get("/daus", api.deploymentDAUs)
627+
})
625628
r.Route("/debug", func(r chi.Router) {
626629
r.Use(
627630
apiKeyMiddleware,

coderd/database/databasefake/databasefake.go

+36
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,42 @@ func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) (
323323
return rs, nil
324324
}
325325

326+
func (q *fakeQuerier) GetDeploymentDAUs(_ context.Context) ([]database.GetDeploymentDAUsRow, error) {
327+
q.mutex.Lock()
328+
defer q.mutex.Unlock()
329+
330+
seens := make(map[time.Time]map[uuid.UUID]struct{})
331+
332+
for _, as := range q.agentStats {
333+
date := as.CreatedAt.Truncate(time.Hour * 24)
334+
335+
dateEntry := seens[date]
336+
if dateEntry == nil {
337+
dateEntry = make(map[uuid.UUID]struct{})
338+
}
339+
dateEntry[as.UserID] = struct{}{}
340+
seens[date] = dateEntry
341+
}
342+
343+
seenKeys := maps.Keys(seens)
344+
sort.Slice(seenKeys, func(i, j int) bool {
345+
return seenKeys[i].Before(seenKeys[j])
346+
})
347+
348+
var rs []database.GetDeploymentDAUsRow
349+
for _, key := range seenKeys {
350+
ids := seens[key]
351+
for id := range ids {
352+
rs = append(rs, database.GetDeploymentDAUsRow{
353+
Date: key,
354+
UserID: id,
355+
})
356+
}
357+
}
358+
359+
return rs, nil
360+
}
361+
326362
func (q *fakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) {
327363
if err := validateDatabaseType(arg); err != nil {
328364
return database.GetTemplateAverageBuildTimeRow{}, err

coderd/database/querier.go

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

+40
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/agentstats.sql

+11
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,16 @@ GROUP BY
2525
ORDER BY
2626
date ASC;
2727

28+
-- name: GetDeploymentDAUs :many
29+
SELECT
30+
(created_at at TIME ZONE 'UTC')::date as date,
31+
user_id
32+
FROM
33+
agent_stats
34+
GROUP BY
35+
date, user_id
36+
ORDER BY
37+
date ASC;
38+
2839
-- name: DeleteOldAgentStats :exec
2940
DELETE FROM agent_stats WHERE created_at < NOW() - INTERVAL '30 days';

coderd/insights.go

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package coderd
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/coder/coder/coderd/httpapi"
7+
"github.com/coder/coder/coderd/rbac"
8+
"github.com/coder/coder/codersdk"
9+
)
10+
11+
// @Summary Get deployment DAUs
12+
// @ID get-deployment-daus
13+
// @Security CoderSessionToken
14+
// @Produce json
15+
// @Tags Insights
16+
// @Success 200 {object} codersdk.DeploymentDAUsResponse
17+
// @Router /insights/daus [get]
18+
func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) {
19+
ctx := r.Context()
20+
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentConfig) {
21+
httpapi.Forbidden(rw)
22+
return
23+
}
24+
25+
resp, _ := api.metricsCache.DeploymentDAUs()
26+
if resp == nil || resp.Entries == nil {
27+
httpapi.Write(ctx, rw, http.StatusOK, &codersdk.DeploymentDAUsResponse{
28+
Entries: []codersdk.DAUEntry{},
29+
})
30+
return
31+
}
32+
httpapi.Write(ctx, rw, http.StatusOK, resp)
33+
}

coderd/insights_test.go

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package coderd_test
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/google/uuid"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"cdr.dev/slog/sloggers/slogtest"
13+
"github.com/coder/coder/agent"
14+
"github.com/coder/coder/coderd/coderdtest"
15+
"github.com/coder/coder/codersdk"
16+
"github.com/coder/coder/provisioner/echo"
17+
"github.com/coder/coder/provisionersdk/proto"
18+
"github.com/coder/coder/testutil"
19+
)
20+
21+
func TestDeploymentInsights(t *testing.T) {
22+
t.Parallel()
23+
24+
client := coderdtest.New(t, &coderdtest.Options{
25+
IncludeProvisionerDaemon: true,
26+
AgentStatsRefreshInterval: time.Millisecond * 100,
27+
MetricsCacheRefreshInterval: time.Millisecond * 100,
28+
})
29+
30+
user := coderdtest.CreateFirstUser(t, client)
31+
authToken := uuid.NewString()
32+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
33+
Parse: echo.ParseComplete,
34+
ProvisionPlan: echo.ProvisionComplete,
35+
ProvisionApply: []*proto.Provision_Response{{
36+
Type: &proto.Provision_Response_Complete{
37+
Complete: &proto.Provision_Complete{
38+
Resources: []*proto.Resource{{
39+
Name: "example",
40+
Type: "aws_instance",
41+
Agents: []*proto.Agent{{
42+
Id: uuid.NewString(),
43+
Auth: &proto.Agent_Token{
44+
Token: authToken,
45+
},
46+
}},
47+
}},
48+
},
49+
},
50+
}},
51+
})
52+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
53+
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
54+
55+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
56+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
57+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
58+
59+
agentClient := codersdk.New(client.URL)
60+
agentClient.SetSessionToken(authToken)
61+
agentCloser := agent.New(agent.Options{
62+
Logger: slogtest.Make(t, nil),
63+
Client: agentClient,
64+
})
65+
defer func() {
66+
_ = agentCloser.Close()
67+
}()
68+
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
69+
70+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
71+
defer cancel()
72+
73+
daus, err := client.DeploymentDAUs(context.Background())
74+
require.NoError(t, err)
75+
76+
require.Equal(t, &codersdk.DeploymentDAUsResponse{
77+
Entries: []codersdk.DAUEntry{},
78+
}, daus, "no DAUs when stats are empty")
79+
80+
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
81+
require.NoError(t, err)
82+
assert.Zero(t, res.Workspaces[0].LastUsedAt)
83+
84+
conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{
85+
Logger: slogtest.Make(t, nil).Named("tailnet"),
86+
})
87+
require.NoError(t, err)
88+
defer func() {
89+
_ = conn.Close()
90+
}()
91+
92+
sshConn, err := conn.SSHClient(ctx)
93+
require.NoError(t, err)
94+
_ = sshConn.Close()
95+
96+
wantDAUs := &codersdk.DeploymentDAUsResponse{
97+
Entries: []codersdk.DAUEntry{
98+
{
99+
100+
Date: time.Now().UTC().Truncate(time.Hour * 24),
101+
Amount: 1,
102+
},
103+
},
104+
}
105+
require.Eventuallyf(t, func() bool {
106+
daus, err = client.DeploymentDAUs(ctx)
107+
require.NoError(t, err)
108+
return len(daus.Entries) > 0
109+
},
110+
testutil.WaitShort, testutil.IntervalFast,
111+
"deployment daus never loaded",
112+
)
113+
gotDAUs, err := client.DeploymentDAUs(ctx)
114+
require.NoError(t, err)
115+
require.Equal(t, gotDAUs, wantDAUs)
116+
117+
template, err = client.Template(ctx, template.ID)
118+
require.NoError(t, err)
119+
120+
res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{})
121+
require.NoError(t, err)
122+
}

0 commit comments

Comments
 (0)