From 0054cca333d7aa2ed83ef6474ccbd6618e84b62c Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 2 Jun 2026 11:12:42 -0700 Subject: [PATCH 1/2] feat(identify): send user data to identify after login --- pkg/auth/token_store.go | 38 +++++++-- pkg/auth/token_store_test.go | 140 ++++++++++++++++++++++++++++++++ pkg/cmd/root/root.go | 4 + pkg/telemetry/telemetry.go | 51 +++++++++--- pkg/telemetry/telemetry_test.go | 91 +++++++++++++++++++++ 5 files changed, 308 insertions(+), 16 deletions(-) create mode 100644 pkg/auth/token_store_test.go diff --git a/pkg/auth/token_store.go b/pkg/auth/token_store.go index 2289632d..9c88e2f1 100644 --- a/pkg/auth/token_store.go +++ b/pkg/auth/token_store.go @@ -3,6 +3,7 @@ package auth import ( "encoding/json" "fmt" + "strconv" "time" "github.com/zalando/go-keyring" @@ -15,12 +16,16 @@ const ( keyringUser = "oauth-token" ) -// StoredToken represents the persisted OAuth tokens. +// StoredToken represents the persisted OAuth tokens and the identity of the +// authenticated user. type StoredToken struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresAt int64 `json:"expires_at"` Scope string `json:"scope,omitempty"` + UserID string `json:"user_id,omitempty"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` } // IsExpired returns true if the access token has expired (with a 60s buffer). @@ -28,8 +33,15 @@ func (t *StoredToken) IsExpired() bool { return time.Now().Unix() >= t.ExpiresAt-60 } -// SaveToken persists tokens from an OAuthTokenResponse to the OS keychain. +// SaveToken persists tokens (and the user identity, when present) from an +// OAuthTokenResponse to the OS keychain. func SaveToken(resp *dashboard.OAuthTokenResponse) error { + return persistToken(storedTokenFromResponse(resp)) +} + +// storedTokenFromResponse builds a StoredToken from an OAuth token response, +// including the user identity when the response carries a user object. +func storedTokenFromResponse(resp *dashboard.OAuthTokenResponse) StoredToken { expiresAt := resp.CreatedAt + int64(resp.ExpiresIn) if expiresAt == 0 { expiresAt = time.Now().Unix() + int64(resp.ExpiresIn) @@ -42,6 +54,17 @@ func SaveToken(resp *dashboard.OAuthTokenResponse) error { Scope: resp.Scope, } + if resp.User != nil && resp.User.ID != 0 { + stored.UserID = strconv.Itoa(resp.User.ID) + stored.Email = resp.User.Email + stored.Name = resp.User.Name + } + + return stored +} + +// persistToken marshals and writes a StoredToken to the OS keychain. +func persistToken(stored StoredToken) error { data, err := json.Marshal(stored) if err != nil { return err @@ -96,9 +119,14 @@ func GetValidToken(client *dashboard.Client) (string, error) { return "", fmt.Errorf("session expired and refresh failed — run `algolia auth login` to re-authenticate: %w", err) } - if err := SaveToken(tokenResp); err != nil { - return tokenResp.AccessToken, nil + refreshed := storedTokenFromResponse(tokenResp) + if refreshed.UserID == "" { + refreshed.UserID = stored.UserID + refreshed.Email = stored.Email + refreshed.Name = stored.Name } - return tokenResp.AccessToken, nil + _ = persistToken(refreshed) + + return refreshed.AccessToken, nil } diff --git a/pkg/auth/token_store_test.go b/pkg/auth/token_store_test.go new file mode 100644 index 00000000..4a79beba --- /dev/null +++ b/pkg/auth/token_store_test.go @@ -0,0 +1,140 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" + + "github.com/algolia/cli/api/dashboard" +) + +func TestSaveToken_PersistsUserIdentity(t *testing.T) { + keyring.MockInit() + + resp := &dashboard.OAuthTokenResponse{ + AccessToken: "access-token", + RefreshToken: "refresh-token", + ExpiresIn: 7200, + CreatedAt: time.Now().Unix(), + Scope: "scope:test", + User: &dashboard.User{ + ID: 42, + Email: "user@test.com", + Name: "Test User", + }, + } + + require.NoError(t, SaveToken(resp)) + + stored := LoadToken() + require.NotNil(t, stored) + assert.Equal(t, "access-token", stored.AccessToken) + assert.Equal(t, "42", stored.UserID) + assert.Equal(t, "user@test.com", stored.Email) + assert.Equal(t, "Test User", stored.Name) +} + +func TestSaveToken_WithoutUserLeavesIdentityEmpty(t *testing.T) { + keyring.MockInit() + + require.NoError(t, SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "access-token", + ExpiresIn: 7200, + CreatedAt: time.Now().Unix(), + })) + + stored := LoadToken() + require.NotNil(t, stored) + assert.Empty(t, stored.UserID) + assert.Empty(t, stored.Email) + assert.Empty(t, stored.Name) +} + +// newRefreshServer returns a dashboard client whose token endpoint replies with +// the given response, mimicking POST /2/oauth/token for grant_type=refresh_token. +func newRefreshServer(t *testing.T, resp dashboard.OAuthTokenResponse) *dashboard.Client { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + t.Cleanup(srv.Close) + + client := dashboard.NewClientWithHTTPClient("test-client-id", srv.Client()) + client.DashboardURL = srv.URL + return client +} + +func TestGetValidToken_PreservesIdentityWhenRefreshOmitsUser(t *testing.T) { + keyring.MockInit() + + // An existing session that already knows its user, but whose access token + // has expired so a refresh is required. + require.NoError(t, persistToken(StoredToken{ + AccessToken: "old-access", + RefreshToken: "refresh-1", + ExpiresAt: time.Now().Unix() - 60, + UserID: "42", + Email: "user@test.com", + Name: "Test User", + })) + + client := newRefreshServer(t, dashboard.OAuthTokenResponse{ + AccessToken: "new-access", + RefreshToken: "refresh-2", + ExpiresIn: 7200, + CreatedAt: time.Now().Unix(), + }) + + token, err := GetValidToken(client) + require.NoError(t, err) + assert.Equal(t, "new-access", token) + + stored := LoadToken() + require.NotNil(t, stored) + assert.Equal(t, "new-access", stored.AccessToken) + assert.Equal(t, "refresh-2", stored.RefreshToken) + // Identity survives a refresh response that doesn't echo the user back. + assert.Equal(t, "42", stored.UserID) + assert.Equal(t, "user@test.com", stored.Email) + assert.Equal(t, "Test User", stored.Name) +} + +func TestGetValidToken_SelfHealsIdentityFromRefresh(t *testing.T) { + keyring.MockInit() + + // A session created before identity was persisted: no user fields yet. + require.NoError(t, persistToken(StoredToken{ + AccessToken: "old-access", + RefreshToken: "refresh-1", + ExpiresAt: time.Now().Unix() - 60, + })) + + client := newRefreshServer(t, dashboard.OAuthTokenResponse{ + AccessToken: "new-access", + RefreshToken: "refresh-2", + ExpiresIn: 7200, + CreatedAt: time.Now().Unix(), + User: &dashboard.User{ + ID: 7, + Email: "healed@test.com", + Name: "Healed User", + }, + }) + + _, err := GetValidToken(client) + require.NoError(t, err) + + stored := LoadToken() + require.NotNil(t, stored) + // Identity is back-filled from the refresh response. + assert.Equal(t, "7", stored.UserID) + assert.Equal(t, "healed@test.com", stored.Email) + assert.Equal(t, "Healed User", stored.Name) +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index e1d5a669..399535e1 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -177,6 +177,10 @@ func Execute() exitCode { telemetryMetadata.SetAppID(appID) telemetryMetadata.SetConfiguredApplicationsNb(len(cfg.ConfiguredProfiles())) + if token := auth.LoadToken(); token != nil { + telemetryMetadata.SetUser(token.UserID, token.Email, token.Name) + } + ctx := cmd.Context() telemetryClient := telemetry.GetTelemetryClient(ctx) diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 42bce595..7dfaff50 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -92,7 +92,9 @@ type NoOpTelemetryClient struct{} type CLIAnalyticsEventMetadata struct { AnonymousID string // the anonymous id is the hash of the mac address of the machine - UserID string // TODO: Once we implement OAuth + UserID string // the authenticated user's id from the OAuth token; empty when logged out + Email string // the authenticated user's email, when available + Name string // the authenticated user's name, when available InvocationID string // the invocation id is unique to each context object and represents all events coming from one command ConfiguredApplicationsNb int // the number of configured applications AppID string // the app id with which the command was called @@ -167,6 +169,13 @@ func (e *CLIAnalyticsEventMetadata) SetConfiguredApplicationsNb(nb int) { e.ConfiguredApplicationsNb = nb } +// SetUser sets the authenticated user identity on the CLIAnalyticsEventContext object +func (e *CLIAnalyticsEventMetadata) SetUser(userID, email, name string) { + e.UserID = userID + e.Email = email + e.Name = name +} + // Identify tracks the user with the provided properties func (a *AnalyticsTelemetryClient) Identify(ctx context.Context) error { metadata := GetEventMetadata(ctx) @@ -176,27 +185,41 @@ func (a *AnalyticsTelemetryClient) Identify(ctx context.Context) error { isCI = 1 } - return a.client.Enqueue(analytics.Identify{ + traits := analytics.Traits{ + "configured_applications": metadata.ConfiguredApplicationsNb, + "version": metadata.CLIVersion, + "operating_system": metadata.OS, + "is_ci": isCI, + } + + identify := analytics.Identify{ AnonymousId: metadata.AnonymousID, - Traits: map[string]interface{}{ - "configured_applications": metadata.ConfiguredApplicationsNb, - "version": metadata.CLIVersion, - "operating_system": metadata.OS, - "is_ci": isCI, - }, + Traits: traits, Context: &analytics.Context{ Device: analytics.DeviceInfo{ Id: metadata.AnonymousID, }, }, - }) + } + + if metadata.UserID != "" { + identify.UserId = metadata.UserID + if metadata.Email != "" { + traits["email"] = metadata.Email + } + if metadata.Name != "" { + traits["name"] = metadata.Name + } + } + + return a.client.Enqueue(identify) } // Track tracks the event with the provided properties func (a *AnalyticsTelemetryClient) Track(ctx context.Context, event string) error { metadata := GetEventMetadata(ctx) - return a.client.Enqueue(analytics.Track{ + track := analytics.Track{ Event: event, AnonymousId: metadata.AnonymousID, Properties: map[string]interface{}{ @@ -210,7 +233,13 @@ func (a *AnalyticsTelemetryClient) Track(ctx context.Context, event string) erro Id: metadata.AnonymousID, }, }, - }) + } + + if metadata.UserID != "" { + track.UserId = metadata.UserID + } + + return a.client.Enqueue(track) } // Close closes the client, waiting for all pending events to be sent. diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go index 01cb4349..8e381464 100644 --- a/pkg/telemetry/telemetry_test.go +++ b/pkg/telemetry/telemetry_test.go @@ -6,6 +6,7 @@ import ( "github.com/segmentio/analytics-go/v3" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -62,3 +63,93 @@ func TestSetCobraCommandContext(t *testing.T) { require.Equal(t, "foo", event.CommandPath) require.Equal(t, []string{"bar"}, event.CommandFlags) } + +func TestSetUser(t *testing.T) { + event := NewEventMetadata() + event.SetUser("user-42", "user@test.com", "Test User") + + assert.Equal(t, "user-42", event.UserID) + assert.Equal(t, "user@test.com", event.Email) + assert.Equal(t, "Test User", event.Name) +} + +// fakeAnalyticsClient captures the messages enqueued by the telemetry client so +// tests can assert on the payload without hitting the network. +type fakeAnalyticsClient struct { + messages []analytics.Message +} + +func (f *fakeAnalyticsClient) Enqueue(msg analytics.Message) error { + f.messages = append(f.messages, msg) + return nil +} + +func (f *fakeAnalyticsClient) Close() error { return nil } + +func TestIdentify_IncludesUserWhenAuthenticated(t *testing.T) { + fake := &fakeAnalyticsClient{} + client := &AnalyticsTelemetryClient{client: fake} + + metadata := NewEventMetadata() + metadata.SetUser("user-42", "user@test.com", "Test User") + ctx := WithEventMetadata(context.Background(), metadata) + + require.NoError(t, client.Identify(ctx)) + require.Len(t, fake.messages, 1) + + identify, ok := fake.messages[0].(analytics.Identify) + require.True(t, ok) + assert.Equal(t, "user-42", identify.UserId) + assert.Equal(t, metadata.AnonymousID, identify.AnonymousId) + assert.Equal(t, "user@test.com", identify.Traits["email"]) + assert.Equal(t, "Test User", identify.Traits["name"]) +} + +func TestIdentify_OmitsUserWhenAnonymous(t *testing.T) { + fake := &fakeAnalyticsClient{} + client := &AnalyticsTelemetryClient{client: fake} + + metadata := NewEventMetadata() + ctx := WithEventMetadata(context.Background(), metadata) + + require.NoError(t, client.Identify(ctx)) + require.Len(t, fake.messages, 1) + + identify, ok := fake.messages[0].(analytics.Identify) + require.True(t, ok) + assert.Empty(t, identify.UserId) + assert.NotContains(t, identify.Traits, "email") + assert.NotContains(t, identify.Traits, "name") +} + +func TestTrack_IncludesUserWhenAuthenticated(t *testing.T) { + fake := &fakeAnalyticsClient{} + client := &AnalyticsTelemetryClient{client: fake} + + metadata := NewEventMetadata() + metadata.SetUser("user-42", "user@test.com", "Test User") + ctx := WithEventMetadata(context.Background(), metadata) + + require.NoError(t, client.Track(ctx, "Command Invoked")) + require.Len(t, fake.messages, 1) + + track, ok := fake.messages[0].(analytics.Track) + require.True(t, ok) + assert.Equal(t, "user-42", track.UserId) + assert.Equal(t, "Command Invoked", track.Event) +} + +func TestTrack_OmitsUserWhenAnonymous(t *testing.T) { + fake := &fakeAnalyticsClient{} + client := &AnalyticsTelemetryClient{client: fake} + + metadata := NewEventMetadata() + ctx := WithEventMetadata(context.Background(), metadata) + + require.NoError(t, client.Track(ctx, "Command Invoked")) + require.Len(t, fake.messages, 1) + + track, ok := fake.messages[0].(analytics.Track) + require.True(t, ok) + assert.Empty(t, track.UserId) +} From 2ab5a449118cb32371257d04500313ee54a97fe6 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 2 Jun 2026 11:53:07 -0700 Subject: [PATCH 2/2] fix(user): call identify after signup/login --- pkg/cmd/auth/login/login.go | 38 +++++++++++++++++--- pkg/cmd/auth/login/login_test.go | 59 ++++++++++++++++++++++++++++++++ pkg/cmd/auth/signup/signup.go | 2 +- pkg/telemetry/telemetry.go | 21 ++++++++++++ 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 7497fef5..7fdb1461 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -1,6 +1,7 @@ package login import ( + "context" "fmt" "github.com/AlecAivazis/survey/v2" @@ -14,6 +15,7 @@ import ( "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/pkg/telemetry" "github.com/algolia/cli/pkg/validators" ) @@ -71,7 +73,7 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command { `), Args: validators.NoArgs(), RunE: func(cmd *cobra.Command, args []string) error { - return runLoginCmd(opts) + return runLoginCmd(cmd.Context(), opts) }, } @@ -83,13 +85,13 @@ func NewLoginCmd(f *cmdutil.Factory) *cobra.Command { return cmd } -func runLoginCmd(opts *LoginOptions) error { - return RunOAuthFlow(opts, false) +func runLoginCmd(ctx context.Context, opts *LoginOptions) error { + return RunOAuthFlow(ctx, opts, false) } // RunOAuthFlow runs the full browser-based OAuth + profile setup flow. // If signup is true, the browser opens to the sign-up page instead of sign-in. -func RunOAuthFlow(opts *LoginOptions, signup bool) error { +func RunOAuthFlow(ctx context.Context, opts *LoginOptions, signup bool) error { cs := opts.IO.ColorScheme() client := opts.NewDashboardClient(auth.OAuthClientID()) @@ -99,6 +101,8 @@ func RunOAuthFlow(opts *LoginOptions, signup bool) error { return err } + identifyAuthenticatedUser(ctx) + opts.IO.StartProgressIndicatorWithLabel("Fetching applications") apps, err := client.ListApplications(accessToken) opts.IO.StopProgressIndicator() @@ -138,6 +142,32 @@ func RunOAuthFlow(opts *LoginOptions, signup bool) error { return apputil.ConfigureProfile(opts.IO, opts.Config, appDetails, profileName, opts.Default) } +// identifyAuthenticatedUser emits a telemetry Identify for the user that just +// authenticated. It is a no-op when no identified token is present. +func identifyAuthenticatedUser(ctx context.Context) { + if applyStoredIdentity(ctx) { + telemetry.IdentifyOnce(ctx) + } +} + +// applyStoredIdentity copies the persisted user identity from the stored token +// onto the request's telemetry metadata. It reports whether an identity was +// applied. +func applyStoredIdentity(ctx context.Context) bool { + token := auth.LoadToken() + if token == nil || token.UserID == "" { + return false + } + + metadata := telemetry.GetEventMetadata(ctx) + if metadata == nil { + return false + } + + metadata.SetUser(token.UserID, token.Email, token.Name) + return true +} + // reuseExistingAPIKey checks if a local profile already has an API key for // the given application. If so, it sets app.APIKey and returns true. func reuseExistingAPIKey(cfg config.IConfig, app *dashboard.Application) bool { diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index ac45c95b..ed818558 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -1,14 +1,18 @@ package login import ( + "context" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/auth" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/telemetry" "github.com/algolia/cli/test" ) @@ -71,6 +75,61 @@ func TestSelectApplication_ByName_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "not found") } +func TestApplyStoredIdentity_SetsMetadataFromToken(t *testing.T) { + keyring.MockInit() + t.Cleanup(auth.ClearToken) + + err := auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "access", + RefreshToken: "refresh", + ExpiresIn: 3600, + User: &dashboard.User{ + ID: 42, + Email: "user@example.com", + Name: "Ada Lovelace", + }, + }) + require.NoError(t, err) + + metadata := telemetry.NewEventMetadata() + ctx := telemetry.WithEventMetadata(context.Background(), metadata) + + assert.True(t, applyStoredIdentity(ctx)) + assert.Equal(t, "42", metadata.UserID) + assert.Equal(t, "user@example.com", metadata.Email) + assert.Equal(t, "Ada Lovelace", metadata.Name) +} + +func TestApplyStoredIdentity_NoTokenReturnsFalse(t *testing.T) { + keyring.MockInit() + auth.ClearToken() + + metadata := telemetry.NewEventMetadata() + ctx := telemetry.WithEventMetadata(context.Background(), metadata) + + assert.False(t, applyStoredIdentity(ctx)) + assert.Empty(t, metadata.UserID) +} + +func TestApplyStoredIdentity_TokenWithoutIdentityReturnsFalse(t *testing.T) { + keyring.MockInit() + t.Cleanup(auth.ClearToken) + + // Token persisted before identity was tracked (no user object). + err := auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "access", + RefreshToken: "refresh", + ExpiresIn: 3600, + }) + require.NoError(t, err) + + metadata := telemetry.NewEventMetadata() + ctx := telemetry.WithEventMetadata(context.Background(), metadata) + + assert.False(t, applyStoredIdentity(ctx)) + assert.Empty(t, metadata.UserID) +} + func TestSelectApplication_MultipleApps_NonInteractive_NoAppName(t *testing.T) { io, _, _, _ := iostreams.Test() opts := &LoginOptions{IO: io} diff --git a/pkg/cmd/auth/signup/signup.go b/pkg/cmd/auth/signup/signup.go index e09442ff..7b68d6da 100644 --- a/pkg/cmd/auth/signup/signup.go +++ b/pkg/cmd/auth/signup/signup.go @@ -34,7 +34,7 @@ func NewSignupCmd(f *cmdutil.Factory) *cobra.Command { `), Args: validators.NoArgs(), RunE: func(cmd *cobra.Command, args []string) error { - return login.RunOAuthFlow(opts, true) + return login.RunOAuthFlow(cmd.Context(), opts, true) }, } diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 7dfaff50..f81abe75 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net" + "os" "runtime" "github.com/segmentio/analytics-go/v3" @@ -69,6 +70,26 @@ func NewAnalyticsTelemetryClient(debug bool) (TelemetryClient, error) { return &AnalyticsTelemetryClient{client: client}, nil } +// IdentifyOnce sends a single Identify event through a short-lived client and +// flushes it before returning. It is meant for one-shot identification (for +// example, right after authentication fills the token) where the command's +// request-scoped client may already have been closed. It honors the same +// ALGOLIA_CLI_TELEMETRY and DEBUG environment variables as the root command and +// fails silently so telemetry never blocks the user. +func IdentifyOnce(ctx context.Context) { + if os.Getenv("ALGOLIA_CLI_TELEMETRY") == "0" { + return + } + + client, err := NewAnalyticsTelemetryClient(os.Getenv("DEBUG") != "") + if err != nil { + return + } + defer client.Close() + + _ = client.Identify(ctx) +} + // anonymousID is a unique identifier for an anonymous user of the CLI (basically the hash of the mac address) func anonymousID() string { addrs, err := net.Interfaces()