Skip to content

Commit 85e20a4

Browse files
authored
feat: add CLI telemetry for command execution tracking (#652)
* feat: add CLI telemetry for command execution tracking - Add telemetry package to capture command execution data - Generate UUID for each command execution - Track command name, duration, exit code, and environment (CI detection) - Send telemetry to /vendor/v3/cli/telemetry/event endpoint - Send error details to /vendor/v3/cli/telemetry/error endpoint for failed commands - Fire-and-forget pattern ensures telemetry never blocks CLI execution - Silent operation with opt-out via REPLICATED_TELEMETRY_DISABLED env var - Tested with production backend endpoints
1 parent 11b7d40 commit 85e20a4

File tree

9 files changed

+1374
-11
lines changed

9 files changed

+1374
-11
lines changed

cli/cmd/lint.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/replicatedhq/replicated/cli/print"
1313
"github.com/replicatedhq/replicated/pkg/imageextract"
1414
"github.com/replicatedhq/replicated/pkg/lint2"
15+
"github.com/replicatedhq/replicated/pkg/telemetry"
1516
"github.com/replicatedhq/replicated/pkg/tools"
1617
"github.com/replicatedhq/replicated/pkg/version"
1718
"github.com/spf13/cobra"
@@ -505,6 +506,34 @@ func (r *runners) runLint(cmd *cobra.Command, args []string) error {
505506
output.Metadata.EmbeddedClusterVersion = extracted.ECVersion
506507
}
507508

509+
// Collect resource statistics for telemetry
510+
stats := telemetry.ResourceStats{
511+
HelmChartsCount: len(extracted.ChartPaths),
512+
PreflightsCount: len(extracted.Preflights),
513+
SupportBundlesCount: len(extracted.SupportBundles),
514+
ManifestsCount: len(extracted.HelmChartManifests),
515+
ToolVersions: map[string]string{
516+
"helm": extracted.HelmVersion,
517+
"preflight-check": extracted.PreflightVersion,
518+
"support-bundle-lint": extracted.SBVersion,
519+
},
520+
}
521+
522+
// Add EC version if used
523+
if extracted.ECVersion != "" && len(extracted.EmbeddedClusterPaths) > 0 {
524+
stats.ToolVersions["embedded-cluster"] = extracted.ECVersion
525+
}
526+
527+
// Add KOTS version if used
528+
if extracted.KotsVersion != "" && len(extracted.KotsPaths) > 0 {
529+
stats.ToolVersions["kots"] = extracted.KotsVersion
530+
}
531+
532+
// Record stats for telemetry (will be sent when command completes)
533+
if r.telemetry != nil {
534+
r.telemetry.RecordStats(stats)
535+
}
536+
508537
// Check tool versions (only for enabled linters with pinned versions)
509538
// This displays warnings if configured versions are outdated by minor/major version
510539
toolVersionWarnings := r.checkToolVersions(config, extracted)

cli/cmd/release_lint.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"io/fs"
55
"os"
66
"path/filepath"
7+
"strings"
78

89
"github.com/mholt/archiver/v3"
910
"github.com/pkg/errors"
1011
"github.com/replicatedhq/replicated/cli/print"
12+
"github.com/replicatedhq/replicated/pkg/telemetry"
1113
"github.com/replicatedhq/replicated/pkg/types"
1214
"github.com/spf13/cobra"
1315
"helm.sh/helm/v3/pkg/chart/loader"
@@ -90,6 +92,10 @@ func (r *runners) releaseLintV1(_ *cobra.Command, _ []string) error {
9092
return errors.New("no app specified")
9193
}
9294

95+
// Collect stats for telemetry before sending to remote API
96+
// We'll count resources in the directory/chart being linted
97+
var stats *telemetry.ResourceStats
98+
9399
var isBuildersRelease bool
94100
var lintReleaseData []byte
95101
var contentType string
@@ -98,6 +104,13 @@ func (r *runners) releaseLintV1(_ *cobra.Command, _ []string) error {
98104
if err != nil {
99105
return errors.Wrap(err, "failed to check if yaml dir is helm charts only")
100106
}
107+
108+
// Count resources in the directory for stats
109+
if r.telemetry != nil {
110+
resourceStats := countResourcesInDirectory(r.args.lintReleaseYamlDir)
111+
stats = &resourceStats
112+
}
113+
101114
data, err := tarYAMLDir(r.args.lintReleaseYamlDir)
102115
if err != nil {
103116
return errors.Wrap(err, "failed to read yaml dir")
@@ -130,6 +143,11 @@ func (r *runners) releaseLintV1(_ *cobra.Command, _ []string) error {
130143
return errors.Wrap(err, "failed to print lint errors")
131144
}
132145

146+
// Record stats for telemetry if collected
147+
if r.telemetry != nil && stats != nil {
148+
r.telemetry.RecordStats(*stats)
149+
}
150+
133151
if hasError := shouldFail(lintResult, r.args.lintReleaseFailOn); hasError {
134152
return errors.Errorf("One or more errors of severity %q or higher were found", r.args.lintReleaseFailOn)
135153
}
@@ -186,6 +204,51 @@ func tarYAMLDir(yamlDir string) ([]byte, error) {
186204
return data, nil
187205
}
188206

207+
// countResourcesInDirectory counts resources in a directory for telemetry stats
208+
// This is a basic implementation for the old lint path
209+
func countResourcesInDirectory(yamlDir string) telemetry.ResourceStats {
210+
stats := telemetry.ResourceStats{
211+
ToolVersions: make(map[string]string),
212+
}
213+
214+
// Walk the directory and count YAML files by type
215+
// This is a best-effort count - may not be 100% accurate but better than nothing
216+
filepath.Walk(yamlDir, func(path string, info fs.FileInfo, err error) error {
217+
if err != nil || info.IsDir() {
218+
return nil
219+
}
220+
221+
// Only process YAML files
222+
ext := strings.ToLower(filepath.Ext(path))
223+
if ext != ".yaml" && ext != ".yml" {
224+
return nil
225+
}
226+
227+
// Read file to determine type
228+
data, err := os.ReadFile(path)
229+
if err != nil {
230+
return nil
231+
}
232+
233+
content := string(data)
234+
235+
// Simple heuristic based on kind field
236+
if strings.Contains(content, "kind: Chart") || strings.Contains(content, "apiVersion: v2") {
237+
stats.HelmChartsCount++
238+
} else if strings.Contains(content, "kind: Preflight") {
239+
stats.PreflightsCount++
240+
} else if strings.Contains(content, "kind: SupportBundle") {
241+
stats.SupportBundlesCount++
242+
} else if strings.Contains(content, "kind: HelmChart") {
243+
stats.ManifestsCount++
244+
}
245+
246+
return nil
247+
})
248+
249+
return stats
250+
}
251+
189252
func isHelmChartsOnly(yamlDir string) (bool, error) {
190253
helmError := errors.New("helm error")
191254
err := filepath.Walk(yamlDir, func(path string, info fs.FileInfo, err error) error {

cli/cmd/root.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"strings"
99
"text/tabwriter"
10+
"time"
1011

1112
"github.com/Masterminds/sprig/v3"
1213
"github.com/pkg/errors"
@@ -15,6 +16,7 @@ import (
1516
"github.com/replicatedhq/replicated/pkg/credentials"
1617
"github.com/replicatedhq/replicated/pkg/kotsclient"
1718
"github.com/replicatedhq/replicated/pkg/platformclient"
19+
"github.com/replicatedhq/replicated/pkg/telemetry"
1820
"github.com/replicatedhq/replicated/pkg/types"
1921
"github.com/replicatedhq/replicated/pkg/version"
2022
"github.com/spf13/cobra"
@@ -110,6 +112,12 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i
110112
stdin: stdin,
111113
w: w,
112114
}
115+
116+
// Telemetry for tracking CLI command execution
117+
var tel *telemetry.Telemetry
118+
var executedCmd *cobra.Command // Track the actual executed command
119+
var telemetryConsented bool // Track if user consented to telemetry
120+
113121
if runCmds.rootCmd == nil {
114122
runCmds.rootCmd = GetRootCmd()
115123
}
@@ -406,7 +414,26 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i
406414
commonAPI := client.NewClient(platformOrigin, apiToken, kurlDotSHOrigin)
407415
runCmds.api = commonAPI
408416

409-
// Print update info from cache, then start background update for next time
417+
// Check telemetry consent FIRST (before any background tasks) for clean prompt
418+
// Only check once per Execute() call
419+
if apiToken != "" && !telemetryConsented {
420+
// This may prompt user on first run - do it synchronously before background tasks
421+
telemetryConsented = telemetry.CheckAndPromptConsent()
422+
}
423+
424+
// Initialize telemetry if consented
425+
if apiToken != "" && telemetryConsented {
426+
tel = telemetry.New(apiToken, platformOrigin)
427+
if tel != nil {
428+
// Capture the actual executed command for telemetry
429+
executedCmd = cmd
430+
tel.RecordCommandStart(cmd)
431+
// Store telemetry in runners so commands can access it
432+
runCmds.telemetry = tel
433+
}
434+
}
435+
436+
// Start background tasks AFTER consent prompt completes
410437
version.PrintIfUpgradeAvailable()
411438
version.CheckForUpdatesInBackground(version.Version(), "replicatedhq/replicated/cli")
412439

@@ -495,7 +522,33 @@ func Execute(rootCmd *cobra.Command, stdin io.Reader, stdout io.Writer, stderr i
495522

496523
runCmds.rootCmd.AddCommand(Version())
497524

498-
return runCmds.rootCmd.Execute()
525+
// Execute the command and capture any error
526+
executeErr := runCmds.rootCmd.Execute()
527+
528+
// Always send telemetry after command completes (even if it errored)
529+
// This ensures telemetry is sent regardless of PreRunE/RunE/PostRunE failures
530+
if tel != nil {
531+
// Use the actual executed command if captured, otherwise fall back to root
532+
cmdToRecord := executedCmd
533+
if cmdToRecord == nil {
534+
cmdToRecord = runCmds.rootCmd
535+
}
536+
537+
tel.RecordCommandComplete(cmdToRecord, executeErr)
538+
539+
// In debug mode, wait briefly for goroutine to complete so we can see telemetry output
540+
// Use shorter wait in CI to avoid unnecessary delays in automated environments
541+
if debugFlag {
542+
waitTime := 2 * time.Second
543+
// Check if we're in CI - if so, use shorter wait
544+
if os.Getenv("CI") == "true" || os.Getenv("GITHUB_ACTIONS") == "true" {
545+
waitTime = 500 * time.Millisecond
546+
}
547+
time.Sleep(waitTime)
548+
}
549+
}
550+
551+
return executeErr
499552
}
500553

501554
func printIfError(cmd *cobra.Command, err error) {

cli/cmd/runner.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/replicatedhq/replicated/client"
99
"github.com/replicatedhq/replicated/pkg/kotsclient"
1010
"github.com/replicatedhq/replicated/pkg/platformclient"
11+
"github.com/replicatedhq/replicated/pkg/telemetry"
1112
"github.com/spf13/cobra"
1213
"helm.sh/helm/v3/pkg/cli/values"
1314
)
@@ -25,8 +26,9 @@ type runners struct {
2526
outputFormat string
2627
w *tabwriter.Writer
2728

28-
rootCmd *cobra.Command
29-
args runnerArgs
29+
rootCmd *cobra.Command
30+
args runnerArgs
31+
telemetry *telemetry.Telemetry // Telemetry for tracking command execution
3032
}
3133

3234
func (r *runners) hasApp() bool {

0 commit comments

Comments
 (0)