diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 86ead4322d..d26302c54d 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1265,6 +1265,13 @@ func (q *querier) GetTemplateAppInsights(ctx context.Context, arg database.GetTe return q.db.GetTemplateAppInsights(ctx, arg) } +func (q *querier) GetTemplateAppInsightsByTemplate(ctx context.Context, arg database.GetTemplateAppInsightsByTemplateParams) ([]database.GetTemplateAppInsightsByTemplateRow, error) { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } + return q.db.GetTemplateAppInsightsByTemplate(ctx, arg) +} + // Only used by metrics cache. func (q *querier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 3a0f32cc8e..985b2d4b42 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -2365,6 +2365,106 @@ func (q *FakeQuerier) GetTemplateAppInsights(ctx context.Context, arg database.G return rows, nil } +func (q *FakeQuerier) GetTemplateAppInsightsByTemplate(ctx context.Context, arg database.GetTemplateAppInsightsByTemplateParams) ([]database.GetTemplateAppInsightsByTemplateRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + type uniqueKey struct { + TemplateID uuid.UUID + DisplayName string + Slug string + } + + // map (TemplateID + DisplayName + Slug) x time.Time x UserID x + usageByTemplateAppUser := map[uniqueKey]map[time.Time]map[uuid.UUID]int64{} + + // Review agent stats in terms of usage + for _, s := range q.workspaceAppStats { + // (was.session_started_at >= ts.from_ AND was.session_started_at < ts.to_) + // OR (was.session_ended_at > ts.from_ AND was.session_ended_at < ts.to_) + // OR (was.session_started_at < ts.from_ AND was.session_ended_at >= ts.to_) + if !(((s.SessionStartedAt.After(arg.StartTime) || s.SessionStartedAt.Equal(arg.StartTime)) && s.SessionStartedAt.Before(arg.EndTime)) || + (s.SessionEndedAt.After(arg.StartTime) && s.SessionEndedAt.Before(arg.EndTime)) || + (s.SessionStartedAt.Before(arg.StartTime) && (s.SessionEndedAt.After(arg.EndTime) || s.SessionEndedAt.Equal(arg.EndTime)))) { + continue + } + + w, err := q.getWorkspaceByIDNoLock(ctx, s.WorkspaceID) + if err != nil { + return nil, err + } + + app, _ := q.getWorkspaceAppByAgentIDAndSlugNoLock(ctx, database.GetWorkspaceAppByAgentIDAndSlugParams{ + AgentID: s.AgentID, + Slug: s.SlugOrPort, + }) + + key := uniqueKey{ + TemplateID: w.TemplateID, + DisplayName: app.DisplayName, + Slug: app.Slug, + } + + t := s.SessionStartedAt.Truncate(time.Minute) + if t.Before(arg.StartTime) { + t = arg.StartTime + } + for t.Before(s.SessionEndedAt) && t.Before(arg.EndTime) { + if _, ok := usageByTemplateAppUser[key]; !ok { + usageByTemplateAppUser[key] = map[time.Time]map[uuid.UUID]int64{} + } + if _, ok := usageByTemplateAppUser[key][t]; !ok { + usageByTemplateAppUser[key][t] = map[uuid.UUID]int64{} + } + if _, ok := usageByTemplateAppUser[key][t][s.UserID]; !ok { + usageByTemplateAppUser[key][t][s.UserID] = 60 // 1 minute + } + t = t.Add(1 * time.Minute) + } + } + + // Sort usage data + usageKeys := make([]uniqueKey, len(usageByTemplateAppUser)) + var i int + for key := range usageByTemplateAppUser { + usageKeys[i] = key + i++ + } + + slices.SortFunc(usageKeys, func(a, b uniqueKey) int { + if a.TemplateID != b.TemplateID { + return slice.Ascending(a.TemplateID.String(), b.TemplateID.String()) + } + if a.DisplayName != b.DisplayName { + return slice.Ascending(a.DisplayName, b.DisplayName) + } + return slice.Ascending(a.Slug, b.Slug) + }) + + // Build result + var result []database.GetTemplateAppInsightsByTemplateRow + for _, usageKey := range usageKeys { + r := database.GetTemplateAppInsightsByTemplateRow{ + TemplateID: usageKey.TemplateID, + DisplayName: sql.NullString{String: usageKey.DisplayName, Valid: true}, + SlugOrPort: usageKey.Slug, + } + for _, mUserUsage := range usageByTemplateAppUser[usageKey] { + r.ActiveUsers += int64(len(mUserUsage)) + for _, usage := range mUserUsage { + r.UsageSeconds += usage + } + } + result = append(result, r) + } + return result, nil +} + func (q *FakeQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { if err := validateDatabaseType(arg); err != nil { return database.GetTemplateAverageBuildTimeRow{}, err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 3a89ddd379..3d04591938 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -662,6 +662,13 @@ func (m metricsStore) GetTemplateAppInsights(ctx context.Context, arg database.G return r0, r1 } +func (m metricsStore) GetTemplateAppInsightsByTemplate(ctx context.Context, arg database.GetTemplateAppInsightsByTemplateParams) ([]database.GetTemplateAppInsightsByTemplateRow, error) { + start := time.Now() + r0, r1 := m.s.GetTemplateAppInsightsByTemplate(ctx, arg) + m.queryLatencies.WithLabelValues("GetTemplateAppInsightsByTemplate").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetTemplateAverageBuildTime(ctx context.Context, arg database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { start := time.Now() buildTime, err := m.s.GetTemplateAverageBuildTime(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 080c9630e7..bfa9cebc01 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1328,6 +1328,21 @@ func (mr *MockStoreMockRecorder) GetTemplateAppInsights(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateAppInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateAppInsights), arg0, arg1) } +// GetTemplateAppInsightsByTemplate mocks base method. +func (m *MockStore) GetTemplateAppInsightsByTemplate(arg0 context.Context, arg1 database.GetTemplateAppInsightsByTemplateParams) ([]database.GetTemplateAppInsightsByTemplateRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTemplateAppInsightsByTemplate", arg0, arg1) + ret0, _ := ret[0].([]database.GetTemplateAppInsightsByTemplateRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTemplateAppInsightsByTemplate indicates an expected call of GetTemplateAppInsightsByTemplate. +func (mr *MockStoreMockRecorder) GetTemplateAppInsightsByTemplate(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateAppInsightsByTemplate", reflect.TypeOf((*MockStore)(nil).GetTemplateAppInsightsByTemplate), arg0, arg1) +} + // GetTemplateAverageBuildTime mocks base method. func (m *MockStore) GetTemplateAverageBuildTime(arg0 context.Context, arg1 database.GetTemplateAverageBuildTimeParams) (database.GetTemplateAverageBuildTimeRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2d278ba933..1332644ac4 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -132,6 +132,7 @@ type sqlcQuerier interface { // timeframe. The result can be filtered on template_ids, meaning only user data // from workspaces based on those templates will be included. GetTemplateAppInsights(ctx context.Context, arg GetTemplateAppInsightsParams) ([]GetTemplateAppInsightsRow, error) + GetTemplateAppInsightsByTemplate(ctx context.Context, arg GetTemplateAppInsightsByTemplateParams) ([]GetTemplateAppInsightsByTemplateRow, error) GetTemplateAverageBuildTime(ctx context.Context, arg GetTemplateAverageBuildTimeParams) (GetTemplateAverageBuildTimeRow, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ff5aa26afa..2c5bcedd39 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1757,6 +1757,96 @@ func (q *sqlQuerier) GetTemplateAppInsights(ctx context.Context, arg GetTemplate return items, nil } +const getTemplateAppInsightsByTemplate = `-- name: GetTemplateAppInsightsByTemplate :many +WITH app_stats_by_user_and_agent AS ( + SELECT + s.start_time, + 60 as seconds, + w.template_id, + was.user_id, + was.agent_id, + was.slug_or_port, + wa.display_name, + (wa.slug IS NOT NULL)::boolean AS is_app + FROM workspace_app_stats was + JOIN workspaces w ON ( + w.id = was.workspace_id + ) + -- We do a left join here because we want to include user IDs that have used + -- e.g. ports when counting active users. + LEFT JOIN workspace_apps wa ON ( + wa.agent_id = was.agent_id + AND wa.slug = was.slug_or_port + ) + -- This table contains both 1 minute entries and >1 minute entries, + -- to calculate this with our uniqueness constraints, we generate series + -- for the longer intervals. + CROSS JOIN LATERAL generate_series( + date_trunc('minute', was.session_started_at), + -- Subtract 1 microsecond to avoid creating an extra series. + date_trunc('minute', was.session_ended_at - '1 microsecond'::interval), + '1 minute'::interval + ) s(start_time) + WHERE + s.start_time >= $1::timestamptz + -- Subtract one minute because the series only contains the start time. + AND s.start_time < ($2::timestamptz) - '1 minute'::interval + GROUP BY s.start_time, w.template_id, was.user_id, was.agent_id, was.slug_or_port, wa.display_name, wa.slug +) + +SELECT + template_id, + display_name, + slug_or_port, + COALESCE(COUNT(DISTINCT user_id))::bigint AS active_users, + SUM(seconds) AS usage_seconds +FROM app_stats_by_user_and_agent +WHERE is_app IS TRUE +GROUP BY template_id, display_name, slug_or_port +` + +type GetTemplateAppInsightsByTemplateParams struct { + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` +} + +type GetTemplateAppInsightsByTemplateRow struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + DisplayName sql.NullString `db:"display_name" json:"display_name"` + SlugOrPort string `db:"slug_or_port" json:"slug_or_port"` + ActiveUsers int64 `db:"active_users" json:"active_users"` + UsageSeconds int64 `db:"usage_seconds" json:"usage_seconds"` +} + +func (q *sqlQuerier) GetTemplateAppInsightsByTemplate(ctx context.Context, arg GetTemplateAppInsightsByTemplateParams) ([]GetTemplateAppInsightsByTemplateRow, error) { + rows, err := q.db.QueryContext(ctx, getTemplateAppInsightsByTemplate, arg.StartTime, arg.EndTime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTemplateAppInsightsByTemplateRow + for rows.Next() { + var i GetTemplateAppInsightsByTemplateRow + if err := rows.Scan( + &i.TemplateID, + &i.DisplayName, + &i.SlugOrPort, + &i.ActiveUsers, + &i.UsageSeconds, + ); 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 getTemplateInsights = `-- name: GetTemplateInsights :one WITH agent_stats_by_interval_and_user AS ( SELECT diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 01863fede1..f81141d06f 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -218,6 +218,53 @@ SELECT FROM app_stats_by_user_and_agent GROUP BY access_method, slug_or_port, display_name, icon, is_app; +-- name: GetTemplateAppInsightsByTemplate :many +WITH app_stats_by_user_and_agent AS ( + SELECT + s.start_time, + 60 as seconds, + w.template_id, + was.user_id, + was.agent_id, + was.slug_or_port, + wa.display_name, + (wa.slug IS NOT NULL)::boolean AS is_app + FROM workspace_app_stats was + JOIN workspaces w ON ( + w.id = was.workspace_id + ) + -- We do a left join here because we want to include user IDs that have used + -- e.g. ports when counting active users. + LEFT JOIN workspace_apps wa ON ( + wa.agent_id = was.agent_id + AND wa.slug = was.slug_or_port + ) + -- This table contains both 1 minute entries and >1 minute entries, + -- to calculate this with our uniqueness constraints, we generate series + -- for the longer intervals. + CROSS JOIN LATERAL generate_series( + date_trunc('minute', was.session_started_at), + -- Subtract 1 microsecond to avoid creating an extra series. + date_trunc('minute', was.session_ended_at - '1 microsecond'::interval), + '1 minute'::interval + ) s(start_time) + WHERE + s.start_time >= @start_time::timestamptz + -- Subtract one minute because the series only contains the start time. + AND s.start_time < (@end_time::timestamptz) - '1 minute'::interval + GROUP BY s.start_time, w.template_id, was.user_id, was.agent_id, was.slug_or_port, wa.display_name, wa.slug +) + +SELECT + template_id, + display_name, + slug_or_port, + COALESCE(COUNT(DISTINCT user_id))::bigint AS active_users, + SUM(seconds) AS usage_seconds +FROM app_stats_by_user_and_agent +WHERE is_app IS TRUE +GROUP BY template_id, display_name, slug_or_port; + -- name: GetTemplateInsightsByInterval :many -- GetTemplateInsightsByInterval returns all intervals between start and end -- time, if end time is a partial interval, it will be included in the results and diff --git a/coderd/insights.go b/coderd/insights.go index 714835db43..7b0d98a66a 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -452,7 +452,7 @@ func convertTemplateInsightsApps(usage database.GetTemplateInsightsRow, appUsage { TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, - DisplayName: "Visual Studio Code", + DisplayName: codersdk.TemplateBuiltinAppDisplayNameVSCode, Slug: "vscode", Icon: "/icon/code.svg", Seconds: usage.UsageVscodeSeconds, @@ -460,7 +460,7 @@ func convertTemplateInsightsApps(usage database.GetTemplateInsightsRow, appUsage { TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, - DisplayName: "JetBrains", + DisplayName: codersdk.TemplateBuiltinAppDisplayNameJetBrains, Slug: "jetbrains", Icon: "/icon/intellij.svg", Seconds: usage.UsageJetbrainsSeconds, @@ -474,7 +474,7 @@ func convertTemplateInsightsApps(usage database.GetTemplateInsightsRow, appUsage { TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, - DisplayName: "Web Terminal", + DisplayName: codersdk.TemplateBuiltinAppDisplayNameWebTerminal, Slug: "reconnecting-pty", Icon: "/icon/terminal.svg", Seconds: usage.UsageReconnectingPtySeconds, @@ -482,7 +482,7 @@ func convertTemplateInsightsApps(usage database.GetTemplateInsightsRow, appUsage { TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, - DisplayName: "SSH", + DisplayName: codersdk.TemplateBuiltinAppDisplayNameSSH, Slug: "ssh", Icon: "/icon/terminal.svg", Seconds: usage.UsageSshSeconds, diff --git a/coderd/prometheusmetrics/insights/metricscollector.go b/coderd/prometheusmetrics/insights/metricscollector.go index d19785e8e6..cf1155d317 100644 --- a/coderd/prometheusmetrics/insights/metricscollector.go +++ b/coderd/prometheusmetrics/insights/metricscollector.go @@ -13,9 +13,13 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" ) -var templatesActiveUsersDesc = prometheus.NewDesc("coderd_insights_templates_active_users", "The number of active users of the template.", []string{"template_name"}, nil) +var ( + templatesActiveUsersDesc = prometheus.NewDesc("coderd_insights_templates_active_users", "The number of active users of the template.", []string{"template_name"}, nil) + applicationsUsageSecondsDesc = prometheus.NewDesc("coderd_insights_applications_usage_seconds", "The application usage per template.", []string{"template_name", "application_name", "slug"}, nil) +) type MetricsCollector struct { database database.Store @@ -28,6 +32,7 @@ type MetricsCollector struct { type insightsData struct { templates []database.GetTemplateInsightsByTemplateRow + apps []database.GetTemplateAppInsightsByTemplateRow templateNames map[uuid.UUID]string } @@ -70,9 +75,10 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) { // Phase 1: Fetch insights from database // FIXME errorGroup will be used to fetch insights for apps and parameters eg, egCtx := errgroup.WithContext(ctx) - eg.SetLimit(1) + eg.SetLimit(2) var templateInsights []database.GetTemplateInsightsByTemplateRow + var appInsights []database.GetTemplateAppInsightsByTemplateRow eg.Go(func() error { var err error @@ -85,13 +91,24 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) { } return err }) + eg.Go(func() error { + var err error + appInsights, err = mc.database.GetTemplateAppInsightsByTemplate(egCtx, database.GetTemplateAppInsightsByTemplateParams{ + StartTime: startTime, + EndTime: endTime, + }) + if err != nil { + mc.logger.Error(ctx, "unable to fetch application insights from database", slog.Error(err)) + } + return err + }) err := eg.Wait() if err != nil { return } // Phase 2: Collect template IDs, and fetch relevant details - templateIDs := uniqueTemplateIDs(templateInsights) + templateIDs := uniqueTemplateIDs(templateInsights, appInsights) templateNames := make(map[uuid.UUID]string, len(templateIDs)) if len(templateIDs) > 0 { @@ -107,7 +124,9 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) { // Refresh the collector state mc.data.Store(&insightsData{ - templates: templateInsights, + templates: templateInsights, + apps: appInsights, + templateNames: templateNames, }) } @@ -133,6 +152,7 @@ func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) { func (*MetricsCollector) Describe(descCh chan<- *prometheus.Desc) { descCh <- templatesActiveUsersDesc + descCh <- applicationsUsageSecondsDesc } func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) { @@ -143,6 +163,40 @@ func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) { return // insights data not loaded yet } + // Custom apps + for _, appRow := range data.apps { + metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue, float64(appRow.UsageSeconds), data.templateNames[appRow.TemplateID], + appRow.DisplayName.String, appRow.SlugOrPort) + } + + // Built-in apps + for _, templateRow := range data.templates { + metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue, + float64(templateRow.UsageVscodeSeconds), + data.templateNames[templateRow.TemplateID], + codersdk.TemplateBuiltinAppDisplayNameVSCode, + "") + + metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue, + float64(templateRow.UsageJetbrainsSeconds), + data.templateNames[templateRow.TemplateID], + codersdk.TemplateBuiltinAppDisplayNameJetBrains, + "") + + metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue, + float64(templateRow.UsageReconnectingPtySeconds), + data.templateNames[templateRow.TemplateID], + codersdk.TemplateBuiltinAppDisplayNameWebTerminal, + "") + + metricsCh <- prometheus.MustNewConstMetric(applicationsUsageSecondsDesc, prometheus.GaugeValue, + float64(templateRow.UsageSshSeconds), + data.templateNames[templateRow.TemplateID], + codersdk.TemplateBuiltinAppDisplayNameSSH, + "") + } + + // Templates for _, templateRow := range data.templates { metricsCh <- prometheus.MustNewConstMetric(templatesActiveUsersDesc, prometheus.GaugeValue, float64(templateRow.ActiveUsers), data.templateNames[templateRow.TemplateID]) } @@ -150,11 +204,14 @@ func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) { // Helper functions below. -func uniqueTemplateIDs(templateInsights []database.GetTemplateInsightsByTemplateRow) []uuid.UUID { +func uniqueTemplateIDs(templateInsights []database.GetTemplateInsightsByTemplateRow, appInsights []database.GetTemplateAppInsightsByTemplateRow) []uuid.UUID { tids := map[uuid.UUID]bool{} for _, t := range templateInsights { tids[t.TemplateID] = true } + for _, t := range appInsights { + tids[t.TemplateID] = true + } uniqueUUIDs := make([]uuid.UUID, len(tids)) var i int diff --git a/coderd/prometheusmetrics/insights/metricscollector_test.go b/coderd/prometheusmetrics/insights/metricscollector_test.go index 0c1726a910..8ead4f647d 100644 --- a/coderd/prometheusmetrics/insights/metricscollector_test.go +++ b/coderd/prometheusmetrics/insights/metricscollector_test.go @@ -5,25 +5,30 @@ import ( "encoding/json" "io" "os" + "strings" "testing" "time" "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/sloggers/slogtest" "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbauthz" "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/codersdk" "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" ) -func TestCollect_TemplateInsights(t *testing.T) { +func TestCollectInsights(t *testing.T) { t.Parallel() logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) @@ -53,9 +58,11 @@ func TestCollect_TemplateInsights(t *testing.T) { version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, ProvisionPlan: echo.PlanComplete, - ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + ProvisionApply: provisionApplyWithAgentAndApp(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.Name = "golden-template" }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart]) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -66,6 +73,24 @@ func TestCollect_TemplateInsights(t *testing.T) { _ = agenttest.New(t, client.URL, authToken) resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + // Fake app usage + reporter := workspaceapps.NewStatsDBReporter(db, workspaceapps.DefaultStatsDBReporterBatchSize) + //nolint:gocritic // This is a test. + err = reporter.Report(dbauthz.AsSystemRestricted(context.Background()), []workspaceapps.StatsReport{ + { + UserID: user.UserID, + WorkspaceID: workspace.ID, + AgentID: resources[0].Agents[0].ID, + AccessMethod: "terminal", + SlugOrPort: "golden-slug", + SessionID: uuid.New(), + SessionStartedAt: time.Now().Add(-3 * time.Minute), + SessionEndedAt: time.Now().Add(-time.Minute).Add(-time.Second), + Requests: 1, + }, + }) + require.NoError(t, err, "want no error inserting app stats") + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -97,6 +122,11 @@ func TestCollect_TemplateInsights(t *testing.T) { err = sess.Start("cat") require.NoError(t, err) + defer func() { + _ = sess.Close() + _ = sshConn.Close() + }() + goldenFile, err := os.ReadFile("testdata/insights-metrics.json") require.NoError(t, err) golden := map[string]int{} @@ -112,9 +142,13 @@ func TestCollect_TemplateInsights(t *testing.T) { // Then for _, metric := range metrics { switch metric.GetName() { - case "coderd_insights_templates_active_users": + case "coderd_insights_applications_usage_seconds", "coderd_insights_templates_active_users": for _, m := range metric.Metric { - collected[metric.GetName()] = int(m.Gauge.GetValue()) + key := metric.GetName() + if len(m.Label) > 0 { + key = key + "[" + metricLabelAsString(m) + "]" + } + collected[key] = int(m.Gauge.GetValue()) } default: require.FailNowf(t, "unexpected metric collected", "metric: %s", metric.GetName()) @@ -122,11 +156,41 @@ func TestCollect_TemplateInsights(t *testing.T) { } return assert.ObjectsAreEqualValues(golden, collected) - }, testutil.WaitMedium, testutil.IntervalFast, "template insights are missing") - - // We got our latency metrics, close the connection. - _ = sess.Close() - _ = sshConn.Close() - - require.EqualValues(t, golden, collected) + }, testutil.WaitMedium, testutil.IntervalFast, "template insights are inconsistent with golden files, got: %v", 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, ",") +} + +func provisionApplyWithAgentAndApp(authToken string) []*proto.Response { + return []*proto.Response{{ + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + Auth: &proto.Agent_Token{ + Token: authToken, + }, + Apps: []*proto.App{ + { + Slug: "golden-slug", + DisplayName: "Golden Slug", + SharingLevel: proto.AppSharingLevel_OWNER, + Url: "http://localhost:1234", + }, + }, + }}, + }}, + }, + }, + }} } diff --git a/coderd/prometheusmetrics/insights/testdata/insights-metrics.json b/coderd/prometheusmetrics/insights/testdata/insights-metrics.json index 01c96a78b6..dfda2dd11f 100644 --- a/coderd/prometheusmetrics/insights/testdata/insights-metrics.json +++ b/coderd/prometheusmetrics/insights/testdata/insights-metrics.json @@ -1,3 +1,8 @@ { - "coderd_insights_templates_active_users": 1 + "coderd_insights_applications_usage_seconds[application_name=JetBrains,slug=,template_name=golden-template]": 0, + "coderd_insights_applications_usage_seconds[application_name=Visual Studio Code,slug=,template_name=golden-template]": 0, + "coderd_insights_applications_usage_seconds[application_name=Web Terminal,slug=,template_name=golden-template]": 0, + "coderd_insights_applications_usage_seconds[application_name=SSH,slug=,template_name=golden-template]": 60, + "coderd_insights_applications_usage_seconds[application_name=Golden Slug,slug=golden-slug,template_name=golden-template]": 180, + "coderd_insights_templates_active_users[template_name=golden-template]": 1 } diff --git a/codersdk/insights.go b/codersdk/insights.go index 047f35a4da..5166d29d2d 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -200,6 +200,14 @@ const ( TemplateAppsTypeApp TemplateAppsType = "app" ) +// Enums define the display name of the builtin app reported. +const ( + TemplateBuiltinAppDisplayNameVSCode string = "Visual Studio Code" + TemplateBuiltinAppDisplayNameJetBrains string = "JetBrains" + TemplateBuiltinAppDisplayNameWebTerminal string = "Web Terminal" + TemplateBuiltinAppDisplayNameSSH string = "SSH" +) + // TemplateAppUsage shows the usage of an app for one or more templates. type TemplateAppUsage struct { TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`