Skip to content

Commit 17e9900

Browse files
committed
feat: Add anonymized telemetry to report product usage
This adds a background service to report telemetry to a Coder server for usage data. There will be realtime event data sent in the future, but for now usage will report on a CRON.
1 parent e3a1cd3 commit 17e9900

25 files changed

+1412
-74
lines changed

cli/server.go

+46
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/briandowns/spinner"
2626
"github.com/coreos/go-systemd/daemon"
2727
"github.com/google/go-github/v43/github"
28+
"github.com/google/uuid"
2829
"github.com/pion/turn/v2"
2930
"github.com/pion/webrtc/v3"
3031
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -49,6 +50,7 @@ import (
4950
"github.com/coder/coder/coderd/database/databasefake"
5051
"github.com/coder/coder/coderd/devtunnel"
5152
"github.com/coder/coder/coderd/gitsshkey"
53+
"github.com/coder/coder/coderd/telemetry"
5254
"github.com/coder/coder/coderd/tracing"
5355
"github.com/coder/coder/coderd/turnconn"
5456
"github.com/coder/coder/codersdk"
@@ -80,6 +82,8 @@ func server() *cobra.Command {
8082
oauth2GithubClientSecret string
8183
oauth2GithubAllowedOrganizations []string
8284
oauth2GithubAllowSignups bool
85+
telemetryEnabled bool
86+
telemetryURL string
8387
tlsCertFile string
8488
tlsClientCAFile string
8589
tlsClientAuth string
@@ -301,6 +305,45 @@ func server() *cobra.Command {
301305
}
302306
}
303307

308+
deploymentID, err := options.Database.GetDeploymentID(cmd.Context())
309+
if errors.Is(err, sql.ErrNoRows) {
310+
err = nil
311+
}
312+
if err != nil {
313+
return xerrors.Errorf("get deployment id: %w", err)
314+
}
315+
if deploymentID == "" {
316+
deploymentID = uuid.NewString()
317+
err = options.Database.InsertDeploymentID(cmd.Context(), deploymentID)
318+
if err != nil {
319+
return xerrors.Errorf("set deployment id: %w", err)
320+
}
321+
}
322+
323+
// Parse the raw telemetry URL!
324+
telemetryURL, err := url.Parse(telemetryURL)
325+
if err != nil {
326+
return xerrors.Errorf("parse telemetry url: %w", err)
327+
}
328+
// Disable telemetry if in dev-mode. If the telemetry flag
329+
// is manually specified, override this behavior!
330+
if buildModeDev && !cmd.Flags().Changed("telemetry") {
331+
telemetryEnabled = false
332+
}
333+
reporter, err := telemetry.New(telemetry.Options{
334+
DeploymentID: deploymentID,
335+
Database: options.Database,
336+
Logger: logger.Named("telemetry"),
337+
URL: telemetryURL,
338+
DevMode: dev,
339+
Disabled: !telemetryEnabled,
340+
})
341+
if err != nil {
342+
return xerrors.Errorf("create telemetry reporter: %w", err)
343+
}
344+
defer reporter.Close()
345+
options.Telemetry = reporter
346+
304347
coderAPI := coderd.New(options)
305348
client := codersdk.New(localURL)
306349
if tlsEnable {
@@ -537,6 +580,9 @@ func server() *cobra.Command {
537580
"Specifies organizations the user must be a member of to authenticate with GitHub.")
538581
cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false,
539582
"Specifies whether new users can sign up with GitHub.")
583+
cliflag.BoolVarP(root.Flags(), &telemetryEnabled, "telemetry", "", "CODER_TELEMETRY", true, "Specifies whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product!")
584+
cliflag.StringVarP(root.Flags(), &telemetryURL, "telemetry-url", "", "CODER_TELEMETRY_URL", "https://telemetry.coder.com", "Specifies a URL to send telemetry to.")
585+
_ = root.Flags().MarkHidden("telemetry-url")
540586
cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false, "Specifies if TLS will be enabled")
541587
cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "",
542588
"Specifies the path to the certificate for TLS. It requires a PEM-encoded file. "+

