mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
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
130 lines
3.6 KiB
Go
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))
|
|
}
|