Skip to content

feat: Add anonymized telemetry to report product usage #2273

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 17, 2022
Merged
Next Next commit
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.
  • Loading branch information
kylecarbs committed Jun 12, 2022
commit 17e99001f2635bae37f234aafb8a522c4790c70b
46 changes: 46 additions & 0 deletions cli/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/briandowns/spinner"
"github.com/coreos/go-systemd/daemon"
"github.com/google/go-github/v43/github"
"github.com/google/uuid"
"github.com/pion/turn/v2"
"github.com/pion/webrtc/v3"
"github.com/prometheus/client_golang/prometheus/promhttp"
Expand All @@ -49,6 +50,7 @@ import (
"github.com/coder/coder/coderd/database/databasefake"
"github.com/coder/coder/coderd/devtunnel"
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/turnconn"
"github.com/coder/coder/codersdk"
Expand Down Expand Up @@ -80,6 +82,8 @@ func server() *cobra.Command {
oauth2GithubClientSecret string
oauth2GithubAllowedOrganizations []string
oauth2GithubAllowSignups bool
telemetryEnabled bool
telemetryURL string
tlsCertFile string
tlsClientCAFile string
tlsClientAuth string
Expand Down Expand Up @@ -301,6 +305,45 @@ func server() *cobra.Command {
}
}

deploymentID, err := options.Database.GetDeploymentID(cmd.Context())
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
return xerrors.Errorf("get deployment id: %w", err)
}
if deploymentID == "" {
deploymentID = uuid.NewString()
err = options.Database.InsertDeploymentID(cmd.Context(), deploymentID)
if err != nil {
return xerrors.Errorf("set deployment id: %w", err)
}
}

// Parse the raw telemetry URL!
telemetryURL, err := url.Parse(telemetryURL)
if err != nil {
return xerrors.Errorf("parse telemetry url: %w", err)
}
// Disable telemetry if in dev-mode. If the telemetry flag
// is manually specified, override this behavior!
if buildModeDev && !cmd.Flags().Changed("telemetry") {
telemetryEnabled = false
}
reporter, err := telemetry.New(telemetry.Options{
DeploymentID: deploymentID,
Database: options.Database,
Logger: logger.Named("telemetry"),
URL: telemetryURL,
DevMode: dev,
Disabled: !telemetryEnabled,
})
if err != nil {
return xerrors.Errorf("create telemetry reporter: %w", err)
}
defer reporter.Close()
options.Telemetry = reporter

coderAPI := coderd.New(options)
client := codersdk.New(localURL)
if tlsEnable {
Expand Down Expand Up @@ -537,6 +580,9 @@ func server() *cobra.Command {
"Specifies organizations the user must be a member of to authenticate with GitHub.")
cliflag.BoolVarP(root.Flags(), &oauth2GithubAllowSignups, "oauth2-github-allow-signups", "", "CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS", false,
"Specifies whether new users can sign up with GitHub.")
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!")
cliflag.StringVarP(root.Flags(), &telemetryURL, "telemetry-url", "", "CODER_TELEMETRY_URL", "https://telemetry.coder.com", "Specifies a URL to send telemetry to.")
_ = root.Flags().MarkHidden("telemetry-url")
cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false, "Specifies if TLS will be enabled")
cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "",
"Specifies the path to the certificate for TLS. It requires a PEM-encoded file. "+
Expand Down
37 changes: 37 additions & 0 deletions cli/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,29 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"runtime"
"strings"
"testing"
"time"

"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"

"github.com/coder/coder/cli/clitest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database/postgres"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/codersdk"
)

