feat: add flag to disable template insights (#20940)

Closes #20399 

To summarize the original commit messages:

- Do not log stats to the database.
- Return errors on the insight endpoints.
- Update the frontend to show those errors.
- Also fixes an issue with getting the user status count via codersdk,
since I added a test to ensure it was not disabled by this flag and it
was sending the wrong payload.
This commit is contained in:
Asher
2025-12-13 18:00:03 -09:00
committed by GitHub
parent b9f8295845
commit 27f0413347
19 changed files with 1615 additions and 979 deletions
+10
View File
@@ -277,6 +277,16 @@ INTROSPECTION / PROMETHEUS OPTIONS:
--prometheus-enable bool, $CODER_PROMETHEUS_ENABLE
Serve prometheus metrics on the address defined by prometheus address.
INTROSPECTION / TEMPLATE INSIGHTS OPTIONS:
--template-insights-enable bool, $CODER_TEMPLATE_INSIGHTS_ENABLE (default: true)
Enable the collection and display of template insights along with the
associated API endpoints. This will also enable aggregating these
insights into daily active users, application usage, and transmission
rates for overall deployment stats. When disabled, these values will
be zero, which will also affect what the bottom deployment overview
bar displays. Disabling will also prevent Prometheus collection of
these values.
INTROSPECTION / TRACING OPTIONS:
--trace-logs bool, $CODER_TRACE_LOGS
Enables capturing of logs as events in traces. This is useful for
+9
View File
@@ -191,6 +191,15 @@ autobuildPollInterval: 1m0s
# (default: 1m0s, type: duration)
jobHangDetectorInterval: 1m0s
introspection:
templateInsights:
# Enable the collection and display of template insights along with the associated
# API endpoints. This will also enable aggregating these insights into daily
# active users, application usage, and transmission rates for overall deployment
# stats. When disabled, these values will be zero, which will also affect what the
# bottom deployment overview bar displays. Disabling will also prevent Prometheus
# collection of these values.
# (default: true, type: bool)
enable: true
prometheus:
# Serve prometheus metrics on the address defined by prometheus address.
# (default: <unset>, type: bool)
+130 -1
View File
@@ -28,7 +28,7 @@ import (
"github.com/coder/coder/v2/testutil"
)
func TestUpdateStates(t *testing.T) {
func TestUpdateStats(t *testing.T) {
t.Parallel()
var (
@@ -542,6 +542,135 @@ func TestUpdateStates(t *testing.T) {
}
require.True(t, updateAgentMetricsFnCalled)
})
t.Run("DropStats", func(t *testing.T) {
t.Parallel()
var (
now = dbtime.Now()
dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
templateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
panic("should not be called")
},
SetFn: func(context.Context, database.Store, database.Template, schedule.TemplateScheduleOptions) (database.Template, error) {
panic("not implemented")
},
}
updateAgentMetricsFnCalled = false
tickCh = make(chan time.Time)
flushCh = make(chan int, 1)
wut = workspacestats.NewTracker(dbM,
workspacestats.TrackerWithTickFlush(tickCh, flushCh),
)
req = &agentproto.UpdateStatsRequest{
Stats: &agentproto.Stats{
ConnectionsByProto: map[string]int64{
"tcp": 1,
"dean": 2,
},
ConnectionCount: 3,
ConnectionMedianLatencyMs: 23,
RxPackets: 120,
RxBytes: 1000,
TxPackets: 130,
TxBytes: 2000,
SessionCountVscode: 1,
SessionCountJetbrains: 2,
SessionCountReconnectingPty: 3,
SessionCountSsh: 4,
Metrics: []*agentproto.Stats_Metric{
{
Name: "awesome metric",
Value: 42,
},
{
Name: "uncool metric",
Value: 0,
},
},
},
}
)
api := agentapi.StatsAPI{
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
return agent, nil
},
Workspace: &workspaceAsCacheFields,
Database: dbM,
StatsReporter: workspacestats.NewReporter(workspacestats.ReporterOptions{
Database: dbM,
Pubsub: ps,
StatsBatcher: nil, // Should not be called.
UsageTracker: wut,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
UpdateAgentMetricsFn: func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric) {
updateAgentMetricsFnCalled = true
assert.Equal(t, prometheusmetrics.AgentMetricLabels{
Username: user.Username,
WorkspaceName: workspace.Name,
AgentName: agent.Name,
TemplateName: template.Name,
}, labels)
assert.Equal(t, req.Stats.Metrics, metrics)
},
DisableDatabaseInserts: true,
}),
AgentStatsRefreshInterval: 10 * time.Second,
TimeNowFn: func() time.Time {
return now
},
}
defer wut.Close()
// We expect an activity bump because ConnectionCount > 0.
dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
WorkspaceID: workspace.ID,
NextAutostart: time.Time{}.UTC(),
}).Return(nil)
// Workspace last used at gets bumped.
dbM.EXPECT().BatchUpdateWorkspaceLastUsedAt(gomock.Any(), database.BatchUpdateWorkspaceLastUsedAtParams{
IDs: []uuid.UUID{workspace.ID},
LastUsedAt: now,
}).Return(nil)
// Ensure that pubsub notifications are sent.
notifyDescription := make(chan struct{})
ps.SubscribeWithErr(wspubsub.WorkspaceEventChannel(workspace.OwnerID),
wspubsub.HandleWorkspaceEvent(
func(_ context.Context, e wspubsub.WorkspaceEvent, err error) {
if err != nil {
return
}
if e.Kind == wspubsub.WorkspaceEventKindStatsUpdate && e.WorkspaceID == workspace.ID {
go func() {
notifyDescription <- struct{}{}
}()
}
}))
resp, err := api.UpdateStats(context.Background(), req)
require.NoError(t, err)
require.Equal(t, &agentproto.UpdateStatsResponse{
ReportInterval: durationpb.New(10 * time.Second),
}, resp)
tickCh <- now
count := <-flushCh
require.Equal(t, 1, count, "expected one flush with one id")
ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ctx.Done():
t.Error("timed out while waiting for pubsub notification")
case <-notifyDescription:
}
require.True(t, updateAgentMetricsFnCalled)
})
}
func templateScheduleStorePtr(store schedule.TemplateScheduleStore) *atomic.Pointer[schedule.TemplateScheduleStore] {
+11
View File
@@ -14347,6 +14347,9 @@ const docTemplate = `{
"telemetry": {
"$ref": "#/definitions/codersdk.TelemetryConfig"
},
"template_insights": {
"$ref": "#/definitions/codersdk.TemplateInsightsConfig"
},
"terms_of_service_url": {
"type": "string"
},
@@ -18596,6 +18599,14 @@ const docTemplate = `{
}
}
},
"codersdk.TemplateInsightsConfig": {
"type": "object",
"properties": {
"enable": {
"type": "boolean"
}
}
},
"codersdk.TemplateInsightsIntervalReport": {
"type": "object",
"properties": {
+11
View File
@@ -12931,6 +12931,9 @@
"telemetry": {
"$ref": "#/definitions/codersdk.TelemetryConfig"
},
"template_insights": {
"$ref": "#/definitions/codersdk.TemplateInsightsConfig"
},
"terms_of_service_url": {
"type": "string"
},
@@ -17032,6 +17035,14 @@
}
}
},
"codersdk.TemplateInsightsConfig": {
"type": "object",
"properties": {
"enable": {
"type": "boolean"
}
}
},
"codersdk.TemplateInsightsIntervalReport": {
"type": "object",
"properties": {
+30 -12
View File
@@ -768,14 +768,15 @@ func New(options *Options) *API {
}
api.statsReporter = workspacestats.NewReporter(workspacestats.ReporterOptions{
Database: options.Database,
Logger: options.Logger.Named("workspacestats"),
Pubsub: options.Pubsub,
TemplateScheduleStore: options.TemplateScheduleStore,
StatsBatcher: options.StatsBatcher,
UsageTracker: options.WorkspaceUsageTracker,
UpdateAgentMetricsFn: options.UpdateAgentMetrics,
AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize,
Database: options.Database,
Logger: options.Logger.Named("workspacestats"),
Pubsub: options.Pubsub,
TemplateScheduleStore: options.TemplateScheduleStore,
StatsBatcher: options.StatsBatcher,
UsageTracker: options.WorkspaceUsageTracker,
UpdateAgentMetricsFn: options.UpdateAgentMetrics,
AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize,
DisableDatabaseInserts: !options.DeploymentValues.TemplateInsights.Enable.Value(),
})
workspaceAppsLogger := options.Logger.Named("workspaceapps")
if options.WorkspaceAppsStatsCollectorOptions.Logger == nil {
@@ -1528,11 +1529,28 @@ func New(options *Options) *API {
})
r.Route("/insights", func(r chi.Router) {
r.Use(apiKeyMiddleware)
r.Get("/daus", api.deploymentDAUs)
r.Get("/user-activity", api.insightsUserActivity)
r.Group(func(r chi.Router) {
r.Use(
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if !options.DeploymentValues.TemplateInsights.Enable.Value() {
httpapi.Write(context.Background(), rw, http.StatusNotFound, codersdk.Response{
Message: "Not Found.",
Detail: "Template insights are disabled.",
})
return
}
next.ServeHTTP(rw, r)
})
},
)
r.Get("/daus", api.deploymentDAUs)
r.Get("/user-activity", api.insightsUserActivity)
r.Get("/user-latency", api.insightsUserLatency)
r.Get("/templates", api.insightsTemplates)
})
r.Get("/user-status-counts", api.insightsUserStatusCounts)
r.Get("/user-latency", api.insightsUserLatency)
r.Get("/templates", api.insightsTemplates)
})
r.Route("/debug", func(r chi.Router) {
r.Use(
+185 -48
View File
@@ -520,7 +520,7 @@ func TestTemplateInsights_Golden(t *testing.T) {
return templates, users, testData
}
prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen) (*codersdk.Client, chan dbrollup.Event) {
prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen, disableStorage bool) (*codersdk.Client, chan dbrollup.Event) {
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
events := make(chan dbrollup.Event)
@@ -706,22 +706,24 @@ func TestTemplateInsights_Golden(t *testing.T) {
require.NoError(t, err)
defer batcherCloser() // Flushes the stats, this is to ensure they're written.
for workspace, data := range testData {
for _, stat := range data.agentStats {
createdAt := stat.startedAt
connectionCount := int64(1)
if stat.noConnections {
connectionCount = 0
}
for createdAt.Before(stat.endedAt) {
batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, &agentproto.Stats{
ConnectionCount: connectionCount,
SessionCountVscode: stat.sessionCountVSCode,
SessionCountJetbrains: stat.sessionCountJetBrains,
SessionCountReconnectingPty: stat.sessionCountReconnectingPTY,
SessionCountSsh: stat.sessionCountSSH,
}, false)
createdAt = createdAt.Add(30 * time.Second)
if !disableStorage {
for workspace, data := range testData {
for _, stat := range data.agentStats {
createdAt := stat.startedAt
connectionCount := int64(1)
if stat.noConnections {
connectionCount = 0
}
for createdAt.Before(stat.endedAt) {
batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, &agentproto.Stats{
ConnectionCount: connectionCount,
SessionCountVscode: stat.sessionCountVSCode,
SessionCountJetbrains: stat.sessionCountJetBrains,
SessionCountReconnectingPty: stat.sessionCountReconnectingPTY,
SessionCountSsh: stat.sessionCountSSH,
}, false)
createdAt = createdAt.Add(30 * time.Second)
}
}
}
}
@@ -750,8 +752,9 @@ func TestTemplateInsights_Golden(t *testing.T) {
}
}
reporter := workspacestats.NewReporter(workspacestats.ReporterOptions{
Database: db,
AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize,
Database: db,
AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize,
DisableDatabaseInserts: disableStorage,
})
err = reporter.ReportAppStats(dbauthz.AsSystemRestricted(ctx), stats)
require.NoError(t, err, "want no error inserting app stats")
@@ -1057,10 +1060,11 @@ func TestTemplateInsights_Golden(t *testing.T) {
ignoreTimes bool
}
tests := []struct {
name string
makeFixture func() ([]*testTemplate, []*testUser)
makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen
requests []testRequest
name string
makeFixture func() ([]*testTemplate, []*testUser)
makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen
disableStorage bool
requests []testRequest
}{
{
name: "multiple users and workspaces",
@@ -1237,6 +1241,24 @@ func TestTemplateInsights_Golden(t *testing.T) {
},
},
},
{
name: "disabled",
makeFixture: baseTemplateAndUserFixture,
makeTestData: makeBaseTestData,
disableStorage: true,
requests: []testRequest{
{
name: "week deployment wide",
makeRequest: func(_ []*testTemplate) codersdk.TemplateInsightsRequest {
return codersdk.TemplateInsightsRequest{
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
Interval: codersdk.InsightsReportIntervalDay,
}
},
},
},
},
}
for _, tt := range tests {
@@ -1246,7 +1268,7 @@ func TestTemplateInsights_Golden(t *testing.T) {
require.NotNil(t, tt.makeFixture, "test bug: makeFixture must be set")
require.NotNil(t, tt.makeTestData, "test bug: makeTestData must be set")
templates, users, testData := prepareFixtureAndTestData(t, tt.makeFixture, tt.makeTestData)
client, events := prepare(t, templates, users, testData)
client, events := prepare(t, templates, users, testData, tt.disableStorage)
// Drain two events, the first one resumes rolluper
// operation and the second one waits for the rollup
@@ -1431,7 +1453,7 @@ func TestUserActivityInsights_Golden(t *testing.T) {
return templates, users, testData
}
prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen) (*codersdk.Client, chan dbrollup.Event) {
prepare := func(t *testing.T, templates []*testTemplate, users []*testUser, testData map[*testWorkspace]testDataGen, disableStorage bool) (*codersdk.Client, chan dbrollup.Event) {
logger := testutil.Logger(t)
db, ps := dbtestutil.NewDB(t)
events := make(chan dbrollup.Event)
@@ -1595,22 +1617,24 @@ func TestUserActivityInsights_Golden(t *testing.T) {
require.NoError(t, err)
defer batcherCloser() // Flushes the stats, this is to ensure they're written.
for workspace, data := range testData {
for _, stat := range data.agentStats {
createdAt := stat.startedAt
connectionCount := int64(1)
if stat.noConnections {
connectionCount = 0
}
for createdAt.Before(stat.endedAt) {
batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, &agentproto.Stats{
ConnectionCount: connectionCount,
SessionCountVscode: stat.sessionCountVSCode,
SessionCountJetbrains: stat.sessionCountJetBrains,
SessionCountReconnectingPty: stat.sessionCountReconnectingPTY,
SessionCountSsh: stat.sessionCountSSH,
}, false)
createdAt = createdAt.Add(30 * time.Second)
if !disableStorage {
for workspace, data := range testData {
for _, stat := range data.agentStats {
createdAt := stat.startedAt
connectionCount := int64(1)
if stat.noConnections {
connectionCount = 0
}
for createdAt.Before(stat.endedAt) {
batcher.Add(createdAt, workspace.agentID, workspace.template.id, workspace.user.(*testUser).sdk.ID, workspace.id, &agentproto.Stats{
ConnectionCount: connectionCount,
SessionCountVscode: stat.sessionCountVSCode,
SessionCountJetbrains: stat.sessionCountJetBrains,
SessionCountReconnectingPty: stat.sessionCountReconnectingPTY,
SessionCountSsh: stat.sessionCountSSH,
}, false)
createdAt = createdAt.Add(30 * time.Second)
}
}
}
}
@@ -1639,8 +1663,9 @@ func TestUserActivityInsights_Golden(t *testing.T) {
}
}
reporter := workspacestats.NewReporter(workspacestats.ReporterOptions{
Database: db,
AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize,
Database: db,
AppStatBatchSize: workspaceapps.DefaultStatsDBReporterBatchSize,
DisableDatabaseInserts: disableStorage,
})
err = reporter.ReportAppStats(dbauthz.AsSystemRestricted(ctx), stats)
require.NoError(t, err, "want no error inserting app stats")
@@ -1902,10 +1927,11 @@ func TestUserActivityInsights_Golden(t *testing.T) {
ignoreTimes bool
}
tests := []struct {
name string
makeFixture func() ([]*testTemplate, []*testUser)
makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen
requests []testRequest
name string
makeFixture func() ([]*testTemplate, []*testUser)
makeTestData func([]*testTemplate, []*testUser) map[*testWorkspace]testDataGen
disableStorage bool
requests []testRequest
}{
{
name: "multiple users and workspaces",
@@ -2013,6 +2039,23 @@ func TestUserActivityInsights_Golden(t *testing.T) {
},
},
},
{
name: "disabled",
makeFixture: baseTemplateAndUserFixture,
makeTestData: makeBaseTestData,
disableStorage: true,
requests: []testRequest{
{
name: "week deployment wide",
makeRequest: func(templates []*testTemplate) codersdk.UserActivityInsightsRequest {
return codersdk.UserActivityInsightsRequest{
StartTime: frozenWeekAgo,
EndTime: frozenWeekAgo.AddDate(0, 0, 7),
}
},
},
},
},
}
for _, tt := range tests {
@@ -2022,7 +2065,7 @@ func TestUserActivityInsights_Golden(t *testing.T) {
require.NotNil(t, tt.makeFixture, "test bug: makeFixture must be set")
require.NotNil(t, tt.makeTestData, "test bug: makeTestData must be set")
templates, users, testData := prepareFixtureAndTestData(t, tt.makeFixture, tt.makeTestData)
client, events := prepare(t, templates, users, testData)
client, events := prepare(t, templates, users, testData, tt.disableStorage)
// Drain two events, the first one resumes rolluper
// operation and the second one waits for the rollup
@@ -2346,3 +2389,97 @@ func TestGenericInsights_RBAC(t *testing.T) {
})
}
}
func TestGenericInsights_Disabled(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
logger := testutil.Logger(t)
client := coderdtest.New(t, &coderdtest.Options{
Database: db,
Pubsub: ps,
Logger: &logger,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
DatabaseRolluper: dbrollup.New(
logger.Named("dbrollup"),
db,
dbrollup.WithInterval(time.Millisecond*100),
),
DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) {
dv.TemplateInsights = codersdk.TemplateInsightsConfig{
Enable: false,
}
}),
})
user := coderdtest.CreateFirstUser(t, client)
_, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
tests := []struct {
name string
fn func(ctx context.Context) error
// ok means there should be no error, otherwise assume 404 due to being
// disabled.
ok bool
}{
{
name: "DAUS",
fn: func(ctx context.Context) error {
_, err := client.DeploymentDAUs(ctx, 0)
return err
},
},
{
name: "UserActivity",
fn: func(ctx context.Context) error {
_, err := client.UserActivityInsights(ctx, codersdk.UserActivityInsightsRequest{})
return err
},
},
{
name: "UserLatency",
fn: func(ctx context.Context) error {
_, err := client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{})
return err
},
},
{
name: "UserStatusCounts",
fn: func(ctx context.Context) error {
_, err := client.GetUserStatusCounts(ctx, codersdk.GetUserStatusCountsRequest{
Offset: 0,
})
return err
},
// Status count is not derived from template insights, so it should not be
// disabled.
ok: true,
},
{
name: "Templates",
fn: func(ctx context.Context) error {
_, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{})
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
err := tt.fn(ctx)
if tt.ok {
require.NoError(t, err)
} else {
require.Error(t, err)
cerr := coderdtest.SDKError(t, err)
require.Contains(t, cerr.Error(), "disabled")
require.Equal(t, http.StatusNotFound, cerr.StatusCode())
}
})
}
}
@@ -0,0 +1,107 @@
{
"report": {
"start_time": "2023-08-15T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [],
"active_users": 0,
"apps_usage": [
{
"template_ids": [],
"type": "builtin",
"display_name": "Visual Studio Code",
"slug": "vscode",
"icon": "/icon/code.svg",
"seconds": 0,
"times_used": 0
},
{
"template_ids": [],
"type": "builtin",
"display_name": "JetBrains",
"slug": "jetbrains",
"icon": "/icon/intellij.svg",
"seconds": 0,
"times_used": 0
},
{
"template_ids": [],
"type": "builtin",
"display_name": "Web Terminal",
"slug": "reconnecting-pty",
"icon": "/icon/terminal.svg",
"seconds": 0,
"times_used": 0
},
{
"template_ids": [],
"type": "builtin",
"display_name": "SSH",
"slug": "ssh",
"icon": "/icon/terminal.svg",
"seconds": 0,
"times_used": 0
},
{
"template_ids": [],
"type": "builtin",
"display_name": "SFTP",
"slug": "sftp",
"icon": "/icon/terminal.svg",
"seconds": 0,
"times_used": 0
}
],
"parameters_usage": []
},
"interval_reports": [
{
"start_time": "2023-08-15T00:00:00Z",
"end_time": "2023-08-16T00:00:00Z",
"template_ids": [],
"interval": "day",
"active_users": 0
},
{
"start_time": "2023-08-16T00:00:00Z",
"end_time": "2023-08-17T00:00:00Z",
"template_ids": [],
"interval": "day",
"active_users": 0
},
{
"start_time": "2023-08-17T00:00:00Z",
"end_time": "2023-08-18T00:00:00Z",
"template_ids": [],
"interval": "day",
"active_users": 0
},
{
"start_time": "2023-08-18T00:00:00Z",
"end_time": "2023-08-19T00:00:00Z",
"template_ids": [],
"interval": "day",
"active_users": 0
},
{
"start_time": "2023-08-19T00:00:00Z",
"end_time": "2023-08-20T00:00:00Z",
"template_ids": [],
"interval": "day",
"active_users": 0
},
{
"start_time": "2023-08-20T00:00:00Z",
"end_time": "2023-08-21T00:00:00Z",
"template_ids": [],
"interval": "day",
"active_users": 0
},
{
"start_time": "2023-08-21T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [],
"interval": "day",
"active_users": 0
}
]
}
@@ -0,0 +1,8 @@
{
"report": {
"start_time": "2023-08-15T00:00:00Z",
"end_time": "2023-08-22T00:00:00Z",
"template_ids": [],
"users": []
}
}
+33 -10
View File
@@ -22,6 +22,23 @@ import (
"github.com/coder/coder/v2/coderd/wspubsub"
)
// TODO: There are currently two paths for reporting activity, both of which are
// tied up with stat collection:
//
// 1. The workspace agent periodically POSTs stats to coderd. On receiving
// this POST, if there is an active SSH or web terminal session, bump both
// the workspace's last_used_at and the deadline.
// 2. The coderd app proxy and wsproxy will periodically report app status
// (coderd calls directly, wsproxy POSTs). This only bumps the workspace's
// last_used_at, as only SSH and web terminal sessions count as activity.
//
// Ideally we would have a single code path for this and we may want to untangle
// activity bumping from stat reporting so we can disable stats collection
// entirely when template insights are disabled rather than having to still
// collect stats but then drop them here.
//
// https://github.com/coder/internal/issues/196
type ReporterOptions struct {
Database database.Store
Logger slog.Logger
@@ -31,6 +48,10 @@ type ReporterOptions struct {
UsageTracker *UsageTracker
UpdateAgentMetricsFn func(ctx context.Context, labels prometheusmetrics.AgentMetricLabels, metrics []*agentproto.Stats_Metric)
// DisableDatabaseInserts prevents inserting stats in the database. The
// reporter will still call UpdateAgentMetricsFn and bump workspace activity.
DisableDatabaseInserts bool
AppStatBatchSize int
}
@@ -93,15 +114,12 @@ func (r *Reporter) ReportAppStats(ctx context.Context, stats []workspaceapps.Sta
return nil
}
if err := tx.InsertWorkspaceAppStats(ctx, batch); err != nil {
return err
if !r.opts.DisableDatabaseInserts {
if err := tx.InsertWorkspaceAppStats(ctx, batch); err != nil {
return err
}
}
// TODO: We currently measure workspace usage based on when we get stats from it.
// There are currently two paths for this:
// 1) From SSH -> workspace agent stats POSTed from agent
// 2) From workspace apps / rpty -> workspace app stats (from coderd / wsproxy)
// Ideally we would have a single code path for this.
uniqueIDs := slice.Unique(batch.WorkspaceID)
if err := tx.BatchUpdateWorkspaceLastUsedAt(ctx, database.BatchUpdateWorkspaceLastUsedAtParams{
IDs: uniqueIDs,
@@ -122,9 +140,11 @@ func (r *Reporter) ReportAppStats(ctx context.Context, stats []workspaceapps.Sta
// nolint:revive // usage is a control flag while we have the experiment
func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspace database.WorkspaceIdentity, workspaceAgent database.WorkspaceAgent, stats *agentproto.Stats, usage bool) error {
// update agent stats
r.opts.StatsBatcher.Add(now, workspaceAgent.ID, workspace.TemplateID, workspace.OwnerID, workspace.ID, stats, usage)
if !r.opts.DisableDatabaseInserts {
r.opts.StatsBatcher.Add(now, workspaceAgent.ID, workspace.TemplateID, workspace.OwnerID, workspace.ID, stats, usage)
}
// update prometheus metrics
// update prometheus metrics (even if template insights are disabled)
if r.opts.UpdateAgentMetricsFn != nil {
r.opts.UpdateAgentMetricsFn(ctx, prometheusmetrics.AgentMetricLabels{
Username: workspace.OwnerUsername,
@@ -135,7 +155,10 @@ func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspac
}
// workspace activity: if no sessions we do not bump activity
if usage && stats.SessionCountVscode == 0 && stats.SessionCountJetbrains == 0 && stats.SessionCountReconnectingPty == 0 && stats.SessionCountSsh == 0 {
if usage && stats.SessionCountVscode == 0 &&
stats.SessionCountJetbrains == 0 &&
stats.SessionCountReconnectingPty == 0 &&
stats.SessionCountSsh == 0 {
return nil
}
+20
View File
@@ -511,6 +511,7 @@ type DeploymentValues struct {
Prebuilds PrebuildsConfig `json:"workspace_prebuilds,omitempty" typescript:",notnull"`
HideAITasks serpent.Bool `json:"hide_ai_tasks,omitempty" typescript:",notnull"`
AI AIConfig `json:"ai,omitempty"`
TemplateInsights TemplateInsightsConfig `json:"template_insights,omitempty" typescript:",notnull"`
Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"`
@@ -610,6 +611,10 @@ type DERPConfig struct {
Path serpent.String `json:"path" typescript:",notnull"`
}
type TemplateInsightsConfig struct {
Enable serpent.Bool `json:"enable" typescript:",notnull"`
}
type PrometheusConfig struct {
Enable serpent.Bool `json:"enable" typescript:",notnull"`
Address serpent.HostPort `json:"address" typescript:",notnull"`
@@ -1080,6 +1085,11 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Name: "pprof",
YAML: "pprof",
}
deploymentGroupIntrospectionTemplateInsights = serpent.Group{
Parent: &deploymentGroupIntrospection,
Name: "Template Insights",
YAML: "templateInsights",
}
deploymentGroupIntrospectionPrometheus = serpent.Group{
Parent: &deploymentGroupIntrospection,
Name: "Prometheus",
@@ -1701,6 +1711,16 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
Group: &deploymentGroupNetworkingDERP,
YAML: "configPath",
},
{
Name: "Enable Template Insights",
Description: "Enable the collection and display of template insights along with the associated API endpoints. This will also enable aggregating these insights into daily active users, application usage, and transmission rates for overall deployment stats. When disabled, these values will be zero, which will also affect what the bottom deployment overview bar displays. Disabling will also prevent Prometheus collection of these values.",
Flag: "template-insights-enable",
Env: "CODER_TEMPLATE_INSIGHTS_ENABLE",
Default: "true",
Value: &c.TemplateInsights.Enable,
Group: &deploymentGroupIntrospectionTemplateInsights,
YAML: "enable",
},
// TODO: support Git Auth settings.
// Prometheus settings
{
+5 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -293,12 +294,14 @@ type UserStatusChangeCount struct {
}
type GetUserStatusCountsRequest struct {
Offset time.Time `json:"offset" format:"date-time"`
// Timezone offset in hours. Use 0 for UTC, and TimezoneOffsetHour(time.Local)
// for the local timezone.
Offset int `json:"offset"`
}
func (c *Client) GetUserStatusCounts(ctx context.Context, req GetUserStatusCountsRequest) (GetUserStatusCountsResponse, error) {
qp := url.Values{}
qp.Add("offset", req.Offset.Format(insightsTimeLayout))
qp.Add("tz_offset", strconv.Itoa(req.Offset))
reqURL := fmt.Sprintf("/api/v2/insights/user-status-counts?%s", qp.Encode())
resp, err := c.Request(ctx, http.MethodGet, reqURL, nil)
+3
View File
@@ -518,6 +518,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"user": {}
}
},
"template_insights": {
"enable": true
},
"terms_of_service_url": "string",
"tls": {
"address": {
+21
View File
@@ -3208,6 +3208,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"user": {}
}
},
"template_insights": {
"enable": true
},
"terms_of_service_url": "string",
"tls": {
"address": {
@@ -3733,6 +3736,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"user": {}
}
},
"template_insights": {
"enable": true
},
"terms_of_service_url": "string",
"tls": {
"address": {
@@ -3842,6 +3848,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
| `support` | [codersdk.SupportConfig](#codersdksupportconfig) | false | | |
| `swagger` | [codersdk.SwaggerConfig](#codersdkswaggerconfig) | false | | |
| `telemetry` | [codersdk.TelemetryConfig](#codersdktelemetryconfig) | false | | |
| `template_insights` | [codersdk.TemplateInsightsConfig](#codersdktemplateinsightsconfig) | false | | |
| `terms_of_service_url` | string | false | | |
| `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | |
| `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | |
@@ -8517,6 +8524,20 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
| `role` | `admin` |
| `role` | `use` |
## codersdk.TemplateInsightsConfig
```json
{
"enable": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------|---------|----------|--------------|-------------|
| `enable` | boolean | false | | |
## codersdk.TemplateInsightsIntervalReport
```json
+11
View File
@@ -269,6 +269,17 @@ URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custo
Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/.
### --template-insights-enable
| | |
|-------------|----------------------------------------------------|
| Type | <code>bool</code> |
| Environment | <code>$CODER_TEMPLATE_INSIGHTS_ENABLE</code> |
| YAML | <code>introspection.templateInsights.enable</code> |
| Default | <code>true</code> |
Enable the collection and display of template insights along with the associated API endpoints. This will also enable aggregating these insights into daily active users, application usage, and transmission rates for overall deployment stats. When disabled, these values will be zero, which will also affect what the bottom deployment overview bar displays. Disabling will also prevent Prometheus collection of these values.
### --prometheus-enable
| | |
+10
View File
@@ -278,6 +278,16 @@ INTROSPECTION / PROMETHEUS OPTIONS:
--prometheus-enable bool, $CODER_PROMETHEUS_ENABLE
Serve prometheus metrics on the address defined by prometheus address.
INTROSPECTION / TEMPLATE INSIGHTS OPTIONS:
--template-insights-enable bool, $CODER_TEMPLATE_INSIGHTS_ENABLE (default: true)
Enable the collection and display of template insights along with the
associated API endpoints. This will also enable aggregating these
insights into daily active users, application usage, and transmission
rates for overall deployment stats. When disabled, these values will
be zero, which will also affect what the bottom deployment overview
bar displays. Disabling will also prevent Prometheus collection of
these values.
INTROSPECTION / TRACING OPTIONS:
--trace-logs bool, $CODER_TRACE_LOGS
Enables capturing of logs as events in traces. This is useful for
+11 -1
View File
@@ -1790,6 +1790,7 @@ export interface DeploymentValues {
readonly workspace_prebuilds?: PrebuildsConfig;
readonly hide_ai_tasks?: boolean;
readonly ai?: AIConfig;
readonly template_insights?: TemplateInsightsConfig;
readonly config?: string;
readonly write_config?: boolean;
/**
@@ -2179,7 +2180,11 @@ export interface GetInboxNotificationResponse {
// From codersdk/insights.go
export interface GetUserStatusCountsRequest {
readonly offset: string;
/**
* Timezone offset in hours. Use 0 for UTC, and TimezoneOffsetHour(time.Local)
* for the local timezone.
*/
readonly offset: number;
}
// From codersdk/insights.go
@@ -5076,6 +5081,11 @@ export interface TemplateGroup extends Group {
readonly role: TemplateRole;
}
// From codersdk/deployment.go
export interface TemplateInsightsConfig {
readonly enable: boolean;
}
// From codersdk/insights.go
/**
* TemplateInsightsIntervalReport is the report from the template insights
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import { useTheme } from "@emotion/react";
import LinearProgress from "@mui/material/LinearProgress";
import Link from "@mui/material/Link";
import { getErrorDetail, getErrorMessage } from "api/errors";
import { entitlements } from "api/queries/entitlements";
import {
insightsTemplate,
@@ -95,9 +96,9 @@ export default function TemplateInsightsPage() {
};
const insightsFilter = { ...commonFilters, interval };
const { data: templateInsights } = useQuery(insightsTemplate(insightsFilter));
const { data: userLatency } = useQuery(insightsUserLatency(commonFilters));
const { data: userActivity } = useQuery(insightsUserActivity(commonFilters));
const templateInsights = useQuery(insightsTemplate(insightsFilter));
const userLatency = useQuery(insightsUserLatency(commonFilters));
const userActivity = useQuery(insightsUserActivity(commonFilters));
const { metadata } = useEmbeddedMetadata();
const { data: entitlementsQuery } = useQuery(
@@ -202,9 +203,18 @@ const getDateRange = (
};
interface TemplateInsightsPageViewProps {
templateInsights: TemplateInsightsResponse | undefined;
userLatency: UserLatencyInsightsResponse | undefined;
userActivity: UserActivityInsightsResponse | undefined;
templateInsights: {
data: TemplateInsightsResponse | undefined;
error: unknown;
};
userLatency: {
data: UserLatencyInsightsResponse | undefined;
error: unknown;
};
userActivity: {
data: UserActivityInsightsResponse | undefined;
error: unknown;
};
entitlements: Entitlements | undefined;
controls: ReactNode;
interval: InsightsInterval;
@@ -246,17 +256,23 @@ export const TemplateInsightsPageView: FC<TemplateInsightsPageViewProps> = ({
? entitlements?.features.user_limit.limit
: undefined
}
data={templateInsights?.interval_reports}
data={templateInsights.data?.interval_reports}
error={templateInsights.error}
/>
<UsersLatencyPanel data={userLatency} />
<UsersLatencyPanel data={userLatency.data} error={userLatency.error} />
<TemplateUsagePanel
css={{ gridColumn: "span 2" }}
data={templateInsights?.report?.apps_usage}
data={templateInsights.data?.report?.apps_usage}
error={templateInsights.error}
/>
<UsersActivityPanel
data={userActivity.data}
error={userActivity.error}
/>
<UsersActivityPanel data={userActivity} />
<TemplateParametersUsagePanel
css={{ gridColumn: "span 3" }}
data={templateInsights?.report?.parameters_usage}
data={templateInsights.data?.report?.parameters_usage}
error={templateInsights.error}
/>
</div>
</>
@@ -265,12 +281,14 @@ export const TemplateInsightsPageView: FC<TemplateInsightsPageViewProps> = ({
interface ActiveUsersPanelProps extends PanelProps {
data: TemplateInsightsResponse["interval_reports"] | undefined;
error: unknown;
interval: InsightsInterval;
userLimit: number | undefined;
}
const ActiveUsersPanel: FC<ActiveUsersPanelProps> = ({
data,
error,
interval,
userLimit,
...panelProps
@@ -283,8 +301,8 @@ const ActiveUsersPanel: FC<ActiveUsersPanelProps> = ({
</PanelTitle>
</PanelHeader>
<PanelContent>
{!data && <Loader css={{ height: "100%" }} />}
{data && data.length === 0 && <NoDataAvailable />}
{!error && !data && <Loader css={{ height: "100%" }} />}
{(error || data?.length === 0) && <NoDataAvailable error={error} />}
{data && data.length > 0 && (
<ActiveUserChart
data={data.map((d) => ({
@@ -300,10 +318,12 @@ const ActiveUsersPanel: FC<ActiveUsersPanelProps> = ({
interface UsersLatencyPanelProps extends PanelProps {
data: UserLatencyInsightsResponse | undefined;
error: unknown;
}
const UsersLatencyPanel: FC<UsersLatencyPanelProps> = ({
data,
error,
...panelProps
}) => {
const theme = useTheme();
@@ -327,8 +347,8 @@ const UsersLatencyPanel: FC<UsersLatencyPanelProps> = ({
</PanelHeader>
<PanelContent>
{!data && <Loader css={{ height: "100%" }} />}
{users && users.length === 0 && <NoDataAvailable />}
{!error && !users && <Loader css={{ height: "100%" }} />}
{(error || users?.length === 0) && <NoDataAvailable error={error} />}
{users &&
[...users]
.sort((a, b) => b.latency_ms.p50 - a.latency_ms.p50)
@@ -367,10 +387,12 @@ const UsersLatencyPanel: FC<UsersLatencyPanelProps> = ({
interface UsersActivityPanelProps extends PanelProps {
data: UserActivityInsightsResponse | undefined;
error: unknown;
}
const UsersActivityPanel: FC<UsersActivityPanelProps> = ({
data,
error,
...panelProps
}) => {
const theme = useTheme();
@@ -395,8 +417,8 @@ const UsersActivityPanel: FC<UsersActivityPanelProps> = ({
</PanelTitle>
</PanelHeader>
<PanelContent>
{!data && <Loader css={{ height: "100%" }} />}
{users && users.length === 0 && <NoDataAvailable />}
{!error && !users && <Loader css={{ height: "100%" }} />}
{(error || users?.length === 0) && <NoDataAvailable error={error} />}
{users &&
[...users]
.sort((a, b) => b.seconds - a.seconds)
@@ -434,13 +456,16 @@ const UsersActivityPanel: FC<UsersActivityPanelProps> = ({
interface TemplateUsagePanelProps extends PanelProps {
data: readonly TemplateAppUsage[] | undefined;
error: unknown;
}
const TemplateUsagePanel: FC<TemplateUsagePanelProps> = ({
data,
error,
...panelProps
}) => {
const theme = useTheme();
// The API returns a row for each app, even if the user didn't use it.
const validUsage = data
?.filter((u) => u.seconds > 0)
.sort((a, b) => b.seconds - a.seconds);
@@ -450,8 +475,6 @@ const TemplateUsagePanel: FC<TemplateUsagePanelProps> = ({
.scale([theme.roles.success.fill.solid, theme.roles.warning.fill.solid])
.mode("lch")
.colors(validUsage?.length ?? 0);
// The API returns a row for each app, even if the user didn't use it.
const hasDataAvailable = validUsage && validUsage.length > 0;
return (
<Panel {...panelProps} css={{ overflowY: "auto" }}>
@@ -459,9 +482,11 @@ const TemplateUsagePanel: FC<TemplateUsagePanelProps> = ({
<PanelTitle>App & IDE Usage</PanelTitle>
</PanelHeader>
<PanelContent>
{!data && <Loader css={{ height: "100%" }} />}
{data && !hasDataAvailable && <NoDataAvailable />}
{data && hasDataAvailable && (
{!error && !data && <Loader css={{ height: "100%" }} />}
{(error || validUsage?.length === 0) && (
<NoDataAvailable error={error} />
)}
{validUsage && validUsage.length > 0 && (
<div
css={{
display: "flex",
@@ -556,10 +581,12 @@ const TemplateUsagePanel: FC<TemplateUsagePanelProps> = ({
interface TemplateParametersUsagePanelProps extends PanelProps {
data: readonly TemplateParameterUsage[] | undefined;
error: unknown;
}
const TemplateParametersUsagePanel: FC<TemplateParametersUsagePanelProps> = ({
data,
error,
...panelProps
}) => {
const theme = useTheme();
@@ -570,82 +597,80 @@ const TemplateParametersUsagePanel: FC<TemplateParametersUsagePanelProps> = ({
<PanelTitle>Parameters usage</PanelTitle>
</PanelHeader>
<PanelContent>
{!data && <Loader css={{ height: 200 }} />}
{data && data.length === 0 && <NoDataAvailable css={{ height: 200 }} />}
{data &&
data.length > 0 &&
data.map((parameter, parameterIndex) => {
const label =
parameter.display_name !== ""
? parameter.display_name
: parameter.name;
return (
<div
key={parameter.name}
css={{
display: "flex",
alignItems: "start",
padding: 24,
marginLeft: -24,
marginRight: -24,
borderTop: `1px solid ${theme.palette.divider}`,
width: "calc(100% + 48px)",
"&:first-of-type": {
borderTop: 0,
},
gap: 24,
}}
>
<div css={{ flex: 1 }}>
<div css={{ fontWeight: 500 }}>{label}</div>
<p
css={{
fontSize: 14,
color: theme.palette.text.secondary,
maxWidth: 400,
margin: 0,
}}
>
{parameter.description}
</p>
</div>
<div css={{ flex: 1, fontSize: 14, flexGrow: 2 }}>
<ParameterUsageRow
css={{
color: theme.palette.text.secondary,
fontWeight: 500,
fontSize: 13,
cursor: "default",
}}
>
<div>Value</div>
<Tooltip>
<TooltipTrigger asChild>
<div>Count</div>
</TooltipTrigger>
<TooltipContent>
The number of workspaces using this value
</TooltipContent>
</Tooltip>
</ParameterUsageRow>
{[...parameter.values]
.sort((a, b) => b.count - a.count)
.filter((usage) => filterOrphanValues(usage, parameter))
.map((usage, usageIndex) => (
<ParameterUsageRow
key={`${parameterIndex}-${usageIndex}`}
>
<ParameterUsageLabel
usage={usage}
parameter={parameter}
/>
<div css={{ textAlign: "right" }}>{usage.count}</div>
</ParameterUsageRow>
))}
</div>
{!error && !data && <Loader css={{ height: 200 }} />}
{(error || data?.length === 0) && (
<NoDataAvailable error={error} css={{ height: 200 }} />
)}
{data?.map((parameter, parameterIndex) => {
const label =
parameter.display_name !== ""
? parameter.display_name
: parameter.name;
return (
<div
key={parameter.name}
css={{
display: "flex",
alignItems: "start",
padding: 24,
marginLeft: -24,
marginRight: -24,
borderTop: `1px solid ${theme.palette.divider}`,
width: "calc(100% + 48px)",
"&:first-of-type": {
borderTop: 0,
},
gap: 24,
}}
>
<div css={{ flex: 1 }}>
<div css={{ fontWeight: 500 }}>{label}</div>
<p
css={{
fontSize: 14,
color: theme.palette.text.secondary,
maxWidth: 400,
margin: 0,
}}
>
{parameter.description}
</p>
</div>
);
})}
<div css={{ flex: 1, fontSize: 14, flexGrow: 2 }}>
<ParameterUsageRow
css={{
color: theme.palette.text.secondary,
fontWeight: 500,
fontSize: 13,
cursor: "default",
}}
>
<div>Value</div>
<Tooltip>
<TooltipTrigger asChild>
<div>Count</div>
</TooltipTrigger>
<TooltipContent>
The number of workspaces using this value
</TooltipContent>
</Tooltip>
</ParameterUsageRow>
{[...parameter.values]
.sort((a, b) => b.count - a.count)
.filter((usage) => filterOrphanValues(usage, parameter))
.map((usage, usageIndex) => (
<ParameterUsageRow key={`${parameterIndex}-${usageIndex}`}>
<ParameterUsageLabel
usage={usage}
parameter={parameter}
/>
<div css={{ textAlign: "right" }}>{usage.count}</div>
</ParameterUsageRow>
))}
</div>
</div>
);
})}
</PanelContent>
</Panel>
);
@@ -850,7 +875,11 @@ const PanelContent: FC<HTMLAttributes<HTMLDivElement>> = ({
);
};
const NoDataAvailable = (props: HTMLAttributes<HTMLDivElement>) => {
interface NoDataAvailableProps extends HTMLAttributes<HTMLDivElement> {
error: unknown;
}
const NoDataAvailable: FC<NoDataAvailableProps> = ({ error, ...props }) => {
const theme = useTheme();
return (
@@ -866,7 +895,10 @@ const NoDataAvailable = (props: HTMLAttributes<HTMLDivElement>) => {
justifyContent: "center",
}}
>
No data available
{error
? getErrorDetail(error) ||
getErrorMessage(error, "Unable to fetch insights")
: "No data available"}
</div>
);
};