Files
coder/coderd/workspacestats/sessioncounts_internal_test.go
Ehab Younes bacb4fc6ed 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
2026-09-24 15:12:28 +03:00

130 lines
3.6 KiB
Go

package workspacestats
import (
"fmt"
"testing"
"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.
func sessionCountsFromProto(st *agentproto.Stats) map[string]int64 {
counts, _ := capSessionCounts(normalizedSessionCounts(st))
return counts
}
func TestSessionCountsFromProto(t *testing.T) {
t.Parallel()
// The deprecated fields simulate agents older than API v2.11.
for _, tc := range []struct {
name string
stats *agentproto.Stats
want map[string]int64
}{
{
"Empty",
&agentproto.Stats{},
map[string]int64{},
},
{
"NormalizesAndMergesNames",
&agentproto.Stats{SessionCounts: map[string]int64{"VSCode": 1, "vscode": 2, "Reconnecting-PTY": 1}},
map[string]int64{"vscode": 3, "reconnecting_pty": 1},
},
{
"DropsNonPositiveEntries",
&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},
map[string]int64{"cursor": 2, "ssh": 1},
},
{
"OldAgentFallback",
&agentproto.Stats{SessionCountVscode: 3, SessionCountJetbrains: 1, SessionCountSsh: 2},
map[string]int64{"vscode": 3, "jetbrains": 1, "ssh": 2},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, sessionCountsFromProto(tc.stats))
})
}
}
func TestCapSessionCounts(t *testing.T) {
t.Parallel()
// Known names carry the lowest counts and the busiest name sorts last, so
// only the ordering rules can keep them under the cap.
counts := map[string]int64{"ssh": 1, "vscode": 1, "zzz-busy-ide": 5}
const overflowing = 200
for i := range overflowing {
counts[fmt.Sprintf("0ide-%03d", i)] = 2
}
got := sessionCountsFromProto(&agentproto.Stats{SessionCounts: counts})
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 overflow.
require.EqualValues(t, 2, got["0ide_000"])
require.NotContains(t, got, "0ide_199")
require.EqualValues(t, (overflowing-61)*2, got[codersdk.AppNameOverflow])
var reported, stored int64
for _, count := range counts {
reported += count
}
for _, count := range got {
stored += count
}
require.Equal(t, reported, stored, "capping must not lose counts")
}
func TestHasSessionCounts(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
stats *agentproto.Stats
want bool
}{
{"Empty", &agentproto.Stats{}, false},
{"MapPositive", &agentproto.Stats{SessionCounts: map[string]int64{"cursor": 1}}, true},
{"MapZeroOnly", &agentproto.Stats{SessionCounts: map[string]int64{"ssh": 0}}, false},
{"DeprecatedFieldPositive", &agentproto.Stats{SessionCountSsh: 2}, true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tc.want, HasSessionCounts(tc.stats))
})
}
}
func TestClearSessionCounts(t *testing.T) {
t.Parallel()
st := &agentproto.Stats{
SessionCounts: map[string]int64{"vscode": 1},
SessionCountVscode: 1,
SessionCountSsh: 2,
}
ClearSessionCounts(st)
require.Empty(t, sessionCountsFromProto(st))
require.False(t, HasSessionCounts(st))
}