cli/server_test.go

+37
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,29 @@ import (
88
"crypto/tls"
99
"crypto/x509"
1010
"crypto/x509/pkix"
11+
"encoding/json"
1112
"encoding/pem"
1213
"fmt"
1314
"math/big"
1415
"net"
1516
"net/http"
17+
"net/http/httptest"
1618
"net/url"
1719
"os"
1820
"runtime"
1921
"strings"
2022
"testing"
2123
"time"
2224

25+
"github.com/go-chi/chi"
2326
"github.com/stretchr/testify/assert"
2427
"github.com/stretchr/testify/require"
2528
"go.uber.org/goleak"
2629

2730
"github.com/coder/coder/cli/clitest"
2831
"github.com/coder/coder/coderd/coderdtest"
2932
"github.com/coder/coder/coderd/database/postgres"
33+
"github.com/coder/coder/coderd/telemetry"
3034
"github.com/coder/coder/codersdk"
3135
)
3236

@@ -322,6 +326,39 @@ func TestServer(t *testing.T) {
322326
require.ErrorIs(t, <-errC, context.Canceled)
323327
require.Error(t, goleak.Find())
324328
})
329+
t.Run("Telemetry", func(t *testing.T) {
330+
t.Parallel()
331+
ctx, cancelFunc := context.WithCancel(context.Background())
332+
defer cancelFunc()
333+
334+
deployment := make(chan struct{}, 64)
335+
snapshot := make(chan *telemetry.Snapshot, 64)
336+
r := chi.NewRouter()
337+
r.Post("/deployment", func(w http.ResponseWriter, r *http.Request) {
338+
w.WriteHeader(http.StatusAccepted)
339+
deployment <- struct{}{}
340+
})
341+
r.Post("/snapshot", func(w http.ResponseWriter, r *http.Request) {
342+
w.WriteHeader(http.StatusAccepted)
343+
ss := &telemetry.Snapshot{}
344+
err := json.NewDecoder(r.Body).Decode(ss)
345+
require.NoError(t, err)
346+
snapshot <- ss
347+
})
348+
server := httptest.NewServer(r)
349+
t.Cleanup(server.Close)
350+
351+
root, _ := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0", "--telemetry", "true", "--telemetry-url", server.URL)
352+
var buf strings.Builder
353+
errC := make(chan error)
354+
root.SetOutput(&buf)
355+
go func() {
356+
errC <- root.ExecuteContext(ctx)
357+
}()
358+
359+
<-deployment
360+
<-snapshot
361+
})
325362
}
326363

327364
func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {

coderd/coderd.go

+2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/coder/coder/coderd/httpapi"
2626
"github.com/coder/coder/coderd/httpmw"
2727
"github.com/coder/coder/coderd/rbac"
28+
"github.com/coder/coder/coderd/telemetry"
2829
"github.com/coder/coder/coderd/tracing"
2930
"github.com/coder/coder/coderd/turnconn"
3031
"github.com/coder/coder/coderd/wsconncache"
@@ -52,6 +53,7 @@ type Options struct {
5253
ICEServers []webrtc.ICEServer
5354
SecureAuthCookie bool
5455
SSHKeygenAlgorithm gitsshkey.Algorithm
56+
Telemetry *telemetry.Reporter
5557
TURNServer *turnconn.Server
5658
TracerProvider *sdktrace.TracerProvider
5759
}

coderd/database/databasefake/databasefake.go

+116-1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ type fakeQuerier struct {
6666
workspaceBuilds []database.WorkspaceBuild
6767
workspaceApps []database.WorkspaceApp
6868
workspaces []database.Workspace
69+
70+
deploymentID string
6971
}
7072

7173
// InTx doesn't rollback data properly for in-memory yet.
@@ -315,7 +317,7 @@ func (q *fakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U
315317
}, nil
316318
}
317319