Expand Down Expand Up @@ -322,6 +326,39 @@ func TestServer(t *testing.T) {
require.ErrorIs(t, <-errC, context.Canceled)
require.Error(t, goleak.Find())
})
t.Run("Telemetry", func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()

deployment := make(chan struct{}, 64)
snapshot := make(chan *telemetry.Snapshot, 64)
r := chi.NewRouter()
r.Post("/deployment", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
deployment <- struct{}{}
})
r.Post("/snapshot", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
ss := &telemetry.Snapshot{}
err := json.NewDecoder(r.Body).Decode(ss)
require.NoError(t, err)
snapshot <- ss
})
server := httptest.NewServer(r)
t.Cleanup(server.Close)

root, _ := clitest.New(t, "server", "--dev", "--tunnel=false", "--address", ":0", "--telemetry", "true", "--telemetry-url", server.URL)
var buf strings.Builder
errC := make(chan error)
root.SetOutput(&buf)
go func() {
errC <- root.ExecuteContext(ctx)
}()

<-deployment
<-snapshot
})
}

func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {
Expand Down
2 changes: 2 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/telemetry"
"github.com/coder/coder/coderd/tracing"
"github.com/coder/coder/coderd/turnconn"
"github.com/coder/coder/coderd/wsconncache"
Expand Down Expand Up @@ -52,6 +53,7 @@ type Options struct {
ICEServers []webrtc.ICEServer
SecureAuthCookie bool
SSHKeygenAlgorithm gitsshkey.Algorithm
Telemetry *telemetry.Reporter
TURNServer *turnconn.Server
TracerProvider *sdktrace.TracerProvider
}
Expand Down
117 changes: 116 additions & 1 deletion coderd/database/databasefake/databasefake.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ type fakeQuerier struct {
workspaceBuilds []database.WorkspaceBuild
workspaceApps []database.WorkspaceApp
workspaces []database.Workspace

deploymentID string
}

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

func (q *fakeQuerier) GetWorkspacesWithFilter(_ context.Context, arg database.GetWorkspacesWithFilterParams) ([]database.Workspace, error) {
func (q *fakeQuerier) GetWorkspaces(_ context.Context, arg database.GetWorkspacesParams) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

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

func (q *fakeQuerier) GetWorkspaceAppsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceApp, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

apps := make([]database.WorkspaceApp, 0)
for _, app := range q.workspaceApps {
if app.CreatedAt.After(after) {
apps = append(apps, app)
}
}
return apps, nil
}

func (q *fakeQuerier) GetWorkspaceAppsByAgentIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceApp, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
Expand Down Expand Up @@ -641,6 +656,19 @@ func (q *fakeQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(_ context.Con
return database.WorkspaceBuild{}, sql.ErrNoRows
}

func (q *fakeQuerier) GetWorkspaceBuildsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceBuild, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

workspaceBuilds := make([]database.WorkspaceBuild, 0)
for _, workspaceBuild := range q.workspaceBuilds {
if workspaceBuild.CreatedAt.After(after) {
workspaceBuilds = append(workspaceBuilds, workspaceBuild)
}
}
return workspaceBuilds, nil
}

func (q *fakeQuerier) GetWorkspacesByOrganizationIDs(_ context.Context, req database.GetWorkspacesByOrganizationIDsParams) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
Expand Down Expand Up @@ -845,6 +873,19 @@ func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg dat
return version, nil
}

func (q *fakeQuerier) GetTemplateVersionsCreatedAfter(_ context.Context, after time.Time) ([]database.TemplateVersion, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

versions := make([]database.TemplateVersion, 0)
for _, version := range q.templateVersions {
if version.CreatedAt.After(after) {
versions = append(versions, version)
}
}
return versions, nil
}

