Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ builds:
- -trimpath

ldflags:
- "-s -w -X main.version={{.Version}}"
- "-s -w -X main.version={{.Version}} -X github.com/CircleCI-Public/chunk-cli/internal/cmd.writeKey={{.Env.SEGMENT_WRITE_KEY}}"

goos:
- linux
Expand Down
6 changes: 5 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ require (
github.com/coder/websocket v1.8.14
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/segmentio/analytics-go/v3 v3.3.0
github.com/sethvargo/go-envconfig v1.3.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.51.0
golang.org/x/term v0.43.0
Expand Down Expand Up @@ -59,6 +62,7 @@ require (
github.com/bitfield/gotestdox v0.2.2 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.8.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
Expand Down Expand Up @@ -213,14 +217,14 @@ require (
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.26.1 // indirect
github.com/segmentio/backo-go v1.0.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5
github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo=
github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M=
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.8.0 h1:JTkyfs4yl8SPejrCF2GdABXE+mO1WvM7iUYzRWlsxDs=
Expand Down Expand Up @@ -369,6 +371,8 @@ github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmI
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs=
Expand Down Expand Up @@ -601,6 +605,10 @@ github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iM
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.26.1 h1:gdkttGhQFVehqRJ8grKH4DrpqM/QlPKNHBnl8QgcEC4=
github.com/securego/gosec/v2 v2.26.1/go.mod h1:57UW4p0uoP3kxoTkhoo3axLdVAi+OWrLg/Ax/kdqtPE=
github.com/segmentio/analytics-go/v3 v3.3.0 h1:8VOMaVGBW03pdBrj1CMFfY9o/rnjJC+1wyQHlVxjw5o=
github.com/segmentio/analytics-go/v3 v3.3.0/go.mod h1:p8owAF8X+5o27jmvUognuXxdtqvSGtD0ZrfY2kcS9bE=
github.com/segmentio/backo-go v1.0.0 h1:kbOAtGJY2DqOR0jfRkYEorx/b18RgtepGtY3+Cpe6qA=
github.com/segmentio/backo-go v1.0.0/go.mod h1:kJ9mm9YmoWSkk+oQ+5Cj8DEoRCX2JT6As4kEtIIOp1M=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U=
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/errmsg.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
const (
configFilePermHint = "Check file permissions on the chunk config file."
msgCouldNotLoadConfig = "Could not load configuration."
msgCouldNotSaveConfig = "Could not save configuration."
msgCouldNotAccessConfig = "Could not access configuration."
msgCouldNotDetermineWorkDir = "Could not determine working directory."
msgCouldNotLoadSidecar = "Could not load the active sidecar."
Expand Down
170 changes: 169 additions & 1 deletion internal/cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
package cmd

import (
"context"
"os"
"runtime"
"slices"
"strings"
"time"

"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/spf13/pflag"

"github.com/CircleCI-Public/chunk-cli/internal/config"
"github.com/CircleCI-Public/chunk-cli/internal/telemetry"
)

// writeKey is the Segment write key injected at build time via -ldflags.
// Empty by default; telemetry silently uses ModeNOOP when unset.
var writeKey string

func NewRootCmd(version string) *cobra.Command {
cobra.EnableTraverseRunHooks = true

telem := &delegatingTelemetry{}

rootCmd := &cobra.Command{
Use: "chunk",
Short: "Generate AI review context and trigger AI coding tasks",
Version: version,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
tc, err := newTelemetryClient(cmd.Context(), version, noTelemetry)
if err != nil {
tc, _ = telemetry.New(cmd.Context(), telemetry.Config{Mode: telemetry.ModeNOOP})
}
telem.Client = tc
return nil
},
}
Expand All @@ -39,6 +63,25 @@ Environment Variables:
SSH_AUTH_SOCK SSH agent socket for sidecar key auth
NO_COLOR Disable colored output
CI Disable interactive prompts (set by most CI systems)
CHUNK_NO_TELEMETRY Disable telemetry (also: NO_ANALYTICS, DO_NOT_TRACK)

Telemetry:
chunk collects anonymous usage statistics to help improve the tool.

What we collect:
- Which commands are used
- Whether commands succeed or fail (no error messages)
- chunk version, OS, and architecture

What we do NOT collect:
- Command arguments or flag values
- File or directory names
- IP addresses or hostnames
- Any personally identifiable information

To disable telemetry:
Set CHUNK_NO_TELEMETRY=1 in your environment
Pass --no-telemetry to disable for a single invocation

Configuration:
~/.config/chunk/config.json User credentials and settings ($XDG_CONFIG_HOME/chunk/config.json)
Expand All @@ -57,11 +100,136 @@ Configuration:
rootCmd.AddCommand(newValidateCmd())
rootCmd.AddCommand(newHookCmd())
rootCmd.AddCommand(newUpgradeCmd())

rootCmd.AddCommand(newCommandsCmd())

recordTelemetryForSubcommands(rootCmd, telem)

rootCmd.PersistentFlags().Bool("insecure-storage", false, "do not use the system's secure storage for storing tokens")
_ = rootCmd.PersistentFlags().MarkHidden("insecure-storage")
rootCmd.PersistentFlags().Bool("no-telemetry", false, "Disable telemetry for this invocation")

return rootCmd
}

// delegatingTelemetry wraps a *telemetry.Client that is populated lazily in
// PersistentPreRunE, allowing subcommand RunE wrappers registered at startup
// to call through to the client once it is available.
type delegatingTelemetry struct {
*telemetry.Client
}

type tracker interface {
Track(eventName string, props map[string]any) error
Close() error
}

// recordTelemetry wraps cmd.RunE to emit a command_invocation event and flush
// the telemetry client after each command. The 500 ms timeout prevents a slow
// Segment endpoint from stalling the CLI.
func recordTelemetry(cmd *cobra.Command, t tracker) {
if cmd.Annotations["telemetry"] == "disabled" {
return
}
if cmd.RunE == nil {
return
}
original := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
runErr := original(cmd, args)

var flags []string
cmd.Flags().Visit(func(f *pflag.Flag) {
flags = append(flags, f.Name)
})
slices.Sort(flags)

_ = t.Track("command_invocation", map[string]any{
"command": cmd.CommandPath(),
"flags": strings.Join(flags, ","),
"success": runErr == nil,
"action": "invoked",
})

done := make(chan struct{})
go func() {
defer close(done)
_ = t.Close()
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
}

return runErr
}
}

func recordTelemetryForSubcommands(cmd *cobra.Command, t tracker) {
for _, c := range cmd.Commands() {
recordTelemetry(c, t)
recordTelemetryForSubcommands(c, t)
}
}

// newTelemetryClient determines the appropriate mode and constructs the client.
func newTelemetryClient(ctx context.Context, version string, noTelemetryFlag bool) (*telemetry.Client, error) {
mode := telemetry.ModeSend
if noTelemetryFlag || telemetryOptedOut() || writeKey == "" {
mode = telemetry.ModeNOOP
}

cfg, err := config.Load()
if err != nil {
return nil, err
}
if cfg.NoTelemetry {
mode = telemetry.ModeNOOP
}

env, err := config.LoadEnv(ctx)
if err != nil {
return nil, err
}

instanceID := cfg.InstanceID
freshID := instanceID == ""
if freshID {
instanceID = uuid.NewString()
cfg.InstanceID = instanceID
_ = config.Save(cfg)
}

tc, err := telemetry.New(ctx, telemetry.Config{
Mode: mode,
WriteKey: writeKey,
User: telemetry.User{
InstanceID: instanceID,
OrganizationID: env.CircleCIOrgID,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: version,
},
})
if err != nil {
return nil, err
}
if freshID && mode == telemetry.ModeSend {
_ = tc.Identify()
}
return tc, nil
}

// telemetryOptedOut returns true if any standard opt-out environment variable is set.
func telemetryOptedOut() bool {
for _, env := range []string{
config.EnvChunkNoTelemetry,
config.EnvNoAnalytics,
config.EnvDoNotTrack,
config.EnvCI,
} {
if os.Getenv(env) != "" {
return true
}
}
return false
}
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const (
EnvModel = "CODE_REVIEW_CLI_MODEL"
EnvCircleCIOrgID = "CIRCLECI_ORG_ID"
EnvChunkHooksDisabled = "CHUNK_HOOKS_DISABLED"
EnvChunkNoTelemetry = "CHUNK_NO_TELEMETRY"
)

// System/standard environment variable names.
Expand All @@ -66,6 +67,9 @@ const (
EnvXDGConfigHome = "XDG_CONFIG_HOME"
EnvXDGStateHome = "XDG_STATE_HOME"
EnvXDGDataHome = "XDG_DATA_HOME"
EnvNoAnalytics = "NO_ANALYTICS"
EnvDoNotTrack = "DO_NOT_TRACK"
EnvCI = "CI"
)

// EnvVars holds all environment variables the application reads.
Expand Down Expand Up @@ -105,6 +109,10 @@ type UserConfig struct {
CircleCIToken string `json:"circleCIToken,omitempty"`
GitHubToken string `json:"gitHubToken,omitempty"`
Model string `json:"model,omitempty"`
// InstanceID is a stable UUID generated on first run and used as the
// telemetry device identifier.
InstanceID string `json:"instanceID,omitempty"`
NoTelemetry bool `json:"noTelemetry,omitempty"`

// LegacyAPIKey reads the pre-rename "apiKey" field so existing users don't
// silently lose their stored Anthropic key on upgrade. Migrated into
Expand Down
29 changes: 29 additions & 0 deletions internal/telemetry/log.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package telemetry

import (
"context"
"fmt"
"os"

"github.com/segmentio/analytics-go/v3"
)

type loggingClient struct {
ctx context.Context
}

func newLoggingClient(ctx context.Context) *loggingClient {
return &loggingClient{ctx: ctx}
}

func (l *loggingClient) Close() error { return nil }

func (l *loggingClient) Enqueue(m analytics.Message) error {
switch m := m.(type) {
case analytics.Track:
fmt.Fprintf(os.Stderr, "[telemetry] track %s %v\n", m.Event, m.Properties)
case analytics.Identify:
fmt.Fprintf(os.Stderr, "[telemetry] identify %s\n", m.AnonymousId)
}
return nil
}
8 changes: 8 additions & 0 deletions internal/telemetry/noop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package telemetry

import "github.com/segmentio/analytics-go/v3"

type noopClient struct{}

func (noopClient) Close() error { return nil }
func (noopClient) Enqueue(_ analytics.Message) error { return nil }
Loading