feat: report user secrets adoption summary in telemetry (#24854)

Add a deployment-wide user secrets summary to the telemetry snapshot so
we can track adoption of user secrets
The summary reports:

- A breakdown of secrets by which injection fields are populated:
EnvNameOnly, FilePathOnly, Both, Neither
- The distribution of secrets per user (max, p25, p50, p75, p90)

All metrics are scoped to active non-system users. Soft-deleted users
are excluded. The percentile distribution is computed across the entire
active non-system user base, including users with zero secrets, so the
percentiles reflect deployment-wide adoption.

Assisted by Coder Agents.
This commit is contained in:
Zach
2026-05-05 10:56:39 -06:00
committed by GitHub
parent e189f73cc0
commit 1b2a1af097
13 changed files with 575 additions and 1 deletions
+11
View File
@@ -4445,6 +4445,17 @@ func (q *querier) GetUserSecretByUserIDAndName(ctx context.Context, arg database
return q.db.GetUserSecretByUserIDAndName(ctx, arg)
}
func (q *querier) GetUserSecretsTelemetrySummary(ctx context.Context) (database.GetUserSecretsTelemetrySummaryRow, error) {
// Telemetry queries are called from system contexts only. The
// query reads aggregate counts across all users' secrets, so
// authorize against the resource type rather than a per-user
// owner.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUserSecret); err != nil {
return database.GetUserSecretsTelemetrySummaryRow{}, err
}
return q.db.GetUserSecretsTelemetrySummary(ctx)
}
func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
return nil, err
+4
View File
@@ -5765,6 +5765,10 @@ func (s *MethodTestSuite) TestUserSecrets() {
Asserts(secret, policy.ActionRead).
Returns(secret)
}))
s.Run("GetUserSecretsTelemetrySummary", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetUserSecretsTelemetrySummary(gomock.Any()).Return(database.GetUserSecretsTelemetrySummaryRow{}, nil).AnyTimes()
check.Args().Asserts(rbac.ResourceUserSecret, policy.ActionRead)
}))
}
func (s *MethodTestSuite) TestUsageEvents() {
@@ -2904,6 +2904,14 @@ func (m queryMetricsStore) GetUserSecretByUserIDAndName(ctx context.Context, arg
return r0, r1
}
func (m queryMetricsStore) GetUserSecretsTelemetrySummary(ctx context.Context) (database.GetUserSecretsTelemetrySummaryRow, error) {
start := time.Now()
r0, r1 := m.s.GetUserSecretsTelemetrySummary(ctx)
m.queryLatencies.WithLabelValues("GetUserSecretsTelemetrySummary").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserSecretsTelemetrySummary").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
start := time.Now()
r0, r1 := m.s.GetUserStatusCounts(ctx, arg)
+15
View File
@@ -5432,6 +5432,21 @@ func (mr *MockStoreMockRecorder) GetUserSecretByUserIDAndName(ctx, arg any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).GetUserSecretByUserIDAndName), ctx, arg)
}
// GetUserSecretsTelemetrySummary mocks base method.
func (m *MockStore) GetUserSecretsTelemetrySummary(ctx context.Context) (database.GetUserSecretsTelemetrySummaryRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserSecretsTelemetrySummary", ctx)
ret0, _ := ret[0].(database.GetUserSecretsTelemetrySummaryRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserSecretsTelemetrySummary indicates an expected call of GetUserSecretsTelemetrySummary.
func (mr *MockStoreMockRecorder) GetUserSecretsTelemetrySummary(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSecretsTelemetrySummary", reflect.TypeOf((*MockStore)(nil).GetUserSecretsTelemetrySummary), ctx)
}
// GetUserStatusCounts mocks base method.
func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
m.ctrl.T.Helper()
+1 -1
View File
@@ -2439,7 +2439,7 @@ CREATE TABLE telemetry_items (
CREATE TABLE telemetry_locks (
event_type text NOT NULL,
period_ending_at timestamp with time zone NOT NULL,
CONSTRAINT telemetry_lock_event_type_constraint CHECK ((event_type = ANY (ARRAY['aibridge_interceptions_summary'::text, 'boundary_usage_summary'::text])))
CONSTRAINT telemetry_lock_event_type_constraint CHECK ((event_type = ANY (ARRAY['aibridge_interceptions_summary'::text, 'boundary_usage_summary'::text, 'user_secrets_summary'::text])))
);
COMMENT ON TABLE telemetry_locks IS 'Telemetry lock tracking table for deduplication of heartbeat events across replicas.';
@@ -0,0 +1,8 @@
-- Restore the previous telemetry_locks event_type constraint. Existing
-- user_secrets_summary rows must be removed first or the new constraint
-- check would fail.
DELETE FROM telemetry_locks WHERE event_type = 'user_secrets_summary';
ALTER TABLE telemetry_locks DROP CONSTRAINT telemetry_lock_event_type_constraint;
ALTER TABLE telemetry_locks ADD CONSTRAINT telemetry_lock_event_type_constraint
CHECK (event_type IN ('aibridge_interceptions_summary', 'boundary_usage_summary'));
@@ -0,0 +1,7 @@
-- Add user_secrets_summary to the telemetry_locks event_type constraint.
-- User secrets aggregates do not have a natural per-row UUID for the
-- telemetry server to dedupe on, so we elect a single replica per
-- snapshot period to report them via this lock table.
ALTER TABLE telemetry_locks DROP CONSTRAINT telemetry_lock_event_type_constraint;
ALTER TABLE telemetry_locks ADD CONSTRAINT telemetry_lock_event_type_constraint
CHECK (event_type IN ('aibridge_interceptions_summary', 'boundary_usage_summary', 'user_secrets_summary'));
@@ -0,0 +1,3 @@
-- Smoke fixture: a single user_secrets_summary lock for a fixed period.
INSERT INTO telemetry_locks (event_type, period_ending_at)
VALUES ('user_secrets_summary', '2026-01-01 00:00:00+00');
+25
View File
@@ -717,6 +717,31 @@ type sqlcQuerier interface {
GetUserNotificationPreferences(ctx context.Context, userID uuid.UUID) ([]NotificationPreference, error)
GetUserSecretByID(ctx context.Context, id uuid.UUID) (UserSecret, error)
GetUserSecretByUserIDAndName(ctx context.Context, arg GetUserSecretByUserIDAndNameParams) (UserSecret, error)
// Returns deployment-wide aggregates for the telemetry snapshot.
//
// The denominator for both user-level counts and the per-user
// distribution is active non-system users. Specifically:
//
// * deleted = false: Coder soft-deletes by flipping users.deleted
// rather than removing rows, so secrets persist after delete but
// are unreachable.
// * status = 'active': dormant users (no recent activity) and
// suspended users (explicitly disabled) cannot use secrets, so
// they shouldn't dilute the percentile distribution as
// zero-secret entries.
// * is_system = false: internal subjects like the prebuilds user
// never use secrets in the normal flow.
//
// Status transitions move users in and out of this denominator, so a
// snapshot's UsersWithSecrets can drop without any secret being
// deleted.
//
// The percentile distribution is computed across all active non-system
// users, including those with zero secrets, so the percentiles reflect
// deployment-wide adoption rather than only the power-user subset.
// percentile_disc returns an actual integer count from the underlying
// values rather than interpolating between rows.
GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error)
// GetUserStatusCounts returns the count of users in each status over time.
// The time range is inclusively defined by the start_time and end_time parameters.
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
+91
View File
@@ -24890,6 +24890,97 @@ func (q *sqlQuerier) GetUserSecretByUserIDAndName(ctx context.Context, arg GetUs
return i, err
}
const getUserSecretsTelemetrySummary = `-- name: GetUserSecretsTelemetrySummary :one
WITH active_users AS (
SELECT id AS user_id
FROM users
WHERE deleted = false
AND is_system = false
AND status = 'active'::user_status
),
per_user AS (
SELECT au.user_id, COUNT(us.id)::bigint AS n
FROM active_users au
LEFT JOIN user_secrets us ON us.user_id = au.user_id
GROUP BY au.user_id
),
secrets_filtered AS (
SELECT us.env_name, us.file_path
FROM user_secrets us
JOIN active_users au ON au.user_id = us.user_id
)
SELECT
COUNT(*) FILTER (WHERE n > 0)::bigint AS users_with_secrets,
(SELECT COUNT(*) FROM secrets_filtered)::bigint AS total_secrets,
(SELECT COUNT(*) FROM secrets_filtered WHERE env_name != '' AND file_path = '' )::bigint AS env_name_only,
(SELECT COUNT(*) FROM secrets_filtered WHERE env_name = '' AND file_path != '')::bigint AS file_path_only,
(SELECT COUNT(*) FROM secrets_filtered WHERE env_name != '' AND file_path != '')::bigint AS both,
(SELECT COUNT(*) FROM secrets_filtered WHERE env_name = '' AND file_path = '' )::bigint AS neither,
COALESCE(MAX(n), 0)::bigint AS secrets_per_user_max,
COALESCE(percentile_disc(0.25) WITHIN GROUP (ORDER BY n), 0)::bigint AS secrets_per_user_p25,
COALESCE(percentile_disc(0.50) WITHIN GROUP (ORDER BY n), 0)::bigint AS secrets_per_user_p50,
COALESCE(percentile_disc(0.75) WITHIN GROUP (ORDER BY n), 0)::bigint AS secrets_per_user_p75,
COALESCE(percentile_disc(0.90) WITHIN GROUP (ORDER BY n), 0)::bigint AS secrets_per_user_p90
FROM per_user
`
type GetUserSecretsTelemetrySummaryRow struct {
UsersWithSecrets int64 `db:"users_with_secrets" json:"users_with_secrets"`
TotalSecrets int64 `db:"total_secrets" json:"total_secrets"`
EnvNameOnly int64 `db:"env_name_only" json:"env_name_only"`
FilePathOnly int64 `db:"file_path_only" json:"file_path_only"`
Both int64 `db:"both" json:"both"`
Neither int64 `db:"neither" json:"neither"`
SecretsPerUserMax int64 `db:"secrets_per_user_max" json:"secrets_per_user_max"`
SecretsPerUserP25 int64 `db:"secrets_per_user_p25" json:"secrets_per_user_p25"`
SecretsPerUserP50 int64 `db:"secrets_per_user_p50" json:"secrets_per_user_p50"`
SecretsPerUserP75 int64 `db:"secrets_per_user_p75" json:"secrets_per_user_p75"`
SecretsPerUserP90 int64 `db:"secrets_per_user_p90" json:"secrets_per_user_p90"`
}
// Returns deployment-wide aggregates for the telemetry snapshot.
//
// The denominator for both user-level counts and the per-user
// distribution is active non-system users. Specifically:
//
// - deleted = false: Coder soft-deletes by flipping users.deleted
// rather than removing rows, so secrets persist after delete but
// are unreachable.
// - status = 'active': dormant users (no recent activity) and
// suspended users (explicitly disabled) cannot use secrets, so
// they shouldn't dilute the percentile distribution as
// zero-secret entries.
// - is_system = false: internal subjects like the prebuilds user
// never use secrets in the normal flow.
//
// Status transitions move users in and out of this denominator, so a
// snapshot's UsersWithSecrets can drop without any secret being
// deleted.
//
// The percentile distribution is computed across all active non-system
// users, including those with zero secrets, so the percentiles reflect
// deployment-wide adoption rather than only the power-user subset.
// percentile_disc returns an actual integer count from the underlying
// values rather than interpolating between rows.
func (q *sqlQuerier) GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error) {
row := q.db.QueryRowContext(ctx, getUserSecretsTelemetrySummary)
var i GetUserSecretsTelemetrySummaryRow
err := row.Scan(
&i.UsersWithSecrets,
&i.TotalSecrets,
&i.EnvNameOnly,
&i.FilePathOnly,
&i.Both,
&i.Neither,
&i.SecretsPerUserMax,
&i.SecretsPerUserP25,
&i.SecretsPerUserP50,
&i.SecretsPerUserP75,
&i.SecretsPerUserP90,
)
return i, err
}
const listUserSecrets = `-- name: ListUserSecrets :many
SELECT
id, user_id, name, description,
+57
View File
@@ -65,3 +65,60 @@ RETURNING *;
DELETE FROM user_secrets
WHERE user_id = @user_id AND name = @name
RETURNING *;
-- name: GetUserSecretsTelemetrySummary :one
-- Returns deployment-wide aggregates for the telemetry snapshot.
--
-- The denominator for both user-level counts and the per-user
-- distribution is active non-system users. Specifically:
--
-- * deleted = false: Coder soft-deletes by flipping users.deleted
-- rather than removing rows, so secrets persist after delete but
-- are unreachable.
-- * status = 'active': dormant users (no recent activity) and
-- suspended users (explicitly disabled) cannot use secrets, so
-- they shouldn't dilute the percentile distribution as
-- zero-secret entries.
-- * is_system = false: internal subjects like the prebuilds user
-- never use secrets in the normal flow.
--
-- Status transitions move users in and out of this denominator, so a
-- snapshot's UsersWithSecrets can drop without any secret being
-- deleted.
--
-- The percentile distribution is computed across all active non-system
-- users, including those with zero secrets, so the percentiles reflect
-- deployment-wide adoption rather than only the power-user subset.
-- percentile_disc returns an actual integer count from the underlying
-- values rather than interpolating between rows.
WITH active_users AS (
SELECT id AS user_id
FROM users
WHERE deleted = false
AND is_system = false
AND status = 'active'::user_status
),
per_user AS (
SELECT au.user_id, COUNT(us.id)::bigint AS n
FROM active_users au
LEFT JOIN user_secrets us ON us.user_id = au.user_id
GROUP BY au.user_id
),
secrets_filtered AS (
SELECT us.env_name, us.file_path
FROM user_secrets us
JOIN active_users au ON au.user_id = us.user_id
)
SELECT
COUNT(*) FILTER (WHERE n > 0)::bigint AS users_with_secrets,
(SELECT COUNT(*) FROM secrets_filtered)::bigint AS total_secrets,
(SELECT COUNT(*) FROM secrets_filtered WHERE env_name != '' AND file_path = '' )::bigint AS env_name_only,
(SELECT COUNT(*) FROM secrets_filtered WHERE env_name = '' AND file_path != '')::bigint AS file_path_only,
(SELECT COUNT(*) FROM secrets_filtered WHERE env_name != '' AND file_path != '')::bigint AS both,
(SELECT COUNT(*) FROM secrets_filtered WHERE env_name = '' AND file_path = '' )::bigint AS neither,
COALESCE(MAX(n), 0)::bigint AS secrets_per_user_max,
COALESCE(percentile_disc(0.25) WITHIN GROUP (ORDER BY n), 0)::bigint AS secrets_per_user_p25,
COALESCE(percentile_disc(0.50) WITHIN GROUP (ORDER BY n), 0)::bigint AS secrets_per_user_p50,
COALESCE(percentile_disc(0.75) WITHIN GROUP (ORDER BY n), 0)::bigint AS secrets_per_user_p75,
COALESCE(percentile_disc(0.90) WITHIN GROUP (ORDER BY n), 0)::bigint AS secrets_per_user_p90
FROM per_user;