mirror of
https://github.com/coder/coder.git
synced 2026-10-16 06:37:58 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Generated
+54
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+54
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+14
@@ -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,
|
||||
|
||||
Generated
+70
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+62
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user