mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
Joins in fields like `username`, `avatar_url`, `organization_name`, `template_name` to `workspaces` via a **view**. The view must be maintained moving forward, but this prevents needing to add RBAC permissions to fetch related workspace fields.
225 lines
8.9 KiB
Go
225 lines
8.9 KiB
Go
package insights_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/google/uuid"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
io_prometheus_client "github.com/prometheus/client_model/go"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog"
|
|
"cdr.dev/slog/sloggers/slogtest"
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
"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/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/prometheusmetrics/insights"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps"
|
|
"github.com/coder/coder/v2/coderd/workspacestats"
|
|
"github.com/coder/coder/v2/codersdk/agentsdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestCollectInsights(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
|
db, ps := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
|
|
|
|
options := &coderdtest.Options{
|
|
IncludeProvisionerDaemon: true,
|
|
AgentStatsRefreshInterval: time.Millisecond * 100,
|
|
Database: db,
|
|
Pubsub: ps,
|
|
}
|
|
ownerClient := coderdtest.New(t, options)
|
|
ownerClient.SetLogger(logger.Named("ownerClient").Leveled(slog.LevelDebug))
|
|
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
|
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
|
|
|
// Given
|
|
// Initialize metrics collector
|
|
mc, err := insights.NewMetricsCollector(db, logger, 0, time.Second)
|
|
require.NoError(t, err)
|
|
|
|
registry := prometheus.NewRegistry()
|
|
registry.Register(mc)
|
|
|
|
var (
|
|
orgID = owner.OrganizationID
|
|
tpl = dbgen.Template(t, db, database.Template{OrganizationID: orgID, CreatedBy: user.ID, Name: "golden-template"})
|
|
ver = dbgen.TemplateVersion(t, db, database.TemplateVersion{OrganizationID: orgID, CreatedBy: user.ID, TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}})
|
|
param1 = dbgen.TemplateVersionParameter(t, db, database.TemplateVersionParameter{TemplateVersionID: ver.ID, Name: "first_parameter"})
|
|
param2 = dbgen.TemplateVersionParameter(t, db, database.TemplateVersionParameter{TemplateVersionID: ver.ID, Name: "second_parameter", Type: "bool"})
|
|
param3 = dbgen.TemplateVersionParameter(t, db, database.TemplateVersionParameter{TemplateVersionID: ver.ID, Name: "third_parameter", Type: "number"})
|
|
workspace1 = dbgen.Workspace(t, db, database.WorkspaceTable{OrganizationID: orgID, TemplateID: tpl.ID, OwnerID: user.ID})
|
|
workspace2 = dbgen.Workspace(t, db, database.WorkspaceTable{OrganizationID: orgID, TemplateID: tpl.ID, OwnerID: user.ID})
|
|
job1 = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: orgID})
|
|
job2 = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: orgID})
|
|
build1 = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{TemplateVersionID: ver.ID, WorkspaceID: workspace1.ID, JobID: job1.ID})
|
|
build2 = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{TemplateVersionID: ver.ID, WorkspaceID: workspace2.ID, JobID: job2.ID})
|
|
res1 = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: build1.JobID})
|
|
res2 = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: build2.JobID})
|
|
agent1 = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res1.ID})
|
|
agent2 = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res2.ID})
|
|
app1 = dbgen.WorkspaceApp(t, db, database.WorkspaceApp{AgentID: agent1.ID, Slug: "golden-slug", DisplayName: "Golden Slug"})
|
|
app2 = dbgen.WorkspaceApp(t, db, database.WorkspaceApp{AgentID: agent2.ID, Slug: "golden-slug", DisplayName: "Golden Slug"})
|
|
_ = dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{
|
|
{WorkspaceBuildID: build1.ID, Name: param1.Name, Value: "Foobar"},
|
|
{WorkspaceBuildID: build1.ID, Name: param2.Name, Value: "true"},
|
|
{WorkspaceBuildID: build1.ID, Name: param3.Name, Value: "789"},
|
|
})
|
|
_ = dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{
|
|
{WorkspaceBuildID: build2.ID, Name: param1.Name, Value: "Baz"},
|
|
{WorkspaceBuildID: build2.ID, Name: param2.Name, Value: "true"},
|
|
{WorkspaceBuildID: build2.ID, Name: param3.Name, Value: "999"},
|
|
})
|
|
)
|
|
|
|
// Start an agent so that we can generate stats.
|
|
var agentClients []agentproto.DRPCAgentClient
|
|
for i, agent := range []database.WorkspaceAgent{agent1, agent2} {
|
|
agentClient := agentsdk.New(client.URL)
|
|
agentClient.SetSessionToken(agent.AuthToken.String())
|
|
agentClient.SDK.SetLogger(logger.Leveled(slog.LevelDebug).Named(fmt.Sprintf("agent%d", i+1)))
|
|
conn, err := agentClient.ConnectRPC(context.Background())
|
|
require.NoError(t, err)
|
|
agentAPI := agentproto.NewDRPCAgentClient(conn)
|
|
agentClients = append(agentClients, agentAPI)
|
|
}
|
|
|
|
defer func() {
|
|
for a := range agentClients {
|
|
err := agentClients[a].DRPCConn().Close()
|
|
require.NoError(t, err)
|
|
}
|
|
}()
|
|
|
|
// Fake app stats
|
|
_, err = agentClients[0].UpdateStats(context.Background(), &agentproto.UpdateStatsRequest{
|
|
Stats: &agentproto.Stats{
|
|
// ConnectionCount must be positive as database query ignores stats with no active connections at the time frame
|
|
ConnectionsByProto: map[string]int64{"TCP": 1},
|
|
ConnectionCount: 1,
|
|
ConnectionMedianLatencyMs: 15,
|
|
// Session counts must be positive, but the exact value is ignored.
|
|
// Database query approximates it to 60s of usage.
|
|
SessionCountSsh: 99,
|
|
SessionCountJetbrains: 47,
|
|
SessionCountVscode: 34,
|
|
},
|
|
})
|
|
require.NoError(t, err, "unable to post fake stats")
|
|
|
|
// Fake app usage
|
|
reporter := workspacestats.NewReporter(workspacestats.ReporterOptions{
|
|
Database: db,
|
|
AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize,
|
|
})
|
|
refTime := time.Now().Add(-3 * time.Minute).Truncate(time.Minute)
|
|
//nolint:gocritic // This is a test.
|
|
err = reporter.ReportAppStats(dbauthz.AsSystemRestricted(context.Background()), []workspaceapps.StatsReport{
|
|
{
|
|
UserID: user.ID,
|
|
WorkspaceID: workspace1.ID,
|
|
AgentID: agent1.ID,
|
|
AccessMethod: "path",
|
|
SlugOrPort: app1.Slug,
|
|
SessionID: uuid.New(),
|
|
SessionStartedAt: refTime,
|
|
SessionEndedAt: refTime.Add(2 * time.Minute).Add(-time.Second),
|
|
Requests: 1,
|
|
},
|
|
// Same usage on differrent workspace/agent in same template,
|
|
// should not be counted as extra.
|
|
{
|
|
UserID: user.ID,
|
|
WorkspaceID: workspace2.ID,
|
|
AgentID: agent2.ID,
|
|
AccessMethod: "path",
|
|
SlugOrPort: app2.Slug,
|
|
SessionID: uuid.New(),
|
|
SessionStartedAt: refTime,
|
|
SessionEndedAt: refTime.Add(2 * time.Minute).Add(-time.Second),
|
|
Requests: 1,
|
|
},
|
|
{
|
|
UserID: user.ID,
|
|
WorkspaceID: workspace2.ID,
|
|
AgentID: agent2.ID,
|
|
AccessMethod: "path",
|
|
SlugOrPort: app2.Slug,
|
|
SessionID: uuid.New(),
|
|
SessionStartedAt: refTime.Add(2 * time.Minute),
|
|
SessionEndedAt: refTime.Add(2 * time.Minute).Add(30 * time.Second),
|
|
Requests: 1,
|
|
},
|
|
})
|
|
require.NoError(t, err, "want no error inserting app stats")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// Run metrics collector
|
|
closeFunc, err := mc.Run(ctx)
|
|
require.NoError(t, err)
|
|
defer closeFunc()
|
|
|
|
goldenFile, err := os.ReadFile("testdata/insights-metrics.json")
|
|
require.NoError(t, err)
|
|
golden := map[string]int{}
|
|
err = json.Unmarshal(goldenFile, &golden)
|
|
require.NoError(t, err)
|
|
|
|
collected := map[string]int{}
|
|
ok := assert.Eventuallyf(t, func() bool {
|
|
// When
|
|
metrics, err := registry.Gather()
|
|
if !assert.NoError(t, err) {
|
|
return false
|
|
}
|
|
|
|
// Then
|
|
for _, metric := range metrics {
|
|
t.Logf("metric: %s: %#v", metric.GetName(), metric)
|
|
switch metric.GetName() {
|
|
case "coderd_insights_applications_usage_seconds", "coderd_insights_templates_active_users", "coderd_insights_parameters":
|
|
for _, m := range metric.Metric {
|
|
key := metric.GetName()
|
|
if len(m.Label) > 0 {
|
|
key = key + "[" + metricLabelAsString(m) + "]"
|
|
}
|
|
collected[key] = int(m.Gauge.GetValue())
|
|
}
|
|
default:
|
|
assert.Failf(t, "unexpected metric collected", "metric: %s", metric.GetName())
|
|
}
|
|
}
|
|
|
|
return assert.ObjectsAreEqualValues(golden, collected)
|
|
}, testutil.WaitMedium, testutil.IntervalFast, "template insights are inconsistent with golden files")
|
|
if !ok {
|
|
diff := cmp.Diff(golden, collected)
|
|
assert.Empty(t, diff, "template insights are inconsistent with golden files (-golden +collected)")
|
|
}
|
|
}
|
|
|
|
func metricLabelAsString(m *io_prometheus_client.Metric) string {
|
|
var labels []string
|
|
for _, labelPair := range m.Label {
|
|
labels = append(labels, labelPair.GetName()+"="+labelPair.GetValue())
|
|
}
|
|
return strings.Join(labels, ",")
|
|
}
|