Files
coder/coderd/workspacestats/batcher_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

297 lines
10 KiB
Go

package workspacestats
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/google/uuid"
prom_testutil "github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stretchr/testify/require"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/slogtest"
agentproto "github.com/coder/coder/v2/agent/proto"
"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/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/cryptorand"
"github.com/coder/coder/v2/testutil"
)
func TestBatchStats(t *testing.T) {
t.Parallel()
// Given: a fresh batcher with no data
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
store, ps := dbtestutil.NewDB(t)
// Set up some test dependencies.
deps1 := setupDeps(t, store, ps)
deps2 := setupDeps(t, store, ps)
tick := make(chan time.Time)
flushed := make(chan int, 1)
b, closer, err := NewBatcher(ctx,
BatcherWithStore(store),
BatcherWithLogger(log),
func(b *DBBatcher) {
b.tickCh = tick
b.flushed = flushed
},
)
require.NoError(t, err)
t.Cleanup(closer)
// Given: no data points are added for workspace
// When: it becomes time to report stats
t1 := dbtime.Now()
// Signal a tick and wait for a flush to complete.
tick <- t1
f := <-flushed
require.Equal(t, 0, f, "expected no data to be flushed")
t.Log("flush 1 completed")
// Then: it should report no stats.
stats, err := store.GetWorkspaceAgentStats(ctx, t1)
require.NoError(t, err, "should not error getting stats")
require.Empty(t, stats, "should have no stats for workspace")
// Given: a stat per workspace, with session counts distinct per agent so
// that a positional misalignment on insert shows up.
t2 := t1.Add(time.Second)
t.Log("inserting 2 stats")
b.Add(t2.Add(time.Millisecond), deps1.Agent.ID, deps1.Template.ID, deps1.User.ID, deps1.Workspace.ID, randStats(t, func(s *agentproto.Stats) {
s.SessionCounts = map[string]int64{"VSCode": 3, "ssh": 1, "idle-ide": 0}
}), false)
b.Add(t2.Add(time.Millisecond), deps2.Agent.ID, deps2.Template.ID, deps2.User.ID, deps2.Workspace.ID, randStats(t, func(s *agentproto.Stats) {
s.SessionCounts = map[string]int64{"jetbrains": 4, "reconnecting-pty": 2}
}), false)
// When: it becomes time to report stats
// Signal a tick and wait for a flush to complete.
tick <- t2
f = <-flushed // Wait for a flush to complete.
require.Equal(t, 2, f, "expected two stats to be flushed")
t.Log("flush 2 completed")
// Then: counts reach the right agent, normalized, without the zero entry.
stats, err = store.GetWorkspaceAgentStats(ctx, t2)
require.NoError(t, err, "should not error getting stats")
require.Len(t, stats, 2, "should have stats for both workspaces")
byAgent := make(map[uuid.UUID]database.GetWorkspaceAgentStatsRow)
for _, stat := range stats {
byAgent[stat.AgentID] = stat
}
require.EqualValues(t, 3, sessionFamilyCounts(t, byAgent[deps1.Agent.ID].SessionCounts)["vscode"])
require.EqualValues(t, 1, sessionFamilyCounts(t, byAgent[deps1.Agent.ID].SessionCounts)["ssh"])
require.EqualValues(t, 0, sessionFamilyCounts(t, byAgent[deps1.Agent.ID].SessionCounts)["jetbrains"])
require.EqualValues(t, 4, sessionFamilyCounts(t, byAgent[deps2.Agent.ID].SessionCounts)["jetbrains"])
require.EqualValues(t, 2, sessionFamilyCounts(t, byAgent[deps2.Agent.ID].SessionCounts)["reconnecting_pty"])
require.EqualValues(t, 0, sessionFamilyCounts(t, byAgent[deps2.Agent.ID].SessionCounts)["vscode"])
// Given: a lot of data points are added for both workspaces
// (equal to batch size)
t3 := t2.Add(time.Second)
done := make(chan struct{})
go func() {
defer close(done)
t.Logf("inserting %d stats", defaultBufferSize)
for i := 0; i < defaultBufferSize; i++ {
if i%2 == 0 {
b.Add(t3.Add(time.Millisecond), deps1.Agent.ID, deps1.Template.ID, deps1.User.ID, deps1.Workspace.ID, randStats(t), false)
} else {
b.Add(t3.Add(time.Millisecond), deps2.Agent.ID, deps2.Template.ID, deps2.User.ID, deps2.Workspace.ID, randStats(t), false)
}
}
}()
// When: the buffer comes close to capacity
// Then: The buffer will force-flush once.
f = <-flushed
t.Log("flush 3 completed")
require.Greater(t, f, 819, "expected at least 819 stats to be flushed (>=80% of buffer)")
// And we should finish inserting the stats
<-done
stats, err = store.GetWorkspaceAgentStats(ctx, t3)
require.NoError(t, err, "should not error getting stats")
require.Len(t, stats, 2, "should have stats for both workspaces")
// Ensures that a subsequent flush pushes all the remaining data
t4 := t3.Add(time.Second)
tick <- t4
f2 := <-flushed
t.Log("flush 4 completed")
expectedCount := defaultBufferSize - f
require.Equal(t, expectedCount, f2, "did not flush expected remaining rows")
// Ensure that a subsequent flush does not push stale data.
t5 := t4.Add(time.Second)
tick <- t5
f = <-flushed
require.Zero(t, f, "expected zero stats to have been flushed")
t.Log("flush 5 completed")
stats, err = store.GetWorkspaceAgentStats(ctx, t5)
require.NoError(t, err, "should not error getting stats")
require.Len(t, stats, 0, "should have no stats for workspace")
// Ensure that buf never grew beyond what we expect
require.Equal(t, defaultBufferSize, cap(b.buf.ID), "buffer grew beyond expected capacity")
}
// TestBatchStatsSessionCountFold asserts that a report over the session count
// cap folds the excess into the overflow counter and logs it at debug, never
// at warn, so one misbehaving agent cannot flood the logs.
func TestBatchStatsSessionCountFold(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
sink := testutil.NewFakeSink(t)
store, ps := dbtestutil.NewDB(t)
deps := setupDeps(t, store, ps)
b, closer, err := NewBatcher(ctx,
BatcherWithStore(store),
BatcherWithLogger(sink.Logger(slog.LevelDebug)),
func(b *DBBatcher) {
// Take control of flushes so they do not interleave with the
// assertions below.
b.tickCh = make(chan time.Time)
},
)
require.NoError(t, err)
t.Cleanup(closer)
const extra = 6
st := randStats(t, func(s *agentproto.Stats) {
s.SessionCounts = make(map[string]int64, maxSessionCountEntries+extra)
for i := range maxSessionCountEntries + extra {
s.SessionCounts[fmt.Sprintf("app_%d", i)] = 1
}
})
b.Add(dbtime.Now(), deps.Agent.ID, deps.Template.ID, deps.User.ID, deps.Workspace.ID, st, false)
overcap := sink.Entries(func(e slog.SinkEntry) bool {
return strings.Contains(e.Message, "too many distinct session types")
})
require.Len(t, overcap, 1)
require.Equal(t, slog.LevelDebug, overcap[0].Level)
require.Equal(t, float64(extra), prom_testutil.ToFloat64(b.metrics.SessionCountsOverflowTotal))
}
// randStats returns a random agentproto.Stats
func randStats(t *testing.T, opts ...func(*agentproto.Stats)) *agentproto.Stats {
t.Helper()
s := &agentproto.Stats{
ConnectionsByProto: map[string]int64{
"ssh": mustRandInt64n(t, 9) + 1,
"vscode": mustRandInt64n(t, 9) + 1,
"jetbrains": mustRandInt64n(t, 9) + 1,
"reconnecting_pty": mustRandInt64n(t, 9) + 1,
},
ConnectionCount: mustRandInt64n(t, 99) + 1,
ConnectionMedianLatencyMs: float64(mustRandInt64n(t, 99) + 1),
RxPackets: mustRandInt64n(t, 99) + 1,
RxBytes: mustRandInt64n(t, 99) + 1,
TxPackets: mustRandInt64n(t, 99) + 1,
TxBytes: mustRandInt64n(t, 99) + 1,
SessionCountVscode: mustRandInt64n(t, 9) + 1,
SessionCountJetbrains: mustRandInt64n(t, 9) + 1,
SessionCountReconnectingPty: mustRandInt64n(t, 9) + 1,
SessionCountSsh: mustRandInt64n(t, 9) + 1,
Metrics: []*agentproto.Stats_Metric{},
}
for _, opt := range opts {
opt(s)
}
return s
}
// deps is a set of test dependencies.
type deps struct {
Agent database.WorkspaceAgent
Template database.Template
User database.User
Workspace database.WorkspaceTable
}
// setupDeps sets up a set of test dependencies.
// It creates an organization, user, template, workspace, and agent
// along with all the other miscellaneous plumbing required to link
// them together.
func setupDeps(t *testing.T, store database.Store, ps pubsub.Pubsub) deps {
t.Helper()
org := dbgen.Organization(t, store, database.Organization{})
user := dbgen.User(t, store, database.User{})
_, err := store.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{
OrganizationID: org.ID,
UserID: user.ID,
Roles: []string{codersdk.RoleOrganizationMember},
})
require.NoError(t, err)
tv := dbgen.TemplateVersion(t, store, database.TemplateVersion{
OrganizationID: org.ID,
CreatedBy: user.ID,
})
tpl := dbgen.Template(t, store, database.Template{
CreatedBy: user.ID,
OrganizationID: org.ID,
ActiveVersionID: tv.ID,
})
ws := dbgen.Workspace(t, store, database.WorkspaceTable{
TemplateID: tpl.ID,
OwnerID: user.ID,
OrganizationID: org.ID,
LastUsedAt: time.Now().Add(-time.Hour),
})
pj := dbgen.ProvisionerJob(t, store, ps, database.ProvisionerJob{
InitiatorID: user.ID,
OrganizationID: org.ID,
})
_ = dbgen.WorkspaceBuild(t, store, database.WorkspaceBuild{
TemplateVersionID: tv.ID,
WorkspaceID: ws.ID,
JobID: pj.ID,
})
res := dbgen.WorkspaceResource(t, store, database.WorkspaceResource{
Transition: database.WorkspaceTransitionStart,
JobID: pj.ID,
})
agt := dbgen.WorkspaceAgent(t, store, database.WorkspaceAgent{
ResourceID: res.ID,
})
return deps{
Agent: agt,
Template: tpl,
User: user,
Workspace: ws,
}
}
// mustRandInt64n returns a random int64 in the range [0, n).
func mustRandInt64n(t *testing.T, n int64) int64 {
t.Helper()
i, err := cryptorand.Intn(int(n))
require.NoError(t, err)
return int64(i)
}
func sessionFamilyCounts(t *testing.T, data json.RawMessage) map[codersdk.AppFamilyName]int64 {
t.Helper()
counts, err := codersdk.DecodeAppMap[int64](data)
require.NoError(t, err)
return codersdk.SumByFamily(counts)
}