coder/coderd/insights_test.go

2349 lines
76 KiB
Go

package coderd_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/agent/agenttest"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/batchstats"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbrollup"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/workspaceapps"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/testutil"
)
func TestDeploymentInsights(t *testing.T) {
t.Parallel()
clientTz, err := time.LoadLocation("America/Chicago")
require.NoError(t, err)
db, ps := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
logger := slogtest.Make(t, nil)
rollupEvents := make(chan dbrollup.Event)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup").Leveled(slog.LevelDebug),
db,
dbrollup.WithInterval(time.Millisecond*100),
dbrollup.WithEventChannel(rollupEvents),
),
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
ctx := testutil.Context(t, testutil.WaitLong)
// Pre-check, no permission issues.
daus, err := client.DeploymentDAUs(ctx, codersdk.TimezoneOffsetHour(clientTz))
require.NoError(t, err)
_ = agenttest.New(t, client.URL, authToken)
resources := coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait()
conn, err := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{
Logger: slogtest.Make(t, nil).Named("dialagent"),
})
require.NoError(t, err)
defer conn.Close()
sshConn, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshConn.Close()
sess, err := sshConn.NewSession()
require.NoError(t, err)
defer sess.Close()
r, w := io.Pipe()
defer r.Close()
defer w.Close()
sess.Stdin = r
sess.Stdout = io.Discard
err = sess.Start("cat")
require.NoError(t, err)
for {
select {
case <-ctx.Done():
require.Fail(t, "timed out waiting for deployment daus to update", daus)
case <-rollupEvents:
}
daus, err = client.DeploymentDAUs(ctx, codersdk.TimezoneOffsetHour(clientTz))
require.NoError(t, err)
if len(daus.Entries) > 0 && daus.Entries[len(daus.Entries)-1].Amount > 0 {
break
}
}
}
func TestUserActivityInsights_SanityCheck(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
logger := slogtest.Make(t, nil)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup"),
db,
dbrollup.WithInterval(time.Millisecond*100),
),
})
// Create two users, one that will appear in the report and another that
// won't (due to not having/using a workspace).
user := coderdtest.CreateFirstUser(t, client)
_, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Start an agent so that we can generate stats.
_ = agenttest.New(t, client.URL, authToken)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
// Start must be at the beginning of the day, initialize it early in case
// the day changes so that we get the relevant stats faster.
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong)
defer cancel()
// Connect to the agent to generate usage/latency stats.
conn, err := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{
Logger: logger.Named("client"),
})
require.NoError(t, err)
defer conn.Close()
sshConn, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshConn.Close()
sess, err := sshConn.NewSession()
require.NoError(t, err)
defer sess.Close()
r, w := io.Pipe()
defer r.Close()
defer w.Close()
sess.Stdin = r
sess.Stdout = io.Discard
err = sess.Start("cat")
require.NoError(t, err)
var userActivities codersdk.UserActivityInsightsResponse
require.Eventuallyf(t, func() bool {
// Keep connection active.
_, err := w.Write([]byte("hello world\n"))
if !assert.NoError(t, err) {
return false
}
userActivities, err = client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{
StartTime: today,
EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
TemplateIDs: []uuid.UUID{template.ID},
})
if !assert.NoError(t, err) {
return false
}
return len(userActivities.Report.Users) > 0 && userActivities.Report.Users[0].Seconds > 0
}, testutil.WaitSuperLong, testutil.IntervalMedium, "user activity is missing")
// We got our latency data, close the connection.
_ = sess.Close()
_ = sshConn.Close()
require.Len(t, userActivities.Report.Users, 1, "want only 1 user")
require.Equal(t, userActivities.Report.Users[0].UserID, user.UserID, "want user id to match")
assert.Greater(t, userActivities.Report.Users[0].Seconds, int64(0), "want usage in seconds to be greater than 0")
}
func TestUserLatencyInsights(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
logger := slogtest.Make(t, nil)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 50,
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup"),
db,
dbrollup.WithInterval(time.Millisecond*100),
),
})
// Create two users, one that will appear in the report and another that
// won't (due to not having/using a workspace).
user := coderdtest.CreateFirstUser(t, client)
_, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Start an agent so that we can generate stats.
_ = agenttest.New(t, client.URL, authToken)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
// Start must be at the beginning of the day, initialize it early in case
// the day changes so that we get the relevant stats faster.
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Connect to the agent to generate usage/latency stats.
conn, err := workspacesdk.New(client).
DialAgent(ctx, resources[0].Agents[0].ID, &workspacesdk.DialAgentOptions{
Logger: logger.Named("client"),
})
require.NoError(t, err)
defer conn.Close()
sshConn, err := conn.SSHClient(ctx)
require.NoError(t, err)
defer sshConn.Close()
sess, err := sshConn.NewSession()
require.NoError(t, err)
defer sess.Close()
r, w := io.Pipe()
defer r.Close()
defer w.Close()
sess.Stdin = r
sess.Stdout = io.Discard
err = sess.Start("cat")
require.NoError(t, err)
var userLatencies codersdk.UserLatencyInsightsResponse
require.Eventuallyf(t, func() bool {
// Keep connection active.
_, err := w.Write([]byte("hello world\n"))
if !assert.NoError(t, err) {
return false
}
userLatencies, err = client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
StartTime: today,
EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
TemplateIDs: []uuid.UUID{template.ID},
})
if !assert.NoError(t, err) {
return false
}
return len(userLatencies.Report.Users) > 0 && userLatencies.Report.Users[0].LatencyMS.P50 > 0
}, testutil.WaitMedium, testutil.IntervalFast, "user latency is missing")
// We got our latency data, close the connection.
_ = sess.Close()
_ = sshConn.Close()
require.Len(t, userLatencies.Report.Users, 1, "want only 1 user")
require.Equal(t, userLatencies.Report.Users[0].UserID, user.UserID, "want user id to match")
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P50, float64(0), "want p50 to be greater than 0")
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P95, float64(0), "want p95 to be greater than 0")
}
func TestUserLatencyInsights_BadRequest(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{})
_ = coderdtest.CreateFirstUser(t, client)
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
StartTime: today,
EndTime: today.AddDate(0, 0, -1),
})
assert.Error(t, err, "want error for end time before start time")
}
func TestUserActivityInsights_BadRequest(t *testing.T) {
t.Parallel()
saoPaulo, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, saoPaulo)
// Prepare
client := coderdtest.New(t, &coderdtest.Options{})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Send insights request
_, err = client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{
StartTime: today,
EndTime: today.AddDate(0, 0, -1),
})
assert.Error(t, err, "want error for end time before start time")
}
func TestTemplateInsights_Golden(t *testing.T) {
t.Parallel()
// Prepare test data types.
type templateParameterOption struct {
name string
value string
}
type templateParameter struct {
name string
description string
options []templateParameterOption
}
type templateApp struct {
name string
icon string
}
type testTemplate struct {
name string
parameters []*templateParameter
apps []templateApp
// Filled later.
id uuid.UUID
}
type buildParameter struct {
templateParameter *templateParameter
value string
}
type workspaceApp templateApp
type testWorkspace struct {
name string
template *testTemplate
buildParameters []buildParameter
// Filled later.
id uuid.UUID
user any // *testUser, but it's not available yet, defined below.
agentID uuid.UUID
apps []*workspaceApp
agentClient *agentsdk.Client
}
type testUser struct {
name string
workspaces []*testWorkspace
client *codersdk.Client
sdk codersdk.User
}
// Represent agent stats, to be inserted via stats batcher.
type agentStat struct {
// Set a range via start/end, multiple stats will be generated
// within the range.
startedAt time.Time
endedAt time.Time
sessionCountVSCode int64
sessionCountJetBrains int64
sessionCountReconnectingPTY int64
sessionCountSSH int64
noConnections bool
}
// Represent app usage stats, to be inserted via stats reporter.
type appUsage struct {
app *workspaceApp
startedAt time.Time
endedAt time.Time
requests int
}
// Represent actual data being generated on a per-workspace basis.
type testDataGen struct {
agentStats []agentStat
appUsage []appUsage
}
prepareFixtureAndTestData := func(t *testing.T, makeFixture func() ([]*testTemplate, []*testUser), makeData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen) ([]*testTemplate, []*testUser, map[*testWorkspace]testDataGen) {
var stableIDs []uuid.UUID
newStableUUID := func() uuid.UUID {
stableIDs = append(stableIDs, uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-%012d", len(stableIDs)+1)))
stableID := stableIDs[len(stableIDs)-1]
return stableID
}
templates, users := makeFixture()
for _, template := range templates {
template.id = newStableUUID()
}
for _, user := range users {
for _, workspace := range user.workspaces {
workspace.user = user
for _, app := range workspace.template.apps {
app := workspaceApp(app)
workspace.apps = append(workspace.apps, &app)
}
for _, bp := range workspace.buildParameters {
foundBuildParam := false
for _, param := range workspace.template.parameters {
if bp.templateParameter == param {
foundBuildParam = true
break
}
}
require.True(t, foundBuildParam, "test bug: parameter not in workspace %s template %q", workspace.name, workspace.template.name)
}
}
}
testData := makeData(templates, users)
// Sanity check.
for ws, data := range testData {
for _, usage := range data.appUsage {
found := false
for _, app := range ws.apps {
if usage.app == app { // Pointer equality
found = true
break
}
}
if !found {
for _, user := range users {
for _, workspace := range user.workspaces {
for _, app := range workspace.apps {
if usage.app == app { // Pointer equality
require.True(t, found, "test bug: app %q not in workspace %q: want user=%s workspace=%s; got user=%s workspace=%s ", usage.app.name, ws.name, ws.user.(*testUser).name, ws.name, user.name, workspace.name)
break
}
}
}
}
require.True(t, found, "test bug: app %q not in workspace %q", usage.app.name, ws.name)
}
}
}
return templates, users, testData
}
prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen) (*codersdk.Client, chan dbrollup.Event) {
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug)
db, ps := dbtestutil.NewDB(t)
events := make(chan dbrollup.Event)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Hour, // Not relevant for this test.
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup"),
db,
dbrollup.WithInterval(time.Millisecond*50),
dbrollup.WithEventChannel(events),
),
})
firstUser := coderdtest.CreateFirstUser(t, client)
// Prepare all test users.
for _, user := range users {
user.client, user.sdk = coderdtest.CreateAnotherUserMutators(t, client, firstUser.OrganizationID, nil, func(r *codersdk.CreateUserRequest) {
r.Username = user.name
})
user.client.SetLogger(logger.Named("user").With(slog.Field{Name: "name", Value: user.name}))
}
// Prepare all the templates.
for _, template := range templates {
template := template
var parameters []*proto.RichParameter
for _, parameter := range template.parameters {
var options []*proto.RichParameterOption
var defaultValue string
for _, option := range parameter.options {
if defaultValue == "" {
defaultValue = option.value
}
options = append(options, &proto.RichParameterOption{
Name: option.name,
Value: option.value,
})
}
parameters = append(parameters, &proto.RichParameter{
Name: parameter.name,
DisplayName: parameter.name,
Type: "string",
Description: parameter.description,
Options: options,
DefaultValue: defaultValue,
})
}
// Prepare all workspace resources (agents and apps).
var (
createWorkspaces []func(uuid.UUID)
waitWorkspaces []func()
)
var resources []*proto.Resource
for _, user := range users {
user := user
for _, workspace := range user.workspaces {
workspace := workspace
if workspace.template != template {
continue
}
authToken := uuid.New()
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken.String())
workspace.agentClient = agentClient
var apps []*proto.App
for _, app := range workspace.apps {
apps = append(apps, &proto.App{
Slug: app.name,
DisplayName: app.name,
Icon: app.icon,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: "http://",
})
}
resources = append(resources, &proto.Resource{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(), // Doesn't matter, not used in DB.
Name: "dev",
Auth: &proto.Agent_Token{
Token: authToken.String(),
},
Apps: apps,
}},
})
var buildParameters []codersdk.WorkspaceBuildParameter
for _, buildParameter := range workspace.buildParameters {
buildParameters = append(buildParameters, codersdk.WorkspaceBuildParameter{
Name: buildParameter.templateParameter.name,
Value: buildParameter.value,
})
}
createWorkspaces = append(createWorkspaces, func(templateID uuid.UUID) {
// Create workspace using the users client.
createdWorkspace := coderdtest.CreateWorkspace(t, user.client, firstUser.OrganizationID, templateID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.RichParameterValues = buildParameters
})
workspace.id = createdWorkspace.ID
waitWorkspaces = append(waitWorkspaces, func() {
coderdtest.AwaitWorkspaceBuildJobCompleted(t, user.client, createdWorkspace.LatestBuild.ID)
ctx := testutil.Context(t, testutil.WaitShort)
ws, err := user.client.Workspace(ctx, workspace.id)
require.NoError(t, err, "want no error getting workspace")
workspace.agentID = ws.LatestBuild.Resources[0].Agents[0].ID
})
})
}
}
// Create the template version and template.
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Parameters: parameters,
},
},
},
},
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: resources,
},
},
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
// Create template, essentially a modified version of CreateTemplate
// where we can control the template ID.
// createdTemplate := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
createdTemplate := dbgen.Template(t, db, database.Template{
ID: template.id,
ActiveVersionID: version.ID,
OrganizationID: firstUser.OrganizationID,
CreatedBy: firstUser.UserID,
GroupACL: database.TemplateACL{
firstUser.OrganizationID.String(): []rbac.Action{rbac.ActionRead},
},
})
err := db.UpdateTemplateVersionByID(context.Background(), database.UpdateTemplateVersionByIDParams{
ID: version.ID,
TemplateID: uuid.NullUUID{
UUID: createdTemplate.ID,
Valid: true,
},
})
require.NoError(t, err, "want no error updating template version")
// Create all workspaces and wait for them.
for _, createWorkspace := range createWorkspaces {
createWorkspace(template.id)
}
for _, waitWorkspace := range waitWorkspaces {
waitWorkspace()
}
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Use agent stats batcher to insert agent stats, similar to live system.
// NOTE(mafredri): Ideally we would pass batcher as a coderd option and
// insert using the agentClient, but we have a circular dependency on
// the database.
batcher, batcherCloser, err := batchstats.New(
ctx,
batchstats.WithStore(db),
batchstats.WithLogger(logger.Named("batchstats")),
batchstats.WithInterval(time.Hour),
)
require.NoError(t, err)
defer batcherCloser() // Flushes the stats, this is to ensure they're written.
for workspace, data := range testData {
for _, stat := range data.agentStats {
createdAt := stat.startedAt
connectionCount := int64(1)
if stat.noConnections {
connectionCount = 0
}
for createdAt.Before(stat.endedAt) {
err = batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, &agentproto.Stats{
ConnectionCount: connectionCount,
SessionCountVscode: stat.sessionCountVSCode,
SessionCountJetbrains: stat.sessionCountJetBrains,
SessionCountReconnectingPty: stat.sessionCountReconnectingPTY,
SessionCountSsh: stat.sessionCountSSH,
})
require.NoError(t, err, "want no error inserting agent stats")
createdAt = createdAt.Add(30 * time.Second)
}
}
}
// Insert app usage.
var stats []workspaceapps.StatsReport
for workspace, data := range testData {
for _, usage := range data.appUsage {
appName := usage.app.name
accessMethod := workspaceapps.AccessMethodPath
if usage.app.name == "terminal" {
appName = ""
accessMethod = workspaceapps.AccessMethodTerminal
}
stats = append(stats, workspaceapps.StatsReport{
UserID: workspace.user.(*testUser).sdk.ID,
WorkspaceID: workspace.id,
AgentID: workspace.agentID,
AccessMethod: accessMethod,
SlugOrPort: appName,
SessionID: uuid.New(),
SessionStartedAt: usage.startedAt,
SessionEndedAt: usage.endedAt,
Requests: usage.requests,
})
}
}
reporter := workspaceapps.NewStatsDBReporter(db, workspaceapps.DefaultStatsDBReporterBatchSize)
//nolint:gocritic // This is a test.
err = reporter.Report(dbauthz.AsSystemRestricted(ctx), stats)
require.NoError(t, err, "want no error inserting app stats")
return client, events
}
baseTemplateAndUserFixture := func() ([]*testTemplate, []*testUser) {
// Test templates and configuration to generate.
templates := []*testTemplate{
// Create two templates with near-identical apps and parameters
// to allow testing for grouping similar data.
{
name: "template1",
parameters: []*templateParameter{
{name: "param1", description: "This is first parameter"},
{name: "param2", description: "This is second parameter"},
{name: "param3", description: "This is third parameter"},
{
name: "param4",
description: "This is fourth parameter",
options: []templateParameterOption{
{name: "option1", value: "option1"},
{name: "option2", value: "option2"},
},
},
},
apps: []templateApp{
{name: "app1", icon: "/icon1.png"},
{name: "app2", icon: "/icon2.png"},
{name: "app3", icon: "/icon2.png"},
},
},
{
name: "template2",
parameters: []*templateParameter{
{name: "param1", description: "This is first parameter"},
{name: "param2", description: "This is second parameter"},
{name: "param3", description: "This is third parameter"},
},
apps: []templateApp{
{name: "app1", icon: "/icon1.png"},
{name: "app2", icon: "/icon2.png"},
{name: "app3", icon: "/icon2.png"},
},
},
// Create another template with different parameters and apps.
{
name: "othertemplate",
parameters: []*templateParameter{
{name: "otherparam1", description: "This is another parameter"},
},
apps: []templateApp{
{name: "otherapp1", icon: "/icon1.png"},
// This "special test app" will be converted into web
// terminal usage, this is not included in stats since we
// currently rely on agent stats for this data.
{name: "terminal", icon: "/terminal.png"},
},
},
}
// Users and workspaces to generate.
users := []*testUser{
{
name: "user1",
workspaces: []*testWorkspace{
{
name: "workspace1",
template: templates[0],
buildParameters: []buildParameter{
{templateParameter: templates[0].parameters[0], value: "abc"},
{templateParameter: templates[0].parameters[1], value: "123"},
{templateParameter: templates[0].parameters[2], value: "bbb"},
{templateParameter: templates[0].parameters[3], value: "option1"},
},
},
{
name: "workspace2",
template: templates[1],
buildParameters: []buildParameter{
{templateParameter: templates[1].parameters[0], value: "ABC"},
{templateParameter: templates[1].parameters[1], value: "123"},
{templateParameter: templates[1].parameters[2], value: "BBB"},
},
},
{
name: "otherworkspace3",
template: templates[2],
},
},
},
{
name: "user2",
workspaces: []*testWorkspace{
{
name: "workspace1",
template: templates[0],
buildParameters: []buildParameter{
{templateParameter: templates[0].parameters[0], value: "abc"},
{templateParameter: templates[0].parameters[1], value: "123"},
{templateParameter: templates[0].parameters[2], value: "BBB"},
{templateParameter: templates[0].parameters[3], value: "option1"},
},
},
},
},
{
name: "user3",
workspaces: []*testWorkspace{
{
name: "otherworkspace1",
template: templates[2],
buildParameters: []buildParameter{
{templateParameter: templates[2].parameters[0], value: "xyz"},
},
},
{
name: "workspace2",
template: templates[0],
buildParameters: []buildParameter{
{templateParameter: templates[0].parameters[3], value: "option2"},
},
},
},
},
}
return templates, users
}
// Time range for report, test data will be generated within and
// outside this range, but only data within the range should be
// included in the report.
frozenLastNight := time.Date(2023, 8, 22, 0, 0, 0, 0, time.UTC)
frozenWeekAgo := frozenLastNight.AddDate(0, 0, -7)
saoPaulo, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
frozenWeekAgoSaoPaulo, err := time.ParseInLocation(time.DateTime, frozenWeekAgo.Format(time.DateTime), saoPaulo)
require.NoError(t, err)
//nolint:dupl // For testing purposes
makeBaseTestData := func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen {
return map[*testWorkspace]testDataGen{
users[0].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // 12 minutes of usage.
startedAt: frozenWeekAgo.AddDate(0, 0, 1),
endedAt: frozenWeekAgo.AddDate(0, 0, 1).Add(12 * time.Minute),
sessionCountSSH: 1,
},
{ // 1m30s of usage -> 2m rounded.
startedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(4*time.Minute + 30*time.Second),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Minute),
sessionCountJetBrains: 1,
},
},
appUsage: []appUsage{
{ // One hour of usage.
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
requests: 1,
},
{ // 30s of app usage -> 1m rounded.
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo.Add(2*time.Hour + 10*time.Second),
endedAt: frozenWeekAgo.Add(2*time.Hour + 40*time.Second),
requests: 1,
},
{ // 1m30s of app usage -> 2m rounded (included in São Paulo).
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo.Add(3*time.Hour + 30*time.Second),
endedAt: frozenWeekAgo.Add(3*time.Hour + 90*time.Second),
requests: 1,
},
{ // used an app on the last day, counts as active user, 12m.
app: users[0].workspaces[0].apps[2],
startedAt: frozenWeekAgo.AddDate(0, 0, 6),
endedAt: frozenWeekAgo.AddDate(0, 0, 6).Add(12 * time.Minute),
requests: 1,
},
},
},
users[0].workspaces[1]: {
agentStats: []agentStat{
{
// One hour of usage in second template at the same time
// as in first template. When selecting both templates
// this user and their app usage will only be counted
// once but the template ID will show up in the data.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // One hour of usage.
startedAt: frozenWeekAgo.AddDate(0, 0, -12),
endedAt: frozenWeekAgo.AddDate(0, 0, -12).Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
{ // Another one hour of usage, but "active users" shouldn't be increased twice.
startedAt: frozenWeekAgo.AddDate(0, 0, -10),
endedAt: frozenWeekAgo.AddDate(0, 0, -10).Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
},
appUsage: []appUsage{
{ // One hour of usage, but same user and same template app, only count once.
app: users[0].workspaces[1].apps[0],
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
requests: 1,
},
{
// Different templates but identical apps, apps will be
// combined and usage will be summed.
app: users[0].workspaces[1].apps[0],
startedAt: frozenWeekAgo.AddDate(0, 0, 2),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Hour),
requests: 1,
},
},
},
users[0].workspaces[2]: {
agentStats: []agentStat{},
appUsage: []appUsage{},
},
users[1].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of agent usage before timeframe (exclude).
startedAt: frozenWeekAgo.Add(-time.Hour),
endedAt: frozenWeekAgo,
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountSSH: 1,
},
{ // One hour of agent usage after timeframe (exclude in UTC, include in São Paulo).
startedAt: frozenWeekAgo.AddDate(0, 0, 7),
endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
},
appUsage: []appUsage{
{ // One hour of app usage before timeframe (exclude).
app: users[1].workspaces[0].apps[2],
startedAt: frozenWeekAgo.Add(-time.Hour),
endedAt: frozenWeekAgo,
requests: 1,
},
{ // One hour of app usage after timeframe (exclude in UTC, include in São Paulo).
app: users[1].workspaces[0].apps[2],
startedAt: frozenWeekAgo.AddDate(0, 0, 7),
endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour),
requests: 1,
},
},
},
users[2].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
},
appUsage: []appUsage{
{
app: users[2].workspaces[0].apps[0],
startedAt: frozenWeekAgo.AddDate(0, 0, 2),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(5 * time.Minute),
requests: 1,
},
{ // Special app; excluded from apps, but counted as active during the day.
app: users[2].workspaces[0].apps[1],
startedAt: frozenWeekAgo.AddDate(0, 0, 3),
endedAt: frozenWeekAgo.AddDate(0, 0, 3).Add(5 * time.Minute),
requests: 1,
},
},
},
}
}
type testRequest struct {
name string
makeRequest func([]*testTemplate) codersdk.TemplateInsightsRequest
ignoreTimes bool
}
tests := []struct {
name string
makeFixture func() ([]*testTemplate, []*testUser)
makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen
requests []testRequest
}{
{
name: "multiple users and workspaces",
makeFixture: baseTemplateAndUserFixture,
makeTestData: makeBaseTestData,
requests: []testRequest{
{
name: "week deployment wide",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "weekly aggregated deployment wide",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
StartTime: frozenWeekAgo.AddDate(0, 0, -3),
EndTime: frozenWeekAgo.AddDate(0, 0, 4),
Interval: codersdk.InsightsReportIntervalWeek,
}
},
},
{
name: "week all templates",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "weekly aggregated templates",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -1),
EndTime: frozenWeekAgo.AddDate(0, 0, 6),
Interval: codersdk.InsightsReportIntervalWeek,
}
},
},
{
name: "week first template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "weekly aggregated first template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalWeek,
}
},
},
{
name: "week second template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "three weeks second template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -14),
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalWeek,
}
},
},
{
name: "week third template",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[2].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
// São Paulo is three hours behind UTC, so we should not see
// any data between weekAgo and weekAgo.Add(3 * time.Hour).
name: "week other timezone (São Paulo)",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
StartTime: frozenWeekAgoSaoPaulo,
EndTime: frozenWeekAgoSaoPaulo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
{
name: "three weeks second template only report",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -14),
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalWeek,
Sections: []codersdk.TemplateInsightsSection{codersdk.TemplateInsightsSectionReport},
}
},
},
{
name: "three weeks second template only interval reports",
makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -14),
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalWeek,
Sections: []codersdk.TemplateInsightsSection{codersdk.TemplateInsightsSectionIntervalReports},
}
},
},
},
},
{
name: "parameters",
makeFixture: baseTemplateAndUserFixture,
makeTestData: func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen {
return map[*testWorkspace]testDataGen{}
},
requests: []testRequest{
{
// Since workspaces are created "now", we can only get
// parameters using a time range that includes "now".
// We check yesterday and today for stability just in case
// the test runs at UTC midnight.
name: "yesterday and today deployment wide",
ignoreTimes: true,
makeRequest: func(_ []*testTemplate) codersdk.TemplateInsightsRequest {
now := time.Now().UTC()
return codersdk.TemplateInsightsRequest{
StartTime: now.Truncate(24*time.Hour).AddDate(0, 0, -1),
EndTime: now.Truncate(time.Hour).Add(time.Hour),
}
},
},
{
name: "two days ago, no data",
ignoreTimes: true,
makeRequest: func(_ []*testTemplate) codersdk.TemplateInsightsRequest {
twoDaysAgo := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -2)
return codersdk.TemplateInsightsRequest{
StartTime: twoDaysAgo,
EndTime: twoDaysAgo.AddDate(0, 0, 1),
}
},
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.NotNil(t, tt.makeFixture, "test bug: makeFixture must be set")
require.NotNil(t, tt.makeTestData, "test bug: makeTestData must be set")
templates, users, testData := prepareFixtureAndTestData(t, tt.makeFixture, tt.makeTestData)
client, events := prepare(t, templates, users, testData)
// Drain two events, the first one resumes rolluper
// operation and the second one waits for the rollup
// to complete.
_, _ = <-events, <-events
for _, req := range tt.requests {
req := req
t.Run(req.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
report, err := client.TemplateInsights(ctx, req.makeRequest(templates))
require.NoError(t, err, "want no error getting template insights")
if req.ignoreTimes {
// Ignore times, we're only interested in the data.
report.Report.StartTime = time.Time{}
report.Report.EndTime = time.Time{}
for i := range report.IntervalReports {
report.IntervalReports[i].StartTime = time.Time{}
report.IntervalReports[i].EndTime = time.Time{}
}
}
partialName := strings.Join(strings.Split(t.Name(), "/")[1:], "_")
goldenFile := filepath.Join("testdata", "insights", "template", partialName+".json.golden")
if *updateGoldenFiles {
err = os.MkdirAll(filepath.Dir(goldenFile), 0o755)
require.NoError(t, err, "want no error creating golden file directory")
f, err := os.Create(goldenFile)
require.NoError(t, err, "want no error creating golden file")
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
enc.Encode(report)
return
}
f, err := os.Open(goldenFile)
require.NoError(t, err, "open golden file, run \"make update-golden-files\" and commit the changes")
defer f.Close()
var want codersdk.TemplateInsightsResponse
err = json.NewDecoder(f).Decode(&want)
require.NoError(t, err, "want no error decoding golden file")
cmpOpts := []cmp.Option{
// Ensure readable UUIDs in diff.
cmp.Transformer("UUIDs", func(in []uuid.UUID) (s []string) {
for _, id := range in {
s = append(s, id.String())
}
return s
}),
}
// Use cmp.Diff here because it produces more readable diffs.
assert.Empty(t, cmp.Diff(want, report, cmpOpts...), "golden file mismatch (-want +got): %s, run \"make update-golden-files\", verify and commit the changes", goldenFile)
})
}
})
}
}
func TestUserActivityInsights_Golden(t *testing.T) {
t.Parallel()
// Prepare test data types.
type templateApp struct {
name string
icon string
}
type testTemplate struct {
name string
apps []templateApp
// Filled later.
id uuid.UUID
}
type workspaceApp templateApp
type testWorkspace struct {
name string
template *testTemplate
// Filled later.
id uuid.UUID
user any // *testUser, but it's not available yet, defined below.
agentID uuid.UUID
apps []*workspaceApp
agentClient *agentsdk.Client
}
type testUser struct {
name string
workspaces []*testWorkspace
client *codersdk.Client
sdk codersdk.User
// Filled later.
id uuid.UUID
}
// Represent agent stats, to be inserted via stats batcher.
type agentStat struct {
// Set a range via start/end, multiple stats will be generated
// within the range.
startedAt time.Time
endedAt time.Time
sessionCountVSCode int64
sessionCountJetBrains int64
sessionCountReconnectingPTY int64
sessionCountSSH int64
noConnections bool
}
// Represent app usage stats, to be inserted via stats reporter.
type appUsage struct {
app *workspaceApp
startedAt time.Time
endedAt time.Time
requests int
}
// Represent actual data being generated on a per-workspace basis.
type testDataGen struct {
agentStats []agentStat
appUsage []appUsage
}
prepareFixtureAndTestData := func(t *testing.T, makeFixture func() ([]*testTemplate, []*testUser), makeData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen) ([]*testTemplate, []*testUser, map[*testWorkspace]testDataGen) {
var stableIDs []uuid.UUID
newStableUUID := func() uuid.UUID {
stableIDs = append(stableIDs, uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-%012d", len(stableIDs)+1)))
stableID := stableIDs[len(stableIDs)-1]
return stableID
}
templates, users := makeFixture()
for _, template := range templates {
template.id = newStableUUID()
}
for _, user := range users {
user.id = newStableUUID()
}
for _, user := range users {
for _, workspace := range user.workspaces {
workspace.user = user
for _, app := range workspace.template.apps {
app := workspaceApp(app)
workspace.apps = append(workspace.apps, &app)
}
}
}
testData := makeData(templates, users)
// Sanity check.
for ws, data := range testData {
for _, usage := range data.appUsage {
found := false
for _, app := range ws.apps {
if usage.app == app { // Pointer equality
found = true
break
}
}
if !found {
for _, user := range users {
for _, workspace := range user.workspaces {
for _, app := range workspace.apps {
if usage.app == app { // Pointer equality
require.True(t, found, "test bug: app %q not in workspace %q: want user=%s workspace=%s; got user=%s workspace=%s ", usage.app.name, ws.name, ws.user.(*testUser).name, ws.name, user.name, workspace.name)
break
}
}
}
}
require.True(t, found, "test bug: app %q not in workspace %q", usage.app.name, ws.name)
}
}
}
return templates, users, testData
}
prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen) (*codersdk.Client, chan dbrollup.Event) {
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug)
db, ps := dbtestutil.NewDB(t)
events := make(chan dbrollup.Event)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Hour, // Not relevant for this test.
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup"),
db,
dbrollup.WithInterval(time.Millisecond*50),
dbrollup.WithEventChannel(events),
),
})
firstUser := coderdtest.CreateFirstUser(t, client)
// Prepare all test users.
for _, user := range users {
_ = dbgen.User(t, db, database.User{
ID: user.id,
Username: user.name,
Status: database.UserStatusActive,
})
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{
UserID: user.id,
OrganizationID: firstUser.OrganizationID,
})
token, err := client.CreateToken(context.Background(), user.id.String(), codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24,
Scope: codersdk.APIKeyScopeAll,
TokenName: "no-password-user-token",
})
require.NoError(t, err)
userClient := codersdk.New(client.URL)
userClient.SetSessionToken(token.Key)
coderUser, err := userClient.User(context.Background(), user.id.String())
require.NoError(t, err)
user.client = userClient
user.sdk = coderUser
user.client.SetLogger(logger.Named("user").With(slog.Field{Name: "name", Value: user.name}))
}
// Prepare all the templates.
for _, template := range templates {
template := template
// Prepare all workspace resources (agents and apps).
var (
createWorkspaces []func(uuid.UUID)
waitWorkspaces []func()
)
var resources []*proto.Resource
for _, user := range users {
user := user
for _, workspace := range user.workspaces {
workspace := workspace
if workspace.template != template {
continue
}
authToken := uuid.New()
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(authToken.String())
workspace.agentClient = agentClient
var apps []*proto.App
for _, app := range workspace.apps {
apps = append(apps, &proto.App{
Slug: app.name,
DisplayName: app.name,
Icon: app.icon,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: "http://",
})
}
resources = append(resources, &proto.Resource{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(), // Doesn't matter, not used in DB.
Name: "dev",
Auth: &proto.Agent_Token{
Token: authToken.String(),
},
Apps: apps,
}},
})
createWorkspaces = append(createWorkspaces, func(templateID uuid.UUID) {
// Create workspace using the users client.
createdWorkspace := coderdtest.CreateWorkspace(t, user.client, firstUser.OrganizationID, templateID)
workspace.id = createdWorkspace.ID
waitWorkspaces = append(waitWorkspaces, func() {
coderdtest.AwaitWorkspaceBuildJobCompleted(t, user.client, createdWorkspace.LatestBuild.ID)
ctx := testutil.Context(t, testutil.WaitShort)
ws, err := user.client.Workspace(ctx, workspace.id)
require.NoError(t, err, "want no error getting workspace")
workspace.agentID = ws.LatestBuild.Resources[0].Agents[0].ID
})
})
}
}
// Create the template version and template.
version := coderdtest.CreateTemplateVersion(t, client, firstUser.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: []*proto.Response{{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: resources,
},
},
}},
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
// Create template, essentially a modified version of CreateTemplate
// where we can control the template ID.
// createdTemplate := coderdtest.CreateTemplate(t, client, firstUser.OrganizationID, version.ID)
createdTemplate := dbgen.Template(t, db, database.Template{
ID: template.id,
ActiveVersionID: version.ID,
OrganizationID: firstUser.OrganizationID,
CreatedBy: firstUser.UserID,
GroupACL: database.TemplateACL{
firstUser.OrganizationID.String(): []rbac.Action{rbac.ActionRead},
},
})
err := db.UpdateTemplateVersionByID(context.Background(), database.UpdateTemplateVersionByIDParams{
ID: version.ID,
TemplateID: uuid.NullUUID{
UUID: createdTemplate.ID,
Valid: true,
},
})
require.NoError(t, err, "want no error updating template version")
// Create all workspaces and wait for them.
for _, createWorkspace := range createWorkspaces {
createWorkspace(template.id)
}
for _, waitWorkspace := range waitWorkspaces {
waitWorkspace()
}
}
ctx := testutil.Context(t, testutil.WaitSuperLong)
// Use agent stats batcher to insert agent stats, similar to live system.
// NOTE(mafredri): Ideally we would pass batcher as a coderd option and
// insert using the agentClient, but we have a circular dependency on
// the database.
batcher, batcherCloser, err := batchstats.New(
ctx,
batchstats.WithStore(db),
batchstats.WithLogger(logger.Named("batchstats")),
batchstats.WithInterval(time.Hour),
)
require.NoError(t, err)
defer batcherCloser() // Flushes the stats, this is to ensure they're written.
for workspace, data := range testData {
for _, stat := range data.agentStats {
createdAt := stat.startedAt
connectionCount := int64(1)
if stat.noConnections {
connectionCount = 0
}
for createdAt.Before(stat.endedAt) {
err = batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, &agentproto.Stats{
ConnectionCount: connectionCount,
SessionCountVscode: stat.sessionCountVSCode,
SessionCountJetbrains: stat.sessionCountJetBrains,
SessionCountReconnectingPty: stat.sessionCountReconnectingPTY,
SessionCountSsh: stat.sessionCountSSH,
})
require.NoError(t, err, "want no error inserting agent stats")
createdAt = createdAt.Add(30 * time.Second)
}
}
}
// Insert app usage.
var stats []workspaceapps.StatsReport
for workspace, data := range testData {
for _, usage := range data.appUsage {
appName := usage.app.name
accessMethod := workspaceapps.AccessMethodPath
if usage.app.name == "terminal" {
appName = ""
accessMethod = workspaceapps.AccessMethodTerminal
}
stats = append(stats, workspaceapps.StatsReport{
UserID: workspace.user.(*testUser).sdk.ID,
WorkspaceID: workspace.id,
AgentID: workspace.agentID,
AccessMethod: accessMethod,
SlugOrPort: appName,
SessionID: uuid.New(),
SessionStartedAt: usage.startedAt,
SessionEndedAt: usage.endedAt,
Requests: usage.requests,
})
}
}
reporter := workspaceapps.NewStatsDBReporter(db, workspaceapps.DefaultStatsDBReporterBatchSize)
//nolint:gocritic // This is a test.
err = reporter.Report(dbauthz.AsSystemRestricted(ctx), stats)
require.NoError(t, err, "want no error inserting app stats")
return client, events
}
baseTemplateAndUserFixture := func() ([]*testTemplate, []*testUser) {
// Test templates and configuration to generate.
templates := []*testTemplate{
// Create two templates with near-identical apps and parameters
// to allow testing for grouping similar data.
{
name: "template1",
apps: []templateApp{
{name: "app1", icon: "/icon1.png"},
{name: "app2", icon: "/icon2.png"},
{name: "app3", icon: "/icon2.png"},
},
},
{
name: "template2",
apps: []templateApp{
{name: "app1", icon: "/icon1.png"},
{name: "app2", icon: "/icon2.png"},
{name: "app3", icon: "/icon2.png"},
},
},
// Create another template with different parameters and apps.
{
name: "othertemplate",
apps: []templateApp{
{name: "otherapp1", icon: "/icon1.png"},
// This "special test app" will be converted into web
// terminal usage, this is not included in stats since we
// currently rely on agent stats for this data.
{name: "terminal", icon: "/terminal.png"},
},
},
}
// Users and workspaces to generate.
users := []*testUser{
{
name: "user1",
workspaces: []*testWorkspace{
{
name: "workspace1",
template: templates[0],
},
{
name: "workspace2",
template: templates[1],
},
{
name: "otherworkspace3",
template: templates[2],
},
},
},
{
name: "user2",
workspaces: []*testWorkspace{
{
name: "workspace1",
template: templates[0],
},
},
},
{
name: "user3",
workspaces: []*testWorkspace{
{
name: "otherworkspace1",
template: templates[2],
},
{
name: "workspace2",
template: templates[0],
},
},
},
}
return templates, users
}
// Time range for report, test data will be generated within and
// outside this range, but only data within the range should be
// included in the report.
frozenLastNight := time.Date(2023, 8, 22, 0, 0, 0, 0, time.UTC)
frozenWeekAgo := frozenLastNight.AddDate(0, 0, -7)
saoPaulo, err := time.LoadLocation("America/Sao_Paulo")
require.NoError(t, err)
frozenWeekAgoSaoPaulo, err := time.ParseInLocation(time.DateTime, frozenWeekAgo.Format(time.DateTime), saoPaulo)
require.NoError(t, err)
//nolint:dupl // For testing purposes
makeBaseTestData := func(templates []*testTemplate, users []*testUser) map[*testWorkspace]testDataGen {
return map[*testWorkspace]testDataGen{
users[0].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // 12 minutes of usage.
startedAt: frozenWeekAgo.AddDate(0, 0, 1),
endedAt: frozenWeekAgo.AddDate(0, 0, 1).Add(12 * time.Minute),
sessionCountSSH: 1,
},
{ // 1m30s of usage -> 2m rounded.
startedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(4*time.Minute + 30*time.Second),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Minute),
sessionCountJetBrains: 1,
},
},
appUsage: []appUsage{
{ // One hour of usage.
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
requests: 1,
},
{ // 30s of app usage -> 1m rounded.
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo.Add(2*time.Hour + 10*time.Second),
endedAt: frozenWeekAgo.Add(2*time.Hour + 40*time.Second),
requests: 1,
},
{ // 1m30s of app usage -> 2m rounded (included in São Paulo).
app: users[0].workspaces[0].apps[0],
startedAt: frozenWeekAgo.Add(3*time.Hour + 30*time.Second),
endedAt: frozenWeekAgo.Add(3*time.Hour + 90*time.Second),
requests: 1,
},
{ // used an app on the last day, counts as active user, 12m.
app: users[0].workspaces[0].apps[2],
startedAt: frozenWeekAgo.AddDate(0, 0, 6),
endedAt: frozenWeekAgo.AddDate(0, 0, 6).Add(12 * time.Minute),
requests: 1,
},
},
},
users[0].workspaces[1]: {
agentStats: []agentStat{
{
// One hour of usage in second template at the same time
// as in first template. When selecting both templates
// this user and their app usage will only be counted
// once but the template ID will show up in the data.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // One hour of usage.
startedAt: frozenWeekAgo.AddDate(0, 0, -12),
endedAt: frozenWeekAgo.AddDate(0, 0, -12).Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
{ // Another one hour of usage, but "active users" shouldn't be increased twice.
startedAt: frozenWeekAgo.AddDate(0, 0, -10),
endedAt: frozenWeekAgo.AddDate(0, 0, -10).Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
},
appUsage: []appUsage{
{ // One hour of usage, but same user and same template app, only count once.
app: users[0].workspaces[1].apps[0],
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
requests: 1,
},
{
// Different templates but identical apps, apps will be
// combined and usage will be summed.
app: users[0].workspaces[1].apps[0],
startedAt: frozenWeekAgo.AddDate(0, 0, 2),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(6 * time.Hour),
requests: 1,
},
},
},
users[0].workspaces[2]: {
agentStats: []agentStat{},
appUsage: []appUsage{},
},
users[1].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of agent usage before timeframe (exclude).
startedAt: frozenWeekAgo.Add(-time.Hour),
endedAt: frozenWeekAgo,
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountSSH: 1,
},
{ // One hour of agent usage after timeframe (exclude in UTC, include in São Paulo).
startedAt: frozenWeekAgo.AddDate(0, 0, 7),
endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour),
sessionCountVSCode: 1,
sessionCountSSH: 1,
},
},
appUsage: []appUsage{
{ // One hour of app usage before timeframe (exclude).
app: users[1].workspaces[0].apps[2],
startedAt: frozenWeekAgo.Add(-time.Hour),
endedAt: frozenWeekAgo,
requests: 1,
},
{ // One hour of app usage after timeframe (exclude in UTC, include in São Paulo).
app: users[1].workspaces[0].apps[2],
startedAt: frozenWeekAgo.AddDate(0, 0, 7),
endedAt: frozenWeekAgo.AddDate(0, 0, 7).Add(time.Hour),
requests: 1,
},
},
},
users[2].workspaces[0]: {
agentStats: []agentStat{
{ // One hour of usage.
startedAt: frozenWeekAgo,
endedAt: frozenWeekAgo.Add(time.Hour),
sessionCountSSH: 1,
sessionCountReconnectingPTY: 1,
},
},
appUsage: []appUsage{
{
app: users[2].workspaces[0].apps[0],
startedAt: frozenWeekAgo.AddDate(0, 0, 2),
endedAt: frozenWeekAgo.AddDate(0, 0, 2).Add(5 * time.Minute),
requests: 1,
},
{ // Special app; excluded from apps, but counted as active during the day.
app: users[2].workspaces[0].apps[1],
startedAt: frozenWeekAgo.AddDate(0, 0, 3),
endedAt: frozenWeekAgo.AddDate(0, 0, 3).Add(5 * time.Minute),
requests: 1,
},
},
},
}
}
type testRequest struct {
name string
makeRequest func([]*testTemplate) codersdk.UserActivityInsightsRequest
ignoreTimes bool
}
tests := []struct {
name string
makeFixture func() ([]*testTemplate, []*testUser)
makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen
requests []testRequest
}{
{
name: "multiple users and workspaces",
makeFixture: baseTemplateAndUserFixture,
makeTestData: makeBaseTestData,
requests: []testRequest{
{
name: "week deployment wide",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "weekly aggregated deployment wide",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
StartTime: frozenWeekAgo.AddDate(0, 0, -3),
EndTime: frozenWeekAgo.AddDate(0, 0, 4),
}
},
},
{
name: "week all templates",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "weekly aggregated templates",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id, templates[1].id, templates[2].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -1),
EndTime: frozenWeekAgo.AddDate(0, 0, 6),
}
},
},
{
name: "week first template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "weekly aggregated first template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[0].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "week second template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "three weeks second template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[1].id},
StartTime: frozenWeekAgo.AddDate(0, 0, -14),
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
name: "week third template",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
TemplateIDs: []uuid.UUID{templates[2].id},
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
{
// São Paulo is three hours behind UTC, so we should not see
// any data between weekAgo and weekAgo.Add(3 * time.Hour).
name: "week other timezone (São Paulo)",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
StartTime: frozenWeekAgoSaoPaulo,
EndTime: frozenWeekAgoSaoPaulo.AddDate(0, 0, 7),
}
},
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.NotNil(t, tt.makeFixture, "test bug: makeFixture must be set")
require.NotNil(t, tt.makeTestData, "test bug: makeTestData must be set")
templates, users, testData := prepareFixtureAndTestData(t, tt.makeFixture, tt.makeTestData)
client, events := prepare(t, templates, users, testData)
// Drain two events, the first one resumes rolluper
// operation and the second one waits for the rollup
// to complete.
_, _ = <-events, <-events
for _, req := range tt.requests {
req := req
t.Run(req.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
report, err := client.UserActivityInsights(ctx, req.makeRequest(templates))
require.NoError(t, err, "want no error getting template insights")
if req.ignoreTimes {
// Ignore times, we're only interested in the data.
report.Report.StartTime = time.Time{}
report.Report.EndTime = time.Time{}
}
partialName := strings.Join(strings.Split(t.Name(), "/")[1:], "_")
goldenFile := filepath.Join("testdata", "insights", "user-activity", partialName+".json.golden")
if *updateGoldenFiles {
err = os.MkdirAll(filepath.Dir(goldenFile), 0o755)
require.NoError(t, err, "want no error creating golden file directory")
f, err := os.Create(goldenFile)
require.NoError(t, err, "want no error creating golden file")
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
enc.Encode(report)
return
}
f, err := os.Open(goldenFile)
require.NoError(t, err, "open golden file, run \"make update-golden-files\" and commit the changes")
defer f.Close()
var want codersdk.UserActivityInsightsResponse
err = json.NewDecoder(f).Decode(&want)
require.NoError(t, err, "want no error decoding golden file")
cmpOpts := []cmp.Option{
// Ensure readable UUIDs in diff.
cmp.Transformer("UUIDs", func(in []uuid.UUID) (s []string) {
for _, id := range in {
s = append(s, id.String())
}
return s
}),
}
// Use cmp.Diff here because it produces more readable diffs.
assert.Empty(t, cmp.Diff(want, report, cmpOpts...), "golden file mismatch (-want +got): %s, run \"make update-golden-files\", verify and commit the changes", goldenFile)
})
}
})
}
}
func TestTemplateInsights_BadRequest(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{})
_ = coderdtest.CreateFirstUser(t, client)
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today,
EndTime: today.AddDate(0, 0, -1),
})
assert.Error(t, err, "want error for end time before start time")
_, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: "invalid",
})
assert.Error(t, err, "want error for bad interval")
_, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -5),
EndTime: today,
Interval: codersdk.InsightsReportIntervalWeek,
})
assert.Error(t, err, "last report interval must have at least 6 days")
_, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: codersdk.InsightsReportIntervalWeek,
Sections: []codersdk.TemplateInsightsSection{"invalid"},
})
assert.Error(t, err, "want error for bad section")
}
func TestTemplateInsights_RBAC(t *testing.T) {
t.Parallel()
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
type test struct {
interval codersdk.InsightsReportInterval
withTemplate bool
}
tests := []test{
{codersdk.InsightsReportIntervalDay, true},
{codersdk.InsightsReportIntervalDay, false},
{"", true},
{"", false},
}
for _, tt := range tests {
tt := tt
t.Run(fmt.Sprintf("with interval=%q", tt.interval), func(t *testing.T) {
t.Parallel()
t.Run("AsOwner", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
_, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: tt.interval,
TemplateIDs: templateIDs,
})
require.NoError(t, err)
})
t.Run("AsTemplateAdmin", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
_, err := templateAdmin.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: tt.interval,
TemplateIDs: templateIDs,
})
require.NoError(t, err)
})
t.Run("AsRegularUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
regular, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
_, err := regular.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
StartTime: today.AddDate(0, 0, -1),
EndTime: today,
Interval: tt.interval,
TemplateIDs: templateIDs,
})
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
})
}
}
func TestGenericInsights_RBAC(t *testing.T) {
t.Parallel()
y, m, d := time.Now().UTC().Date()
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
type fetchInsightsFunc func(ctx context.Context, client *codersdk.Client, startTime, endTime time.Time, templateIDs ...uuid.UUID) error
type test struct {
withTemplate bool
}
endpoints := map[string]fetchInsightsFunc{
"UserLatency": func(ctx context.Context, client *codersdk.Client, startTime, endTime time.Time, templateIDs ...uuid.UUID) error {
_, err := client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: templateIDs,
})
return err
},
"UserActivity": func(ctx context.Context, client *codersdk.Client, startTime, endTime time.Time, templateIDs ...uuid.UUID) error {
_, err := client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{
StartTime: startTime,
EndTime: endTime,
TemplateIDs: templateIDs,
})
return err
},
}
for endpointName, endpoint := range endpoints {
endpointName := endpointName
endpoint := endpoint
t.Run(fmt.Sprintf("With%sEndpoint", endpointName), func(t *testing.T) {
t.Parallel()
tests := []test{
{true},
{false},
}
for _, tt := range tests {
tt := tt
t.Run("AsOwner", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
err := endpoint(ctx, client,
today,
time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
templateIDs...)
require.NoError(t, err)
})
t.Run("AsTemplateAdmin", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
templateAdmin, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleTemplateAdmin())
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
err := endpoint(ctx, templateAdmin,
today,
time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
templateIDs...)
require.NoError(t, err)
})
t.Run("AsRegularUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
regular, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
var templateIDs []uuid.UUID
if tt.withTemplate {
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
templateIDs = append(templateIDs, template.ID)
}
err := endpoint(ctx, regular,
today,
time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
templateIDs...)
require.Error(t, err)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
})
}
})
}
}