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
368 lines
12 KiB
Go
368 lines
12 KiB
Go
package metricscache_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"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"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
func date(year, month, day int) time.Time {
|
|
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func newMetricsCache(t *testing.T, log slog.Logger, clock quartz.Clock, intervals metricscache.Intervals, usage bool) (*metricscache.Cache, database.Store) {
|
|
t.Helper()
|
|
|
|
accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{}
|
|
var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{}
|
|
accessControlStore.Store(&acs)
|
|
|
|
var (
|
|
auth = rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
|
|
db, _ = dbtestutil.NewDB(t)
|
|
dbauth = dbauthz.New(db, auth, log, accessControlStore)
|
|
cache = metricscache.New(dbauth, log, clock, intervals, usage)
|
|
)
|
|
|
|
t.Cleanup(func() { cache.Close() })
|
|
|
|
return cache, db
|
|
}
|
|
|
|
func TestCache_TemplateWorkspaceOwners(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
log = testutil.Logger(t)
|
|
clock = quartz.NewMock(t)
|
|
)
|
|
|
|
trapTickerFunc := clock.Trap().TickerFunc("metricscache")
|
|
defer trapTickerFunc.Close()
|
|
|
|
cache, db := newMetricsCache(t, log, clock, metricscache.Intervals{
|
|
TemplateBuildTimes: time.Minute,
|
|
}, false)
|
|
|
|
org := dbgen.Organization(t, db, database.Organization{})
|
|
user1 := dbgen.User(t, db, database.User{})
|
|
user2 := dbgen.User(t, db, database.User{})
|
|
template := dbgen.Template(t, db, database.Template{
|
|
OrganizationID: org.ID,
|
|
Provisioner: database.ProvisionerTypeEcho,
|
|
CreatedBy: user1.ID,
|
|
})
|
|
|
|
// Wait for both ticker functions to be created (template build times and deployment stats)
|
|
trapTickerFunc.MustWait(ctx).MustRelease(ctx)
|
|
trapTickerFunc.MustWait(ctx).MustRelease(ctx)
|
|
|
|
clock.Advance(time.Minute).MustWait(ctx)
|
|
|
|
count, ok := cache.TemplateWorkspaceOwners(template.ID)
|
|
require.True(t, ok, "TemplateWorkspaceOwners should be populated")
|
|
require.Equal(t, 0, count, "should have 0 owners initially")
|
|
|
|
dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OrganizationID: org.ID,
|
|
TemplateID: template.ID,
|
|
OwnerID: user1.ID,
|
|
})
|
|
|
|
clock.Advance(time.Minute).MustWait(ctx)
|
|
|
|
count, _ = cache.TemplateWorkspaceOwners(template.ID)
|
|
require.Equal(t, 1, count, "should have 1 owner after adding workspace")
|
|
|
|
workspace2 := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OrganizationID: org.ID,
|
|
TemplateID: template.ID,
|
|
OwnerID: user2.ID,
|
|
})
|
|
|
|
clock.Advance(time.Minute).MustWait(ctx)
|
|
|
|
count, _ = cache.TemplateWorkspaceOwners(template.ID)
|
|
require.Equal(t, 2, count, "should have 2 owners after adding second workspace")
|
|
|
|
// 3rd workspace should not be counted since we have the same owner as workspace2.
|
|
dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OrganizationID: org.ID,
|
|
TemplateID: template.ID,
|
|
OwnerID: user1.ID,
|
|
})
|
|
|
|
db.UpdateWorkspaceDeletedByID(context.Background(), database.UpdateWorkspaceDeletedByIDParams{
|
|
ID: workspace2.ID,
|
|
Deleted: true,
|
|
})
|
|
|
|
clock.Advance(time.Minute).MustWait(ctx)
|
|
|
|
count, _ = cache.TemplateWorkspaceOwners(template.ID)
|
|
require.Equal(t, 1, count, "should have 1 owner after deleting workspace")
|
|
}
|
|
|
|
func clockTime(t time.Time, hour, minute, sec int) time.Time {
|
|
return time.Date(t.Year(), t.Month(), t.Day(), hour, minute, sec, t.Nanosecond(), t.Location())
|
|
}
|
|
|
|
func requireBuildTimeStatsEmpty(t *testing.T, stats codersdk.TemplateBuildTimeStats) {
|
|
require.Empty(t, stats[codersdk.WorkspaceTransitionStart])
|
|
require.Empty(t, stats[codersdk.WorkspaceTransitionStop])
|
|
require.Empty(t, stats[codersdk.WorkspaceTransitionDelete])
|
|
}
|
|
|
|
func TestCache_BuildTime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
someDay := date(2022, 10, 1)
|
|
|
|
type jobParams struct {
|
|
startedAt time.Time
|
|
completedAt time.Time
|
|
}
|
|
|
|
type args struct {
|
|
rows []jobParams
|
|
transition database.WorkspaceTransition
|
|
}
|
|
type want struct {
|
|
buildTimeMs int64
|
|
loads bool
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want want
|
|
}{
|
|
{"empty", args{}, want{-1, false}},
|
|
{
|
|
"one/start", args{
|
|
rows: []jobParams{
|
|
{
|
|
startedAt: clockTime(someDay, 10, 1, 0),
|
|
completedAt: clockTime(someDay, 10, 1, 10),
|
|
},
|
|
},
|
|
transition: database.WorkspaceTransitionStart,
|
|
}, want{10 * 1000, true},
|
|
},
|
|
{
|
|
"two/stop", args{
|
|
rows: []jobParams{
|
|
{
|
|
startedAt: clockTime(someDay, 10, 1, 0),
|
|
completedAt: clockTime(someDay, 10, 1, 10),
|
|
},
|
|
{
|
|
startedAt: clockTime(someDay, 10, 1, 0),
|
|
completedAt: clockTime(someDay, 10, 1, 50),
|
|
},
|
|
},
|
|
transition: database.WorkspaceTransitionStop,
|
|
}, want{10 * 1000, true},
|
|
},
|
|
{
|
|
"three/delete", args{
|
|
rows: []jobParams{
|
|
{
|
|
startedAt: clockTime(someDay, 10, 1, 0),
|
|
completedAt: clockTime(someDay, 10, 1, 10),
|
|
},
|
|
{
|
|
startedAt: clockTime(someDay, 10, 1, 0),
|
|
completedAt: clockTime(someDay, 10, 1, 50),
|
|
},
|
|
{
|
|
startedAt: clockTime(someDay, 10, 1, 0),
|
|
completedAt: clockTime(someDay, 10, 1, 20),
|
|
},
|
|
},
|
|
transition: database.WorkspaceTransitionDelete,
|
|
}, want{20 * 1000, true},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var (
|
|
ctx = testutil.Context(t, testutil.WaitShort)
|
|
log = testutil.Logger(t)
|
|
clock = quartz.NewMock(t)
|
|
)
|
|
|
|
clock.Set(someDay)
|
|
|
|
trapTickerFunc := clock.Trap().TickerFunc("metricscache")
|
|
|
|
defer trapTickerFunc.Close()
|
|
cache, db := newMetricsCache(t, log, clock, metricscache.Intervals{
|
|
TemplateBuildTimes: time.Minute,
|
|
}, false)
|
|
|
|
org := dbgen.Organization(t, db, database.Organization{})
|
|
user := dbgen.User(t, db, database.User{})
|
|
|
|
template := dbgen.Template(t, db, database.Template{
|
|
CreatedBy: user.ID,
|
|
OrganizationID: org.ID,
|
|
})
|
|
|
|
templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{
|
|
OrganizationID: org.ID,
|
|
CreatedBy: user.ID,
|
|
TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true},
|
|
})
|
|
|
|
workspace := dbgen.Workspace(t, db, database.WorkspaceTable{
|
|
OrganizationID: org.ID,
|
|
OwnerID: user.ID,
|
|
TemplateID: template.ID,
|
|
})
|
|
|
|
gotStats := cache.TemplateBuildTimeStats(template.ID)
|
|
requireBuildTimeStatsEmpty(t, gotStats)
|
|
|
|
for buildNumber, row := range tt.args.rows {
|
|
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
|
OrganizationID: org.ID,
|
|
InitiatorID: user.ID,
|
|
Type: database.ProvisionerJobTypeWorkspaceBuild,
|
|
StartedAt: sql.NullTime{Time: row.startedAt, Valid: true},
|
|
CompletedAt: sql.NullTime{Time: row.completedAt, Valid: true},
|
|
})
|
|
|
|
dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
|
|
BuildNumber: int32(1 + buildNumber), // nolint:gosec
|
|
WorkspaceID: workspace.ID,
|
|
InitiatorID: user.ID,
|
|
TemplateVersionID: templateVersion.ID,
|
|
JobID: job.ID,
|
|
Transition: tt.args.transition,
|
|
})
|
|
}
|
|
|
|
// Wait for both ticker functions to be created (template build times and deployment stats)
|
|
trapTickerFunc.MustWait(ctx).MustRelease(ctx)
|
|
trapTickerFunc.MustWait(ctx).MustRelease(ctx)
|
|
|
|
clock.Advance(time.Minute).MustWait(ctx)
|
|
|
|
if tt.want.loads {
|
|
wantTransition := codersdk.WorkspaceTransition(tt.args.transition)
|
|
gotStats := cache.TemplateBuildTimeStats(template.ID)
|
|
ts := gotStats[wantTransition]
|
|
require.NotNil(t, ts.P50, "P50 should be set for %v", wantTransition)
|
|
require.Equal(t, tt.want.buildTimeMs, *ts.P50, "P50 should match expected value for %v", wantTransition)
|
|
|
|
for transition, ts := range gotStats {
|
|
if transition == wantTransition {
|
|
// Checked above
|
|
continue
|
|
}
|
|
require.Empty(t, ts, "%v", transition)
|
|
}
|
|
} else {
|
|
stats := cache.TemplateBuildTimeStats(template.ID)
|
|
requireBuildTimeStatsEmpty(t, stats)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCache_DeploymentStats(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()
|
|
|
|
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, testutil.Logger(t), clock, metricscache.Intervals{
|
|
DeploymentStats: time.Minute,
|
|
}, usage)
|
|
|
|
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),
|
|
})
|
|
|
|
// 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)
|
|
|
|
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)
|
|
|
|
// 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")
|
|
})
|
|
}
|
|
}
|