diff --git a/cli/server.go b/cli/server.go index b69c6c5c83489..1ff7a2cbe294c 100644 --- a/cli/server.go +++ b/cli/server.go @@ -49,6 +49,7 @@ import ( "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/devtunnel" "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/coderd/monitoring" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/coderd/turnconn" "github.com/coder/coder/codersdk" @@ -80,6 +81,7 @@ func server() *cobra.Command { oauth2GithubClientSecret string oauth2GithubAllowedOrganizations []string oauth2GithubAllowSignups bool + telemetryRaw string tlsCertFile string tlsClientCAFile string tlsClientAuth string @@ -112,7 +114,7 @@ func server() *cobra.Command { sqlDriver = "postgres" ) if trace { - tracerProvider, err = tracing.TracerProvider(cmd.Context(), "coderd") + tracerProvider, err = tracing.Provider(cmd.Context(), "coderd") if err != nil { logger.Warn(cmd.Context(), "failed to start telemetry exporter", slog.Error(err)) } else { @@ -243,6 +245,11 @@ func server() *cobra.Command { return xerrors.Errorf("parse ssh keygen algorithm %s: %w", sshKeygenAlgorithmRaw, err) } + telemetry, err := monitoring.ParseTelemetry(telemetryRaw) + if err != nil { + return xerrors.Errorf("parse telemetry %s: %w", telemetryRaw, err) + } + turnServer, err := turnconn.New(&turn.RelayAddressGeneratorStatic{ RelayAddress: net.ParseIP(turnRelayAddress), Address: turnRelayAddress, @@ -301,6 +308,12 @@ func server() *cobra.Command { } } + options.Monitor = monitoring.New(cmd.Context(), &monitoring.Options{ + Database: options.Database, + Logger: options.Logger, + Telemetry: telemetry, + }) + coderAPI := coderd.New(options) client := codersdk.New(localURL) if tlsEnable { @@ -537,6 +550,8 @@ func server() *cobra.Command { "Specifies organizations the user must be a member of to authenticate with GitHub.") cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false, "Specifies whether new users can sign up with GitHub.") + cliflag.StringVarP(root.Flags(), &telemetryRaw, "telemetry", "", "CODER_TELEMETRY", "all", "The level of telemetry to send. "+ + `Accepted values are "all", "core", or "none"`) cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false, "Specifies if TLS will be enabled") cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "", "Specifies the path to the certificate for TLS. It requires a PEM-encoded file. "+ @@ -698,16 +713,16 @@ func newProvisionerDaemon(ctx context.Context, coderAPI *coderd.API, func printLogo(cmd *cobra.Command, spooky bool) { if spooky { _, _ = fmt.Fprintf(cmd.OutOrStdout(), ` - ▄████▄ ▒█████ ▓█████▄ ▓█████ ██▀███ + ▄████▄ ▒█████ ▓█████▄ ▓█████ ██▀███ ▒██▀ ▀█ ▒██▒ ██▒▒██▀ ██▌▓█ ▀ ▓██ ▒ ██▒ ▒▓█ ▄ ▒██░ ██▒░██ █▌▒███ ▓██ ░▄█ ▒ - ▒▓▓▄ ▄██▒▒██ ██░░▓█▄ ▌▒▓█ ▄ ▒██▀▀█▄ + ▒▓▓▄ ▄██▒▒██ ██░░▓█▄ ▌▒▓█ ▄ ▒██▀▀█▄ ▒ ▓███▀ ░░ ████▓▒░░▒████▓ ░▒████▒░██▓ ▒██▒ ░ ░▒ ▒ ░░ ▒░▒░▒░ ▒▒▓ ▒ ░░ ▒░ ░░ ▒▓ ░▒▓░ ░ ▒ ░ ▒ ▒░ ░ ▒ ▒ ░ ░ ░ ░▒ ░ ▒░ - ░ ░ ░ ░ ▒ ░ ░ ░ ░ ░░ ░ - ░ ░ ░ ░ ░ ░ ░ ░ - ░ ░ + ░ ░ ░ ░ ▒ ░ ░ ░ ░ ░░ ░ + ░ ░ ░ ░ ░ ░ ░ ░ + ░ ░ `) return diff --git a/coderd/coderd.go b/coderd/coderd.go index 2cd94d1eac646..226daacb11eb1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -24,6 +24,7 @@ import ( "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/monitoring" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/tracing" "github.com/coder/coder/coderd/turnconn" @@ -50,6 +51,7 @@ type Options struct { GoogleTokenValidator *idtoken.Validator GithubOAuth2Config *GithubOAuth2Config ICEServers []webrtc.ICEServer + Monitor *monitoring.Monitor SecureAuthCookie bool SSHKeygenAlgorithm gitsshkey.Algorithm TURNServer *turnconn.Server @@ -92,7 +94,7 @@ func New(options *Options) *API { next.ServeHTTP(middleware.NewWrapResponseWriter(w, r.ProtoMajor), r) }) }, - httpmw.Prometheus, + httpmw.Prometheus(options.Monitor), tracing.HTTPMW(api.TracerProvider, "coderd.http"), ) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index ea068927004df..893745e36aae9 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -47,6 +47,7 @@ import ( "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/database/postgres" "github.com/coder/coder/coderd/gitsshkey" + "github.com/coder/coder/coderd/monitoring" "github.com/coder/coder/coderd/turnconn" "github.com/coder/coder/codersdk" "github.com/coder/coder/cryptorand" @@ -160,10 +161,15 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, *coderd.API) AzureCertificates: options.AzureCertificates, GithubOAuth2Config: options.GithubOAuth2Config, GoogleTokenValidator: options.GoogleTokenValidator, - SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, - TURNServer: turnServer, - APIRateLimit: options.APIRateLimit, - Authorizer: options.Authorizer, + Monitor: monitoring.New(ctx, &monitoring.Options{ + Database: db, + Logger: slogtest.Make(t, nil), + Telemetry: monitoring.TelemetryNone, + }), + SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, + TURNServer: turnServer, + APIRateLimit: options.APIRateLimit, + Authorizer: options.Authorizer, }) srv.Config.Handler = coderAPI.Handler if options.IncludeProvisionerD { diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index fcc214a5745b7..8eeae6c1d25c8 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -554,6 +554,35 @@ func (q *fakeQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(_ context.Context, return returnBuilds, nil } +func (q *fakeQuerier) GetLatestWorkspaceResources(ctx context.Context) ([]database.WorkspaceResource, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + // Get latest workspace builds. + builds := make(map[uuid.UUID]database.WorkspaceBuild) + buildNumbers := make(map[uuid.UUID]int32) + for _, workspaceBuild := range q.workspaceBuilds { + if workspaceBuild.BuildNumber > buildNumbers[workspaceBuild.WorkspaceID] { + builds[workspaceBuild.WorkspaceID] = workspaceBuild + buildNumbers[workspaceBuild.WorkspaceID] = workspaceBuild.BuildNumber + } + } + + // Get resources for each latest build. + resources := make([]database.WorkspaceResource, 0) + for _, workspaceBuild := range q.workspaceBuilds { + rs, err := q.GetWorkspaceResourcesByJobID(ctx, workspaceBuild.JobID) + if err != nil { + return nil, err + } + resources = append(resources, rs...) + } + if len(resources) == 0 { + return nil, sql.ErrNoRows + } + return resources, nil +} + func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceID(_ context.Context, params database.GetWorkspaceBuildByWorkspaceIDParams) ([]database.WorkspaceBuild, error) { q.mutex.RLock() @@ -1180,6 +1209,23 @@ func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid return resources, nil } +// revive:disable-next-line:flag-parameter +func (q *fakeQuerier) GetWorkspaces(_ context.Context, deleted bool) ([]database.Workspace, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspaces { + if workspace.Deleted == deleted { + workspaces = append(workspaces, workspace) + } + } + if len(workspaces) == 0 { + return nil, sql.ErrNoRows + } + return workspaces, nil +} + func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID) ([]database.ProvisionerJob, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c3f57d3a9f795..6b410d1a580c2 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -32,6 +32,7 @@ type querier interface { GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) + GetLatestWorkspaceResources(ctx context.Context) ([]WorkspaceResource, error) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Organization, error) GetOrganizationByName(ctx context.Context, name string) (Organization, error) GetOrganizationIDsByMemberIDs(ctx context.Context, ids []uuid.UUID) ([]GetOrganizationIDsByMemberIDsRow, error) @@ -77,6 +78,7 @@ type querier interface { GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) + GetWorkspaces(ctx context.Context, deleted bool) ([]Workspace, error) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error) GetWorkspacesByOrganizationIDs(ctx context.Context, arg GetWorkspacesByOrganizationIDsParams) ([]Workspace, error) GetWorkspacesByTemplateID(ctx context.Context, arg GetWorkspacesByTemplateIDParams) ([]Workspace, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index edb2fc774589c..8aae1b5aa6c0c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3386,6 +3386,57 @@ func (q *sqlQuerier) UpdateWorkspaceBuildByID(ctx context.Context, arg UpdateWor return err } +const getLatestWorkspaceResources = `-- name: GetLatestWorkspaceResources :many +SELECT workspace_resources.id, workspace_resources.created_at, workspace_resources.job_id, workspace_resources.transition, workspace_resources.type, workspace_resources.name +FROM ( + SELECT + workspace_id, MAX(build_number) as max_build_number + FROM + workspace_builds + GROUP BY + workspace_id +) latest_workspace_builds +INNER JOIN + workspace_builds +ON + workspace_builds.workspace_id = latest_workspace_builds.workspace_id + AND workspace_builds.build_number = latest_workspace_builds.max_build_number +INNER JOIN + workspace_resources +ON + workspace_resources.job_id = workspace_builds.job_id +` + +func (q *sqlQuerier) GetLatestWorkspaceResources(ctx context.Context) ([]WorkspaceResource, error) { + rows, err := q.db.QueryContext(ctx, getLatestWorkspaceResources) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceResource + for rows.Next() { + var i WorkspaceResource + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.JobID, + &i.Transition, + &i.Type, + &i.Name, + ); 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 getWorkspaceResourceByID = `-- name: GetWorkspaceResourceByID :one SELECT id, created_at, job_id, transition, type, name @@ -3590,6 +3641,44 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i return items, nil } +const getWorkspaces = `-- name: GetWorkspaces :many +SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl FROM workspaces WHERE deleted = $1 +` + +func (q *sqlQuerier) GetWorkspaces(ctx context.Context, deleted bool) ([]Workspace, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaces, deleted) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + ); 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 getWorkspacesAutostart = `-- name: GetWorkspacesAutostart :many SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl diff --git a/coderd/database/queries/workspaceresources.sql b/coderd/database/queries/workspaceresources.sql index c120cf41a8d57..3c95e27e54d08 100644 --- a/coderd/database/queries/workspaceresources.sql +++ b/coderd/database/queries/workspaceresources.sql @@ -19,3 +19,23 @@ INSERT INTO workspace_resources (id, created_at, job_id, transition, type, name) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *; + +-- name: GetLatestWorkspaceResources :many +SELECT workspace_resources.* +FROM ( + SELECT + workspace_id, MAX(build_number) as max_build_number + FROM + workspace_builds + GROUP BY + workspace_id +) latest_workspace_builds +INNER JOIN + workspace_builds +ON + workspace_builds.workspace_id = latest_workspace_builds.workspace_id + AND workspace_builds.build_number = latest_workspace_builds.max_build_number +INNER JOIN + workspace_resources +ON + workspace_resources.job_id = workspace_builds.job_id; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 1b9f6a88f6256..70f38f8652957 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -1,3 +1,6 @@ +-- name: GetWorkspaces :many +SELECT * FROM workspaces WHERE deleted = $1; + -- name: GetWorkspaceByID :one SELECT * diff --git a/coderd/httpmw/prometheus.go b/coderd/httpmw/prometheus.go index e03966ff9788f..213bbf5c37778 100644 --- a/coderd/httpmw/prometheus.go +++ b/coderd/httpmw/prometheus.go @@ -9,29 +9,30 @@ import ( chimw "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" + + "github.com/coder/coder/coderd/monitoring" ) var ( - requestsProcessed = promauto.NewCounterVec(prometheus.CounterOpts{ + requestsProcessed = prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: "coderd", Subsystem: "api", Name: "requests_processed_total", Help: "The total number of processed API requests", }, []string{"code", "method", "path"}) - requestsConcurrent = promauto.NewGauge(prometheus.GaugeOpts{ + requestsConcurrent = prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: "coderd", Subsystem: "api", Name: "concurrent_requests", Help: "The number of concurrent API requests", }) - websocketsConcurrent = promauto.NewGauge(prometheus.GaugeOpts{ + websocketsConcurrent = prometheus.NewGauge(prometheus.GaugeOpts{ Namespace: "coderd", Subsystem: "api", Name: "concurrent_websockets", Help: "The total number of concurrent API websockets", }) - websocketsDist = promauto.NewHistogramVec(prometheus.HistogramOpts{ + websocketsDist = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: "coderd", Subsystem: "api", Name: "websocket_durations_ms", @@ -45,7 +46,7 @@ var ( durationToFloatMs(30 * time.Hour), }, }, []string{"path"}) - requestsDist = promauto.NewHistogramVec(prometheus.HistogramOpts{ + requestsDist = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: "coderd", Subsystem: "api", Name: "request_latencies_ms", @@ -58,45 +59,55 @@ func durationToFloatMs(d time.Duration) float64 { return float64(d.Milliseconds()) } -func Prometheus(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var ( - start = time.Now() - method = r.Method - rctx = chi.RouteContext(r.Context()) - ) - sw, ok := w.(chimw.WrapResponseWriter) - if !ok { - panic("dev error: http.ResponseWriter is not chimw.WrapResponseWriter") - } +func Prometheus(monitor *monitoring.Monitor) func(http.Handler) http.Handler { + monitor.MustRegister( + monitoring.TelemetryNone, + requestsProcessed, + requestsConcurrent, + websocketsConcurrent, + requestsDist, + ) - var ( - dist *prometheus.HistogramVec - distOpts []string - ) - // We want to count websockets separately. - if isWebsocketUpgrade(r) { - websocketsConcurrent.Inc() - defer websocketsConcurrent.Dec() + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ( + start = time.Now() + method = r.Method + rctx = chi.RouteContext(r.Context()) + ) + sw, ok := w.(chimw.WrapResponseWriter) + if !ok { + panic("dev error: http.ResponseWriter is not chimw.WrapResponseWriter") + } - dist = websocketsDist - } else { - requestsConcurrent.Inc() - defer requestsConcurrent.Dec() + var ( + dist *prometheus.HistogramVec + distOpts []string + ) + // We want to count websockets separately. + if isWebsocketUpgrade(r) { + websocketsConcurrent.Inc() + defer websocketsConcurrent.Dec() - dist = requestsDist - distOpts = []string{method} - } + dist = websocketsDist + } else { + requestsConcurrent.Inc() + defer requestsConcurrent.Dec() - next.ServeHTTP(w, r) + dist = requestsDist + distOpts = []string{method} + } - path := rctx.RoutePattern() - distOpts = append(distOpts, path) - statusStr := strconv.Itoa(sw.Status()) + next.ServeHTTP(w, r) - requestsProcessed.WithLabelValues(statusStr, method, path).Inc() - dist.WithLabelValues(distOpts...).Observe(float64(time.Since(start)) / 1e6) - }) + path := rctx.RoutePattern() + distOpts = append(distOpts, path) + statusStr := strconv.Itoa(sw.Status()) + + requestsProcessed.WithLabelValues(statusStr, method, path).Inc() + dist.WithLabelValues(distOpts...).Observe(float64(time.Since(start)) / 1e6) + }) + } } func isWebsocketUpgrade(r *http.Request) bool { diff --git a/coderd/monitoring/collector.go b/coderd/monitoring/collector.go new file mode 100644 index 0000000000000..c0e8d5f25c6c1 --- /dev/null +++ b/coderd/monitoring/collector.go @@ -0,0 +1,122 @@ +package monitoring + +import ( + "context" + "database/sql" + "sync" + + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" +) + +// Collector implements prometheus.Collector and collects statistics from the +// provided database. +type Collector struct { + ctx context.Context + db database.Store + users *prometheus.Desc + workspaces *prometheus.Desc + workspaceResources *prometheus.Desc +} + +func NewCollector(ctx context.Context, db database.Store) *Collector { + return &Collector{ + ctx: ctx, + db: db, + users: prometheus.NewDesc( + "coder_users", + "The users in a Coder deployment.", + nil, + nil, + ), + workspaces: prometheus.NewDesc( + "coder_workspaces", + "The workspaces in a Coder deployment.", + nil, + nil, + ), + workspaceResources: prometheus.NewDesc( + "coder_workspace_resources", + "The workspace resources in a Coder deployment.", + []string{ + "workspace_resource_type", + }, + nil, + ), + } +} + +// Describe implements prometheus.Collector. +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.users + ch <- c.workspaces + ch <- c.workspaceResources +} + +// Collect implements prometheus.Collector. +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + + dbUsers, err := c.db.GetUsers(c.ctx, database.GetUsersParams{}) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + ch <- prometheus.NewInvalidMetric(c.users, err) + return + } + + ch <- prometheus.MustNewConstMetric( + c.users, + prometheus.GaugeValue, + float64(len(dbUsers)), + ) + }() + + wg.Add(1) + go func() { + defer wg.Done() + + dbWorkspaces, err := c.db.GetWorkspaces(c.ctx, false) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + ch <- prometheus.NewInvalidMetric(c.workspaces, err) + return + } + + ch <- prometheus.MustNewConstMetric( + c.workspaces, + prometheus.GaugeValue, + float64(len(dbWorkspaces)), + ) + }() + + wg.Add(1) + go func() { + defer wg.Done() + + dbWorkspaceResources, err := c.db.GetLatestWorkspaceResources(c.ctx) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + ch <- prometheus.NewInvalidMetric(c.workspaceResources, err) + return + } + + resourcesByType := map[string][]database.WorkspaceResource{} + for _, dbwr := range dbWorkspaceResources { + resourcesByType[dbwr.Type] = append(resourcesByType[dbwr.Type], dbwr) + } + + for resourceType, resources := range resourcesByType { + ch <- prometheus.MustNewConstMetric( + c.workspaceResources, + prometheus.GaugeValue, + float64(len(resources)), + resourceType, + ) + } + }() + + wg.Wait() +} diff --git a/coderd/monitoring/collector_test.go b/coderd/monitoring/collector_test.go new file mode 100644 index 0000000000000..131da453c4c58 --- /dev/null +++ b/coderd/monitoring/collector_test.go @@ -0,0 +1,100 @@ +package monitoring_test + +import ( + "context" + "strings" + "testing" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/monitoring" + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/require" +) + +func TestCollector(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + db := databasefake.New() + populateDB(ctx, db) + + collector := monitoring.NewCollector(ctx, db) + expected := ` + # HELP coder_users The users in a Coder deployment. + # TYPE coder_users gauge + coder_users 1 + # HELP coder_workspace_resources The workspace resources in a Coder deployment. + # TYPE coder_workspace_resources gauge + coder_workspace_resources{workspace_resource_type="google_compute_instance"} 2 + # HELP coder_workspaces The workspaces in a Coder deployment. + # TYPE coder_workspaces gauge + coder_workspaces 2 + ` + require.NoError(t, testutil.CollectAndCompare(collector, strings.NewReader(expected))) +} + +func populateDB(ctx context.Context, db database.Store) { + user, _ := db.InsertUser(ctx, database.InsertUserParams{ + ID: uuid.New(), + Username: "kyle", + }) + org, _ := db.InsertOrganization(ctx, database.InsertOrganizationParams{ + ID: uuid.New(), + Name: "potato", + }) + template, _ := db.InsertTemplate(ctx, database.InsertTemplateParams{ + ID: uuid.New(), + Name: "something", + OrganizationID: org.ID, + }) + workspace, _ := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{ + ID: uuid.New(), + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: template.ID, + Name: "banana1", + }) + job, _ := db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + ID: uuid.New(), + OrganizationID: org.ID, + }) + version, _ := db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + ID: uuid.New(), + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + CreatedAt: database.Now(), + OrganizationID: org.ID, + JobID: job.ID, + }) + db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{ + ID: uuid.New(), + JobID: job.ID, + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + Transition: database.WorkspaceTransitionStart, + }) + db.InsertWorkspaceResource(ctx, database.InsertWorkspaceResourceParams{ + ID: uuid.New(), + JobID: job.ID, + Type: "google_compute_instance", + Name: "banana2", + }) + db.InsertWorkspaceResource(ctx, database.InsertWorkspaceResourceParams{ + ID: uuid.New(), + JobID: job.ID, + Type: "google_compute_instance", + Name: "banana3", + }) + db.InsertWorkspace(ctx, database.InsertWorkspaceParams{ + ID: uuid.New(), + OwnerID: user.ID, + OrganizationID: org.ID, + TemplateID: template.ID, + Name: "banana4", + }) +} diff --git a/coderd/monitoring/monitoring.go b/coderd/monitoring/monitoring.go new file mode 100644 index 0000000000000..86437b4113ce1 --- /dev/null +++ b/coderd/monitoring/monitoring.go @@ -0,0 +1,85 @@ +package monitoring + +import ( + "context" + "strings" + + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/coderd/database" +) + +type Telemetry string + +const ( + TelemetryAll Telemetry = "all" + TelemetryCore Telemetry = "core" + TelemetryNone Telemetry = "none" +) + +// ParseTelemetry returns a valid Telemetry or error if input is not a valid. +func ParseTelemetry(t string) (Telemetry, error) { + ok := []string{ + string(TelemetryAll), + string(TelemetryCore), + string(TelemetryNone), + } + + for _, a := range ok { + if strings.EqualFold(a, t) { + return Telemetry(a), nil + } + } + + return "", xerrors.Errorf(`invalid telemetry level: %s, must be one of: %s`, t, strings.Join(ok, ",")) +} + +type Options struct { + Database database.Store + Logger slog.Logger + Telemetry Telemetry +} + +// Monitor provides Prometheus registries on which to register metric +// collectors. Depending on the level these metrics may also be sent to Coder. +// Monitor automatically registers a collector that collects statistics from the +// database. +type Monitor struct { + // allRegistry registers metrics that will be sent when the telemetry level + // is `all`. + allRegistry *prometheus.Registry + // coreRegistry registers metrics that will be sent when the telemetry level + // is `core` or `all`. + coreRegistry *prometheus.Registry + // internalRegistry registers metrics that will never be sent. + internalRegistry *prometheus.Registry + // Telemetry determines which metrics are sent to Coder. + Telemetry Telemetry +} + +func New(ctx context.Context, options *Options) *Monitor { + monitor := Monitor{ + allRegistry: prometheus.NewRegistry(), + coreRegistry: prometheus.NewRegistry(), + internalRegistry: prometheus.NewRegistry(), + Telemetry: options.Telemetry, + } + + monitor.MustRegister(TelemetryAll, NewCollector(ctx, options.Database)) + + return &monitor +} + +// MustRegister registers collectors at the specified level. +func (t Monitor) MustRegister(level Telemetry, cs ...prometheus.Collector) { + switch level { + case TelemetryAll: + t.allRegistry.MustRegister(cs...) + case TelemetryCore: + t.coreRegistry.MustRegister(cs...) + case TelemetryNone: + t.internalRegistry.MustRegister(cs...) + } +} diff --git a/coderd/monitoring/monitoring_test.go b/coderd/monitoring/monitoring_test.go new file mode 100644 index 0000000000000..f734ffb9451f1 --- /dev/null +++ b/coderd/monitoring/monitoring_test.go @@ -0,0 +1,39 @@ +package monitoring_test + +import ( + "testing" + + "github.com/coder/coder/coderd/monitoring" + "github.com/stretchr/testify/require" +) + +func TestParseTelemetry(t *testing.T) { + t.Parallel() + + tests := []struct { + value string + telemetry monitoring.Telemetry + }{ + { + value: "all", + telemetry: monitoring.TelemetryAll, + }, + { + value: "core", + telemetry: monitoring.TelemetryCore, + }, + { + value: "none", + telemetry: monitoring.TelemetryNone, + }, + } + + for _, tt := range tests { + telemetry, err := monitoring.ParseTelemetry(tt.value) + require.NoError(t, err) + require.Equal(t, tt.telemetry, telemetry) + } + + _, err := monitoring.ParseTelemetry("invalid") + require.Error(t, err) +} diff --git a/coderd/tracing/exporter.go b/coderd/tracing/exporter.go index 6b85bc2af17a7..0c2a25f823dab 100644 --- a/coderd/tracing/exporter.go +++ b/coderd/tracing/exporter.go @@ -13,7 +13,7 @@ import ( // TracerProvider creates a grpc otlp exporter and configures a trace provider. // Caller is responsible for calling TracerProvider.Shutdown to ensure all data is flushed. -func TracerProvider(ctx context.Context, service string) (*sdktrace.TracerProvider, error) { +func Provider(ctx context.Context, service string) (*sdktrace.TracerProvider, error) { res, err := resource.New(ctx, resource.WithAttributes( // the service name used to display traces in backends