chore: track the first time html is served in telemetry (#16334)

Addresses https://github.com/coder/nexus/issues/175.

## Changes

- Adds the `telemetry_items` database table. It's a key value store for
telemetry events that don't fit any other database tables.
- Adds a telemetry report when HTML is served for the first time in
`site.go`.
This commit is contained in:
Hugo Dutka
2025-01-31 13:55:46 +01:00
committed by GitHub
parent f6e990ed87
commit 2ace044e0b
21 changed files with 521 additions and 12 deletions
+20 -1
View File
@@ -964,7 +964,7 @@ func TestServer(t *testing.T) {
server := httptest.NewServer(r)
defer server.Close()
inv, _ := clitest.New(t,
inv, cfg := clitest.New(t,
"server",
"--in-memory",
"--http-address", ":0",
@@ -977,6 +977,25 @@ func TestServer(t *testing.T) {
<-deployment
<-snapshot
accessURL := waitAccessURL(t, cfg)
ctx := testutil.Context(t, testutil.WaitMedium)
client := codersdk.New(accessURL)
body, err := client.Request(ctx, http.MethodGet, "/", nil)
require.NoError(t, err)
require.NoError(t, body.Body.Close())
require.Eventually(t, func() bool {
snap := <-snapshot
htmlFirstServedFound := false
for _, item := range snap.TelemetryItems {
if item.Key == string(telemetry.TelemetryItemKeyHTMLFirstServedAt) {
htmlFirstServedFound = true
}
}
return htmlFirstServedFound
}, testutil.WaitMedium, testutil.IntervalFast, "no html_first_served telemetry item")
})
t.Run("Prometheus", func(t *testing.T) {
t.Parallel()
+2
View File
@@ -585,6 +585,8 @@ func New(options *Options) *API {
AppearanceFetcher: &api.AppearanceFetcher,
BuildInfo: buildInfo,
Entitlements: options.Entitlements,
Telemetry: options.Telemetry,
Logger: options.Logger.Named("site"),
})
api.SiteHandler.Experiments.Store(&experiments)
+28
View File
@@ -2096,6 +2096,20 @@ func (q *querier) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID)
return q.db.GetTailnetTunnelPeerIDs(ctx, srcID)
}
func (q *querier) GetTelemetryItem(ctx context.Context, key string) (database.TelemetryItem, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return database.TelemetryItem{}, err
}
return q.db.GetTelemetryItem(ctx, key)
}
func (q *querier) GetTelemetryItems(ctx context.Context) ([]database.TelemetryItem, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
return nil, err
}
return q.db.GetTelemetryItems(ctx)
}
func (q *querier) GetTemplateAppInsights(ctx context.Context, arg database.GetTemplateAppInsightsParams) ([]database.GetTemplateAppInsightsRow, error) {
if err := q.authorizeTemplateInsights(ctx, arg.TemplateIDs); err != nil {
return nil, err
@@ -3085,6 +3099,13 @@ func (q *querier) InsertReplica(ctx context.Context, arg database.InsertReplicaP
return q.db.InsertReplica(ctx, arg)
}
func (q *querier) InsertTelemetryItemIfNotExists(ctx context.Context, arg database.InsertTelemetryItemIfNotExistsParams) error {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
return err
}
return q.db.InsertTelemetryItemIfNotExists(ctx, arg)
}
func (q *querier) InsertTemplate(ctx context.Context, arg database.InsertTemplateParams) error {
obj := rbac.ResourceTemplate.InOrg(arg.OrganizationID)
if err := q.authorizeContext(ctx, policy.ActionCreate, obj); err != nil {
@@ -4345,6 +4366,13 @@ func (q *querier) UpsertTailnetTunnel(ctx context.Context, arg database.UpsertTa
return q.db.UpsertTailnetTunnel(ctx, arg)
}
func (q *querier) UpsertTelemetryItem(ctx context.Context, arg database.UpsertTelemetryItemParams) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
}
return q.db.UpsertTelemetryItem(ctx, arg)
}
func (q *querier) UpsertTemplateUsageStats(ctx context.Context) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return err
+18
View File
@@ -4224,6 +4224,24 @@ func (s *MethodTestSuite) TestSystemFunctions() {
s.Run("GetWorkspaceModulesCreatedAfter", s.Subtest(func(db database.Store, check *expects) {
check.Args(dbtime.Now()).Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("GetTelemetryItem", s.Subtest(func(db database.Store, check *expects) {
check.Args("test").Asserts(rbac.ResourceSystem, policy.ActionRead).Errors(sql.ErrNoRows)
}))
s.Run("GetTelemetryItems", s.Subtest(func(db database.Store, check *expects) {
check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead)
}))
s.Run("InsertTelemetryItemIfNotExists", s.Subtest(func(db database.Store, check *expects) {
check.Args(database.InsertTelemetryItemIfNotExistsParams{
Key: "test",
Value: "value",
}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
}))
s.Run("UpsertTelemetryItem", s.Subtest(func(db database.Store, check *expects) {
check.Args(database.UpsertTelemetryItemParams{
Key: "test",
Value: "value",
}).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
}))
}
func (s *MethodTestSuite) TestNotifications() {
+17
View File
@@ -1093,6 +1093,23 @@ func ProvisionerJobTimings(t testing.TB, db database.Store, build database.Works
return timings
}
func TelemetryItem(t testing.TB, db database.Store, seed database.TelemetryItem) database.TelemetryItem {
if seed.Key == "" {
seed.Key = testutil.GetRandomName(t)
}
if seed.Value == "" {
seed.Value = time.Now().Format(time.RFC3339)
}
err := db.UpsertTelemetryItem(genCtx, database.UpsertTelemetryItemParams{
Key: seed.Key,
Value: seed.Value,
})
require.NoError(t, err, "upsert telemetry item")
item, err := db.GetTelemetryItem(genCtx, seed.Key)
require.NoError(t, err, "get telemetry item")
return item
}
func provisionerJobTiming(t testing.TB, db database.Store, seed database.ProvisionerJobTiming) database.ProvisionerJobTiming {
timing, err := db.InsertProvisionerJobTimings(genCtx, database.InsertProvisionerJobTimingsParams{
JobID: takeFirst(seed.JobID, uuid.New()),
+70
View File
@@ -89,6 +89,7 @@ func New() database.Store {
locks: map[int64]struct{}{},
runtimeConfig: map[string]string{},
userStatusChanges: make([]database.UserStatusChange, 0),
telemetryItems: make([]database.TelemetryItem, 0),
},
}
// Always start with a default org. Matching migration 198.
@@ -258,6 +259,7 @@ type data struct {
defaultProxyDisplayName string
defaultProxyIconURL string
userStatusChanges []database.UserStatusChange
telemetryItems []database.TelemetryItem
}
func tryPercentile(fs []float64, p float64) float64 {
@@ -4330,6 +4332,23 @@ func (*FakeQuerier) GetTailnetTunnelPeerIDs(context.Context, uuid.UUID) ([]datab
return nil, ErrUnimplemented
}
func (q *FakeQuerier) GetTelemetryItem(_ context.Context, key string) (database.TelemetryItem, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
for _, item := range q.telemetryItems {
if item.Key == key {
return item, nil
}
}
return database.TelemetryItem{}, sql.ErrNoRows
}
func (q *FakeQuerier) GetTelemetryItems(_ context.Context) ([]database.TelemetryItem, error) {
return q.telemetryItems, nil
}
func (q *FakeQuerier) GetTemplateAppInsights(ctx context.Context, arg database.GetTemplateAppInsightsParams) ([]database.GetTemplateAppInsightsRow, error) {
err := validateDatabaseType(arg)
if err != nil {
@@ -8120,6 +8139,30 @@ func (q *FakeQuerier) InsertReplica(_ context.Context, arg database.InsertReplic
return replica, nil
}
func (q *FakeQuerier) InsertTelemetryItemIfNotExists(_ context.Context, arg database.InsertTelemetryItemIfNotExistsParams) error {
err := validateDatabaseType(arg)
if err != nil {
return err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for _, item := range q.telemetryItems {
if item.Key == arg.Key {
return nil
}
}
q.telemetryItems = append(q.telemetryItems, database.TelemetryItem{
Key: arg.Key,
Value: arg.Value,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
return nil
}
func (q *FakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTemplateParams) error {
if err := validateDatabaseType(arg); err != nil {
return err
@@ -10874,6 +10917,33 @@ func (*FakeQuerier) UpsertTailnetTunnel(_ context.Context, arg database.UpsertTa
return database.TailnetTunnel{}, ErrUnimplemented
}
func (q *FakeQuerier) UpsertTelemetryItem(_ context.Context, arg database.UpsertTelemetryItemParams) error {
err := validateDatabaseType(arg)
if err != nil {
return err
}
q.mutex.Lock()
defer q.mutex.Unlock()
for i, item := range q.telemetryItems {
if item.Key == arg.Key {
q.telemetryItems[i].Value = arg.Value
q.telemetryItems[i].UpdatedAt = time.Now()
return nil
}
}
q.telemetryItems = append(q.telemetryItems, database.TelemetryItem{
Key: arg.Key,
Value: arg.Value,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
return nil
}
func (q *FakeQuerier) UpsertTemplateUsageStats(ctx context.Context) error {
q.mutex.Lock()
defer q.mutex.Unlock()
+28
View File
@@ -1134,6 +1134,20 @@ func (m queryMetricsStore) GetTailnetTunnelPeerIDs(ctx context.Context, srcID uu
return r0, r1
}
func (m queryMetricsStore) GetTelemetryItem(ctx context.Context, key string) (database.TelemetryItem, error) {
start := time.Now()
r0, r1 := m.s.GetTelemetryItem(ctx, key)
m.queryLatencies.WithLabelValues("GetTelemetryItem").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetTelemetryItems(ctx context.Context) ([]database.TelemetryItem, error) {
start := time.Now()
r0, r1 := m.s.GetTelemetryItems(ctx)
m.queryLatencies.WithLabelValues("GetTelemetryItems").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m queryMetricsStore) GetTemplateAppInsights(ctx context.Context, arg database.GetTemplateAppInsightsParams) ([]database.GetTemplateAppInsightsRow, error) {
start := time.Now()
r0, r1 := m.s.GetTemplateAppInsights(ctx, arg)
@@ -1911,6 +1925,13 @@ func (m queryMetricsStore) InsertReplica(ctx context.Context, arg database.Inser
return replica, err
}
func (m queryMetricsStore) InsertTelemetryItemIfNotExists(ctx context.Context, arg database.InsertTelemetryItemIfNotExistsParams) error {
start := time.Now()
r0 := m.s.InsertTelemetryItemIfNotExists(ctx, arg)
m.queryLatencies.WithLabelValues("InsertTelemetryItemIfNotExists").Observe(time.Since(start).Seconds())
return r0
}
func (m queryMetricsStore) InsertTemplate(ctx context.Context, arg database.InsertTemplateParams) error {
start := time.Now()
err := m.s.InsertTemplate(ctx, arg)
@@ -2772,6 +2793,13 @@ func (m queryMetricsStore) UpsertTailnetTunnel(ctx context.Context, arg database
return r0, r1
}
func (m queryMetricsStore) UpsertTelemetryItem(ctx context.Context, arg database.UpsertTelemetryItemParams) error {
start := time.Now()
r0 := m.s.UpsertTelemetryItem(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertTelemetryItem").Observe(time.Since(start).Seconds())
return r0
}
func (m queryMetricsStore) UpsertTemplateUsageStats(ctx context.Context) error {
start := time.Now()
r0 := m.s.UpsertTemplateUsageStats(ctx)
+58
View File
@@ -2346,6 +2346,36 @@ func (mr *MockStoreMockRecorder) GetTailnetTunnelPeerIDs(ctx, srcID any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTailnetTunnelPeerIDs", reflect.TypeOf((*MockStore)(nil).GetTailnetTunnelPeerIDs), ctx, srcID)
}
// GetTelemetryItem mocks base method.
func (m *MockStore) GetTelemetryItem(ctx context.Context, key string) (database.TelemetryItem, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTelemetryItem", ctx, key)
ret0, _ := ret[0].(database.TelemetryItem)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTelemetryItem indicates an expected call of GetTelemetryItem.
func (mr *MockStoreMockRecorder) GetTelemetryItem(ctx, key any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTelemetryItem", reflect.TypeOf((*MockStore)(nil).GetTelemetryItem), ctx, key)
}
// GetTelemetryItems mocks base method.
func (m *MockStore) GetTelemetryItems(ctx context.Context) ([]database.TelemetryItem, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTelemetryItems", ctx)
ret0, _ := ret[0].([]database.TelemetryItem)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetTelemetryItems indicates an expected call of GetTelemetryItems.
func (mr *MockStoreMockRecorder) GetTelemetryItems(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTelemetryItems", reflect.TypeOf((*MockStore)(nil).GetTelemetryItems), ctx)
}
// GetTemplateAppInsights mocks base method.
func (m *MockStore) GetTemplateAppInsights(ctx context.Context, arg database.GetTemplateAppInsightsParams) ([]database.GetTemplateAppInsightsRow, error) {
m.ctrl.T.Helper()
@@ -4051,6 +4081,20 @@ func (mr *MockStoreMockRecorder) InsertReplica(ctx, arg any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertReplica", reflect.TypeOf((*MockStore)(nil).InsertReplica), ctx, arg)
}
// InsertTelemetryItemIfNotExists mocks base method.
func (m *MockStore) InsertTelemetryItemIfNotExists(ctx context.Context, arg database.InsertTelemetryItemIfNotExistsParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "InsertTelemetryItemIfNotExists", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// InsertTelemetryItemIfNotExists indicates an expected call of InsertTelemetryItemIfNotExists.
func (mr *MockStoreMockRecorder) InsertTelemetryItemIfNotExists(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertTelemetryItemIfNotExists", reflect.TypeOf((*MockStore)(nil).InsertTelemetryItemIfNotExists), ctx, arg)
}
// InsertTemplate mocks base method.
func (m *MockStore) InsertTemplate(ctx context.Context, arg database.InsertTemplateParams) error {
m.ctrl.T.Helper()
@@ -5861,6 +5905,20 @@ func (mr *MockStoreMockRecorder) UpsertTailnetTunnel(ctx, arg any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTailnetTunnel", reflect.TypeOf((*MockStore)(nil).UpsertTailnetTunnel), ctx, arg)
}
// UpsertTelemetryItem mocks base method.
func (m *MockStore) UpsertTelemetryItem(ctx context.Context, arg database.UpsertTelemetryItemParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertTelemetryItem", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertTelemetryItem indicates an expected call of UpsertTelemetryItem.
func (mr *MockStoreMockRecorder) UpsertTelemetryItem(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertTelemetryItem", reflect.TypeOf((*MockStore)(nil).UpsertTelemetryItem), ctx, arg)
}
// UpsertTemplateUsageStats mocks base method.
func (m *MockStore) UpsertTemplateUsageStats(ctx context.Context) error {
m.ctrl.T.Helper()
+10
View File
@@ -1164,6 +1164,13 @@ CREATE TABLE tailnet_tunnels (
updated_at timestamp with time zone NOT NULL
);
CREATE TABLE telemetry_items (
key text NOT NULL,
value text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE template_usage_stats (
start_time timestamp with time zone NOT NULL,
end_time timestamp with time zone NOT NULL,
@@ -2026,6 +2033,9 @@ ALTER TABLE ONLY tailnet_peers
ALTER TABLE ONLY tailnet_tunnels
ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id);
ALTER TABLE ONLY telemetry_items
ADD CONSTRAINT telemetry_items_pkey PRIMARY KEY (key);
ALTER TABLE ONLY template_usage_stats
ADD CONSTRAINT template_usage_stats_pkey PRIMARY KEY (start_time, template_id, user_id);
@@ -0,0 +1 @@
DROP TABLE telemetry_items;
@@ -0,0 +1,6 @@
CREATE TABLE telemetry_items (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
@@ -0,0 +1,4 @@
INSERT INTO
telemetry_items (key, value)
VALUES
('example_key', 'example_value');
+7
View File
@@ -2787,6 +2787,13 @@ type TailnetTunnel struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type TelemetryItem struct {
Key string `db:"key" json:"key"`
Value string `db:"value" json:"value"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// Joins in the display name information such as username, avatar, and organization name.
type Template struct {
ID uuid.UUID `db:"id" json:"id"`
+4
View File
@@ -224,6 +224,8 @@ type sqlcQuerier interface {
GetTailnetPeers(ctx context.Context, id uuid.UUID) ([]TailnetPeer, error)
GetTailnetTunnelPeerBindings(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerBindingsRow, error)
GetTailnetTunnelPeerIDs(ctx context.Context, srcID uuid.UUID) ([]GetTailnetTunnelPeerIDsRow, error)
GetTelemetryItem(ctx context.Context, key string) (TelemetryItem, error)
GetTelemetryItems(ctx context.Context) ([]TelemetryItem, error)
// GetTemplateAppInsights returns the aggregate usage of each app in a given
// timeframe. The result can be filtered on template_ids, meaning only user data
// from workspaces based on those templates will be included.
@@ -404,6 +406,7 @@ type sqlcQuerier interface {
InsertProvisionerJobTimings(ctx context.Context, arg InsertProvisionerJobTimingsParams) ([]ProvisionerJobTiming, error)
InsertProvisionerKey(ctx context.Context, arg InsertProvisionerKeyParams) (ProvisionerKey, error)
InsertReplica(ctx context.Context, arg InsertReplicaParams) (Replica, error)
InsertTelemetryItemIfNotExists(ctx context.Context, arg InsertTelemetryItemIfNotExistsParams) error
InsertTemplate(ctx context.Context, arg InsertTemplateParams) error
InsertTemplateVersion(ctx context.Context, arg InsertTemplateVersionParams) error
InsertTemplateVersionParameter(ctx context.Context, arg InsertTemplateVersionParameterParams) (TemplateVersionParameter, error)
@@ -546,6 +549,7 @@ type sqlcQuerier interface {
UpsertTailnetCoordinator(ctx context.Context, id uuid.UUID) (TailnetCoordinator, error)
UpsertTailnetPeer(ctx context.Context, arg UpsertTailnetPeerParams) (TailnetPeer, error)
UpsertTailnetTunnel(ctx context.Context, arg UpsertTailnetTunnelParams) (TailnetTunnel, error)
UpsertTelemetryItem(ctx context.Context, arg UpsertTelemetryItemParams) error
// This query aggregates the workspace_agent_stats and workspace_app_stats data
// into a single table for efficient storage and querying. Half-hour buckets are
// used to store the data, and the minutes are summed for each user and template
+80
View File
@@ -8702,6 +8702,86 @@ func (q *sqlQuerier) UpsertTailnetTunnel(ctx context.Context, arg UpsertTailnetT
return i, err
}
const getTelemetryItem = `-- name: GetTelemetryItem :one
SELECT key, value, created_at, updated_at FROM telemetry_items WHERE key = $1
`
func (q *sqlQuerier) GetTelemetryItem(ctx context.Context, key string) (TelemetryItem, error) {
row := q.db.QueryRowContext(ctx, getTelemetryItem, key)
var i TelemetryItem
err := row.Scan(
&i.Key,
&i.Value,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getTelemetryItems = `-- name: GetTelemetryItems :many
SELECT key, value, created_at, updated_at FROM telemetry_items
`
func (q *sqlQuerier) GetTelemetryItems(ctx context.Context) ([]TelemetryItem, error) {
rows, err := q.db.QueryContext(ctx, getTelemetryItems)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TelemetryItem
for rows.Next() {
var i TelemetryItem
if err := rows.Scan(
&i.Key,
&i.Value,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertTelemetryItemIfNotExists = `-- name: InsertTelemetryItemIfNotExists :exec
INSERT INTO telemetry_items (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING
`
type InsertTelemetryItemIfNotExistsParams struct {
Key string `db:"key" json:"key"`
Value string `db:"value" json:"value"`
}
func (q *sqlQuerier) InsertTelemetryItemIfNotExists(ctx context.Context, arg InsertTelemetryItemIfNotExistsParams) error {
_, err := q.db.ExecContext(ctx, insertTelemetryItemIfNotExists, arg.Key, arg.Value)
return err
}
const upsertTelemetryItem = `-- name: UpsertTelemetryItem :exec
INSERT INTO telemetry_items (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW() WHERE telemetry_items.key = $1
`
type UpsertTelemetryItemParams struct {
Key string `db:"key" json:"key"`
Value string `db:"value" json:"value"`
}
func (q *sqlQuerier) UpsertTelemetryItem(ctx context.Context, arg UpsertTelemetryItemParams) error {
_, err := q.db.ExecContext(ctx, upsertTelemetryItem, arg.Key, arg.Value)
return err
}
const getTemplateAverageBuildTime = `-- name: GetTemplateAverageBuildTime :one
WITH build_times AS (
SELECT
@@ -0,0 +1,15 @@
-- name: InsertTelemetryItemIfNotExists :exec
INSERT INTO telemetry_items (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO NOTHING;
-- name: GetTelemetryItem :one
SELECT * FROM telemetry_items WHERE key = $1;
-- name: UpsertTelemetryItem :exec
INSERT INTO telemetry_items (key, value)
VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW() WHERE telemetry_items.key = $1;
-- name: GetTelemetryItems :many
SELECT * FROM telemetry_items;
+1
View File
@@ -55,6 +55,7 @@ const (
UniqueTailnetCoordinatorsPkey UniqueConstraint = "tailnet_coordinators_pkey" // ALTER TABLE ONLY tailnet_coordinators ADD CONSTRAINT tailnet_coordinators_pkey PRIMARY KEY (id);
UniqueTailnetPeersPkey UniqueConstraint = "tailnet_peers_pkey" // ALTER TABLE ONLY tailnet_peers ADD CONSTRAINT tailnet_peers_pkey PRIMARY KEY (id, coordinator_id);
UniqueTailnetTunnelsPkey UniqueConstraint = "tailnet_tunnels_pkey" // ALTER TABLE ONLY tailnet_tunnels ADD CONSTRAINT tailnet_tunnels_pkey PRIMARY KEY (coordinator_id, src_id, dst_id);
UniqueTelemetryItemsPkey UniqueConstraint = "telemetry_items_pkey" // ALTER TABLE ONLY telemetry_items ADD CONSTRAINT telemetry_items_pkey PRIMARY KEY (key);
UniqueTemplateUsageStatsPkey UniqueConstraint = "template_usage_stats_pkey" // ALTER TABLE ONLY template_usage_stats ADD CONSTRAINT template_usage_stats_pkey PRIMARY KEY (start_time, template_id, user_id);
UniqueTemplateVersionParametersTemplateVersionIDNameKey UniqueConstraint = "template_version_parameters_template_version_id_name_key" // ALTER TABLE ONLY template_version_parameters ADD CONSTRAINT template_version_parameters_template_version_id_name_key UNIQUE (template_version_id, name);
UniqueTemplateVersionVariablesTemplateVersionIDNameKey UniqueConstraint = "template_version_variables_template_version_id_name_key" // ALTER TABLE ONLY template_version_variables ADD CONSTRAINT template_version_variables_template_version_id_name_key UNIQUE (template_version_id, name);
+40
View File
@@ -579,6 +579,17 @@ func (r *remoteReporter) createSnapshot() (*Snapshot, error) {
}
return nil
})
eg.Go(func() error {
items, err := r.options.Database.GetTelemetryItems(ctx)
if err != nil {
return xerrors.Errorf("get telemetry items: %w", err)
}
snapshot.TelemetryItems = make([]TelemetryItem, 0, len(items))
for _, item := range items {
snapshot.TelemetryItems = append(snapshot.TelemetryItems, ConvertTelemetryItem(item))
}
return nil
})
err := eg.Wait()
if err != nil {
@@ -985,6 +996,15 @@ func ConvertOrganization(org database.Organization) Organization {
}
}
func ConvertTelemetryItem(item database.TelemetryItem) TelemetryItem {
return TelemetryItem{
Key: item.Key,
Value: item.Value,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
// Snapshot represents a point-in-time anonymized database dump.
// Data is aggregated by latest on the server-side, so partial data
// can be sent without issue.
@@ -1012,6 +1032,7 @@ type Snapshot struct {
Workspaces []Workspace `json:"workspaces"`
NetworkEvents []NetworkEvent `json:"network_events"`
Organizations []Organization `json:"organizations"`
TelemetryItems []TelemetryItem `json:"telemetry_items"`
}
// Deployment contains information about the host running Coder.
@@ -1536,6 +1557,25 @@ type Organization struct {
CreatedAt time.Time `json:"created_at"`
}
type telemetryItemKey string
// The comment below gets rid of the warning that the name "TelemetryItemKey" has
// the "Telemetry" prefix, and that stutters when you use it outside the package
// (telemetry.TelemetryItemKey...). "TelemetryItem" is the name of a database table,
// so it makes sense to use the "Telemetry" prefix.
//
//revive:disable:exported
const (
TelemetryItemKeyHTMLFirstServedAt telemetryItemKey = "html_first_served_at"
)
type TelemetryItem struct {
Key string `json:"key"`
Value string `json:"value"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type noopReporter struct{}
func (*noopReporter) Report(_ *Snapshot) {}
+46 -1
View File
@@ -75,6 +75,10 @@ func TestTelemetry(t *testing.T) {
Health: database.WorkspaceAppHealthDisabled,
OpenIn: database.WorkspaceAppOpenInSlimWindow,
})
_ = dbgen.TelemetryItem(t, db, database.TelemetryItem{
Key: string(telemetry.TelemetryItemKeyHTMLFirstServedAt),
Value: time.Now().Format(time.RFC3339),
})
group := dbgen.Group(t, db, database.Group{})
_ = dbgen.GroupMember(t, db, database.GroupMemberTable{UserID: user.ID, GroupID: group.ID})
wsagent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{})
@@ -127,7 +131,7 @@ func TestTelemetry(t *testing.T) {
require.Len(t, snapshot.WorkspaceProxies, 1)
require.Len(t, snapshot.WorkspaceModules, 1)
require.Len(t, snapshot.Organizations, 1)
require.Len(t, snapshot.TelemetryItems, 1)
wsa := snapshot.WorkspaceAgents[0]
require.Len(t, wsa.Subsystems, 2)
require.Equal(t, string(database.WorkspaceAgentSubsystemEnvbox), wsa.Subsystems[0])
@@ -316,6 +320,47 @@ func TestTelemetryInstallSource(t *testing.T) {
require.Equal(t, "aws_marketplace", deployment.InstallSource)
}
func TestTelemetryItem(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
db, _ := dbtestutil.NewDB(t)
key := testutil.GetRandomName(t)
value := time.Now().Format(time.RFC3339)
err := db.InsertTelemetryItemIfNotExists(ctx, database.InsertTelemetryItemIfNotExistsParams{
Key: key,
Value: value,
})
require.NoError(t, err)
item, err := db.GetTelemetryItem(ctx, key)
require.NoError(t, err)
require.Equal(t, item.Key, key)
require.Equal(t, item.Value, value)
// Inserting a new value should not update the existing value
err = db.InsertTelemetryItemIfNotExists(ctx, database.InsertTelemetryItemIfNotExistsParams{
Key: key,
Value: "new_value",
})
require.NoError(t, err)
item, err = db.GetTelemetryItem(ctx, key)
require.NoError(t, err)
require.Equal(t, item.Value, value)
// Upserting a new value should update the existing value
err = db.UpsertTelemetryItem(ctx, database.UpsertTelemetryItemParams{
Key: key,
Value: "new_value",
})
require.NoError(t, err)
item, err = db.GetTelemetryItem(ctx, key)
require.NoError(t, err)
require.Equal(t, item.Value, "new_value")
}
func collectSnapshot(t *testing.T, db database.Store, addOptionsFn func(opts telemetry.Options) telemetry.Options) (*telemetry.Deployment, *telemetry.Snapshot) {
t.Helper()
deployment := make(chan *telemetry.Deployment, 64)
+45
View File
@@ -34,6 +34,7 @@ import (
"golang.org/x/sync/singleflight"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/appearance"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
@@ -41,6 +42,7 @@ import (
"github.com/coder/coder/v2/coderd/entitlements"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/codersdk"
)
@@ -81,6 +83,8 @@ type Options struct {
BuildInfo codersdk.BuildInfoResponse
AppearanceFetcher *atomic.Pointer[appearance.Fetcher]
Entitlements *entitlements.Set
Telemetry telemetry.Reporter
Logger slog.Logger
}
func New(opts *Options) *Handler {
@@ -183,6 +187,8 @@ type Handler struct {
Entitlements *entitlements.Set
Experiments atomic.Pointer[codersdk.Experiments]
telemetryHTMLServedOnce sync.Once
}
func (h *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
@@ -321,12 +327,51 @@ func ShouldCacheFile(reqFile string) bool {
return true
}
// reportHTMLFirstServedAt sends a telemetry report when the first HTML is ever served.
// The purpose is to track the first time the first user opens the site.
func (h *Handler) reportHTMLFirstServedAt() {
// nolint:gocritic // Manipulating telemetry items is system-restricted.
// TODO(hugodutka): Add a telemetry context in RBAC.
ctx := dbauthz.AsSystemRestricted(context.Background())
itemKey := string(telemetry.TelemetryItemKeyHTMLFirstServedAt)
_, err := h.opts.Database.GetTelemetryItem(ctx, itemKey)
if err == nil {
// If the value is already set, then we reported it before.
// We don't need to report it again.
return
}
if !errors.Is(err, sql.ErrNoRows) {
h.opts.Logger.Debug(ctx, "failed to get telemetry html first served at", slog.Error(err))
return
}
if err := h.opts.Database.InsertTelemetryItemIfNotExists(ctx, database.InsertTelemetryItemIfNotExistsParams{
Key: string(telemetry.TelemetryItemKeyHTMLFirstServedAt),
Value: time.Now().Format(time.RFC3339),
}); err != nil {
h.opts.Logger.Debug(ctx, "failed to set telemetry html first served at", slog.Error(err))
return
}
item, err := h.opts.Database.GetTelemetryItem(ctx, itemKey)
if err != nil {
h.opts.Logger.Debug(ctx, "failed to get telemetry html first served at", slog.Error(err))
return
}
h.opts.Telemetry.Report(&telemetry.Snapshot{
TelemetryItems: []telemetry.TelemetryItem{telemetry.ConvertTelemetryItem(item)},
})
}
func (h *Handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
if data, err := h.renderHTMLWithState(request, reqPath, state); err == nil {
if reqPath == "" {
// Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
reqPath = "index.html"
}
// `Once` is used to reduce the volume of db calls and telemetry reports.
// It's fine to run the enclosed function multiple times, but it's unnecessary.
h.telemetryHTMLServedOnce.Do(func() {
go h.reportHTMLFirstServedAt()
})
http.ServeContent(resp, request, reqPath, time.Time{}, bytes.NewReader(data))
return true
}
+21 -10
View File
@@ -27,8 +27,10 @@ import (
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbmem"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/telemetry"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/site"
"github.com/coder/coder/v2/testutil"
@@ -45,9 +47,10 @@ func TestInjection(t *testing.T) {
binFs := http.FS(fstest.MapFS{})
db := dbmem.New()
handler := site.New(&site.Options{
BinFS: binFs,
Database: db,
SiteFS: siteFS,
Telemetry: telemetry.NewNoop(),
BinFS: binFs,
Database: db,
SiteFS: siteFS,
})
user := dbgen.User(t, db, database.User{})
@@ -101,9 +104,10 @@ func TestInjectionFailureProducesCleanHTML(t *testing.T) {
},
}
handler := site.New(&site.Options{
BinFS: binFs,
Database: db,
SiteFS: siteFS,
Telemetry: telemetry.NewNoop(),
BinFS: binFs,
Database: db,
SiteFS: siteFS,
// No OAuth2 configs, refresh will fail.
OAuth2Configs: &httpmw.OAuth2Configs{
@@ -147,9 +151,12 @@ func TestCaching(t *testing.T) {
}
binFS := http.FS(fstest.MapFS{})
db, _ := dbtestutil.NewDB(t)
srv := httptest.NewServer(site.New(&site.Options{
BinFS: binFS,
SiteFS: rootFS,
Telemetry: telemetry.NewNoop(),
BinFS: binFS,
SiteFS: rootFS,
Database: db,
}))
defer srv.Close()
@@ -213,9 +220,12 @@ func TestServingFiles(t *testing.T) {
}
binFS := http.FS(fstest.MapFS{})
db, _ := dbtestutil.NewDB(t)
srv := httptest.NewServer(site.New(&site.Options{
BinFS: binFS,
SiteFS: rootFS,
Telemetry: telemetry.NewNoop(),
BinFS: binFS,
SiteFS: rootFS,
Database: db,
}))
defer srv.Close()
@@ -473,6 +483,7 @@ func TestServingBin(t *testing.T) {
}
srv := httptest.NewServer(site.New(&site.Options{
Telemetry: telemetry.NewNoop(),
BinFS: binFS,
BinHashes: binHashes,
SiteFS: rootFS,