diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81c16ba784798..23389633f0b87 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7490,6 +7490,9 @@ const docTemplate = `{ "CoderSessionToken": [] } ], + "consumes": [ + "application/json" + ], "tags": [ "Workspaces" ], @@ -7503,6 +7506,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 +9211,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 +10164,18 @@ const docTemplate = `{ } } }, + "codersdk.PostWorkspaceUsageRequest": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "format": "uuid" + }, + "app_name": { + "$ref": "#/definitions/codersdk.UsageAppName" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -11902,6 +11928,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..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", @@ -6639,6 +6640,14 @@ "name": "workspace", "in": "path", "required": true + }, + { + "description": "Post workspace usage request", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/codersdk.PostWorkspaceUsageRequest" + } } ], "responses": { @@ -8236,19 +8245,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 +9143,18 @@ } } }, + "codersdk.PostWorkspaceUsageRequest": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "format": "uuid" + }, + "app_name": { + "$ref": "#/definitions/codersdk.UsageAppName" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -10791,6 +10815,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/coderd/workspaces.go b/coderd/workspaces.go index 1b3f076e8f4bf..22a269fc5fb7f 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" @@ -1105,7 +1107,9 @@ 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 // @Router /workspaces/{workspace}/usage [post] func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { @@ -1116,6 +1120,102 @@ 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 + } + + 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) { + 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{ + ConnectionCount: 1, + } + 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 in the codersdk.AllowedAppNames but not being + // handled by this switch statement. + 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/workspaces_test.go b/coderd/workspaces_test.go index a20a26d2ab161..e5a01df9f8edc 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3371,3 +3371,127 @@ 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) + require.NoError(t, err) + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) + require.NoError(t, err) + }) + 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{ + DeploymentValues: dv, + }) + 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: 8 * time.Hour.Milliseconds(), + }) + 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() + + // continue legacy behavior + 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.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.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AppName: "ssh", + }) + require.ErrorContains(t, err, "app_name") + // unknown app name fails + 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.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.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.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.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + 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) + }) +} 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..69472f8d4579d 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -316,7 +316,43 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx return nil } +type PostWorkspaceUsageRequest struct { + AgentID uuid.UUID `json:"agent_id" format:"uuid"` + 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 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 { + return xerrors.Errorf("post workspace usage: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // 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) @@ -330,14 +366,52 @@ func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID) error { 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, id uuid.UUID) func() { +// Deprecated: use UpdateWorkspaceUsageContextWithBody instead +func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, workspaceID uuid.UUID) func() { hbCtx, hbCancel := context.WithCancel(ctx) // Perform one initial update - if err := c.PostWorkspaceUsage(hbCtx, id); err != nil { + err := c.PostWorkspaceUsage(hbCtx, workspaceID) + if err != nil { c.logger.Warn(ctx, "failed to post workspace usage", slog.Error(err)) } ticker := time.NewTicker(time.Minute) @@ -350,7 +424,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) + if err != nil { c.logger.Warn(ctx, "failed to post workspace usage in background", slog.Error(err)) } case <-hbCtx.Done(): diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 348ce54e11ba3..905cb45479b54 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": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "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..f16d9be857fef 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": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "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"];