|
| 1 | +package insights_test |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "fmt" |
| 7 | + "os" |
| 8 | + "strings" |
| 9 | + "testing" |
| 10 | + "time" |
| 11 | + |
| 12 | + "github.com/google/go-cmp/cmp" |
| 13 | + "github.com/google/uuid" |
| 14 | + "github.com/prometheus/client_golang/prometheus" |
| 15 | + io_prometheus_client "github.com/prometheus/client_model/go" |
| 16 | + "github.com/stretchr/testify/assert" |
| 17 | + "github.com/stretchr/testify/require" |
| 18 | + |
| 19 | + "cdr.dev/slog" |
| 20 | + "cdr.dev/slog/sloggers/slogtest" |
| 21 | + agentproto "github.com/coder/coder/v2/agent/proto" |
| 22 | + "github.com/coder/coder/v2/coderd/coderdtest" |
| 23 | + "github.com/coder/coder/v2/coderd/database" |
| 24 | + "github.com/coder/coder/v2/coderd/database/dbauthz" |
| 25 | + "github.com/coder/coder/v2/coderd/database/dbgen" |
| 26 | + "github.com/coder/coder/v2/coderd/database/dbtestutil" |
| 27 | + "github.com/coder/coder/v2/coderd/prometheusmetrics/insights" |
| 28 | + "github.com/coder/coder/v2/coderd/workspaceapps" |
| 29 | + "github.com/coder/coder/v2/coderd/workspacestats" |
| 30 | + "github.com/coder/coder/v2/codersdk/agentsdk" |
| 31 | + "github.com/coder/coder/v2/testutil" |
| 32 | +) |
| 33 | + |
| 34 | +func TestCollectInsights(t *testing.T) { |
| 35 | + t.Parallel() |
| 36 | + |
| 37 | + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) |
| 38 | + db, ps := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) |
| 39 | + |
| 40 | + options := &coderdtest.Options{ |
| 41 | + IncludeProvisionerDaemon: true, |
| 42 | + AgentStatsRefreshInterval: time.Millisecond * 100, |
| 43 | + Database: db, |
| 44 | + Pubsub: ps, |
| 45 | + } |
| 46 | + ownerClient := coderdtest.New(t, options) |
| 47 | + ownerClient.SetLogger(logger.Named("ownerClient").Leveled(slog.LevelDebug)) |
| 48 | + owner := coderdtest.CreateFirstUser(t, ownerClient) |
| 49 | + client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) |
| 50 | + |
| 51 | + // Given |
| 52 | + // Initialize metrics collector |
| 53 | + mc, err := insights.NewMetricsCollector(db, logger, 0, time.Second) |
| 54 | + require.NoError(t, err) |
| 55 | + |
| 56 | + registry := prometheus.NewRegistry() |
| 57 | + registry.Register(mc) |
| 58 | + |
| 59 | + var ( |
| 60 | + orgID = owner.OrganizationID |
| 61 | + tpl = dbgen.Template(t, db, database.Template{OrganizationID: orgID, CreatedBy: user.ID, Name: "golden-template"}) |
| 62 | + ver = dbgen.TemplateVersion(t, db, database.TemplateVersion{OrganizationID: orgID, CreatedBy: user.ID, TemplateID: uuid.NullUUID{UUID: tpl.ID, Valid: true}}) |
| 63 | + param1 = dbgen.TemplateVersionParameter(t, db, database.TemplateVersionParameter{TemplateVersionID: ver.ID, Name: "first_parameter"}) |
| 64 | + param2 = dbgen.TemplateVersionParameter(t, db, database.TemplateVersionParameter{TemplateVersionID: ver.ID, Name: "second_parameter", Type: "bool"}) |
| 65 | + param3 = dbgen.TemplateVersionParameter(t, db, database.TemplateVersionParameter{TemplateVersionID: ver.ID, Name: "third_parameter", Type: "number"}) |
| 66 | + workspace1 = dbgen.Workspace(t, db, database.Workspace{OrganizationID: orgID, TemplateID: tpl.ID, OwnerID: user.ID}) |
| 67 | + workspace2 = dbgen.Workspace(t, db, database.Workspace{OrganizationID: orgID, TemplateID: tpl.ID, OwnerID: user.ID}) |
| 68 | + job1 = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: orgID}) |
| 69 | + job2 = dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: orgID}) |
| 70 | + build1 = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{TemplateVersionID: ver.ID, WorkspaceID: workspace1.ID, JobID: job1.ID}) |
| 71 | + build2 = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{TemplateVersionID: ver.ID, WorkspaceID: workspace2.ID, JobID: job2.ID}) |
| 72 | + res1 = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: build1.JobID}) |
| 73 | + res2 = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: build2.JobID}) |
| 74 | + agent1 = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res1.ID}) |
| 75 | + agent2 = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: res2.ID}) |
| 76 | + app1 = dbgen.WorkspaceApp(t, db, database.WorkspaceApp{AgentID: agent1.ID, Slug: "golden-slug", DisplayName: "Golden Slug"}) |
| 77 | + app2 = dbgen.WorkspaceApp(t, db, database.WorkspaceApp{AgentID: agent2.ID, Slug: "golden-slug", DisplayName: "Golden Slug"}) |
| 78 | + _ = dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{ |
| 79 | + {WorkspaceBuildID: build1.ID, Name: param1.Name, Value: "Foobar"}, |
| 80 | + {WorkspaceBuildID: build1.ID, Name: param2.Name, Value: "true"}, |
| 81 | + {WorkspaceBuildID: build1.ID, Name: param3.Name, Value: "789"}, |
| 82 | + }) |
| 83 | + _ = dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{ |
| 84 | + {WorkspaceBuildID: build2.ID, Name: param1.Name, Value: "Baz"}, |
| 85 | + {WorkspaceBuildID: build2.ID, Name: param2.Name, Value: "true"}, |
| 86 | + {WorkspaceBuildID: build2.ID, Name: param3.Name, Value: "999"}, |
| 87 | + }) |
| 88 | + ) |
| 89 | + |
| 90 | + // Start an agent so that we can generate stats. |
| 91 | + var agentClients []agentproto.DRPCAgentClient |
| 92 | + for i, agent := range []database.WorkspaceAgent{agent1, agent2} { |
| 93 | + agentClient := agentsdk.New(client.URL) |
| 94 | + agentClient.SetSessionToken(agent.AuthToken.String()) |
| 95 | + agentClient.SDK.SetLogger(logger.Leveled(slog.LevelDebug).Named(fmt.Sprintf("agent%d", i+1))) |
| 96 | + conn, err := agentClient.ConnectRPC(context.Background()) |
| 97 | + require.NoError(t, err) |
| 98 | + agentAPI := agentproto.NewDRPCAgentClient(conn) |
| 99 | + agentClients = append(agentClients, agentAPI) |
| 100 | + } |
| 101 | + |
| 102 | + // Fake app stats |
| 103 | + _, err = agentClients[0].UpdateStats(context.Background(), &agentproto.UpdateStatsRequest{ |
| 104 | + Stats: &agentproto.Stats{ |
| 105 | + // ConnectionCount must be positive as database query ignores stats with no active connections at the time frame |
| 106 | + ConnectionsByProto: map[string]int64{"TCP": 1}, |
| 107 | + ConnectionCount: 1, |
| 108 | + ConnectionMedianLatencyMs: 15, |
| 109 | + // Session counts must be positive, but the exact value is ignored. |
| 110 | + // Database query approximates it to 60s of usage. |
| 111 | + SessionCountSsh: 99, |
| 112 | + SessionCountJetbrains: 47, |
| 113 | + SessionCountVscode: 34, |
| 114 | + }, |
| 115 | + }) |
| 116 | + require.NoError(t, err, "unable to post fake stats") |
| 117 | + |
| 118 | + // Fake app usage |
| 119 | + reporter := workspacestats.NewReporter(workspacestats.ReporterOptions{ |
| 120 | + Database: db, |
| 121 | + AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize, |
| 122 | + }) |
| 123 | + refTime := time.Now().Add(-3 * time.Minute).Truncate(time.Minute) |
| 124 | + //nolint:gocritic // This is a test. |
| 125 | + err = reporter.ReportAppStats(dbauthz.AsSystemRestricted(context.Background()), []workspaceapps.StatsReport{ |
| 126 | + { |
| 127 | + UserID: user.ID, |
| 128 | + WorkspaceID: workspace1.ID, |
| 129 | + AgentID: agent1.ID, |
| 130 | + AccessMethod: "path", |
| 131 | + SlugOrPort: app1.Slug, |
| 132 | + SessionID: uuid.New(), |
| 133 | + SessionStartedAt: refTime, |
| 134 | + SessionEndedAt: refTime.Add(2 * time.Minute).Add(-time.Second), |
| 135 | + Requests: 1, |
| 136 | + }, |
| 137 | + // Same usage on differrent workspace/agent in same template, |
| 138 | + // should not be counted as extra. |
| 139 | + { |
| 140 | + UserID: user.ID, |
| 141 | + WorkspaceID: workspace2.ID, |
| 142 | + AgentID: agent2.ID, |
| 143 | + AccessMethod: "path", |
| 144 | + SlugOrPort: app2.Slug, |
| 145 | + SessionID: uuid.New(), |
| 146 | + SessionStartedAt: refTime, |
| 147 | + SessionEndedAt: refTime.Add(2 * time.Minute).Add(-time.Second), |
| 148 | + Requests: 1, |
| 149 | + }, |
| 150 | + { |
| 151 | + UserID: user.ID, |
| 152 | + WorkspaceID: workspace2.ID, |
| 153 | + AgentID: agent2.ID, |
| 154 | + AccessMethod: "path", |
| 155 | + SlugOrPort: app2.Slug, |
| 156 | + SessionID: uuid.New(), |
| 157 | + SessionStartedAt: refTime.Add(2 * time.Minute), |
| 158 | + SessionEndedAt: refTime.Add(2 * time.Minute).Add(30 * time.Second), |
| 159 | + Requests: 1, |
| 160 | + }, |
| 161 | + }) |
| 162 | + require.NoError(t, err, "want no error inserting app stats") |
| 163 | + |
| 164 | + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) |
| 165 | + defer cancel() |
| 166 | + |
| 167 | + // Run metrics collector |
| 168 | + closeFunc, err := mc.Run(ctx) |
| 169 | + require.NoError(t, err) |
| 170 | + defer closeFunc() |
| 171 | + |
| 172 | + goldenFile, err := os.ReadFile("testdata/insights-metrics.json") |
| 173 | + require.NoError(t, err) |
| 174 | + golden := map[string]int{} |
| 175 | + err = json.Unmarshal(goldenFile, &golden) |
| 176 | + require.NoError(t, err) |
| 177 | + |
| 178 | + collected := map[string]int{} |
| 179 | + ok := assert.Eventuallyf(t, func() bool { |
| 180 | + // When |
| 181 | + metrics, err := registry.Gather() |
| 182 | + if !assert.NoError(t, err) { |
| 183 | + return false |
| 184 | + } |
| 185 | + |
| 186 | + // Then |
| 187 | + for _, metric := range metrics { |
| 188 | + t.Logf("metric: %s: %#v", metric.GetName(), metric) |
| 189 | + switch metric.GetName() { |
| 190 | + case "coderd_insights_applications_usage_seconds", "coderd_insights_templates_active_users", "coderd_insights_parameters": |
| 191 | + for _, m := range metric.Metric { |
| 192 | + key := metric.GetName() |
| 193 | + if len(m.Label) > 0 { |
| 194 | + key = key + "[" + metricLabelAsString(m) + "]" |
| 195 | + } |
| 196 | + collected[key] = int(m.Gauge.GetValue()) |
| 197 | + } |
| 198 | + default: |
| 199 | + assert.Failf(t, "unexpected metric collected", "metric: %s", metric.GetName()) |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + return assert.ObjectsAreEqualValues(golden, collected) |
| 204 | + }, testutil.WaitMedium, testutil.IntervalFast, "template insights are inconsistent with golden files") |
| 205 | + if !ok { |
| 206 | + diff := cmp.Diff(golden, collected) |
| 207 | + assert.Empty(t, diff, "template insights are inconsistent with golden files (-golden +collected)") |
| 208 | + } |
| 209 | +} |
| 210 | + |
| 211 | +func metricLabelAsString(m *io_prometheus_client.Metric) string { |
| 212 | + var labels []string |
| 213 | + for _, labelPair := range m.Label { |
| 214 | + labels = append(labels, labelPair.GetName()+"="+labelPair.GetValue()) |
| 215 | + } |
| 216 | + return strings.Join(labels, ",") |
| 217 | +} |
0 commit comments