feat: expose per-app session counts (#29351)

Track individual applications across deployment statistics, Prometheus, telemetry, and the dashboard instead of exposing only fixed family totals. Preserve existing fields and gauges so current consumers can migrate without breaking.

- Add per-app counts with normalized identifiers and registry-backed display names, families, and bundled icons. Unrecognized apps retain their identifiers.
- Limit each statistics report to 64 distinct app names, combining excess names into an explicit overflow bucket.
- Group dashboard connections by family, including an Other category, with alphabetical per-app tooltips and distinct loading and empty states.
- Export app-keyed Prometheus counts separately from current family metadata, allowing queries to enrich historical counts without rewriting stored samples.
- Document family queries with unknown fallback, deployment isolation, and replica handling, alongside collection behavior and migration guidance.
- Include per-app counts in telemetry, update API schemas and documentation, and add regression coverage across collection, metrics, and dashboard behavior.

Closes #27411
This commit is contained in:
Ehab Younes
2026-09-24 15:12:28 +03:00
committed by GitHub
parent deafad29cc
commit bacb4fc6ed
32 changed files with 1201 additions and 298 deletions
+1 -1
View File
@@ -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
}
+54
View File
@@ -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"
}
}
+54
View File
@@ -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"
}
}
+2 -2
View File
@@ -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) {
+24 -7
View File
@@ -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)
}
+9 -8
View File
@@ -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 {
+64 -28
View File
@@ -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")
})
}
}
+41 -7
View File
@@ -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()
@@ -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()
+12
View File
@@ -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
}
+24 -22
View File
@@ -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 {
+12 -3
View File
@@ -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)
+1 -1
View File
@@ -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),
@@ -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)
}
+1 -1
View File
@@ -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.",
}),
}
}
+11 -5
View File
@@ -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)
}
@@ -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 {
+87 -60
View File
@@ -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
}
+3 -2
View File
@@ -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)
+36 -51
View File
@@ -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)
}
}
+10 -3
View File
@@ -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"`
}
+108 -8
View File
@@ -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`.
<details><summary>Expand the multi-deployment query</summary>
```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", "", ""
)
)
```
</details>
`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.
<!-- Code generated by 'make docs/admin/integrations/prometheus.md'. DO NOT EDIT -->
| 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.
+2
View File
@@ -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
+14
View File
@@ -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,
+70 -6
View File
@@ -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
+7 -1
View File
@@ -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.
+3
View File
@@ -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
+62
View File
@@ -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<string, SessionCountApp>;
/**
* @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;
}
@@ -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<typeof DeploymentBannerView> = {
title: "modules/dashboard/DeploymentBannerView",
component: DeploymentBannerView,
@@ -19,6 +68,67 @@ type Story = StoryObj<typeof DeploymentBannerView>;
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,
@@ -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());
});
});
@@ -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<DeploymentBannerViewProps> = ({
</div>
</div>
<div className="flex items-center">
<div className="mr-4 text-content-primary">Active Connections</div>
<ActiveConnections sessionCount={stats?.session_count} />
<div className="flex gap-2 text-content-secondary">
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<ExternalImage
src="/icon/code.svg"
alt=""
className="size-icon-xs"
/>
{typeof stats?.session_count.vscode === "undefined"
? "-"
: stats?.session_count.vscode}
</div>
</TooltipTrigger>
<TooltipContent>
VS Code Editors with the Coder Remote Extension
</TooltipContent>
</Tooltip>
</TooltipProvider>
<ValueSeparator />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<ExternalImage
src="/icon/jetbrains.svg"
alt=""
className="size-icon-xs"
/>
{typeof stats?.session_count.jetbrains === "undefined"
? "-"
: stats?.session_count.jetbrains}
</div>
</TooltipTrigger>
<TooltipContent>JetBrains Editors</TooltipContent>
</Tooltip>
</TooltipProvider>
<ValueSeparator />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<SquareTerminalIcon className="size-icon-xs" />
{typeof stats?.session_count.ssh === "undefined"
? "-"
: stats?.session_count.ssh}
</div>
</TooltipTrigger>
<TooltipContent>SSH Sessions</TooltipContent>
</Tooltip>
</TooltipProvider>
<ValueSeparator />
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<AppWindowIcon className="size-icon-xs" />
{typeof stats?.session_count.reconnecting_pty === "undefined"
? "-"
: stats?.session_count.reconnecting_pty}
</div>
</TooltipTrigger>
<TooltipContent>Web Terminal Sessions</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="ml-auto flex mr-3 items-center gap-8 text-content-primary">
<div className="ml-auto flex mr-3 items-center gap-8 pl-8 text-content-primary">
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
@@ -358,6 +297,140 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = ({
);
};
/**
* 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: <ExternalImage src="/icon/code.svg" className="size-icon-xs" />,
},
jetbrains: {
name: "JetBrains",
icon: <ExternalImage src="/icon/jetbrains.svg" className="size-icon-xs" />,
},
ssh: { name: "SSH", icon: <SquareTerminalIcon className="size-icon-xs" /> },
reconnecting_pty: {
name: "Web Terminal",
icon: <AppWindowIcon className="size-icon-xs" />,
},
unknown: { name: "Other", icon: <BlocksIcon className="size-icon-xs" /> },
sftp: null,
} satisfies Record<AppFamilyName, { name: string; icon: ReactNode } | null>;
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<AppFamilyName, (SessionCountApp & { id: string })[]>();
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 (
<TooltipProvider delayDuration={TOOLTIP_DELAY_DURATION}>
<div className="flex items-center">
<div className="mr-4 text-content-primary">Active Connections</div>
<div className="flex gap-2 text-content-secondary">
{FAMILY_SLOTS.map(({ key, name, icon }, index) => {
const apps = groups.get(key) ?? [];
const count = apps.reduce((sum, app) => sum + app.count, 0);
return (
<Fragment key={key}>
{index > 0 && <ValueSeparator />}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="subtle"
size="xs"
aria-label={
sessionCount
? `${name}: ${count} active connections`
: `${name}: - loading active connections`
}
className="min-w-0 gap-1 p-0 font-mono text-xs [&>img]:p-0 [&>svg]:p-0"
>
{icon}
{sessionCount ? count : "-"}
</Button>
</TooltipTrigger>
<TooltipContent className="p-3 font-sans text-xs">
<div className="mb-2 font-medium text-content-primary">
{name}
</div>
{!sessionCount ? (
<div>Loading active connections</div>
) : apps.length === 0 ? (
<div>No active connections</div>
) : (
<ul className="m-0 grid max-h-64 list-none gap-2 overflow-y-auto p-0">
{apps.map((app) => (
<li
key={app.id}
className="flex items-center gap-2"
>
<AppLabel
icon={app.icon}
name={app.display_name}
/>
<span className="font-mono">{app.count}</span>
</li>
))}
</ul>
)}
</TooltipContent>
</Tooltip>
</Fragment>
);
})}
</div>
</div>
</TooltipProvider>
);
},
);
/** Renders an app's icon and name. Only bundled "/icon/" paths load. */
const AppLabel: FC<{ icon?: string; name: string }> = ({ icon, name }) => (
<>
{icon?.startsWith("/icon/") ? (
<ExternalImage src={icon} className="size-icon-xs shrink-0" />
) : (
<AppWindowIcon className="size-icon-xs shrink-0" />
)}
<span className="min-w-0 flex-1 break-words">{name}</span>
</>
);
type WorkspaceBuildValueProps = {
status: WorkspaceStatus;
count?: number;
+32 -1
View File
@@ -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,