func (q *fakeQuerier) GetTemplateVersionByTemplateIDAndName(_ context.Context, arg database.GetTemplateVersionByTemplateIDAndNameParams) (database.TemplateVersion, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
Expand Down Expand Up @@ -904,6 +945,19 @@ func (q *fakeQuerier) GetParameterSchemasByJobID(_ context.Context, jobID uuid.U
return parameters, nil
}

func (q *fakeQuerier) GetParameterSchemasCreatedAfter(_ context.Context, after time.Time) ([]database.ParameterSchema, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

parameters := make([]database.ParameterSchema, 0)
for _, parameterSchema := range q.parameterSchemas {
if parameterSchema.CreatedAt.After(after) {
parameters = append(parameters, parameterSchema)
}
}
return parameters, nil
}

func (q *fakeQuerier) GetParameterValueByScopeAndName(_ context.Context, arg database.GetParameterValueByScopeAndNameParams) (database.ParameterValue, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
Expand All @@ -923,6 +977,13 @@ func (q *fakeQuerier) GetParameterValueByScopeAndName(_ context.Context, arg dat
return database.ParameterValue{}, sql.ErrNoRows
}

func (q *fakeQuerier) GetTemplates(_ context.Context) ([]database.Template, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

return q.templates, nil
}

func (q *fakeQuerier) GetTemplatesByOrganization(_ context.Context, arg database.GetTemplatesByOrganizationParams) ([]database.Template, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
Expand Down Expand Up @@ -1109,6 +1170,19 @@ func (q *fakeQuerier) GetWorkspaceAgentsByResourceIDs(_ context.Context, resourc
return workspaceAgents, nil
}

func (q *fakeQuerier) GetWorkspaceAgentsCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceAgent, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

workspaceAgents := make([]database.WorkspaceAgent, 0)
for _, agent := range q.provisionerJobAgents {
if agent.CreatedAt.After(after) {
workspaceAgents = append(workspaceAgents, agent)
}
}
return workspaceAgents, nil
}

func (q *fakeQuerier) GetWorkspaceAppByAgentIDAndName(_ context.Context, arg database.GetWorkspaceAppByAgentIDAndNameParams) (database.WorkspaceApp, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
Expand Down Expand Up @@ -1180,6 +1254,19 @@ func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid
return resources, nil
}

func (q *fakeQuerier) GetWorkspaceResourcesCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceResource, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

resources := make([]database.WorkspaceResource, 0)
for _, resource := range q.provisionerJobResources {
if resource.CreatedAt.After(after) {
resources = append(resources, resource)
}
}
return resources, nil
}

func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID) ([]database.ProvisionerJob, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
Expand All @@ -1200,6 +1287,19 @@ func (q *fakeQuerier) GetProvisionerJobsByIDs(_ context.Context, ids []uuid.UUID
return jobs, nil
}

func (q *fakeQuerier) GetProvisionerJobsCreatedAfter(_ context.Context, after time.Time) ([]database.ProvisionerJob, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

jobs := make([]database.ProvisionerJob, 0)
for _, job := range q.provisionerJobs {
if job.CreatedAt.After(after) {
jobs = append(jobs, job)
}
}
return jobs, nil
}

func (q *fakeQuerier) GetProvisionerLogsByIDBetween(_ context.Context, arg database.GetProvisionerLogsByIDBetweenParams) ([]database.ProvisionerJobLog, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
Expand Down Expand Up @@ -1980,3 +2080,18 @@ func (q *fakeQuerier) InsertAuditLog(_ context.Context, arg database.InsertAudit

return alog, nil
}

func (q *fakeQuerier) InsertDeploymentID(_ context.Context, id string) error {
q.mutex.Lock()
defer q.mutex.Unlock()

q.deploymentID = id
return nil
}

func (q *fakeQuerier) GetDeploymentID(_ context.Context) (string, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

return q.deploymentID, nil
}
8 changes: 8 additions & 0 deletions coderd/database/dump.sql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions coderd/database/migrations/000023_site_config.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE site_config;
4 changes: 4 additions & 0 deletions coderd/database/migrations/000023_site_config.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CREATE TABLE IF NOT EXISTS site_config (
key varchar(256) NOT NULL UNIQUE,
value varchar(8192) NOT NULL
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using jsonb as the value would make it much easier in the future to do migrations on structured data. We've needed to do this a couple times in v1.

);
5 changes: 5 additions & 0 deletions coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading