diff --git a/authconfig/identity.go b/authconfig/identity.go new file mode 100644 index 0000000..59b7838 --- /dev/null +++ b/authconfig/identity.go @@ -0,0 +1,68 @@ +package authconfig + +import ( + "crypto/sha256" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// UserIdentity holds the cached identity resolved from the API key. +type UserIdentity struct { + CustomerID string `yaml:"customer_id"` // e.g. "user_123" or "team_456" + APIKeyHash string `yaml:"api_key_hash"` // SHA-256 of the API key for cache invalidation +} + +// identityFileName is the file storing the cached user identity. +const identityFileName = "user_identity.yaml" + +// LoadIdentity reads the cached user identity. Returns nil, nil if the file does not exist. +func LoadIdentity() (*UserIdentity, error) { + dir, err := Dir() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(filepath.Join(dir, identityFileName)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + + var id UserIdentity + if err := yaml.Unmarshal(data, &id); err != nil { + return nil, err + } + return &id, nil +} + +// SaveIdentity writes the user identity cache to disk. +func SaveIdentity(id *UserIdentity) error { + dir, err := Dir() + if err != nil { + return err + } + + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + + data, err := yaml.Marshal(id) + if err != nil { + return err + } + + return os.WriteFile(filepath.Join(dir, identityFileName), data, 0o600) +} + +// HashAPIKey returns the hex-encoded SHA-256 hash of an API key. +func HashAPIKey(apiKey string) string { + h := sha256.Sum256([]byte(apiKey)) + return fmt.Sprintf("%x", h) +} diff --git a/authconfig/identity_test.go b/authconfig/identity_test.go new file mode 100644 index 0000000..f8bc6d6 --- /dev/null +++ b/authconfig/identity_test.go @@ -0,0 +1,38 @@ +package authconfig_test + +import ( + "testing" + + "github.com/duneanalytics/cli/authconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSaveAndLoadIdentity(t *testing.T) { + setupTempDir(t) + + want := &authconfig.UserIdentity{ + CustomerID: "user_42", + APIKeyHash: authconfig.HashAPIKey("test-api-key"), + } + require.NoError(t, authconfig.SaveIdentity(want)) + + got, err := authconfig.LoadIdentity() + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestLoadIdentityNonExistent(t *testing.T) { + setupTempDir(t) + + id, err := authconfig.LoadIdentity() + assert.NoError(t, err) + assert.Nil(t, id) +} + +func TestHashAPIKeyDeterministic(t *testing.T) { + h1 := authconfig.HashAPIKey("my-key") + h2 := authconfig.HashAPIKey("my-key") + assert.Equal(t, h1, h2) + assert.NotEqual(t, h1, authconfig.HashAPIKey("other-key")) +} diff --git a/cli/root.go b/cli/root.go index be3f526..f1d9a53 100644 --- a/cli/root.go +++ b/cli/root.go @@ -22,6 +22,7 @@ import ( "github.com/duneanalytics/cli/cmd/query" "github.com/duneanalytics/cli/cmd/sim" "github.com/duneanalytics/cli/cmd/usage" + "github.com/duneanalytics/cli/cmd/whoami" "github.com/duneanalytics/cli/cmdutil" "github.com/duneanalytics/cli/tracking" ) @@ -77,6 +78,13 @@ var rootCmd = &cobra.Command{ client := dune.NewDuneClient(env) cmdutil.SetClient(cmd, client) + // Resolve customer identity for analytics (best-effort, never blocks the CLI). + if tr := cmdutil.TrackerFromCmd(cmd); tr != nil { + if customerID := resolveCustomerID(client, env.APIKey); customerID != "" { + tr.SetUserID(customerID) + } + } + return nil }, PersistentPostRunE: func(cmd *cobra.Command, _ []string) error { @@ -93,7 +101,8 @@ var rootCmd = &cobra.Command{ commandPath = parts[1] } - tr.Track(commandPath, tracking.StatusSuccess, "", durationMs) + isSim := strings.HasPrefix(commandPath, "sim") + tr.Track(commandPath, tracking.StatusSuccess, "", durationMs, isSim) return nil }, } @@ -107,6 +116,7 @@ func init() { rootCmd.AddCommand(query.NewQueryCmd()) rootCmd.AddCommand(execution.NewExecutionCmd()) rootCmd.AddCommand(usage.NewUsageCmd()) + rootCmd.AddCommand(whoami.NewWhoAmICmd()) rootCmd.AddCommand(sim.NewSimCmd()) } @@ -115,11 +125,9 @@ func Execute(version, commit, date, amplitudeKey string) { versionStr := fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date) telemetryEnabled := duneconfig.IsTelemetryEnabled() - configDir, _ := authconfig.Dir() tracker := tracking.New(tracking.Config{ AmplitudeKey: amplitudeKey, CLIVersion: version, - ConfigDir: configDir, Enabled: telemetryEnabled, }) defer tracker.Shutdown() @@ -132,7 +140,8 @@ func Execute(version, commit, date, amplitudeKey string) { ); err != nil { // Build best-effort command path from os.Args (strip flags). commandPath := commandPathFromArgs(os.Args) - tracker.Track(commandPath, tracking.StatusError, err.Error(), 0) + isSim := strings.HasPrefix(commandPath, "sim") + tracker.Track(commandPath, tracking.StatusError, err.Error(), 0, isSim) // Flush the event before exiting — os.Exit does not run deferred funcs, // so defer tracker.Shutdown() above would never fire. tracker.Shutdown() @@ -142,6 +151,34 @@ func Execute(version, commit, date, amplitudeKey string) { } } +// resolveCustomerID returns the customer_id associated with the given API key. +// The customer_id may represent a user ("user_123") or a team ("team_456"). +// It uses a local cache to avoid calling /api/whoami on every invocation. +// On any error it returns "" silently — analytics should never block the CLI. +func resolveCustomerID(client dune.DuneClient, apiKey string) string { + keyHash := authconfig.HashAPIKey(apiKey) + + // Try the cache first. + cached, err := authconfig.LoadIdentity() + if err == nil && cached != nil && cached.APIKeyHash == keyHash && cached.CustomerID != "" { + return cached.CustomerID + } + + // Cache miss or stale — call the API. + resp, err := client.WhoAmI() + if err != nil || resp == nil || resp.CustomerID == "" { + return "" + } + + // Persist for next time (best-effort). + _ = authconfig.SaveIdentity(&authconfig.UserIdentity{ + CustomerID: resp.CustomerID, + APIKeyHash: keyHash, + }) + + return resp.CustomerID +} + // commandPathFromArgs extracts the subcommand path from os.Args, skipping // the binary name, flags, and flag values so the tracked path is e.g. // "query list" even when invoked as "dune --api-key KEY query list --limit 10". diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go new file mode 100644 index 0000000..1d8a1e5 --- /dev/null +++ b/cmd/whoami/whoami.go @@ -0,0 +1,47 @@ +package whoami + +import ( + "fmt" + + "github.com/duneanalytics/cli/cmdutil" + "github.com/duneanalytics/cli/output" + "github.com/spf13/cobra" +) + +// NewWhoAmICmd returns the "whoami" command. +func NewWhoAmICmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "whoami", + Short: "Show the identity associated with the current API key", + Long: "Display the handle, customer ID, and API key ID of the currently\n" + + "configured API key. Useful for verifying which account is active.\n\n" + + "Examples:\n" + + " dune whoami\n" + + " dune whoami --output json", + RunE: runWhoAmI, + } + + output.AddFormatFlag(cmd, "text") + + return cmd +} + +func runWhoAmI(cmd *cobra.Command, _ []string) error { + client := cmdutil.ClientFromCmd(cmd) + + resp, err := client.WhoAmI() + if err != nil { + return fmt.Errorf("identifying API key: %w", err) + } + + w := cmd.OutOrStdout() + switch output.FormatFromCmd(cmd) { + case output.FormatJSON: + return output.PrintJSON(w, resp) + default: + fmt.Fprintf(w, "Handle: %s\n", resp.Handle) + fmt.Fprintf(w, "Customer ID: %s\n", resp.CustomerID) + fmt.Fprintf(w, "API Key ID: %s\n", resp.APIKeyID) + return nil + } +} diff --git a/go.mod b/go.mod index 0ea1fbf..9a63930 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,11 @@ go 1.25.6 require ( github.com/amplitude/analytics-go v1.3.0 github.com/charmbracelet/fang v0.4.4 - github.com/duneanalytics/duneapi-client-go v0.4.3 + github.com/duneanalytics/duneapi-client-go v0.4.4 github.com/modelcontextprotocol/go-sdk v1.4.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + golang.org/x/term v0.40.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -45,6 +46,5 @@ require ( golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.24.0 // indirect ) diff --git a/go.sum b/go.sum index 6f3a94a..de97f7e 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/duneanalytics/duneapi-client-go v0.4.3 h1:3meDMQPwGk6jrVntN4o2nm4CWuzobKnD4nmG52hWZ+0= -github.com/duneanalytics/duneapi-client-go v0.4.3/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE= +github.com/duneanalytics/duneapi-client-go v0.4.4 h1:vNI1musSDuqlICNXLdf059J3YwwonTi/GX5NhTrLdqI= +github.com/duneanalytics/duneapi-client-go v0.4.4/go.mod h1:7pXXufWvR/Mh2KOehdyBaunJXmHI+pzjUmyQTQhJjdE= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -87,8 +87,6 @@ golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= diff --git a/tracking/tracking.go b/tracking/tracking.go index 51fe331..1b4ef7d 100644 --- a/tracking/tracking.go +++ b/tracking/tracking.go @@ -1,14 +1,11 @@ package tracking import ( - "os" - "path/filepath" "runtime" "strings" "time" "github.com/amplitude/analytics-go/amplitude" - "github.com/google/uuid" ) const ( @@ -26,7 +23,6 @@ type Tracker struct { type Config struct { AmplitudeKey string CLIVersion string - ConfigDir string Enabled bool } @@ -39,6 +35,7 @@ func New(cfg Config) *Tracker { ampConfig.ServerZone = "EU" ampConfig.FlushQueueSize = 1 ampConfig.FlushInterval = 1 * time.Second + ampConfig.MinIDLength = 1 if !isDevVersion(cfg.CLIVersion) { ampConfig.Logger = silentLogger{} } @@ -46,12 +43,41 @@ func New(cfg Config) *Tracker { return &Tracker{ client: amplitude.NewClient(ampConfig), version: cfg.CLIVersion, - userID: loadOrCreateAnonID(cfg.ConfigDir), + userID: "cli", enabled: true, } } -func (t *Tracker) Track(commandPath, status, errMsg string, durationMs int64) { +// SetUserID converts a customer ID (e.g. "user_123", "team_456") to the +// canonical Amplitude user_id format used by duneapi and core-node: +// +// "user_123" → "123" +// "team_456" → "system_456" +// +// If not called, events are sent with UserID "anonymous". +func (t *Tracker) SetUserID(id string) { + t.userID = toAmplitudeUserID(id) +} + +// toAmplitudeUserID strips the customer-ID prefix so the Amplitude user_id +// matches the format used by duneapi and core-node (MCP). +// +// "user_123" → "123" +// "team_456" → "system_456" +// anything else is returned as-is. +func toAmplitudeUserID(customerID string) string { + if after, ok := strings.CutPrefix(customerID, "team_"); ok { + return "system_" + after + } + if after, ok := strings.CutPrefix(customerID, "user_"); ok { + return after + } + return customerID +} + +// Track sends a "CLI Command Executed" event to Amplitude. +// Set isSim to true for commands under `dune sim`. +func (t *Tracker) Track(commandPath, status, errMsg string, durationMs int64, isSim bool) { if !t.enabled || t.client == nil { return } @@ -69,6 +95,7 @@ func (t *Tracker) Track(commandPath, status, errMsg string, durationMs int64) { "cli_version": t.version, "os": runtime.GOOS, "arch": runtime.GOARCH, + "is_sim": isSim, }, }) } @@ -91,35 +118,3 @@ func (silentLogger) Debugf(string, ...interface{}) {} func (silentLogger) Infof(string, ...interface{}) {} func (silentLogger) Warnf(string, ...interface{}) {} func (silentLogger) Errorf(string, ...interface{}) {} - -const ( - anonIDFile = "anonymous_id" - anonFallback = "anonymous" -) - -func loadOrCreateAnonID(configDir string) string { - if configDir == "" { - return anonFallback - } - - path := filepath.Join(configDir, anonIDFile) - - data, err := os.ReadFile(path) - if err == nil { - id := strings.TrimSpace(string(data)) - if _, err := uuid.Parse(id); err == nil { - return id - } - } - - id := uuid.New().String() - - if err := os.MkdirAll(configDir, 0o755); err != nil { - return anonFallback - } - if err := os.WriteFile(path, []byte(id), 0o644); err != nil { - return anonFallback - } - - return id -} diff --git a/tracking/tracking_test.go b/tracking/tracking_test.go index 5fa7985..9df02b4 100644 --- a/tracking/tracking_test.go +++ b/tracking/tracking_test.go @@ -1,20 +1,39 @@ package tracking import ( - "os" - "path/filepath" "testing" - "github.com/google/uuid" + "github.com/amplitude/analytics-go/amplitude" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// spyClient records events sent via Track for test assertions. +type spyClient struct { + events []amplitude.Event +} + +func (s *spyClient) Track(event amplitude.Event) { s.events = append(s.events, event) } +func (s *spyClient) Identify(amplitude.Identify, amplitude.EventOptions) {} +func (s *spyClient) GroupIdentify(string, string, amplitude.Identify, amplitude.EventOptions) {} +func (s *spyClient) SetGroup(string, []string, amplitude.EventOptions) {} +func (s *spyClient) Revenue(amplitude.Revenue, amplitude.EventOptions) {} +func (s *spyClient) Flush() {} +func (s *spyClient) Shutdown() {} +func (s *spyClient) Add(amplitude.Plugin) {} +func (s *spyClient) Remove(string) {} +func (s *spyClient) Config() amplitude.Config { return amplitude.Config{} } + +func newTestTracker(spy *spyClient) *Tracker { + return &Tracker{client: spy, version: "test", userID: "cli", enabled: true} +} + func TestTracker_DisabledNoOp(t *testing.T) { tr := New(Config{Enabled: false}) assert.False(t, tr.enabled) // Should not panic. - tr.Track("test cmd", StatusSuccess, "", 100) + tr.Track("test cmd", StatusSuccess, "", 100, false) + tr.Track("sim evm balances", StatusSuccess, "", 100, true) tr.Shutdown() } @@ -23,29 +42,78 @@ func TestTracker_EmptyAmplitudeKeyDisabled(t *testing.T) { assert.False(t, tr.enabled) } -func TestLoadOrCreateAnonID_CreatesFile(t *testing.T) { - dir := t.TempDir() - id := loadOrCreateAnonID(dir) +func TestTracker_DefaultUserIDCli(t *testing.T) { + tr := New(Config{Enabled: true, AmplitudeKey: "test-key"}) + assert.Equal(t, "cli", tr.userID, "userID should default to 'cli'") +} + +func TestTracker_SetUserID(t *testing.T) { + tr := New(Config{Enabled: true, AmplitudeKey: "test-key"}) + assert.Equal(t, "cli", tr.userID) + + tr.SetUserID("user_123") + assert.Equal(t, "123", tr.userID, "user_ prefix should be stripped") +} - _, err := uuid.Parse(id) - assert.NoError(t, err, "should return a valid UUID") - assert.NotEqual(t, anonFallback, id) +func TestToAmplitudeUserID(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"user_123", "123"}, + {"user_0", "0"}, + {"team_456", "system_456"}, + {"team_1", "system_1"}, + {"anonymous", "anonymous"}, + {"something_else", "something_else"}, + {"", ""}, + {"user_", ""}, + {"team_", "system_"}, + } + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + assert.Equal(t, tc.want, toAmplitudeUserID(tc.input)) + }) + } +} - data, err := os.ReadFile(filepath.Join(dir, anonIDFile)) - require.NoError(t, err) - assert.Equal(t, id, string(data)) +func TestTracker_TrackWithoutSetUserID(t *testing.T) { + tr := New(Config{Enabled: true, AmplitudeKey: "test-key"}) + // Should not panic — events are sent with "cli" UserID. + tr.Track("test cmd", StatusSuccess, "", 100, false) + tr.Shutdown() } -func TestLoadOrCreateAnonID_ReusesExisting(t *testing.T) { - dir := t.TempDir() - knownID := "550e8400-e29b-41d4-a716-446655440000" - require.NoError(t, os.WriteFile(filepath.Join(dir, anonIDFile), []byte(knownID), 0o644)) +func TestTrack_IsSim(t *testing.T) { + spy := &spyClient{} + tr := newTestTracker(spy) + + tr.Track("query list", StatusSuccess, "", 42, false) + tr.Track("sim evm balances", StatusSuccess, "", 99, true) - id := loadOrCreateAnonID(dir) - assert.Equal(t, knownID, id) + require.Len(t, spy.events, 2) + + // Non-sim event + props0 := spy.events[0].EventProperties + assert.Equal(t, "CLI Command Executed", spy.events[0].EventType) + assert.Equal(t, "cli", spy.events[0].UserID) + assert.Equal(t, "query list", props0["command_path"]) + assert.Equal(t, false, props0["is_sim"]) + + // Sim event + props1 := spy.events[1].EventProperties + assert.Equal(t, "CLI Command Executed", spy.events[1].EventType) + assert.Equal(t, "sim evm balances", props1["command_path"]) + assert.Equal(t, true, props1["is_sim"]) } -func TestLoadOrCreateAnonID_InvalidDir(t *testing.T) { - id := loadOrCreateAnonID("/nonexistent/path/that/should/not/exist") - assert.Equal(t, anonFallback, id) +func TestTrack_SetUserIDReflectedInEvents(t *testing.T) { + spy := &spyClient{} + tr := newTestTracker(spy) + + tr.SetUserID("user_42") + tr.Track("query run", StatusSuccess, "", 10, false) + + require.Len(t, spy.events, 1) + assert.Equal(t, "42", spy.events[0].UserID) }