feat: report arbitrary workspace agent session types (#28338)

Report session counts as a dynamic app-name map from the agent, the SSH CLI, and the workspace usage API, so a new IDE flows end to end without a schema, proto, or server change.

- Report through `Stats.session_counts` on Agent API v2.11. Older agents keep reporting through the deprecated count fields.
- Accept arbitrary names from `coder ssh --usage-app` and the workspace usage API. A name the registry does not recognize keeps its identity and is attributed to the `unknown` family.
- Add reconnecting PTY counts to the map without overwriting the counts SSH reported.
- Normalize and merge names before applying the deterministic 64-name cap, ordered by known apps, then count, then name. Overflow sums into `unknown`, and non-positive counts cannot cancel valid activity.
- Strip control characters, treat whitespace-only HTTP app names as absent, and require the exact environment variable prefix when you extract an SSH session type.
- Count overflow entries in `coderd_agentstats_session_counts_overflow_total` and keep raw payloads out of logs.
- Change the usage API's `app_name` to a string, and update the SDK types and API reference.

closes #27410
This commit is contained in:
Ehab Younes
2026-09-19 00:32:51 +02:00
committed by GitHub
parent cc8c76651e
commit 9974294ca9
32 changed files with 547 additions and 223 deletions
+12 -25
View File
@@ -127,23 +127,10 @@ type Options struct {
}
type Client interface {
ConnectRPC29(ctx context.Context) (
proto.DRPCAgentClient29, tailnetproto.DRPCTailnetClient28, error,
)
// ConnectRPC29WithRole is like ConnectRPC29 but sends an explicit
// role query parameter to the server. The workspace agent should
// use role "agent" to enable connection monitoring.
ConnectRPC29WithRole(ctx context.Context, role string) (
proto.DRPCAgentClient29, tailnetproto.DRPCTailnetClient28, error,
)
ConnectRPC210(ctx context.Context) (
proto.DRPCAgentClient210, tailnetproto.DRPCTailnetClient28, error,
)
// ConnectRPC210WithRole is like ConnectRPC210 but sends an explicit
// role query parameter to the server. The workspace agent should
// use role "agent" to enable connection monitoring.
ConnectRPC210WithRole(ctx context.Context, role string) (
proto.DRPCAgentClient210, tailnetproto.DRPCTailnetClient28, error,
// ConnectRPC211WithRole connects to the Agent API v2.11. The workspace
// agent should use role "agent" to enable connection monitoring.
ConnectRPC211WithRole(ctx context.Context, role string) (
proto.DRPCAgentClient211, tailnetproto.DRPCTailnetClient28, error,
)
tailnet.DERPMapRewriter
agentsdk.RefreshableSessionTokenProvider
@@ -1166,7 +1153,7 @@ func (a *agent) run() (retErr error) {
// ConnectRPC returns the dRPC connection we use for the Agent and Tailnet v2+ APIs.
// We pass role "agent" to enable connection monitoring on the server, which tracks
// the agent's connectivity state (first_connected_at, last_connected_at, disconnected_at).
aAPI, tAPI, err := a.client.ConnectRPC210WithRole(a.hardCtx, "agent")
aAPI, tAPI, err := a.client.ConnectRPC211WithRole(a.hardCtx, "agent")
if err != nil {
return err
}
@@ -2152,13 +2139,13 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
stats.TxPackets += int64(counts.TxPackets)
}
// The count of active sessions; types without a protocol field are dropped.
sessionCounts := a.sshServer.SessionCounts()
stats.SessionCountSsh = sessionCounts[string(codersdk.AppFamilySSH)]
stats.SessionCountVscode = sessionCounts[string(codersdk.AppFamilyVSCode)]
stats.SessionCountJetbrains = sessionCounts[string(codersdk.AppFamilyJetBrains)]
stats.SessionCountReconnectingPty = a.reconnectingPTYServer.ConnCount()
// A client picks its own app name and can pick reconnecting_pty, so add
// rather than overwrite. A non-empty map suppresses the deprecated
// session_count_* fields on ingest, so leave them zero.
stats.SessionCounts = a.sshServer.SessionCounts()
if count := a.reconnectingPTYServer.ConnCount(); count > 0 {
stats.SessionCounts[string(codersdk.AppFamilyReconnectingPTY)] += count
}
// Compute the median connection latency!
a.logger.Debug(ctx, "starting peer latency measurement for stats")
+10 -10
View File
@@ -229,7 +229,7 @@ func assertSSHStats(t *testing.T, stats <-chan *proto.Stats) {
return false
}
t.Logf("got stats: ConnectionCount=%d, RxBytes=%d, TxBytes=%d, SessionCountSsh=%d",
s.ConnectionCount, s.RxBytes, s.TxBytes, s.SessionCountSsh)
s.ConnectionCount, s.RxBytes, s.TxBytes, s.SessionCounts["ssh"])
if s.ConnectionCount > 0 {
connectionCountSeen = true
}
@@ -239,7 +239,7 @@ func assertSSHStats(t *testing.T, stats <-chan *proto.Stats) {
if s.TxBytes > 0 {
txBytesSeen = true
}
if s.SessionCountSsh == 1 {
if s.SessionCounts["ssh"] == 1 {
sessionCountSSHSeen = true
}
return connectionCountSeen && rxBytesSeen && txBytesSeen && sessionCountSSHSeen
@@ -287,7 +287,7 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) {
if s.TxBytes > 0 {
txBytesSeen = true
}
if s.SessionCountReconnectingPty == 1 {
if s.SessionCounts["reconnecting_pty"] == 1 {
sessionCountReconnectingPTYSeen = true
}
return connectionCountSeen && rxBytesSeen && txBytesSeen && sessionCountReconnectingPTYSeen
@@ -346,12 +346,12 @@ func TestAgent_Stats_Magic(t *testing.T) {
require.NoError(t, err)
require.Eventuallyf(t, func() bool {
s, ok := <-stats
t.Logf("got stats: ok=%t, ConnectionCount=%d, RxBytes=%d, TxBytes=%d, SessionCountVSCode=%d, ConnectionMedianLatencyMS=%f",
ok, s.ConnectionCount, s.RxBytes, s.TxBytes, s.SessionCountVscode, s.ConnectionMedianLatencyMs)
t.Logf("got stats: ok=%t, ConnectionCount=%d, RxBytes=%d, TxBytes=%d, SessionCounts[vscode]=%d, ConnectionMedianLatencyMS=%f",
ok, s.ConnectionCount, s.RxBytes, s.TxBytes, s.SessionCounts["vscode"], s.ConnectionMedianLatencyMs)
return ok &&
// Ensure that the connection didn't count as a "normal" SSH session.
// This was a special one, so it should be labeled specially in the stats!
s.SessionCountVscode == 1 &&
s.SessionCounts["vscode"] == 1 &&
// Ensure that connection latency is being counted!
// If it isn't, it's set to -1.
s.ConnectionMedianLatencyMs >= 0
@@ -417,8 +417,8 @@ func TestAgent_Stats_Magic(t *testing.T) {
require.Eventuallyf(t, func() bool {
s, ok := <-stats
t.Logf("got stats with conn open: ok=%t, ConnectionCount=%d, SessionCountJetBrains=%d",
ok, s.ConnectionCount, s.SessionCountJetbrains)
return ok && s.SessionCountJetbrains == 1
ok, s.ConnectionCount, s.SessionCounts["jetbrains"])
return ok && s.SessionCounts["jetbrains"] == 1
}, testutil.WaitLong, testutil.IntervalFast,
"never saw stats with conn open",
)
@@ -431,9 +431,9 @@ func TestAgent_Stats_Magic(t *testing.T) {
require.Eventuallyf(t, func() bool {
s, ok := <-stats
t.Logf("got stats after disconnect %t, %d",
ok, s.SessionCountJetbrains)
ok, s.SessionCounts["jetbrains"])
return ok &&
s.SessionCountJetbrains == 0
s.SessionCounts["jetbrains"] == 0
}, testutil.WaitLong, testutil.IntervalFast,
"never saw stats after conn closes",
)
+7 -6
View File
@@ -340,13 +340,14 @@ func (s *Server) SessionCounts() map[string]int64 {
}
func extractAppName(env []string) (appName, rawAppName string, filteredEnv []string) {
// Match the full assignment so a longer variable such as
// CODER_SSH_SESSION_TYPE_FOO=bar is not mistaken for this one.
prefix := AppNameEnvironmentVariable + "="
for _, kv := range env {
if !strings.HasPrefix(kv, AppNameEnvironmentVariable) {
continue
if value, ok := strings.CutPrefix(kv, prefix); ok {
// The last instance of the variable wins.
rawAppName = value
}
rawAppName = strings.TrimPrefix(kv, AppNameEnvironmentVariable+"=")
// Keep going, we'll use the last instance of the env.
}
if rawAppName == "" {
@@ -357,7 +358,7 @@ func extractAppName(env []string) (appName, rawAppName string, filteredEnv []str
}
return appName, rawAppName, slices.DeleteFunc(env, func(kv string) bool {
return strings.HasPrefix(kv, AppNameEnvironmentVariable+"=")
return strings.HasPrefix(kv, prefix)
})
}
+26 -7
View File
@@ -21,14 +21,30 @@ func TestExtractAppName(t *testing.T) {
for _, tc := range []struct {
name string
env []string
want string
// keepEnv, when set, must survive filtering.
keepEnv string
want string
}{
{"NoEnvDefaultsToSSH", []string{"FOO=bar"}, "ssh"},
{"EmptyValueDefaultsToSSH", envWith(""), "ssh"},
{"VSCode", envWith("vscode"), "vscode"},
{"JetBrainsLegacyCasing", envWith("JetBrains"), "jetbrains"},
{"UnknownTypeNormalized", envWith("Cursor-Nightly"), "cursor_nightly"},
{"LastInstanceWins", append(envWith("vscode"), AppNameEnvironmentVariable+"=cursor"), "cursor"},
{"NoEnvDefaultsToSSH", []string{"FOO=bar"}, "FOO=bar", "ssh"},
{"EmptyValueDefaultsToSSH", envWith(""), "", "ssh"},
{"VSCode", envWith("vscode"), "", "vscode"},
{"JetBrainsLegacyCasing", envWith("JetBrains"), "", "jetbrains"},
{"UnknownTypeNormalized", envWith("Cursor-Nightly"), "", "cursor_nightly"},
{"LastInstanceWins", append(envWith("vscode"), AppNameEnvironmentVariable+"=cursor"), "", "cursor"},
// A variable that merely starts with the same characters is a
// different variable, not an app name.
{
"LongerVariableIgnored",
[]string{AppNameEnvironmentVariable + "_FOO=bar"},
AppNameEnvironmentVariable + "_FOO=bar",
"ssh",
},
{
"LongerVariableDoesNotShadowAppName",
append(envWith("cursor"), AppNameEnvironmentVariable+"_FOO=bar"),
AppNameEnvironmentVariable + "_FOO=bar",
"cursor",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -37,6 +53,9 @@ func TestExtractAppName(t *testing.T) {
for _, kv := range filteredEnv {
require.NotContains(t, kv, AppNameEnvironmentVariable+"=")
}
if tc.keepEnv != "" {
require.Contains(t, filteredEnv, tc.keepEnv)
}
})
}
}
+4 -2
View File
@@ -153,6 +153,7 @@ func (c *Client) Close() {
c.derpMapOnce.Do(func() { close(c.derpMapUpdates) })
}
// Required by the agentfake scaletest dialer.
func (c *Client) ConnectRPC29WithRole(ctx context.Context, _ string) (
agentproto.DRPCAgentClient29, proto.DRPCTailnetClient28, error,
) {
@@ -177,8 +178,9 @@ func (c *Client) ConnectRPC210(ctx context.Context) (
return client, tAPI, nil
}
func (c *Client) ConnectRPC210WithRole(ctx context.Context, _ string) (
agentproto.DRPCAgentClient210, proto.DRPCTailnetClient28, error,
// v2.11 adds no RPCs, so the v2.10 client satisfies it as-is.
func (c *Client) ConnectRPC211WithRole(ctx context.Context, _ string) (
agentproto.DRPCAgentClient211, proto.DRPCTailnetClient28, error,
) {
return c.ConnectRPC210(ctx)
}
+1 -1
View File
@@ -168,7 +168,7 @@ func handleRPTY(inv *serpent.Invocation, client *codersdk.Client, args handleRPT
closeUsage := client.UpdateWorkspaceUsageWithBodyContext(ctx, ws.ID, codersdk.PostWorkspaceUsageRequest{
AgentID: agt.ID,
AppName: codersdk.UsageAppNameReconnectingPty,
AppName: string(codersdk.UsageAppNameReconnectingPty),
})
defer closeUsage()
+1
View File
@@ -1115,6 +1115,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
batcher, closeBatcher, err := workspacestats.NewBatcher(ctx,
workspacestats.BatcherWithLogger(options.Logger.Named("batchstats")),
workspacestats.BatcherWithStore(options.Database),
workspacestats.BatcherWithRegisterer(options.PrometheusRegistry),
)
if err != nil {
return xerrors.Errorf("failed to create agent stats batcher: %w", err)
+10 -13
View File
@@ -1532,21 +1532,18 @@ func (r stdioErrLogReader) Read(_ []byte) (int, error) {
return 0, io.EOF
}
func getUsageAppName(usageApp string) codersdk.UsageAppName {
if usageApp == disableUsageApp {
// getUsageAppName returns the app name to report usage under, or the empty
// string to report none. Any name is valid because the server normalizes it
// at ingestion.
func getUsageAppName(usageApp string) string {
switch usageApp {
case disableUsageApp:
return ""
case "":
return string(codersdk.UsageAppNameSSH)
default:
return usageApp
}
allowedUsageApps := []string{
string(codersdk.UsageAppNameSSH),
string(codersdk.UsageAppNameVscode),
string(codersdk.UsageAppNameJetbrains),
}
if slices.Contains(allowedUsageApps, usageApp) {
return codersdk.UsageAppName(usageApp)
}
return codersdk.UsageAppNameSSH
}
func setStatsCallback(
+43 -35
View File
@@ -1630,51 +1630,51 @@ func TestSSH(t *testing.T) {
t.Parallel()
type testCase struct {
name string
experiment bool
usageAppName string
expectedCalls int
expectedCountSSH int
expectedCountJetbrains int
expectedCountVscode int
name string
experiment bool
usageAppName string
expectedCalls int
expectedCounts map[string]int64
}
tcs := []testCase{
{
name: "NoExperiment",
},
{
name: "Empty",
experiment: true,
expectedCalls: 1,
expectedCountSSH: 1,
name: "Empty",
experiment: true,
expectedCalls: 1,
expectedCounts: map[string]int64{"ssh": 1},
},
{
name: "SSH",
experiment: true,
usageAppName: "ssh",
expectedCalls: 1,
expectedCountSSH: 1,
name: "SSH",
experiment: true,
usageAppName: "ssh",
expectedCalls: 1,
expectedCounts: map[string]int64{"ssh": 1},
},
{
name: "Jetbrains",
experiment: true,
usageAppName: "jetbrains",
expectedCalls: 1,
expectedCountJetbrains: 1,
name: "Jetbrains",
experiment: true,
usageAppName: "jetbrains",
expectedCalls: 1,
expectedCounts: map[string]int64{"jetbrains": 1},
},
{
name: "Vscode",
experiment: true,
usageAppName: "vscode",
expectedCalls: 1,
expectedCountVscode: 1,
name: "Vscode",
experiment: true,
usageAppName: "vscode",
expectedCalls: 1,
expectedCounts: map[string]int64{"vscode": 1},
},
{
name: "InvalidDefaultsToSSH",
experiment: true,
usageAppName: "invalid",
expectedCalls: 1,
expectedCountSSH: 1,
// Arbitrary app names are accepted, normalized by the server
// at ingestion, so new IDEs need no CLI changes.
name: "ArbitraryNamePassthrough",
experiment: true,
usageAppName: "Some-Future-IDE",
expectedCalls: 1,
expectedCounts: map[string]int64{"some_future_ide": 1},
},
{
name: "Disable",
@@ -1729,10 +1729,18 @@ func TestSSH(t *testing.T) {
stdin.WriteLine("exit")
<-cmdDone
require.EqualValues(t, tc.expectedCalls, batcher.Called)
require.EqualValues(t, tc.expectedCountSSH, batcher.LastStats.SessionCountSsh)
require.EqualValues(t, tc.expectedCountJetbrains, batcher.LastStats.SessionCountJetbrains)
require.EqualValues(t, tc.expectedCountVscode, batcher.LastStats.SessionCountVscode)
// The agent may still be reporting stats concurrently, so
// the batcher fields are only safe to read under its mutex.
batcher.Mu.Lock()
called := batcher.Called
sessionCounts := batcher.LastStats.GetSessionCounts()
batcher.Mu.Unlock()
require.EqualValues(t, tc.expectedCalls, called)
if len(tc.expectedCounts) == 0 {
require.Empty(t, sessionCounts)
} else {
require.EqualValues(t, tc.expectedCounts, sessionCounts)
}
})
}
})
+1 -1
View File
@@ -178,7 +178,7 @@ func (r *RootCmd) vscodeSSH() *serpent.Command {
closeUsage := client.UpdateWorkspaceUsageWithBodyContext(ctx, workspace.ID, codersdk.PostWorkspaceUsageRequest{
AgentID: workspaceAgent.ID,
AppName: codersdk.UsageAppNameVscode,
AppName: string(codersdk.UsageAppNameVscode),
})
defer closeUsage()
+1 -1
View File
@@ -87,5 +87,5 @@ func TestVSCodeSSH(t *testing.T) {
}
require.EqualValues(t, 1, batcher.Called)
require.EqualValues(t, 1, batcher.LastStats.SessionCountVscode)
require.EqualValues(t, 1, batcher.LastStats.GetSessionCounts()["vscode"])
}
+5 -1
View File
@@ -56,10 +56,14 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR
ws = database.WorkspaceIdentityFromWorkspace(w)
}
// The payload is unnormalized and unbounded until the batcher caps it, so
// log scalars rather than its contents.
a.Log.Debug(ctx, "read stats report",
slog.F("interval", a.AgentStatsRefreshInterval),
slog.F("workspace_id", ws.ID),
slog.F("payload", req),
slog.F("connection_count", req.Stats.GetConnectionCount()),
slog.F("session_count_keys", len(req.Stats.GetSessionCounts())),
slog.F("connections_by_proto_keys", len(req.Stats.GetConnectionsByProto())),
)
if a.Experiments.Enabled(codersdk.ExperimentWorkspaceUsage) {
+130
View File
@@ -3,6 +3,9 @@ package agentapi_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"sync/atomic"
"testing"
"time"
@@ -666,6 +669,133 @@ func TestUpdateStats(t *testing.T) {
}
require.True(t, updateAgentMetricsFnCalled)
})
t.Run("SessionCounts", func(t *testing.T) {
t.Parallel()
// Every case starts from 300 whitespace aliases of one app, which
// normalize to a single name.
vscodeAliases := func() map[string]int64 {
counts := make(map[string]int64, 300)
for i := range 300 {
counts["VSCode"+strings.Repeat(" ", i)] = 1
}
return counts
}
for _, tc := range []struct {
name string
setup func() (counts, want map[string]int64)
}{
{
name: "AliasesFitWithoutFolding",
setup: func() (map[string]int64, map[string]int64) {
return vscodeAliases(), map[string]int64{"vscode": 300}
},
},
{
// Known apps win even with smaller counts. Unknown aliases
// merge before ranking, with name breaking count ties, and
// the kept names fill the 64 cap.
name: "RankAfterMerging",
setup: func() (map[string]int64, map[string]int64) {
counts := vscodeAliases()
counts["SSH"] = 1
counts["unknown"] = -1_000
counts[" VSCODE"] = -1_000
counts["idle"] = 0
for i := range 300 {
counts[fmt.Sprintf("app-%03d", i)] = 2
counts["zzz-busy"+strings.Repeat(" ", i)] = 1
}
want := map[string]int64{"vscode": 300, "ssh": 1, "zzz_busy": 300, "unknown": 478}
for i := range 61 {
want[fmt.Sprintf("app_%03d", i)] = 2
}
return counts, want
},
},
{
name: "NonPositiveSuppressesFallback",
setup: func() (map[string]int64, map[string]int64) {
counts := vscodeAliases()
for key := range counts {
counts[key] = -1
}
counts["unknown"] = 0
return counts, map[string]int64{}
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
counts, wantCounts := tc.setup()
ctx := testutil.Context(t, testutil.WaitLong)
dbM := dbmock.NewMockStore(gomock.NewController(t))
inserted := make(chan database.InsertWorkspaceAgentStatsParams, 1)
dbM.EXPECT().InsertWorkspaceAgentStats(gomock.Any(), gomock.Any()).Times(1).DoAndReturn(
func(_ context.Context, params database.InsertWorkspaceAgentStatsParams) error {
inserted <- params
return nil
})
batcher, closeBatcher, err := workspacestats.NewBatcher(ctx,
workspacestats.BatcherWithStore(dbM),
workspacestats.BatcherWithLogger(testutil.Logger(t)),
workspacestats.BatcherWithInterval(time.Hour),
)
require.NoError(t, err)
t.Cleanup(closeBatcher)
api := agentapi.StatsAPI{
AgentID: agent.ID,
AgentName: agent.Name,
Workspace: &workspaceAsCacheFields,
Database: dbM,
StatsReporter: workspacestats.NewReporter(workspacestats.ReporterOptions{
StatsBatcher: batcher,
}),
AgentStatsRefreshInterval: 10 * time.Second,
}
_, err = api.UpdateStats(ctx, &agentproto.UpdateStatsRequest{Stats: &agentproto.Stats{
SessionCounts: counts,
// A present map must suppress deprecated fields even when
// every reported count is non-positive.
SessionCountVscode: 9,
ConnectionsByProto: map[string]int64{"tcp": 1},
ConnectionMedianLatencyMs: 23,
RxPackets: 120,
RxBytes: 1000,
TxPackets: 130,
TxBytes: 2000,
}})
require.NoError(t, err)
closeBatcher() // Flush synchronously without waiting for a timer.
params := testutil.RequireReceive(ctx, t, inserted)
wantSessionCounts, err := json.Marshal([]map[string]int64{wantCounts})
require.NoError(t, err)
// ID and CreatedAt are generated inside the batcher; the rest
// must survive capping untouched.
require.Equal(t, database.InsertWorkspaceAgentStatsParams{
ID: params.ID,
CreatedAt: params.CreatedAt,
UserID: []uuid.UUID{workspace.OwnerID},
WorkspaceID: []uuid.UUID{workspace.ID},
TemplateID: []uuid.UUID{workspace.TemplateID},
AgentID: []uuid.UUID{agent.ID},
ConnectionsByProto: json.RawMessage(`[{"tcp":1}]`),
ConnectionCount: []int64{0},
RxPackets: []int64{120},
RxBytes: []int64{1000},
TxPackets: []int64{130},
TxBytes: []int64{2000},
SessionCounts: wantSessionCounts,
ConnectionMedianLatencyMS: []float64{23},
Usage: []bool{false},
}, params)
})
}
})
}
func templateScheduleStorePtr(store schedule.TemplateScheduleStore) *atomic.Pointer[schedule.TemplateScheduleStore] {
+2 -16
View File
@@ -26596,7 +26596,8 @@ const docTemplate = `{
"format": "uuid"
},
"app_name": {
"$ref": "#/definitions/codersdk.UsageAppName"
"description": "AppName is any name for the app reporting usage. The server normalizes\nit at ingestion, so a new app needs no server change. The UsageAppName\nconstants are the well-known names.",
"type": "string"
}
}
},
@@ -30411,21 +30412,6 @@ const docTemplate = `{
}
}
},
"codersdk.UsageAppName": {
"type": "string",
"enum": [
"vscode",
"jetbrains",
"reconnecting-pty",
"ssh"
],
"x-enum-varnames": [
"UsageAppNameVscode",
"UsageAppNameJetbrains",
"UsageAppNameReconnectingPty",
"UsageAppNameSSH"
]
},
"codersdk.UsagePeriod": {
"type": "object",
"properties": {
+2 -11
View File
@@ -24374,7 +24374,8 @@
"format": "uuid"
},
"app_name": {
"$ref": "#/definitions/codersdk.UsageAppName"
"description": "AppName is any name for the app reporting usage. The server normalizes\nit at ingestion, so a new app needs no server change. The UsageAppName\nconstants are the well-known names.",
"type": "string"
}
}
},
@@ -28001,16 +28002,6 @@
}
}
},
"codersdk.UsageAppName": {
"type": "string",
"enum": ["vscode", "jetbrains", "reconnecting-pty", "ssh"],
"x-enum-varnames": [
"UsageAppNameVscode",
"UsageAppNameJetbrains",
"UsageAppNameReconnectingPty",
"UsageAppNameSSH"
]
},
"codersdk.UsagePeriod": {
"type": "object",
"properties": {
+22 -27
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5"
@@ -1766,7 +1767,11 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
return
}
if req.AgentID == uuid.Nil && req.AppName == "" {
// Normalize at the edge so storage and lookup agree on the key, and so a
// name that carries no information reads the same as an absent one.
appName := normalizeUsageAppName(req.AppName)
if req.AgentID == uuid.Nil && appName == "" {
// Continue previous behavior if body is empty.
rw.WriteHeader(http.StatusNoContent)
return
@@ -1781,7 +1786,7 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
})
return
}
if req.AppName == "" {
if appName == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid request",
Validations: []codersdk.ValidationError{{
@@ -1791,34 +1796,10 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
})
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
SessionCounts: map[string]int64{appName: 1},
}
agent, err := api.Database.GetWorkspaceAgentByID(ctx, req.AgentID)
@@ -1846,6 +1827,20 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusNoContent)
}
// normalizeUsageAppName prepares a client-supplied app name for storage. A
// name of only whitespace and control characters carries no app, so it
// returns the empty string and the caller rejects the request rather than
// counting a session under the unknown family.
func normalizeUsageAppName(appName string) string {
named := strings.ContainsFunc(appName, func(r rune) bool {
return !unicode.IsControl(r) && !unicode.IsSpace(r)
})
if !named {
return ""
}
return codersdk.NormalizeAppName(appName)
}
// @Summary Favorite workspace by ID.
// @ID favorite-workspace-by-id
// @Security CoderSessionToken
+35
View File
@@ -0,0 +1,35 @@
package coderd
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeUsageAppName(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
appName string
want string
}{
{"Empty", "", ""},
{"Whitespace", " \t\n ", ""},
{"NullBytes", "\x00\x00", ""},
// Control characters carry no name, so they must read as an absent
// app name rather than a session under the unknown family.
{"ControlOnly", "\x1b\x07\x01", ""},
{"ControlAndWhitespace", " \x1b \x07\t", ""},
{"EscapeSequenceKeepsPrintableText", "\x1b[31m", "[31m"},
{"Normalized", "Some-Future-IDE", "some_future_ide"},
{"ControlStripped", "cur\x1bsor", "cursor"},
{"LongWhitespace", strings.Repeat(" ", 300), ""},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, normalizeUsageAppName(tc.appName))
})
}
}
+85 -3
View File
@@ -45,6 +45,7 @@ import (
"github.com/coder/coder/v2/coderd/schedule/cron"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/workspacestats"
"github.com/coder/coder/v2/coderd/wsbuilder"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
@@ -5000,12 +5001,29 @@ func TestWorkspaceUsageTracking(t *testing.T) {
AppName: "ssh",
})
require.ErrorContains(t, err, "app_name")
// unknown app name fails
// unknown app names are accepted
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID,
AppName: "unknown",
AppName: "SomeFutureIDE",
})
require.ErrorContains(t, err, "app_name")
require.NoError(t, err)
// an app name of only whitespace or control characters, at any
// length, reads as an absent app name: the usage bump still happens
// and no session is counted
for _, appName := range []string{" ", strings.Repeat(" ", 300), "\x00", "\x1b\x07", " \x1b \t"} {
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
AppName: appName,
})
require.NoError(t, err)
// with an agent set, those names fail like an empty one
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID,
AppName: appName,
})
require.ErrorContains(t, err, "app_name")
}
// vscode works
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
@@ -5041,6 +5059,70 @@ func TestWorkspaceUsageTracking(t *testing.T) {
})
}
// TestWorkspaceUsageArbitraryAppNameNormalized posts an arbitrary app name
// through a real batcher and asserts the database stores the normalized key,
// so readers of session_counts only ever see canonical names.
func TestWorkspaceUsageArbitraryAppNameNormalized(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
store, ps, sqlDB := dbtestutil.NewDBWithSQLDB(t)
batcher, closeBatcher, err := workspacestats.NewBatcher(ctx,
workspacestats.BatcherWithStore(store),
workspacestats.BatcherWithLogger(testutil.Logger(t).Named("batcher")),
workspacestats.BatcherWithInterval(testutil.IntervalFast),
)
require.NoError(t, err)
t.Cleanup(closeBatcher)
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceUsage)}
client := coderdtest.New(t, &coderdtest.Options{
Database: store,
Pubsub: ps,
DeploymentValues: dv,
StatsBatcher: batcher,
})
user := coderdtest.CreateFirstUser(t, client)
r := dbfake.WorkspaceBuild(t, store, database.WorkspaceTable{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
Name: fmt.Sprintf("usage-%d", time.Now().UnixNano()),
}).WithAgent().Do()
require.Len(t, r.Agents, 1)
agentID := r.Agents[0].ID
err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{
AgentID: agentID,
AppName: "Some-Future-IDE",
})
require.NoError(t, err)
// The batcher flushes on its own schedule, so poll until the row lands.
var count int64
require.True(t, testutil.Eventually(ctx, t, func(ctx context.Context) bool {
err := sqlDB.QueryRowContext(ctx, `
SELECT coalesce(sum((session_counts->>'some_future_ide')::bigint), 0)
FROM workspace_agent_stats
WHERE agent_id = $1`, agentID).Scan(&count)
if err != nil {
t.Logf("query session counts: %s", err)
return false
}
return count > 0
}, testutil.IntervalFast), "expected the normalized app name to reach the database")
require.EqualValues(t, 1, count)
// The raw name must never reach storage as a key.
var rawKeyRows int64
err = sqlDB.QueryRowContext(ctx, `
SELECT count(*)
FROM workspace_agent_stats
WHERE agent_id = $1 AND session_counts ? 'Some-Future-IDE'`, agentID).Scan(&rawKeyRows)
require.NoError(t, err)
require.Zero(t, rawKeyRows)
}
func TestWorkspaceNotifications(t *testing.T) {
t.Parallel()
+22 -5
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
@@ -51,6 +52,10 @@ type DBBatcher struct {
flushForced atomic.Bool
// flushed is used during testing to signal that a flush has completed.
flushed chan<- int
metrics batcherMetrics
// registerer is nil unless BatcherWithRegisterer is passed.
registerer prometheus.Registerer
}
// Option is a functional option for configuring a Batcher.
@@ -84,15 +89,25 @@ func BatcherWithLogger(log slog.Logger) BatcherOption {
}
}
// BatcherWithRegisterer sets the Prometheus registerer for batcher metrics.
func BatcherWithRegisterer(reg prometheus.Registerer) BatcherOption {
return func(b *DBBatcher) {
b.registerer = reg
}
}
// NewBatcher creates a new Batcher and starts it.
func NewBatcher(ctx context.Context, opts ...BatcherOption) (*DBBatcher, func(), error) {
b := &DBBatcher{}
b.log = slog.Make(sloghuman.Sink(os.Stderr))
b.metrics = newBatcherMetrics()
b.flushLever = make(chan struct{}, 1) // Buffered so that it doesn't block.
for _, opt := range opts {
opt(b)
}
b.metrics.register(b.registerer)
if b.store == nil {
return nil, nil, xerrors.Errorf("no store configured for batcher")
}
@@ -141,15 +156,17 @@ func (b *DBBatcher) Add(
usage bool,
) {
// Normalize and cap outside the lock.
sessionCounts := normalizedSessionCounts(st)
if len(sessionCounts) > maxSessionCountEntries {
b.log.Warn(context.Background(), "too many distinct session types, overflow counted under unknown",
sessionCounts, overflow := capSessionCounts(normalizedSessionCounts(st))
if overflow > 0 {
// A misbehaving agent hits this on every report, so alert on the
// counter and keep the log at debug.
b.log.Debug(context.Background(), "too many distinct session types, overflow counted under unknown",
slog.F("agent_id", agentID),
slog.F("reported", len(sessionCounts)),
slog.F("overflow", overflow),
slog.F("max", maxSessionCountEntries),
)
b.metrics.SessionCountsOverflowTotal.Add(float64(overflow))
}
sessionCounts = capSessionCounts(sessionCounts)
b.mu.Lock()
defer b.mu.Unlock()
@@ -3,10 +3,13 @@ package workspacestats
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/google/uuid"
prom_testutil "github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
@@ -19,6 +22,7 @@ import (
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/testutil"
)
func TestBatchStats(t *testing.T) {
@@ -146,6 +150,46 @@ func TestBatchStats(t *testing.T) {
require.Equal(t, defaultBufferSize, cap(b.buf.ID), "buffer grew beyond expected capacity")
}
// TestBatchStatsSessionCountFold asserts that a report over the session count
// cap folds the excess into the overflow counter and logs it at debug, never
// at warn, so one misbehaving agent cannot flood the logs.
func TestBatchStatsSessionCountFold(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
sink := testutil.NewFakeSink(t)
store, ps := dbtestutil.NewDB(t)
deps := setupDeps(t, store, ps)
b, closer, err := NewBatcher(ctx,
BatcherWithStore(store),
BatcherWithLogger(sink.Logger(slog.LevelDebug)),
func(b *DBBatcher) {
// Take control of flushes so they do not interleave with the
// assertions below.
b.tickCh = make(chan time.Time)
},
)
require.NoError(t, err)
t.Cleanup(closer)
const extra = 6
st := randStats(t, func(s *agentproto.Stats) {
s.SessionCounts = make(map[string]int64, maxSessionCountEntries+extra)
for i := range maxSessionCountEntries + extra {
s.SessionCounts[fmt.Sprintf("app_%d", i)] = 1
}
})
b.Add(dbtime.Now(), deps.Agent.ID, deps.Template.ID, deps.User.ID, deps.Workspace.ID, st, false)
overcap := sink.Entries(func(e slog.SinkEntry) bool {
return strings.Contains(e.Message, "too many distinct session types")
})
require.Len(t, overcap, 1)
require.Equal(t, slog.LevelDebug, overcap[0].Level)
require.Equal(t, float64(extra), prom_testutil.ToFloat64(b.metrics.SessionCountsOverflowTotal))
}
// randStats returns a random agentproto.Stats
func randStats(t *testing.T, opts ...func(*agentproto.Stats)) *agentproto.Stats {
t.Helper()
+27
View File
@@ -0,0 +1,27 @@
package workspacestats
import (
"github.com/prometheus/client_golang/prometheus"
)
// batcherMetrics collects Prometheus metrics for the stats batcher.
type batcherMetrics struct {
SessionCountsOverflowTotal prometheus.Counter
}
func newBatcherMetrics() batcherMetrics {
return batcherMetrics{
SessionCountsOverflowTotal: prometheus.NewCounter(prometheus.CounterOpts{
Namespace: "coderd",
Subsystem: "agentstats",
Name: "session_counts_overflow_total",
Help: "Total number of reported session count entries summed into the unknown app after exceeding the per-report cap.",
}),
}
}
func (m batcherMetrics) register(reg prometheus.Registerer) {
if reg != nil {
reg.MustRegister(m.SessionCountsOverflowTotal)
}
}
+15 -11
View File
@@ -41,19 +41,22 @@ func normalizedSessionCounts(st *agentproto.Stats) map[string]int64 {
// aggregates under AppFamilyUnknown.
const maxSessionCountEntries = 64
// capSessionCounts keeps the busiest maxSessionCountEntries names, preferring
// known apps, and sums the rest into AppFamilyUnknown, so the result can hold
// one name past the cap.
func capSessionCounts(counts map[string]int64) map[string]int64 {
// capSessionCounts keeps the busiest maxSessionCountEntries normalized names,
// preferring known apps, and sums the rest into AppFamilyUnknown, so the
// result can hold one name past the cap. The second return counts the names
// summed away.
func capSessionCounts(counts map[string]int64) (map[string]int64, int) {
if len(counts) <= maxSessionCountEntries {
return counts
return counts, 0
}
// Known apps rank 0, unknown 1, so known apps win the cap.
// Names are already normalized, so look up families directly rather
// than normalizing again on every sort comparison.
families := codersdk.SessionCountAppFamilies()
rank := func(name string) int {
if codersdk.AppNameFamily(name) == codersdk.AppFamilyUnknown {
return 1
if family, ok := families[name]; ok && family != codersdk.AppFamilyUnknown {
return 0
}
return 0
return 1
}
ranked := slices.SortedFunc(maps.Keys(counts), func(a, b string) int {
return cmp.Or(
@@ -66,10 +69,11 @@ func capSessionCounts(counts map[string]int64) map[string]int64 {
for _, name := range ranked[:maxSessionCountEntries] {
capped[name] = counts[name]
}
for _, name := range ranked[maxSessionCountEntries:] {
overflow := ranked[maxSessionCountEntries:]
for _, name := range overflow {
capped[string(codersdk.AppFamilyUnknown)] += counts[name]
}
return capped
return capped, len(overflow)
}
// HasSessionCounts reports whether the stats contain any active session.
@@ -11,7 +11,8 @@ import (
// sessionCountsFromProto is what the batcher applies on ingest.
func sessionCountsFromProto(st *agentproto.Stats) map[string]int64 {
return capSessionCounts(normalizedSessionCounts(st))
counts, _ := capSessionCounts(normalizedSessionCounts(st))
return counts
}
func TestSessionCountsFromProto(t *testing.T) {
-13
View File
@@ -349,19 +349,6 @@ func (c *Client) ConnectRPC210(ctx context.Context) (
return proto.NewDRPCAgentClient(conn), tailnetproto.NewDRPCTailnetClient(conn), nil
}
// ConnectRPC210WithRole is like ConnectRPC210 but sends an explicit role
// query parameter to the server. Use "agent" for workspace agents to
// enable connection monitoring.
func (c *Client) ConnectRPC210WithRole(ctx context.Context, role string) (
proto.DRPCAgentClient210, tailnetproto.DRPCTailnetClient28, error,
) {
conn, err := c.connectRPCVersion(ctx, apiversion.New(2, 10), role)
if err != nil {
return nil, nil, err
}
return proto.NewDRPCAgentClient(conn), tailnetproto.NewDRPCTailnetClient(conn), nil
}
// ConnectRPC211WithRole returns a dRPC client to the Agent API v2.11, which
// reports per-app session counts on Stats. Pass role "agent" for workspace
// agents to enable connection monitoring.
+11 -4
View File
@@ -5,6 +5,7 @@ import (
"maps"
"slices"
"strings"
"unicode"
"github.com/google/uuid"
"golang.org/x/xerrors"
@@ -120,11 +121,17 @@ func AppNameFamily(appName string) AppFamilyName {
}
// NormalizeAppName prepares a client-supplied app name for storage and
// lookup: it strips the null bytes Postgres TEXT rejects, trims, truncates,
// lowercases, and folds hyphens to underscores. Empty becomes
// AppFamilyUnknown.
// lookup: it strips control characters, then trims, truncates, lowercases,
// and folds hyphens to underscores. Empty becomes AppFamilyUnknown.
// Stripping control characters covers both the null bytes Postgres TEXT
// rejects and escape sequences that would otherwise reach logs and terminals.
func NormalizeAppName(appName string) string {
appName = strings.ReplaceAll(appName, "\x00", "")
appName = strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, appName)
// Trim before truncating so padding does not spend the budget, and after
// in case the cut lands in whitespace.
appName = strings.TrimSpace(appName)
+5
View File
@@ -31,6 +31,11 @@ func TestNormalizeAppName(t *testing.T) {
{"UnknownLowercased", "Cursor Nightly", "cursor nightly"},
{"UnknownPreservesUnicode", "エディタ", "エディタ"},
{"StripsNullBytes", "cur\x00sor", "cursor"},
{"StripsControlCharacters", "vs\ncode\r\t", "vscode"},
// Only the escape byte itself is a control character, so the rest of
// an ANSI sequence survives as ordinary text.
{"StripsANSIEscape", "\x1b[31mvscode\x1b[0m", "[31mvscode[0m"},
{"OnlyControlCharacters", "\n\r\t\x1b", "unknown"},
{"TrimsWhitespace", " vscode\t", "vscode"},
{"Empty", "", "unknown"},
{"OnlyNullBytes", "\x00\x00", "unknown"},
+8 -9
View File
@@ -376,12 +376,18 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx
}
type PostWorkspaceUsageRequest struct {
AgentID uuid.UUID `json:"agent_id" format:"uuid"`
AppName UsageAppName `json:"app_name"`
AgentID uuid.UUID `json:"agent_id" format:"uuid"`
// AppName is any name for the app reporting usage. The server normalizes
// it at ingestion, so a new app needs no server change. The UsageAppName
// constants are the well-known names.
AppName string `json:"app_name"`
}
type UsageAppName string
// Well-known usage app names. The values are wire format and cannot change.
// UsageAppNameReconnectingPty keeps its hyphen, which the server folds to the
// canonical reconnecting_pty.
const (
UsageAppNameVscode UsageAppName = "vscode"
UsageAppNameJetbrains UsageAppName = "jetbrains"
@@ -389,13 +395,6 @@ const (
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())
+1
View File
@@ -218,6 +218,7 @@ The `coder_ai_gateway_cost_control_*` metrics are exported only by `coderd`.
| `coderd_agentstats_session_count_reconnecting_pty` | gauge | The number of session established by reconnecting PTY | `agent_name` `username` `workspace_name` |
| `coderd_agentstats_session_count_ssh` | gauge | The number of session established by SSH | `agent_name` `username` `workspace_name` |
| `coderd_agentstats_session_count_vscode` | gauge | The number of session established by VSCode | `agent_name` `username` `workspace_name` |
| `coderd_agentstats_session_counts_overflow_total` | counter | Total number of reported session count entries summed into the unknown app after exceeding the per-report cap. | |
| `coderd_agentstats_startup_script_seconds` | gauge | The number of seconds the startup script took to execute. | `agent_name` `success` `template_name` `username` `workspace_name` |
| `coderd_agentstats_tx_bytes` | gauge | Agent Tx bytes | `agent_name` `username` `workspace_name` |
| `coderd_api_active_users_duration_hour` | gauge | The number of users that have been active within the last hour. | |
+5 -19
View File
@@ -12210,16 +12210,16 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
```json
{
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
"app_name": "vscode"
"app_name": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|------------|------------------------------------------------|----------|--------------|-------------|
| `agent_id` | string | false | | |
| `app_name` | [codersdk.UsageAppName](#codersdkusageappname) | false | | |
| Name | Type | Required | Restrictions | Description |
|------------|--------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `agent_id` | string | false | | |
| `app_name` | string | false | | App name is any name for the app reporting usage. The server normalizes it at ingestion, so a new app needs no server change. The UsageAppName constants are the well-known names. |
## codersdk.PprofConfig
@@ -16477,20 +16477,6 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `protocol` | `http`, `https` |
| `share_level` | `authenticated`, `organization`, `owner`, `public` |
## codersdk.UsageAppName
```json
"vscode"
```
### Properties
#### Enumerated Values
| Value(s) |
|--------------------------------------------------|
| `jetbrains`, `reconnecting-pty`, `ssh`, `vscode` |
## codersdk.UsagePeriod
```json
+1 -1
View File
@@ -2568,7 +2568,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/usage \
```json
{
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
"app_name": "vscode"
"app_name": "string"
}
```
+3
View File
@@ -226,6 +226,9 @@ coderd_agentstats_session_count_ssh 0
# HELP coderd_agentstats_session_count_vscode The number of session established by VSCode
# TYPE coderd_agentstats_session_count_vscode gauge
coderd_agentstats_session_count_vscode 0
# HELP coderd_agentstats_session_counts_overflow_total Total number of reported session count entries summed into the unknown app after exceeding the per-report cap.
# TYPE coderd_agentstats_session_counts_overflow_total counter
coderd_agentstats_session_counts_overflow_total 0
# HELP coderd_agentstats_startup_script_seconds Amount of time taken to run the startup script in seconds.
# TYPE coderd_agentstats_startup_script_seconds gauge
coderd_agentstats_startup_script_seconds{success=""} 0
+6 -1
View File
@@ -7507,7 +7507,12 @@ export interface PostOAuth2ProviderAppRequest {
// From codersdk/workspaces.go
export interface PostWorkspaceUsageRequest {
readonly agent_id: string;
readonly app_name: UsageAppName;
/**
* AppName is any name for the app reporting usage. The server normalizes
* it at ingestion, so a new app needs no server change. The UsageAppName
* constants are the well-known names.
*/
readonly app_name: string;
}
// From codersdk/deployment.go