diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 1445a0cd3c9ff..df9090b878d1e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -878,6 +878,56 @@ const docTemplate = `{ } } }, + "/insights/templates": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Insights" + ], + "summary": "Get insights about templates", + "operationId": "get-insights-about-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.TemplateInsightsResponse" + } + } + } + } + }, + "/insights/user-latency": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Insights" + ], + "summary": "Get insights about user latency", + "operationId": "get-insights-about-user-latency", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserLatencyInsightsResponse" + } + } + } + } + }, "/licenses": { "get": { "security": [ @@ -6956,6 +7006,19 @@ const docTemplate = `{ "BuildReasonAutostop" ] }, + "codersdk.ConnectionLatency": { + "type": "object", + "properties": { + "p50": { + "type": "number", + "example": 31.312 + }, + "p95": { + "type": "number", + "example": 119.832 + } + } + }, "codersdk.ConvertLoginRequest": { "type": "object", "required": [ @@ -8040,6 +8103,15 @@ const docTemplate = `{ } } }, + "codersdk.InsightsReportInterval": { + "type": "string", + "enum": [ + "day" + ], + "x-enum-varnames": [ + "InsightsReportIntervalDay" + ] + }, "codersdk.IssueReconnectingPTYSignedTokenRequest": { "type": "object", "required": [ @@ -9123,6 +9195,50 @@ const docTemplate = `{ } } }, + "codersdk.TemplateAppUsage": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "example": "Visual Studio Code" + }, + "icon": { + "type": "string" + }, + "seconds": { + "type": "integer", + "example": 80500 + }, + "slug": { + "type": "string", + "example": "vscode" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateAppsType" + } + ], + "example": "builtin" + } + } + }, + "codersdk.TemplateAppsType": { + "type": "string", + "enum": [ + "builtin" + ], + "x-enum-varnames": [ + "TemplateAppsTypeBuiltin" + ] + }, "codersdk.TemplateBuildTimeStats": { "type": "object", "additionalProperties": { @@ -9159,6 +9275,77 @@ const docTemplate = `{ } } }, + "codersdk.TemplateInsightsIntervalReport": { + "type": "object", + "properties": { + "active_users": { + "type": "integer", + "example": 14 + }, + "end_time": { + "type": "string", + "format": "date-time" + }, + "interval": { + "$ref": "#/definitions/codersdk.InsightsReportInterval" + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "codersdk.TemplateInsightsReport": { + "type": "object", + "properties": { + "active_users": { + "type": "integer", + "example": 22 + }, + "apps_usage": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateAppUsage" + } + }, + "end_time": { + "type": "string", + "format": "date-time" + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "codersdk.TemplateInsightsResponse": { + "type": "object", + "properties": { + "interval_reports": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateInsightsIntervalReport" + } + }, + "report": { + "$ref": "#/definitions/codersdk.TemplateInsightsReport" + } + } + }, "codersdk.TemplateRestartRequirement": { "type": "object", "properties": { @@ -9708,6 +9895,62 @@ const docTemplate = `{ } } }, + "codersdk.UserLatency": { + "type": "object", + "properties": { + "latency_ms": { + "$ref": "#/definitions/codersdk.ConnectionLatency" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, + "codersdk.UserLatencyInsightsReport": { + "type": "object", + "properties": { + "end_time": { + "type": "string", + "format": "date-time" + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserLatency" + } + } + } + }, + "codersdk.UserLatencyInsightsResponse": { + "type": "object", + "properties": { + "report": { + "$ref": "#/definitions/codersdk.UserLatencyInsightsReport" + } + } + }, "codersdk.UserLoginType": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4119f580e91ad..768a8d7be1166 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -756,6 +756,48 @@ } } }, + "/insights/templates": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Insights"], + "summary": "Get insights about templates", + "operationId": "get-insights-about-templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.TemplateInsightsResponse" + } + } + } + } + }, + "/insights/user-latency": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Insights"], + "summary": "Get insights about user latency", + "operationId": "get-insights-about-user-latency", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.UserLatencyInsightsResponse" + } + } + } + } + }, "/licenses": { "get": { "security": [ @@ -6195,6 +6237,19 @@ "BuildReasonAutostop" ] }, + "codersdk.ConnectionLatency": { + "type": "object", + "properties": { + "p50": { + "type": "number", + "example": 31.312 + }, + "p95": { + "type": "number", + "example": 119.832 + } + } + }, "codersdk.ConvertLoginRequest": { "type": "object", "required": ["password", "to_type"], @@ -7220,6 +7275,11 @@ } } }, + "codersdk.InsightsReportInterval": { + "type": "string", + "enum": ["day"], + "x-enum-varnames": ["InsightsReportIntervalDay"] + }, "codersdk.IssueReconnectingPTYSignedTokenRequest": { "type": "object", "required": ["agentID", "url"], @@ -8238,6 +8298,46 @@ } } }, + "codersdk.TemplateAppUsage": { + "type": "object", + "properties": { + "display_name": { + "type": "string", + "example": "Visual Studio Code" + }, + "icon": { + "type": "string" + }, + "seconds": { + "type": "integer", + "example": 80500 + }, + "slug": { + "type": "string", + "example": "vscode" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "type": { + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateAppsType" + } + ], + "example": "builtin" + } + } + }, + "codersdk.TemplateAppsType": { + "type": "string", + "enum": ["builtin"], + "x-enum-varnames": ["TemplateAppsTypeBuiltin"] + }, "codersdk.TemplateBuildTimeStats": { "type": "object", "additionalProperties": { @@ -8274,6 +8374,77 @@ } } }, + "codersdk.TemplateInsightsIntervalReport": { + "type": "object", + "properties": { + "active_users": { + "type": "integer", + "example": 14 + }, + "end_time": { + "type": "string", + "format": "date-time" + }, + "interval": { + "$ref": "#/definitions/codersdk.InsightsReportInterval" + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "codersdk.TemplateInsightsReport": { + "type": "object", + "properties": { + "active_users": { + "type": "integer", + "example": 22 + }, + "apps_usage": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateAppUsage" + } + }, + "end_time": { + "type": "string", + "format": "date-time" + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + } + }, + "codersdk.TemplateInsightsResponse": { + "type": "object", + "properties": { + "interval_reports": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.TemplateInsightsIntervalReport" + } + }, + "report": { + "$ref": "#/definitions/codersdk.TemplateInsightsReport" + } + } + }, "codersdk.TemplateRestartRequirement": { "type": "object", "properties": { @@ -8774,6 +8945,62 @@ } } }, + "codersdk.UserLatency": { + "type": "object", + "properties": { + "latency_ms": { + "$ref": "#/definitions/codersdk.ConnectionLatency" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "username": { + "type": "string" + } + } + }, + "codersdk.UserLatencyInsightsReport": { + "type": "object", + "properties": { + "end_time": { + "type": "string", + "format": "date-time" + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserLatency" + } + } + } + }, + "codersdk.UserLatencyInsightsResponse": { + "type": "object", + "properties": { + "report": { + "$ref": "#/definitions/codersdk.UserLatencyInsightsReport" + } + } + }, "codersdk.UserLoginType": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 5ac7881ccdeb4..805022278b524 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -850,6 +850,8 @@ func New(options *Options) *API { r.Route("/insights", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Get("/daus", api.deploymentDAUs) + r.Get("/user-latency", api.insightsUserLatency) + r.Get("/templates", api.insightsTemplates) }) r.Route("/debug", func(r chi.Router) { r.Use( diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 891b322bc213d..d606ff510f576 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1173,6 +1173,22 @@ func (q *querier) GetTemplateDAUs(ctx context.Context, arg database.GetTemplateD return q.db.GetTemplateDAUs(ctx, arg) } +func (q *querier) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { + // FIXME: this should maybe be READ rbac.ResourceTemplate or it's own resource. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetTemplateDailyInsights(ctx, arg) +} + +func (q *querier) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { + // FIXME: this should maybe be READ rbac.ResourceTemplate or it's own resource. + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { + return database.GetTemplateInsightsRow{}, err + } + return q.db.GetTemplateInsights(ctx, arg) +} + func (q *querier) GetTemplateVersionByID(ctx context.Context, tvid uuid.UUID) (database.TemplateVersion, error) { tv, err := q.db.GetTemplateVersionByID(ctx, tvid) if err != nil { @@ -1339,6 +1355,13 @@ func (q *querier) GetUserCount(ctx context.Context) (int64, error) { return q.db.GetUserCount(ctx) } +func (q *querier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetUserLatencyInsights(ctx, arg) +} + func (q *querier) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (database.UserLink, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { return database.UserLink{}, err diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index ed147d6488f72..d2f71f7ea22f0 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -1944,6 +1944,129 @@ func (q *FakeQuerier) GetTemplateDAUs(_ context.Context, arg database.GetTemplat return rs, nil } +func (q *FakeQuerier) GetTemplateDailyInsights(_ context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + type dailyStat struct { + startTime, endTime time.Time + userSet map[uuid.UUID]struct{} + templateIDSet map[uuid.UUID]struct{} + } + dailyStats := []dailyStat{{arg.StartTime, arg.StartTime.AddDate(0, 0, 1), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})}} + for dailyStats[len(dailyStats)-1].endTime.Before(arg.EndTime) { + dailyStats = append(dailyStats, dailyStat{dailyStats[len(dailyStats)-1].endTime, dailyStats[len(dailyStats)-1].endTime.AddDate(0, 0, 1), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})}) + } + if dailyStats[len(dailyStats)-1].endTime.After(arg.EndTime) { + dailyStats[len(dailyStats)-1].endTime = arg.EndTime + } + + for _, s := range q.workspaceAgentStats { + if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) { + continue + } + if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) { + continue + } + if s.ConnectionCount == 0 { + continue + } + + for _, ds := range dailyStats { + if s.CreatedAt.Before(ds.startTime) || s.CreatedAt.Equal(ds.endTime) || s.CreatedAt.After(ds.endTime) { + continue + } + ds.userSet[s.UserID] = struct{}{} + ds.templateIDSet[s.TemplateID] = struct{}{} + break + } + } + + var result []database.GetTemplateDailyInsightsRow + for _, ds := range dailyStats { + templateIDs := make([]uuid.UUID, 0, len(ds.templateIDSet)) + for templateID := range ds.templateIDSet { + templateIDs = append(templateIDs, templateID) + } + slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool { + return a.String() < b.String() + }) + result = append(result, database.GetTemplateDailyInsightsRow{ + StartTime: ds.startTime, + EndTime: ds.endTime, + TemplateIDs: templateIDs, + ActiveUsers: int64(len(ds.userSet)), + }) + } + return result, nil +} + +func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.GetTemplateInsightsRow{}, err + } + + templateIDSet := make(map[uuid.UUID]struct{}) + appUsageIntervalsByUser := make(map[uuid.UUID]map[time.Time]*database.GetTemplateInsightsRow) + for _, s := range q.workspaceAgentStats { + if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) { + continue + } + if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) { + continue + } + if s.ConnectionCount == 0 { + continue + } + + templateIDSet[s.TemplateID] = struct{}{} + if appUsageIntervalsByUser[s.UserID] == nil { + appUsageIntervalsByUser[s.UserID] = make(map[time.Time]*database.GetTemplateInsightsRow) + } + t := s.CreatedAt.Truncate(5 * time.Minute) + if _, ok := appUsageIntervalsByUser[s.UserID][t]; !ok { + appUsageIntervalsByUser[s.UserID][t] = &database.GetTemplateInsightsRow{} + } + + if s.SessionCountJetBrains > 0 { + appUsageIntervalsByUser[s.UserID][t].UsageJetbrainsSeconds = 300 + } + if s.SessionCountVSCode > 0 { + appUsageIntervalsByUser[s.UserID][t].UsageVscodeSeconds = 300 + } + if s.SessionCountReconnectingPTY > 0 { + appUsageIntervalsByUser[s.UserID][t].UsageReconnectingPtySeconds = 300 + } + if s.SessionCountSSH > 0 { + appUsageIntervalsByUser[s.UserID][t].UsageSshSeconds = 300 + } + } + + templateIDs := make([]uuid.UUID, 0, len(templateIDSet)) + for templateID := range templateIDSet { + templateIDs = append(templateIDs, templateID) + } + slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool { + return a.String() < b.String() + }) + result := database.GetTemplateInsightsRow{ + TemplateIDs: templateIDs, + ActiveUsers: int64(len(appUsageIntervalsByUser)), + } + for _, intervals := range appUsageIntervalsByUser { + for _, interval := range intervals { + result.UsageJetbrainsSeconds += interval.UsageJetbrainsSeconds + result.UsageVscodeSeconds += interval.UsageVscodeSeconds + result.UsageReconnectingPtySeconds += interval.UsageReconnectingPtySeconds + result.UsageSshSeconds += interval.UsageSshSeconds + } + } + return result, nil +} + func (q *FakeQuerier) GetTemplateVersionByID(ctx context.Context, templateVersionID uuid.UUID) (database.TemplateVersion, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -2188,6 +2311,74 @@ func (q *FakeQuerier) GetUserCount(_ context.Context) (int64, error) { return existing, nil } +func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + latenciesByUserID := make(map[uuid.UUID][]float64) + seenTemplatesByUserID := make(map[uuid.UUID]map[uuid.UUID]struct{}) + for _, s := range q.workspaceAgentStats { + if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) { + continue + } + if !arg.StartTime.Equal(s.CreatedAt) && !(s.CreatedAt.After(arg.StartTime) && s.CreatedAt.Before(arg.EndTime)) { + continue + } + if s.ConnectionCount == 0 { + continue + } + + latenciesByUserID[s.UserID] = append(latenciesByUserID[s.UserID], s.ConnectionMedianLatencyMS) + if seenTemplatesByUserID[s.UserID] == nil { + seenTemplatesByUserID[s.UserID] = make(map[uuid.UUID]struct{}) + } + seenTemplatesByUserID[s.UserID][s.TemplateID] = struct{}{} + } + + tryPercentile := func(fs []float64, p float64) float64 { + if len(fs) == 0 { + return -1 + } + sort.Float64s(fs) + return fs[int(float64(len(fs))*p/100)] + } + + var rows []database.GetUserLatencyInsightsRow + for userID, latencies := range latenciesByUserID { + sort.Float64s(latencies) + templateIDSet := seenTemplatesByUserID[userID] + templateIDs := make([]uuid.UUID, 0, len(templateIDSet)) + for templateID := range templateIDSet { + templateIDs = append(templateIDs, templateID) + } + slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool { + return a.String() < b.String() + }) + user, err := q.getUserByIDNoLock(userID) + if err != nil { + return nil, err + } + row := database.GetUserLatencyInsightsRow{ + UserID: userID, + Username: user.Username, + TemplateIDs: templateIDs, + WorkspaceConnectionLatency50: tryPercentile(latencies, 50), + WorkspaceConnectionLatency95: tryPercentile(latencies, 95), + } + rows = append(rows, row) + } + slices.SortFunc(rows, func(a, b database.GetUserLatencyInsightsRow) bool { + return a.UserID.String() < b.UserID.String() + }) + + return rows, nil +} + func (q *FakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -5333,9 +5524,9 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } } - if len(arg.TemplateIds) > 0 { + if len(arg.TemplateIDs) > 0 { match := false - for _, id := range arg.TemplateIds { + for _, id := range arg.TemplateIDs { if workspace.TemplateID == id { match = true break diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 2c8e18ab556c8..5264e64779793 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -599,6 +599,20 @@ func (m metricsStore) GetTemplateDAUs(ctx context.Context, arg database.GetTempl return daus, err } +func (m metricsStore) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { + start := time.Now() + r0, r1 := m.s.GetTemplateDailyInsights(ctx, arg) + m.queryLatencies.WithLabelValues("GetTemplateDailyInsights").Observe(time.Since(start).Seconds()) + return r0, r1 +} + +func (m metricsStore) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { + start := time.Now() + r0, r1 := m.s.GetTemplateInsights(ctx, arg) + m.queryLatencies.WithLabelValues("GetTemplateInsights").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (database.TemplateVersion, error) { start := time.Now() version, err := m.s.GetTemplateVersionByID(ctx, id) @@ -697,6 +711,13 @@ func (m metricsStore) GetUserCount(ctx context.Context) (int64, error) { return count, err } +func (m metricsStore) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { + start := time.Now() + r0, r1 := m.s.GetUserLatencyInsights(ctx, arg) + m.queryLatencies.WithLabelValues("GetUserLatencyInsights").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (database.UserLink, error) { start := time.Now() link, err := m.s.GetUserLinkByLinkedID(ctx, linkedID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index fd6b5d3f33013..e9cdb1e4c9ec4 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1196,6 +1196,21 @@ func (mr *MockStoreMockRecorder) GetTemplateDAUs(arg0, arg1 interface{}) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateDAUs", reflect.TypeOf((*MockStore)(nil).GetTemplateDAUs), arg0, arg1) } +// GetTemplateDailyInsights mocks base method. +func (m *MockStore) GetTemplateDailyInsights(arg0 context.Context, arg1 database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTemplateDailyInsights", arg0, arg1) + ret0, _ := ret[0].([]database.GetTemplateDailyInsightsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTemplateDailyInsights indicates an expected call of GetTemplateDailyInsights. +func (mr *MockStoreMockRecorder) GetTemplateDailyInsights(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateDailyInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateDailyInsights), arg0, arg1) +} + // GetTemplateGroupRoles mocks base method. func (m *MockStore) GetTemplateGroupRoles(arg0 context.Context, arg1 uuid.UUID) ([]database.TemplateGroup, error) { m.ctrl.T.Helper() @@ -1211,6 +1226,21 @@ func (mr *MockStoreMockRecorder) GetTemplateGroupRoles(arg0, arg1 interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateGroupRoles", reflect.TypeOf((*MockStore)(nil).GetTemplateGroupRoles), arg0, arg1) } +// GetTemplateInsights mocks base method. +func (m *MockStore) GetTemplateInsights(arg0 context.Context, arg1 database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTemplateInsights", arg0, arg1) + ret0, _ := ret[0].(database.GetTemplateInsightsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTemplateInsights indicates an expected call of GetTemplateInsights. +func (mr *MockStoreMockRecorder) GetTemplateInsights(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateInsights), arg0, arg1) +} + // GetTemplateUserRoles mocks base method. func (m *MockStore) GetTemplateUserRoles(arg0 context.Context, arg1 uuid.UUID) ([]database.TemplateUser, error) { m.ctrl.T.Helper() @@ -1436,6 +1466,21 @@ func (mr *MockStoreMockRecorder) GetUserCount(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCount", reflect.TypeOf((*MockStore)(nil).GetUserCount), arg0) } +// GetUserLatencyInsights mocks base method. +func (m *MockStore) GetUserLatencyInsights(arg0 context.Context, arg1 database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserLatencyInsights", arg0, arg1) + ret0, _ := ret[0].([]database.GetUserLatencyInsightsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserLatencyInsights indicates an expected call of GetUserLatencyInsights. +func (mr *MockStoreMockRecorder) GetUserLatencyInsights(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLatencyInsights", reflect.TypeOf((*MockStore)(nil).GetUserLatencyInsights), arg0, arg1) +} + // GetUserLinkByLinkedID mocks base method. func (m *MockStore) GetUserLinkByLinkedID(arg0 context.Context, arg1 string) (database.UserLink, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 3e2cfbc125696..ffa346d04998c 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -213,7 +213,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.OwnerID, arg.OwnerUsername, arg.TemplateName, - pq.Array(arg.TemplateIds), + pq.Array(arg.TemplateIDs), arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, diff --git a/coderd/database/querier.go b/coderd/database/querier.go index acb93080af824..d64090ea32c8f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -105,6 +105,14 @@ type sqlcQuerier interface { GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsParams) ([]GetTemplateDAUsRow, error) + // GetTemplateDailyInsights returns all daily intervals between start and end + // time, if end time is a partial day, it will be included in the results and + // that interval will be less than 24 hours. If there is no data for a selected + // interval/template, it will be included in the results with 0 active users. + GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) + // GetTemplateInsights has a granularity of 5 minutes where if a session/app was + // in use, we will add 5 minutes to the total usage for that session (per user). + GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error) GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error) @@ -119,6 +127,11 @@ type sqlcQuerier interface { GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) + // GetUserLatencyInsights returns the median and 95th percentile connection + // latency that users have experienced. The result can be filtered on + // template_ids, meaning only user data from workspaces based on those templates + // will be included. + GetUserLatencyInsights(ctx context.Context, arg GetUserLatencyInsightsParams) ([]GetUserLatencyInsightsRow, error) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error) GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) // This will never return deleted users. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b23cdfe646db2..e85603b1debdc 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1375,6 +1375,228 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar return i, err } +const getTemplateDailyInsights = `-- name: GetTemplateDailyInsights :many +WITH d AS ( + -- sqlc workaround, use SELECT generate_series instead of SELECT * FROM generate_series. + SELECT generate_series($1::timestamptz, $2::timestamptz, '1 day'::interval) AS d +), ts AS ( + SELECT + d::timestamptz AS from_, + CASE WHEN (d + '1 day'::interval)::timestamptz <= $2::timestamptz THEN (d + '1 day'::interval)::timestamptz ELSE $2::timestamptz END AS to_ + FROM d +), usage_by_day AS ( + SELECT + ts.from_, ts.to_, + was.user_id, + array_agg(was.template_id) AS template_ids + FROM ts + LEFT JOIN workspace_agent_stats was ON ( + was.created_at >= ts.from_ + AND was.created_at < ts.to_ + AND was.connection_count > 0 + AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN was.template_id = ANY($3::uuid[]) ELSE TRUE END + ) + GROUP BY ts.from_, ts.to_, was.user_id +), template_ids AS ( + SELECT array_agg(DISTINCT template_id) AS ids + FROM usage_by_day, unnest(template_ids) template_id + WHERE template_id IS NOT NULL +) + +SELECT + from_ AS start_time, + to_ AS end_time, + COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids, + COUNT(DISTINCT user_id) AS active_users +FROM usage_by_day, unnest(template_ids) as template_id +GROUP BY from_, to_ +` + +type GetTemplateDailyInsightsParams struct { + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` +} + +type GetTemplateDailyInsightsRow struct { + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + ActiveUsers int64 `db:"active_users" json:"active_users"` +} + +// GetTemplateDailyInsights returns all daily intervals between start and end +// time, if end time is a partial day, it will be included in the results and +// that interval will be less than 24 hours. If there is no data for a selected +// interval/template, it will be included in the results with 0 active users. +func (q *sqlQuerier) GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) { + rows, err := q.db.QueryContext(ctx, getTemplateDailyInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTemplateDailyInsightsRow + for rows.Next() { + var i GetTemplateDailyInsightsRow + if err := rows.Scan( + &i.StartTime, + &i.EndTime, + pq.Array(&i.TemplateIDs), + &i.ActiveUsers, + ); 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 d AS ( + SELECT generate_series($1::timestamptz, $2::timestamptz, '5 minute'::interval) AS d +), ts AS ( + SELECT + d::timestamptz AS from_, + (d + '5 minute'::interval)::timestamptz AS to_, + EXTRACT(epoch FROM '5 minute'::interval) AS seconds + FROM d +), usage_by_user AS ( + SELECT + ts.from_, + ts.to_, + was.user_id, + array_agg(was.template_id) AS template_ids, + CASE WHEN SUM(was.session_count_vscode) > 0 THEN ts.seconds ELSE 0 END AS usage_vscode_seconds, + CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN ts.seconds ELSE 0 END AS usage_jetbrains_seconds, + CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN ts.seconds ELSE 0 END AS usage_reconnecting_pty_seconds, + CASE WHEN SUM(was.session_count_ssh) > 0 THEN ts.seconds ELSE 0 END AS usage_ssh_seconds + FROM ts + JOIN workspace_agent_stats was ON ( + was.created_at >= ts.from_ + AND was.created_at < ts.to_ + AND was.connection_count > 0 + AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN was.template_id = ANY($3::uuid[]) ELSE TRUE END + ) + GROUP BY ts.from_, ts.to_, ts.seconds, was.user_id +), template_ids AS ( + SELECT array_agg(DISTINCT template_id) AS ids + FROM usage_by_user, unnest(template_ids) template_id + WHERE template_id IS NOT NULL +) + +SELECT + COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids, + COUNT(DISTINCT user_id) AS active_users, + COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds, + COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds, + COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds, + COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds +FROM usage_by_user +` + +type GetTemplateInsightsParams struct { + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` +} + +type GetTemplateInsightsRow struct { + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + ActiveUsers int64 `db:"active_users" json:"active_users"` + UsageVscodeSeconds int64 `db:"usage_vscode_seconds" json:"usage_vscode_seconds"` + UsageJetbrainsSeconds int64 `db:"usage_jetbrains_seconds" json:"usage_jetbrains_seconds"` + UsageReconnectingPtySeconds int64 `db:"usage_reconnecting_pty_seconds" json:"usage_reconnecting_pty_seconds"` + UsageSshSeconds int64 `db:"usage_ssh_seconds" json:"usage_ssh_seconds"` +} + +// GetTemplateInsights has a granularity of 5 minutes where if a session/app was +// in use, we will add 5 minutes to the total usage for that session (per user). +func (q *sqlQuerier) GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error) { + row := q.db.QueryRowContext(ctx, getTemplateInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs)) + var i GetTemplateInsightsRow + err := row.Scan( + pq.Array(&i.TemplateIDs), + &i.ActiveUsers, + &i.UsageVscodeSeconds, + &i.UsageJetbrainsSeconds, + &i.UsageReconnectingPtySeconds, + &i.UsageSshSeconds, + ) + return i, err +} + +const getUserLatencyInsights = `-- name: GetUserLatencyInsights :many +SELECT + workspace_agent_stats.user_id, + users.username, + array_agg(DISTINCT template_id)::uuid[] AS template_ids, + coalesce((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_50, + coalesce((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_95 +FROM workspace_agent_stats +JOIN users ON (users.id = workspace_agent_stats.user_id) +WHERE + workspace_agent_stats.created_at >= $1 + AND workspace_agent_stats.created_at < $2 + AND workspace_agent_stats.connection_median_latency_ms > 0 + AND workspace_agent_stats.connection_count > 0 + AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN template_id = ANY($3::uuid[]) ELSE TRUE END +GROUP BY workspace_agent_stats.user_id, users.username +ORDER BY user_id ASC +` + +type GetUserLatencyInsightsParams struct { + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` +} + +type GetUserLatencyInsightsRow struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + Username string `db:"username" json:"username"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + WorkspaceConnectionLatency50 float64 `db:"workspace_connection_latency_50" json:"workspace_connection_latency_50"` + WorkspaceConnectionLatency95 float64 `db:"workspace_connection_latency_95" json:"workspace_connection_latency_95"` +} + +// GetUserLatencyInsights returns the median and 95th percentile connection +// latency that users have experienced. The result can be filtered on +// template_ids, meaning only user data from workspaces based on those templates +// will be included. +func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLatencyInsightsParams) ([]GetUserLatencyInsightsRow, error) { + rows, err := q.db.QueryContext(ctx, getUserLatencyInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUserLatencyInsightsRow + for rows.Next() { + var i GetUserLatencyInsightsRow + if err := rows.Scan( + &i.UserID, + &i.Username, + pq.Array(&i.TemplateIDs), + &i.WorkspaceConnectionLatency50, + &i.WorkspaceConnectionLatency95, + ); 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 deleteLicense = `-- name: DeleteLicense :one DELETE FROM licenses @@ -8536,7 +8758,7 @@ type GetWorkspacesParams struct { OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` OwnerUsername string `db:"owner_username" json:"owner_username"` TemplateName string `db:"template_name" json:"template_name"` - TemplateIds []uuid.UUID `db:"template_ids" json:"template_ids"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` Name string `db:"name" json:"name"` HasAgent string `db:"has_agent" json:"has_agent"` AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` @@ -8571,7 +8793,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.OwnerID, arg.OwnerUsername, arg.TemplateName, - pq.Array(arg.TemplateIds), + pq.Array(arg.TemplateIDs), arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql new file mode 100644 index 0000000000000..c3dad57d2d673 --- /dev/null +++ b/coderd/database/queries/insights.sql @@ -0,0 +1,105 @@ +-- name: GetUserLatencyInsights :many +-- GetUserLatencyInsights returns the median and 95th percentile connection +-- latency that users have experienced. The result can be filtered on +-- template_ids, meaning only user data from workspaces based on those templates +-- will be included. +SELECT + workspace_agent_stats.user_id, + users.username, + array_agg(DISTINCT template_id)::uuid[] AS template_ids, + coalesce((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_50, + coalesce((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_95 +FROM workspace_agent_stats +JOIN users ON (users.id = workspace_agent_stats.user_id) +WHERE + workspace_agent_stats.created_at >= @start_time + AND workspace_agent_stats.created_at < @end_time + AND workspace_agent_stats.connection_median_latency_ms > 0 + AND workspace_agent_stats.connection_count > 0 + AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN template_id = ANY(@template_ids::uuid[]) ELSE TRUE END +GROUP BY workspace_agent_stats.user_id, users.username +ORDER BY user_id ASC; + +-- name: GetTemplateInsights :one +-- GetTemplateInsights has a granularity of 5 minutes where if a session/app was +-- in use, we will add 5 minutes to the total usage for that session (per user). +WITH d AS ( + SELECT generate_series(@start_time::timestamptz, @end_time::timestamptz, '5 minute'::interval) AS d +), ts AS ( + SELECT + d::timestamptz AS from_, + (d + '5 minute'::interval)::timestamptz AS to_, + EXTRACT(epoch FROM '5 minute'::interval) AS seconds + FROM d +), usage_by_user AS ( + SELECT + ts.from_, + ts.to_, + was.user_id, + array_agg(was.template_id) AS template_ids, + CASE WHEN SUM(was.session_count_vscode) > 0 THEN ts.seconds ELSE 0 END AS usage_vscode_seconds, + CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN ts.seconds ELSE 0 END AS usage_jetbrains_seconds, + CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN ts.seconds ELSE 0 END AS usage_reconnecting_pty_seconds, + CASE WHEN SUM(was.session_count_ssh) > 0 THEN ts.seconds ELSE 0 END AS usage_ssh_seconds + FROM ts + JOIN workspace_agent_stats was ON ( + was.created_at >= ts.from_ + AND was.created_at < ts.to_ + AND was.connection_count > 0 + AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END + ) + GROUP BY ts.from_, ts.to_, ts.seconds, was.user_id +), template_ids AS ( + SELECT array_agg(DISTINCT template_id) AS ids + FROM usage_by_user, unnest(template_ids) template_id + WHERE template_id IS NOT NULL +) + +SELECT + COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids, + COUNT(DISTINCT user_id) AS active_users, + COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds, + COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds, + COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds, + COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds +FROM usage_by_user; + +-- name: GetTemplateDailyInsights :many +-- GetTemplateDailyInsights returns all daily intervals between start and end +-- time, if end time is a partial day, it will be included in the results and +-- that interval will be less than 24 hours. If there is no data for a selected +-- interval/template, it will be included in the results with 0 active users. +WITH d AS ( + -- sqlc workaround, use SELECT generate_series instead of SELECT * FROM generate_series. + SELECT generate_series(@start_time::timestamptz, @end_time::timestamptz, '1 day'::interval) AS d +), ts AS ( + SELECT + d::timestamptz AS from_, + CASE WHEN (d + '1 day'::interval)::timestamptz <= @end_time::timestamptz THEN (d + '1 day'::interval)::timestamptz ELSE @end_time::timestamptz END AS to_ + FROM d +), usage_by_day AS ( + SELECT + ts.*, + was.user_id, + array_agg(was.template_id) AS template_ids + FROM ts + LEFT JOIN workspace_agent_stats was ON ( + was.created_at >= ts.from_ + AND was.created_at < ts.to_ + AND was.connection_count > 0 + AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END + ) + GROUP BY ts.from_, ts.to_, was.user_id +), template_ids AS ( + SELECT array_agg(DISTINCT template_id) AS ids + FROM usage_by_day, unnest(template_ids) template_id + WHERE template_id IS NOT NULL +) + +SELECT + from_ AS start_time, + to_ AS end_time, + COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids, + COUNT(DISTINCT user_id) AS active_users +FROM usage_by_day, unnest(template_ids) as template_id +GROUP BY from_, to_; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 2bb3adea0980d..7d390efa1cb44 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -69,6 +69,7 @@ overrides: inactivity_ttl: InactivityTTL eof: EOF locked_ttl: LockedTTL + template_ids: TemplateIDs sql: - schema: "./dump.sql" diff --git a/coderd/insights.go b/coderd/insights.go index b1ed1b2dc379d..3da60a13bfe84 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -1,13 +1,24 @@ package coderd import ( + "context" + "fmt" "net/http" + "time" + "github.com/google/uuid" + "golang.org/x/exp/slices" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" ) +// Duplicated in codersdk. +const insightsTimeLayout = time.RFC3339 + // @Summary Get deployment DAUs // @ID get-deployment-daus // @Security CoderSessionToken @@ -43,3 +54,358 @@ func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) { } httpapi.Write(ctx, rw, http.StatusOK, resp) } + +// @Summary Get insights about user latency +// @ID get-insights-about-user-latency +// @Security CoderSessionToken +// @Produce json +// @Tags Insights +// @Success 200 {object} codersdk.UserLatencyInsightsResponse +// @Router /insights/user-latency [get] +func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentValues) { + httpapi.Forbidden(rw) + return + } + + p := httpapi.NewQueryParamParser(). + Required("start_time"). + Required("end_time") + vals := r.URL.Query() + var ( + // The QueryParamParser does not preserve timezone, so we need + // to parse the time ourselves. + startTimeString = p.String(vals, "", "start_time") + endTimeString = p.String(vals, "", "end_time") + templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids") + ) + p.ErrorExcessParams(vals) + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: p.Errors, + }) + return + } + + startTime, endTime, ok := parseInsightsStartAndEndTime(ctx, rw, startTimeString, endTimeString) + if !ok { + return + } + + rows, err := api.Database.GetUserLatencyInsights(ctx, database.GetUserLatencyInsightsParams{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching user latency.", + Detail: err.Error(), + }) + return + } + + templateIDSet := make(map[uuid.UUID]struct{}) + userLatencies := make([]codersdk.UserLatency, 0, len(rows)) + for _, row := range rows { + for _, templateID := range row.TemplateIDs { + templateIDSet[templateID] = struct{}{} + } + userLatencies = append(userLatencies, codersdk.UserLatency{ + TemplateIDs: row.TemplateIDs, + UserID: row.UserID, + Username: row.Username, + LatencyMS: codersdk.ConnectionLatency{ + P50: row.WorkspaceConnectionLatency50, + P95: row.WorkspaceConnectionLatency95, + }, + }) + } + + // TemplateIDs that contributed to the data. + seenTemplateIDs := make([]uuid.UUID, 0, len(templateIDSet)) + for templateID := range templateIDSet { + seenTemplateIDs = append(seenTemplateIDs, templateID) + } + slices.SortFunc(seenTemplateIDs, func(a, b uuid.UUID) bool { + return a.String() < b.String() + }) + + resp := codersdk.UserLatencyInsightsResponse{ + Report: codersdk.UserLatencyInsightsReport{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: seenTemplateIDs, + Users: userLatencies, + }, + } + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + +// @Summary Get insights about templates +// @ID get-insights-about-templates +// @Security CoderSessionToken +// @Produce json +// @Tags Insights +// @Success 200 {object} codersdk.TemplateInsightsResponse +// @Router /insights/templates [get] +func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentValues) { + httpapi.Forbidden(rw) + return + } + + p := httpapi.NewQueryParamParser(). + Required("start_time"). + Required("end_time") + vals := r.URL.Query() + var ( + // The QueryParamParser does not preserve timezone, so we need + // to parse the time ourselves. + startTimeString = p.String(vals, "", "start_time") + endTimeString = p.String(vals, "", "end_time") + intervalString = p.String(vals, "", "interval") + templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids") + ) + p.ErrorExcessParams(vals) + if len(p.Errors) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameters have invalid values.", + Validations: p.Errors, + }) + return + } + + startTime, endTime, ok := parseInsightsStartAndEndTime(ctx, rw, startTimeString, endTimeString) + if !ok { + return + } + interval, ok := verifyInsightsInterval(ctx, rw, intervalString) + if !ok { + return + } + + var usage database.GetTemplateInsightsRow + var dailyUsage []database.GetTemplateDailyInsightsRow + // Use a transaction to ensure that we get consistent data between + // the full and interval report. + err := api.Database.InTx(func(db database.Store) error { + var err error + + if interval != "" { + dailyUsage, err = db.GetTemplateDailyInsights(ctx, database.GetTemplateDailyInsightsParams{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + }) + if err != nil { + return xerrors.Errorf("get template daily insights: %w", err) + } + } + + usage, err = db.GetTemplateInsights(ctx, database.GetTemplateInsightsParams{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + }) + if err != nil { + return xerrors.Errorf("get template insights: %w", err) + } + + return nil + }, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template insights.", + Detail: err.Error(), + }) + return + } + + resp := codersdk.TemplateInsightsResponse{ + Report: codersdk.TemplateInsightsReport{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: usage.TemplateIDs, + ActiveUsers: usage.ActiveUsers, + AppsUsage: convertTemplateInsightsBuiltinApps(usage), + }, + IntervalReports: []codersdk.TemplateInsightsIntervalReport{}, + } + for _, row := range dailyUsage { + resp.IntervalReports = append(resp.IntervalReports, codersdk.TemplateInsightsIntervalReport{ + StartTime: row.StartTime, + EndTime: row.EndTime, + Interval: interval, + TemplateIDs: row.TemplateIDs, + ActiveUsers: row.ActiveUsers, + }) + } + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + +// convertTemplateInsightsBuiltinApps builds the list of builtin apps from the +// database row, these are apps that are implicitly a part of all templates. +func convertTemplateInsightsBuiltinApps(usage database.GetTemplateInsightsRow) []codersdk.TemplateAppUsage { + return []codersdk.TemplateAppUsage{ + { + TemplateIDs: usage.TemplateIDs, + Type: codersdk.TemplateAppsTypeBuiltin, + DisplayName: "Visual Studio Code", + Slug: "vscode", + Icon: "/icons/code.svg", + Seconds: usage.UsageVscodeSeconds, + }, + { + TemplateIDs: usage.TemplateIDs, + Type: codersdk.TemplateAppsTypeBuiltin, + DisplayName: "JetBrains", + Slug: "jetbrains", + Icon: "/icons/intellij.svg", + Seconds: usage.UsageJetbrainsSeconds, + }, + { + TemplateIDs: usage.TemplateIDs, + Type: codersdk.TemplateAppsTypeBuiltin, + DisplayName: "Web Terminal", + Slug: "reconnecting-pty", + Icon: "/icons/terminal.svg", + Seconds: usage.UsageReconnectingPtySeconds, + }, + { + TemplateIDs: usage.TemplateIDs, + Type: codersdk.TemplateAppsTypeBuiltin, + DisplayName: "SSH", + Slug: "ssh", + Icon: "/icons/terminal.svg", + Seconds: usage.UsageSshSeconds, + }, + } +} + +// parseInsightsStartAndEndTime parses the start and end time query parameters +// and returns the parsed values. The client provided timezone must be preserved +// when parsing the time. Verification is performed so that the start and end +// time are not zero and that the end time is not before the start time. The +// clock must be set to 00:00:00, except for "today", where end time is allowed +// to provide the hour of the day (e.g. 14:00:00). +func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, startTimeString, endTimeString string) (startTime, endTime time.Time, ok bool) { + now := time.Now() + + for _, qp := range []struct { + name, value string + dest *time.Time + }{ + {"start_time", startTimeString, &startTime}, + {"end_time", endTimeString, &endTime}, + } { + t, err := time.Parse(insightsTimeLayout, qp.value) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: qp.name, + Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", qp.name, insightsTimeLayout, err.Error()), + }, + }, + }) + return time.Time{}, time.Time{}, false + } + + if t.IsZero() { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: qp.name, + Detail: fmt.Sprintf("Query param %q must not be zero", qp.name), + }, + }, + }) + return time.Time{}, time.Time{}, false + } + + // Round upwards one hour to ensure we can fetch the latest data. + if t.After(now.Truncate(time.Hour).Add(time.Hour)) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: qp.name, + Detail: fmt.Sprintf("Query param %q must not be in the future", qp.name), + }, + }, + }) + return time.Time{}, time.Time{}, false + } + + ensureZeroHour := true + if qp.name == "end_time" { + ey, em, ed := t.Date() + ty, tm, td := now.Date() + + ensureZeroHour = ey != ty || em != tm || ed != td + } + h, m, s := t.Clock() + if ensureZeroHour && (h != 0 || m != 0 || s != 0) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: qp.name, + Detail: fmt.Sprintf("Query param %q must have the clock set to 00:00:00", qp.name), + }, + }, + }) + return time.Time{}, time.Time{}, false + } else if m != 0 || s != 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: qp.name, + Detail: fmt.Sprintf("Query param %q must have the clock set to %02d:00:00", qp.name, h), + }, + }, + }) + return time.Time{}, time.Time{}, false + } + *qp.dest = t + } + if endTime.Before(startTime) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: "end_time", + Detail: fmt.Sprintf("Query param %q must be after than %q", "end_time", "start_time"), + }, + }, + }) + return time.Time{}, time.Time{}, false + } + + return startTime, endTime, true +} + +func verifyInsightsInterval(ctx context.Context, rw http.ResponseWriter, intervalString string) (codersdk.InsightsReportInterval, bool) { + switch v := codersdk.InsightsReportInterval(intervalString); v { + case codersdk.InsightsReportIntervalDay, "": + return v, true + default: + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: "interval", + Detail: fmt.Sprintf("must be one of %v", []codersdk.InsightsReportInterval{codersdk.InsightsReportIntervalDay}), + }, + }, + }) + return "", false + } +} diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go new file mode 100644 index 0000000000000..1f1d7a7bed6e6 --- /dev/null +++ b/coderd/insights_internal_test.go @@ -0,0 +1,150 @@ +package coderd + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_parseInsightsStartAndEndTime(t *testing.T) { + t.Parallel() + + layout := insightsTimeLayout + now := time.Now().UTC() + y, m, d := now.Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + thisHour := time.Date(y, m, d, now.Hour(), 0, 0, 0, time.UTC) + thisHourRoundUp := thisHour.Add(time.Hour) + + helsinki, err := time.LoadLocation("Europe/Helsinki") + require.NoError(t, err) + + type args struct { + startTime string + endTime string + } + tests := []struct { + name string + args args + wantStartTime time.Time + wantEndTime time.Time + wantOk bool + }{ + { + name: "Week", + args: args{ + startTime: "2023-07-10T00:00:00Z", + endTime: "2023-07-17T00:00:00Z", + }, + wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, time.UTC), + wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, time.UTC), + wantOk: true, + }, + { + name: "Today", + args: args{ + startTime: today.Format(layout), + endTime: thisHour.Format(layout), + }, + wantStartTime: time.Date(2023, 7, today.Day(), 0, 0, 0, 0, time.UTC), + wantEndTime: time.Date(2023, 7, today.Day(), thisHour.Hour(), 0, 0, 0, time.UTC), + wantOk: true, + }, + { + name: "Today with minutes and seconds", + args: args{ + startTime: today.Format(layout), + endTime: thisHour.Add(time.Minute + time.Second).Format(layout), + }, + wantOk: false, + }, + { + name: "Today (hour round up)", + args: args{ + startTime: today.Format(layout), + endTime: thisHourRoundUp.Format(layout), + }, + wantStartTime: time.Date(2023, 7, today.Day(), 0, 0, 0, 0, time.UTC), + wantEndTime: time.Date(2023, 7, today.Day(), thisHourRoundUp.Hour(), 0, 0, 0, time.UTC), + wantOk: true, + }, + { + name: "Other timezone week", + args: args{ + startTime: "2023-07-10T00:00:00+03:00", + endTime: "2023-07-17T00:00:00+03:00", + }, + wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, helsinki), + wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, helsinki), + wantOk: true, + }, + { + name: "Daylight savings time", + args: args{ + startTime: "2023-03-26T00:00:00+02:00", + endTime: "2023-03-27T00:00:00+03:00", + }, + wantStartTime: time.Date(2023, 3, 26, 0, 0, 0, 0, helsinki), + wantEndTime: time.Date(2023, 3, 27, 0, 0, 0, 0, helsinki), + wantOk: true, + }, + { + name: "Bad format", + args: args{ + startTime: "2023-07-10", + endTime: "2023-07-17", + }, + wantOk: false, + }, + { + name: "Zero time", + args: args{ + startTime: (time.Time{}).Format(layout), + endTime: (time.Time{}).Format(layout), + }, + wantOk: false, + }, + { + name: "Time in future", + args: args{ + startTime: today.AddDate(0, 0, 1).Format(layout), + endTime: today.AddDate(0, 0, 2).Format(layout), + }, + wantOk: false, + }, + { + name: "End before start", + args: args{ + startTime: today.Format(layout), + endTime: today.AddDate(0, 0, -1).Format(layout), + }, + wantOk: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + rw := httptest.NewRecorder() + gotStartTime, gotEndTime, gotOk := parseInsightsStartAndEndTime(context.Background(), rw, tt.args.startTime, tt.args.endTime) + + if !assert.Equal(t, tt.wantOk, gotOk) { + //nolint:bodyclose + t.Log("Status: ", rw.Result().StatusCode) + t.Log("Body: ", rw.Body.String()) + } + // assert.Equal is unable to test time equality with different + // (but same) locations because the *time.Location names differ + // between LoadLocation and Parse, so we use assert.WithinDuration. + assert.WithinDuration(t, tt.wantStartTime, gotStartTime, 0) + assert.True(t, tt.wantStartTime.Equal(gotStartTime)) + assert.WithinDuration(t, tt.wantEndTime, gotEndTime, 0) + assert.True(t, tt.wantEndTime.Equal(gotEndTime)) + }) + } +} diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 20ddc6bb62a29..613a2c07ec428 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -2,12 +2,14 @@ package coderd_test import ( "context" + "io" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/agent" @@ -100,3 +102,275 @@ func TestDeploymentInsights(t *testing.T) { res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{}) require.NoError(t, err) } + +func TestUserLatencyInsights(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, + }) + + // Create two users, one that will appear in the report and another that + // won't (due to not having/using a workspace). + user := coderdtest.CreateFirstUser(t, client) + _, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart]) + + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // Start an agent so that we can generate stats. + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + agentCloser := agent.New(agent.Options{ + Logger: logger.Named("agent"), + Client: agentClient, + }) + defer func() { + _ = agentCloser.Close() + }() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + // Start must be at the beginning of the day, initialize it early in case + // the day changes so that we get the relevant stats faster. + y, m, d := time.Now().UTC().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Connect to the agent to generate usage/latency stats. + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: logger.Named("client"), + }) + require.NoError(t, err) + defer conn.Close() + + sshConn, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshConn.Close() + + sess, err := sshConn.NewSession() + require.NoError(t, err) + defer sess.Close() + + r, w := io.Pipe() + defer r.Close() + defer w.Close() + sess.Stdin = r + err = sess.Start("cat") + require.NoError(t, err) + + var userLatencies codersdk.UserLatencyInsightsResponse + require.Eventuallyf(t, func() bool { + userLatencies, err = client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{ + StartTime: today, + EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour. + TemplateIDs: []uuid.UUID{template.ID}, + }) + if !assert.NoError(t, err) { + return false + } + return len(userLatencies.Report.Users) > 0 && userLatencies.Report.Users[0].LatencyMS.P50 > 0 + }, testutil.WaitShort, testutil.IntervalFast, "user latency is missing") + + // We got our latency data, close the connection. + _ = sess.Close() + _ = sshConn.Close() + + require.Len(t, userLatencies.Report.Users, 1, "want only 1 user") + require.Equal(t, userLatencies.Report.Users[0].UserID, user.UserID, "want user id to match") + assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P50, float64(0), "want p50 to be greater than 0") + assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P95, float64(0), "want p95 to be greater than 0") +} + +func TestUserLatencyInsights_BadRequest(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + + y, m, d := time.Now().UTC().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{ + StartTime: today, + EndTime: today.AddDate(0, 0, -1), + }) + assert.Error(t, err, "want error for end time before start time") + + _, err = client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{ + StartTime: today.AddDate(0, 0, -7), + EndTime: today.Add(-time.Hour), + }) + assert.Error(t, err, "want error for end time partial day when not today") +} + +func TestTemplateInsights(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + opts := &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, + } + client := coderdtest.New(t, opts) + + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart]) + + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // Start an agent so that we can generate stats. + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + agentCloser := agent.New(agent.Options{ + Logger: logger.Named("agent"), + Client: agentClient, + }) + defer func() { + _ = agentCloser.Close() + }() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + // Start must be at the beginning of the day, initialize it early in case + // the day changes so that we get the relevant stats faster. + y, m, d := time.Now().UTC().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Connect to the agent to generate usage/latency stats. + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: logger.Named("client"), + }) + require.NoError(t, err) + defer conn.Close() + + sshConn, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshConn.Close() + + // Start an SSH session to generate SSH usage stats. + sess, err := sshConn.NewSession() + require.NoError(t, err) + defer sess.Close() + + r, w := io.Pipe() + defer r.Close() + defer w.Close() + sess.Stdin = r + err = sess.Start("cat") + require.NoError(t, err) + + // Start an rpty session to generate rpty usage stats. + rpty, err := client.WorkspaceAgentReconnectingPTY(ctx, codersdk.WorkspaceAgentReconnectingPTYOpts{ + AgentID: resources[0].Agents[0].ID, + Reconnect: uuid.New(), + Width: 80, + Height: 24, + }) + require.NoError(t, err) + defer rpty.Close() + + var resp codersdk.TemplateInsightsResponse + var req codersdk.TemplateInsightsRequest + waitForAppSeconds := func(slug string) func() bool { + return func() bool { + req = codersdk.TemplateInsightsRequest{ + StartTime: today, + EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), + Interval: codersdk.InsightsReportIntervalDay, + } + resp, err = client.TemplateInsights(ctx, req) + if !assert.NoError(t, err) { + return false + } + + if slices.IndexFunc(resp.Report.AppsUsage, func(au codersdk.TemplateAppUsage) bool { + return au.Slug == slug && au.Seconds > 0 + }) != -1 { + return true + } + return false + } + } + require.Eventually(t, waitForAppSeconds("reconnecting-pty"), testutil.WaitShort, testutil.IntervalFast, "reconnecting-pty seconds missing") + require.Eventually(t, waitForAppSeconds("ssh"), testutil.WaitShort, testutil.IntervalFast, "ssh seconds missing") + + // We got our data, close down sessions and connections. + _ = rpty.Close() + _ = sess.Close() + _ = sshConn.Close() + + assert.WithinDuration(t, req.StartTime, resp.Report.StartTime, 0) + assert.WithinDuration(t, req.EndTime, resp.Report.EndTime, 0) + assert.Equal(t, resp.Report.ActiveUsers, int64(1), "want one active user") + for _, app := range resp.Report.AppsUsage { + if slices.Contains([]string{"reconnecting-pty", "ssh"}, app.Slug) { + assert.Equal(t, app.Seconds, int64(300), "want app %q to have 5 minutes of usage", app.Slug) + } else { + assert.Equal(t, app.Seconds, int64(0), "want app %q to have 0 minutes of usage", app.Slug) + } + } + // The full timeframe is <= 24h, so the interval matches exactly. + assert.Len(t, resp.IntervalReports, 1, "want one interval report") + assert.WithinDuration(t, req.StartTime, resp.IntervalReports[0].StartTime, 0) + assert.WithinDuration(t, req.EndTime, resp.IntervalReports[0].EndTime, 0) + assert.Equal(t, resp.IntervalReports[0].ActiveUsers, int64(1), "want one active user in the interval report") +} + +func TestTemplateInsights_BadRequest(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + + y, m, d := time.Now().UTC().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today, + EndTime: today.AddDate(0, 0, -1), + }) + assert.Error(t, err, "want error for end time before start time") + + _, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -7), + EndTime: today.Add(-time.Hour), + }) + assert.Error(t, err, "want error for end time partial day when not today") + + _, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -1), + EndTime: today, + Interval: "invalid", + }) + assert.Error(t, err, "want error for bad interval") +} diff --git a/coderd/templates.go b/coderd/templates.go index b53d2a65041e4..c9b19f086e8e4 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -69,7 +69,7 @@ func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) { // return ALL workspaces. Not just workspaces the user can view. // nolint:gocritic workspaces, err := api.Database.GetWorkspaces(dbauthz.AsSystemRestricted(ctx), database.GetWorkspacesParams{ - TemplateIds: []uuid.UUID{template.ID}, + TemplateIDs: []uuid.UUID{template.ID}, }) if err != nil && !errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/codersdk/insights.go b/codersdk/insights.go new file mode 100644 index 0000000000000..fb1c582c686c8 --- /dev/null +++ b/codersdk/insights.go @@ -0,0 +1,189 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +// Duplicated in coderd. +const insightsTimeLayout = time.RFC3339 + +// InsightsReportInterval is the interval of time over which to generate a +// smaller insights report within a time range. +type InsightsReportInterval string + +// InsightsReportInterval enums. +const ( + InsightsReportIntervalDay InsightsReportInterval = "day" +) + +// UserLatencyInsightsResponse is the response from the user latency insights +// endpoint. +type UserLatencyInsightsResponse struct { + Report UserLatencyInsightsReport `json:"report"` +} + +// UserLatencyInsightsReport is the report from the user latency insights +// endpoint. +type UserLatencyInsightsReport struct { + StartTime time.Time `json:"start_time" format:"date-time"` + EndTime time.Time `json:"end_time" format:"date-time"` + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` + Users []UserLatency `json:"users"` +} + +// UserLatency shows the connection latency for a user. +type UserLatency struct { + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` + UserID uuid.UUID `json:"user_id" format:"uuid"` + Username string `json:"username"` + LatencyMS ConnectionLatency `json:"latency_ms"` +} + +// ConnectionLatency shows the latency for a connection. +type ConnectionLatency struct { + P50 float64 `json:"p50" example:"31.312"` + P95 float64 `json:"p95" example:"119.832"` +} + +type UserLatencyInsightsRequest struct { + StartTime time.Time `json:"start_time" format:"date-time"` + EndTime time.Time `json:"end_time" format:"date-time"` + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` +} + +func (c *Client) UserLatencyInsights(ctx context.Context, req UserLatencyInsightsRequest) (UserLatencyInsightsResponse, error) { + var qp []string + qp = append(qp, fmt.Sprintf("start_time=%s", req.StartTime.Format(insightsTimeLayout))) + qp = append(qp, fmt.Sprintf("end_time=%s", req.EndTime.Format(insightsTimeLayout))) + if len(req.TemplateIDs) > 0 { + var templateIDs []string + for _, id := range req.TemplateIDs { + templateIDs = append(templateIDs, id.String()) + } + qp = append(qp, fmt.Sprintf("template_ids=%s", strings.Join(templateIDs, ","))) + } + + reqURL := fmt.Sprintf("/api/v2/insights/user-latency?%s", strings.Join(qp, "&")) + resp, err := c.Request(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return UserLatencyInsightsResponse{}, xerrors.Errorf("make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return UserLatencyInsightsResponse{}, ReadBodyAsError(resp) + } + var result UserLatencyInsightsResponse + return result, json.NewDecoder(resp.Body).Decode(&result) +} + +// TemplateInsightsResponse is the response from the template insights endpoint. +type TemplateInsightsResponse struct { + Report TemplateInsightsReport `json:"report"` + IntervalReports []TemplateInsightsIntervalReport `json:"interval_reports"` +} + +// TemplateInsightsReport is the report from the template insights endpoint. +type TemplateInsightsReport struct { + StartTime time.Time `json:"start_time" format:"date-time"` + EndTime time.Time `json:"end_time" format:"date-time"` + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` + ActiveUsers int64 `json:"active_users" example:"22"` + AppsUsage []TemplateAppUsage `json:"apps_usage"` + // TODO(mafredri): To be introduced in a future pull request. + // TemplateParametersUsage []TemplateParameterUsage `json:"parameters_usage"` +} + +// TemplateInsightsIntervalReport is the report from the template insights +// endpoint for a specific interval. +type TemplateInsightsIntervalReport struct { + StartTime time.Time `json:"start_time" format:"date-time"` + EndTime time.Time `json:"end_time" format:"date-time"` + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` + Interval InsightsReportInterval `json:"interval"` + ActiveUsers int64 `json:"active_users" example:"14"` +} + +// TemplateAppsType defines the type of app reported. +type TemplateAppsType string + +// TemplateAppsType enums. +const ( + TemplateAppsTypeBuiltin TemplateAppsType = "builtin" + // TODO(mafredri): To be introduced in a future pull request. + // TemplateAppsTypeApp TemplateAppsType = "app" +) + +// TemplateAppUsage shows the usage of an app for one or more templates. +type TemplateAppUsage struct { + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` + Type TemplateAppsType `json:"type" example:"builtin"` + DisplayName string `json:"display_name" example:"Visual Studio Code"` + Slug string `json:"slug" example:"vscode"` + Icon string `json:"icon"` + Seconds int64 `json:"seconds" example:"80500"` +} + +// TODO(mafredri): To be introduced in a future pull request. +/* +// TemplateParameterUsage shows the usage of a parameter for one or more +// templates. +type TemplateParameterUsage struct { + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` + DisplayName string `json:"display_name"` + Name string `json:"name"` + Values []TemplateParameterValue `json:"values"` +} + +// TemplateParameterValue shows the usage of a parameter value for one or more +// templates. +type TemplateParameterValue struct { + Value *string `json:"value"` + Icon string `json:"icon"` + Count int64 `json:"count"` +} +*/ + +type TemplateInsightsRequest struct { + StartTime time.Time `json:"start_time" format:"date-time"` + EndTime time.Time `json:"end_time" format:"date-time"` + TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` + Interval InsightsReportInterval `json:"interval"` +} + +func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsRequest) (TemplateInsightsResponse, error) { + var qp []string + qp = append(qp, fmt.Sprintf("start_time=%s", req.StartTime.Format(insightsTimeLayout))) + qp = append(qp, fmt.Sprintf("end_time=%s", req.EndTime.Format(insightsTimeLayout))) + if len(req.TemplateIDs) > 0 { + var templateIDs []string + for _, id := range req.TemplateIDs { + templateIDs = append(templateIDs, id.String()) + } + qp = append(qp, fmt.Sprintf("template_ids=%s", strings.Join(templateIDs, ","))) + } + if req.Interval != "" { + qp = append(qp, fmt.Sprintf("interval=%s", req.Interval)) + } + + reqURL := fmt.Sprintf("/api/v2/insights/templates?%s", strings.Join(qp, "&")) + resp, err := c.Request(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return TemplateInsightsResponse{}, xerrors.Errorf("make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return TemplateInsightsResponse{}, ReadBodyAsError(resp) + } + var result TemplateInsightsResponse + return result, json.NewDecoder(resp.Body).Decode(&result) +} diff --git a/docs/api/insights.md b/docs/api/insights.md index 90f236d1574cf..a802916fa579c 100644 --- a/docs/api/insights.md +++ b/docs/api/insights.md @@ -36,3 +36,104 @@ curl -X GET http://coder-server:8080/api/v2/insights/daus \ | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.DAUsResponse](schemas.md#codersdkdausresponse) | To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get insights about templates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/insights/templates \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /insights/templates` + +### Example responses + +> 200 Response + +```json +{ + "interval_reports": [ + { + "active_users": 14, + "end_time": "2019-08-24T14:15:22Z", + "interval": "day", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"] + } + ], + "report": { + "active_users": 22, + "apps_usage": [ + { + "display_name": "Visual Studio Code", + "icon": "string", + "seconds": 80500, + "slug": "vscode", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "builtin" + } + ], + "end_time": "2019-08-24T14:15:22Z", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"] + } +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TemplateInsightsResponse](schemas.md#codersdktemplateinsightsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get insights about user latency + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/insights/user-latency \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /insights/user-latency` + +### Example responses + +> 200 Response + +```json +{ + "report": { + "end_time": "2019-08-24T14:15:22Z", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "users": [ + { + "latency_ms": { + "p50": 31.312, + "p95": 119.832 + }, + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } + ] + } +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserLatencyInsightsResponse](schemas.md#codersdkuserlatencyinsightsresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 075f44fd56a27..355e388393676 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1281,6 +1281,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `autostart` | | `autostop` | +## codersdk.ConnectionLatency + +```json +{ + "p50": 31.312, + "p95": 119.832 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----- | ------ | -------- | ------------ | ----------- | +| `p50` | number | false | | | +| `p95` | number | false | | | + ## codersdk.ConvertLoginRequest ```json @@ -2895,6 +2911,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `threshold` | integer | false | | Threshold specifies the number of consecutive failed health checks before returning "unhealthy". | | `url` | string | false | | URL specifies the endpoint to check for the app health. | +## codersdk.InsightsReportInterval + +```json +"day" +``` + +### Properties + +#### Enumerated Values + +| Value | +| ----- | +| `day` | + ## codersdk.IssueReconnectingPTYSignedTokenRequest ```json @@ -4055,6 +4085,44 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ------------- | ----------- | | `provisioner` | `terraform` | +## codersdk.TemplateAppUsage + +```json +{ + "display_name": "Visual Studio Code", + "icon": "string", + "seconds": 80500, + "slug": "vscode", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "builtin" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------------------------------------------------------ | -------- | ------------ | ----------- | +| `display_name` | string | false | | | +| `icon` | string | false | | | +| `seconds` | integer | false | | | +| `slug` | string | false | | | +| `template_ids` | array of string | false | | | +| `type` | [codersdk.TemplateAppsType](#codersdktemplateappstype) | false | | | + +## codersdk.TemplateAppsType + +```json +"builtin" +``` + +### Properties + +#### Enumerated Values + +| Value | +| --------- | +| `builtin` | + ## codersdk.TemplateBuildTimeStats ```json @@ -4102,6 +4170,98 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `tags` | array of string | false | | | | `url` | string | false | | | +## codersdk.TemplateInsightsIntervalReport + +```json +{ + "active_users": 14, + "end_time": "2019-08-24T14:15:22Z", + "interval": "day", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------------------------------------------------------------------ | -------- | ------------ | ----------- | +| `active_users` | integer | false | | | +| `end_time` | string | false | | | +| `interval` | [codersdk.InsightsReportInterval](#codersdkinsightsreportinterval) | false | | | +| `start_time` | string | false | | | +| `template_ids` | array of string | false | | | + +## codersdk.TemplateInsightsReport + +```json +{ + "active_users": 22, + "apps_usage": [ + { + "display_name": "Visual Studio Code", + "icon": "string", + "seconds": 80500, + "slug": "vscode", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "builtin" + } + ], + "end_time": "2019-08-24T14:15:22Z", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | --------------------------------------------------------------- | -------- | ------------ | ----------- | +| `active_users` | integer | false | | | +| `apps_usage` | array of [codersdk.TemplateAppUsage](#codersdktemplateappusage) | false | | | +| `end_time` | string | false | | | +| `start_time` | string | false | | | +| `template_ids` | array of string | false | | | + +## codersdk.TemplateInsightsResponse + +```json +{ + "interval_reports": [ + { + "active_users": 14, + "end_time": "2019-08-24T14:15:22Z", + "interval": "day", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"] + } + ], + "report": { + "active_users": 22, + "apps_usage": [ + { + "display_name": "Visual Studio Code", + "icon": "string", + "seconds": 80500, + "slug": "vscode", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "type": "builtin" + } + ], + "end_time": "2019-08-24T14:15:22Z", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"] + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------------------------------------------------------------------------------------------- | -------- | ------------ | ----------- | +| `interval_reports` | array of [codersdk.TemplateInsightsIntervalReport](#codersdktemplateinsightsintervalreport) | false | | | +| `report` | [codersdk.TemplateInsightsReport](#codersdktemplateinsightsreport) | false | | | + ## codersdk.TemplateRestartRequirement ```json @@ -4694,6 +4854,88 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `status` | `active` | | `status` | `suspended` | +## codersdk.UserLatency + +```json +{ + "latency_ms": { + "p50": 31.312, + "p95": 119.832 + }, + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | -------------------------------------------------------- | -------- | ------------ | ----------- | +| `latency_ms` | [codersdk.ConnectionLatency](#codersdkconnectionlatency) | false | | | +| `template_ids` | array of string | false | | | +| `user_id` | string | false | | | +| `username` | string | false | | | + +## codersdk.UserLatencyInsightsReport + +```json +{ + "end_time": "2019-08-24T14:15:22Z", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "users": [ + { + "latency_ms": { + "p50": 31.312, + "p95": 119.832 + }, + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } + ] +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ----------------------------------------------------- | -------- | ------------ | ----------- | +| `end_time` | string | false | | | +| `start_time` | string | false | | | +| `template_ids` | array of string | false | | | +| `users` | array of [codersdk.UserLatency](#codersdkuserlatency) | false | | | + +## codersdk.UserLatencyInsightsResponse + +```json +{ + "report": { + "end_time": "2019-08-24T14:15:22Z", + "start_time": "2019-08-24T14:15:22Z", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "users": [ + { + "latency_ms": { + "p50": 31.312, + "p95": 119.832 + }, + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" + } + ] + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------- | ------------------------------------------------------------------------ | -------- | ------------ | ----------- | +| `report` | [codersdk.UserLatencyInsightsReport](#codersdkuserlatencyinsightsreport) | false | | | + ## codersdk.UserLoginType ```json diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a6734f669e96a..9f74ef107ddfa 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -140,6 +140,12 @@ export interface BuildInfoResponse { readonly workspace_proxy: boolean } +// From codersdk/insights.go +export interface ConnectionLatency { + readonly p50: number + readonly p95: number +} + // From codersdk/users.go export interface ConvertLoginRequest { readonly to_type: LoginType @@ -886,6 +892,16 @@ export interface TemplateACL { readonly group: TemplateGroup[] } +// From codersdk/insights.go +export interface TemplateAppUsage { + readonly template_ids: string[] + readonly type: TemplateAppsType + readonly display_name: string + readonly slug: string + readonly icon: string + readonly seconds: number +} + // From codersdk/templates.go export type TemplateBuildTimeStats = Record< WorkspaceTransition, @@ -908,6 +924,38 @@ export interface TemplateGroup extends Group { readonly role: TemplateRole } +// From codersdk/insights.go +export interface TemplateInsightsIntervalReport { + readonly start_time: string + readonly end_time: string + readonly template_ids: string[] + readonly interval: InsightsReportInterval + readonly active_users: number +} + +// From codersdk/insights.go +export interface TemplateInsightsReport { + readonly start_time: string + readonly end_time: string + readonly template_ids: string[] + readonly active_users: number + readonly apps_usage: TemplateAppUsage[] +} + +// From codersdk/insights.go +export interface TemplateInsightsRequest { + readonly start_time: string + readonly end_time: string + readonly template_ids: string[] + readonly interval: InsightsReportInterval +} + +// From codersdk/insights.go +export interface TemplateInsightsResponse { + readonly report: TemplateInsightsReport + readonly interval_reports: TemplateInsightsIntervalReport[] +} + // From codersdk/templates.go export interface TemplateRestartRequirement { readonly days_of_week: string[] @@ -1116,6 +1164,34 @@ export interface User { readonly avatar_url: string } +// From codersdk/insights.go +export interface UserLatency { + readonly template_ids: string[] + readonly user_id: string + readonly username: string + readonly latency_ms: ConnectionLatency +} + +// From codersdk/insights.go +export interface UserLatencyInsightsReport { + readonly start_time: string + readonly end_time: string + readonly template_ids: string[] + readonly users: UserLatency[] +} + +// From codersdk/insights.go +export interface UserLatencyInsightsRequest { + readonly start_time: string + readonly end_time: string + readonly template_ids: string[] +} + +// From codersdk/insights.go +export interface UserLatencyInsightsResponse { + readonly report: UserLatencyInsightsReport +} + // From codersdk/users.go export interface UserLoginType { readonly login_type: LoginType @@ -1513,6 +1589,10 @@ export const GitProviders: GitProvider[] = [ "gitlab", ] +// From codersdk/insights.go +export type InsightsReportInterval = "day" +export const InsightsReportIntervals: InsightsReportInterval[] = ["day"] + // From codersdk/provisionerdaemons.go export type JobErrorCode = | "MISSING_TEMPLATE_PARAMETER" @@ -1664,6 +1744,10 @@ export const ServerSentEventTypes: ServerSentEventType[] = [ "ping", ] +// From codersdk/insights.go +export type TemplateAppsType = "builtin" +export const TemplateAppsTypes: TemplateAppsType[] = ["builtin"] + // From codersdk/templates.go export type TemplateRole = "" | "admin" | "use" export const TemplateRoles: TemplateRole[] = ["", "admin", "use"]