Skip to content

Commit 446083b

Browse files
authored
Add light optional telemetry (#19)
* Add light telemetry * Make sure old telemetry config is preserved
1 parent 060d175 commit 446083b

14 files changed

Lines changed: 385 additions & 15 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ jobs:
3030
args: release --clean
3131
env:
3232
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33+
AMPLITUDE_KEY: ${{ secrets.AMPLITUDE_KEY }}

.goreleaser.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ version: 2
22

33
project_name: dune-cli
44

5+
env:
6+
- AMPLITUDE_KEY={{ if index .Env "AMPLITUDE_KEY" }}{{ .Env.AMPLITUDE_KEY }}{{ end }}
7+
58
before:
69
hooks:
710
- go mod tidy
@@ -16,6 +19,7 @@ builds:
1619
- -X main.version={{.Version}}
1720
- -X main.commit={{.Commit}}
1821
- -X main.date={{.Date}}
22+
- -X main.amplitudeKey={{ .Env.AMPLITUDE_KEY }}
1923
goos:
2024
- linux
2125
- darwin

authconfig/authconfig.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import (
1212

1313
// Config holds the persisted CLI configuration.
1414
type Config struct {
15-
APIKey string `yaml:"api_key"`
15+
APIKey string `yaml:"api_key"`
16+
Telemetry *bool `yaml:"telemetry,omitempty"`
1617
}
1718

1819
// configDirFunc allows tests to override the config directory.

cli/root.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,19 @@ import (
55
"fmt"
66
"os"
77
"strings"
8+
"time"
89

910
"github.com/charmbracelet/fang"
1011
"github.com/duneanalytics/cli/authconfig"
1112
"github.com/duneanalytics/cli/cmd/auth"
13+
duneconfig "github.com/duneanalytics/cli/cmd/config"
1214
"github.com/duneanalytics/cli/cmd/dataset"
1315
"github.com/duneanalytics/cli/cmd/docs"
1416
"github.com/duneanalytics/cli/cmd/execution"
1517
"github.com/duneanalytics/cli/cmd/query"
1618
"github.com/duneanalytics/cli/cmd/usage"
1719
"github.com/duneanalytics/cli/cmdutil"
20+
"github.com/duneanalytics/cli/tracking"
1821
"github.com/duneanalytics/duneapi-client-go/config"
1922
"github.com/duneanalytics/duneapi-client-go/dune"
2023
"github.com/spf13/cobra"
@@ -37,6 +40,8 @@ var rootCmd = &cobra.Command{
3740
"Authenticate with an API key via --api-key, the DUNE_API_KEY environment variable,\n" +
3841
"or by running `dune auth`.",
3942
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
43+
cmdutil.SetStartTime(cmd, time.Now())
44+
4045
if cmd.Annotations["skipAuth"] == "true" {
4146
return nil
4247
}
@@ -68,13 +73,32 @@ var rootCmd = &cobra.Command{
6873

6974
client := dune.NewDuneClient(env)
7075
cmdutil.SetClient(cmd, client)
76+
77+
return nil
78+
},
79+
PersistentPostRunE: func(cmd *cobra.Command, _ []string) error {
80+
tr := cmdutil.TrackerFromCmd(cmd)
81+
if tr == nil {
82+
return nil
83+
}
84+
start := cmdutil.StartTimeFromCmd(cmd)
85+
durationMs := time.Since(start).Milliseconds()
86+
87+
commandPath := cmd.CommandPath()
88+
// Strip the root command name for cleaner paths.
89+
if parts := strings.SplitN(commandPath, " ", 2); len(parts) == 2 {
90+
commandPath = parts[1]
91+
}
92+
93+
tr.Track(commandPath, tracking.StatusSuccess, "", durationMs)
7194
return nil
7295
},
7396
}
7497

7598
func init() {
7699
rootCmd.PersistentFlags().StringVar(&apiKeyFlag, "api-key", "", "Dune API key (overrides DUNE_API_KEY env var)")
77100
rootCmd.AddCommand(auth.NewAuthCmd())
101+
rootCmd.AddCommand(duneconfig.NewConfigCmd())
78102
rootCmd.AddCommand(dataset.NewDatasetCmd())
79103
rootCmd.AddCommand(docs.NewDocsCmd())
80104
rootCmd.AddCommand(query.NewQueryCmd())
@@ -83,12 +107,27 @@ func init() {
83107
}
84108

85109
// Execute runs the root command via Fang.
86-
func Execute(version, commit, date string) {
110+
func Execute(version, commit, date, amplitudeKey string) {
87111
versionStr := fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date)
88112

89-
if err := fang.Execute(context.Background(), rootCmd,
113+
telemetryEnabled := duneconfig.IsTelemetryEnabled()
114+
configDir, _ := authconfig.Dir()
115+
tracker := tracking.New(tracking.Config{
116+
AmplitudeKey: amplitudeKey,
117+
CLIVersion: version,
118+
ConfigDir: configDir,
119+
Enabled: telemetryEnabled,
120+
})
121+
defer tracker.Shutdown()
122+
123+
rootCmd.SetContext(context.Background())
124+
cmdutil.SetTracker(rootCmd, tracker)
125+
126+
if err := fang.Execute(rootCmd.Context(), rootCmd,
90127
fang.WithVersion(versionStr),
91128
); err != nil {
129+
tracker.Track("unknown", tracking.StatusError, err.Error(), 0)
130+
92131
fmt.Fprintln(os.Stderr, err)
93132
os.Exit(1)
94133
}

cmd/auth/auth.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ func runAuth(cmd *cobra.Command, _ []string) error {
4040
return fmt.Errorf("no API key provided")
4141
}
4242

43-
if err := authconfig.Save(&authconfig.Config{APIKey: key}); err != nil {
43+
cfg, err := authconfig.Load()
44+
if err != nil {
45+
return fmt.Errorf("loading existing config: %w", err)
46+
}
47+
if cfg == nil {
48+
cfg = &authconfig.Config{}
49+
}
50+
cfg.APIKey = key
51+
if err := authconfig.Save(cfg); err != nil {
4452
return fmt.Errorf("saving config: %w", err)
4553
}
4654

cmd/config/config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package config
2+
3+
import "github.com/spf13/cobra"
4+
5+
func NewConfigCmd() *cobra.Command {
6+
cmd := &cobra.Command{
7+
Use: "config",
8+
Short: "Manage CLI configuration",
9+
Annotations: map[string]string{"skipAuth": "true"},
10+
}
11+
cmd.AddCommand(newTelemetryCmd())
12+
return cmd
13+
}

cmd/config/telemetry.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/duneanalytics/cli/authconfig"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func newTelemetryCmd() *cobra.Command {
12+
cmd := &cobra.Command{
13+
Use: "telemetry",
14+
Short: "Manage anonymous usage telemetry",
15+
}
16+
cmd.AddCommand(
17+
newTelemetryEnableCmd(),
18+
newTelemetryDisableCmd(),
19+
newTelemetryStatusCmd(),
20+
)
21+
return cmd
22+
}
23+
24+
func newTelemetryEnableCmd() *cobra.Command {
25+
return &cobra.Command{
26+
Use: "enable",
27+
Short: "Enable anonymous usage telemetry",
28+
Annotations: map[string]string{"skipAuth": "true"},
29+
RunE: func(cmd *cobra.Command, _ []string) error {
30+
return setTelemetry(cmd, true)
31+
},
32+
}
33+
}
34+
35+
func newTelemetryDisableCmd() *cobra.Command {
36+
return &cobra.Command{
37+
Use: "disable",
38+
Short: "Disable anonymous usage telemetry",
39+
Annotations: map[string]string{"skipAuth": "true"},
40+
RunE: func(cmd *cobra.Command, _ []string) error {
41+
return setTelemetry(cmd, false)
42+
},
43+
}
44+
}
45+
46+
func newTelemetryStatusCmd() *cobra.Command {
47+
return &cobra.Command{
48+
Use: "status",
49+
Short: "Show current telemetry status",
50+
Annotations: map[string]string{"skipAuth": "true"},
51+
RunE: func(cmd *cobra.Command, _ []string) error {
52+
enabled := IsTelemetryEnabled()
53+
if enabled {
54+
fmt.Fprintln(cmd.OutOrStdout(), "Telemetry is enabled.")
55+
} else {
56+
fmt.Fprintln(cmd.OutOrStdout(), "Telemetry is disabled.")
57+
}
58+
return nil
59+
},
60+
}
61+
}
62+
63+
func setTelemetry(cmd *cobra.Command, enabled bool) error {
64+
cfg, err := authconfig.Load()
65+
if err != nil {
66+
return fmt.Errorf("loading config: %w", err)
67+
}
68+
if cfg == nil {
69+
cfg = &authconfig.Config{}
70+
}
71+
cfg.Telemetry = &enabled
72+
if err := authconfig.Save(cfg); err != nil {
73+
return fmt.Errorf("saving config: %w", err)
74+
}
75+
if enabled {
76+
fmt.Fprintln(cmd.OutOrStdout(), "Telemetry enabled.")
77+
} else {
78+
fmt.Fprintln(cmd.OutOrStdout(), "Telemetry disabled.")
79+
}
80+
return nil
81+
}
82+
83+
// IsTelemetryEnabled checks env vars and config to determine if telemetry is on.
84+
func IsTelemetryEnabled() bool {
85+
if os.Getenv("DUNE_NO_TELEMETRY") == "1" {
86+
return false
87+
}
88+
if os.Getenv("CI") != "" {
89+
return false
90+
}
91+
cfg, err := authconfig.Load()
92+
if err != nil || cfg == nil {
93+
return true // default: enabled
94+
}
95+
if cfg.Telemetry == nil {
96+
return true // nil = default opt-in
97+
}
98+
return *cfg.Telemetry
99+
}

cmd/main.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import (
99

1010
// Set by GoReleaser or Makefile via ldflags.
1111
var (
12-
version = ""
13-
commit = ""
14-
date = ""
12+
version = ""
13+
commit = ""
14+
date = ""
15+
amplitudeKey = ""
1516
)
1617

1718
func main() {
1819
resolveVersion()
19-
cli.Execute(version, commit, date)
20+
cli.Execute(version, commit, date, amplitudeKey)
2021
}
2122

2223
// resolveVersion fills in version/commit/date from Go build info

cmdutil/client.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ package cmdutil
22

33
import (
44
"context"
5+
"time"
56

7+
"github.com/duneanalytics/cli/tracking"
68
"github.com/duneanalytics/duneapi-client-go/dune"
79
"github.com/spf13/cobra"
810
)
911

1012
type clientKey struct{}
13+
type trackerKey struct{}
14+
type startTimeKey struct{}
1115

1216
// SetClient stores a DuneClient in the command's context.
1317
func SetClient(cmd *cobra.Command, client dune.DuneClient) {
@@ -22,3 +26,33 @@ func SetClient(cmd *cobra.Command, client dune.DuneClient) {
2226
func ClientFromCmd(cmd *cobra.Command) dune.DuneClient {
2327
return cmd.Context().Value(clientKey{}).(dune.DuneClient)
2428
}
29+
30+
// SetTracker stores a Tracker in the command's context.
31+
func SetTracker(cmd *cobra.Command, t *tracking.Tracker) {
32+
ctx := cmd.Context()
33+
if ctx == nil {
34+
ctx = context.Background()
35+
}
36+
cmd.SetContext(context.WithValue(ctx, trackerKey{}, t))
37+
}
38+
39+
// TrackerFromCmd extracts the Tracker stored in the command's context.
40+
func TrackerFromCmd(cmd *cobra.Command) *tracking.Tracker {
41+
v, _ := cmd.Context().Value(trackerKey{}).(*tracking.Tracker)
42+
return v
43+
}
44+
45+
// SetStartTime stores the command start time in the command's context.
46+
func SetStartTime(cmd *cobra.Command, t time.Time) {
47+
ctx := cmd.Context()
48+
if ctx == nil {
49+
ctx = context.Background()
50+
}
51+
cmd.SetContext(context.WithValue(ctx, startTimeKey{}, t))
52+
}
53+
54+
// StartTimeFromCmd extracts the command start time from the command's context.
55+
func StartTimeFromCmd(cmd *cobra.Command) time.Time {
56+
v, _ := cmd.Context().Value(startTimeKey{}).(time.Time)
57+
return v
58+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/duneanalytics/cli
33
go 1.25.6
44

55
require (
6+
github.com/amplitude/analytics-go v1.3.0
67
github.com/charmbracelet/fang v0.4.4
78
github.com/duneanalytics/duneapi-client-go v0.4.3
89
github.com/modelcontextprotocol/go-sdk v1.4.0
@@ -25,6 +26,7 @@ require (
2526
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
2627
github.com/davecgh/go-spew v1.1.1 // indirect
2728
github.com/google/jsonschema-go v0.4.2 // indirect
29+
github.com/google/uuid v1.3.0 // indirect
2830
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2931
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3032
github.com/mattn/go-runewidth v0.0.19 // indirect

0 commit comments

Comments
 (0)