feat(site): Add deployment-wide DAU chart (#5810)

This commit is contained in:
Presley Pizzo
2023-01-25 20:03:47 -05:00
committed by GitHub
parent e7b8318b87
commit 16d8cc4176
26 changed files with 568 additions and 31 deletions

36
coderd/apidoc/docs.go generated
View File

@ -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": [

View File

@ -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"],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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