Skip to content
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

feat: add telemetry #1981

Merged
merged 1 commit into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ Given a version number `MAJOR.MINOR.PATCH`, we increment the:

## Unreleased

### Added

- Add telemetry to collect anonymous usage metrics.
- This helps us to improve user experience by measuring which Terramate features are used most actively.
For further details, see [documentation](https://terramate.io/docs/cli/telemetry).
- Can be turned off by setting `terramate.config.telemetry.enabled = false` in the project configuration,
or by setting `disable_telemetry = true` in the user configuration.

### Fixed

- Fix the command-line parsing of `run` and `script run` which were not failing from unknown flags.
Expand Down
144 changes: 141 additions & 3 deletions cmd/terramate/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/terramate-io/terramate/cmd/terramate/cli/cliconfig"
"github.com/terramate-io/terramate/cmd/terramate/cli/clitest"
"github.com/terramate-io/terramate/cmd/terramate/cli/out"
tel "github.com/terramate-io/terramate/cmd/terramate/cli/telemetry"
"github.com/terramate-io/terramate/config/filter"
"github.com/terramate-io/terramate/config/tag"
"github.com/terramate-io/terramate/errors"
Expand Down Expand Up @@ -696,37 +697,94 @@ func (c *cli) run() {
logger.Debug().Msg("Handle command.")

switch c.ctx.Command() {
case "fmt":
c.format()
case "fmt <files>":
case "fmt", "fmt <files>":
c.initAndSendAnalytics("fmt",
tel.BoolFlag("detailed-exit-code", c.parsedArgs.Fmt.DetailedExitCode),
)
c.format()
c.waitForAnalytics()
case "create <path>":
c.initAndSendAnalytics("create")
c.createStack()
c.waitForAnalytics()
case "create":
c.initAnalytics("create",
tel.BoolFlag("all-terragrunt", c.parsedArgs.Create.AllTerragrunt),
tel.BoolFlag("all-terraform", c.parsedArgs.Create.AllTerraform),
)
c.scanCreate()
c.waitForAnalytics()
case "list":
c.initAndSendAnalytics("list",
tel.BoolFlag("filter-changed", c.parsedArgs.Changed),
tel.BoolFlag("filter-tags", len(c.parsedArgs.Tags) != 0),
tel.StringFlag("filter-status", c.parsedArgs.List.Status),
tel.StringFlag("filter-drift-status", c.parsedArgs.List.DriftStatus),
tel.StringFlag("filter-deployment-status", c.parsedArgs.List.DeploymentStatus),
tel.StringFlag("filter-target", c.parsedArgs.List.Target),
tel.BoolFlag("run-order", c.parsedArgs.List.RunOrder),
)
c.setupGit()
c.setupChangeDetection(c.parsedArgs.List.EnableChangeDetection, c.parsedArgs.List.DisableChangeDetection)
c.printStacks()
c.waitForAnalytics()
case "run":
fatal("no command specified")
case "run <cmd>":
c.initAndSendAnalytics("run",
tel.BoolFlag("filter-changed", c.parsedArgs.Changed),
tel.BoolFlag("filter-tags", len(c.parsedArgs.Tags) != 0),
tel.StringFlag("filter-status", c.parsedArgs.Run.Status),
tel.StringFlag("filter-drift-status", c.parsedArgs.Run.DriftStatus),
tel.StringFlag("filter-deployment-status", c.parsedArgs.Run.DeploymentStatus),
tel.StringFlag("target", c.parsedArgs.Run.Target),
tel.BoolFlag("sync-deployment", c.parsedArgs.Run.SyncDeployment),
tel.BoolFlag("sync-drift", c.parsedArgs.Run.SyncDriftStatus),
tel.BoolFlag("sync-preview", c.parsedArgs.Run.SyncPreview),
tel.StringFlag("terraform-planfile", c.parsedArgs.Run.TerraformPlanFile),
tel.StringFlag("tofu-planfile", c.parsedArgs.Run.TofuPlanFile),
tel.StringFlag("layer", string(c.parsedArgs.Run.Layer)),
tel.BoolFlag("terragrunt", c.parsedArgs.Run.Terragrunt),
tel.BoolFlag("reverse", c.parsedArgs.Run.Reverse),
tel.BoolFlag("parallel", c.parsedArgs.Run.Parallel > 0),
tel.BoolFlag("output-sharing", c.parsedArgs.Run.EnableSharing),
tel.BoolFlag("output-mocks", c.parsedArgs.Run.MockOnFail),
)
c.setupGit()
c.setupChangeDetection(c.parsedArgs.Run.EnableChangeDetection, c.parsedArgs.Run.DisableChangeDetection)
c.setupSafeguards(c.parsedArgs.Run.runSafeguardsCliSpec)
c.runOnStacks()
c.waitForAnalytics()
case "generate":
c.initAnalytics("generate",
tel.BoolFlag("detailed-exit-code", c.parsedArgs.Generate.DetailedExitCode),
tel.BoolFlag("parallel", c.parsedArgs.Generate.Parallel > 0),
)
exitCode := c.generate()
stopProfiler(c.parsedArgs)
c.sendAnalytics()
c.waitForAnalytics()
os.Exit(exitCode)
case "experimental clone <srcdir> <destdir>":
c.initAndSendAnalytics("clone")
c.cloneStack()
c.waitForAnalytics()
case "experimental trigger":
c.initAndSendAnalytics("trigger")
c.triggerStackByFilter()
c.waitForAnalytics()
case "experimental trigger <stack>":
c.initAndSendAnalytics("trigger",
tel.StringFlag("stack", c.parsedArgs.Experimental.Trigger.Stack),
tel.BoolFlag("change", c.parsedArgs.Experimental.Trigger.Change),
tel.BoolFlag("ignore-change", c.parsedArgs.Experimental.Trigger.IgnoreChange),
)
c.triggerStack(c.parsedArgs.Experimental.Trigger.Stack)
c.waitForAnalytics()
case "experimental vendor download <source> <ref>":
c.initAndSendAnalytics("vendor-download")
c.vendorDownload()
c.waitForAnalytics()
case "debug show globals":
c.setupGit()
c.printStacksGlobals()
Expand All @@ -737,8 +795,10 @@ func (c *cli) run() {
c.setupGit()
c.printMetadata()
case "experimental run-graph":
c.initAndSendAnalytics("graph")
c.setupGit()
c.generateGraph()
c.waitForAnalytics()
case "debug show runtime-env":
c.setupGit()
c.printRuntimeEnv()
Expand All @@ -757,37 +817,115 @@ func (c *cli) run() {
case "experimental cloud info": // Deprecated
fallthrough
case "cloud info":
c.initAndSendAnalytics("cloud-info")
c.cloudInfo()
c.waitForAnalytics()
case "experimental cloud drift show": // Deprecated
fallthrough
case "cloud drift show":
c.initAndSendAnalytics("cloud-drift-show")
c.cloudDriftShow()
c.waitForAnalytics()
case "script list":
c.initAndSendAnalytics("script-list")
c.checkScriptEnabled()
c.printScriptList()
c.waitForAnalytics()
case "script tree":
c.initAndSendAnalytics("script-tree")
c.checkScriptEnabled()
c.printScriptTree()
c.waitForAnalytics()
case "script info":
c.checkScriptEnabled()
fatal("no script specified")
case "script info <cmds>":
c.initAndSendAnalytics("script-info")
c.checkScriptEnabled()
c.printScriptInfo()
c.waitForAnalytics()
case "script run":
c.checkScriptEnabled()
fatal("no script specified")
case "script run <cmds>":
c.initAnalytics("script-run",
tel.BoolFlag("filter-changed", c.parsedArgs.Changed),
tel.BoolFlag("filter-tags", len(c.parsedArgs.Tags) != 0),
tel.StringFlag("filter-status", c.parsedArgs.Script.Run.Status),
tel.StringFlag("filter-drift-status", c.parsedArgs.Script.Run.DriftStatus),
tel.StringFlag("filter-deployment-status", c.parsedArgs.Script.Run.DeploymentStatus),
tel.StringFlag("target", c.parsedArgs.Script.Run.Target),
tel.BoolFlag("reverse", c.parsedArgs.Script.Run.Reverse),
tel.BoolFlag("parallel", c.parsedArgs.Script.Run.Parallel > 0),
)
c.checkScriptEnabled()
c.setupGit()
c.setupChangeDetection(c.parsedArgs.Script.Run.EnableChangeDetection, c.parsedArgs.Script.Run.DisableChangeDetection)
c.setupSafeguards(c.parsedArgs.Script.Run.runSafeguardsCliSpec)
c.runScript()
c.sendAnalytics()
c.waitForAnalytics()
default:
fatal("unexpected command sequence")
}
}

func (c *cli) initAnalytics(cmd string, opts ...tel.MessageOpt) {
cpsigfile := filepath.Join(c.clicfg.UserTerramateDir, "checkpoint_signature")
anasigfile := filepath.Join(c.clicfg.UserTerramateDir, "analytics_signature")
credfile := filepath.Join(c.clicfg.UserTerramateDir, credfile)

r := tel.DefaultRecord
r.Set(
tel.Command(cmd),
tel.OrgName(c.cloudOrgName()),
tel.DetectFromEnv(credfile, cpsigfile, anasigfile),
tel.StringFlag("chdir", c.parsedArgs.Chdir),
)
r.Set(opts...)
}

func (c *cli) sendAnalytics() {
// There are several ways to disable this, but this requires the least amount of special handling.
// Prepare the record, but don't send it.
if !c.isTelemetryEnabled() {
return
}

tel.DefaultRecord.Send(tel.SendMessageParams{
Timeout: 100 * time.Millisecond,
})
}

func (c *cli) waitForAnalytics() {
if err := tel.DefaultRecord.WaitForSend(); err != nil {
logger := log.With().
Str("action", "cli.waitForAnalytics()").
Logger()
logger.Debug().Err(err).Msgf("failed to wait for analytics")
}
}

func (c *cli) initAndSendAnalytics(cmd string, opts ...tel.MessageOpt) {
c.initAnalytics(cmd, opts...)
c.sendAnalytics()
}

func (c *cli) isTelemetryEnabled() bool {
if c.clicfg.DisableTelemetry {
return false
}

cfg := c.rootNode()
if cfg.Terramate == nil ||
cfg.Terramate.Config == nil ||
cfg.Terramate.Config.Telemetry == nil ||
cfg.Terramate.Config.Telemetry.Enabled == nil {
return true
}
return *cfg.Terramate.Config.Telemetry.Enabled
}

func (c *cli) setupSafeguards(run runSafeguardsCliSpec) {
global := c.parsedArgs.deprecatedGlobalSafeguardsCliSpec

Expand Down
6 changes: 6 additions & 0 deletions cmd/terramate/cli/cliconfig/cliconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const (
type Config struct {
DisableCheckpoint bool
DisableCheckpointSignature bool
DisableTelemetry bool
UserTerramateDir string
}

Expand Down Expand Up @@ -76,6 +77,11 @@ func LoadFrom(fname string) (Config, error) {
return Config{}, err
}
cfg.DisableCheckpointSignature = val.True()
case "disable_telemetry":
if err := checkBoolType(val, name); err != nil {
return Config{}, err
}
cfg.DisableTelemetry = val.True()
case "user_terramate_dir":
if err := checkStrType(val, name); err != nil {
return Config{}, err
Expand Down
9 changes: 9 additions & 0 deletions cmd/terramate/cli/cliconfig/cliconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ func TestLoad(t *testing.T) {
},
},
},
{
name: "valid disable_telemetry",
cfg: `disable_telemetry = true`,
want: want{
cfg: cliconfig.Config{
DisableTelemetry: true,
},
},
},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions cmd/terramate/cli/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ func (c *cli) setupCloudConfig(requestedFeatures []string) error {
c.cloud.run.orgName = activeOrgs[0].Name
c.cloud.run.orgUUID = activeOrgs[0].UUID
}

return nil
}

Expand Down
13 changes: 13 additions & 0 deletions cmd/terramate/cli/script_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/fatih/color"
"github.com/hashicorp/go-uuid"
"github.com/terramate-io/terramate/cloud"
tel "github.com/terramate-io/terramate/cmd/terramate/cli/telemetry"
"github.com/terramate-io/terramate/config"
"github.com/terramate-io/terramate/errors"
"github.com/terramate-io/terramate/globals"
Expand Down Expand Up @@ -134,6 +135,18 @@ func (c *cli) runScript() {
task.UseTerragrunt = cmd.Options.UseTerragrunt
task.EnableSharing = cmd.Options.EnableSharing
task.MockOnFail = cmd.Options.MockOnFail

tel.DefaultRecord.Set(
tel.BoolFlag("sync-deployment", cmd.Options.CloudSyncDeployment),
tel.BoolFlag("sync-drift", cmd.Options.CloudSyncDriftStatus),
tel.BoolFlag("sync-preview", cmd.Options.CloudSyncPreview),
tel.StringFlag("terraform-planfile", cmd.Options.CloudTerraformPlanFile),
tel.StringFlag("tofu-planfile", cmd.Options.CloudTofuPlanFile),
tel.StringFlag("layer", string(cmd.Options.CloudSyncLayer)),
tel.BoolFlag("terragrunt", cmd.Options.UseTerragrunt),
tel.BoolFlag("output-sharing", cmd.Options.EnableSharing),
tel.BoolFlag("output-mocks", cmd.Options.MockOnFail),
)
}
run.Tasks = append(run.Tasks, task)
if task.CloudSyncDeployment || task.CloudSyncDriftStatus || task.CloudSyncPreview {
Expand Down
31 changes: 31 additions & 0 deletions cmd/terramate/cli/telemetry/_test_mock.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// TERRAMATE: GENERATED AUTOMATICALLY DO NOT EDIT

resource "local_file" "telemetry" {
content = <<-EOT
package telemetry // import "github.com/terramate-io/terramate/cmd/terramate/cli/telemetry"

var DefaultRecord = NewRecord()
func Endpoint() url.URL
func GenerateOrReadSignature(cpsigfile, anasigfile string) (string, bool)
func GenerateSignature() string
func ReadSignature(p string) string
func SendMessage(msg *Message, p SendMessageParams) <-chan error
type AuthType int
const AuthNone AuthType = iota ...
func DetectAuthTypeFromEnv(credpath string) AuthType
type Message struct{ ... }
type MessageOpt func(msg *Message)
func BoolFlag(name string, flag bool, ifCmds ...string) MessageOpt
func Command(cmd string) MessageOpt
func DetectFromEnv(cmd string, credfile, cpsigfile, anasigfile string) MessageOpt
func StringFlag(name string, flag string, ifCmds ...string) MessageOpt
type PlatformType int
const PlatformLocal PlatformType = iota ...
func DetectPlatformFromEnv() PlatformType
type Record struct{ ... }
func NewRecord() *Record
type SendMessageParams struct{ ... }
EOT

filename = "${path.module}/mock-telemetry.ignore"
}
18 changes: 18 additions & 0 deletions cmd/terramate/cli/telemetry/endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright 2023 Terramate GmbH
// SPDX-License-Identifier: MPL-2.0

//go:build !localhostEndpoints

package telemetry

import (
"net/url"
)

func Endpoint() url.URL {
var u url.URL
u.Scheme = "https"
u.Host = "analytics.terramate.io"
u.Path = "/"
return u
}
18 changes: 18 additions & 0 deletions cmd/terramate/cli/telemetry/endpoint_localhost.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright 2023 Terramate GmbH
// SPDX-License-Identifier: MPL-2.0

//go:build localhostEndpoints
Copy link
Contributor

Choose a reason for hiding this comment

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

Why don't you use environment variables?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm using the same approach as the checkpoint API here. It could be more convenient for debugging, but beyond that there is no reason to make the endpoint address configurable, so I'd just go with the existing scheme.


package telemetry

import (
"net/url"
)

func Endpoint() url.URL {
var u url.URL
u.Scheme = "http"
u.Host = "localhost:3000"
u.Path = "/"
return u
}
Loading
Loading