diff --git a/coderd/agentapi/stats_test.go b/coderd/agentapi/stats_test.go index 8b75ee51e1..ece22448e7 100644 --- a/coderd/agentapi/stats_test.go +++ b/coderd/agentapi/stats_test.go @@ -708,7 +708,7 @@ func TestUpdateStats(t *testing.T) { 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} + want := map[string]int64{"vscode": 300, "ssh": 1, "zzz_busy": 300, codersdk.AppNameOverflow: 478} for i := range 61 { want[fmt.Sprintf("app_%03d", i)] = 2 } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 516733510a..dda1e0cf59 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -19164,6 +19164,25 @@ const docTemplate = `{ "AgentSubsystemExectrace" ] }, + "codersdk.AppFamilyName": { + "type": "string", + "enum": [ + "vscode", + "jetbrains", + "ssh", + "reconnecting_pty", + "sftp", + "unknown" + ], + "x-enum-varnames": [ + "AppFamilyVSCode", + "AppFamilyJetBrains", + "AppFamilySSH", + "AppFamilyReconnectingPTY", + "AppFamilySFTP", + "AppFamilyUnknown" + ] + }, "codersdk.AppHostResponse": { "type": "object", "properties": { @@ -28093,19 +28112,54 @@ const docTemplate = `{ "ServerSentEventTypeError" ] }, + "codersdk.SessionCountApp": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "display_name": { + "description": "DisplayName is the registry's name for a known app, otherwise the\nnormalized identifier itself.", + "type": "string" + }, + "family": { + "description": "Family is the group this app totals under.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AppFamilyName" + } + ] + }, + "icon": { + "description": "Icon is a bundled path under /icon/, empty if the app has none.", + "type": "string" + } + } + }, "codersdk.SessionCountDeploymentStats": { "type": "object", "properties": { + "apps": { + "description": "Apps holds one entry per reported app name, each carrying the family it\ntotals under. The fields below duplicate those totals for one release.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.SessionCountApp" + } + }, "jetbrains": { + "description": "Deprecated: total Apps by Family instead.", "type": "integer" }, "reconnecting_pty": { + "description": "Deprecated: total Apps by Family instead.", "type": "integer" }, "ssh": { + "description": "Deprecated: total Apps by Family instead.", "type": "integer" }, "vscode": { + "description": "Deprecated: total Apps by Family instead.", "type": "integer" } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b3c7c43a3b..83a494a20b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -17207,6 +17207,25 @@ "AgentSubsystemExectrace" ] }, + "codersdk.AppFamilyName": { + "type": "string", + "enum": [ + "vscode", + "jetbrains", + "ssh", + "reconnecting_pty", + "sftp", + "unknown" + ], + "x-enum-varnames": [ + "AppFamilyVSCode", + "AppFamilyJetBrains", + "AppFamilySSH", + "AppFamilyReconnectingPTY", + "AppFamilySFTP", + "AppFamilyUnknown" + ] + }, "codersdk.AppHostResponse": { "type": "object", "properties": { @@ -25812,19 +25831,54 @@ "ServerSentEventTypeError" ] }, + "codersdk.SessionCountApp": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "display_name": { + "description": "DisplayName is the registry's name for a known app, otherwise the\nnormalized identifier itself.", + "type": "string" + }, + "family": { + "description": "Family is the group this app totals under.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.AppFamilyName" + } + ] + }, + "icon": { + "description": "Icon is a bundled path under /icon/, empty if the app has none.", + "type": "string" + } + } + }, "codersdk.SessionCountDeploymentStats": { "type": "object", "properties": { + "apps": { + "description": "Apps holds one entry per reported app name, each carrying the family it\ntotals under. The fields below duplicate those totals for one release.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/codersdk.SessionCountApp" + } + }, "jetbrains": { + "description": "Deprecated: total Apps by Family instead.", "type": "integer" }, "reconnecting_pty": { + "description": "Deprecated: total Apps by Family instead.", "type": "integer" }, "ssh": { + "description": "Deprecated: total Apps by Family instead.", "type": "integer" }, "vscode": { + "description": "Deprecated: total Apps by Family instead.", "type": "integer" } } diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index c2cf01accb..7f6fab2599 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -19201,9 +19201,9 @@ func TestUpsertTemplateUsageStatsStoresReportedAppNames(t *testing.T) { func sessionFamilyCounts(t *testing.T, data json.RawMessage) map[codersdk.AppFamilyName]int64 { t.Helper() - counts, err := codersdk.SessionCountsByFamilyJSON(data) + counts, err := codersdk.DecodeAppMap[int64](data) require.NoError(t, err) - return counts + return codersdk.SumByFamily(counts) } func TestUpdateUserEmail(t *testing.T) { diff --git a/coderd/deployment_test.go b/coderd/deployment_test.go index c087526ed0..66b8c5a158 100644 --- a/coderd/deployment_test.go +++ b/coderd/deployment_test.go @@ -4,10 +4,13 @@ import ( "context" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -52,13 +55,27 @@ func TestDeploymentValues(t *testing.T) { func TestDeploymentStats(t *testing.T) { t.Parallel() - t.Log("This test is time-sensitive. It may fail if the deployment is not ready in time.") - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - client := coderdtest.New(t, &coderdtest.Options{}) + ctx := testutil.Context(t, testutil.WaitLong) + db, _ := dbtestutil.NewDB(t) + dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{ + ConnectionMedianLatencyMS: 10, + SessionCounts: dbgen.SessionCounts(t, map[string]int64{"vscode": 1, "cursor": 2, "future_ide": 3}), + }) + client := coderdtest.New(t, &coderdtest.Options{Database: db}) _ = coderdtest.CreateFirstUser(t, client) - assert.True(t, testutil.Eventually(ctx, t, func(tctx context.Context) bool { - _, err := client.DeploymentStats(tctx) + var stats codersdk.DeploymentStats + require.True(t, testutil.Eventually(ctx, t, func(tctx context.Context) bool { + var err error + stats, err = client.DeploymentStats(tctx) return err == nil }, testutil.IntervalMedium), "failed to get deployment stats in time") + // The legacy total folds both known names into VS Code. + require.Equal(t, codersdk.SessionCountDeploymentStats{ + Apps: map[string]codersdk.SessionCountApp{ + "vscode": {Count: 1, DisplayName: "VS Code", Icon: "/icon/code.svg", Family: codersdk.AppFamilyVSCode}, + "cursor": {Count: 2, DisplayName: "Cursor", Icon: "/icon/cursor.svg", Family: codersdk.AppFamilyVSCode}, + "future_ide": {Count: 3, DisplayName: "future_ide", Family: codersdk.AppFamilyUnknown}, + }, + VSCode: 3, + }, stats.SessionCount) } diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index 2f2d6f9a5e..9c0a56e257 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -150,12 +150,11 @@ func (c *Cache) refreshDeploymentStats(ctx context.Context) error { } } - // The query sums sessions per app name, so a session reported under a name - // this version does not know about is counted here rather than dropped. - sessionCounts, err := codersdk.SessionCountsByFamilyJSON(agentStats.SessionCounts) + appCounts, err := codersdk.DecodeAppMap[int64](agentStats.SessionCounts) if err != nil { - return xerrors.Errorf("group deployment session counts by app family: %w", err) + return xerrors.Errorf("decode deployment session counts: %w", err) } + familyCounts := codersdk.SumByFamily(appCounts) workspaceStats, err := c.database.GetDeploymentWorkspaceStats(ctx) if err != nil { @@ -179,10 +178,11 @@ func (c *Cache) refreshDeploymentStats(ctx context.Context) error { TxBytes: agentStats.WorkspaceTxBytes, }, SessionCount: codersdk.SessionCountDeploymentStats{ - VSCode: sessionCounts[codersdk.AppFamilyVSCode], - SSH: sessionCounts[codersdk.AppFamilySSH], - JetBrains: sessionCounts[codersdk.AppFamilyJetBrains], - ReconnectingPTY: sessionCounts[codersdk.AppFamilyReconnectingPTY], + Apps: codersdk.SessionCountApps(appCounts), + VSCode: familyCounts[codersdk.AppFamilyVSCode], + SSH: familyCounts[codersdk.AppFamilySSH], + JetBrains: familyCounts[codersdk.AppFamilyJetBrains], + ReconnectingPTY: familyCounts[codersdk.AppFamilyReconnectingPTY], }, }) return nil @@ -282,6 +282,7 @@ func (c *Cache) TemplateWorkspaceOwners(id uuid.UUID) (int, bool) { return resp, true } +// DeploymentStats shares its maps with the cache; callers must not mutate them. func (c *Cache) DeploymentStats() (codersdk.DeploymentStats, bool) { deploymentStats := c.deploymentStatsResponse.Load() if deploymentStats == nil { diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index d3bf1c7122..559df72f8b 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/metricscache" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" @@ -294,38 +295,73 @@ func TestCache_BuildTime(t *testing.T) { func TestCache_DeploymentStats(t *testing.T) { t.Parallel() - var ( - ctx = testutil.Context(t, testutil.WaitShort) - log = testutil.Logger(t) - clock = quartz.NewMock(t) - ) + for name, usage := range map[string]bool{"Stats": false, "Usage": true} { + t.Run(name, func(t *testing.T) { + t.Parallel() - tickerTrap := clock.Trap().TickerFunc("metricscache") - defer tickerTrap.Close() + var ( + ctx = testutil.Context(t, testutil.WaitLong) + clock = quartz.NewMock(t) + now = dbtime.Now().Truncate(time.Minute) + tickerTrap = clock.Trap().TickerFunc("metricscache") + ) + clock.Set(now) + defer tickerTrap.Close() - cache, db := newMetricsCache(t, log, clock, metricscache.Intervals{ - DeploymentStats: time.Minute, - }, false) + cache, db := newMetricsCache(t, testutil.Logger(t), clock, metricscache.Intervals{ + DeploymentStats: time.Minute, + }, usage) - dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{ - CreatedAt: clock.Now(), - RxBytes: 1, - TxBytes: 1, - ConnectionCount: 1, - ConnectionMedianLatencyMS: 10, - // Names from the same family, one of them an alias, so the fixed - // deployment stats fields cover the app name folding. - SessionCounts: dbgen.SessionCounts(t, map[string]int64{"vscode": 1, "cursor": 2, "zed": 3}), - }) + counts := map[string]int64{"vscode": 1, "cursor": 2, "zed": 3, "future_ide": 4, "jetbrains": 5, "reconnecting_pty": 6} + agentStat := dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{ + CreatedAt: now.Add(-2 * time.Minute), + Usage: usage, + RxBytes: 1, + TxBytes: 1, + ConnectionCount: 1, + ConnectionMedianLatencyMS: 10, + SessionCounts: dbgen.SessionCounts(t, counts), + }) - // Wait for both ticker functions to be created (template build times and deployment stats) - tickerTrap.MustWait(ctx).MustRelease(ctx) - tickerTrap.MustWait(ctx).MustRelease(ctx) + // Both ticker functions: template build times and deployment stats. + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.MustWait(ctx).MustRelease(ctx) + clock.Advance(time.Minute).MustWait(ctx) - clock.Advance(time.Minute).MustWait(ctx) + stat, ok := cache.DeploymentStats() + require.True(t, ok, "cache should be populated after refresh") + // Legacy totals fold cursor into VS Code and zed into SSH. + require.Equal(t, codersdk.SessionCountDeploymentStats{ + Apps: map[string]codersdk.SessionCountApp{ + "vscode": {Count: 1, DisplayName: "VS Code", Icon: "/icon/code.svg", Family: codersdk.AppFamilyVSCode}, + "cursor": {Count: 2, DisplayName: "Cursor", Icon: "/icon/cursor.svg", Family: codersdk.AppFamilyVSCode}, + "zed": {Count: 3, DisplayName: "Zed", Icon: "/icon/zed.svg", Family: codersdk.AppFamilySSH}, + "future_ide": {Count: 4, DisplayName: "future_ide", Family: codersdk.AppFamilyUnknown}, + "jetbrains": {Count: 5, DisplayName: "JetBrains", Icon: "/icon/jetbrains.svg", Family: codersdk.AppFamilyJetBrains}, + "reconnecting_pty": {Count: 6, DisplayName: "Web Terminal", Family: codersdk.AppFamilyReconnectingPTY}, + }, + VSCode: 3, SSH: 3, JetBrains: 5, ReconnectingPTY: 6, + }, stat.SessionCount) - stat, ok := cache.DeploymentStats() - require.True(t, ok, "cache should be populated after refresh") - require.Equal(t, int64(3), stat.SessionCount.VSCode) - require.Equal(t, int64(3), stat.SessionCount.SSH) + // A later report with no sessions clears every app. + dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{ + AgentID: agentStat.AgentID, + UserID: agentStat.UserID, + WorkspaceID: agentStat.WorkspaceID, + TemplateID: agentStat.TemplateID, + CreatedAt: now.Add(-time.Minute), + Usage: usage, + ConnectionMedianLatencyMS: 10, + SessionCounts: dbgen.SessionCounts(t, map[string]int64{}), + }) + clock.Advance(time.Minute).MustWait(ctx) + + empty, ok := cache.DeploymentStats() + require.True(t, ok) + require.Equal(t, codersdk.SessionCountDeploymentStats{ + Apps: map[string]codersdk.SessionCountApp{}, + }, empty.SessionCount) + require.Len(t, stat.SessionCount.Apps, len(counts), "refresh must not mutate a published snapshot") + }) + } } diff --git a/coderd/prometheusmetrics/prometheusmetrics.go b/coderd/prometheusmetrics/prometheusmetrics.go index 771d787ece..c9756c32c3 100644 --- a/coderd/prometheusmetrics/prometheusmetrics.go +++ b/coderd/prometheusmetrics/prometheusmetrics.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "strconv" "strings" "sync/atomic" @@ -540,6 +541,31 @@ func AgentStats(ctx context.Context, logger slog.Logger, registerer prometheus.R return nil, err } + appInfoGauge := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "coderd", + Subsystem: "agentstats", + Name: "app_info", + Help: "The current family of each registered session app. Value is always 1.", + }, []string{"app_name", "family"}) + for appName, family := range codersdk.SessionCountAppFamilies() { + appInfoGauge.WithLabelValues(appName, string(family)).Set(1) + } + if err := registerer.Register(appInfoGauge); err != nil { + return nil, err + } + + sessionCountLabels := append(slices.Clone(aggregateByLabels), "app_name") + agentStatsSessionCountGauge := NewCachedGaugeVec(prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: "coderd", + Subsystem: "agentstats", + Name: "session_count", + Help: "The number of sessions established by app name", + }, sessionCountLabels)) + err = registerer.Register(agentStatsSessionCountGauge) + if err != nil { + return nil, err + } + ctx, cancelFunc := context.WithCancel(ctx) done := make(chan struct{}) @@ -579,7 +605,7 @@ func AgentStats(ctx context.Context, logger slog.Logger, registerer prometheus.R logger.Error(ctx, "can't get agent stats", slog.Error(err)) } else { for _, agentStat := range stats { - var labelValues []string + labelValues := make([]string, 0, len(aggregateByLabels)) for _, label := range aggregateByLabels { switch label { case agentmetrics.LabelUsername: @@ -597,19 +623,25 @@ func AgentStats(ctx context.Context, logger slog.Logger, registerer prometheus.R agentStatsConnectionCountGauge.WithLabelValues(VectorOperationSet, float64(agentStat.ConnectionCount), labelValues...) agentStatsConnectionMedianLatencyGauge.WithLabelValues(VectorOperationSet, agentStat.ConnectionMedianLatencyMS/1000.0 /* (to seconds) */, labelValues...) - // The query sums sessions per app name, so a session reported - // under a name this version does not know about is counted - // here rather than dropped. A malformed sum leaves the other - // gauges for this agent intact. - sessionCounts, err := codersdk.SessionCountsByFamilyJSON(agentStat.SessionCounts) + // A malformed payload leaves this agent's other gauges intact. + appCounts, err := codersdk.DecodeAppMap[int64](agentStat.SessionCounts) if err != nil { - logger.Error(ctx, "can't group agent session counts by app family", + logger.Error(ctx, "can't decode agent session counts", slog.F("agent_name", agentStat.AgentName), slog.F("workspace_name", agentStat.WorkspaceName), slog.Error(err), ) continue } + sessionCounts := make(map[codersdk.AppFamilyName]int64) + for appName, count := range appCounts { + family := codersdk.AppNameFamily(appName) + sessionCounts[family] += count + // The gauge retains the slice, so give each series its own. Add, + // not Set: aggregateByLabels can collapse agents onto one series. + appLabels := append(slices.Clone(labelValues), appName) + agentStatsSessionCountGauge.WithLabelValues(VectorOperationAdd, float64(count), appLabels...) + } agentStatsSessionCountJetBrainsGauge.WithLabelValues(VectorOperationSet, float64(sessionCounts[codersdk.AppFamilyJetBrains]), labelValues...) agentStatsSessionCountReconnectingPTYGauge.WithLabelValues(VectorOperationSet, float64(sessionCounts[codersdk.AppFamilyReconnectingPTY]), labelValues...) @@ -617,6 +649,7 @@ func AgentStats(ctx context.Context, logger slog.Logger, registerer prometheus.R agentStatsSessionCountVSCodeGauge.WithLabelValues(VectorOperationSet, float64(sessionCounts[codersdk.AppFamilyVSCode]), labelValues...) } + // An empty window keeps the last published values. if len(stats) > 0 { agentStatsRxBytesGauge.Commit() agentStatsTxBytesGauge.Commit() @@ -624,6 +657,7 @@ func AgentStats(ctx context.Context, logger slog.Logger, registerer prometheus.R agentStatsConnectionCountGauge.Commit() agentStatsConnectionMedianLatencyGauge.Commit() + agentStatsSessionCountGauge.Commit() agentStatsSessionCountJetBrainsGauge.Commit() agentStatsSessionCountReconnectingPTYGauge.Commit() agentStatsSessionCountSSHGauge.Commit() diff --git a/coderd/prometheusmetrics/prometheusmetrics_test.go b/coderd/prometheusmetrics/prometheusmetrics_test.go index 03bd12f4ee..e300685d74 100644 --- a/coderd/prometheusmetrics/prometheusmetrics_test.go +++ b/coderd/prometheusmetrics/prometheusmetrics_test.go @@ -16,6 +16,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/xerrors" "tailscale.com/tailcfg" "cdr.dev/slog/v3" @@ -23,6 +24,7 @@ import ( agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/agentmetrics" "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/coderdtest/promhelp" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -750,6 +752,20 @@ func TestAgentStats(t *testing.T) { switch metric.GetName() { case "coderd_prometheusmetrics_agentstats_execution_seconds": executionSeconds = true + case "coderd_agentstats_app_info": + // Registry metadata is independent of the agent statistics. + case "coderd_agentstats_session_count": + for _, m := range metric.Metric { + labels := map[string]string{} + for _, label := range m.Label { + labels[label.GetName()] = label.GetValue() + } + require.NotContains(t, labels, "family") + // username:workspace:agent:metric_app=value = count + key := labels["username"] + ":" + labels["workspace_name"] + ":" + labels["agent_name"] + + ":" + metric.GetName() + "_app=" + labels["app_name"] + collected[key] = int(m.Gauge.GetValue()) + } case "coderd_agentstats_connection_count", "coderd_agentstats_connection_median_latency_seconds", "coderd_agentstats_rx_bytes", @@ -773,6 +789,140 @@ func TestAgentStats(t *testing.T) { assert.EqualValues(t, golden, collected) } +// sessionStatsStore hands each poll the next scripted response. +type sessionStatsStore struct { + database.Store + responses chan sessionStatsResponse +} + +type sessionStatsResponse struct { + rows []database.GetWorkspaceAgentStatsAndLabelsRow + err error +} + +func (s *sessionStatsStore) GetWorkspaceAgentStatsAndLabels(ctx context.Context, _ time.Time) ([]database.GetWorkspaceAgentStatsAndLabelsRow, error) { + select { + case response := <-s.responses: + return response.rows, response.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (s *sessionStatsStore) GetWorkspaceAgentUsageStatsAndLabels(ctx context.Context, since time.Time) ([]database.GetWorkspaceAgentUsageStatsAndLabelsRow, error) { + rows, err := s.GetWorkspaceAgentStatsAndLabels(ctx, since) + converted := make([]database.GetWorkspaceAgentUsageStatsAndLabelsRow, len(rows)) + for i, row := range rows { + converted[i] = database.GetWorkspaceAgentUsageStatsAndLabelsRow(row) + } + return converted, err +} + +func TestAgentStatsSessionCounts(t *testing.T) { + t.Parallel() + + for name, usage := range map[string]bool{"Stats": false, "Usage": true} { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + store := &sessionStatsStore{responses: make(chan sessionStatsResponse)} + registry := prometheus.NewRegistry() + closeFn, err := prometheusmetrics.AgentStats(ctx, slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}), registry, store, + time.Now().Add(-time.Minute), time.Millisecond, []string{agentmetrics.LabelUsername}, usage) + require.NoError(t, err) + t.Cleanup(closeFn) + + // Registry metadata is available before any statistics arrive, + // including apps without sessions and regardless of identity labels. + appInfo := func() map[string]codersdk.AppFamilyName { + t.Helper() + metrics, err := registry.Gather() + require.NoError(t, err) + result := map[string]codersdk.AppFamilyName{} + for _, metric := range metrics { + if metric.GetName() != "coderd_agentstats_app_info" { + continue + } + for _, m := range metric.Metric { + labels := map[string]string{} + for _, label := range m.Label { + labels[label.GetName()] = label.GetValue() + } + require.Len(t, labels, 2) + require.Contains(t, labels, "app_name") + require.Contains(t, labels, "family") + require.NotContains(t, result, labels["app_name"]) + require.Equal(t, float64(1), m.GetGauge().GetValue()) + result[labels["app_name"]] = codersdk.AppFamilyName(labels["family"]) + } + } + return result + } + require.Equal(t, codersdk.SessionCountAppFamilies(), appInfo()) + + polls := uint64(0) + poll := func(response sessionStatsResponse) { + t.Helper() + select { + case store.responses <- response: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + polls++ + require.Eventually(t, func() bool { + executions := promhelp.MetricValue(t, registry, + "coderd_prometheusmetrics_agentstats_execution_seconds", prometheus.Labels{}) + return executions.GetHistogram().GetSampleCount() >= polls + }, testutil.WaitShort, testutil.IntervalFast) + } + counts := func() map[string]float64 { + t.Helper() + metrics, err := registry.Gather() + require.NoError(t, err) + result := map[string]float64{} + for _, family := range metrics { + if family.GetName() != "coderd_agentstats_session_count" { + continue + } + for _, m := range family.Metric { + labels := map[string]string{} + for _, label := range m.Label { + labels[label.GetName()] = label.GetValue() + } + require.Len(t, labels, 2, "username plus app_name") + require.NotContains(t, labels, "family") + require.Equal(t, "alice", labels["username"]) + result[labels["app_name"]] = m.GetGauge().GetValue() + } + } + return result + } + row := func(raw string) database.GetWorkspaceAgentStatsAndLabelsRow { + return database.GetWorkspaceAgentStatsAndLabelsRow{Username: "alice", SessionCounts: json.RawMessage(raw)} + } + + poll(sessionStatsResponse{rows: []database.GetWorkspaceAgentStatsAndLabelsRow{ + row(`{"cursor":2,"vscode":1,"future_ide":4}`), + row(`{"cursor":3}`), + row(`not-json`), + }}) + expected := map[string]float64{"cursor": 5, "vscode": 1, "future_ide": 4} + require.Equal(t, expected, counts()) + + poll(sessionStatsResponse{err: xerrors.New("query failed")}) + require.Equal(t, expected, counts(), "failed query must retain the last snapshot") + + poll(sessionStatsResponse{}) + require.Equal(t, expected, counts(), "empty window must retain the last snapshot") + require.Equal(t, codersdk.SessionCountAppFamilies(), appInfo(), "statistics must not change registry metadata") + + poll(sessionStatsResponse{rows: []database.GetWorkspaceAgentStatsAndLabelsRow{row(`{"vscode":7}`)}}) + require.Equal(t, map[string]float64{"vscode": 7}, counts(), "apps no longer reported must be dropped") + }) + } +} + func TestExperimentsMetric(t *testing.T) { t.Parallel() diff --git a/coderd/prometheusmetrics/testdata/agent-stats.json b/coderd/prometheusmetrics/testdata/agent-stats.json index 92c9f80c52..038e2020a9 100644 --- a/coderd/prometheusmetrics/testdata/agent-stats.json +++ b/coderd/prometheusmetrics/testdata/agent-stats.json @@ -3,24 +3,36 @@ "testuser:workspace-1:example:coderd_agentstats_connection_median_latency_seconds": 8, "testuser:workspace-1:example:coderd_agentstats_rx_bytes": 9, "testuser:workspace-1:example:coderd_agentstats_session_count_jetbrains": 6, + "testuser:workspace-1:example:coderd_agentstats_session_count_app=jetbrains": 6, "testuser:workspace-1:example:coderd_agentstats_session_count_reconnecting_pty": 7, + "testuser:workspace-1:example:coderd_agentstats_session_count_app=reconnecting_pty": 7, "testuser:workspace-1:example:coderd_agentstats_session_count_ssh": 8, + "testuser:workspace-1:example:coderd_agentstats_session_count_app=ssh": 8, "testuser:workspace-1:example:coderd_agentstats_session_count_vscode": 5, + "testuser:workspace-1:example:coderd_agentstats_session_count_app=vscode": 5, "testuser:workspace-1:example:coderd_agentstats_tx_bytes": 6, "testuser:workspace-2:example:coderd_agentstats_connection_count": 10, "testuser:workspace-2:example:coderd_agentstats_connection_median_latency_seconds": 10, "testuser:workspace-2:example:coderd_agentstats_rx_bytes": 15, "testuser:workspace-2:example:coderd_agentstats_session_count_jetbrains": 10, + "testuser:workspace-2:example:coderd_agentstats_session_count_app=jetbrains": 10, "testuser:workspace-2:example:coderd_agentstats_session_count_reconnecting_pty": 12, + "testuser:workspace-2:example:coderd_agentstats_session_count_app=reconnecting_pty": 12, "testuser:workspace-2:example:coderd_agentstats_session_count_ssh": 14, + "testuser:workspace-2:example:coderd_agentstats_session_count_app=ssh": 14, "testuser:workspace-2:example:coderd_agentstats_session_count_vscode": 8, + "testuser:workspace-2:example:coderd_agentstats_session_count_app=vscode": 8, "testuser:workspace-2:example:coderd_agentstats_tx_bytes": 9, "testuser:workspace-3:example:coderd_agentstats_connection_count": 11, "testuser:workspace-3:example:coderd_agentstats_connection_median_latency_seconds": 12, "testuser:workspace-3:example:coderd_agentstats_rx_bytes": 21, "testuser:workspace-3:example:coderd_agentstats_session_count_jetbrains": 16, + "testuser:workspace-3:example:coderd_agentstats_session_count_app=jetbrains": 16, "testuser:workspace-3:example:coderd_agentstats_session_count_reconnecting_pty": 18, + "testuser:workspace-3:example:coderd_agentstats_session_count_app=reconnecting_pty": 18, "testuser:workspace-3:example:coderd_agentstats_session_count_ssh": 20, + "testuser:workspace-3:example:coderd_agentstats_session_count_app=ssh": 20, "testuser:workspace-3:example:coderd_agentstats_session_count_vscode": 14, + "testuser:workspace-3:example:coderd_agentstats_session_count_app=vscode": 14, "testuser:workspace-3:example:coderd_agentstats_tx_bytes": 12 } diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 69f536dee9..af8266069c 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -1145,14 +1145,13 @@ func ConvertWorkspaceAgentVolumeResourceMonitor(monitor database.WorkspaceAgentV } } -// ConvertWorkspaceAgentStat anonymizes a workspace agent stat. The query sums -// sessions per app name, so a session reported under a name this version does -// not know about is counted here rather than dropped. +// ConvertWorkspaceAgentStat reports raw per-app counts beside family totals. func ConvertWorkspaceAgentStat(stat database.GetWorkspaceAgentStatsRow) (WorkspaceAgentStat, error) { - sessionCounts, err := codersdk.SessionCountsByFamilyJSON(stat.SessionCounts) + sessionCounts, err := codersdk.DecodeAppMap[int64](stat.SessionCounts) if err != nil { - return WorkspaceAgentStat{}, xerrors.Errorf("group session counts by app family: %w", err) + return WorkspaceAgentStat{}, xerrors.Errorf("decode session counts: %w", err) } + familySessionCounts := codersdk.SumByFamily(sessionCounts) return WorkspaceAgentStat{ UserID: stat.UserID, TemplateID: stat.TemplateID, @@ -1163,10 +1162,11 @@ func ConvertWorkspaceAgentStat(stat database.GetWorkspaceAgentStatsRow) (Workspa ConnectionLatency95: stat.WorkspaceConnectionLatency95, RxBytes: stat.WorkspaceRxBytes, TxBytes: stat.WorkspaceTxBytes, - SessionCountVSCode: sessionCounts[codersdk.AppFamilyVSCode], - SessionCountJetBrains: sessionCounts[codersdk.AppFamilyJetBrains], - SessionCountReconnectingPTY: sessionCounts[codersdk.AppFamilyReconnectingPTY], - SessionCountSSH: sessionCounts[codersdk.AppFamilySSH], + SessionCounts: sessionCounts, + SessionCountVSCode: familySessionCounts[codersdk.AppFamilyVSCode], + SessionCountJetBrains: familySessionCounts[codersdk.AppFamilyJetBrains], + SessionCountReconnectingPTY: familySessionCounts[codersdk.AppFamilyReconnectingPTY], + SessionCountSSH: familySessionCounts[codersdk.AppFamilySSH], }, nil } @@ -1641,19 +1641,21 @@ type WorkspaceAgent struct { } type WorkspaceAgentStat struct { - UserID uuid.UUID `json:"user_id"` - TemplateID uuid.UUID `json:"template_id"` - WorkspaceID uuid.UUID `json:"workspace_id"` - AggregatedFrom time.Time `json:"aggregated_from"` - AgentID uuid.UUID `json:"agent_id"` - RxBytes int64 `json:"rx_bytes"` - TxBytes int64 `json:"tx_bytes"` - ConnectionLatency50 float64 `json:"connection_latency_50"` - ConnectionLatency95 float64 `json:"connection_latency_95"` - SessionCountVSCode int64 `json:"session_count_vscode"` - SessionCountJetBrains int64 `json:"session_count_jetbrains"` - SessionCountReconnectingPTY int64 `json:"session_count_reconnecting_pty"` - SessionCountSSH int64 `json:"session_count_ssh"` + UserID uuid.UUID `json:"user_id"` + TemplateID uuid.UUID `json:"template_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + AggregatedFrom time.Time `json:"aggregated_from"` + AgentID uuid.UUID `json:"agent_id"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` + ConnectionLatency50 float64 `json:"connection_latency_50"` + ConnectionLatency95 float64 `json:"connection_latency_95"` + SessionCounts map[string]int64 `json:"session_counts,omitempty"` + // The counts below are family totals derived from SessionCounts. + SessionCountVSCode int64 `json:"session_count_vscode"` + SessionCountJetBrains int64 `json:"session_count_jetbrains"` + SessionCountReconnectingPTY int64 `json:"session_count_reconnecting_pty"` + SessionCountSSH int64 `json:"session_count_ssh"` } type WorkspaceAgentMemoryResourceMonitor struct { diff --git a/coderd/telemetry/telemetry_test.go b/coderd/telemetry/telemetry_test.go index d37f54bd78..decbd4430c 100644 --- a/coderd/telemetry/telemetry_test.go +++ b/coderd/telemetry/telemetry_test.go @@ -154,9 +154,12 @@ func TestTelemetry(t *testing.T) { _ = dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{ ConnectionMedianLatencyMS: 1, - // Names from the same family, one of them an alias, so the fixed - // session count fields cover the app name folding. - SessionCounts: dbgen.SessionCounts(t, map[string]int64{"vscode": 1, "cursor": 2, "zed": 3}), + SessionCounts: dbgen.SessionCounts(t, map[string]int64{ + "cursor": 2, + "vscode": 1, + "zed": 3, + "unknown": 4, + }), }) _, err = db.InsertLicense(ctx, database.InsertLicenseParams{ UploadedAt: dbtime.Now(), @@ -276,6 +279,12 @@ func TestTelemetry(t *testing.T) { require.Len(t, snapshot.WorkspaceBuilds, 1) require.Len(t, snapshot.WorkspaceResources, 1) require.Len(t, snapshot.WorkspaceAgentStats, 1) + require.Equal(t, map[string]int64{ + "cursor": 2, + "vscode": 1, + "zed": 3, + "unknown": 4, + }, snapshot.WorkspaceAgentStats[0].SessionCounts) require.Equal(t, int64(3), snapshot.WorkspaceAgentStats[0].SessionCountVSCode) require.Equal(t, int64(3), snapshot.WorkspaceAgentStats[0].SessionCountSSH) require.Len(t, snapshot.WorkspaceProxies, 1) diff --git a/coderd/workspacestats/batcher.go b/coderd/workspacestats/batcher.go index 902870acbc..14bb59c6bc 100644 --- a/coderd/workspacestats/batcher.go +++ b/coderd/workspacestats/batcher.go @@ -160,7 +160,7 @@ func (b *DBBatcher) Add( 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", + b.log.Debug(context.Background(), "too many distinct session types, excess counted under overflow", slog.F("agent_id", agentID), slog.F("overflow", overflow), slog.F("max", maxSessionCountEntries), diff --git a/coderd/workspacestats/batcher_internal_test.go b/coderd/workspacestats/batcher_internal_test.go index b852282068..5755f16f73 100644 --- a/coderd/workspacestats/batcher_internal_test.go +++ b/coderd/workspacestats/batcher_internal_test.go @@ -290,7 +290,7 @@ func mustRandInt64n(t *testing.T, n int64) int64 { func sessionFamilyCounts(t *testing.T, data json.RawMessage) map[codersdk.AppFamilyName]int64 { t.Helper() - counts, err := codersdk.SessionCountsByFamilyJSON(data) + counts, err := codersdk.DecodeAppMap[int64](data) require.NoError(t, err) - return counts + return codersdk.SumByFamily(counts) } diff --git a/coderd/workspacestats/batcher_metrics.go b/coderd/workspacestats/batcher_metrics.go index b4a6ad84f2..603ef080d5 100644 --- a/coderd/workspacestats/batcher_metrics.go +++ b/coderd/workspacestats/batcher_metrics.go @@ -15,7 +15,7 @@ func newBatcherMetrics() batcherMetrics { 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.", + Help: "Total number of reported session count entries summed into the overflow app after exceeding the per-report cap.", }), } } diff --git a/coderd/workspacestats/sessioncounts.go b/coderd/workspacestats/sessioncounts.go index 51540aaddb..8dfd3e84e4 100644 --- a/coderd/workspacestats/sessioncounts.go +++ b/coderd/workspacestats/sessioncounts.go @@ -30,19 +30,25 @@ func normalizedSessionCounts(st *agentproto.Stats) map[string]int64 { } counts := make(map[string]int64, len(reported)) for app, count := range reported { - if count > 0 { - counts[codersdk.NormalizeAppName(app)] += count + if count <= 0 { + continue } + name := codersdk.NormalizeAppName(app) + // Reserved for capSessionCounts. + if name == codersdk.AppNameOverflow { + name = string(codersdk.AppFamilyUnknown) + } + counts[name] += count } return counts } // maxSessionCountEntries bounds distinct app names per stats report. Overflow -// aggregates under AppFamilyUnknown. +// aggregates under codersdk.AppNameOverflow. const maxSessionCountEntries = 64 // capSessionCounts keeps the busiest maxSessionCountEntries normalized names, -// preferring known apps, and sums the rest into AppFamilyUnknown, so the +// preferring known apps, and sums the rest into codersdk.AppNameOverflow, 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) { @@ -71,7 +77,7 @@ func capSessionCounts(counts map[string]int64) (map[string]int64, int) { } overflow := ranked[maxSessionCountEntries:] for _, name := range overflow { - capped[string(codersdk.AppFamilyUnknown)] += counts[name] + capped[codersdk.AppNameOverflow] += counts[name] } return capped, len(overflow) } diff --git a/coderd/workspacestats/sessioncounts_internal_test.go b/coderd/workspacestats/sessioncounts_internal_test.go index 141d186b28..58bfacfaed 100644 --- a/coderd/workspacestats/sessioncounts_internal_test.go +++ b/coderd/workspacestats/sessioncounts_internal_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/codersdk" ) // sessionCountsFromProto is what the batcher applies on ingest. @@ -39,6 +40,11 @@ func TestSessionCountsFromProto(t *testing.T) { &agentproto.Stats{SessionCounts: map[string]int64{"vscode": 1, "reconnecting_pty": 0, "bogus": -1}}, map[string]int64{"vscode": 1}, }, + { + "ReportsClientOverflowAsUnknown", + &agentproto.Stats{SessionCounts: map[string]int64{"Overflow": 2, "unknown": 1}}, + map[string]int64{"unknown": 3}, + }, { "MapTakesPrecedenceOverDeprecatedFields", &agentproto.Stats{SessionCounts: map[string]int64{"cursor": 2, "ssh": 1}, SessionCountVscode: 9}, @@ -70,14 +76,14 @@ func TestCapSessionCounts(t *testing.T) { got := sessionCountsFromProto(&agentproto.Stats{SessionCounts: counts}) - require.Len(t, got, maxSessionCountEntries+1, "cap plus the unknown bucket") + require.Len(t, got, maxSessionCountEntries+1, "cap plus the overflow bucket") require.EqualValues(t, 1, got["ssh"]) require.EqualValues(t, 1, got["vscode"]) require.EqualValues(t, 5, got["zzz_busy_ide"]) - // Those three plus 61 more fill the cap; the other 139 sum into unknown. + // Those three plus 61 more fill the cap; the other 139 sum into overflow. require.EqualValues(t, 2, got["0ide_000"]) require.NotContains(t, got, "0ide_199") - require.EqualValues(t, (overflowing-61)*2, got["unknown"]) + require.EqualValues(t, (overflowing-61)*2, got[codersdk.AppNameOverflow]) var reported, stored int64 for _, count := range counts { diff --git a/codersdk/appname.go b/codersdk/appname.go index 39f4fb017f..be09602820 100644 --- a/codersdk/appname.go +++ b/codersdk/appname.go @@ -1,6 +1,7 @@ package codersdk import ( + "cmp" "encoding/json" "maps" "slices" @@ -16,11 +17,8 @@ import ( // maxAppNameLength caps an app name in runes. const maxAppNameLength = 64 -// AppFamilyName is the bounded set of names that arbitrary app names group -// into, for callers that need a fixed value such as a metric label or the -// Connection_Type enum. Server-side only, not part of the HTTP API. -// -// @typescript-ignore AppFamilyName +// AppFamilyName is the bounded set of groups an app name falls into, such as +// vscode or ssh. A name Coder does not recognize falls under unknown. type AppFamilyName string const ( @@ -33,44 +31,80 @@ const ( AppFamilyUnknown AppFamilyName = "unknown" ) -// appNameFamilies is the only place an app name is attributed to a family. -// Storage keeps the raw name, so a missing alias only costs an -// AppFamilyUnknown attribution. Keys are normalized registry module IDs. -var appNameFamilies = map[string]AppFamilyName{ - "vscode": AppFamilyVSCode, - "vscode_insiders": AppFamilyVSCode, - "vscode_web": AppFamilyVSCode, - "code_server": AppFamilyVSCode, - "cursor": AppFamilyVSCode, - "windsurf": AppFamilyVSCode, - "positron": AppFamilyVSCode, - "vscodium": AppFamilyVSCode, - "codium": AppFamilyVSCode, - "antigravity": AppFamilyVSCode, - "trae": AppFamilyVSCode, - "kiro": AppFamilyVSCode, - "devin": AppFamilyVSCode, +// AppNameOverflow sums the app names past the per-report cap. +const AppNameOverflow = "overflow" - "jetbrains": AppFamilyJetBrains, - "sftp": AppFamilySFTP, - // Zed has no Connection_Type or session count field of its own, so it - // rolls up under SSH. The raw name still reaches storage. - "zed": AppFamilySSH, - "ssh": AppFamilySSH, - "reconnecting_pty": AppFamilyReconnectingPTY, +// SessionCountApp is one app's session count and how to present it. +type SessionCountApp struct { + Count int64 `json:"count"` + // DisplayName is the registry's name for a known app, otherwise the + // normalized identifier itself. + DisplayName string `json:"display_name"` + // Icon is a bundled path under /icon/, empty if the app has none. + Icon string `json:"icon,omitempty"` + // Family is the group this app totals under. + Family AppFamilyName `json:"family"` } -// SessionCountAppFamilies returns the attribution registry, one entry per -// known app name. Registering an app means editing appNameFamilies alone. +type sessionApp struct { + family AppFamilyName + displayName string + icon string +} + +// sessionApps keys attribution and presentation by normalized registry module +// ID. An unregistered ID shows as-is under AppFamilyUnknown. +var sessionApps = map[string]sessionApp{ + "vscode": {AppFamilyVSCode, "VS Code", "/icon/code.svg"}, + "vscode_insiders": {AppFamilyVSCode, "VS Code Insiders", "/icon/code-insiders.svg"}, + "vscode_web": {AppFamilyVSCode, "VS Code Web", "/icon/code.svg"}, + "code_server": {AppFamilyVSCode, "code-server", "/icon/code.svg"}, + "cursor": {AppFamilyVSCode, "Cursor", "/icon/cursor.svg"}, + "windsurf": {AppFamilyVSCode, "Windsurf", "/icon/windsurf.svg"}, + "positron": {AppFamilyVSCode, "Positron", "/icon/positron.svg"}, + "vscodium": {AppFamilyVSCode, "VSCodium", ""}, + "codium": {AppFamilyVSCode, "VSCodium", ""}, + "antigravity": {AppFamilyVSCode, "Antigravity", "/icon/antigravity.svg"}, + "trae": {AppFamilyVSCode, "Trae", ""}, + "kiro": {AppFamilyVSCode, "Kiro", "/icon/kiro.svg"}, + "devin": {AppFamilyVSCode, "Devin", "/icon/devin.svg"}, + "jetbrains": {AppFamilyJetBrains, "JetBrains", "/icon/jetbrains.svg"}, + // No agent reports sftp; the family covers the sftp_mins history. + "sftp": {AppFamilySFTP, "SFTP", "/icon/terminal.svg"}, + // Zed speaks SSH, so it counts toward the SSH total. + "zed": {AppFamilySSH, "Zed", "/icon/zed.svg"}, + "ssh": {AppFamilySSH, "SSH", "/icon/terminal.svg"}, + "reconnecting_pty": {AppFamilyReconnectingPTY, "Web Terminal", ""}, +} + +// SessionCountApps pairs each count with its presentation, keyed by app name. +func SessionCountApps(counts map[string]int64) map[string]SessionCountApp { + apps := make(map[string]SessionCountApp, len(counts)) + for appName, count := range counts { + app := sessionApps[NormalizeAppName(appName)] + apps[appName] = SessionCountApp{ + Count: count, + DisplayName: cmp.Or(app.displayName, appName), + Icon: app.icon, + Family: cmp.Or(app.family, AppFamilyUnknown), + } + } + return apps +} + +// SessionCountAppFamilies returns a copy of the registry's app-to-family mapping. func SessionCountAppFamilies() map[string]AppFamilyName { - return maps.Clone(appNameFamilies) + families := make(map[string]AppFamilyName, len(sessionApps)) + for appName, app := range sessionApps { + families[appName] = app.family + } + return families } -// SumByFamily folds a per-app map into per-family totals. Values are -// additive, so usage two apps of one family share counts in each. An -// unregistered name totals under AppFamilyUnknown. +// SumByFamily totals a per-app map by family. An unregistered name totals +// under AppFamilyUnknown. func SumByFamily(byApp map[string]int64) map[AppFamilyName]int64 { - byFamily := make(map[AppFamilyName]int64, len(byApp)) + byFamily := make(map[AppFamilyName]int64) for appName, value := range byApp { byFamily[AppNameFamily(appName)] += value } @@ -98,24 +132,11 @@ func UnionByFamily(byApp map[string][]uuid.UUID) map[AppFamilyName][]uuid.UUID { return byFamily } -// SessionCountsByFamilyJSON is SumByFamily over the session counts a query -// returns. An absent object means no sessions, because a query with no rows -// aggregates to SQL NULL. -func SessionCountsByFamilyJSON(appCounts json.RawMessage) (map[AppFamilyName]int64, error) { - var counts map[string]int64 - if len(appCounts) > 0 { - if err := json.Unmarshal(appCounts, &counts); err != nil { - return nil, xerrors.Errorf("unmarshal session counts by app name: %w", err) - } - } - return SumByFamily(counts), nil -} - // AppNameFamily normalizes an app name and returns its family, or // AppFamilyUnknown. func AppNameFamily(appName string) AppFamilyName { - if family, ok := appNameFamilies[NormalizeAppName(appName)]; ok { - return family + if app, ok := sessionApps[NormalizeAppName(appName)]; ok { + return app.family } return AppFamilyUnknown } @@ -142,16 +163,22 @@ func NormalizeAppName(appName string) string { return strings.ReplaceAll(strings.ToLower(appName), "-", "_") } -// DecodeAppMap decodes a JSONB payload keyed by app name. An absent payload -// is empty, a malformed one an error, so callers never report zero usage for -// data they failed to read. -func DecodeAppMap[V any](raw json.RawMessage) (map[string]V, error) { - if len(raw) == 0 { - return map[string]V{}, nil - } +// appMapValue is what an app-keyed JSONB payload holds. +type appMapValue interface { + int64 | []uuid.UUID +} + +// DecodeAppMap decodes an app-keyed JSONB payload. An absent or SQL NULL +// payload decodes as empty; a malformed one errors rather than reading as zero. +func DecodeAppMap[V appMapValue](raw json.RawMessage) (map[string]V, error) { var decoded map[string]V - if err := json.Unmarshal(raw, &decoded); err != nil { - return nil, xerrors.Errorf("unmarshal session app map: %w", err) + if len(raw) > 0 { + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, xerrors.Errorf("unmarshal session app map: %w", err) + } + } + if decoded == nil { + decoded = map[string]V{} } return decoded, nil } diff --git a/codersdk/appname_internal_test.go b/codersdk/appname_internal_test.go index 2a894a1c51..6fbd3ab0dc 100644 --- a/codersdk/appname_internal_test.go +++ b/codersdk/appname_internal_test.go @@ -10,7 +10,7 @@ import ( // that are not already normalized can never match. func TestAppNameFamilyKeysAreNormalized(t *testing.T) { t.Parallel() - for name := range appNameFamilies { + for name := range sessionApps { require.NotEmpty(t, name) require.Equal(t, NormalizeAppName(name), name) } @@ -21,7 +21,8 @@ func TestAppNameFamilyKeysAreNormalized(t *testing.T) { // fold destination for unregistered apps, not something to register. func TestAppNameFamilyValuesAreUsable(t *testing.T) { t.Parallel() - for appName, family := range appNameFamilies { + for appName, app := range sessionApps { + family := app.family require.NotEmpty(t, family, "app %q has no family", appName) require.NotEqual(t, AppFamilyUnknown, family, "app %q must not register the unknown family", appName) diff --git a/codersdk/appname_test.go b/codersdk/appname_test.go index 97aa2056b6..764d3ecdfb 100644 --- a/codersdk/appname_test.go +++ b/codersdk/appname_test.go @@ -2,6 +2,7 @@ package codersdk_test import ( "encoding/json" + "path/filepath" "slices" "strings" "testing" @@ -212,56 +213,6 @@ func TestSumByFamilyCoversEveryRegisteredFamily(t *testing.T) { require.Equal(t, want, codersdk.SumByFamily(appCounts)) } -func TestSessionCountsByFamilyJSON(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - name string - appCounts json.RawMessage - want map[codersdk.AppFamilyName]int64 - }{ - {"Counts", json.RawMessage(`{"cursor":2,"vscode":1,"ssh":4}`), map[codersdk.AppFamilyName]int64{ - codersdk.AppFamilyVSCode: 3, - codersdk.AppFamilySSH: 4, - }}, - {"UnknownApp", json.RawMessage(`{"some_future_ide":9}`), map[codersdk.AppFamilyName]int64{ - codersdk.AppFamilyUnknown: 9, - }}, - {"EmptyObject", json.RawMessage(`{}`), map[codersdk.AppFamilyName]int64{}}, - // A query with no matching rows aggregates to SQL NULL, which is not - // an error, just no sessions. - {"JSONNull", json.RawMessage(`null`), map[codersdk.AppFamilyName]int64{}}, - {"Absent", nil, map[codersdk.AppFamilyName]int64{}}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, err := codersdk.SessionCountsByFamilyJSON(tc.appCounts) - require.NoError(t, err) - require.Equal(t, tc.want, got) - }) - } -} - -func TestSessionCountsByFamilyJSONMalformed(t *testing.T) { - t.Parallel() - - for _, tc := range []struct { - name string - appCounts json.RawMessage - }{ - {"Truncated", json.RawMessage(`{"vscode":`)}, - {"NotAnObject", json.RawMessage(`["vscode"]`)}, - {"NonNumericCount", json.RawMessage(`{"vscode":"1"}`)}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, err := codersdk.SessionCountsByFamilyJSON(tc.appCounts) - require.Error(t, err) - require.Nil(t, got) - }) - } -} - func TestDecodeAppMap(t *testing.T) { t.Parallel() @@ -289,7 +240,12 @@ func TestDecodeAppMap(t *testing.T) { require.Equal(t, map[string]int64{"cursor": 60}, got) }) - for name, raw := range map[string]json.RawMessage{"Absent": nil, "EmptyObject": json.RawMessage(`{}`)} { + // A query with no rows aggregates to SQL NULL: no usage, not an error. + for name, raw := range map[string]json.RawMessage{ + "Absent": nil, + "EmptyObject": json.RawMessage(`{}`), + "JSONNull": json.RawMessage(`null`), + } { t.Run(name, func(t *testing.T) { t.Parallel() @@ -319,3 +275,32 @@ func TestUnionByFamily(t *testing.T) { require.Equal(t, []uuid.UUID{cursorOnly}, got[codersdk.AppFamilyUnknown]) require.Empty(t, got[codersdk.AppFamilyJetBrains]) } + +func TestSessionCountApps(t *testing.T) { + t.Parallel() + + apps := codersdk.SessionCountApps(map[string]int64{"cursor": 2, "vscodium": 1, "future_ide": 3}) + require.Equal(t, map[string]codersdk.SessionCountApp{ + "cursor": {Count: 2, DisplayName: "Cursor", Icon: "/icon/cursor.svg", Family: codersdk.AppFamilyVSCode}, + "vscodium": {Count: 1, DisplayName: "VSCodium", Family: codersdk.AppFamilyVSCode}, + // An app the registry does not know shows its own name. + "future_ide": {Count: 3, DisplayName: "future_ide", Family: codersdk.AppFamilyUnknown}, + }, apps) + require.Equal(t, map[string]codersdk.SessionCountApp{}, codersdk.SessionCountApps(nil)) +} + +func TestSessionCountAppIcons(t *testing.T) { + t.Parallel() + + // Every icon is a bundled, clean path, never an arbitrary URL. + for name := range codersdk.SessionCountAppFamilies() { + app := codersdk.SessionCountApps(map[string]int64{name: 1})[name] + require.NotEmpty(t, app.DisplayName, name) + if app.Icon == "" { + continue + } + require.True(t, strings.HasPrefix(app.Icon, "/icon/"), name) + require.Equal(t, filepath.Clean(app.Icon), app.Icon) + require.FileExists(t, filepath.Join("..", "site", "static", app.Icon), "icon for %s must be bundled", name) + } +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index df79a7147c..df172391e5 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -5462,9 +5462,16 @@ type WorkspaceDeploymentStats struct { } type SessionCountDeploymentStats struct { - VSCode int64 `json:"vscode"` - SSH int64 `json:"ssh"` - JetBrains int64 `json:"jetbrains"` + // Apps holds one entry per reported app name, each carrying the family it + // totals under. The fields below duplicate those totals for one release. + Apps map[string]SessionCountApp `json:"apps"` + // Deprecated: total Apps by Family instead. + VSCode int64 `json:"vscode"` + // Deprecated: total Apps by Family instead. + SSH int64 `json:"ssh"` + // Deprecated: total Apps by Family instead. + JetBrains int64 `json:"jetbrains"` + // Deprecated: total Apps by Family instead. ReconnectingPTY int64 `json:"reconnecting_pty"` } diff --git a/docs/admin/integrations/prometheus.md b/docs/admin/integrations/prometheus.md index 16d8097c28..188cfc72c6 100644 --- a/docs/admin/integrations/prometheus.md +++ b/docs/admin/integrations/prometheus.md @@ -108,6 +108,104 @@ deployment. They will always be available from the agent. The `coder_ai_gateway_cost_control_*` metrics are exported only by `coderd`. +## Monitor sessions by application + +Coder exports session counts and application metadata separately: + +- `coderd_agentstats_session_count` reports active sessions by normalized `app_name`, with the other agent identity labels. +- `coderd_agentstats_app_info` reports `app_name`, `family`, and a value of `1` for every registered application, including inactive ones. + +Before each execution or dashboard refresh, replace `1790243193` in every info selector with the current Unix time in seconds. +This enriches historical samples without rewriting them; a fixed timestamp freezes metadata, while `@ end()` uses the graph's end time. + +To total sessions by family for one deployment, use the same deployment-unique selector on both metrics. +The `job="coder"` selector matches this page's scrape example. + +```promql +sum by (family) ( + ( + sum by (app_name) ( + coderd_agentstats_session_count{job="coder"} + ) + * on (app_name) group_left (family) + max by (app_name, family) ( + coderd_agentstats_app_info{job="coder"} @ 1790243193 + ) + ) + or + label_replace( + sum by (app_name) ( + coderd_agentstats_session_count{job="coder"} + ) + unless on (app_name) + max by (app_name) ( + coderd_agentstats_app_info{job="coder"} @ 1790243193 + ), + "family", "unknown", "", "" + ) +) +``` + +The fallback assigns `family="unknown"` to unregistered applications and unavailable metadata. + +### Query multiple deployments + +Coder doesn't export a deployment label. +Attach one to every target and include it in selectors, aggregations, and vector matches. +This example uses `coder_deployment`. + +
Expand the multi-deployment query + +```promql +sum by (coder_deployment, family) ( + ( + sum by (coder_deployment, app_name) ( + coderd_agentstats_session_count{job="coder", coder_deployment=~".+"} + ) + * on (coder_deployment, app_name) group_left (family) + max by (coder_deployment, app_name, family) ( + coderd_agentstats_app_info{job="coder", coder_deployment=~".+"} @ 1790243193 + ) + ) + or + label_replace( + sum by (coder_deployment, app_name) ( + coderd_agentstats_session_count{job="coder", coder_deployment=~".+"} + ) + unless on (coder_deployment, app_name) + max by (coder_deployment, app_name) ( + coderd_agentstats_app_info{job="coder", coder_deployment=~".+"} @ 1790243193 + ), + "family", "unknown", "", "" + ) +) +``` + +
+ +`max` deduplicates identical replica metadata. +Conflicting families for the same deployment and application cause a many-to-many matching error; resolve the replica version mismatch and wait for agreement. + +### Plan your series count + +- Session gauge: one series per agent and active application, with at most 64 names per report. + Extra names become `app_name="overflow"`; blank names become `app_name="unknown"`. +- Info gauge: one series per registered application and `coderd` replica. +- Deprecated gauges: four series per agent, whether used or not. + +The 64-name cap limits each report, not the deployment total. + +### Understand collection behavior + +A collection with reports replaces the session gauge, so queries and alerts must tolerate removed series. +A collection without reports retains the previous series. +When identity labels combine agents, Coder keeps `app_name` and sums their values. + +### Migrate off the deprecated gauges + +The unchanged family-specific gauges count the same sessions as `coderd_agentstats_session_count`. +Querying both counts sessions twice. + | Name | Type | Description | Labels | @@ -199,15 +297,17 @@ The `coder_ai_gateway_cost_control_*` metrics are exported only by `coderd`. | `coderd_agents_connections` | gauge | Agent connections with statuses. | `agent_name` `lifecycle_state` `status` `tailnet_node` `username` `workspace_name` | | `coderd_agents_first_connection_seconds` | histogram | Duration from agent creation to first connection in seconds. | `agent_name` `template_name` | | `coderd_agents_up` | gauge | The number of active agents per workspace. | `template_name` `template_version` `username` `workspace_name` | +| `coderd_agentstats_app_info` | gauge | The current family of each registered session app. Value is always 1. | `app_name` `family` | | `coderd_agentstats_connection_count` | gauge | The number of established connections by agent | `agent_name` `username` `workspace_name` | | `coderd_agentstats_connection_median_latency_seconds` | gauge | The median agent connection latency | `agent_name` `username` `workspace_name` | | `coderd_agentstats_currently_reachable_peers` | gauge | The number of peers (e.g. clients) that are currently reachable over the encrypted network. | `agent_name` `connection_type` `template_name` `username` `workspace_name` | | `coderd_agentstats_rx_bytes` | gauge | Agent Rx bytes | `agent_name` `username` `workspace_name` | +| `coderd_agentstats_session_count` | gauge | The number of sessions established by app name | `agent_name` `app_name` `username` `workspace_name` | | `coderd_agentstats_session_count_jetbrains` | gauge | The number of session established by JetBrains | `agent_name` `username` `workspace_name` | | `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_session_counts_overflow_total` | counter | Total number of reported session count entries summed into the overflow 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. | | @@ -360,17 +460,17 @@ The `coder_ai_gateway_cost_control_*` metrics are exported only by `coderd`. The following metrics support native histograms: -* `coderd_workspace_creation_duration_seconds` -* `coderd_prebuilt_workspace_claim_duration_seconds` -* `coderd_template_workspace_build_duration_seconds` -* `coder_pubsub_send_duration_seconds` -* `coder_pubsub_receive_duration_seconds` +- `coderd_workspace_creation_duration_seconds` +- `coderd_prebuilt_workspace_claim_duration_seconds` +- `coderd_template_workspace_build_duration_seconds` +- `coder_pubsub_send_duration_seconds` +- `coder_pubsub_receive_duration_seconds` Native histograms are an **experimental** Prometheus feature that removes the need to predefine bucket boundaries and allows higher-resolution buckets that adapt to deployment characteristics. Whether a metric is exposed as classic or native depends entirely on the Prometheus server configuration (see [Prometheus docs](https://prometheus.io/docs/specs/native_histograms/) for details): -* If native histograms are enabled, Prometheus ingests the high-resolution histogram. -* If not, it falls back to the predefined buckets. +- If native histograms are enabled, Prometheus ingests the high-resolution histogram. +- If not, it falls back to the predefined buckets. ⚠️ Important: classic and native histograms cannot be aggregated together. If Prometheus is switched from classic to native at a certain point in time, dashboards may need to account for that transition. For this reason, it’s recommended to follow [Prometheus’ migration guidelines](https://prometheus.io/docs/specs/native_histograms/#migration-considerations) when moving from classic to native histograms. diff --git a/docs/admin/setup/telemetry.md b/docs/admin/setup/telemetry.md index d8f6aa012c..d8a0a82d1e 100644 --- a/docs/admin/setup/telemetry.md +++ b/docs/admin/setup/telemetry.md @@ -23,6 +23,8 @@ As a rule, we **do not collect** the following types of information: For example, we do not collect parameters, environment variables, or user email addresses. We do collect the administrator email. +Workspace agent statistics include session counts keyed by normalized application names, which can include custom IDE names that workspace agents report. + ## Why we collect Telemetry helps us understand which features are most valuable, what use cases diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index eb86b0254e..c2ed8ef103 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -768,6 +768,20 @@ curl -X GET http://coder-server:8080/api/v2/deployment/stats \ "collected_at": "2019-08-24T14:15:22Z", "next_update_at": "2019-08-24T14:15:22Z", "session_count": { + "apps": { + "property1": { + "count": 0, + "display_name": "string", + "family": "vscode", + "icon": "string" + }, + "property2": { + "count": 0, + "display_name": "string", + "family": "vscode", + "icon": "string" + } + }, "jetbrains": 0, "reconnecting_pty": 0, "ssh": 0, diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index a69c4f30a2..8021921b68 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1584,6 +1584,20 @@ None |-------------------------------------| | `envbox`, `envbuilder`, `exectrace` | +## codersdk.AppFamilyName + +```json +"vscode" +``` + +### Properties + +#### Enumerated Values + +| Value(s) | +|---------------------------------------------------------------------| +| `jetbrains`, `reconnecting_pty`, `sftp`, `ssh`, `unknown`, `vscode` | + ## codersdk.AppHostResponse ```json @@ -7852,6 +7866,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "collected_at": "2019-08-24T14:15:22Z", "next_update_at": "2019-08-24T14:15:22Z", "session_count": { + "apps": { + "property1": { + "count": 0, + "display_name": "string", + "family": "vscode", + "icon": "string" + }, + "property2": { + "count": 0, + "display_name": "string", + "family": "vscode", + "icon": "string" + } + }, "jetbrains": 0, "reconnecting_pty": 0, "ssh": 0, @@ -13568,10 +13596,44 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith |-------------------------| | `data`, `error`, `ping` | +## codersdk.SessionCountApp + +```json +{ + "count": 0, + "display_name": "string", + "family": "vscode", + "icon": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------------|--------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------| +| `count` | integer | false | | | +| `display_name` | string | false | | Display name is the registry's name for a known app, otherwise the normalized identifier itself. | +| `family` | [codersdk.AppFamilyName](#codersdkappfamilyname) | false | | Family is the group this app totals under. | +| `icon` | string | false | | Icon is a bundled path under /icon/, empty if the app has none. | + ## codersdk.SessionCountDeploymentStats ```json { + "apps": { + "property1": { + "count": 0, + "display_name": "string", + "family": "vscode", + "icon": "string" + }, + "property2": { + "count": 0, + "display_name": "string", + "family": "vscode", + "icon": "string" + } + }, "jetbrains": 0, "reconnecting_pty": 0, "ssh": 0, @@ -13581,12 +13643,14 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|---------|----------|--------------|-------------| -| `jetbrains` | integer | false | | | -| `reconnecting_pty` | integer | false | | | -| `ssh` | integer | false | | | -| `vscode` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +|--------------------|------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| `apps` | object | false | | Apps holds one entry per reported app name, each carrying the family it totals under. The fields below duplicate those totals for one release. | +| » `[any property]` | [codersdk.SessionCountApp](#codersdksessioncountapp) | false | | | +| `jetbrains` | integer | false | | Deprecated: total Apps by Family instead. | +| `reconnecting_pty` | integer | false | | Deprecated: total Apps by Family instead. | +| `ssh` | integer | false | | Deprecated: total Apps by Family instead. | +| `vscode` | integer | false | | Deprecated: total Apps by Family instead. | ## codersdk.SessionLifetime diff --git a/scripts/metricsdocgen/generated_metrics b/scripts/metricsdocgen/generated_metrics index 70ec94e2c1..d5baa5e0b0 100644 --- a/scripts/metricsdocgen/generated_metrics +++ b/scripts/metricsdocgen/generated_metrics @@ -256,6 +256,9 @@ coderd_agents_first_connection_seconds{template_name="",agent_name=""} 0 # HELP coderd_agents_up The number of active agents per workspace. # TYPE coderd_agents_up gauge coderd_agents_up{username="",workspace_name="",template_name="",template_version=""} 0 +# HELP coderd_agentstats_app_info The current family of each registered session app. Value is always 1. +# TYPE coderd_agentstats_app_info gauge +coderd_agentstats_app_info{app_name="",family=""} 0 # HELP coderd_agentstats_connection_count The number of established connections by agent # TYPE coderd_agentstats_connection_count gauge coderd_agentstats_connection_count 0 @@ -268,6 +271,9 @@ coderd_agentstats_currently_reachable_peers{connection_type=""} 0 # HELP coderd_agentstats_rx_bytes Agent Rx bytes # TYPE coderd_agentstats_rx_bytes gauge coderd_agentstats_rx_bytes 0 +# HELP coderd_agentstats_session_count The number of sessions established by app name +# TYPE coderd_agentstats_session_count gauge +coderd_agentstats_session_count 0 # HELP coderd_agentstats_session_count_jetbrains The number of session established by JetBrains # TYPE coderd_agentstats_session_count_jetbrains gauge coderd_agentstats_session_count_jetbrains 0 @@ -280,7 +286,7 @@ 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. +# HELP coderd_agentstats_session_counts_overflow_total Total number of reported session count entries summed into the overflow 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. diff --git a/scripts/metricsdocgen/metrics b/scripts/metricsdocgen/metrics index f69ca54736..9a5c4f6b86 100644 --- a/scripts/metricsdocgen/metrics +++ b/scripts/metricsdocgen/metrics @@ -16,6 +16,9 @@ coderd_agentstats_currently_reachable_peers{agent_name="main",connection_type="d # HELP coderd_agentstats_rx_bytes Agent Rx bytes # TYPE coderd_agentstats_rx_bytes gauge coderd_agentstats_rx_bytes{agent_name="main",username="admin",workspace_name="workspace1"} 7731 +# HELP coderd_agentstats_session_count The number of sessions established by app name +# TYPE coderd_agentstats_session_count gauge +coderd_agentstats_session_count{agent_name="main",app_name="cursor",username="admin",workspace_name="workspace1"} 1 # HELP coderd_agentstats_session_count_jetbrains The number of session established by JetBrains # TYPE coderd_agentstats_session_count_jetbrains gauge coderd_agentstats_session_count_jetbrains{agent_name="main",username="admin",workspace_name="workspace1"} 0 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e9635f8179..824667ce81 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1534,6 +1534,24 @@ export const AgentsUnsupportedProviderTypes: AgentsUnsupportedProviderType[] = [ */ export const AnthropicInlineImageCapBytes = 5242880; +// From codersdk/appname.go +export type AppFamilyName = + | "jetbrains" + | "reconnecting_pty" + | "sftp" + | "ssh" + | "unknown" + | "vscode"; + +export const AppFamilyNames: AppFamilyName[] = [ + "jetbrains", + "reconnecting_pty", + "sftp", + "ssh", + "unknown", + "vscode", +]; + // From codersdk/deployment.go export interface AppHostResponse { /** @@ -1542,6 +1560,12 @@ export interface AppHostResponse { readonly host: string; } +// From codersdk/appname.go +/** + * AppNameOverflow sums the app names past the per-report cap. + */ +export const AppNameOverflow = "overflow"; + // From codersdk/deployment.go export interface AppearanceConfig { readonly application_name: string; @@ -8794,11 +8818,49 @@ export interface ServiceBannerConfig { readonly background_color?: string; } +// From codersdk/appname.go +/** + * SessionCountApp is one app's session count and how to present it. + */ +export interface SessionCountApp { + readonly count: number; + /** + * DisplayName is the registry's name for a known app, otherwise the + * normalized identifier itself. + */ + readonly display_name: string; + /** + * Icon is a bundled path under /icon/, empty if the app has none. + */ + readonly icon?: string; + /** + * Family is the group this app totals under. + */ + readonly family: AppFamilyName; +} + // From codersdk/deployment.go export interface SessionCountDeploymentStats { + /** + * Apps holds one entry per reported app name, each carrying the family it + * totals under. The fields below duplicate those totals for one release. + */ + readonly apps: Record; + /** + * @deprecated total Apps by Family instead. + */ readonly vscode: number; + /** + * @deprecated total Apps by Family instead. + */ readonly ssh: number; + /** + * @deprecated total Apps by Family instead. + */ readonly jetbrains: number; + /** + * @deprecated total Apps by Family instead. + */ readonly reconnecting_pty: number; } diff --git a/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.stories.tsx b/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.stories.tsx index 409121e94b..a674202cc5 100644 --- a/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.stories.tsx +++ b/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.stories.tsx @@ -1,11 +1,60 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import type { + AppFamilyName, + DeploymentStats, + SessionCountApp, + SessionCountDeploymentStats, +} from "#/api/typesGenerated"; import { DeploymentHealthUnhealthy, MockDeploymentStats, } from "#/testHelpers/entities"; import { DeploymentBannerView } from "./DeploymentBannerView"; +const app = ( + count: number, + display_name: string, + family: AppFamilyName = "unknown", + icon?: string, +): SessionCountApp => ({ count, display_name, family, icon }); + +/** Zeroes the deprecated family totals, since the banner reads apps. */ +const withSessionCount = ( + apps: SessionCountDeploymentStats["apps"], +): DeploymentStats => ({ + ...MockDeploymentStats, + session_count: { + vscode: 0, + jetbrains: 0, + ssh: 0, + reconnecting_pty: 0, + apps, + }, +}); + +const otherApps = withSessionCount({ + ...MockDeploymentStats.session_count.apps, + unknown_app: app(3, "unknown_app"), + long_name: app( + 1, + "A workspace application with an intentionally long display name", + ), + offsite_icon: app( + 1, + "Offsite Icon", + "unknown", + "https://example.com/icon.svg", + ), + sftp: app(2, "SFTP", "sftp", "/icon/terminal.svg"), + ...Object.fromEntries( + Array.from({ length: 20 }, (_, i) => [ + `custom_${i}`, + app(1, `custom_${i}`), + ]), + ), +}); + const meta: Meta = { title: "modules/dashboard/DeploymentBannerView", component: DeploymentBannerView, @@ -19,6 +68,67 @@ type Story = StoryObj; export const Example: Story = {}; +export const Loading: Story = { + args: { stats: undefined }, + play: async ({ canvasElement }) => { + within(canvasElement) + .getByRole("button", { + name: "Visual Studio Code: - loading active connections", + }) + .focus(); + await waitFor(() => screen.getByRole("tooltip")); + }, +}; + +export const NoActiveConnections: Story = { + args: { stats: withSessionCount({}) }, + play: async ({ canvasElement }) => { + await userEvent.hover( + within(canvasElement).getByRole("button", { + name: "Visual Studio Code: 0 active connections", + }), + ); + await waitFor(() => screen.getByRole("tooltip")); + }, +}; + +/** VS Code and its forks, as a real deployment reports them. */ +export const VSCodeForks: Story = { + args: { + stats: withSessionCount({ + ...MockDeploymentStats.session_count.apps, + vscode_insiders: app( + 12, + "VS Code Insiders", + "vscode", + "/icon/code-insiders.svg", + ), + antigravity: app(9, "Antigravity", "vscode", "/icon/antigravity.svg"), + }), + }, + play: async ({ canvasElement }) => { + await userEvent.hover( + within(canvasElement).getByRole("button", { + name: "Visual Studio Code: 173 active connections", + }), + ); + await waitFor(() => screen.getByRole("tooltip")); + }, +}; + +/** Long names, an offsite icon, a family without a slot, and a scrolling list. */ +export const OtherApps: Story = { + args: { stats: otherApps }, + play: async ({ canvasElement }) => { + await userEvent.hover( + within(canvasElement).getByRole("button", { + name: "Other: 27 active connections", + }), + ); + await waitFor(() => screen.getByRole("tooltip")); + }, +}; + export const WithHealthIssues: Story = { args: { health: DeploymentHealthUnhealthy, diff --git a/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.test.tsx b/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.test.tsx new file mode 100644 index 0000000000..7aa782f4c2 --- /dev/null +++ b/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.test.tsx @@ -0,0 +1,37 @@ +import type { AppFamilyName, SessionCountApp } from "#/api/typesGenerated"; +import { groupSessionApps } from "./DeploymentBannerView"; + +const app = ( + count: number, + display_name: string, + family: AppFamilyName = "unknown", +): SessionCountApp => ({ count, display_name, family }); + +describe("groupSessionApps", () => { + it("groups active apps by family, ordered by display name then identifier", () => { + const groups = groupSessionApps({ + vscodium: app(1, "VSCodium", "vscode"), + codium: app(1, "VSCodium", "vscode"), + cursor: app(9, "Cursor", "vscode"), + zed: app(3, "Zed", "ssh"), + sftp: app(2, "SFTP", "sftp"), + future_ide: app(4, "future_ide"), + idle: app(0, "idle", "ssh"), + negative: app(-1, "negative", "jetbrains"), + }); + + const ids = Object.fromEntries( + [...groups].map(([family, apps]) => [family, apps.map((a) => a.id)]), + ); + // sftp has no slot in the banner, so it joins unknown. + expect(ids).toEqual({ + vscode: ["cursor", "codium", "vscodium"], + ssh: ["zed"], + unknown: ["future_ide", "sftp"], + }); + }); + + it("handles a deployment that reported no apps", () => { + expect(groupSessionApps()).toEqual(new Map()); + }); +}); diff --git a/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.tsx b/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.tsx index 0607eb5a97..04d7b0d1df 100644 --- a/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.tsx +++ b/site/src/modules/dashboard/DeploymentBanner/DeploymentBannerView.tsx @@ -1,6 +1,7 @@ import dayjs from "dayjs"; import { AppWindowIcon, + BlocksIcon, CircleAlertIcon, CloudDownloadIcon, CloudUploadIcon, @@ -14,22 +15,30 @@ import { import prettyBytes from "pretty-bytes"; import { type FC, + Fragment, + memo, type PropsWithChildren, + type ReactNode, useEffect, useMemo, useState, } from "react"; import { Link as RouterLink } from "react-router"; -import type { - DeploymentStats, - HealthcheckReport, - WorkspaceStatus, +import { + type AppFamilyName, + AppFamilyNames, + type DeploymentStats, + type HealthcheckReport, + type SessionCountApp, + type SessionCountDeploymentStats, + type WorkspaceStatus, } from "#/api/typesGenerated"; import { Button } from "#/components/Button/Button"; import { ExternalImage } from "#/components/ExternalImage/ExternalImage"; import { HelpPopoverTitle } from "#/components/HelpPopover/HelpPopover"; import { Link } from "#/components/Link/Link"; import { + TOOLTIP_DELAY_DURATION, Tooltip, TooltipContent, TooltipProvider, @@ -237,79 +246,9 @@ export const DeploymentBannerView: FC = ({ -
-
Active Connections
+ -
- - - -
- - {typeof stats?.session_count.vscode === "undefined" - ? "-" - : stats?.session_count.vscode} -
-
- - VS Code Editors with the Coder Remote Extension - -
-
- - - - -
- - {typeof stats?.session_count.jetbrains === "undefined" - ? "-" - : stats?.session_count.jetbrains} -
-
- JetBrains Editors -
-
- - - - -
- - {typeof stats?.session_count.ssh === "undefined" - ? "-" - : stats?.session_count.ssh} -
-
- SSH Sessions -
-
- - - - -
- - {typeof stats?.session_count.reconnecting_pty === "undefined" - ? "-" - : stats?.session_count.reconnecting_pty} -
-
- Web Terminal Sessions -
-
-
-
- -
+
@@ -358,6 +297,140 @@ export const DeploymentBannerView: FC = ({ ); }; +/** + * Banner slots in bar order. A family added in Go fails typecheck until it + * gets a slot here, or null to total under Other. + */ +const SESSION_FAMILIES = { + vscode: { + name: "Visual Studio Code", + icon: , + }, + jetbrains: { + name: "JetBrains", + icon: , + }, + ssh: { name: "SSH", icon: }, + reconnecting_pty: { + name: "Web Terminal", + icon: , + }, + unknown: { name: "Other", icon: }, + sftp: null, +} satisfies Record; + +const FAMILY_SLOTS = Object.entries(SESSION_FAMILIES).flatMap( + ([name, slot]) => { + const key = AppFamilyNames.find((family) => family === name); + return key && slot ? [{ key, ...slot }] : []; + }, +); + +const APP_COLLATOR = new Intl.Collator("en-US", { numeric: true }); + +/** Groups active apps by banner slot, sorted by name so rows stay put. */ +export const groupSessionApps = ( + apps: SessionCountDeploymentStats["apps"] = {}, +) => { + const groups = new Map(); + const sorted = Object.entries(apps) + .filter(([, app]) => app.count > 0) + .map(([id, app]) => ({ ...app, id })) + .sort( + (first, second) => + APP_COLLATOR.compare(first.display_name, second.display_name) || + APP_COLLATOR.compare(first.id, second.id), + ); + for (const app of sorted) { + const family = SESSION_FAMILIES[app.family] ? app.family : "unknown"; + groups.set(family, [...(groups.get(family) ?? []), app]); + } + return groups; +}; + +/** + * Memoized because the banner rerenders every second for its countdown, while + * these rows change once per poll. + */ +const ActiveConnections = memo( + ({ sessionCount }: { sessionCount?: SessionCountDeploymentStats }) => { + const groups = groupSessionApps(sessionCount?.apps); + + return ( + +
+
Active Connections
+
+ {FAMILY_SLOTS.map(({ key, name, icon }, index) => { + const apps = groups.get(key) ?? []; + const count = apps.reduce((sum, app) => sum + app.count, 0); + return ( + + {index > 0 && } + + + + + +
+ {name} +
+ {!sessionCount ? ( +
Loading active connections
+ ) : apps.length === 0 ? ( +
No active connections
+ ) : ( +
    + {apps.map((app) => ( +
  • + + {app.count} +
  • + ))} +
+ )} +
+
+
+ ); + })} +
+
+
+ ); + }, +); + +/** Renders an app's icon and name. Only bundled "/icon/" paths load. */ +const AppLabel: FC<{ icon?: string; name: string }> = ({ icon, name }) => ( + <> + {icon?.startsWith("/icon/") ? ( + + ) : ( + + )} + {name} + +); + type WorkspaceBuildValueProps = { status: WorkspaceStatus; count?: number; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index ea87802574..ab9bfcc08e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3868,10 +3868,41 @@ export const MockDeploymentStats: TypesGen.DeploymentStats = { collected_at: "2023-03-06T19:12:55.211625Z", next_update_at: "2023-03-06T19:20:55.211625Z", session_count: { - vscode: 128, + vscode: 152, jetbrains: 5, ssh: 32, reconnecting_pty: 15, + apps: { + cursor: { + count: 24, + display_name: "Cursor", + icon: "/icon/cursor.svg", + family: "vscode", + }, + vscode: { + count: 128, + display_name: "VS Code", + icon: "/icon/code.svg", + family: "vscode", + }, + jetbrains: { + count: 5, + display_name: "JetBrains", + icon: "/icon/jetbrains.svg", + family: "jetbrains", + }, + ssh: { + count: 32, + display_name: "SSH", + icon: "/icon/terminal.svg", + family: "ssh", + }, + reconnecting_pty: { + count: 15, + display_name: "Web Terminal", + family: "reconnecting_pty", + }, + }, }, workspaces: { building: 15,