318-
func (q *fakeQuerier) GetWorkspacesWithFilter(_ context.Context, arg database.GetWorkspacesWithFilterParams) ([]database.Workspace, error) {
320+
func (q *fakeQuerier) GetWorkspaces(_ context.Context, arg database.GetWorkspacesParams) ([]database.Workspace, error) {
319321
q.mutex.RLock()
320322
defer q.mutex.RUnlock()
321323

@@ -415,6 +417,19 @@ func (q *fakeQuerier) GetWorkspaceAppsByAgentID(_ context.Context, id uuid.UUID)
415417
return apps, nil
416418
}
417419

420+
func (q *fakeQuerier) GetWorkspaceAppsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceApp, error) {
421+
q.mutex.RLock()
422+
defer q.mutex.RUnlock()
423+
424+
apps := make([]database.WorkspaceApp, 0)
425+
for _, app := range q.workspaceApps {
426+
if app.CreatedAt.After(after) {
427+
apps = append(apps, app)
428+
}
429+
}
430+
return apps, nil
431+
}
432+
418433
func (q *fakeQuerier) GetWorkspaceAppsByAgentIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceApp, error) {
419434
q.mutex.RLock()
420435
defer q.mutex.RUnlock()
@@ -641,6 +656,19 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Con
641656
return database.WorkspaceBuild{}, sql.ErrNoRows
642657
}
643658

659+
func (q *fakeQuerier) GetWorkspaceBuildsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceBuild, error) {
660+
q.mutex.RLock()
661+
defer q.mutex.RUnlock()
662+
663+
workspaceBuilds := make([]database.WorkspaceBuild, 0)
664+
for _, workspaceBuild := range q.workspaceBuilds {
665+
if workspaceBuild.CreatedAt.After(after) {
666+
workspaceBuilds = append(workspaceBuilds, workspaceBuild)
667+
}
668+
}
669+
return workspaceBuilds, nil
670+
}
671+
644672
func (q *fakeQuerier) GetWorkspacesByOrganizationIDs(_ context.Context, req database.GetWorkspacesByOrganizationIDsParams) ([]database.Workspace, error) {
645673
q.mutex.RLock()
646674
defer q.mutex.RUnlock()
@@ -845,6 +873,19 @@ func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg dat
845873
return version, nil
846874
}
847875

