From 97e47001fdfac4c8394bebfade9c0d6b79097a98 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 11 Jun 2024 18:03:58 +0000 Subject: [PATCH 01/12] chore: accept payload on workspace usage route --- cli/portforward.go | 2 +- coderd/workspaces.go | 90 +++++++++++++++++++++++++++ coderd/workspacestats/tracker_test.go | 20 +++--- codersdk/deployment.go | 1 + codersdk/workspaces.go | 33 ++++++++-- 5 files changed, 130 insertions(+), 16 deletions(-) diff --git a/cli/portforward.go b/cli/portforward.go index 4c0b1d772eecc..7ceedd2c88986 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -137,7 +137,7 @@ func (r *RootCmd) portForward() *serpent.Command { listeners[i] = l } - stopUpdating := client.UpdateWorkspaceUsageContext(ctx, workspace.ID) + stopUpdating := client.UpdateWorkspaceUsageContext(ctx, workspace.ID, nil) // Wait for the context to be canceled or for a signal and close // all listeners. diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 1b3f076e8f4bf..a2a1a4b3ac4bd 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strconv" "time" @@ -15,6 +16,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -1106,6 +1108,7 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { // @Security CoderSessionToken // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) +// @Param request body codersdk.PostWorkspaceUsageRequest false "Post workspace usage request" // @Success 204 // @Router /workspaces/{workspace}/usage [post] func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { @@ -1116,6 +1119,93 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { } api.statsReporter.TrackUsage(workspace.ID) + + if !api.Experiments.Enabled(codersdk.ExperimentWorkspaceUsage) { + // Continue previous behavior if the experiment is not enabled. + rw.WriteHeader(http.StatusNoContent) + return + } + + ctx := r.Context() + var req codersdk.PostWorkspaceUsageRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if req.AgentID == uuid.Nil && req.AppName == "" { + // Continue previous behavior if body is empty. + rw.WriteHeader(http.StatusNoContent) + return + } + if req.AgentID == uuid.Nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Validations: []codersdk.ValidationError{{ + Field: "agent_id", + Detail: "must be set when app_name is set", + }}, + }) + return + } + if req.AppName == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Validations: []codersdk.ValidationError{{ + Field: "app_name", + Detail: "must be set when agent_id is set", + }}, + }) + return + } + if !slices.Contains(codersdk.AllowedAppNames, req.AppName) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Validations: []codersdk.ValidationError{{ + Field: "app_name", + Detail: fmt.Sprintf("must be one of %v", codersdk.AllowedAppNames), + }}, + }) + return + } + + stat := &proto.Stats{} + switch req.AppName { + case codersdk.UsageAppNameVscode: + stat.SessionCountVscode = 1 + case codersdk.UsageAppNameJetbrains: + stat.SessionCountJetbrains = 1 + case codersdk.UsageAppNameReconnectingPty: + stat.SessionCountReconnectingPty = 1 + case codersdk.UsageAppNameSsh: + stat.SessionCountSsh = 1 + default: + // This means the app_name is not in the list of allowed app names. + httpapi.InternalServerError(rw, xerrors.Errorf("unknown app_name %q", req.AppName)) + return + } + + agent, err := api.Database.GetWorkspaceAgentByID(ctx, req.AgentID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.InternalServerError(rw, err) + return + } + + template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + err = api.statsReporter.ReportAgentStats(ctx, dbtime.Now(), workspace, agent, template.Name, stat) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/workspacestats/tracker_test.go b/coderd/workspacestats/tracker_test.go index 99e9f9503b645..6a67e3da890a6 100644 --- a/coderd/workspacestats/tracker_test.go +++ b/coderd/workspacestats/tracker_test.go @@ -158,18 +158,18 @@ func TestTracker_MultipleInstances(t *testing.T) { } // Use client A to update LastUsedAt of the first three - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[0].Workspace.ID)) - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[1].Workspace.ID)) - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[2].Workspace.ID)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[0].Workspace.ID, nil)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[1].Workspace.ID, nil)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[2].Workspace.ID, nil)) // Use client B to update LastUsedAt of the next three - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[3].Workspace.ID)) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[4].Workspace.ID)) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[5].Workspace.ID)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[3].Workspace.ID, nil)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[4].Workspace.ID, nil)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[5].Workspace.ID, nil)) // The next two will have updated from both instances - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[6].Workspace.ID)) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[6].Workspace.ID)) - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[7].Workspace.ID)) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[7].Workspace.ID)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[6].Workspace.ID, nil)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[6].Workspace.ID, nil)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[7].Workspace.ID, nil)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[7].Workspace.ID, nil)) // The last two will not report any usage. // Tick both with different times and wait for both flushes to complete diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 21d33ebc81dc0..b67964d6a985c 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2233,6 +2233,7 @@ const ( ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentMultiOrganization Experiment = "multi-organization" // Requires organization context for interactions, default org is assumed. ExperimentCustomRoles Experiment = "custom-roles" // Allows creating runtime custom roles + ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking ) // ExperimentsAll should include all experiments that are safe for diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 0007e85de8ee4..7969f5b99ccee 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -316,10 +316,31 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx return nil } +type PostWorkspaceUsageRequest struct { + AgentID uuid.UUID `json:"agent_id"` + AppName UsageAppName `json:"app_name"` +} + +type UsageAppName string + +const ( + UsageAppNameVscode UsageAppName = "vscode" + UsageAppNameJetbrains UsageAppName = "jetbrains" + UsageAppNameReconnectingPty UsageAppName = "reconnecting-pty" + UsageAppNameSsh UsageAppName = "ssh" +) + +var AllowedAppNames = []UsageAppName{ + UsageAppNameVscode, + UsageAppNameJetbrains, + UsageAppNameReconnectingPty, + UsageAppNameSsh, +} + // PostWorkspaceUsage marks the workspace as having been used recently. -func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID) error { +func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID, req *PostWorkspaceUsageRequest) error { path := fmt.Sprintf("/api/v2/workspaces/%s/usage", id.String()) - res, err := c.Request(ctx, http.MethodPost, path, nil) + res, err := c.Request(ctx, http.MethodPost, path, req) if err != nil { return xerrors.Errorf("post workspace usage: %w", err) } @@ -334,10 +355,11 @@ func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID) error { // with the given id in the background. // The caller is responsible for calling the returned function to stop the background // process. -func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, id uuid.UUID) func() { +func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, workspaceID uuid.UUID, req *PostWorkspaceUsageRequest) func() { hbCtx, hbCancel := context.WithCancel(ctx) // Perform one initial update - if err := c.PostWorkspaceUsage(hbCtx, id); err != nil { + err := c.PostWorkspaceUsage(hbCtx, workspaceID, req) + if err != nil { c.logger.Warn(ctx, "failed to post workspace usage", slog.Error(err)) } ticker := time.NewTicker(time.Minute) @@ -350,7 +372,8 @@ func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, id uuid.UUID) for { select { case <-ticker.C: - if err := c.PostWorkspaceUsage(hbCtx, id); err != nil { + err := c.PostWorkspaceUsage(hbCtx, workspaceID, req) + if err != nil { c.logger.Warn(ctx, "failed to post workspace usage in background", slog.Error(err)) } case <-hbCtx.Done(): From 61db1c2e0cce3c6be46c82b1a055d2fccdea1d91 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Tue, 11 Jun 2024 18:11:51 +0000 Subject: [PATCH 02/12] make gen --- coderd/apidoc/docs.go | 43 +++++++++++++++++++++++++++++++--- coderd/apidoc/swagger.json | 38 +++++++++++++++++++++++++++--- docs/api/schemas.md | 34 +++++++++++++++++++++++++++ docs/api/workspaces.md | 17 +++++++++++--- site/src/api/typesGenerated.ts | 19 ++++++++++++++- 5 files changed, 141 insertions(+), 10 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81c16ba784798..865534de929a4 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7503,6 +7503,14 @@ const docTemplate = `{ "name": "workspace", "in": "path", "required": true + }, + { + "description": "Post workspace usage request", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/codersdk.PostWorkspaceUsageRequest" + } } ], "responses": { @@ -9200,19 +9208,22 @@ const docTemplate = `{ "example", "auto-fill-parameters", "multi-organization", - "custom-roles" + "custom-roles", + "workspace-usage" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", - "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." + "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed.", + "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking" }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", "ExperimentMultiOrganization", - "ExperimentCustomRoles" + "ExperimentCustomRoles", + "ExperimentWorkspaceUsage" ] }, "codersdk.ExternalAuth": { @@ -10150,6 +10161,17 @@ const docTemplate = `{ } } }, + "codersdk.PostWorkspaceUsageRequest": { + "type": "object", + "properties": { + "agent_id": { + "type": "string" + }, + "app_name": { + "$ref": "#/definitions/codersdk.UsageAppName" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -11902,6 +11924,21 @@ const docTemplate = `{ } } }, + "codersdk.UsageAppName": { + "type": "string", + "enum": [ + "vscode", + "jetbrains", + "reconnecting-pty", + "ssh" + ], + "x-enum-varnames": [ + "UsageAppNameVscode", + "UsageAppNameJetbrains", + "UsageAppNameReconnectingPty", + "UsageAppNameSsh" + ] + }, "codersdk.User": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7859bcb5ded02..1c40f26ce5ff9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6639,6 +6639,14 @@ "name": "workspace", "in": "path", "required": true + }, + { + "description": "Post workspace usage request", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/codersdk.PostWorkspaceUsageRequest" + } } ], "responses": { @@ -8236,19 +8244,22 @@ "example", "auto-fill-parameters", "multi-organization", - "custom-roles" + "custom-roles", + "workspace-usage" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", - "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." + "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed.", + "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking" }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", "ExperimentMultiOrganization", - "ExperimentCustomRoles" + "ExperimentCustomRoles", + "ExperimentWorkspaceUsage" ] }, "codersdk.ExternalAuth": { @@ -9131,6 +9142,17 @@ } } }, + "codersdk.PostWorkspaceUsageRequest": { + "type": "object", + "properties": { + "agent_id": { + "type": "string" + }, + "app_name": { + "$ref": "#/definitions/codersdk.UsageAppName" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -10791,6 +10813,16 @@ } } }, + "codersdk.UsageAppName": { + "type": "string", + "enum": ["vscode", "jetbrains", "reconnecting-pty", "ssh"], + "x-enum-varnames": [ + "UsageAppNameVscode", + "UsageAppNameJetbrains", + "UsageAppNameReconnectingPty", + "UsageAppNameSsh" + ] + }, "codersdk.User": { "type": "object", "required": ["created_at", "email", "id", "username"], diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 348ce54e11ba3..5ef58f5e8b605 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2360,6 +2360,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `auto-fill-parameters` | | `multi-organization` | | `custom-roles` | +| `workspace-usage` | ## codersdk.ExternalAuth @@ -3359,6 +3360,22 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `icon` | string | false | | | | `name` | string | true | | | +## codersdk.PostWorkspaceUsageRequest + +```json +{ + "agent_id": "string", + "app_name": "vscode" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------- | ---------------------------------------------- | -------- | ------------ | ----------- | +| `agent_id` | string | false | | | +| `app_name` | [codersdk.UsageAppName](#codersdkusageappname) | false | | | + ## codersdk.PprofConfig ```json @@ -5227,6 +5244,23 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `share_level` | `authenticated` | | `share_level` | `public` | +## codersdk.UsageAppName + +```json +"vscode" +``` + +### Properties + +#### Enumerated Values + +| Value | +| ------------------ | +| `vscode` | +| `jetbrains` | +| `reconnecting-pty` | +| `ssh` | + ## codersdk.User ```json diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 886f8401f7d7e..f48b83a5a4181 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -1397,16 +1397,27 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/usage \ + -H 'Content-Type: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` `POST /workspaces/{workspace}/usage` +> Body parameter + +```json +{ + "agent_id": "string", + "app_name": "vscode" +} +``` + ### Parameters -| Name | In | Type | Required | Description | -| ----------- | ---- | ------------ | -------- | ------------ | -| `workspace` | path | string(uuid) | true | Workspace ID | +| Name | In | Type | Required | Description | +| ----------- | ---- | ---------------------------------------------------------------------------------- | -------- | ---------------------------- | +| `workspace` | path | string(uuid) | true | Workspace ID | +| `body` | body | [codersdk.PostWorkspaceUsageRequest](schemas.md#codersdkpostworkspaceusagerequest) | false | Post workspace usage request | ### Responses diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a53717e3e0229..312f43dcd5bb4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -841,6 +841,12 @@ export interface PostOAuth2ProviderAppRequest { readonly icon: string; } +// From codersdk/workspaces.go +export interface PostWorkspaceUsageRequest { + readonly agent_id: string; + readonly app_name: UsageAppName; +} + // From codersdk/deployment.go export interface PprofConfig { readonly enable: boolean; @@ -1943,12 +1949,14 @@ export type Experiment = | "auto-fill-parameters" | "custom-roles" | "example" - | "multi-organization"; + | "multi-organization" + | "workspace-usage"; export const Experiments: Experiment[] = [ "auto-fill-parameters", "custom-roles", "example", "multi-organization", + "workspace-usage", ]; // From codersdk/deployment.go @@ -2243,6 +2251,15 @@ export const TemplateVersionWarnings: TemplateVersionWarning[] = [ "UNSUPPORTED_WORKSPACES", ]; +// From codersdk/workspaces.go +export type UsageAppName = "jetbrains" | "reconnecting-pty" | "ssh" | "vscode"; +export const UsageAppNames: UsageAppName[] = [ + "jetbrains", + "reconnecting-pty", + "ssh", + "vscode", +]; + // From codersdk/users.go export type UserStatus = "active" | "dormant" | "suspended"; export const UserStatuses: UserStatus[] = ["active", "dormant", "suspended"]; From c2a9339d407b5a70a72258189f4890dce3bf8541 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 12 Jun 2024 15:21:56 +0000 Subject: [PATCH 03/12] lint and format --- coderd/workspaces.go | 2 +- codersdk/workspaces.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index a2a1a4b3ac4bd..a0910ce0eccc3 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1176,7 +1176,7 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { stat.SessionCountJetbrains = 1 case codersdk.UsageAppNameReconnectingPty: stat.SessionCountReconnectingPty = 1 - case codersdk.UsageAppNameSsh: + case codersdk.UsageAppNameSSH: stat.SessionCountSsh = 1 default: // This means the app_name is not in the list of allowed app names. diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 7969f5b99ccee..280ec41e3563b 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -317,7 +317,7 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx } type PostWorkspaceUsageRequest struct { - AgentID uuid.UUID `json:"agent_id"` + AgentID uuid.UUID `json:"agent_id" format:"uuid"` AppName UsageAppName `json:"app_name"` } @@ -327,14 +327,14 @@ const ( UsageAppNameVscode UsageAppName = "vscode" UsageAppNameJetbrains UsageAppName = "jetbrains" UsageAppNameReconnectingPty UsageAppName = "reconnecting-pty" - UsageAppNameSsh UsageAppName = "ssh" + UsageAppNameSSH UsageAppName = "ssh" ) var AllowedAppNames = []UsageAppName{ UsageAppNameVscode, UsageAppNameJetbrains, UsageAppNameReconnectingPty, - UsageAppNameSsh, + UsageAppNameSSH, } // PostWorkspaceUsage marks the workspace as having been used recently. From cc226eb2371e698c308939d4deddd708b3663ddf Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 12 Jun 2024 16:00:57 +0000 Subject: [PATCH 04/12] accepts --- coderd/apidoc/docs.go | 8 ++++++-- coderd/apidoc/swagger.json | 6 ++++-- coderd/workspaces.go | 1 + docs/api/schemas.md | 2 +- docs/api/workspaces.md | 2 +- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 865534de929a4..23389633f0b87 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7490,6 +7490,9 @@ const docTemplate = `{ "CoderSessionToken": [] } ], + "consumes": [ + "application/json" + ], "tags": [ "Workspaces" ], @@ -10165,7 +10168,8 @@ const docTemplate = `{ "type": "object", "properties": { "agent_id": { - "type": "string" + "type": "string", + "format": "uuid" }, "app_name": { "$ref": "#/definitions/codersdk.UsageAppName" @@ -11936,7 +11940,7 @@ const docTemplate = `{ "UsageAppNameVscode", "UsageAppNameJetbrains", "UsageAppNameReconnectingPty", - "UsageAppNameSsh" + "UsageAppNameSSH" ] }, "codersdk.User": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1c40f26ce5ff9..d6233524067fd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6628,6 +6628,7 @@ "CoderSessionToken": [] } ], + "consumes": ["application/json"], "tags": ["Workspaces"], "summary": "Post Workspace Usage by ID", "operationId": "post-workspace-usage-by-id", @@ -9146,7 +9147,8 @@ "type": "object", "properties": { "agent_id": { - "type": "string" + "type": "string", + "format": "uuid" }, "app_name": { "$ref": "#/definitions/codersdk.UsageAppName" @@ -10820,7 +10822,7 @@ "UsageAppNameVscode", "UsageAppNameJetbrains", "UsageAppNameReconnectingPty", - "UsageAppNameSsh" + "UsageAppNameSSH" ] }, "codersdk.User": { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index a0910ce0eccc3..4c22f679229d4 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1107,6 +1107,7 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { // @ID post-workspace-usage-by-id // @Security CoderSessionToken // @Tags Workspaces +// @Accept json // @Param workspace path string true "Workspace ID" format(uuid) // @Param request body codersdk.PostWorkspaceUsageRequest false "Post workspace usage request" // @Success 204 diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 5ef58f5e8b605..905cb45479b54 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3364,7 +3364,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ```json { - "agent_id": "string", + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", "app_name": "vscode" } ``` diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index f48b83a5a4181..f16d9be857fef 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -1407,7 +1407,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/usage \ ```json { - "agent_id": "string", + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", "app_name": "vscode" } ``` From f954f2b3315854dbbcfc053bf6026bb39a2c50ea Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 12 Jun 2024 17:57:09 +0000 Subject: [PATCH 05/12] tests --- cli/portforward.go | 2 +- coderd/workspaces_test.go | 93 +++++++++++++++++++++++++++ coderd/workspacestats/tracker_test.go | 20 +++--- codersdk/workspaces.go | 4 +- 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/cli/portforward.go b/cli/portforward.go index 7ceedd2c88986..aebad3a5b2da5 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -137,7 +137,7 @@ func (r *RootCmd) portForward() *serpent.Command { listeners[i] = l } - stopUpdating := client.UpdateWorkspaceUsageContext(ctx, workspace.ID, nil) + stopUpdating := client.UpdateWorkspaceUsageContext(ctx, workspace.ID, codersdk.PostWorkspaceUsageRequest{}) // Wait for the context to be canceled or for a signal and close // all listeners. diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index a20a26d2ab161..0dbd3aa969239 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3371,3 +3371,96 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) } + +func TestWorkspaceUsageTracking(t *testing.T) { + t.Parallel() + t.Run("NoExperiment", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + tmpDir := t.TempDir() + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Directory = tmpDir + return agents + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + // continue legacy behavior + err := client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) + require.NoError(t, err) + }) + t.Run("Experiment", func(t *testing.T) { + t.Parallel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceUsage)} + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + user := coderdtest.CreateFirstUser(t, client) + tmpDir := t.TempDir() + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Directory = tmpDir + return agents + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + // continue legacy behavior + err := client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) + require.NoError(t, err) + + workspace, err := client.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err) + + // only agent id fails + err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + }) + require.ErrorContains(t, err, "agent_id") + // only app name fails + err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AppName: "ssh", + }) + require.ErrorContains(t, err, "app_name") + // unknown app name fails + err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "unknown", + }) + require.ErrorContains(t, err, "app_name") + + // vscode works + err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "vscode", + }) + require.NoError(t, err) + // jetbrains works + err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "jetbrains", + }) + require.NoError(t, err) + // reconnecting-pty works + err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "reconnecting-pty", + }) + require.NoError(t, err) + // ssh works + err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "ssh", + }) + require.NoError(t, err) + }) +} diff --git a/coderd/workspacestats/tracker_test.go b/coderd/workspacestats/tracker_test.go index 6a67e3da890a6..9be1b85781f95 100644 --- a/coderd/workspacestats/tracker_test.go +++ b/coderd/workspacestats/tracker_test.go @@ -158,18 +158,18 @@ func TestTracker_MultipleInstances(t *testing.T) { } // Use client A to update LastUsedAt of the first three - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[0].Workspace.ID, nil)) - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[1].Workspace.ID, nil)) - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[2].Workspace.ID, nil)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[0].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[1].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[2].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) // Use client B to update LastUsedAt of the next three - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[3].Workspace.ID, nil)) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[4].Workspace.ID, nil)) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[5].Workspace.ID, nil)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[3].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[4].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[5].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) // The next two will have updated from both instances - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[6].Workspace.ID, nil)) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[6].Workspace.ID, nil)) - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[7].Workspace.ID, nil)) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[7].Workspace.ID, nil)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[6].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[6].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[7].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[7].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) // The last two will not report any usage. // Tick both with different times and wait for both flushes to complete diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 280ec41e3563b..61651ed88ce14 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -338,7 +338,7 @@ var AllowedAppNames = []UsageAppName{ } // PostWorkspaceUsage marks the workspace as having been used recently. -func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID, req *PostWorkspaceUsageRequest) error { +func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID, req PostWorkspaceUsageRequest) error { path := fmt.Sprintf("/api/v2/workspaces/%s/usage", id.String()) res, err := c.Request(ctx, http.MethodPost, path, req) if err != nil { @@ -355,7 +355,7 @@ func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID, req *Post // with the given id in the background. // The caller is responsible for calling the returned function to stop the background // process. -func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, workspaceID uuid.UUID, req *PostWorkspaceUsageRequest) func() { +func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, workspaceID uuid.UUID, req PostWorkspaceUsageRequest) func() { hbCtx, hbCancel := context.WithCancel(ctx) // Perform one initial update err := c.PostWorkspaceUsage(hbCtx, workspaceID, req) From 8623ae4028dd97c3e6a1511a19eea72a55cda675 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Wed, 12 Jun 2024 18:37:06 +0000 Subject: [PATCH 06/12] comment --- coderd/workspaces.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 4c22f679229d4..1f598cbf4ce06 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1180,7 +1180,8 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { case codersdk.UsageAppNameSSH: stat.SessionCountSsh = 1 default: - // This means the app_name is not in the list of allowed app names. + // This means the app_name is in the codersdk.AllowedAppNames but not being + // handled by this switch statement. httpapi.InternalServerError(rw, xerrors.Errorf("unknown app_name %q", req.AppName)) return } From e4652121474c575497e34d00f06626c2eed57520 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 13 Jun 2024 18:44:03 +0000 Subject: [PATCH 07/12] new client method --- cli/portforward.go | 2 +- coderd/workspaces_test.go | 22 ++++++---- coderd/workspacestats/tracker_test.go | 20 ++++----- codersdk/workspaces.go | 60 ++++++++++++++++++++++++--- 4 files changed, 79 insertions(+), 25 deletions(-) diff --git a/cli/portforward.go b/cli/portforward.go index aebad3a5b2da5..4c0b1d772eecc 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -137,7 +137,7 @@ func (r *RootCmd) portForward() *serpent.Command { listeners[i] = l } - stopUpdating := client.UpdateWorkspaceUsageContext(ctx, workspace.ID, codersdk.PostWorkspaceUsageRequest{}) + stopUpdating := client.UpdateWorkspaceUsageContext(ctx, workspace.ID) // Wait for the context to be canceled or for a signal and close // all listeners. diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 0dbd3aa969239..61535d31147b4 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3391,7 +3391,9 @@ func TestWorkspaceUsageTracking(t *testing.T) { defer cancel() // continue legacy behavior - err := client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) + err := client.PostWorkspaceUsage(ctx, r.Workspace.ID) + require.NoError(t, err) + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) require.NoError(t, err) }) t.Run("Experiment", func(t *testing.T) { @@ -3415,49 +3417,51 @@ func TestWorkspaceUsageTracking(t *testing.T) { defer cancel() // continue legacy behavior - err := client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) + err := client.PostWorkspaceUsage(ctx, r.Workspace.ID) + require.NoError(t, err) + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) require.NoError(t, err) workspace, err := client.Workspace(ctx, r.Workspace.ID) require.NoError(t, err) // only agent id fails - err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, }) require.ErrorContains(t, err, "agent_id") // only app name fails - err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ AppName: "ssh", }) require.ErrorContains(t, err, "app_name") // unknown app name fails - err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, AppName: "unknown", }) require.ErrorContains(t, err, "app_name") // vscode works - err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, AppName: "vscode", }) require.NoError(t, err) // jetbrains works - err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, AppName: "jetbrains", }) require.NoError(t, err) // reconnecting-pty works - err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, AppName: "reconnecting-pty", }) require.NoError(t, err) // ssh works - err = client.PostWorkspaceUsage(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, AppName: "ssh", }) diff --git a/coderd/workspacestats/tracker_test.go b/coderd/workspacestats/tracker_test.go index 9be1b85781f95..99e9f9503b645 100644 --- a/coderd/workspacestats/tracker_test.go +++ b/coderd/workspacestats/tracker_test.go @@ -158,18 +158,18 @@ func TestTracker_MultipleInstances(t *testing.T) { } // Use client A to update LastUsedAt of the first three - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[0].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[1].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[2].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[0].Workspace.ID)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[1].Workspace.ID)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[2].Workspace.ID)) // Use client B to update LastUsedAt of the next three - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[3].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[4].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[5].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[3].Workspace.ID)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[4].Workspace.ID)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[5].Workspace.ID)) // The next two will have updated from both instances - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[6].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[6].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) - require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[7].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) - require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[7].Workspace.ID, codersdk.PostWorkspaceUsageRequest{})) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[6].Workspace.ID)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[6].Workspace.ID)) + require.NoError(t, clientA.PostWorkspaceUsage(ctx, w[7].Workspace.ID)) + require.NoError(t, clientB.PostWorkspaceUsage(ctx, w[7].Workspace.ID)) // The last two will not report any usage. // Tick both with different times and wait for both flushes to complete diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 61651ed88ce14..73c6827313485 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -337,8 +337,8 @@ var AllowedAppNames = []UsageAppName{ UsageAppNameSSH, } -// PostWorkspaceUsage marks the workspace as having been used recently. -func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID, req PostWorkspaceUsageRequest) error { +// PostWorkspaceUsage marks the workspace as having been used recently and records an app stat. +func (c *Client) PostWorkspaceUsageWithBody(ctx context.Context, id uuid.UUID, req PostWorkspaceUsageRequest) error { path := fmt.Sprintf("/api/v2/workspaces/%s/usage", id.String()) res, err := c.Request(ctx, http.MethodPost, path, req) if err != nil { @@ -351,14 +351,64 @@ func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID, req PostW return nil } +// PostWorkspaceUsage marks the workspace as having been used recently. +func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID) error { + path := fmt.Sprintf("/api/v2/workspaces/%s/usage", id.String()) + res, err := c.Request(ctx, http.MethodPost, path, nil) + if err != nil { + return xerrors.Errorf("post workspace usage: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + +// UpdateWorkspaceUsageWithBodyContext periodically posts workspace usage for the workspace +// with the given id and app name in the background. +// The caller is responsible for calling the returned function to stop the background +// process. +func (c *Client) UpdateWorkspaceUsageWithBodyContext(ctx context.Context, workspaceID uuid.UUID, req PostWorkspaceUsageRequest) func() { + hbCtx, hbCancel := context.WithCancel(ctx) + // Perform one initial update + err := c.PostWorkspaceUsageWithBody(hbCtx, workspaceID, req) + if err != nil { + c.logger.Warn(ctx, "failed to post workspace usage", slog.Error(err)) + } + ticker := time.NewTicker(time.Minute) + doneCh := make(chan struct{}) + go func() { + defer func() { + ticker.Stop() + close(doneCh) + }() + for { + select { + case <-ticker.C: + err := c.PostWorkspaceUsageWithBody(hbCtx, workspaceID, req) + if err != nil { + c.logger.Warn(ctx, "failed to post workspace usage in background", slog.Error(err)) + } + case <-hbCtx.Done(): + return + } + } + }() + return func() { + hbCancel() + <-doneCh + } +} + // UpdateWorkspaceUsageContext periodically posts workspace usage for the workspace // with the given id in the background. // The caller is responsible for calling the returned function to stop the background // process. -func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, workspaceID uuid.UUID, req PostWorkspaceUsageRequest) func() { +func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, workspaceID uuid.UUID) func() { hbCtx, hbCancel := context.WithCancel(ctx) // Perform one initial update - err := c.PostWorkspaceUsage(hbCtx, workspaceID, req) + err := c.PostWorkspaceUsage(hbCtx, workspaceID) if err != nil { c.logger.Warn(ctx, "failed to post workspace usage", slog.Error(err)) } @@ -372,7 +422,7 @@ func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, workspaceID uu for { select { case <-ticker.C: - err := c.PostWorkspaceUsage(hbCtx, workspaceID, req) + err := c.PostWorkspaceUsage(hbCtx, workspaceID) if err != nil { c.logger.Warn(ctx, "failed to post workspace usage in background", slog.Error(err)) } From 17b25b946a5400d2874d24e8c74e805fc7727f9d Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 13 Jun 2024 20:05:26 +0000 Subject: [PATCH 08/12] test activity bump --- coderd/workspaces.go | 10 +++++++++- coderd/workspaces_test.go | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 1f598cbf4ce06..22a269fc5fb7f 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1127,6 +1127,12 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { return } + if r.Body == http.NoBody { + // Continue previous behavior if no body is present. + rw.WriteHeader(http.StatusNoContent) + return + } + ctx := r.Context() var req codersdk.PostWorkspaceUsageRequest if !httpapi.Read(ctx, rw, r, &req) { @@ -1169,7 +1175,9 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { return } - stat := &proto.Stats{} + stat := &proto.Stats{ + ConnectionCount: 1, + } switch req.AppName { case codersdk.UsageAppNameVscode: stat.SessionCountVscode = 1 diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 61535d31147b4..75c706f4c9bd3 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3398,6 +3398,8 @@ func TestWorkspaceUsageTracking(t *testing.T) { }) t.Run("Experiment", func(t *testing.T) { t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() dv := coderdtest.DeploymentValues(t) dv.Experiments = []string{string(codersdk.ExperimentWorkspaceUsage)} client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ @@ -3405,19 +3407,37 @@ func TestWorkspaceUsageTracking(t *testing.T) { }) user := coderdtest.CreateFirstUser(t, client) tmpDir := t.TempDir() + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.UserID, + OrganizationID: org.ID, + }) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.UserID, + }) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: templateVersion.ID, + CreatedBy: user.UserID, + DefaultTTL: int64(8 * time.Hour), + }) + _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + ActivityBumpMillis: int64(1 * time.Hour), + }) + require.NoError(t, err) r := dbfake.WorkspaceBuild(t, db, database.Workspace{ OrganizationID: user.OrganizationID, OwnerID: user.UserID, + TemplateID: template.ID, + Ttl: sql.NullInt64{Valid: true, Int64: int64(8 * time.Hour)}, }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { agents[0].Directory = tmpDir return agents }).Do() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) - defer cancel() - // continue legacy behavior - err := client.PostWorkspaceUsage(ctx, r.Workspace.ID) + err = client.PostWorkspaceUsage(ctx, r.Workspace.ID) require.NoError(t, err) err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) require.NoError(t, err) @@ -3466,5 +3486,12 @@ func TestWorkspaceUsageTracking(t *testing.T) { AppName: "ssh", }) require.NoError(t, err) + + // ensure deadline has been bumped + newWorkspace, err := client.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err) + require.True(t, workspace.LatestBuild.Deadline.Valid) + require.True(t, newWorkspace.LatestBuild.Deadline.Valid) + require.Greater(t, newWorkspace.LatestBuild.Deadline.Time, workspace.LatestBuild.Deadline.Time) }) } From 0c7247ca5382254c6f257229d3ae174f0997aa1e Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 13 Jun 2024 20:09:32 +0000 Subject: [PATCH 09/12] deprecation notice --- codersdk/workspaces.go | 1 + 1 file changed, 1 insertion(+) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 73c6827313485..ab727408a502f 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -352,6 +352,7 @@ func (c *Client) PostWorkspaceUsageWithBody(ctx context.Context, id uuid.UUID, r } // PostWorkspaceUsage marks the workspace as having been used recently. +// Deprecated: use PostWorkspaceUsageWithBody instead func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID) error { path := fmt.Sprintf("/api/v2/workspaces/%s/usage", id.String()) res, err := c.Request(ctx, http.MethodPost, path, nil) From 01256ce0222582213256caa97c91e4eba79c3d8a Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 13 Jun 2024 20:10:11 +0000 Subject: [PATCH 10/12] another deprecation notice --- codersdk/workspaces.go | 1 + 1 file changed, 1 insertion(+) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index ab727408a502f..69472f8d4579d 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -406,6 +406,7 @@ func (c *Client) UpdateWorkspaceUsageWithBodyContext(ctx context.Context, worksp // with the given id in the background. // The caller is responsible for calling the returned function to stop the background // process. +// Deprecated: use UpdateWorkspaceUsageContextWithBody instead func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, workspaceID uuid.UUID) func() { hbCtx, hbCancel := context.WithCancel(ctx) // Perform one initial update From 11e633c8d21da568d55d3d80a633b9e3d9be4d3a Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 13 Jun 2024 20:26:48 +0000 Subject: [PATCH 11/12] fix pg test --- coderd/workspaces_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 75c706f4c9bd3..986c8ad1ebd9b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3423,7 +3423,7 @@ func TestWorkspaceUsageTracking(t *testing.T) { DefaultTTL: int64(8 * time.Hour), }) _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - ActivityBumpMillis: int64(1 * time.Hour), + ActivityBumpMillis: int64(1 * time.Hour.Milliseconds()), }) require.NoError(t, err) r := dbfake.WorkspaceBuild(t, db, database.Workspace{ From 19584762d685d8a3066408791ad475ba9656d1d2 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 13 Jun 2024 20:47:33 +0000 Subject: [PATCH 12/12] try fix pg test --- coderd/workspaces_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 986c8ad1ebd9b..e5a01df9f8edc 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3423,7 +3423,7 @@ func TestWorkspaceUsageTracking(t *testing.T) { DefaultTTL: int64(8 * time.Hour), }) _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - ActivityBumpMillis: int64(1 * time.Hour.Milliseconds()), + ActivityBumpMillis: 8 * time.Hour.Milliseconds(), }) require.NoError(t, err) r := dbfake.WorkspaceBuild(t, db, database.Workspace{