From bc83de2a72d04156a5f4cfd64c69f6a0d2387804 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 29 May 2025 13:13:44 +0200 Subject: [PATCH] feat: add prebuilt workspaces telemetry (#18084) Adds telemetry for a _global_ account of prebuilt workspaces created, failed to build, and claimed. Partitioning this data by template/preset tuple is not currently in scope. --------- Signed-off-by: Danny Kopping --- cli/server.go | 1 + coderd/telemetry/telemetry.go | 66 +++++++++++++++++- coderd/telemetry/telemetry_test.go | 107 +++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 1 deletion(-) diff --git a/cli/server.go b/cli/server.go index 1794044bce..62b430cf22 100644 --- a/cli/server.go +++ b/cli/server.go @@ -864,6 +864,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. BuiltinPostgres: builtinPostgres, DeploymentID: deploymentID, Database: options.Database, + Experiments: coderd.ReadExperiments(options.Logger, options.DeploymentValues.Experiments.Value()), Logger: logger.Named("telemetry"), URL: vals.Telemetry.URL.Value(), Tunnel: tunnel != nil, diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 2d67890548..5fa5bb3fbb 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -28,6 +28,7 @@ import ( "google.golang.org/protobuf/types/known/wrapperspb" "cdr.dev/slog" + "github.com/coder/coder/v2/buildinfo" clitelemetry "github.com/coder/coder/v2/cli/telemetry" "github.com/coder/coder/v2/coderd/database" @@ -47,7 +48,8 @@ type Options struct { Database database.Store Logger slog.Logger // URL is an endpoint to direct telemetry towards! - URL *url.URL + URL *url.URL + Experiments codersdk.Experiments DeploymentID string DeploymentConfig *codersdk.DeploymentValues @@ -683,6 +685,52 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) { } return nil }) + eg.Go(func() error { + if !r.options.Experiments.Enabled(codersdk.ExperimentWorkspacePrebuilds) { + return nil + } + + metrics, err := r.options.Database.GetPrebuildMetrics(ctx) + if err != nil { + return xerrors.Errorf("get prebuild metrics: %w", err) + } + + var totalCreated, totalFailed, totalClaimed int64 + for _, metric := range metrics { + totalCreated += metric.CreatedCount + totalFailed += metric.FailedCount + totalClaimed += metric.ClaimedCount + } + + snapshot.PrebuiltWorkspaces = make([]PrebuiltWorkspace, 0, 3) + now := dbtime.Now() + + if totalCreated > 0 { + snapshot.PrebuiltWorkspaces = append(snapshot.PrebuiltWorkspaces, PrebuiltWorkspace{ + ID: uuid.New(), + CreatedAt: now, + EventType: PrebuiltWorkspaceEventTypeCreated, + Count: int(totalCreated), + }) + } + if totalFailed > 0 { + snapshot.PrebuiltWorkspaces = append(snapshot.PrebuiltWorkspaces, PrebuiltWorkspace{ + ID: uuid.New(), + CreatedAt: now, + EventType: PrebuiltWorkspaceEventTypeFailed, + Count: int(totalFailed), + }) + } + if totalClaimed > 0 { + snapshot.PrebuiltWorkspaces = append(snapshot.PrebuiltWorkspaces, PrebuiltWorkspace{ + ID: uuid.New(), + CreatedAt: now, + EventType: PrebuiltWorkspaceEventTypeClaimed, + Count: int(totalClaimed), + }) + } + return nil + }) err := eg.Wait() if err != nil { @@ -1152,6 +1200,7 @@ type Snapshot struct { Organizations []Organization `json:"organizations"` TelemetryItems []TelemetryItem `json:"telemetry_items"` UserTailnetConnections []UserTailnetConnection `json:"user_tailnet_connections"` + PrebuiltWorkspaces []PrebuiltWorkspace `json:"prebuilt_workspaces"` } // Deployment contains information about the host running Coder. @@ -1724,6 +1773,21 @@ type UserTailnetConnection struct { CoderDesktopVersion *string `json:"coder_desktop_version"` } +type PrebuiltWorkspaceEventType string + +const ( + PrebuiltWorkspaceEventTypeCreated PrebuiltWorkspaceEventType = "created" + PrebuiltWorkspaceEventTypeFailed PrebuiltWorkspaceEventType = "failed" + PrebuiltWorkspaceEventTypeClaimed PrebuiltWorkspaceEventType = "claimed" +) + +type PrebuiltWorkspace struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + EventType PrebuiltWorkspaceEventType `json:"event_type"` + Count int `json:"count"` +} + type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index 7de4c98e07..ab9d2a75e9 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -370,6 +370,113 @@ func TestTelemetryItem(t *testing.T) { require.Equal(t, item.Value, "new_value") } +func TestPrebuiltWorkspacesTelemetry(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + db, _ := dbtestutil.NewDB(t) + + cases := []struct { + name string + experimentEnabled bool + storeFn func(store database.Store) database.Store + expectedSnapshotEntries int + expectedCreated int + expectedFailed int + expectedClaimed int + }{ + { + name: "experiment enabled", + experimentEnabled: true, + storeFn: func(store database.Store) database.Store { + return &mockDB{Store: store} + }, + expectedSnapshotEntries: 3, + expectedCreated: 5, + expectedFailed: 2, + expectedClaimed: 3, + }, + { + name: "experiment enabled, prebuilds not used", + experimentEnabled: true, + storeFn: func(store database.Store) database.Store { + return &emptyMockDB{Store: store} + }, + }, + { + name: "experiment disabled", + experimentEnabled: false, + storeFn: func(store database.Store) database.Store { + return &mockDB{Store: store} + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + deployment, snapshot := collectSnapshot(ctx, t, db, func(opts telemetry.Options) telemetry.Options { + opts.Database = tc.storeFn(db) + if tc.experimentEnabled { + opts.Experiments = codersdk.Experiments{ + codersdk.ExperimentWorkspacePrebuilds, + } + } + return opts + }) + + require.NotNil(t, deployment) + require.NotNil(t, snapshot) + + require.Len(t, snapshot.PrebuiltWorkspaces, tc.expectedSnapshotEntries) + + eventCounts := make(map[telemetry.PrebuiltWorkspaceEventType]int) + for _, event := range snapshot.PrebuiltWorkspaces { + eventCounts[event.EventType] = event.Count + require.NotEqual(t, uuid.Nil, event.ID) + require.False(t, event.CreatedAt.IsZero()) + } + + require.Equal(t, tc.expectedCreated, eventCounts[telemetry.PrebuiltWorkspaceEventTypeCreated]) + require.Equal(t, tc.expectedFailed, eventCounts[telemetry.PrebuiltWorkspaceEventTypeFailed]) + require.Equal(t, tc.expectedClaimed, eventCounts[telemetry.PrebuiltWorkspaceEventTypeClaimed]) + }) + } +} + +type mockDB struct { + database.Store +} + +func (*mockDB) GetPrebuildMetrics(context.Context) ([]database.GetPrebuildMetricsRow, error) { + return []database.GetPrebuildMetricsRow{ + { + TemplateName: "template1", + PresetName: "preset1", + OrganizationName: "org1", + CreatedCount: 3, + FailedCount: 1, + ClaimedCount: 2, + }, + { + TemplateName: "template2", + PresetName: "preset2", + OrganizationName: "org1", + CreatedCount: 2, + FailedCount: 1, + ClaimedCount: 1, + }, + }, nil +} + +type emptyMockDB struct { + database.Store +} + +func (*emptyMockDB) GetPrebuildMetrics(context.Context) ([]database.GetPrebuildMetricsRow, error) { + return []database.GetPrebuildMetricsRow{}, nil +} + func TestShouldReportTelemetryDisabled(t *testing.T) { t.Parallel() // Description | telemetryEnabled (db) | telemetryEnabled (is) | Report Telemetry Disabled |