From 8a1d3ee0144b3793c310c542571d02f68c32d832 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 14 Jul 2023 15:20:01 +0000 Subject: [PATCH 01/27] feat(coderd): add user latency and template insights endpoints --- coderd/apidoc/docs.go | 243 +++++++++++++++++++++++++++++++++ coderd/apidoc/swagger.json | 227 ++++++++++++++++++++++++++++++ coderd/coderd.go | 2 + coderd/insights.go | 151 ++++++++++++++++++++ coderd/insights_test.go | 88 ++++++++++++ codersdk/insights.go | 159 +++++++++++++++++++++ docs/api/insights.md | 101 ++++++++++++++ docs/api/schemas.md | 242 ++++++++++++++++++++++++++++++++ site/src/api/typesGenerated.ts | 69 ++++++++++ 9 files changed, 1282 insertions(+) create mode 100644 codersdk/insights.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index dd6c958448391..c8cc7b07e70d8 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": [ @@ -6810,6 +6860,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": [ @@ -7883,6 +7946,15 @@ const docTemplate = `{ } } }, + "codersdk.InsightsReportInterval": { + "type": "string", + "enum": [ + "day" + ], + "x-enum-varnames": [ + "InsightsReportIntervalDay" + ] + }, "codersdk.IssueReconnectingPTYSignedTokenRequest": { "type": "object", "required": [ @@ -8958,6 +9030,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": { @@ -8994,6 +9110,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.TemplateRole": { "type": "string", "enum": [ @@ -9506,6 +9693,62 @@ const docTemplate = `{ } } }, + "codersdk.UserLatency": { + "type": "object", + "properties": { + "latency_ms": { + "$ref": "#/definitions/codersdk.ConnectionLatency" + }, + "name": { + "type": "string" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "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 aa2fedba869a6..e8aa2cbcec512 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": [ @@ -6065,6 +6107,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"], @@ -7079,6 +7134,11 @@ } } }, + "codersdk.InsightsReportInterval": { + "type": "string", + "enum": ["day"], + "x-enum-varnames": ["InsightsReportIntervalDay"] + }, "codersdk.IssueReconnectingPTYSignedTokenRequest": { "type": "object", "required": ["agentID", "url"], @@ -8089,6 +8149,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": { @@ -8125,6 +8225,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.TemplateRole": { "type": "string", "enum": ["admin", "use", ""], @@ -8590,6 +8761,62 @@ } } }, + "codersdk.UserLatency": { + "type": "object", + "properties": { + "latency_ms": { + "$ref": "#/definitions/codersdk.ConnectionLatency" + }, + "name": { + "type": "string" + }, + "template_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "user_id": { + "type": "string", + "format": "uuid" + } + } + }, + "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 3d41d62fded00..0cfdbcac8ffbb 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -838,6 +838,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/insights.go b/coderd/insights.go index b1ed1b2dc379d..248130bc323c0 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -1,7 +1,11 @@ package coderd import ( + "fmt" "net/http" + "time" + + "github.com/google/uuid" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/rbac" @@ -43,3 +47,150 @@ 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 ( + startTime = p.Time3339Nano(vals, time.Time{}, "start_time") + endTime = p.Time3339Nano(vals, time.Time{}, "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 + } + + // TODO(mafredri) Verify template IDs. + _ = templateIDs + + resp := codersdk.UserLatencyInsightsResponse{ + Report: codersdk.UserLatencyInsightsReport{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: []uuid.UUID{}, + Users: []codersdk.UserLatency{ + { + UserID: uuid.New(), + Name: "Some User", + LatencyMS: codersdk.ConnectionLatency{ + P50: 14.45, + P95: 32.16, + }, + }, + }, + }, + } + 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 ( + startTime = p.Time3339Nano(vals, time.Time{}, "start_time") + endTime = p.Time3339Nano(vals, time.Time{}, "end_time") + intervalString = p.String(vals, "day", "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 + } + + // TODO(mafredri) Verify template IDs. + _ = templateIDs + + var interval codersdk.InsightsReportInterval + switch v := codersdk.InsightsReportInterval(intervalString); v { + case codersdk.InsightsReportIntervalDay: + interval = v + 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 %q", codersdk.InsightsReportIntervalDay), + }, + }, + }) + return + } + + intervalReports := []codersdk.TemplateInsightsIntervalReport{} + if interval != "" { + intervalStart := startTime + intervalEnd := startTime.Add(time.Hour * 24) + for !intervalEnd.After(endTime) { + intervalReports = append(intervalReports, codersdk.TemplateInsightsIntervalReport{ + StartTime: intervalStart, + EndTime: intervalEnd, + Interval: interval, + TemplateIDs: []uuid.UUID{}, + ActiveUsers: 10, + }) + intervalStart = intervalEnd + intervalEnd = intervalEnd.Add(time.Hour * 24) + } + } + + resp := codersdk.TemplateInsightsResponse{ + Report: codersdk.TemplateInsightsReport{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: []uuid.UUID{}, + ActiveUsers: 10, + AppsUsage: []codersdk.TemplateAppUsage{ + { + TemplateIDs: []uuid.UUID{}, + Type: codersdk.TemplateAppsTypeBuiltin, + DisplayName: "Visual Studio Code", + Slug: "vscode", + Icon: "/icons/vscode.svg", + Seconds: 80500, + }, + }, + }, + IntervalReports: intervalReports, + } + httpapi.Write(ctx, rw, http.StatusOK, resp) +} diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 20ddc6bb62a29..70efa326b6b88 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -100,3 +100,91 @@ 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, + MetricsCacheRefreshInterval: time.Millisecond * 100, + }) + + 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) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + agentCloser := agent.New(agent.Options{ + Logger: logger.Named("agent"), + Client: agentClient, + }) + defer func() { + _ = agentCloser.Close() + }() + _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + userLatencies, err := client.UserLatencyInsights(ctx) + require.NoError(t, err) + + t.Logf("%#v\n", userLatencies) +} + +func TestTemplateInsights(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, + MetricsCacheRefreshInterval: time.Millisecond * 100, + }) + + 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) + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + agentCloser := agent.New(agent.Options{ + Logger: logger.Named("agent"), + Client: agentClient, + }) + defer func() { + _ = agentCloser.Close() + }() + _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + templateInsights, err := client.TemplateInsights(ctx) + require.NoError(t, err) + + t.Logf("%#v\n", templateInsights) +} diff --git a/codersdk/insights.go b/codersdk/insights.go new file mode 100644 index 0000000000000..677c72b8adc16 --- /dev/null +++ b/codersdk/insights.go @@ -0,0 +1,159 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +// 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 { + TemplateID []uuid.UUID `json:"template_ids" format:"uuid"` + UserID uuid.UUID `json:"user_id" format:"uuid"` + Name string `json:"name"` + 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"` +} + +func (c *Client) UserLatencyInsights(ctx context.Context) (UserLatencyInsightsResponse, error) { + var qp []string + qp = append(qp, fmt.Sprintf("start_time=%s", "2020-01-01T00:00:00Z")) + qp = append(qp, fmt.Sprintf("end_time=%s", "2020-01-01T00:00:00Z")) + qp = append(qp, fmt.Sprintf("template_ids=%s", "e0a3f9a0-4b0a-4b6a-8b0a-4b0a4b6a8b0a")) + + 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 int `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 int `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 int `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"` +} +*/ + +func (c *Client) TemplateInsights(ctx context.Context) (TemplateInsightsResponse, error) { + var qp []string + qp = append(qp, fmt.Sprintf("start_time=%s", "2020-01-01T00:00:00Z")) + qp = append(qp, fmt.Sprintf("end_time=%s", "2020-01-01T00:00:00Z")) + qp = append(qp, fmt.Sprintf("interval=%s", InsightsReportIntervalDay)) + qp = append(qp, fmt.Sprintf("template_ids=%s", "e0a3f9a0-4b0a-4b6a-8b0a-4b0a4b6a8b0a")) + + 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..d3ed3f8171f42 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 + }, + "name": "string", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + } + ] + } +} +``` + +### 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 51fe80fdab213..231ee6787cdf5 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 @@ -2883,6 +2899,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 @@ -4038,6 +4068,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 @@ -4085,6 +4153,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.TemplateRole ```json @@ -4643,6 +4803,88 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `status` | `active` | | `status` | `suspended` | +## codersdk.UserLatency + +```json +{ + "latency_ms": { + "p50": 31.312, + "p95": 119.832 + }, + "name": "string", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | -------------------------------------------------------- | -------- | ------------ | ----------- | +| `latency_ms` | [codersdk.ConnectionLatency](#codersdkconnectionlatency) | false | | | +| `name` | string | false | | | +| `template_ids` | array of string | false | | | +| `user_id` | 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 + }, + "name": "string", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + } + ] +} +``` + +### 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 + }, + "name": "string", + "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + } + ] + } +} +``` + +### 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 26ca40b60aad4..9eaa339d69003 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 @@ -883,6 +889,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, @@ -905,6 +921,30 @@ 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 TemplateInsightsResponse { + readonly report: TemplateInsightsReport + readonly interval_reports: TemplateInsightsIntervalReport[] +} + // From codersdk/templates.go export interface TemplateUser extends User { readonly role: TemplateRole @@ -1101,6 +1141,27 @@ export interface User { readonly avatar_url: string } +// From codersdk/insights.go +export interface UserLatency { + readonly template_ids: string[] + readonly user_id: string + readonly name: 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 UserLatencyInsightsResponse { + readonly report: UserLatencyInsightsReport +} + // From codersdk/users.go export interface UserLoginType { readonly login_type: LoginType @@ -1482,6 +1543,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" @@ -1633,6 +1698,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"] From b31ec33f45c66605fcfe7e019343b05003b07410 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 18 Jul 2023 15:18:11 +0000 Subject: [PATCH 02/27] feat(coderd): implement user latency insights logic --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 6 +- coderd/database/dbauthz/dbauthz.go | 7 ++ coderd/database/dbfake/dbfake.go | 13 ++- coderd/database/dbmetrics/dbmetrics.go | 7 ++ coderd/database/dbmock/dbmock.go | 15 ++++ coderd/database/modelqueries.go | 2 +- coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 65 +++++++++++++- .../database/queries/workspaceagentstats.sql | 18 ++++ coderd/database/sqlc.yaml | 1 + coderd/insights.go | 90 ++++++++++++++++--- coderd/templates.go | 2 +- codersdk/insights.go | 8 +- docs/api/insights.md | 4 +- docs/api/schemas.md | 14 +-- site/src/api/typesGenerated.ts | 4 +- 17 files changed, 223 insertions(+), 40 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index c8cc7b07e70d8..8432140a1a376 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9699,9 +9699,6 @@ const docTemplate = `{ "latency_ms": { "$ref": "#/definitions/codersdk.ConnectionLatency" }, - "name": { - "type": "string" - }, "template_ids": { "type": "array", "items": { @@ -9712,6 +9709,9 @@ const docTemplate = `{ "user_id": { "type": "string", "format": "uuid" + }, + "username": { + "type": "string" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e8aa2cbcec512..a5679fc2a713c 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8767,9 +8767,6 @@ "latency_ms": { "$ref": "#/definitions/codersdk.ConnectionLatency" }, - "name": { - "type": "string" - }, "template_ids": { "type": "array", "items": { @@ -8780,6 +8777,9 @@ "user_id": { "type": "string", "format": "uuid" + }, + "username": { + "type": "string" } } }, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index ef2b7b9d18b91..6a5ca0f8c0ded 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1173,6 +1173,13 @@ func (q *querier) GetTemplateDAUs(ctx context.Context, arg database.GetTemplateD return q.db.GetTemplateDAUs(ctx, arg) } +func (q *querier) GetTemplateUserLatencyStats(ctx context.Context, arg database.GetTemplateUserLatencyStatsParams) ([]database.GetTemplateUserLatencyStatsRow, error) { + if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetTemplateUserLatencyStats(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 { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index d68d0d08af199..082d7b62412e5 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -1917,6 +1917,15 @@ func (q *FakeQuerier) GetTemplateDAUs(_ context.Context, arg database.GetTemplat return rs, nil } +func (q *FakeQuerier) GetTemplateUserLatencyStats(ctx context.Context, arg database.GetTemplateUserLatencyStatsParams) ([]database.GetTemplateUserLatencyStatsRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + panic("not implemented") +} + func (q *FakeQuerier) GetTemplateVersionByID(ctx context.Context, templateVersionID uuid.UUID) (database.TemplateVersion, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -5263,9 +5272,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 12f03ea8c75fd..54f9a1d0e7a2c 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -599,6 +599,13 @@ func (m metricsStore) GetTemplateDAUs(ctx context.Context, arg database.GetTempl return daus, err } +func (m metricsStore) GetTemplateUserLatencyStats(ctx context.Context, arg database.GetTemplateUserLatencyStatsParams) ([]database.GetTemplateUserLatencyStatsRow, error) { + start := time.Now() + r0, r1 := m.s.GetTemplateUserLatencyStats(ctx, arg) + m.queryLatencies.WithLabelValues("GetTemplateUserLatencyStats").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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f672a5e5dfc61..59911b92824b9 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1211,6 +1211,21 @@ func (mr *MockStoreMockRecorder) GetTemplateGroupRoles(arg0, arg1 interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateGroupRoles", reflect.TypeOf((*MockStore)(nil).GetTemplateGroupRoles), arg0, arg1) } +// GetTemplateUserLatencyStats mocks base method. +func (m *MockStore) GetTemplateUserLatencyStats(arg0 context.Context, arg1 database.GetTemplateUserLatencyStatsParams) ([]database.GetTemplateUserLatencyStatsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTemplateUserLatencyStats", arg0, arg1) + ret0, _ := ret[0].([]database.GetTemplateUserLatencyStatsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTemplateUserLatencyStats indicates an expected call of GetTemplateUserLatencyStats. +func (mr *MockStoreMockRecorder) GetTemplateUserLatencyStats(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateUserLatencyStats", reflect.TypeOf((*MockStore)(nil).GetTemplateUserLatencyStats), arg0, arg1) +} + // GetTemplateUserRoles mocks base method. func (m *MockStore) GetTemplateUserRoles(arg0 context.Context, arg1 uuid.UUID) ([]database.TemplateUser, error) { m.ctrl.T.Helper() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index a7f186b668b0a..f9582b26bbe9f 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -209,7 +209,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 9fe7a61b7e6c6..e090a91d8fd44 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -105,6 +105,7 @@ 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) + GetTemplateUserLatencyStats(ctx context.Context, arg GetTemplateUserLatencyStatsParams) ([]GetTemplateUserLatencyStatsRow, 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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 484a6ff4d6491..1d9b1fb53641b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6655,6 +6655,67 @@ func (q *sqlQuerier) GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsPar return items, nil } +const getTemplateUserLatencyStats = `-- name: GetTemplateUserLatencyStats :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 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 GetTemplateUserLatencyStatsParams 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 GetTemplateUserLatencyStatsRow 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"` +} + +func (q *sqlQuerier) GetTemplateUserLatencyStats(ctx context.Context, arg GetTemplateUserLatencyStatsParams) ([]GetTemplateUserLatencyStatsRow, error) { + rows, err := q.db.QueryContext(ctx, getTemplateUserLatencyStats, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTemplateUserLatencyStatsRow + for rows.Next() { + var i GetTemplateUserLatencyStatsRow + 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 getWorkspaceAgentStats = `-- name: GetWorkspaceAgentStats :many WITH agent_stats AS ( SELECT @@ -8568,7 +8629,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"` @@ -8602,7 +8663,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/workspaceagentstats.sql b/coderd/database/queries/workspaceagentstats.sql index 1a598bd6a6263..324ec7ef47a5f 100644 --- a/coderd/database/queries/workspaceagentstats.sql +++ b/coderd/database/queries/workspaceagentstats.sql @@ -155,3 +155,21 @@ JOIN workspaces ON workspaces.id = agent_stats.workspace_id; + + +-- name: GetTemplateUserLatencyStats :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 >= @start_time + AND workspace_agent_stats.created_at < @end_time + AND workspace_agent_stats.connection_median_latency_ms > 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; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 964706cf0ad06..5d3e5af2c929a 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -57,6 +57,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 248130bc323c0..a1aa23fb93234 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -6,7 +6,9 @@ import ( "time" "github.com/google/uuid" + "golang.org/x/exp/slices" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" @@ -62,6 +64,12 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { return } + // TODO(mafredri): Client or deployment timezone? + // Example: + // - I want data from Monday - Friday + // - I'm UTC+3 and the deployment is UTC+0 + // - Do we select Monday - Friday in UTC+0 or UTC+3? + // - Considering users can be in different timezones, perhaps this should be per-user (but we don't keep track of user timezones). p := httpapi.NewQueryParamParser(). Required("start_time"). Required("end_time") @@ -80,24 +88,80 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { return } - // TODO(mafredri) Verify template IDs. - _ = templateIDs + // Should we verify all template IDs exist, or just return no rows? + // _, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ + // IDs: templateIDs, + // }) + + rows, err := api.Database.GetTemplateUserLatencyStats(ctx, database.GetTemplateUserLatencyStatsParams{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + // Fetch all users so that we can still include users that have no + // latency data. + users, err := api.Database.GetUsers(ctx, database.GetUsersParams{}) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + templateIDSet := make(map[uuid.UUID]struct{}) + usersWithLatencyByID := make(map[uuid.UUID]codersdk.UserLatency) + for _, row := range rows { + for _, templateID := range row.TemplateIDs { + templateIDSet[templateID] = struct{}{} + } + usersWithLatencyByID[row.UserID] = codersdk.UserLatency{ + TemplateIDs: row.TemplateIDs, + UserID: row.UserID, + Username: row.Username, + LatencyMS: &codersdk.ConnectionLatency{ + P50: row.WorkspaceConnectionLatency50, + P95: row.WorkspaceConnectionLatency95, + }, + } + } + userLatencies := []codersdk.UserLatency{} + for _, user := range users { + userLatency, ok := usersWithLatencyByID[user.ID] + if !ok { + // TODO(mafredri): Other cases? + // We only include deleted/inactive users if they were + // active as part of the requested timeframe. + if user.Deleted || user.Status != database.UserStatusActive { + continue + } + + userLatency = codersdk.UserLatency{ + TemplateIDs: []uuid.UUID{}, + UserID: user.ID, + Username: user.Username, + } + } + userLatencies = append(userLatencies, userLatency) + } + + // 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: []uuid.UUID{}, - Users: []codersdk.UserLatency{ - { - UserID: uuid.New(), - Name: "Some User", - LatencyMS: codersdk.ConnectionLatency{ - P50: 14.45, - P95: 32.16, - }, - }, - }, + TemplateIDs: seenTemplateIDs, + Users: userLatencies, }, } httpapi.Write(ctx, rw, http.StatusOK, resp) diff --git a/coderd/templates.go b/coderd/templates.go index 3404a3ee16677..88c34aa4db106 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -79,7 +79,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 index 677c72b8adc16..63e48a3c6e6ba 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -38,10 +38,10 @@ type UserLatencyInsightsReport struct { // UserLatency shows the connection latency for a user. type UserLatency struct { - TemplateID []uuid.UUID `json:"template_ids" format:"uuid"` - UserID uuid.UUID `json:"user_id" format:"uuid"` - Name string `json:"name"` - LatencyMS ConnectionLatency `json:"latency_ms"` + 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. diff --git a/docs/api/insights.md b/docs/api/insights.md index d3ed3f8171f42..a802916fa579c 100644 --- a/docs/api/insights.md +++ b/docs/api/insights.md @@ -121,9 +121,9 @@ curl -X GET http://coder-server:8080/api/v2/insights/user-latency \ "p50": 31.312, "p95": 119.832 }, - "name": "string", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], - "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } ] } diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 231ee6787cdf5..675afedb84521 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4811,9 +4811,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "p50": 31.312, "p95": 119.832 }, - "name": "string", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], - "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } ``` @@ -4822,9 +4822,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | -------------- | -------------------------------------------------------- | -------- | ------------ | ----------- | | `latency_ms` | [codersdk.ConnectionLatency](#codersdkconnectionlatency) | false | | | -| `name` | string | false | | | | `template_ids` | array of string | false | | | | `user_id` | string | false | | | +| `username` | string | false | | | ## codersdk.UserLatencyInsightsReport @@ -4839,9 +4839,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "p50": 31.312, "p95": 119.832 }, - "name": "string", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], - "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } ] } @@ -4870,9 +4870,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "p50": 31.312, "p95": 119.832 }, - "name": "string", "template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], - "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5" + "user_id": "a169451c-8525-4352-b8ca-070dd449a1a5", + "username": "string" } ] } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9eaa339d69003..cebe70c30b488 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1145,8 +1145,8 @@ export interface User { export interface UserLatency { readonly template_ids: string[] readonly user_id: string - readonly name: string - readonly latency_ms: ConnectionLatency + readonly username: string + readonly latency_ms?: ConnectionLatency } // From codersdk/insights.go From aaadc6aefcd29670cbdd715949bb28724c38c224 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 19 Jul 2023 11:54:12 +0000 Subject: [PATCH 03/27] feat(coderd): implement template insights logic --- coderd/database/dbauthz/dbauthz.go | 18 +- coderd/database/dbfake/dbfake.go | 20 +- coderd/database/dbmetrics/dbmetrics.go | 20 +- coderd/database/dbmock/dbmock.go | 44 ++- coderd/database/querier.go | 6 +- coderd/database/queries.sql.go | 262 ++++++++++++++---- coderd/database/queries/insights.sql | 84 ++++++ .../database/queries/workspaceagentstats.sql | 18 -- coderd/insights.go | 103 +++++-- codersdk/insights.go | 9 +- site/src/api/typesGenerated.ts | 4 +- 11 files changed, 465 insertions(+), 123 deletions(-) create mode 100644 coderd/database/queries/insights.sql diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 6a5ca0f8c0ded..17180fae29dd7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1173,11 +1173,18 @@ func (q *querier) GetTemplateDAUs(ctx context.Context, arg database.GetTemplateD return q.db.GetTemplateDAUs(ctx, arg) } -func (q *querier) GetTemplateUserLatencyStats(ctx context.Context, arg database.GetTemplateUserLatencyStatsParams) ([]database.GetTemplateUserLatencyStatsRow, error) { +func (q *querier) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil { return nil, err } - return q.db.GetTemplateUserLatencyStats(ctx, arg) + return q.db.GetTemplateDailyInsights(ctx, arg) +} + +func (q *querier) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { + 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) { @@ -1346,6 +1353,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 082d7b62412e5..d58e5a536ff9b 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -1917,7 +1917,7 @@ func (q *FakeQuerier) GetTemplateDAUs(_ context.Context, arg database.GetTemplat return rs, nil } -func (q *FakeQuerier) GetTemplateUserLatencyStats(ctx context.Context, arg database.GetTemplateUserLatencyStatsParams) ([]database.GetTemplateUserLatencyStatsRow, error) { +func (q *FakeQuerier) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { return nil, err @@ -1926,6 +1926,15 @@ func (q *FakeQuerier) GetTemplateUserLatencyStats(ctx context.Context, arg datab panic("not implemented") } +func (q *FakeQuerier) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.GetTemplateInsightsRow{}, err + } + + panic("not implemented") +} + func (q *FakeQuerier) GetTemplateVersionByID(ctx context.Context, templateVersionID uuid.UUID) (database.TemplateVersion, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -2173,6 +2182,15 @@ func (q *FakeQuerier) GetUserCount(_ context.Context) (int64, error) { return existing, nil } +func (q *FakeQuerier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + panic("not implemented") +} + func (q *FakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 54f9a1d0e7a2c..4d96088f37ba4 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -599,10 +599,17 @@ func (m metricsStore) GetTemplateDAUs(ctx context.Context, arg database.GetTempl return daus, err } -func (m metricsStore) GetTemplateUserLatencyStats(ctx context.Context, arg database.GetTemplateUserLatencyStatsParams) ([]database.GetTemplateUserLatencyStatsRow, error) { +func (m metricsStore) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { start := time.Now() - r0, r1 := m.s.GetTemplateUserLatencyStats(ctx, arg) - m.queryLatencies.WithLabelValues("GetTemplateUserLatencyStats").Observe(time.Since(start).Seconds()) + 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 } @@ -704,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 59911b92824b9..72736b742ce57 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,19 +1226,19 @@ func (mr *MockStoreMockRecorder) GetTemplateGroupRoles(arg0, arg1 interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateGroupRoles", reflect.TypeOf((*MockStore)(nil).GetTemplateGroupRoles), arg0, arg1) } -// GetTemplateUserLatencyStats mocks base method. -func (m *MockStore) GetTemplateUserLatencyStats(arg0 context.Context, arg1 database.GetTemplateUserLatencyStatsParams) ([]database.GetTemplateUserLatencyStatsRow, error) { +// 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, "GetTemplateUserLatencyStats", arg0, arg1) - ret0, _ := ret[0].([]database.GetTemplateUserLatencyStatsRow) + ret := m.ctrl.Call(m, "GetTemplateInsights", arg0, arg1) + ret0, _ := ret[0].(database.GetTemplateInsightsRow) ret1, _ := ret[1].(error) return ret0, ret1 } -// GetTemplateUserLatencyStats indicates an expected call of GetTemplateUserLatencyStats. -func (mr *MockStoreMockRecorder) GetTemplateUserLatencyStats(arg0, arg1 interface{}) *gomock.Call { +// 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, "GetTemplateUserLatencyStats", reflect.TypeOf((*MockStore)(nil).GetTemplateUserLatencyStats), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateInsights), arg0, arg1) } // GetTemplateUserRoles mocks base method. @@ -1451,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/querier.go b/coderd/database/querier.go index e090a91d8fd44..e1f6aa4da8afb 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -105,7 +105,10 @@ 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) - GetTemplateUserLatencyStats(ctx context.Context, arg GetTemplateUserLatencyStatsParams) ([]GetTemplateUserLatencyStatsRow, error) + GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) + // GetTemplateInsights has a garnularity 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) @@ -120,6 +123,7 @@ 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(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 1d9b1fb53641b..f54b9dc0fa038 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1374,6 +1374,207 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar return i, err } +const getTemplateDailyInsights = `-- name: GetTemplateDailyInsights :many +WITH d AS ( + SELECT generate_series($1::timestamptz, $2::timestamptz, '1 day'::interval) AS d +), ts AS ( + SELECT + d::timestamptz AS from_, + (d + '1 day'::interval)::timestamptz 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_) + WHERE + 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 +) + +SELECT + from_ AS start_time, + to_ AS end_time, + COUNT(DISTINCT user_id) AS active_users, + array_agg(DISTINCT template_id)::uuid[] AS template_ids +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"` + ActiveUsers int64 `db:"active_users" json:"active_users"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` +} + +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, + &i.ActiveUsers, + pq.Array(&i.TemplateIDs), + ); 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_) + WHERE + 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 +) + +SELECT + COUNT(DISTINCT user_id) AS active_users, + array_agg(DISTINCT template_id)::uuid[] AS template_ids, + SUM(usage_vscode_seconds) AS usage_vscode_seconds, + SUM(usage_jetbrains_seconds) AS usage_jetbrains_seconds, + SUM(usage_reconnecting_pty_seconds) AS usage_reconnecting_pty_seconds, + SUM(usage_ssh_seconds) AS usage_ssh_seconds +FROM usage_by_user, unnest(template_ids) as template_id +` + +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 { + ActiveUsers int64 `db:"active_users" json:"active_users"` + TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + 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 garnularity 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( + &i.ActiveUsers, + pq.Array(&i.TemplateIDs), + &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"` +} + +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 @@ -6655,67 +6856,6 @@ func (q *sqlQuerier) GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsPar return items, nil } -const getTemplateUserLatencyStats = `-- name: GetTemplateUserLatencyStats :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 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 GetTemplateUserLatencyStatsParams 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 GetTemplateUserLatencyStatsRow 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"` -} - -func (q *sqlQuerier) GetTemplateUserLatencyStats(ctx context.Context, arg GetTemplateUserLatencyStatsParams) ([]GetTemplateUserLatencyStatsRow, error) { - rows, err := q.db.QueryContext(ctx, getTemplateUserLatencyStats, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs)) - if err != nil { - return nil, err - } - defer rows.Close() - var items []GetTemplateUserLatencyStatsRow - for rows.Next() { - var i GetTemplateUserLatencyStatsRow - 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 getWorkspaceAgentStats = `-- name: GetWorkspaceAgentStats :many WITH agent_stats AS ( SELECT diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql new file mode 100644 index 0000000000000..5941f62c69b35 --- /dev/null +++ b/coderd/database/queries/insights.sql @@ -0,0 +1,84 @@ +-- 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 >= @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 garnularity 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_) + WHERE + 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 +) + +SELECT + COUNT(DISTINCT user_id) AS active_users, + array_agg(DISTINCT template_id)::uuid[] AS template_ids, + SUM(usage_vscode_seconds) AS usage_vscode_seconds, + SUM(usage_jetbrains_seconds) AS usage_jetbrains_seconds, + SUM(usage_reconnecting_pty_seconds) AS usage_reconnecting_pty_seconds, + SUM(usage_ssh_seconds) AS usage_ssh_seconds +FROM usage_by_user, unnest(template_ids) as template_id; + +-- name: GetTemplateDailyInsights :many +WITH d AS ( + SELECT generate_series(@start_time::timestamptz, @end_time::timestamptz, '1 day'::interval) AS d +), ts AS ( + SELECT + d::timestamptz AS from_, + (d + '1 day'::interval)::timestamptz 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_) + WHERE + 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 +) + +SELECT + from_ AS start_time, + to_ AS end_time, + COUNT(DISTINCT user_id) AS active_users, + array_agg(DISTINCT template_id)::uuid[] AS template_ids +FROM usage_by_day, unnest(template_ids) as template_id +GROUP BY from_, to_; diff --git a/coderd/database/queries/workspaceagentstats.sql b/coderd/database/queries/workspaceagentstats.sql index 324ec7ef47a5f..1a598bd6a6263 100644 --- a/coderd/database/queries/workspaceagentstats.sql +++ b/coderd/database/queries/workspaceagentstats.sql @@ -155,21 +155,3 @@ JOIN workspaces ON workspaces.id = agent_stats.workspace_id; - - --- name: GetTemplateUserLatencyStats :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 >= @start_time - AND workspace_agent_stats.created_at < @end_time - AND workspace_agent_stats.connection_median_latency_ms > 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; diff --git a/coderd/insights.go b/coderd/insights.go index a1aa23fb93234..8007104fe5805 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -7,6 +7,7 @@ import ( "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" @@ -93,7 +94,7 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { // IDs: templateIDs, // }) - rows, err := api.Database.GetTemplateUserLatencyStats(ctx, database.GetTemplateUserLatencyStatsParams{ + rows, err := api.Database.GetUserLatencyInsights(ctx, database.GetUserLatencyInsightsParams{ StartTime: startTime, EndTime: endTime, TemplateIDs: templateIDs, @@ -188,7 +189,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { var ( startTime = p.Time3339Nano(vals, time.Time{}, "start_time") endTime = p.Time3339Nano(vals, time.Time{}, "end_time") - intervalString = p.String(vals, "day", "interval") + intervalString = p.String(vals, string(codersdk.InsightsReportIntervalNone), "interval") templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids") ) p.ErrorExcessParams(vals) @@ -200,12 +201,14 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return } - // TODO(mafredri) Verify template IDs. - _ = templateIDs + // Should we verify all template IDs exist, or just return no rows? + // _, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ + // IDs: templateIDs, + // }) var interval codersdk.InsightsReportInterval switch v := codersdk.InsightsReportInterval(intervalString); v { - case codersdk.InsightsReportIntervalDay: + case codersdk.InsightsReportIntervalDay, codersdk.InsightsReportIntervalNone: interval = v default: httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -213,44 +216,96 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { Validations: []codersdk.ValidationError{ { Field: "interval", - Detail: fmt.Sprintf("must be %q", codersdk.InsightsReportIntervalDay), + Detail: fmt.Sprintf("must be one of %v", []codersdk.InsightsReportInterval{codersdk.InsightsReportIntervalNone, codersdk.InsightsReportIntervalDay}), }, }, }) return } - intervalReports := []codersdk.TemplateInsightsIntervalReport{} - if interval != "" { - intervalStart := startTime - intervalEnd := startTime.Add(time.Hour * 24) - for !intervalEnd.After(endTime) { - intervalReports = append(intervalReports, codersdk.TemplateInsightsIntervalReport{ - StartTime: intervalStart, - EndTime: intervalEnd, - Interval: interval, - TemplateIDs: []uuid.UUID{}, - ActiveUsers: 10, + 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 != codersdk.InsightsReportIntervalNone { + dailyUsage, err = db.GetTemplateDailyInsights(ctx, database.GetTemplateDailyInsightsParams{ + StartTime: startTime, + EndTime: endTime, + TemplateIDs: templateIDs, }) - intervalStart = intervalEnd - intervalEnd = intervalEnd.Add(time.Hour * 24) + 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.InternalServerError(rw, err) + return + } + + intervalReports := []codersdk.TemplateInsightsIntervalReport{} + for _, row := range dailyUsage { + intervalReports = append(intervalReports, codersdk.TemplateInsightsIntervalReport{ + StartTime: row.StartTime, + EndTime: row.EndTime, + Interval: interval, + TemplateIDs: row.TemplateIDs, + ActiveUsers: row.ActiveUsers, + }) } resp := codersdk.TemplateInsightsResponse{ Report: codersdk.TemplateInsightsReport{ StartTime: startTime, EndTime: endTime, - TemplateIDs: []uuid.UUID{}, - ActiveUsers: 10, + TemplateIDs: usage.TemplateIDs, + ActiveUsers: usage.ActiveUsers, AppsUsage: []codersdk.TemplateAppUsage{ { - TemplateIDs: []uuid.UUID{}, + TemplateIDs: usage.TemplateIDs, // TODO(mafredri): Update query to return template IDs/app? Type: codersdk.TemplateAppsTypeBuiltin, DisplayName: "Visual Studio Code", Slug: "vscode", - Icon: "/icons/vscode.svg", - Seconds: 80500, + Icon: "/icons/code.svg", + Seconds: usage.UsageVscodeSeconds, + }, + { + TemplateIDs: usage.TemplateIDs, // TODO(mafredri): Update query to return template IDs/app? + Type: codersdk.TemplateAppsTypeBuiltin, + DisplayName: "JetBrains", + Slug: "jetbrains", + Icon: "/icons/intellij.svg", + Seconds: usage.UsageJetbrainsSeconds, + }, + { + TemplateIDs: usage.TemplateIDs, // TODO(mafredri): Update query to return template IDs/app? + Type: codersdk.TemplateAppsTypeBuiltin, + DisplayName: "Web Terminal", + Slug: "reconnecting-pty", + Icon: "/icons/terminal.svg", + Seconds: usage.UsageReconnectingPtySeconds, + }, + { + TemplateIDs: usage.TemplateIDs, // TODO(mafredri): Update query to return template IDs/app? + Type: codersdk.TemplateAppsTypeBuiltin, + DisplayName: "SSH", + Slug: "ssh", + Icon: "/icons/terminal.svg", + Seconds: usage.UsageSshSeconds, }, }, }, diff --git a/codersdk/insights.go b/codersdk/insights.go index 63e48a3c6e6ba..64f748da979db 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -18,7 +18,8 @@ type InsightsReportInterval string // InsightsReportInterval enums. const ( - InsightsReportIntervalDay InsightsReportInterval = "day" + InsightsReportIntervalNone InsightsReportInterval = "none" + InsightsReportIntervalDay InsightsReportInterval = "day" ) // UserLatencyInsightsResponse is the response from the user latency insights @@ -81,7 +82,7 @@ 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 int `json:"active_users" example:"22"` + 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"` @@ -94,7 +95,7 @@ type TemplateInsightsIntervalReport struct { EndTime time.Time `json:"end_time" format:"date-time"` TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"` Interval InsightsReportInterval `json:"interval"` - ActiveUsers int `json:"active_users" example:"14"` + ActiveUsers int64 `json:"active_users" example:"14"` } // TemplateAppsType defines the type of app reported. @@ -114,7 +115,7 @@ type TemplateAppUsage struct { DisplayName string `json:"display_name" example:"Visual Studio Code"` Slug string `json:"slug" example:"vscode"` Icon string `json:"icon"` - Seconds int `json:"seconds" example:"80500"` + Seconds int64 `json:"seconds" example:"80500"` } // TODO(mafredri): To be introduced in a future pull request. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index cebe70c30b488..8b7604a28d514 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1544,8 +1544,8 @@ export const GitProviders: GitProvider[] = [ ] // From codersdk/insights.go -export type InsightsReportInterval = "day" -export const InsightsReportIntervals: InsightsReportInterval[] = ["day"] +export type InsightsReportInterval = "day" | "none" +export const InsightsReportIntervals: InsightsReportInterval[] = ["day", "none"] // From codersdk/provisionerdaemons.go export type JobErrorCode = From b54b0aa5901bb017dd70772a5eb469456b515cc4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 19 Jul 2023 14:00:04 +0000 Subject: [PATCH 04/27] make start/endtime check more strict --- coderd/insights.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/coderd/insights.go b/coderd/insights.go index 8007104fe5805..37edfd60ef206 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "fmt" "net/http" "time" @@ -89,6 +90,10 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { return } + if !verifyInsightsStartAndEndTime(ctx, rw, startTime, endTime) { + return + } + // Should we verify all template IDs exist, or just return no rows? // _, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ // IDs: templateIDs, @@ -201,6 +206,10 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return } + if !verifyInsightsStartAndEndTime(ctx, rw, startTime, endTime) { + return + } + // Should we verify all template IDs exist, or just return no rows? // _, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ // IDs: templateIDs, @@ -313,3 +322,53 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { } httpapi.Write(ctx, rw, http.StatusOK, resp) } + +func verifyInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, startTime, endTime time.Time) bool { + for _, v := range []struct { + name string + t time.Time + }{ + {"start_time", startTime}, + {"end_time", endTime}, + } { + if v.t.IsZero() { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: v.name, + Detail: "must be not be zero", + }, + }, + }) + return false + } + h, m, s := v.t.Clock() + if h != 0 || m != 0 || s != 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: v.name, + Detail: "clock must be 00:00:00", + }, + }, + }) + return false + } + } + 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: "must be after start_time", + }, + }, + }) + return false + } + + return true +} From 5f2bfd41b59762ec33da8be52b61f98cef4bbf83 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 19 Jul 2023 14:03:35 +0000 Subject: [PATCH 05/27] move interval verification --- coderd/insights.go | 39 ++++++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/coderd/insights.go b/coderd/insights.go index 37edfd60ef206..88d3bb61064b1 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -209,29 +209,16 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { if !verifyInsightsStartAndEndTime(ctx, rw, startTime, endTime) { return } + interval, ok := verifyInsightsInterval(ctx, rw, intervalString) + if !ok { + return + } // Should we verify all template IDs exist, or just return no rows? // _, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ // IDs: templateIDs, // }) - var interval codersdk.InsightsReportInterval - switch v := codersdk.InsightsReportInterval(intervalString); v { - case codersdk.InsightsReportIntervalDay, codersdk.InsightsReportIntervalNone: - interval = v - 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.InsightsReportIntervalNone, codersdk.InsightsReportIntervalDay}), - }, - }, - }) - return - } - var usage database.GetTemplateInsightsRow var dailyUsage []database.GetTemplateDailyInsightsRow // Use a transaction to ensure that we get consistent data between @@ -372,3 +359,21 @@ func verifyInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, return true } + +func verifyInsightsInterval(ctx context.Context, rw http.ResponseWriter, intervalString string) (codersdk.InsightsReportInterval, bool) { + switch v := codersdk.InsightsReportInterval(intervalString); v { + case codersdk.InsightsReportIntervalDay, codersdk.InsightsReportIntervalNone: + 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.InsightsReportIntervalNone, codersdk.InsightsReportIntervalDay}), + }, + }, + }) + return "", false + } +} From 4820c53043fe2405ca1794830e6524eed9f38b7b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 20 Jul 2023 09:09:12 +0000 Subject: [PATCH 06/27] gen --- coderd/apidoc/docs.go | 9 ++++++++- coderd/apidoc/swagger.json | 14 +++++++++++--- codersdk/insights.go | 2 +- docs/api/schemas.md | 9 +++++---- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 8432140a1a376..3a99ef6351b80 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7949,9 +7949,11 @@ const docTemplate = `{ "codersdk.InsightsReportInterval": { "type": "string", "enum": [ + "none", "day" ], "x-enum-varnames": [ + "InsightsReportIntervalNone", "InsightsReportIntervalDay" ] }, @@ -9122,7 +9124,12 @@ const docTemplate = `{ "format": "date-time" }, "interval": { - "$ref": "#/definitions/codersdk.InsightsReportInterval" + "allOf": [ + { + "$ref": "#/definitions/codersdk.InsightsReportInterval" + } + ], + "example": "day" }, "start_time": { "type": "string", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a5679fc2a713c..1c89590fdd526 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7136,8 +7136,11 @@ }, "codersdk.InsightsReportInterval": { "type": "string", - "enum": ["day"], - "x-enum-varnames": ["InsightsReportIntervalDay"] + "enum": ["none", "day"], + "x-enum-varnames": [ + "InsightsReportIntervalNone", + "InsightsReportIntervalDay" + ] }, "codersdk.IssueReconnectingPTYSignedTokenRequest": { "type": "object", @@ -8237,7 +8240,12 @@ "format": "date-time" }, "interval": { - "$ref": "#/definitions/codersdk.InsightsReportInterval" + "allOf": [ + { + "$ref": "#/definitions/codersdk.InsightsReportInterval" + } + ], + "example": "day" }, "start_time": { "type": "string", diff --git a/codersdk/insights.go b/codersdk/insights.go index 64f748da979db..25652bf1ef6eb 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -94,7 +94,7 @@ 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"` + Interval InsightsReportInterval `json:"interval" example:"day"` ActiveUsers int64 `json:"active_users" example:"14"` } diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 675afedb84521..fc10e5d6d373e 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2902,16 +2902,17 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ## codersdk.InsightsReportInterval ```json -"day" +"none" ``` ### Properties #### Enumerated Values -| Value | -| ----- | -| `day` | +| Value | +| ------ | +| `none` | +| `day` | ## codersdk.IssueReconnectingPTYSignedTokenRequest From 6326643ef1a839a0a807a6f5e6b80d3a4f8cb653 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 20 Jul 2023 11:29:00 +0000 Subject: [PATCH 07/27] allow endtime for today include the hour --- coderd/insights.go | 116 ++++++++++++++++++++++++++++----------------- 1 file changed, 73 insertions(+), 43 deletions(-) diff --git a/coderd/insights.go b/coderd/insights.go index 88d3bb61064b1..5c7c442aed611 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -66,20 +66,16 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { return } - // TODO(mafredri): Client or deployment timezone? - // Example: - // - I want data from Monday - Friday - // - I'm UTC+3 and the deployment is UTC+0 - // - Do we select Monday - Friday in UTC+0 or UTC+3? - // - Considering users can be in different timezones, perhaps this should be per-user (but we don't keep track of user timezones). p := httpapi.NewQueryParamParser(). Required("start_time"). Required("end_time") vals := r.URL.Query() var ( - startTime = p.Time3339Nano(vals, time.Time{}, "start_time") - endTime = p.Time3339Nano(vals, time.Time{}, "end_time") - templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids") + // 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 { @@ -90,15 +86,11 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { return } - if !verifyInsightsStartAndEndTime(ctx, rw, startTime, endTime) { + startTime, endTime, ok := parseInsightsStartAndEndTime(ctx, rw, startTimeString, endTimeString) + if !ok { return } - // Should we verify all template IDs exist, or just return no rows? - // _, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ - // IDs: templateIDs, - // }) - rows, err := api.Database.GetUserLatencyInsights(ctx, database.GetUserLatencyInsightsParams{ StartTime: startTime, EndTime: endTime, @@ -192,10 +184,12 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { Required("end_time") vals := r.URL.Query() var ( - startTime = p.Time3339Nano(vals, time.Time{}, "start_time") - endTime = p.Time3339Nano(vals, time.Time{}, "end_time") - intervalString = p.String(vals, string(codersdk.InsightsReportIntervalNone), "interval") - templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids") + // 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, string(codersdk.InsightsReportIntervalNone), "interval") + templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids") ) p.ErrorExcessParams(vals) if len(p.Errors) > 0 { @@ -206,7 +200,8 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return } - if !verifyInsightsStartAndEndTime(ctx, rw, startTime, endTime) { + startTime, endTime, ok := parseInsightsStartAndEndTime(ctx, rw, startTimeString, endTimeString) + if !ok { return } interval, ok := verifyInsightsInterval(ctx, rw, intervalString) @@ -214,11 +209,6 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return } - // Should we verify all template IDs exist, or just return no rows? - // _, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ - // IDs: templateIDs, - // }) - var usage database.GetTemplateInsightsRow var dailyUsage []database.GetTemplateDailyInsightsRow // Use a transaction to ensure that we get consistent data between @@ -310,39 +300,79 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, resp) } -func verifyInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, startTime, endTime time.Time) bool { - for _, v := range []struct { - name string - t time.Time +// 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) { + const insightsTimeLayout = time.RFC3339Nano + + for _, qp := range []struct { + name, value string + dest *time.Time }{ - {"start_time", startTime}, - {"end_time", endTime}, + {"start_time", startTimeString, &startTime}, + {"end_time", endTimeString, &endTime}, } { - if v.t.IsZero() { + 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: v.name, - Detail: "must be not be zero", + Field: qp.name, + Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", qp.name, insightsTimeLayout, err.Error()), }, }, }) - return false + return time.Time{}, time.Time{}, false } - h, m, s := v.t.Clock() - if h != 0 || m != 0 || s != 0 { + 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 + } + ensureZeroHour := true + if qp.name == "end_time" { + ey, em, ed := t.Date() + ty, tm, td := time.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: v.name, - Detail: "clock must be 00:00:00", + Field: qp.name, + Detail: fmt.Sprintf("Query param %q must have the clock set to %02d:00:00", qp.name, h), }, }, }) - return false + return time.Time{}, time.Time{}, false } + *qp.dest = t } if endTime.Before(startTime) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -350,14 +380,14 @@ func verifyInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, Validations: []codersdk.ValidationError{ { Field: "end_time", - Detail: "must be after start_time", + Detail: fmt.Sprintf("Query param %q must be greater than %q", "end_time", "start_time"), }, }, }) - return false + return time.Time{}, time.Time{}, false } - return true + return startTime, endTime, true } func verifyInsightsInterval(ctx context.Context, rw http.ResponseWriter, intervalString string) (codersdk.InsightsReportInterval, bool) { From 2198c5f7fd5f4b81e3bfe5b0cf0e7cfa31474bbc Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 20 Jul 2023 11:30:47 +0000 Subject: [PATCH 08/27] prevent timetravel --- coderd/insights.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/coderd/insights.go b/coderd/insights.go index 5c7c442aed611..7f7f1cff6d800 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -308,6 +308,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { // 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) { const insightsTimeLayout = time.RFC3339Nano + now := time.Now() for _, qp := range []struct { name, value string @@ -329,6 +330,7 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s }) return time.Time{}, time.Time{}, false } + if t.IsZero() { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Query parameter has invalid value.", @@ -341,10 +343,24 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s }) return time.Time{}, time.Time{}, false } + + if t.After(now) { + 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 := time.Now().Date() + ty, tm, td := now.Date() ensureZeroHour = ey != ty || em != tm || ed != td } From 2e51056fe346d27116efc10adc07802eadab3673 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 20 Jul 2023 16:24:58 +0000 Subject: [PATCH 09/27] add test for latencies --- coderd/database/dbfake/dbfake.go | 61 +++++++++++- coderd/database/queries.sql.go | 13 +-- coderd/database/queries/insights.sql | 13 +-- coderd/insights.go | 8 +- coderd/insights_internal_test.go | 137 +++++++++++++++++++++++++++ coderd/insights_test.go | 52 +++++++++- codersdk/insights.go | 47 +++++++-- site/src/api/typesGenerated.ts | 15 +++ 8 files changed, 316 insertions(+), 30 deletions(-) create mode 100644 coderd/insights_internal_test.go diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index d58e5a536ff9b..91a33d0cae02f 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -2188,7 +2188,66 @@ func (q *FakeQuerier) GetUserLatencyInsights(ctx context.Context, arg database.G return nil, err } - panic("not implemented") + 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) + templateSet := seenTemplatesByUserID[userID] + templateIDs := make([]uuid.UUID, 0, len(templateSet)) + for templateID := range templateSet { + 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) { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index f54b9dc0fa038..76a050eb9fba9 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1376,11 +1376,12 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar 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_, - (d + '1 day'::interval)::timestamptz AS to_ + 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 @@ -1474,11 +1475,11 @@ WITH d AS ( SELECT COUNT(DISTINCT user_id) AS active_users, - array_agg(DISTINCT template_id)::uuid[] AS template_ids, - SUM(usage_vscode_seconds) AS usage_vscode_seconds, - SUM(usage_jetbrains_seconds) AS usage_jetbrains_seconds, - SUM(usage_reconnecting_pty_seconds) AS usage_reconnecting_pty_seconds, - SUM(usage_ssh_seconds) AS usage_ssh_seconds + COALESCE(array_agg(DISTINCT template_id), '{}')::uuid[] AS template_ids, + 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, unnest(template_ids) as template_id ` diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 5941f62c69b35..3c0d90048ce38 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -47,20 +47,21 @@ WITH d AS ( SELECT COUNT(DISTINCT user_id) AS active_users, - array_agg(DISTINCT template_id)::uuid[] AS template_ids, - SUM(usage_vscode_seconds) AS usage_vscode_seconds, - SUM(usage_jetbrains_seconds) AS usage_jetbrains_seconds, - SUM(usage_reconnecting_pty_seconds) AS usage_reconnecting_pty_seconds, - SUM(usage_ssh_seconds) AS usage_ssh_seconds + COALESCE(array_agg(DISTINCT template_id), '{}')::uuid[] AS template_ids, + 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, unnest(template_ids) as template_id; -- name: GetTemplateDailyInsights :many 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_, - (d + '1 day'::interval)::timestamptz AS to_ + 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 diff --git a/coderd/insights.go b/coderd/insights.go index 7f7f1cff6d800..282c73333e5a9 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -307,7 +307,6 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { // 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) { - const insightsTimeLayout = time.RFC3339Nano now := time.Now() for _, qp := range []struct { @@ -317,14 +316,14 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s {"start_time", startTimeString, &startTime}, {"end_time", endTimeString, &endTime}, } { - t, err := time.Parse(insightsTimeLayout, qp.value) + t, err := time.Parse(codersdk.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()), + Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", qp.name, codersdk.InsightsTimeLayout, err.Error()), }, }, }) @@ -344,7 +343,8 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s return time.Time{}, time.Time{}, false } - if t.After(now) { + // 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{ diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go new file mode 100644 index 0000000000000..69f9a1dbe4ec6 --- /dev/null +++ b/coderd/insights_internal_test.go @@ -0,0 +1,137 @@ +package coderd + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/codersdk" +) + +func Test_parseInsightsStartAndEndTime(t *testing.T) { + t.Parallel() + + format := codersdk.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(format), + endTime: thisHour.Format(format), + }, + 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(format), + endTime: thisHour.Add(time.Minute + time.Second).Format(format), + }, + wantOk: false, + }, + { + name: "Today (hour round up)", + args: args{ + startTime: today.Format(format), + endTime: thisHourRoundUp.Format(format), + }, + 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: "Bad format", + args: args{ + startTime: "2023-07-10", + endTime: "2023-07-17", + }, + wantOk: false, + }, + { + name: "Zero time", + args: args{ + startTime: (time.Time{}).Format(format), + endTime: (time.Time{}).Format(format), + }, + wantOk: false, + }, + { + name: "Time in future", + args: args{ + startTime: today.AddDate(0, 0, 1).Format(format), + endTime: today.AddDate(0, 0, 2).Format(format), + }, + wantOk: false, + }, + { + name: "End before start", + args: args{ + startTime: today.Format(format), + endTime: today.AddDate(0, 0, -1).Format(format), + }, + 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) + + // assert.Equal is unable to test location equality, 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)) + assert.Equal(t, tt.wantOk, gotOk) + }) + } +} diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 70efa326b6b88..22ce150a3597d 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -112,6 +112,7 @@ func TestUserLatencyInsights(t *testing.T) { }) user := coderdtest.CreateFirstUser(t, client) + _, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -134,15 +135,54 @@ func TestUserLatencyInsights(t *testing.T) { defer func() { _ = agentCloser.Close() }() - _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - userLatencies, err := client.UserLatencyInsights(ctx) + 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() - t.Logf("%#v\n", userLatencies) + // Create users that will not appear in the report. + _, user3 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + _, user4 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + _, err = client.UpdateUserStatus(ctx, user3.Username, codersdk.UserStatusSuspended) + require.NoError(t, err) + err = client.DeleteUser(ctx, user4.ID) + require.NoError(t, err) + + y, m, d := time.Now().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) + + _ = sshConn.Close() + + 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 + } + if userLatencies.Report.Users[0].UserID == user2.ID { + userLatencies.Report.Users[0], userLatencies.Report.Users[1] = userLatencies.Report.Users[1], userLatencies.Report.Users[0] + } + return userLatencies.Report.Users[0].LatencyMS != nil + }, testutil.WaitShort, testutil.IntervalFast, "user latency is missing") + + require.Len(t, userLatencies.Report.Users, 2, "only 2 users should be included") + assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P50, float64(0), "expected p50 to be greater than 0") + assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P95, float64(0), "expected p95 to be greater than 0") + assert.Nil(t, userLatencies.Report.Users[1].LatencyMS, "user 2 should have no latency") } func TestTemplateInsights(t *testing.T) { @@ -183,7 +223,11 @@ func TestTemplateInsights(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - templateInsights, err := client.TemplateInsights(ctx) + templateInsights, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: time.Now().Add(-time.Hour), + EndTime: time.Now(), + Interval: codersdk.InsightsReportIntervalDay, + }) require.NoError(t, err) t.Logf("%#v\n", templateInsights) diff --git a/codersdk/insights.go b/codersdk/insights.go index 25652bf1ef6eb..b51b4b7435579 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -12,6 +12,8 @@ import ( "golang.org/x/xerrors" ) +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 @@ -51,11 +53,23 @@ type ConnectionLatency struct { P95 float64 `json:"p95" example:"119.832"` } -func (c *Client) UserLatencyInsights(ctx context.Context) (UserLatencyInsightsResponse, error) { +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", "2020-01-01T00:00:00Z")) - qp = append(qp, fmt.Sprintf("end_time=%s", "2020-01-01T00:00:00Z")) - qp = append(qp, fmt.Sprintf("template_ids=%s", "e0a3f9a0-4b0a-4b6a-8b0a-4b0a4b6a8b0a")) + 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) @@ -138,12 +152,27 @@ type TemplateParameterValue struct { } */ -func (c *Client) TemplateInsights(ctx context.Context) (TemplateInsightsResponse, error) { +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" example:"day"` +} + +func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsRequest) (TemplateInsightsResponse, error) { var qp []string - qp = append(qp, fmt.Sprintf("start_time=%s", "2020-01-01T00:00:00Z")) - qp = append(qp, fmt.Sprintf("end_time=%s", "2020-01-01T00:00:00Z")) - qp = append(qp, fmt.Sprintf("interval=%s", InsightsReportIntervalDay)) - qp = append(qp, fmt.Sprintf("template_ids=%s", "e0a3f9a0-4b0a-4b6a-8b0a-4b0a4b6a8b0a")) + 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) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8b7604a28d514..f53a4f417b891 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -939,6 +939,14 @@ export interface TemplateInsightsReport { 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 @@ -1157,6 +1165,13 @@ export interface UserLatencyInsightsReport { 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 ddc86062fbebc913cd18f49cf0cd40a0a63002a4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 20 Jul 2023 19:59:32 +0000 Subject: [PATCH 10/27] add test for template insights --- coderd/database/dbfake/dbfake.go | 121 +++++++++++++++++++++++++-- coderd/database/queries.sql.go | 40 +++++---- coderd/database/queries/insights.sql | 32 ++++--- coderd/insights.go | 9 +- coderd/insights_test.go | 102 ++++++++++++++++++---- 5 files changed, 249 insertions(+), 55 deletions(-) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 91a33d0cae02f..5e4231bb85480 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -1917,22 +1917,127 @@ func (q *FakeQuerier) GetTemplateDAUs(_ context.Context, arg database.GetTemplat return rs, nil } -func (q *FakeQuerier) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { +func (q *FakeQuerier) GetTemplateDailyInsights(_ context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { return nil, err } - panic("not implemented") + 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(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { +func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { return database.GetTemplateInsightsRow{}, err } - panic("not implemented") + 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) { @@ -2182,7 +2287,7 @@ func (q *FakeQuerier) GetUserCount(_ context.Context) (int64, error) { return existing, nil } -func (q *FakeQuerier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { +func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { return nil, err @@ -2222,9 +2327,9 @@ func (q *FakeQuerier) GetUserLatencyInsights(ctx context.Context, arg database.G var rows []database.GetUserLatencyInsightsRow for userID, latencies := range latenciesByUserID { sort.Float64s(latencies) - templateSet := seenTemplatesByUserID[userID] - templateIDs := make([]uuid.UUID, 0, len(templateSet)) - for templateID := range templateSet { + 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 { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 76a050eb9fba9..19611f80e23a7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1389,18 +1389,24 @@ WITH d AS ( 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_) - WHERE - was.connection_count > 0 + 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, - COUNT(DISTINCT user_id) AS active_users, - array_agg(DISTINCT template_id)::uuid[] AS template_ids + 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_ ` @@ -1414,8 +1420,8 @@ type GetTemplateDailyInsightsParams struct { type GetTemplateDailyInsightsRow struct { StartTime time.Time `db:"start_time" json:"start_time"` EndTime time.Time `db:"end_time" json:"end_time"` - ActiveUsers int64 `db:"active_users" json:"active_users"` TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` + ActiveUsers int64 `db:"active_users" json:"active_users"` } func (q *sqlQuerier) GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) { @@ -1430,8 +1436,8 @@ func (q *sqlQuerier) GetTemplateDailyInsights(ctx context.Context, arg GetTempla if err := rows.Scan( &i.StartTime, &i.EndTime, - &i.ActiveUsers, pq.Array(&i.TemplateIDs), + &i.ActiveUsers, ); err != nil { return nil, err } @@ -1466,21 +1472,27 @@ WITH d AS ( 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_) - WHERE - was.connection_count > 0 + 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(array_agg(DISTINCT template_id), '{}')::uuid[] AS template_ids, 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, unnest(template_ids) as template_id +FROM usage_by_user ` type GetTemplateInsightsParams struct { @@ -1490,8 +1502,8 @@ type GetTemplateInsightsParams struct { } type GetTemplateInsightsRow struct { - ActiveUsers int64 `db:"active_users" json:"active_users"` 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"` @@ -1504,8 +1516,8 @@ func (q *sqlQuerier) GetTemplateInsights(ctx context.Context, arg GetTemplateIns row := q.db.QueryRowContext(ctx, getTemplateInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs)) var i GetTemplateInsightsRow err := row.Scan( - &i.ActiveUsers, pq.Array(&i.TemplateIDs), + &i.ActiveUsers, &i.UsageVscodeSeconds, &i.UsageJetbrainsSeconds, &i.UsageReconnectingPtySeconds, diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 3c0d90048ce38..a25d24ba92d88 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -38,21 +38,27 @@ WITH d AS ( 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_) - WHERE - was.connection_count > 0 + 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(array_agg(DISTINCT template_id), '{}')::uuid[] AS template_ids, 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, unnest(template_ids) as template_id; +FROM usage_by_user; -- name: GetTemplateDailyInsights :many WITH d AS ( @@ -69,17 +75,23 @@ WITH d AS ( 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_) - WHERE - was.connection_count > 0 + 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, - COUNT(DISTINCT user_id) AS active_users, - array_agg(DISTINCT template_id)::uuid[] AS template_ids + 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/insights.go b/coderd/insights.go index 282c73333e5a9..8a02c75179a4d 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -129,7 +129,6 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { for _, user := range users { userLatency, ok := usersWithLatencyByID[user.ID] if !ok { - // TODO(mafredri): Other cases? // We only include deleted/inactive users if they were // active as part of the requested timeframe. if user.Deleted || user.Status != database.UserStatusActive { @@ -262,7 +261,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { ActiveUsers: usage.ActiveUsers, AppsUsage: []codersdk.TemplateAppUsage{ { - TemplateIDs: usage.TemplateIDs, // TODO(mafredri): Update query to return template IDs/app? + TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, DisplayName: "Visual Studio Code", Slug: "vscode", @@ -270,7 +269,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { Seconds: usage.UsageVscodeSeconds, }, { - TemplateIDs: usage.TemplateIDs, // TODO(mafredri): Update query to return template IDs/app? + TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, DisplayName: "JetBrains", Slug: "jetbrains", @@ -278,7 +277,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { Seconds: usage.UsageJetbrainsSeconds, }, { - TemplateIDs: usage.TemplateIDs, // TODO(mafredri): Update query to return template IDs/app? + TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, DisplayName: "Web Terminal", Slug: "reconnecting-pty", @@ -286,7 +285,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { Seconds: usage.UsageReconnectingPtySeconds, }, { - TemplateIDs: usage.TemplateIDs, // TODO(mafredri): Update query to return template IDs/app? + TemplateIDs: usage.TemplateIDs, Type: codersdk.TemplateAppsTypeBuiltin, DisplayName: "SSH", Slug: "ssh", diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 22ce150a3597d..102a8378b3ac9 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -8,6 +8,7 @@ import ( "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" @@ -106,9 +107,8 @@ func TestUserLatencyInsights(t *testing.T) { logger := slogtest.Make(t, nil) client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - AgentStatsRefreshInterval: time.Millisecond * 100, - MetricsCacheRefreshInterval: time.Millisecond * 100, + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, }) user := coderdtest.CreateFirstUser(t, client) @@ -179,21 +179,21 @@ func TestUserLatencyInsights(t *testing.T) { return userLatencies.Report.Users[0].LatencyMS != nil }, testutil.WaitShort, testutil.IntervalFast, "user latency is missing") - require.Len(t, userLatencies.Report.Users, 2, "only 2 users should be included") - assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P50, float64(0), "expected p50 to be greater than 0") - assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P95, float64(0), "expected p95 to be greater than 0") - assert.Nil(t, userLatencies.Report.Users[1].LatencyMS, "user 2 should have no latency") + require.Len(t, userLatencies.Report.Users, 2, "want only 2 users") + 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") + assert.Nil(t, userLatencies.Report.Users[1].LatencyMS, "want user 2 to have no latency") } func TestTemplateInsights(t *testing.T) { t.Parallel() logger := slogtest.Make(t, nil) - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - AgentStatsRefreshInterval: time.Millisecond * 100, - MetricsCacheRefreshInterval: time.Millisecond * 100, - }) + opts := &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, + } + client := coderdtest.New(t, opts) user := coderdtest.CreateFirstUser(t, client) authToken := uuid.NewString() @@ -218,17 +218,83 @@ func TestTemplateInsights(t *testing.T) { defer func() { _ = agentCloser.Close() }() - _ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + y, m, d := time.Now().Date() + today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - templateInsights, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ - StartTime: time.Now().Add(-time.Hour), - EndTime: time.Now(), - Interval: codersdk.InsightsReportIntervalDay, + 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() + + // Keep SSH session open for long enough to generate insights. + err = sess.Start("sleep 5") + require.NoError(t, err) + + 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().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") - t.Logf("%#v\n", templateInsights) + _ = 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") } From 2c8e3119e4b4f089a6972409261655dbf1e893d0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 20 Jul 2023 20:25:15 +0000 Subject: [PATCH 11/27] verify same timezone for start/end --- coderd/insights.go | 16 +++++++++++++++ coderd/insights_internal_test.go | 34 ++++++++++++++++++++------------ 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/coderd/insights.go b/coderd/insights.go index 8a02c75179a4d..3f029a52b8788 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -389,6 +389,22 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s } *qp.dest = t } + if startTime.Location().String() != endTime.Location().String() { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: "start_time", + Detail: fmt.Sprintf("Query param %q must have the same timezone as %q", "start_time", "end_time"), + }, + { + Field: "end_time", + Detail: fmt.Sprintf("Query param %q must have the same timezone as %q", "end_time", "start_time"), + }, + }, + }) + return time.Time{}, time.Time{}, false + } if endTime.Before(startTime) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Query parameter has invalid value.", diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go index 69f9a1dbe4ec6..6de9f60478a26 100644 --- a/coderd/insights_internal_test.go +++ b/coderd/insights_internal_test.go @@ -15,7 +15,7 @@ import ( func Test_parseInsightsStartAndEndTime(t *testing.T) { t.Parallel() - format := codersdk.InsightsTimeLayout + layout := codersdk.InsightsTimeLayout now := time.Now().UTC() y, m, d := now.Date() today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) @@ -49,8 +49,8 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { { name: "Today", args: args{ - startTime: today.Format(format), - endTime: thisHour.Format(format), + 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), @@ -59,16 +59,16 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { { name: "Today with minutes and seconds", args: args{ - startTime: today.Format(format), - endTime: thisHour.Add(time.Minute + time.Second).Format(format), + 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(format), - endTime: thisHourRoundUp.Format(format), + 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), @@ -84,6 +84,14 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, helsinki), wantOk: true, }, + { + name: "Mixed timezone week", + args: args{ + startTime: "2023-07-10T00:00:00Z", + endTime: "2023-07-17T00:00:00+03:00", + }, + wantOk: false, + }, { name: "Bad format", args: args{ @@ -95,24 +103,24 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { { name: "Zero time", args: args{ - startTime: (time.Time{}).Format(format), - endTime: (time.Time{}).Format(format), + 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(format), - endTime: today.AddDate(0, 0, 2).Format(format), + 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(format), - endTime: today.AddDate(0, 0, -1).Format(format), + startTime: today.Format(layout), + endTime: today.AddDate(0, 0, -1).Format(layout), }, wantOk: false, }, From 917997be58490b72929101ee6eb97f3dd7ab467a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 08:42:46 +0000 Subject: [PATCH 12/27] fix typo in query comment and improve comments --- coderd/database/querier.go | 10 +++++++++- coderd/database/queries.sql.go | 10 +++++++++- coderd/database/queries/insights.sql | 10 +++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 86e7c2944c5fe..bbd374fef2c48 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -105,8 +105,12 @@ 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 garnularity of 5 minutes where if a session/app was + // 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) @@ -123,6 +127,10 @@ 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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index dc0d89740a995..f8fb5ff4d3a7e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1425,6 +1425,10 @@ type GetTemplateDailyInsightsRow struct { 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 { @@ -1511,7 +1515,7 @@ type GetTemplateInsightsRow struct { UsageSshSeconds int64 `db:"usage_ssh_seconds" json:"usage_ssh_seconds"` } -// GetTemplateInsights has a garnularity of 5 minutes where if a session/app was +// 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)) @@ -1560,6 +1564,10 @@ type GetUserLatencyInsightsRow struct { 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 { diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index a25d24ba92d88..c3dad57d2d673 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -1,4 +1,8 @@ -- 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, @@ -17,7 +21,7 @@ GROUP BY workspace_agent_stats.user_id, users.username ORDER BY user_id ASC; -- name: GetTemplateInsights :one --- GetTemplateInsights has a garnularity of 5 minutes where if a session/app was +-- 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 @@ -61,6 +65,10 @@ SELECT 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 From e5b96d4e833f82759b981a0ab415cae9322102e7 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 08:47:44 +0000 Subject: [PATCH 13/27] fix generic internal server error --- coderd/insights.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/coderd/insights.go b/coderd/insights.go index 3f029a52b8788..383896787822f 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -97,7 +97,10 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { TemplateIDs: templateIDs, }) if err != nil { - httpapi.InternalServerError(rw, err) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching user latency.", + Detail: err.Error(), + }) return } @@ -105,7 +108,10 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { // latency data. users, err := api.Database.GetUsers(ctx, database.GetUsersParams{}) if err != nil { - httpapi.InternalServerError(rw, err) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching users.", + Detail: err.Error(), + }) return } @@ -238,7 +244,10 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return nil }, nil) if err != nil { - httpapi.InternalServerError(rw, err) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template insights.", + Detail: err.Error(), + }) return } From 87b1b90f4abd03c26833410a1e64da90872b4979 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 08:54:52 +0000 Subject: [PATCH 14/27] s/greater/after/ --- coderd/insights.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/insights.go b/coderd/insights.go index 383896787822f..2403f80c535bf 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -420,7 +420,7 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s Validations: []codersdk.ValidationError{ { Field: "end_time", - Detail: fmt.Sprintf("Query param %q must be greater than %q", "end_time", "start_time"), + Detail: fmt.Sprintf("Query param %q must be after than %q", "end_time", "start_time"), }, }, }) From bffc673151f1f5c59c99cb4b85fa81b5ff6059e8 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 09:19:18 +0000 Subject: [PATCH 15/27] unexport insights time layout --- coderd/insights.go | 7 +++++-- coderd/insights_internal_test.go | 6 ++---- codersdk/insights.go | 11 ++++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/coderd/insights.go b/coderd/insights.go index 2403f80c535bf..6164ba49b2846 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -16,6 +16,9 @@ import ( "github.com/coder/coder/codersdk" ) +// Duplicated in codersdk. +const insightsTimeLayout = time.RFC3339 + // @Summary Get deployment DAUs // @ID get-deployment-daus // @Security CoderSessionToken @@ -324,14 +327,14 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s {"start_time", startTimeString, &startTime}, {"end_time", endTimeString, &endTime}, } { - t, err := time.Parse(codersdk.InsightsTimeLayout, qp.value) + 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, codersdk.InsightsTimeLayout, err.Error()), + Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", qp.name, insightsTimeLayout, err.Error()), }, }, }) diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go index 6de9f60478a26..45ac6738c44ca 100644 --- a/coderd/insights_internal_test.go +++ b/coderd/insights_internal_test.go @@ -8,14 +8,12 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/coder/coder/codersdk" ) func Test_parseInsightsStartAndEndTime(t *testing.T) { t.Parallel() - layout := codersdk.InsightsTimeLayout + layout := insightsTimeLayout now := time.Now().UTC() y, m, d := now.Date() today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) @@ -135,11 +133,11 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { // assert.Equal is unable to test location equality, so we // use assert.WithinDuration. + assert.Equal(t, tt.wantOk, gotOk) 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)) - assert.Equal(t, tt.wantOk, gotOk) }) } } diff --git a/codersdk/insights.go b/codersdk/insights.go index b51b4b7435579..84a2f2d955e39 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -12,7 +12,8 @@ import ( "golang.org/x/xerrors" ) -const InsightsTimeLayout = time.RFC3339 +// 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. @@ -61,8 +62,8 @@ type UserLatencyInsightsRequest struct { 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))) + 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 { @@ -161,8 +162,8 @@ type TemplateInsightsRequest struct { 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))) + 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 { From 056918ec969ff82748a07401ad7e86745af5626d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 09:24:13 +0000 Subject: [PATCH 16/27] remove interval none --- coderd/apidoc/docs.go | 9 +-------- coderd/apidoc/swagger.json | 14 +++----------- coderd/insights.go | 8 ++++---- codersdk/insights.go | 7 +++---- docs/api/schemas.md | 9 ++++----- site/src/api/typesGenerated.ts | 4 ++-- 6 files changed, 17 insertions(+), 34 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 365a9f6dab295..df9090b878d1e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8106,11 +8106,9 @@ const docTemplate = `{ "codersdk.InsightsReportInterval": { "type": "string", "enum": [ - "none", "day" ], "x-enum-varnames": [ - "InsightsReportIntervalNone", "InsightsReportIntervalDay" ] }, @@ -9289,12 +9287,7 @@ const docTemplate = `{ "format": "date-time" }, "interval": { - "allOf": [ - { - "$ref": "#/definitions/codersdk.InsightsReportInterval" - } - ], - "example": "day" + "$ref": "#/definitions/codersdk.InsightsReportInterval" }, "start_time": { "type": "string", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index eebbf9ab64c2d..768a8d7be1166 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7277,11 +7277,8 @@ }, "codersdk.InsightsReportInterval": { "type": "string", - "enum": ["none", "day"], - "x-enum-varnames": [ - "InsightsReportIntervalNone", - "InsightsReportIntervalDay" - ] + "enum": ["day"], + "x-enum-varnames": ["InsightsReportIntervalDay"] }, "codersdk.IssueReconnectingPTYSignedTokenRequest": { "type": "object", @@ -8389,12 +8386,7 @@ "format": "date-time" }, "interval": { - "allOf": [ - { - "$ref": "#/definitions/codersdk.InsightsReportInterval" - } - ], - "example": "day" + "$ref": "#/definitions/codersdk.InsightsReportInterval" }, "start_time": { "type": "string", diff --git a/coderd/insights.go b/coderd/insights.go index 6164ba49b2846..3c1c31c26d764 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -196,7 +196,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { // to parse the time ourselves. startTimeString = p.String(vals, "", "start_time") endTimeString = p.String(vals, "", "end_time") - intervalString = p.String(vals, string(codersdk.InsightsReportIntervalNone), "interval") + intervalString = p.String(vals, "", "interval") templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids") ) p.ErrorExcessParams(vals) @@ -224,7 +224,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { err := api.Database.InTx(func(db database.Store) error { var err error - if interval != codersdk.InsightsReportIntervalNone { + if interval != "" { dailyUsage, err = db.GetTemplateDailyInsights(ctx, database.GetTemplateDailyInsightsParams{ StartTime: startTime, EndTime: endTime, @@ -435,7 +435,7 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s func verifyInsightsInterval(ctx context.Context, rw http.ResponseWriter, intervalString string) (codersdk.InsightsReportInterval, bool) { switch v := codersdk.InsightsReportInterval(intervalString); v { - case codersdk.InsightsReportIntervalDay, codersdk.InsightsReportIntervalNone: + case codersdk.InsightsReportIntervalDay, "": return v, true default: httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -443,7 +443,7 @@ func verifyInsightsInterval(ctx context.Context, rw http.ResponseWriter, interva Validations: []codersdk.ValidationError{ { Field: "interval", - Detail: fmt.Sprintf("must be one of %v", []codersdk.InsightsReportInterval{codersdk.InsightsReportIntervalNone, codersdk.InsightsReportIntervalDay}), + Detail: fmt.Sprintf("must be one of %v", []codersdk.InsightsReportInterval{codersdk.InsightsReportIntervalDay}), }, }, }) diff --git a/codersdk/insights.go b/codersdk/insights.go index 84a2f2d955e39..dd65ef0ce3a43 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -21,8 +21,7 @@ type InsightsReportInterval string // InsightsReportInterval enums. const ( - InsightsReportIntervalNone InsightsReportInterval = "none" - InsightsReportIntervalDay InsightsReportInterval = "day" + InsightsReportIntervalDay InsightsReportInterval = "day" ) // UserLatencyInsightsResponse is the response from the user latency insights @@ -109,7 +108,7 @@ 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" example:"day"` + Interval InsightsReportInterval `json:"interval"` ActiveUsers int64 `json:"active_users" example:"14"` } @@ -157,7 +156,7 @@ 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" example:"day"` + Interval InsightsReportInterval `json:"interval"` } func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsRequest) (TemplateInsightsResponse, error) { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index bed806f84ce2c..355e388393676 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2914,17 +2914,16 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ## codersdk.InsightsReportInterval ```json -"none" +"day" ``` ### Properties #### Enumerated Values -| Value | -| ------ | -| `none` | -| `day` | +| Value | +| ----- | +| `day` | ## codersdk.IssueReconnectingPTYSignedTokenRequest diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 62d88adb12669..4423eb6773189 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1590,8 +1590,8 @@ export const GitProviders: GitProvider[] = [ ] // From codersdk/insights.go -export type InsightsReportInterval = "day" | "none" -export const InsightsReportIntervals: InsightsReportInterval[] = ["day", "none"] +export type InsightsReportInterval = "day" +export const InsightsReportIntervals: InsightsReportInterval[] = ["day"] // From codersdk/provisionerdaemons.go export type JobErrorCode = From d5a98655f8d8cfa0f58d379814374e6af1907991 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 09:28:01 +0000 Subject: [PATCH 17/27] remove mixed tz restrictions, test DST --- coderd/insights.go | 16 ---------------- coderd/insights_internal_test.go | 15 ++++++++++----- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/coderd/insights.go b/coderd/insights.go index 3c1c31c26d764..ef1b7f129f75d 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -401,22 +401,6 @@ func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, s } *qp.dest = t } - if startTime.Location().String() != endTime.Location().String() { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Query parameter has invalid value.", - Validations: []codersdk.ValidationError{ - { - Field: "start_time", - Detail: fmt.Sprintf("Query param %q must have the same timezone as %q", "start_time", "end_time"), - }, - { - Field: "end_time", - Detail: fmt.Sprintf("Query param %q must have the same timezone as %q", "end_time", "start_time"), - }, - }, - }) - return time.Time{}, time.Time{}, false - } if endTime.Before(startTime) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Query parameter has invalid value.", diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go index 45ac6738c44ca..9a5c629052337 100644 --- a/coderd/insights_internal_test.go +++ b/coderd/insights_internal_test.go @@ -83,12 +83,14 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { wantOk: true, }, { - name: "Mixed timezone week", + name: "Daylight savings time", args: args{ - startTime: "2023-07-10T00:00:00Z", - endTime: "2023-07-17T00:00:00+03:00", + startTime: "2023-03-26T00:00:00+02:00", + endTime: "2023-03-27T00:00:00+03:00", }, - wantOk: false, + 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", @@ -133,7 +135,10 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { // assert.Equal is unable to test location equality, so we // use assert.WithinDuration. - assert.Equal(t, tt.wantOk, gotOk) + if !assert.Equal(t, tt.wantOk, gotOk) { + t.Log("Status: ", rw.Result().StatusCode) + t.Log("Body: ", rw.Body.String()) + } assert.WithinDuration(t, tt.wantStartTime, gotStartTime, 0) assert.True(t, tt.wantStartTime.Equal(gotStartTime)) assert.WithinDuration(t, tt.wantEndTime, gotEndTime, 0) From 088620eea33766c62d7206e0205355723fe59d13 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 09:30:17 +0000 Subject: [PATCH 18/27] fix lint --- coderd/insights_internal_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go index 9a5c629052337..6601bd912de9c 100644 --- a/coderd/insights_internal_test.go +++ b/coderd/insights_internal_test.go @@ -136,6 +136,7 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { // assert.Equal is unable to test location equality, so we // use assert.WithinDuration. if !assert.Equal(t, tt.wantOk, gotOk) { + //nolint:bodyclose t.Log("Status: ", rw.Result().StatusCode) t.Log("Body: ", rw.Body.String()) } From 397cc7ab088cde3a010f4dbacb635464300f3131 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 09:35:20 +0000 Subject: [PATCH 19/27] add fixmes to dbauthz --- coderd/database/dbauthz/dbauthz.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 47d1fe4fa7287..260ad6f08cae2 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1174,6 +1174,7 @@ func (q *querier) GetTemplateDAUs(ctx context.Context, arg database.GetTemplateD } 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 } @@ -1181,6 +1182,7 @@ func (q *querier) GetTemplateDailyInsights(ctx context.Context, arg database.Get } 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 } From ebacfe100c8b5051387ac27de78f09df3bc2345a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 09:38:14 +0000 Subject: [PATCH 20/27] improve comment about assert equality --- coderd/insights_internal_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go index 6601bd912de9c..cb1b13e97f7e3 100644 --- a/coderd/insights_internal_test.go +++ b/coderd/insights_internal_test.go @@ -133,8 +133,9 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { rw := httptest.NewRecorder() gotStartTime, gotEndTime, gotOk := parseInsightsStartAndEndTime(context.Background(), rw, tt.args.startTime, tt.args.endTime) - // assert.Equal is unable to test location equality, so we - // use assert.WithinDuration. + // 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. if !assert.Equal(t, tt.wantOk, gotOk) { //nolint:bodyclose t.Log("Status: ", rw.Result().StatusCode) From cd07478dc684987bda24551986f7f4fc94e5dec8 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 09:42:42 +0000 Subject: [PATCH 21/27] replace sleep with cat --- coderd/insights_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 102a8378b3ac9..14c79b3c64e9f 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "io" "testing" "time" @@ -241,7 +242,11 @@ func TestTemplateInsights(t *testing.T) { defer sess.Close() // Keep SSH session open for long enough to generate insights. - err = sess.Start("sleep 5") + r, w := io.Pipe() + defer r.Close() + defer w.Close() + sess.Stdin = r + err = sess.Start("cat") require.NoError(t, err) rpty, err := client.WorkspaceAgentReconnectingPTY(ctx, codersdk.WorkspaceAgentReconnectingPTYOpts{ From 040390d021773fb604f862663d951dbab230fe57 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 10:03:51 +0000 Subject: [PATCH 22/27] add bad request tests --- coderd/insights_test.go | 57 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 14c79b3c64e9f..fc79133cd482a 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -186,6 +186,31 @@ func TestUserLatencyInsights(t *testing.T) { assert.Nil(t, userLatencies.Report.Users[1].LatencyMS, "want user 2 to have no latency") } +func TestUserLatencyInsights_BadRequest(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + + y, m, d := time.Now().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() @@ -303,3 +328,35 @@ func TestTemplateInsights(t *testing.T) { 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().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") +} From b1de87f96908f0de50a8363f0342606e96f0e766 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 10:25:44 +0000 Subject: [PATCH 23/27] fix comment placement --- coderd/insights_internal_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/coderd/insights_internal_test.go b/coderd/insights_internal_test.go index cb1b13e97f7e3..1f1d7a7bed6e6 100644 --- a/coderd/insights_internal_test.go +++ b/coderd/insights_internal_test.go @@ -133,14 +133,14 @@ func Test_parseInsightsStartAndEndTime(t *testing.T) { rw := httptest.NewRecorder() gotStartTime, gotEndTime, gotOk := parseInsightsStartAndEndTime(context.Background(), rw, tt.args.startTime, tt.args.endTime) - // 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. 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) From 5588e399ace9d157e4280fd17cbde938976f5d09 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 10:36:43 +0000 Subject: [PATCH 24/27] create convert function for builtin apps --- coderd/insights.go | 74 +++++++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/coderd/insights.go b/coderd/insights.go index ef1b7f129f75d..1501af5ea8260 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -271,46 +271,52 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { EndTime: endTime, TemplateIDs: usage.TemplateIDs, ActiveUsers: usage.ActiveUsers, - AppsUsage: []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, - }, - }, + AppsUsage: convertTemplateInsightsBuiltinApps(usage), }, IntervalReports: intervalReports, } 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 From dafbba1d0806a2ba6eeebe7eed00dae478612155 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 10:38:13 +0000 Subject: [PATCH 25/27] move interval loop --- coderd/insights.go | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/coderd/insights.go b/coderd/insights.go index 1501af5ea8260..795996549daa1 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -254,17 +254,6 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return } - intervalReports := []codersdk.TemplateInsightsIntervalReport{} - for _, row := range dailyUsage { - intervalReports = append(intervalReports, codersdk.TemplateInsightsIntervalReport{ - StartTime: row.StartTime, - EndTime: row.EndTime, - Interval: interval, - TemplateIDs: row.TemplateIDs, - ActiveUsers: row.ActiveUsers, - }) - } - resp := codersdk.TemplateInsightsResponse{ Report: codersdk.TemplateInsightsReport{ StartTime: startTime, @@ -273,7 +262,16 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { ActiveUsers: usage.ActiveUsers, AppsUsage: convertTemplateInsightsBuiltinApps(usage), }, - IntervalReports: intervalReports, + 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) } From fc2157d7ee0e777dd247ea661607becaeb384b9a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 11:46:47 +0000 Subject: [PATCH 26/27] remove all users, improve test comments --- coderd/insights.go | 37 +++--------------------- coderd/insights_test.go | 52 ++++++++++++++++++---------------- codersdk/insights.go | 8 +++--- site/src/api/typesGenerated.ts | 2 +- 4 files changed, 36 insertions(+), 63 deletions(-) diff --git a/coderd/insights.go b/coderd/insights.go index 795996549daa1..3da60a13bfe84 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -107,50 +107,21 @@ func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) { return } - // Fetch all users so that we can still include users that have no - // latency data. - users, err := api.Database.GetUsers(ctx, database.GetUsersParams{}) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching users.", - Detail: err.Error(), - }) - return - } - templateIDSet := make(map[uuid.UUID]struct{}) - usersWithLatencyByID := make(map[uuid.UUID]codersdk.UserLatency) + userLatencies := make([]codersdk.UserLatency, 0, len(rows)) for _, row := range rows { for _, templateID := range row.TemplateIDs { templateIDSet[templateID] = struct{}{} } - usersWithLatencyByID[row.UserID] = codersdk.UserLatency{ + userLatencies = append(userLatencies, codersdk.UserLatency{ TemplateIDs: row.TemplateIDs, UserID: row.UserID, Username: row.Username, - LatencyMS: &codersdk.ConnectionLatency{ + LatencyMS: codersdk.ConnectionLatency{ P50: row.WorkspaceConnectionLatency50, P95: row.WorkspaceConnectionLatency95, }, - } - } - userLatencies := []codersdk.UserLatency{} - for _, user := range users { - userLatency, ok := usersWithLatencyByID[user.ID] - if !ok { - // We only include deleted/inactive users if they were - // active as part of the requested timeframe. - if user.Deleted || user.Status != database.UserStatusActive { - continue - } - - userLatency = codersdk.UserLatency{ - TemplateIDs: []uuid.UUID{}, - UserID: user.ID, - Username: user.Username, - } - } - userLatencies = append(userLatencies, userLatency) + }) } // TemplateIDs that contributed to the data. diff --git a/coderd/insights_test.go b/coderd/insights_test.go index fc79133cd482a..5fef59cc8647a 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -112,8 +112,10 @@ func TestUserLatencyInsights(t *testing.T) { 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) - _, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + _, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID) authToken := uuid.NewString() version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ Parse: echo.ParseComplete, @@ -127,6 +129,7 @@ func TestUserLatencyInsights(t *testing.T) { 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{ @@ -138,9 +141,15 @@ func TestUserLatencyInsights(t *testing.T) { }() 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"), }) @@ -151,19 +160,6 @@ func TestUserLatencyInsights(t *testing.T) { require.NoError(t, err) defer sshConn.Close() - // Create users that will not appear in the report. - _, user3 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) - _, user4 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) - _, err = client.UpdateUserStatus(ctx, user3.Username, codersdk.UserStatusSuspended) - require.NoError(t, err) - err = client.DeleteUser(ctx, user4.ID) - require.NoError(t, err) - - y, m, d := time.Now().Date() - today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC) - - _ = sshConn.Close() - var userLatencies codersdk.UserLatencyInsightsResponse require.Eventuallyf(t, func() bool { userLatencies, err = client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{ @@ -174,16 +170,16 @@ func TestUserLatencyInsights(t *testing.T) { if !assert.NoError(t, err) { return false } - if userLatencies.Report.Users[0].UserID == user2.ID { - userLatencies.Report.Users[0], userLatencies.Report.Users[1] = userLatencies.Report.Users[1], userLatencies.Report.Users[0] - } - return userLatencies.Report.Users[0].LatencyMS != nil + return len(userLatencies.Report.Users) > 0 && userLatencies.Report.Users[0].LatencyMS.P50 > 0 }, testutil.WaitShort, testutil.IntervalFast, "user latency is missing") - require.Len(t, userLatencies.Report.Users, 2, "want only 2 users") + // We got our latency data, close the connection. + _ = 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") - assert.Nil(t, userLatencies.Report.Users[1].LatencyMS, "want user 2 to have no latency") } func TestUserLatencyInsights_BadRequest(t *testing.T) { @@ -192,7 +188,7 @@ func TestUserLatencyInsights_BadRequest(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{}) _ = coderdtest.CreateFirstUser(t, client) - y, m, d := time.Now().Date() + 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) @@ -235,6 +231,7 @@ func TestTemplateInsights(t *testing.T) { 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{ @@ -246,12 +243,15 @@ func TestTemplateInsights(t *testing.T) { }() resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) - y, m, d := time.Now().Date() + // 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"), }) @@ -262,11 +262,11 @@ func TestTemplateInsights(t *testing.T) { 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() - // Keep SSH session open for long enough to generate insights. r, w := io.Pipe() defer r.Close() defer w.Close() @@ -274,6 +274,7 @@ func TestTemplateInsights(t *testing.T) { 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(), @@ -289,7 +290,7 @@ func TestTemplateInsights(t *testing.T) { return func() bool { req = codersdk.TemplateInsightsRequest{ StartTime: today, - EndTime: time.Now().Truncate(time.Hour).Add(time.Hour), + EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), Interval: codersdk.InsightsReportIntervalDay, } resp, err = client.TemplateInsights(ctx, req) @@ -308,6 +309,7 @@ func TestTemplateInsights(t *testing.T) { 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() @@ -335,7 +337,7 @@ func TestTemplateInsights_BadRequest(t *testing.T) { client := coderdtest.New(t, &coderdtest.Options{}) _ = coderdtest.CreateFirstUser(t, client) - y, m, d := time.Now().Date() + 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) diff --git a/codersdk/insights.go b/codersdk/insights.go index dd65ef0ce3a43..fb1c582c686c8 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -41,10 +41,10 @@ type UserLatencyInsightsReport struct { // 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"` + 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. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4423eb6773189..9f74ef107ddfa 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1169,7 +1169,7 @@ export interface UserLatency { readonly template_ids: string[] readonly user_id: string readonly username: string - readonly latency_ms?: ConnectionLatency + readonly latency_ms: ConnectionLatency } // From codersdk/insights.go From b03cc29b6ed12fff7f55840d22746a988d1c4127 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 21 Jul 2023 17:14:53 +0000 Subject: [PATCH 27/27] fix windows test? --- coderd/insights_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 5fef59cc8647a..613a2c07ec428 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -160,6 +160,17 @@ func TestUserLatencyInsights(t *testing.T) { 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{ @@ -174,6 +185,7 @@ func TestUserLatencyInsights(t *testing.T) { }, 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")