876+
func (q *fakeQuerier) GetTemplateVersionsCreatedAfter(_ context.Context, after time.Time) ([]database.TemplateVersion, error) {
877+
q.mutex.RLock()
878+
defer q.mutex.RUnlock()
879+
880+
versions := make([]database.TemplateVersion, 0)
881+
for _, version := range q.templateVersions {
882+
if version.CreatedAt.After(after) {
883+
versions = append(versions, version)
884+
}
885+
}
886+
return versions, nil
887+
}
888+
848889
func (q *fakeQuerier) GetTemplateVersionByTemplateIDAndName(_ context.Context, arg database.GetTemplateVersionByTemplateIDAndNameParams) (database.TemplateVersion, error) {
849890
q.mutex.RLock()
850891
defer q.mutex.RUnlock()
@@ -904,6 +945,19 @@ func (q *fakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.U
904945
return parameters, nil
905946
}
906947

948+
func (q *fakeQuerier) GetParameterSchemasCreatedAfter(_ context.Context, after time.Time) ([]database.ParameterSchema, error) {
949+
q.mutex.RLock()
950+
defer q.mutex.RUnlock()
951+
952+
parameters := make([]database.ParameterSchema, 0)
953+
for _, parameterSchema := range q.parameterSchemas {
954+
if parameterSchema.CreatedAt.After(after) {
955+
parameters = append(parameters, parameterSchema)
956+
}
957+
}
958+
return parameters, nil
959+
}
960+
907961
func (q *fakeQuerier) GetParameterValueByScopeAndName(_ context.Context, arg database.GetParameterValueByScopeAndNameParams) (database.ParameterValue, error) {
908962
q.mutex.RLock()
909963
defer q.mutex.RUnlock()
@@ -923,6 +977,13 @@ func (q *fakeQuerier) GetParameterValueByScopeAndName(_ context.Context, arg dat
923977
return database.ParameterValue{}, sql.ErrNoRows
924978
}
925979

980+
func (q *fakeQuerier) GetTemplates(_ context.Context) ([]database.Template, error) {
981+
q.mutex.RLock()
982+
defer q.mutex.RUnlock()
983+
984+
return q.templates, nil
985+
}
986+
926987
func (q *fakeQuerier) GetTemplatesByOrganization(_ context.Context, arg database.GetTemplatesByOrganizationParams) ([]database.Template, error) {
927988
q.mutex.RLock()
928989
defer q.mutex.RUnlock()
@@ -1109,6 +1170,19 @@ func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, resourc
11091170
return workspaceAgents, nil
11101171
}
11111172

1173+
func (q *fakeQuerier) GetWorkspaceAgentsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceAgent, error) {
1174+
q.mutex.RLock()
1175+
defer q.mutex.RUnlock()
1176+
1177+
workspaceAgents := make([]database.WorkspaceAgent, 0)
1178+
for _, agent := range q.provisionerJobAgents {
1179+
if agent.CreatedAt.After(after) {
1180+
workspaceAgents = append(workspaceAgents, agent)
1181+
}
1182+
}
1183+
return workspaceAgents, nil
1184+
}
1185+
11121186
func (q *fakeQuerier) GetWorkspaceAppByAgentIDAndName(_ context.Context, arg database.GetWorkspaceAppByAgentIDAndNameParams) (database.WorkspaceApp, error) {
11131187
q.mutex.RLock()
11141188
defer q.mutex.RUnlock()
@@ -1180,6 +1254,19 @@ func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid
11801254
return resources, nil
11811255
}
11821256

1257+
func (q *fakeQuerier) GetWorkspaceResourcesCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceResource, error) {
1258+
q.mutex.RLock()
1259+
defer q.mutex.RUnlock()
1260+
1261+
resources := make([]database.WorkspaceResource, 0)
1262+
for _, resource := range q.provisionerJobResources {
1263+
if resource.CreatedAt.After(after) {
1264+
resources = append(resources, resource)
1265+
}
1266+
}
1267+
return resources, nil
1268+
}
1269+
11831270
func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID) ([]database.ProvisionerJob, error) {
11841271
q.mutex.RLock()
11851272
defer q.mutex.RUnlock()
@@ -1200,6 +1287,19 @@ func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID
12001287
return jobs, nil
12011288
}
12021289

1290+
func (q *fakeQuerier) GetProvisionerJobsCreatedAfter(_ context.Context, after time.Time) ([]database.ProvisionerJob, error) {
1291+
q.mutex.RLock()
1292+
defer q.mutex.RUnlock()
1293+
1294+
jobs := make([]database.ProvisionerJob, 0)
1295+
for _, job := range q.provisionerJobs {
1296+
if job.CreatedAt.After(after) {
1297+
jobs = append(jobs, job)
1298+
}
1299+
}
1300+
return jobs, nil
1301+
}
1302+
12031303
func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg database.GetProvisionerLogsByIDBetweenParams) ([]database.ProvisionerJobLog, error) {
12041304
q.mutex.RLock()
12051305
defer q.mutex.RUnlock()
@@ -1980,3 +2080,18 @@ func (q *fakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAudit
19802080

19812081
return alog, nil
19822082
}
2083+
2084+
func (q *fakeQuerier) InsertDeploymentID(_ context.Context, id string) error {
2085+
q.mutex.Lock()
2086+
defer q.mutex.Unlock()
2087+
2088+
q.deploymentID = id
2089+
return nil
2090+
}
2091+
2092+
func (q *fakeQuerier) GetDeploymentID(_ context.Context) (string, error) {
2093+
q.mutex.RLock()
2094+
defer q.mutex.RUnlock()
2095+
2096+
return q.deploymentID, nil
2097+
}

coderd/database/dump.sql

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE site_config;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CREATE TABLE IF NOT EXISTS site_config (
2+
key varchar(256) NOT NULL UNIQUE,
3+
value varchar(8192) NOT NULL
4+
);

coderd/database/models.go

+5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)