chore: accept payload on workspace usage route (#13544)

This commit is contained in:
Garrett Delfosse
2024-06-14 10:08:45 -04:00
committed by GitHub
parent 87820a29d7
commit 44d69139d5
9 changed files with 452 additions and 15 deletions
+44 -3
View File
@@ -7527,6 +7527,9 @@ const docTemplate = `{
"CoderSessionToken": []
}
],
"consumes": [
"application/json"
],
"tags": [
"Workspaces"
],
@@ -7540,6 +7543,14 @@ const docTemplate = `{
"name": "workspace",
"in": "path",
"required": true
},
{
"description": "Post workspace usage request",
"name": "request",
"in": "body",
"schema": {
"$ref": "#/definitions/codersdk.PostWorkspaceUsageRequest"
}
}
],
"responses": {
@@ -9240,19 +9251,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": {
@@ -10223,6 +10237,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": {
@@ -11978,6 +12004,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": [
+37 -3
View File
@@ -6661,6 +6661,7 @@
"CoderSessionToken": []
}
],
"consumes": ["application/json"],
"tags": ["Workspaces"],
"summary": "Post Workspace Usage by ID",
"operationId": "post-workspace-usage-by-id",
@@ -6672,6 +6673,14 @@
"name": "workspace",
"in": "path",
"required": true
},
{
"description": "Post workspace usage request",
"name": "request",
"in": "body",
"schema": {
"$ref": "#/definitions/codersdk.PostWorkspaceUsageRequest"
}
}
],
"responses": {
@@ -8272,19 +8281,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": {
@@ -9200,6 +9212,18 @@
}
}
},
"codersdk.PostWorkspaceUsageRequest": {
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"format": "uuid"
},
"app_name": {
"$ref": "#/definitions/codersdk.UsageAppName"
}
}
},
"codersdk.PprofConfig": {
"type": "object",
"properties": {
@@ -10863,6 +10887,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"],
+100
View File
@@ -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)
}
+124
View File
@@ -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)
})
}
+1
View File
@@ -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
+80 -5
View File
@@ -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,15 @@ func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID) error {
return nil
}
// UpdateWorkspaceUsageContext periodically posts workspace usage for the workspace
// with the given id in the background.
// 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) UpdateWorkspaceUsageContext(ctx context.Context, id uuid.UUID) func() {
func (c *Client) UpdateWorkspaceUsageWithBodyContext(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.PostWorkspaceUsageWithBody(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 +387,45 @@ 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.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.
// Deprecated: use UpdateWorkspaceUsageContextWithBody instead
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)
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.PostWorkspaceUsage(hbCtx, workspaceID)
if err != nil {
c.logger.Warn(ctx, "failed to post workspace usage in background", slog.Error(err))
}
case <-hbCtx.Done():
+34
View File
@@ -2362,6 +2362,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `auto-fill-parameters` |
| `multi-organization` |
| `custom-roles` |
| `workspace-usage` |
## codersdk.ExternalAuth
@@ -3393,6 +3394,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
@@ -5263,6 +5280,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
+14 -3
View File
@@ -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
+18 -1
View File
@@ -848,6 +848,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;
@@ -1951,12 +1957,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
@@ -2251,6 +2259,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"];