From 57aeac18ec067c258f857465007b56f00603bf98 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Thu, 11 Jun 2026 12:45:02 -0700 Subject: [PATCH 1/5] feat(telemetry): flow events foundations (#238) Co-authored-by: Claude Fable 5 --- pkg/cmd/root/root.go | 105 ++++++++++++++++++++------ pkg/cmd/root/root_test.go | 97 ++++++++++++++++++++++++ pkg/telemetry/events.go | 127 ++++++++++++++++++++++++++++++++ pkg/telemetry/events_test.go | 45 +++++++++++ pkg/telemetry/telemetry.go | 49 +++++++++--- pkg/telemetry/telemetry_test.go | 94 ++++++++++++++++++++++- 6 files changed, 482 insertions(+), 35 deletions(-) create mode 100644 pkg/telemetry/events.go create mode 100644 pkg/telemetry/events_test.go diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 399535e1..92b7e5b6 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "strings" + "time" "github.com/AlecAivazis/survey/v2/terminal" "github.com/MakeNowJust/heredoc" @@ -121,7 +122,8 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { return cmd } -func Execute() exitCode { +func Execute() (code exitCode) { + start := time.Now() hasDebug := os.Getenv("DEBUG") != "" hasTelemetry := os.Getenv("ALGOLIA_CLI_TELEMETRY") != "0" @@ -192,25 +194,31 @@ func Execute() exitCode { } // Send telemetry. - err = telemetryClient.Track(ctx, "Command Invoked") + err = telemetryClient.Track(ctx, telemetry.EventCommandInvoked, nil) if err != nil && hasDebug { fmt.Fprintf(stderr, "Error tracking telemetry: %s\n", err) } - go telemetryClient.Close() // flush telemetry events - return nil } // Command context is used to pass information to the telemetry client. - ctx, err := createContext(rootCmd, stderr, hasDebug, hasTelemetry) - if err != nil { - printError(stderr, err, rootCmd, hasDebug) - return exitError - } + ctx := createContext(rootCmd, stderr, hasDebug, hasTelemetry) + defer closeTelemetry(ctx) + + // Report how the command ended just before the final flush (deferred + // functions run last-in-first-out). + var executedCmd *cobra.Command + var executeErr error + var elapsed time.Duration + defer func() { + trackCommandCompleted(ctx, executedCmd, code, executeErr, elapsed) + }() - // Run the command. + // Run the command. The duration is measured right away so it never + // includes the update-notifier wait below. cmd, err := rootCmd.ExecuteContextC(ctx) + executedCmd, executeErr, elapsed = cmd, err, time.Since(start) // Handle eventual errors. if err != nil { if err == cmdutil.ErrSilent { @@ -248,31 +256,86 @@ func Execute() exitCode { return exitOK } +// trackCommandCompleted reports how the command ended: success, failure (with +// the class of the error) or user cancellation. +func trackCommandCompleted( + ctx context.Context, + cmd *cobra.Command, + code exitCode, + err error, + elapsed time.Duration, +) { + if cmd == nil || !cmdutil.ShouldTrackUsage(cmd) { + return + } + // An empty command path means PersistentPreRunE never ran (--help, + // --version, unknown flag or command, failed auth check): no Command + // Invoked was sent, so don't send an orphan Command Completed either. + metadata := telemetry.GetEventMetadata(ctx) + if metadata == nil || metadata.CommandPath == "" { + return + } + client := telemetry.GetTelemetryClient(ctx) + if client == nil { + return + } + + props := map[string]any{ + "succeeded": code == exitOK, + "exit_code": int(code), + "duration_ms": elapsed.Milliseconds(), + } + if err != nil { + props["error_class"] = telemetry.ErrorClass(err) + props["user_cancelled"] = cmdutil.IsUserCancellation(err) + } + + _ = client.Track(ctx, telemetry.EventCommandCompleted, props) +} + +// closeTelemetry flushes the pending telemetry events, giving up after a +// short timeout so an unreachable telemetry endpoint never delays exit. +func closeTelemetry(ctx context.Context) { + client := telemetry.GetTelemetryClient(ctx) + if client == nil { + return + } + done := make(chan struct{}) + go func() { + client.Close() + close(done) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + } +} + // createContext creates a context with telemetry. func createContext( cmd *cobra.Command, stderr io.Writer, hasDebug bool, hasTelemetry bool, -) (context.Context, error) { +) context.Context { ctx := context.Background() telemetryMetadata := telemetry.NewEventMetadata() updatedCtx := telemetry.WithEventMetadata(ctx, telemetryMetadata) - var telemetryClient telemetry.TelemetryClient - var err error + var telemetryClient telemetry.TelemetryClient = &telemetry.NoOpTelemetryClient{} if hasTelemetry { - telemetryClient, err = telemetry.NewAnalyticsTelemetryClient(hasDebug) - // Fail silently if telemetry is not available unless in debug mode. - if err != nil && hasDebug { - fmt.Fprintf(stderr, "Error creating telemetry client: %s\n", err) - return nil, err + client, err := telemetry.NewAnalyticsTelemetryClient(hasDebug) + if err != nil { + // Fail silently (fall back to no-op telemetry) unless in debug mode. + if hasDebug { + fmt.Fprintf(stderr, "Error creating telemetry client: %s\n", err) + } + } else { + telemetryClient = client } - } else { - telemetryClient = &telemetry.NoOpTelemetryClient{} } contextWithTelemetry := telemetry.WithTelemetryClient(updatedCtx, telemetryClient) - return contextWithTelemetry, nil + return contextWithTelemetry } // printError prints an error to the stderr, with additional information if applicable. diff --git a/pkg/cmd/root/root_test.go b/pkg/cmd/root/root_test.go index 5e38dca9..da949661 100644 --- a/pkg/cmd/root/root_test.go +++ b/pkg/cmd/root/root_test.go @@ -2,16 +2,113 @@ package root import ( "bytes" + "context" "errors" "fmt" "net" "testing" + "time" "github.com/spf13/cobra" "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/telemetry" ) +// recordingTelemetryClient captures the tracked events so tests can assert on +// them without hitting the network. +type recordingTelemetryClient struct { + events []recordedEvent +} + +type recordedEvent struct { + name string + props map[string]any +} + +func (r *recordingTelemetryClient) Identify(ctx context.Context) error { return nil } + +func (r *recordingTelemetryClient) Track( + ctx context.Context, + event string, + properties map[string]any, +) error { + r.events = append(r.events, recordedEvent{event, properties}) + return nil +} + +func (r *recordingTelemetryClient) Close() {} + +func newTelemetryContext(client telemetry.TelemetryClient, commandPath string) context.Context { + metadata := telemetry.NewEventMetadata() + metadata.SetCommandPath(commandPath) + ctx := telemetry.WithEventMetadata(context.Background(), metadata) + return telemetry.WithTelemetryClient(ctx, client) +} + +func TestTrackCommandCompleted_SkipsWhenPreRunNeverRan(t *testing.T) { + client := &recordingTelemetryClient{} + // An empty command path means PersistentPreRunE never ran. + ctx := newTelemetryContext(client, "") + + trackCommandCompleted(ctx, &cobra.Command{Use: "algolia"}, exitOK, nil, time.Second) + + if len(client.events) != 0 { + t.Errorf("expected no event, got %d", len(client.events)) + } +} + +func TestTrackCommandCompleted_ReportsSuccess(t *testing.T) { + client := &recordingTelemetryClient{} + ctx := newTelemetryContext(client, "algolia indices list") + + trackCommandCompleted(ctx, &cobra.Command{Use: "list"}, exitOK, nil, 1500*time.Millisecond) + + if len(client.events) != 1 { + t.Fatalf("expected 1 event, got %d", len(client.events)) + } + event := client.events[0] + if event.name != telemetry.EventCommandCompleted { + t.Errorf("event = %q, want %q", event.name, telemetry.EventCommandCompleted) + } + if event.props["succeeded"] != true { + t.Errorf("succeeded = %v, want true", event.props["succeeded"]) + } + if event.props["exit_code"] != 0 { + t.Errorf("exit_code = %v, want 0", event.props["exit_code"]) + } + if event.props["duration_ms"] != int64(1500) { + t.Errorf("duration_ms = %v, want 1500", event.props["duration_ms"]) + } + if _, ok := event.props["error_class"]; ok { + t.Error("unexpected error_class on success") + } +} + +func TestTrackCommandCompleted_ReportsFailure(t *testing.T) { + client := &recordingTelemetryClient{} + ctx := newTelemetryContext(client, "algolia indices list") + + trackCommandCompleted(ctx, &cobra.Command{Use: "list"}, exitError, errors.New("boom"), time.Second) + + if len(client.events) != 1 { + t.Fatalf("expected 1 event, got %d", len(client.events)) + } + props := client.events[0].props + if props["succeeded"] != false { + t.Errorf("succeeded = %v, want false", props["succeeded"]) + } + if props["exit_code"] != 1 { + t.Errorf("exit_code = %v, want 1", props["exit_code"]) + } + if props["error_class"] != "*errors.errorString" { + t.Errorf("error_class = %v, want *errors.errorString", props["error_class"]) + } + if props["user_cancelled"] != false { + t.Errorf("user_cancelled = %v, want false", props["user_cancelled"]) + } +} + func TestPrintError(t *testing.T) { cmd := &cobra.Command{} diff --git a/pkg/telemetry/events.go b/pkg/telemetry/events.go new file mode 100644 index 00000000..6d18201f --- /dev/null +++ b/pkg/telemetry/events.go @@ -0,0 +1,127 @@ +package telemetry + +import ( + "errors" + "fmt" + "time" +) + +// Event names. New flow events follow the `CLI ` convention; +// the command lifecycle events stay unprefixed for consistency with the +// historical "Command Invoked". +const ( + EventCommandInvoked = "Command Invoked" + EventCommandCompleted = "Command Completed" + + EventAuthStarted = "CLI Auth Started" + EventAuthCompleted = "CLI Auth Completed" + EventAuthFailed = "CLI Auth Failed" + EventAuthAborted = "CLI Auth Aborted" + + EventApplicationCreateStarted = "CLI Application Create Started" + EventApplicationCreateAcceptedTerms = "CLI Application Create Accepted Terms" + EventApplicationCreateDeclinedTerms = "CLI Application Create Declined Terms" + EventApplicationCreateCompleted = "CLI Application Create Completed" + EventApplicationCreateFailed = "CLI Application Create Failed" + EventApplicationCreateAborted = "CLI Application Create Aborted" + + EventApplicationPlanChangeStarted = "CLI Application Plan Change Started" + EventApplicationPlanChangeAcceptedTerms = "CLI Application Plan Change Accepted Terms" + EventApplicationPlanChangeDeclinedTerms = "CLI Application Plan Change Declined Terms" + EventApplicationPlanChangeCompleted = "CLI Application Plan Change Completed" + EventApplicationPlanChangeFailed = "CLI Application Plan Change Failed" + EventApplicationPlanChangeAborted = "CLI Application Plan Change Aborted" +) + +// Flow is the kind of auth flow the user is going through. +type Flow string + +const ( + FlowLogin Flow = "login" + FlowSignup Flow = "signup" +) + +// Step locates where the user is inside an interactive flow, so aborts and +// failures can tell where the user stopped. +type Step string + +const ( + // Auth flow steps. + StepBrowserWait Step = "browser_wait" + StepCodeExchange Step = "code_exchange" + StepAppsFetch Step = "apps_fetch" + StepAppSelect Step = "app_select" + StepAppCreate Step = "app_create" + StepProfileConfigure Step = "profile_configure" + + // Application create and plan change flow steps. + StepName Step = "name" + StepPlan Step = "plan" + StepTerms Step = "terms" + StepRegion Step = "region" + StepAPICall Step = "api_call" + StepApplyPlan Step = "apply_plan" +) + +// Direction is the direction of a plan change. +type Direction string + +const ( + DirectionUpgrade Direction = "upgrade" + DirectionDowngrade Direction = "downgrade" +) + +// FlowTracker carries the state of one interactive flow: the step the user is +// currently in and the flow start time, to compute durations. All its methods +// are safe on a nil tracker, so helpers shared by several flows can take an +// optional tracker. +type FlowTracker struct { + start time.Time + step Step +} + +func NewFlowTracker() *FlowTracker { + return &FlowTracker{start: time.Now()} +} + +// SetStep records the step the flow is entering. +func (f *FlowTracker) SetStep(step Step) { + if f == nil { + return + } + f.step = step +} + +// Step returns the step the flow is currently in. +func (f *FlowTracker) Step() Step { + if f == nil { + return "" + } + return f.step +} + +// DurationMS returns the time elapsed since the flow started, in milliseconds. +func (f *FlowTracker) DurationMS() int64 { + if f == nil { + return 0 + } + return time.Since(f.start).Milliseconds() +} + +// ErrorClass returns the type of the first informative error of the chain, +// skipping the anonymous wrappers created by fmt.Errorf. It never returns an +// error message, which could contain user data. +func ErrorClass(err error) string { + for err != nil { + class := fmt.Sprintf("%T", err) + switch class { + case "*fmt.wrapError", "*fmt.wrapErrors", "*errors.joinError": + if unwrapped := errors.Unwrap(err); unwrapped != nil { + err = unwrapped + continue + } + } + return class + } + return "" +} diff --git a/pkg/telemetry/events_test.go b/pkg/telemetry/events_test.go new file mode 100644 index 00000000..61c5a9ee --- /dev/null +++ b/pkg/telemetry/events_test.go @@ -0,0 +1,45 @@ +package telemetry + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type testRootError struct{} + +func (testRootError) Error() string { return "boom" } + +type testWrapperError struct{ inner error } + +func (e testWrapperError) Error() string { return "wrap" } +func (e testWrapperError) Unwrap() error { return e.inner } + +func TestErrorClass_SkipsFmtWrappers(t *testing.T) { + wrapped := fmt.Errorf("outer: %w", fmt.Errorf("inner: %w", testRootError{})) + assert.Equal(t, "telemetry.testRootError", ErrorClass(wrapped)) +} + +func TestErrorClass_KeepsInformativeWrapperType(t *testing.T) { + wrapped := fmt.Errorf("outer: %w", testWrapperError{inner: testRootError{}}) + assert.Equal(t, "telemetry.testWrapperError", ErrorClass(wrapped)) +} + +func TestErrorClass_NilError(t *testing.T) { + assert.Equal(t, "", ErrorClass(nil)) +} + +func TestFlowTracker_NilTrackerIsSafe(t *testing.T) { + var tracker *FlowTracker + tracker.SetStep(StepTerms) + assert.Equal(t, Step(""), tracker.Step()) + assert.Equal(t, int64(0), tracker.DurationMS()) +} + +func TestFlowTracker_TracksStepAndDuration(t *testing.T) { + tracker := NewFlowTracker() + tracker.SetStep(StepPlan) + assert.Equal(t, StepPlan, tracker.Step()) + assert.GreaterOrEqual(t, tracker.DurationMS(), int64(0)) +} diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index f81abe75..9330d055 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -8,6 +8,7 @@ import ( "net" "os" "runtime" + "sync/atomic" "github.com/segmentio/analytics-go/v3" "github.com/spf13/cobra" @@ -29,12 +30,16 @@ type telemetryClientKey struct{} type TelemetryClient interface { Identify(ctx context.Context) error - Track(ctx context.Context, event string) error + Track(ctx context.Context, event string, properties map[string]any) error Close() } type AnalyticsTelemetryClient struct { client analytics.Client + // sequence numbers the Track events of one invocation so their order can + // be reconstructed downstream: Segment stores timestamps with millisecond + // precision, so back-to-back events can tie. + sequence atomic.Int64 } type AnalyticsTelemetryLogger struct { @@ -63,6 +68,7 @@ func NewAnalyticsTelemetryClient(debug bool) (TelemetryClient, error) { client, err := analytics.NewWithConfig("", analytics.Config{ Endpoint: telemetryAnalyticsURL, Logger: newTelemetryLogger(debug), + Verbose: debug, }) if err != nil { return nil, err @@ -236,19 +242,30 @@ func (a *AnalyticsTelemetryClient) Identify(ctx context.Context) error { return a.client.Enqueue(identify) } -// Track tracks the event with the provided properties -func (a *AnalyticsTelemetryClient) Track(ctx context.Context, event string) error { +// Track tracks the event with the provided custom properties, merged with the +// base properties of the invocation +func (a *AnalyticsTelemetryClient) Track( + ctx context.Context, + event string, + properties map[string]any, +) error { metadata := GetEventMetadata(ctx) + props := make(map[string]any, len(properties)+5) + for k, v := range properties { + props[k] = v + } + // Base properties are set last so custom ones can never override them. + props["invocation_id"] = metadata.InvocationID + props["app_id"] = metadata.AppID + props["command"] = metadata.CommandPath + props["flags"] = metadata.CommandFlags + props["sequence"] = a.sequence.Add(1) + track := analytics.Track{ Event: event, AnonymousId: metadata.AnonymousID, - Properties: map[string]interface{}{ - "invocation_id": metadata.InvocationID, - "app_id": metadata.AppID, - "command": metadata.CommandPath, - "flags": metadata.CommandFlags, - }, + Properties: props, Context: &analytics.Context{ Device: analytics.DeviceInfo{ Id: metadata.AnonymousID, @@ -268,6 +285,14 @@ func (a *AnalyticsTelemetryClient) Close() { _ = a.client.Close() } -func (a *NoOpTelemetryClient) Identify(ctx context.Context) error { return nil } -func (a *NoOpTelemetryClient) Track(ctx context.Context, event string) error { return nil } -func (a *NoOpTelemetryClient) Close() {} +func (a *NoOpTelemetryClient) Identify(ctx context.Context) error { return nil } + +func (a *NoOpTelemetryClient) Track( + ctx context.Context, + event string, + properties map[string]any, +) error { + return nil +} + +func (a *NoOpTelemetryClient) Close() {} diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go index 8e381464..d9445ce2 100644 --- a/pkg/telemetry/telemetry_test.go +++ b/pkg/telemetry/telemetry_test.go @@ -2,6 +2,7 @@ package telemetry import ( "context" + "sync" "testing" "github.com/segmentio/analytics-go/v3" @@ -76,10 +77,13 @@ func TestSetUser(t *testing.T) { // fakeAnalyticsClient captures the messages enqueued by the telemetry client so // tests can assert on the payload without hitting the network. type fakeAnalyticsClient struct { + mu sync.Mutex messages []analytics.Message } func (f *fakeAnalyticsClient) Enqueue(msg analytics.Message) error { + f.mu.Lock() + defer f.mu.Unlock() f.messages = append(f.messages, msg) return nil } @@ -130,7 +134,7 @@ func TestTrack_IncludesUserWhenAuthenticated(t *testing.T) { metadata.SetUser("user-42", "user@test.com", "Test User") ctx := WithEventMetadata(context.Background(), metadata) - require.NoError(t, client.Track(ctx, "Command Invoked")) + require.NoError(t, client.Track(ctx, "Command Invoked", nil)) require.Len(t, fake.messages, 1) track, ok := fake.messages[0].(analytics.Track) @@ -146,10 +150,96 @@ func TestTrack_OmitsUserWhenAnonymous(t *testing.T) { metadata := NewEventMetadata() ctx := WithEventMetadata(context.Background(), metadata) - require.NoError(t, client.Track(ctx, "Command Invoked")) + require.NoError(t, client.Track(ctx, "Command Invoked", nil)) require.Len(t, fake.messages, 1) track, ok := fake.messages[0].(analytics.Track) require.True(t, ok) assert.Empty(t, track.UserId) } + +func TestTrack_MergesCustomProperties(t *testing.T) { + fake := &fakeAnalyticsClient{} + client := &AnalyticsTelemetryClient{client: fake} + + metadata := NewEventMetadata() + metadata.SetAppID("app-id") + ctx := WithEventMetadata(context.Background(), metadata) + + require.NoError(t, client.Track(ctx, "CLI Auth Started", map[string]any{"flow": "login"})) + require.Len(t, fake.messages, 1) + + track, ok := fake.messages[0].(analytics.Track) + require.True(t, ok) + assert.Equal(t, "login", track.Properties["flow"]) + assert.Equal(t, metadata.InvocationID, track.Properties["invocation_id"]) + assert.Equal(t, "app-id", track.Properties["app_id"]) +} + +func TestTrack_SequenceIsMonotonic(t *testing.T) { + fake := &fakeAnalyticsClient{} + client := &AnalyticsTelemetryClient{client: fake} + + metadata := NewEventMetadata() + ctx := WithEventMetadata(context.Background(), metadata) + + for i := 0; i < 3; i++ { + require.NoError(t, client.Track(ctx, "Command Invoked", nil)) + } + require.Len(t, fake.messages, 3) + + for i, msg := range fake.messages { + track, ok := msg.(analytics.Track) + require.True(t, ok) + assert.Equal(t, int64(i+1), track.Properties["sequence"]) + } +} + +func TestTrack_SequenceIsUniqueUnderConcurrency(t *testing.T) { + fake := &fakeAnalyticsClient{} + client := &AnalyticsTelemetryClient{client: fake} + + metadata := NewEventMetadata() + ctx := WithEventMetadata(context.Background(), metadata) + + const n = 100 + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = client.Track(ctx, "Command Invoked", nil) + }() + } + wg.Wait() + + require.Len(t, fake.messages, n) + seen := make(map[int64]bool, n) + for _, msg := range fake.messages { + track, ok := msg.(analytics.Track) + require.True(t, ok) + seq, ok := track.Properties["sequence"].(int64) + require.True(t, ok) + assert.False(t, seen[seq], "duplicate sequence %d", seq) + seen[seq] = true + } +} + +func TestTrack_CustomPropertiesCannotOverrideBase(t *testing.T) { + fake := &fakeAnalyticsClient{} + client := &AnalyticsTelemetryClient{client: fake} + + metadata := NewEventMetadata() + ctx := WithEventMetadata(context.Background(), metadata) + + require.NoError(t, client.Track(ctx, "Command Invoked", map[string]any{ + "invocation_id": "spoofed", + "sequence": int64(999), + })) + require.Len(t, fake.messages, 1) + + track, ok := fake.messages[0].(analytics.Track) + require.True(t, ok) + assert.Equal(t, metadata.InvocationID, track.Properties["invocation_id"]) + assert.Equal(t, int64(1), track.Properties["sequence"]) +} From ce4111ecaadfe5f34c1ce690573b3ae4e2401256 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Thu, 11 Jun 2026 12:46:41 -0700 Subject: [PATCH 2/5] feat(telemetry): auth flow events (#239) Co-authored-by: Claude Fable 5 --- pkg/auth/authenticate.go | 8 ++- pkg/auth/oauth_flow.go | 13 ++++- pkg/cmd/auth/login/login.go | 51 ++++++++++++++++- pkg/cmd/auth/login/login_test.go | 48 ++++++++++++++++ pkg/cmd/auth/logout/logout.go | 10 +++- pkg/cmd/auth/logout/logout_test.go | 73 ++++++++++++++++++++++++ pkg/telemetry/events.go | 65 +++++++++++++++++++++ pkg/telemetry/events_test.go | 41 +++++++++++++ pkg/telemetry/telemetry.go | 21 ------- pkg/telemetry/telemetrytest/recording.go | 45 +++++++++++++++ 10 files changed, 346 insertions(+), 29 deletions(-) create mode 100644 pkg/cmd/auth/logout/logout_test.go create mode 100644 pkg/telemetry/telemetrytest/recording.go diff --git a/pkg/auth/authenticate.go b/pkg/auth/authenticate.go index af0df076..3ca2f711 100644 --- a/pkg/auth/authenticate.go +++ b/pkg/auth/authenticate.go @@ -23,7 +23,9 @@ func EnsureAuthenticated( cs := io.ColorScheme() fmt.Fprintf(io.Out, "%s %s\n", cs.WarningIcon(), err) - return RunOAuth(io, client, false, true) + // No flow tracker: this re-authentication belongs to the calling flow, + // not to an `auth login` funnel. + return RunOAuth(io, client, false, true, nil) } // ReauthenticateIfExpired checks if err is a session-expired error from the API. @@ -41,5 +43,7 @@ func ReauthenticateIfExpired( ClearToken() fmt.Fprintf(io.Out, "%s Session expired.\n", cs.WarningIcon()) - return RunOAuth(io, client, false, true) + // No flow tracker: this re-authentication belongs to the calling flow, + // not to an `auth login` funnel. + return RunOAuth(io, client, false, true, nil) } diff --git a/pkg/auth/oauth_flow.go b/pkg/auth/oauth_flow.go index a4334f43..31d1ac29 100644 --- a/pkg/auth/oauth_flow.go +++ b/pkg/auth/oauth_flow.go @@ -8,6 +8,7 @@ import ( "github.com/algolia/cli/api/dashboard" "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/telemetry" ) // DefaultOAuthClientID is a public OAuth client ID (PKCE flow, not a secret). @@ -35,7 +36,15 @@ func OAuthClientID() string { // launched, e.g. SSH / containers). // // If signup is true the browser opens to the sign-up page. -func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBrowser bool) (string, error) { +// +// The optional tracker (nil-safe) records which step the flow is in, so the +// telemetry of the calling flow can tell where the user stopped. +func RunOAuth( + io *iostreams.IOStreams, + client *dashboard.Client, + signup, openBrowser bool, + tracker *telemetry.FlowTracker, +) (string, error) { cs := io.ColorScheme() redirectURI, resultCh, err := StartCallbackServer() @@ -69,6 +78,7 @@ func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBro } fmt.Fprintf(io.Out, "Waiting for authentication...\n") + tracker.SetStep(telemetry.StepBrowserWait) cbResult := <-resultCh if cbResult.Error != "" { @@ -78,6 +88,7 @@ func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBro return "", fmt.Errorf("no authorization code received") } + tracker.SetStep(telemetry.StepCodeExchange) io.StartProgressIndicatorWithLabel("Exchanging code for tokens") tokenResp, err := client.AuthorizationCodeGrant(cbResult.Code, codeVerifier, redirectURI) io.StopProgressIndicator() diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 7fdb1461..e3c844ac 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -92,17 +92,54 @@ func runLoginCmd(ctx context.Context, opts *LoginOptions) error { // 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(ctx context.Context, opts *LoginOptions, signup bool) error { + flow := telemetry.FlowLogin + if signup { + flow = telemetry.FlowSignup + } + tracker := telemetry.NewFlowTracker() + telemetry.TrackEvent(ctx, telemetry.AuthStarted(flow, opts.NoBrowser)) + + err := runOAuthFlowSteps(ctx, opts, signup, tracker) + trackOAuthFlowOutcome(ctx, flow, tracker, err) + return err +} + +// trackOAuthFlowOutcome reports how the auth flow ended: completed, aborted by +// the user, or failed. +func trackOAuthFlowOutcome( + ctx context.Context, + flow telemetry.Flow, + tracker *telemetry.FlowTracker, + err error, +) { + switch { + case err == nil: + telemetry.TrackEvent(ctx, telemetry.AuthCompleted(flow, tracker)) + case cmdutil.IsUserCancellation(err): + telemetry.TrackEvent(ctx, telemetry.AuthAborted(flow, tracker)) + default: + telemetry.TrackEvent(ctx, telemetry.AuthFailed(flow, tracker, err)) + } +} + +func runOAuthFlowSteps( + ctx context.Context, + opts *LoginOptions, + signup bool, + tracker *telemetry.FlowTracker, +) error { cs := opts.IO.ColorScheme() client := opts.NewDashboardClient(auth.OAuthClientID()) openBrowser := !opts.NoBrowser - accessToken, err := auth.RunOAuth(opts.IO, client, signup, openBrowser) + accessToken, err := auth.RunOAuth(opts.IO, client, signup, openBrowser, tracker) if err != nil { return err } identifyAuthenticatedUser(ctx) + tracker.SetStep(telemetry.StepAppsFetch) opts.IO.StartProgressIndicatorWithLabel("Fetching applications") apps, err := client.ListApplications(accessToken) opts.IO.StopProgressIndicator() @@ -115,11 +152,13 @@ func RunOAuthFlow(ctx context.Context, opts *LoginOptions, signup bool) error { if len(apps) == 0 { fmt.Fprintf(opts.IO.Out, "\n%s No applications found. Let's create one.\n", cs.WarningIcon()) + tracker.SetStep(telemetry.StepAppCreate) appDetails, err = apputil.CreateAndFetchApplication(opts.IO, client, accessToken, "", opts.AppName) if err != nil { return err } } else { + tracker.SetStep(telemetry.StepAppSelect) interactive := opts.IO.CanPrompt() app, err := selectApplication(opts, apps, interactive) if err != nil { @@ -139,15 +178,21 @@ func RunOAuthFlow(ctx context.Context, opts *LoginOptions, signup bool) error { profileName = appDetails.Name } + tracker.SetStep(telemetry.StepProfileConfigure) 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) + if !applyStoredIdentity(ctx) { + return + } + client := telemetry.GetTelemetryClient(ctx) + if client == nil { + return } + _ = client.Identify(ctx) } // applyStoredIdentity copies the persisted user identity from the stored token diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index ed818558..a1a45d70 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -2,6 +2,7 @@ package login import ( "context" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -13,6 +14,7 @@ import ( "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/iostreams" "github.com/algolia/cli/pkg/telemetry" + "github.com/algolia/cli/pkg/telemetry/telemetrytest" "github.com/algolia/cli/test" ) @@ -130,6 +132,52 @@ func TestApplyStoredIdentity_TokenWithoutIdentityReturnsFalse(t *testing.T) { assert.Empty(t, metadata.UserID) } +func TestTrackOAuthFlowOutcome(t *testing.T) { + tests := []struct { + name string + err error + wantEvent string + wantStep bool + }{ + { + name: "success", + err: nil, + wantEvent: telemetry.EventAuthCompleted, + }, + { + name: "user cancellation", + err: cmdutil.ErrCancel, + wantEvent: telemetry.EventAuthAborted, + wantStep: true, + }, + { + name: "failure", + err: errors.New("boom"), + wantEvent: telemetry.EventAuthFailed, + wantStep: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &telemetrytest.RecordingClient{} + ctx := telemetry.WithTelemetryClient(context.Background(), client) + tracker := telemetry.NewFlowTracker() + tracker.SetStep(telemetry.StepAppsFetch) + + trackOAuthFlowOutcome(ctx, telemetry.FlowLogin, tracker, tt.err) + + require.Len(t, client.Events, 1) + event := client.Events[0] + assert.Equal(t, tt.wantEvent, event.Name) + assert.Equal(t, telemetry.FlowLogin, event.Properties["flow"]) + if tt.wantStep { + assert.Equal(t, telemetry.StepAppsFetch, event.Properties["step"]) + } + }) + } +} + func TestSelectApplication_MultipleApps_NonInteractive_NoAppName(t *testing.T) { io, _, _, _ := iostreams.Test() opts := &LoginOptions{IO: io} diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 6205dc3e..1b0c7b7b 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -1,6 +1,7 @@ package logout import ( + "context" "fmt" "github.com/MakeNowJust/heredoc" @@ -10,6 +11,7 @@ import ( "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/pkg/validators" ) @@ -38,14 +40,14 @@ func NewLogoutCmd(f *cmdutil.Factory) *cobra.Command { `), Args: validators.NoArgs(), RunE: func(cmd *cobra.Command, args []string) error { - return runLogoutCmd(opts) + return runLogoutCmd(cmd.Context(), opts) }, } return cmd } -func runLogoutCmd(opts *LogoutOptions) error { +func runLogoutCmd(ctx context.Context, opts *LogoutOptions) error { cs := opts.IO.ColorScheme() stored := auth.LoadToken() @@ -68,6 +70,10 @@ func runLogoutCmd(opts *LogoutOptions) error { } } + // Tracked before clearing the local state, while the user identifier is + // still attached to the telemetry metadata. + telemetry.TrackEvent(ctx, telemetry.AuthLogout()) + auth.ClearToken() fmt.Fprintf(opts.IO.Out, "%s Signed out successfully.\n", cs.SuccessIcon()) diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go new file mode 100644 index 00000000..dc75bd27 --- /dev/null +++ b/pkg/cmd/auth/logout/logout_test.go @@ -0,0 +1,73 @@ +package logout + +import ( + "context" + "net/http" + "net/http/httptest" + "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/iostreams" + "github.com/algolia/cli/pkg/telemetry" + "github.com/algolia/cli/pkg/telemetry/telemetrytest" +) + +func newLogoutOpts(srv *httptest.Server) *LogoutOptions { + io, _, _, _ := iostreams.Test() + return &LogoutOptions{ + IO: io, + NewDashboardClient: func(string) *dashboard.Client { + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.DashboardURL = srv.URL + return c + }, + } +} + +func TestLogout_EmitsAuthLogoutBeforeClearingToken(t *testing.T) { + keyring.MockInit() + t.Cleanup(auth.ClearToken) + require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "access", + RefreshToken: "refresh", + ExpiresIn: 3600, + })) + + mux := http.NewServeMux() + mux.HandleFunc("/2/oauth/revoke", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + client := &telemetrytest.RecordingClient{} + ctx := telemetry.WithTelemetryClient(context.Background(), client) + + require.NoError(t, runLogoutCmd(ctx, newLogoutOpts(srv))) + + require.Len(t, client.Events, 1) + event := client.Events[0] + assert.Equal(t, telemetry.EventAuthLogout, event.Name) + assert.Equal(t, telemetry.FlowLogout, event.Properties["flow"]) + // The token is cleared after the event was tracked. + assert.Nil(t, auth.LoadToken()) + // No Identify at logout time: Segment cannot un-identify. + assert.Zero(t, client.Identifies) +} + +func TestLogout_AlreadySignedOutEmitsNothing(t *testing.T) { + keyring.MockInit() + auth.ClearToken() + + client := &telemetrytest.RecordingClient{} + ctx := telemetry.WithTelemetryClient(context.Background(), client) + + require.NoError(t, runLogoutCmd(ctx, newLogoutOpts(nil))) + + assert.Empty(t, client.Events) +} diff --git a/pkg/telemetry/events.go b/pkg/telemetry/events.go index 6d18201f..82fccbd6 100644 --- a/pkg/telemetry/events.go +++ b/pkg/telemetry/events.go @@ -1,6 +1,7 @@ package telemetry import ( + "context" "errors" "fmt" "time" @@ -17,6 +18,7 @@ const ( EventAuthCompleted = "CLI Auth Completed" EventAuthFailed = "CLI Auth Failed" EventAuthAborted = "CLI Auth Aborted" + EventAuthLogout = "CLI Auth Logout" EventApplicationCreateStarted = "CLI Application Create Started" EventApplicationCreateAcceptedTerms = "CLI Application Create Accepted Terms" @@ -39,6 +41,7 @@ type Flow string const ( FlowLogin Flow = "login" FlowSignup Flow = "signup" + FlowLogout Flow = "logout" ) // Step locates where the user is inside an interactive flow, so aborts and @@ -108,6 +111,68 @@ func (f *FlowTracker) DurationMS() int64 { return time.Since(f.start).Milliseconds() } +// Event is a fully assembled telemetry event, ready to be tracked. +type Event struct { + Name string + Properties map[string]any +} + +// TrackEvent sends the event through the context's telemetry client, silently +// doing nothing when no client is present. +func TrackEvent(ctx context.Context, event Event) { + client := GetTelemetryClient(ctx) + if client == nil { + return + } + _ = client.Track(ctx, event.Name, event.Properties) +} + +// AuthStarted is emitted when the browser-based OAuth flow begins. +func AuthStarted(flow Flow, noBrowser bool) Event { + return Event{EventAuthStarted, map[string]any{ + "flow": flow, + "no_browser": noBrowser, + }} +} + +// AuthCompleted is emitted when the profile is fully configured at the end of +// the auth flow. +func AuthCompleted(flow Flow, tracker *FlowTracker) Event { + return Event{EventAuthCompleted, map[string]any{ + "flow": flow, + "duration_ms": tracker.DurationMS(), + }} +} + +// AuthAborted is emitted when the user cancelled the auth flow, with the step +// they stopped at. +func AuthAborted(flow Flow, tracker *FlowTracker) Event { + return Event{EventAuthAborted, map[string]any{ + "flow": flow, + "step": tracker.Step(), + }} +} + +// AuthLogout is emitted when the user signs out. It must be tracked before +// the local state is cleared, while the user identifier is still attached to +// the telemetry metadata; no Identify follows, since Segment's identity graph +// has no concept of un-identifying. +func AuthLogout() Event { + return Event{EventAuthLogout, map[string]any{ + "flow": FlowLogout, + }} +} + +// AuthFailed is emitted when the auth flow failed, with the step it failed at. +func AuthFailed(flow Flow, tracker *FlowTracker, err error) Event { + return Event{EventAuthFailed, map[string]any{ + "flow": flow, + "step": tracker.Step(), + "duration_ms": tracker.DurationMS(), + "error_class": ErrorClass(err), + }} +} + // ErrorClass returns the type of the first informative error of the chain, // skipping the anonymous wrappers created by fmt.Errorf. It never returns an // error message, which could contain user data. diff --git a/pkg/telemetry/events_test.go b/pkg/telemetry/events_test.go index 61c5a9ee..7127d30e 100644 --- a/pkg/telemetry/events_test.go +++ b/pkg/telemetry/events_test.go @@ -1,6 +1,8 @@ package telemetry import ( + "context" + "errors" "fmt" "testing" @@ -30,6 +32,45 @@ func TestErrorClass_NilError(t *testing.T) { assert.Equal(t, "", ErrorClass(nil)) } +func TestTrackEvent_NoClientInContextIsSafe(t *testing.T) { + // Must not panic when the context carries no telemetry client. + TrackEvent(context.Background(), AuthStarted(FlowLogin, false)) +} + +func TestAuthStarted(t *testing.T) { + event := AuthStarted(FlowSignup, true) + assert.Equal(t, EventAuthStarted, event.Name) + assert.Equal(t, FlowSignup, event.Properties["flow"]) + assert.Equal(t, true, event.Properties["no_browser"]) +} + +func TestAuthCompleted(t *testing.T) { + event := AuthCompleted(FlowLogin, NewFlowTracker()) + assert.Equal(t, EventAuthCompleted, event.Name) + assert.Equal(t, FlowLogin, event.Properties["flow"]) + assert.Contains(t, event.Properties, "duration_ms") +} + +func TestAuthAborted(t *testing.T) { + tracker := NewFlowTracker() + tracker.SetStep(StepAppSelect) + + event := AuthAborted(FlowLogin, tracker) + assert.Equal(t, EventAuthAborted, event.Name) + assert.Equal(t, StepAppSelect, event.Properties["step"]) +} + +func TestAuthFailed(t *testing.T) { + tracker := NewFlowTracker() + tracker.SetStep(StepAppsFetch) + + event := AuthFailed(FlowLogin, tracker, errors.New("boom")) + assert.Equal(t, EventAuthFailed, event.Name) + assert.Equal(t, StepAppsFetch, event.Properties["step"]) + assert.Equal(t, "*errors.errorString", event.Properties["error_class"]) + assert.Contains(t, event.Properties, "duration_ms") +} + func TestFlowTracker_NilTrackerIsSafe(t *testing.T) { var tracker *FlowTracker tracker.SetStep(StepTerms) diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index 9330d055..d0a4c918 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -6,7 +6,6 @@ import ( "fmt" "log" "net" - "os" "runtime" "sync/atomic" @@ -76,26 +75,6 @@ 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() diff --git a/pkg/telemetry/telemetrytest/recording.go b/pkg/telemetry/telemetrytest/recording.go new file mode 100644 index 00000000..bea7ae86 --- /dev/null +++ b/pkg/telemetry/telemetrytest/recording.go @@ -0,0 +1,45 @@ +// Package telemetrytest provides test doubles for the telemetry package. +package telemetrytest + +import ( + "context" + "sync" + + "github.com/algolia/cli/pkg/telemetry" +) + +var _ telemetry.TelemetryClient = (*RecordingClient)(nil) + +// RecordedEvent is one event captured by a RecordingClient. +type RecordedEvent struct { + Name string + Properties map[string]any +} + +// RecordingClient is a telemetry.TelemetryClient that records the tracked +// events so tests can assert on their names, properties and order. +type RecordingClient struct { + mu sync.Mutex + Events []RecordedEvent + Identifies int +} + +func (r *RecordingClient) Identify(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + r.Identifies++ + return nil +} + +func (r *RecordingClient) Track( + ctx context.Context, + event string, + properties map[string]any, +) error { + r.mu.Lock() + defer r.mu.Unlock() + r.Events = append(r.Events, RecordedEvent{event, properties}) + return nil +} + +func (r *RecordingClient) Close() {} From dfa73ea95b5479c098db4127a3da51b7a9fedf55 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Thu, 11 Jun 2026 12:56:12 -0700 Subject: [PATCH 3/5] feat(telemetry): application create and plan change flow events (#240) Co-authored-by: Claude Fable 5 --- pkg/cmd/application/create/create.go | 161 +++++++++++++----- pkg/cmd/application/create/create_test.go | 92 ++++++++-- pkg/cmd/application/downgrade/downgrade.go | 2 +- pkg/cmd/application/planchange/planchange.go | 146 ++++++++++++++-- .../application/planchange/planchange_test.go | 108 ++++++++++-- pkg/cmd/application/upgrade/upgrade.go | 2 +- pkg/cmd/auth/login/login.go | 4 +- pkg/cmd/shared/apputil/create.go | 22 ++- pkg/cmd/shared/apputil/plan.go | 10 ++ pkg/telemetry/events.go | 135 +++++++++++++++ pkg/telemetry/events_test.go | 42 +++++ pkg/telemetry/telemetry.go | 37 +++- pkg/telemetry/telemetry_test.go | 39 +++++ 13 files changed, 698 insertions(+), 102 deletions(-) diff --git a/pkg/cmd/application/create/create.go b/pkg/cmd/application/create/create.go index ed623900..b1ac08cb 100644 --- a/pkg/cmd/application/create/create.go +++ b/pkg/cmd/application/create/create.go @@ -1,6 +1,7 @@ package create import ( + "context" "fmt" "strings" @@ -16,6 +17,7 @@ import ( "github.com/algolia/cli/pkg/iostreams" pkgopen "github.com/algolia/cli/pkg/open" "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/pkg/telemetry" "github.com/algolia/cli/pkg/validators" ) @@ -78,7 +80,7 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { }, RunE: func(cmd *cobra.Command, args []string) error { opts.nameProvided = cmd.Flags().Changed("name") - return runCreateCmd(opts) + return runCreateCmd(cmd.Context(), opts) }, } @@ -99,57 +101,119 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { return cmd } -func runCreateCmd(opts *CreateOptions) error { - cs := opts.IO.ColorScheme() +func runCreateCmd(ctx context.Context, opts *CreateOptions) error { + if opts.DryRun { + return printDryRunSummary(opts) + } + + tracker := telemetry.NewFlowTracker() + telemetry.TrackEvent(ctx, telemetry.ApplicationCreateStarted()) + + result, err := createApplication(ctx, opts, tracker) + trackCreateOutcome(ctx, tracker, result, err) + return err +} +// trackCreateOutcome reports how the creation flow ended: completed, aborted +// (with the reason why), or failed. +func trackCreateOutcome( + ctx context.Context, + tracker *telemetry.FlowTracker, + result createResult, + err error, +) { + switch { + case err == nil && result.created: + telemetry.TrackEvent( + ctx, + telemetry.ApplicationCreateCompleted(result.region, result.plan, tracker), + ) + case err == nil || result.abortReason != "" || cmdutil.IsUserCancellation(err): + // Stopped without creating anything: declined terms, billing wall, + // or user cancellation. + reason := result.abortReason + if reason == "" && cmdutil.IsUserCancellation(err) { + reason = telemetry.AbortReasonCancelled + } + telemetry.TrackEvent(ctx, telemetry.ApplicationCreateAborted(tracker, reason)) + default: + telemetry.TrackEvent(ctx, telemetry.ApplicationCreateFailed(tracker, err)) + } +} + +// printDryRunSummary prints what would be created without sending anything. +func printDryRunSummary(opts *CreateOptions) error { name, err := resolveName(opts) if err != nil { return err } - if opts.DryRun { - planLabel := opts.Plan - if planLabel == "" { - planLabel = dashboard.PlanTypeFree - } - summary := map[string]any{ - "action": "create_application", - "name": name, - "region": opts.Region, - "plan": planLabel, - "default": opts.Default, - "dryRun": true, - } - return cmdutil.PrintRunSummary( - opts.IO, - opts.PrintFlags, - summary, - fmt.Sprintf( - "Dry run: would create application %q in region %q on the %q plan", - name, - opts.Region, - planLabel, - ), - ) + planLabel := opts.Plan + if planLabel == "" { + planLabel = dashboard.PlanTypeFree + } + summary := map[string]any{ + "action": "create_application", + "name": name, + "region": opts.Region, + "plan": planLabel, + "default": opts.Default, + "dryRun": true, + } + return cmdutil.PrintRunSummary( + opts.IO, + opts.PrintFlags, + summary, + fmt.Sprintf( + "Dry run: would create application %q in region %q on the %q plan", + name, + opts.Region, + planLabel, + ), + ) +} + +// createResult carries what the creation flow produced, for telemetry. +type createResult struct { + created bool + region string + plan string + abortReason telemetry.AbortReason +} + +func createApplication( + ctx context.Context, + opts *CreateOptions, + tracker *telemetry.FlowTracker, +) (createResult, error) { + var result createResult + cs := opts.IO.ColorScheme() + + tracker.SetStep(telemetry.StepName) + name, err := resolveName(opts) + if err != nil { + return result, err } client := opts.NewDashboardClient(auth.OAuthClientID()) + tracker.SetStep(telemetry.StepAuth) token, err := auth.EnsureAuthenticated(opts.IO, client) if err != nil { - return err + return result, err } + tracker.SetStep(telemetry.StepPlan) var plans []dashboard.Plan if err := callWithReauth(opts.IO, client, &token, "Fetching plans", func(t string) error { var e error plans, e = client.GetSelfServePlans(t) return e }); err != nil { - return err + return result, err } if len(plans) == 0 { - return fmt.Errorf("no self-serve plans are available") + return result, fmt.Errorf("no self-serve plans are available") } // Best-effort: continue without billing status if /1/user fails. @@ -164,32 +228,48 @@ func runCreateCmd(opts *CreateOptions) error { target, err := selectPlan(opts, plans, user) if err != nil { - return err + return result, err } + result.plan = apputil.PlanTelemetryID(*target) if !target.IsFree() { billingMissing := !apputil.PlanAvailable(plans, target.ID) || (user != nil && !user.HasPaymentMethod) if billingMissing { - return offerBilling(opts, client, *target) + result.abortReason = telemetry.AbortReasonBillingRequired + return result, offerBilling(opts, client, *target) } } + tracker.SetStep(telemetry.StepTerms) accepted, err := confirmToS(opts, *target) if err != nil { - return err + return result, err } if !accepted { + telemetry.TrackEvent(ctx, telemetry.ApplicationCreateDeclinedTerms(result.plan)) fmt.Fprintf(opts.IO.Out, "%s Aborted; no application was created.\n", cs.WarningIcon()) - return nil + result.abortReason = telemetry.AbortReasonDeclinedTerms + return result, nil } + telemetry.TrackEvent(ctx, telemetry.ApplicationCreateAcceptedTerms(result.plan)) - appDetails, err := apputil.CreateAndFetchApplication(opts.IO, client, token, opts.Region, name) + appDetails, createdRegion, err := apputil.CreateAndFetchApplication( + opts.IO, + client, + token, + opts.Region, + name, + tracker, + ) if err != nil { - return err + return result, err } + result.created = true + result.region = createdRegion if !target.IsFree() { + tracker.SetStep(telemetry.StepApplyPlan) if err := callWithReauth(opts.IO, client, &token, "Applying plan", func(t string) error { _, e := client.ChangeApplicationPlan(t, appDetails.ID, target.ID) return e @@ -216,7 +296,7 @@ func runCreateCmd(opts *CreateOptions) error { opts.Default, ) } - return fmt.Errorf( + return result, fmt.Errorf( "failed to apply the %q plan to application %s: %w", target.Name, appDetails.ID, @@ -235,12 +315,13 @@ func runCreateCmd(opts *CreateOptions) error { if opts.structuredOutput() { p, err := opts.PrintFlags.ToPrinter() if err != nil { - return err + return result, err } - return p.Print(opts.IO, appDetails) + return result, p.Print(opts.IO, appDetails) } - return apputil.ConfigureProfile( + tracker.SetStep(telemetry.StepProfileConfigure) + return result, apputil.ConfigureProfile( opts.IO, opts.Config, appDetails, diff --git a/pkg/cmd/application/create/create_test.go b/pkg/cmd/application/create/create_test.go index 097c2adb..2153dc45 100644 --- a/pkg/cmd/application/create/create_test.go +++ b/pkg/cmd/application/create/create_test.go @@ -1,7 +1,9 @@ package create import ( + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -16,9 +18,67 @@ import ( "github.com/algolia/cli/pkg/auth" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/pkg/telemetry" + "github.com/algolia/cli/pkg/telemetry/telemetrytest" "github.com/algolia/cli/test" ) +func TestTrackCreateOutcome(t *testing.T) { + tests := []struct { + name string + result createResult + err error + wantEvent string + wantReason any + }{ + { + name: "created", + result: createResult{created: true, region: "us-east", plan: "free"}, + wantEvent: telemetry.EventApplicationCreateCompleted, + }, + { + name: "declined terms", + result: createResult{abortReason: telemetry.AbortReasonDeclinedTerms}, + wantEvent: telemetry.EventApplicationCreateAborted, + wantReason: telemetry.AbortReasonDeclinedTerms, + }, + { + name: "billing wall with error", + result: createResult{abortReason: telemetry.AbortReasonBillingRequired}, + err: errors.New("payment method required"), + wantEvent: telemetry.EventApplicationCreateAborted, + wantReason: telemetry.AbortReasonBillingRequired, + }, + { + name: "user cancellation", + err: cmdutil.ErrCancel, + wantEvent: telemetry.EventApplicationCreateAborted, + wantReason: telemetry.AbortReasonCancelled, + }, + { + name: "failure", + err: errors.New("boom"), + wantEvent: telemetry.EventApplicationCreateFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &telemetrytest.RecordingClient{} + ctx := telemetry.WithTelemetryClient(context.Background(), client) + + trackCreateOutcome(ctx, telemetry.NewFlowTracker(), tt.result, tt.err) + + require.Len(t, client.Events, 1) + event := client.Events[0] + assert.Equal(t, tt.wantEvent, event.Name) + if tt.wantReason != nil { + assert.Equal(t, tt.wantReason, event.Properties["reason"]) + } + }) + } +} + // seedToken installs an in-memory keyring with a valid token. func seedToken(t *testing.T) { t.Helper() @@ -212,7 +272,7 @@ func TestRun_FreeNonInteractive(t *testing.T) { opts, out, _ := newOpts(t, srv, false) opts.AcceptTerms = true - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 1, srv.createCalls) assert.Equal(t, 0, srv.patchCalls) assert.Contains(t, out.String(), "APP1") @@ -224,7 +284,7 @@ func TestRun_NonInteractiveRequiresAcceptTerms(t *testing.T) { opts, _, _ := newOpts(t, srv, false) - err := runCreateCmd(opts) + err := runCreateCmd(context.Background(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "must be accepted") assert.Equal(t, 0, srv.createCalls) @@ -238,7 +298,7 @@ func TestRun_PaidWithBillingNonInteractive(t *testing.T) { opts.Plan = "grow" opts.AcceptTerms = true - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 1, srv.createCalls) assert.Equal(t, 1, srv.patchCalls) assert.Equal(t, "grow", srv.lastPlan) @@ -252,7 +312,7 @@ func TestRun_PaidWithBillingRequiresAcceptTerms(t *testing.T) { opts, _, _ := newOpts(t, srv, false) opts.Plan = "grow" - err := runCreateCmd(opts) + err := runCreateCmd(context.Background(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "must be accepted") assert.Equal(t, 0, srv.createCalls) @@ -267,7 +327,7 @@ func TestRun_PaidNoBillingNonInteractive(t *testing.T) { opts.Plan = "grow" opts.AcceptTerms = true - err := runCreateCmd(opts) + err := runCreateCmd(context.Background(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "payment method") assert.Equal(t, 0, srv.createCalls) @@ -284,7 +344,7 @@ func TestRun_PaidNoBillingInteractiveOpensBilling(t *testing.T) { opts, _, opened := newOpts(t, srv, true) opts.Plan = "grow" - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 0, srv.createCalls) assert.Equal( t, @@ -302,7 +362,7 @@ func TestRun_PaidNoBillingInteractiveDeclineOpen(t *testing.T) { opts, _, opened := newOpts(t, srv, true) opts.Plan = "grow" - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 0, srv.createCalls) assert.Empty(t, *opened) } @@ -316,7 +376,7 @@ func TestRun_ToSDeclineAborts(t *testing.T) { opts, out, _ := newOpts(t, srv, true) opts.Plan = "free" - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 0, srv.createCalls) assert.Contains(t, out.String(), "Aborted") } @@ -332,7 +392,7 @@ func TestRun_AcceptTermsSkipsPromptInteractive(t *testing.T) { opts.Plan = "free" opts.AcceptTerms = true - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 1, srv.createCalls) assert.Contains(t, out.String(), "Terms accepted via --accept-terms") } @@ -346,7 +406,7 @@ func TestRun_PaidPlanHiddenByServerNonInteractive(t *testing.T) { opts.Plan = "grow" opts.AcceptTerms = true - err := runCreateCmd(opts) + err := runCreateCmd(context.Background(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "payment method") assert.NotContains(t, err.Error(), "invalid plan") @@ -366,7 +426,7 @@ func TestRun_PaidPlanHiddenByServerInteractiveOpensBilling(t *testing.T) { opts, _, opened := newOpts(t, srv, true) opts.Plan = "grow" - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 0, srv.createCalls) assert.Equal( t, @@ -383,7 +443,7 @@ func TestRun_InvalidPlanErrors(t *testing.T) { opts.Plan = "bogus" opts.AcceptTerms = true - err := runCreateCmd(opts) + err := runCreateCmd(context.Background(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "invalid plan") assert.Equal(t, 0, srv.createCalls) @@ -397,7 +457,7 @@ func TestRun_InteractivePickerHidesPaidWithoutBilling(t *testing.T) { opts, out, _ := newOpts(t, srv, true) - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 1, srv.createCalls) assert.Equal(t, 0, srv.patchCalls) assert.Contains(t, out.String(), "only the Free plan is available") @@ -418,7 +478,7 @@ func TestRun_InteractivePickerSelectsPaid(t *testing.T) { opts, out, _ := newOpts(t, srv, true) - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 1, srv.createCalls) assert.Equal(t, 1, srv.patchCalls) assert.Equal(t, "grow", srv.lastPlan) @@ -434,7 +494,7 @@ func TestRun_DryRunDoesNotCallAPI(t *testing.T) { opts.DryRun = true opts.PrintFlags = newPrintFlags("") - require.NoError(t, runCreateCmd(opts)) + require.NoError(t, runCreateCmd(context.Background(), opts)) assert.Equal(t, 0, srv.createCalls) assert.Equal(t, 0, srv.patchCalls) assert.Contains(t, out.String(), "Dry run") @@ -450,7 +510,7 @@ func TestRun_PlanChangeFailureKeepsFreeApp(t *testing.T) { opts.Plan = "grow" opts.AcceptTerms = true - err := runCreateCmd(opts) + err := runCreateCmd(context.Background(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "failed to apply") assert.Equal(t, 1, srv.createCalls) diff --git a/pkg/cmd/application/downgrade/downgrade.go b/pkg/cmd/application/downgrade/downgrade.go index 27013e51..69be2623 100644 --- a/pkg/cmd/application/downgrade/downgrade.go +++ b/pkg/cmd/application/downgrade/downgrade.go @@ -49,7 +49,7 @@ func NewDowngradeCmd(f *cmdutil.Factory) *cobra.Command { "skipAuthCheck": "true", }, RunE: func(cmd *cobra.Command, args []string) error { - return planchange.Run(opts) + return planchange.Run(cmd.Context(), opts) }, } diff --git a/pkg/cmd/application/planchange/planchange.go b/pkg/cmd/application/planchange/planchange.go index 9c604164..235c2e36 100644 --- a/pkg/cmd/application/planchange/planchange.go +++ b/pkg/cmd/application/planchange/planchange.go @@ -5,6 +5,7 @@ package planchange import ( + "context" "fmt" "strings" @@ -12,11 +13,13 @@ import ( "github.com/algolia/cli/api/dashboard" "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmd/shared/apputil" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" pkgopen "github.com/algolia/cli/pkg/open" "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/pkg/telemetry" ) // Direction selects whether the flow offers higher-tier (upgrade) or @@ -53,13 +56,84 @@ type changeResult struct { Price string `json:"price"` } +// telemetryDirection maps the flow direction to its telemetry value. +func (opts *Options) telemetryDirection() telemetry.Direction { + if opts.Direction == DirectionDowngrade { + return telemetry.DirectionDowngrade + } + return telemetry.DirectionUpgrade +} + +// planChangeResult carries what the plan change flow produced, for telemetry. +type planChangeResult struct { + changed bool + fromPlan string + toPlan string + abortReason telemetry.AbortReason +} + // Run executes the shared plan-change flow. -func Run(opts *Options) error { +func Run(ctx context.Context, opts *Options) error { + if opts.DryRun { + // A dry run is not a funnel: no events, no tracker. + _, err := changePlan(ctx, opts, nil) + return err + } + + direction := opts.telemetryDirection() + tracker := telemetry.NewFlowTracker() + telemetry.TrackEvent(ctx, telemetry.ApplicationPlanChangeStarted(direction)) + + result, err := changePlan(ctx, opts, tracker) + trackPlanChangeOutcome(ctx, direction, tracker, result, err) + return err +} + +// trackPlanChangeOutcome reports how the plan change flow ended: completed, +// aborted (with the reason why), or failed. +func trackPlanChangeOutcome( + ctx context.Context, + direction telemetry.Direction, + tracker *telemetry.FlowTracker, + result planChangeResult, + err error, +) { + switch { + case result.changed: + // The plan was changed: report Completed even when a post-success + // step (courtesy prompt, output printing) failed. + telemetry.TrackEvent( + ctx, + telemetry.ApplicationPlanChangeCompleted(direction, result.fromPlan, result.toPlan, tracker), + ) + case err == nil || result.abortReason != "" || cmdutil.IsUserCancellation(err): + // Stopped without changing anything: declined terms, already on the + // plan, nothing to change to, billing wall, or user cancellation. + reason := result.abortReason + if reason == "" && cmdutil.IsUserCancellation(err) { + reason = telemetry.AbortReasonCancelled + } + telemetry.TrackEvent( + ctx, + telemetry.ApplicationPlanChangeAborted(direction, tracker, reason), + ) + default: + telemetry.TrackEvent(ctx, telemetry.ApplicationPlanChangeFailed(direction, tracker, err)) + } +} + +func changePlan( + ctx context.Context, + opts *Options, + tracker *telemetry.FlowTracker, +) (planChangeResult, error) { + var result planChangeResult cs := opts.IO.ColorScheme() + tracker.SetStep(telemetry.StepAuth) appID, err := opts.Config.Profile().GetApplicationID() if err != nil { - return fmt.Errorf( + return result, fmt.Errorf( "no current application configured; configure a profile with \"algolia profile add\" or \"algolia application select\" first: %w", err, ) @@ -69,19 +143,20 @@ func Run(opts *Options) error { token, err := auth.EnsureAuthenticated(opts.IO, client) if err != nil { - return err + return result, err } + tracker.SetStep(telemetry.StepPlan) var plans []dashboard.Plan if err := callWithReauth(opts.IO, client, &token, "Fetching plans", func(t string) error { var e error plans, e = client.GetSelfServePlans(t) return e }); err != nil { - return err + return result, err } if len(plans) == 0 { - return fmt.Errorf("no self-serve plans are available") + return result, fmt.Errorf("no self-serve plans are available") } // Billing status is best-effort. If /1/user is unavailable we continue @@ -96,14 +171,19 @@ func Run(opts *Options) error { } app := fetchApplication(opts, client, &token, appID) + if app != nil { + result.fromPlan = currentPlanTelemetryID(plans, app) + } target, err := resolveTarget(opts, appID, app, plans) if err != nil { - return err + return result, err } if target == nil { - return nil + result.abortReason = telemetry.AbortReasonNoCandidates + return result, nil } + result.toPlan = apputil.PlanTelemetryID(*target) if isCurrentPlan(app, *target) { fmt.Fprintf( @@ -113,14 +193,16 @@ func Run(opts *Options) error { cs.Bold(appID), cs.Bold(target.Name), ) - return nil + result.abortReason = telemetry.AbortReasonAlreadyOnPlan + return result, nil } // Paid plans require a payment method that the CLI cannot collect. Only // block when we positively know there is none (user fetched, flag false); // otherwise defer to the server. if !target.IsFree() && user != nil && !user.HasPaymentMethod { - return fmt.Errorf( + result.abortReason = telemetry.AbortReasonBillingRequired + return result, fmt.Errorf( "the %q plan requires a payment method, which the CLI can't collect; add one in the Algolia dashboard (Settings → Billing) and try again", target.Name, ) @@ -133,7 +215,7 @@ func Run(opts *Options) error { "plan": target.ID, "dryRun": true, } - return cmdutil.PrintRunSummary( + return result, cmdutil.PrintRunSummary( opts.IO, opts.PrintFlags, summary, @@ -141,32 +223,50 @@ func Run(opts *Options) error { ) } + tracker.SetStep(telemetry.StepTerms) accepted, err := confirmToS(opts, *target) if err != nil { - return err + return result, err } if !accepted { + telemetry.TrackEvent( + ctx, + telemetry.ApplicationPlanChangeDeclinedTerms( + opts.telemetryDirection(), + apputil.PlanTelemetryID(*target), + ), + ) fmt.Fprintf( opts.IO.Out, "%s Plan change aborted; no changes were made.\n", cs.WarningIcon(), ) - return nil - } + result.abortReason = telemetry.AbortReasonDeclinedTerms + return result, nil + } + telemetry.TrackEvent( + ctx, + telemetry.ApplicationPlanChangeAcceptedTerms( + opts.telemetryDirection(), + apputil.PlanTelemetryID(*target), + ), + ) + tracker.SetStep(telemetry.StepAPICall) if err := callWithReauth(opts.IO, client, &token, "Changing plan", func(t string) error { _, e := client.ChangeApplicationPlan(t, appID, target.ID) return e }); err != nil { - return err + return result, err } + result.changed = true if opts.PrintFlags.OutputFlagSpecified() && opts.PrintFlags.OutputFormat != nil { p, err := opts.PrintFlags.ToPrinter() if err != nil { - return err + return result, err } - return p.Print(opts.IO, changeResult{ + return result, p.Print(opts.IO, changeResult{ ApplicationID: appID, Plan: target.ID, PlanName: target.Name, @@ -183,10 +283,10 @@ func Run(opts *Options) error { ) if target.IsFree() { - return nil + return result, nil } - return offerCostManagementBudget(opts, client.DashboardURL, appID) + return result, offerCostManagementBudget(opts, client.DashboardURL, appID) } // offerCostManagementBudget tells the user they can create a budget and, when @@ -294,6 +394,16 @@ func normalizePlanKey(s string) string { return strings.TrimSpace(strings.ToLower(s)) } +// currentPlanTelemetryID maps the app's current plan to the identifier used in +// telemetry properties, falling back to the normalized label when the plan is +// not in the self-serve list. +func currentPlanTelemetryID(plans []dashboard.Plan, app *dashboard.Application) string { + if idx := currentPlanIndex(plans, app); idx >= 0 { + return apputil.PlanTelemetryID(plans[idx]) + } + return normalizePlanKey(app.PlanLabel) +} + // currentPlanIndex returns the index of the app's current plan in plans, or -1. func currentPlanIndex(plans []dashboard.Plan, app *dashboard.Application) int { if app == nil { diff --git a/pkg/cmd/application/planchange/planchange_test.go b/pkg/cmd/application/planchange/planchange_test.go index bd637118..8906bcb6 100644 --- a/pkg/cmd/application/planchange/planchange_test.go +++ b/pkg/cmd/application/planchange/planchange_test.go @@ -1,7 +1,9 @@ package planchange import ( + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -16,9 +18,81 @@ import ( "github.com/algolia/cli/pkg/auth" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/pkg/telemetry" + "github.com/algolia/cli/pkg/telemetry/telemetrytest" "github.com/algolia/cli/test" ) +func TestTrackPlanChangeOutcome(t *testing.T) { + tests := []struct { + name string + result planChangeResult + err error + wantEvent string + wantReason any + }{ + { + name: "changed", + result: planChangeResult{changed: true, fromPlan: "free", toPlan: "grow"}, + wantEvent: telemetry.EventApplicationPlanChangeCompleted, + }, + { + // The change succeeded; cancelling the post-success courtesy + // prompt must not turn it into an abort. + name: "changed but cost-management prompt cancelled", + result: planChangeResult{changed: true, fromPlan: "free", toPlan: "grow"}, + err: cmdutil.ErrCancel, + wantEvent: telemetry.EventApplicationPlanChangeCompleted, + }, + { + name: "no candidates", + result: planChangeResult{abortReason: telemetry.AbortReasonNoCandidates}, + wantEvent: telemetry.EventApplicationPlanChangeAborted, + wantReason: telemetry.AbortReasonNoCandidates, + }, + { + name: "billing wall with error", + result: planChangeResult{abortReason: telemetry.AbortReasonBillingRequired}, + err: errors.New("payment method required"), + wantEvent: telemetry.EventApplicationPlanChangeAborted, + wantReason: telemetry.AbortReasonBillingRequired, + }, + { + name: "user cancellation", + err: cmdutil.ErrCancel, + wantEvent: telemetry.EventApplicationPlanChangeAborted, + wantReason: telemetry.AbortReasonCancelled, + }, + { + name: "failure", + err: errors.New("boom"), + wantEvent: telemetry.EventApplicationPlanChangeFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := &telemetrytest.RecordingClient{} + ctx := telemetry.WithTelemetryClient(context.Background(), client) + + trackPlanChangeOutcome( + ctx, + telemetry.DirectionUpgrade, + telemetry.NewFlowTracker(), + tt.result, + tt.err, + ) + + require.Len(t, client.Events, 1) + event := client.Events[0] + assert.Equal(t, tt.wantEvent, event.Name) + if tt.wantReason != nil { + assert.Equal(t, tt.wantReason, event.Properties["reason"]) + } + }) + } +} + // seedToken installs an in-memory keyring with a valid token so // auth.EnsureAuthenticated short-circuits without hitting the network. func seedToken(t *testing.T) { @@ -196,7 +270,7 @@ func TestRun_WithPlanFlag(t *testing.T) { opts.Plan = "grow" opts.AcceptTerms = true - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) assert.Equal(t, "grow", srv.lastPlan) assert.Contains(t, out.String(), "Grow") @@ -211,7 +285,7 @@ func TestRun_FreeTargetNotBilled(t *testing.T) { opts.Plan = "free" opts.AcceptTerms = true - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) // "free" maps to the free-type template, whose id is "build". assert.Equal(t, "build", srv.lastPlan) @@ -226,7 +300,7 @@ func TestRun_BillingBlock(t *testing.T) { opts.Plan = "grow" opts.AcceptTerms = true - err := Run(opts) + err := Run(context.Background(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "payment method") assert.Equal(t, 0, srv.patchCalls) @@ -241,7 +315,7 @@ func TestRun_ToSDeclineAborts(t *testing.T) { opts, out, _ := newOpts(t, srv, true) opts.Plan = "grow" - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 0, srv.patchCalls) assert.Contains(t, out.String(), "aborted") } @@ -253,7 +327,7 @@ func TestRun_NonInteractiveRequiresPlan(t *testing.T) { opts, _, _ := newOpts(t, srv, false) // No --plan and no TTY. - err := Run(opts) + err := Run(context.Background(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "--plan is required") assert.Equal(t, 0, srv.patchCalls) @@ -274,7 +348,7 @@ func TestRun_InteractivePicker(t *testing.T) { opts, out, _ := newOpts(t, srv, true) - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) assert.Equal(t, "grow", srv.lastPlan) assert.Contains(t, out.String(), "Current application: APP1 (My App)") @@ -288,7 +362,7 @@ func TestRun_DryRunDoesNotCallAPI(t *testing.T) { opts.Plan = "grow" opts.DryRun = true - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 0, srv.patchCalls) assert.Contains(t, out.String(), "Dry run") assert.Contains(t, out.String(), "Grow") @@ -303,7 +377,7 @@ func TestRun_OfferCostManagementBudget(t *testing.T) { opts, out, opened := newOpts(t, srv, true) opts.Plan = "grow" - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) assert.Contains(t, out.String(), "create a budget") assert.Equal( @@ -323,7 +397,7 @@ func TestRun_FreePlanSkipsCostManagementBudget(t *testing.T) { opts.Plan = "free" opts.AcceptTerms = true - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) assert.NotContains(t, out.String(), "create a budget") assert.Empty(t, *opened) @@ -338,7 +412,7 @@ func TestRun_OutputJSON(t *testing.T) { opts.AcceptTerms = true opts.PrintFlags = newPrintFlags("json") - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) assert.Contains(t, out.String(), `"plan":"grow"`) assert.Contains(t, out.String(), `"application_id":"APP1"`) @@ -355,7 +429,7 @@ func TestRun_UpgradeFiltersToHigherPlans(t *testing.T) { opts, out, _ := newOpts(t, srv, true) opts.Direction = DirectionUpgrade - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) assert.Equal(t, "grow-plus", srv.lastPlan) assert.Contains(t, out.String(), "current plan: Grow") @@ -372,7 +446,7 @@ func TestRun_DowngradeFiltersToLowerPlans(t *testing.T) { opts, _, _ := newOpts(t, srv, true) opts.Direction = DirectionDowngrade - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) assert.Equal(t, "build", srv.lastPlan) } @@ -385,7 +459,7 @@ func TestRun_UpgradeAtHighestPlanIsNoOp(t *testing.T) { opts, out, _ := newOpts(t, srv, true) opts.Direction = DirectionUpgrade - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 0, srv.patchCalls) assert.Contains(t, out.String(), "already on the highest") assert.Contains(t, out.String(), "nothing to upgrade") @@ -399,7 +473,7 @@ func TestRun_DowngradeAtLowestPlanIsNoOp(t *testing.T) { opts, out, _ := newOpts(t, srv, true) opts.Direction = DirectionDowngrade - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 0, srv.patchCalls) assert.Contains(t, out.String(), "already on the lowest") assert.Contains(t, out.String(), "nothing to downgrade") @@ -417,7 +491,7 @@ func TestRun_PlanFlagOverridesDirection(t *testing.T) { opts.Plan = "free" opts.AcceptTerms = true - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) assert.Equal(t, "build", srv.lastPlan) } @@ -431,7 +505,7 @@ func TestRun_SamePlanIsNoOp(t *testing.T) { opts.Plan = "grow" opts.AcceptTerms = true - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 0, srv.patchCalls) assert.Contains(t, out.String(), "already on the Grow plan") assert.Contains(t, out.String(), "no change needed") @@ -448,7 +522,7 @@ func TestRun_UnknownCurrentPlanShowsAllPlans(t *testing.T) { opts, _, _ := newOpts(t, srv, true) opts.Direction = DirectionUpgrade - require.NoError(t, Run(opts)) + require.NoError(t, Run(context.Background(), opts)) assert.Equal(t, 1, srv.patchCalls) assert.Equal(t, "build", srv.lastPlan) } diff --git a/pkg/cmd/application/upgrade/upgrade.go b/pkg/cmd/application/upgrade/upgrade.go index ed1cd632..1eee1c7e 100644 --- a/pkg/cmd/application/upgrade/upgrade.go +++ b/pkg/cmd/application/upgrade/upgrade.go @@ -50,7 +50,7 @@ func NewUpgradeCmd(f *cmdutil.Factory) *cobra.Command { "skipAuthCheck": "true", }, RunE: func(cmd *cobra.Command, args []string) error { - return planchange.Run(opts) + return planchange.Run(cmd.Context(), opts) }, } diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index e3c844ac..7506b7e2 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -153,7 +153,9 @@ func runOAuthFlowSteps( fmt.Fprintf(opts.IO.Out, "\n%s No applications found. Let's create one.\n", cs.WarningIcon()) tracker.SetStep(telemetry.StepAppCreate) - appDetails, err = apputil.CreateAndFetchApplication(opts.IO, client, accessToken, "", opts.AppName) + // No create tracker: this creation belongs to the auth funnel, which + // stays on the app_create step. + appDetails, _, err = apputil.CreateAndFetchApplication(opts.IO, client, accessToken, "", opts.AppName, nil) if err != nil { return err } diff --git a/pkg/cmd/shared/apputil/create.go b/pkg/cmd/shared/apputil/create.go index a5d217ee..6f384ac9 100644 --- a/pkg/cmd/shared/apputil/create.go +++ b/pkg/cmd/shared/apputil/create.go @@ -11,21 +11,27 @@ 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" ) // CreateApplicationWithRetry creates an application, retrying with a different // region if the selected one has no available cluster. +// +// The optional tracker (nil-safe) records which step the flow is in, so the +// telemetry of the calling flow can tell where the user stopped. func CreateApplicationWithRetry( io *iostreams.IOStreams, client *dashboard.Client, accessToken string, region string, appName string, + tracker *telemetry.FlowTracker, ) (*dashboard.Application, string, error) { cs := io.ColorScheme() for { if region == "" { + tracker.SetStep(telemetry.StepRegion) var err error region, err = PromptRegion(io, client, accessToken) if err != nil { @@ -33,6 +39,7 @@ func CreateApplicationWithRetry( } } + tracker.SetStep(telemetry.StepAPICall) io.StartProgressIndicatorWithLabel("Creating application") app, err := client.CreateApplication(accessToken, region, appName) io.StopProgressIndicator() @@ -101,22 +108,25 @@ func PromptRegion( } // CreateAndFetchApplication creates an application (with region retry) and -// generates an API key for it. +// generates an API key for it. It returns the region the application was +// actually created in, which may differ from the requested one. func CreateAndFetchApplication( io *iostreams.IOStreams, client *dashboard.Client, accessToken, region, appName string, -) (*dashboard.Application, error) { - app, _, err := CreateApplicationWithRetry(io, client, accessToken, region, appName) + tracker *telemetry.FlowTracker, +) (*dashboard.Application, string, error) { + app, createdRegion, err := CreateApplicationWithRetry(io, client, accessToken, region, appName, tracker) if err != nil { - return nil, err + return nil, "", err } + tracker.SetStep(telemetry.StepAPIKey) if err := EnsureAPIKey(io, client, accessToken, app); err != nil { - return nil, err + return nil, "", err } - return app, nil + return app, createdRegion, nil } // EnsureAPIKey generates a write API key for the application. diff --git a/pkg/cmd/shared/apputil/plan.go b/pkg/cmd/shared/apputil/plan.go index 4c514156..45ac0311 100644 --- a/pkg/cmd/shared/apputil/plan.go +++ b/pkg/cmd/shared/apputil/plan.go @@ -75,6 +75,16 @@ func PlanChoices(plans []dashboard.Plan) []string { return choices } +// PlanTelemetryID returns the plan identifier used in telemetry properties: +// the user-facing "free" for the free tier (whose underlying template id is +// not fixed and can be "build"), the plan id otherwise. +func PlanTelemetryID(p dashboard.Plan) string { + if p.IsFree() { + return dashboard.PlanTypeFree + } + return p.ID +} + // SelectablePlans returns the plans a user may choose from. When hideNonFree is // true (no payment method on file) only the free plan(s) are offered, because // paid plans require billing details the CLI can't collect. diff --git a/pkg/telemetry/events.go b/pkg/telemetry/events.go index 82fccbd6..e146aeff 100644 --- a/pkg/telemetry/events.go +++ b/pkg/telemetry/events.go @@ -58,11 +58,13 @@ const ( StepProfileConfigure Step = "profile_configure" // Application create and plan change flow steps. + StepAuth Step = "auth" StepName Step = "name" StepPlan Step = "plan" StepTerms Step = "terms" StepRegion Step = "region" StepAPICall Step = "api_call" + StepAPIKey Step = "api_key" StepApplyPlan Step = "apply_plan" ) @@ -74,6 +76,17 @@ const ( DirectionDowngrade Direction = "downgrade" ) +// AbortReason tells why the user stopped a flow. +type AbortReason string + +const ( + AbortReasonDeclinedTerms AbortReason = "declined_terms" + AbortReasonCancelled AbortReason = "cancelled" + AbortReasonBillingRequired AbortReason = "billing_required" + AbortReasonAlreadyOnPlan AbortReason = "already_on_plan" + AbortReasonNoCandidates AbortReason = "no_candidates" +) + // FlowTracker carries the state of one interactive flow: the step the user is // currently in and the flow start time, to compute durations. All its methods // are safe on a nil tracker, so helpers shared by several flows can take an @@ -173,6 +186,128 @@ func AuthFailed(flow Flow, tracker *FlowTracker, err error) Event { }} } +// ApplicationCreateStarted is emitted when the application creation flow +// begins (dry runs are not tracked). +func ApplicationCreateStarted() Event { + return Event{EventApplicationCreateStarted, nil} +} + +// ApplicationCreateAcceptedTerms is emitted when the user accepted the terms +// of the selected plan. +func ApplicationCreateAcceptedTerms(plan string) Event { + return Event{EventApplicationCreateAcceptedTerms, map[string]any{ + "plan": plan, + }} +} + +// ApplicationCreateDeclinedTerms is emitted when the user declined the terms +// of the selected plan. +func ApplicationCreateDeclinedTerms(plan string) Event { + return Event{EventApplicationCreateDeclinedTerms, map[string]any{ + "plan": plan, + }} +} + +// ApplicationCreateCompleted is emitted when the application was created, with +// the region and plan the user chose. +func ApplicationCreateCompleted(region, plan string, tracker *FlowTracker) Event { + return Event{EventApplicationCreateCompleted, map[string]any{ + "region": region, + "plan": plan, + "duration_ms": tracker.DurationMS(), + }} +} + +// ApplicationCreateAborted is emitted when the user stopped the creation flow, +// with the step they stopped at and the reason why. +func ApplicationCreateAborted(tracker *FlowTracker, reason AbortReason) Event { + props := map[string]any{ + "step": tracker.Step(), + } + if reason != "" { + props["reason"] = reason + } + return Event{EventApplicationCreateAborted, props} +} + +// ApplicationCreateFailed is emitted when the creation flow failed, with the +// step it failed at. +func ApplicationCreateFailed(tracker *FlowTracker, err error) Event { + return Event{EventApplicationCreateFailed, map[string]any{ + "step": tracker.Step(), + "duration_ms": tracker.DurationMS(), + "error_class": ErrorClass(err), + }} +} + +// ApplicationPlanChangeStarted is emitted when the plan change flow begins +// (dry runs are not tracked). +func ApplicationPlanChangeStarted(direction Direction) Event { + return Event{EventApplicationPlanChangeStarted, map[string]any{ + "direction": direction, + }} +} + +// ApplicationPlanChangeAcceptedTerms is emitted when the user accepted the +// terms of the target plan. +func ApplicationPlanChangeAcceptedTerms(direction Direction, plan string) Event { + return Event{EventApplicationPlanChangeAcceptedTerms, map[string]any{ + "direction": direction, + "plan": plan, + }} +} + +// ApplicationPlanChangeDeclinedTerms is emitted when the user declined the +// terms of the target plan. +func ApplicationPlanChangeDeclinedTerms(direction Direction, plan string) Event { + return Event{EventApplicationPlanChangeDeclinedTerms, map[string]any{ + "direction": direction, + "plan": plan, + }} +} + +// ApplicationPlanChangeCompleted is emitted when the plan was changed. +func ApplicationPlanChangeCompleted( + direction Direction, + fromPlan, toPlan string, + tracker *FlowTracker, +) Event { + return Event{EventApplicationPlanChangeCompleted, map[string]any{ + "direction": direction, + "from_plan": fromPlan, + "to_plan": toPlan, + "duration_ms": tracker.DurationMS(), + }} +} + +// ApplicationPlanChangeAborted is emitted when the user stopped the plan +// change flow, with the step they stopped at and the reason why. +func ApplicationPlanChangeAborted( + direction Direction, + tracker *FlowTracker, + reason AbortReason, +) Event { + props := map[string]any{ + "direction": direction, + "step": tracker.Step(), + } + if reason != "" { + props["reason"] = reason + } + return Event{EventApplicationPlanChangeAborted, props} +} + +// ApplicationPlanChangeFailed is emitted when the plan change flow failed, +// with the step it failed at. +func ApplicationPlanChangeFailed(direction Direction, tracker *FlowTracker, err error) Event { + return Event{EventApplicationPlanChangeFailed, map[string]any{ + "direction": direction, + "step": tracker.Step(), + "duration_ms": tracker.DurationMS(), + "error_class": ErrorClass(err), + }} +} + // ErrorClass returns the type of the first informative error of the chain, // skipping the anonymous wrappers created by fmt.Errorf. It never returns an // error message, which could contain user data. diff --git a/pkg/telemetry/events_test.go b/pkg/telemetry/events_test.go index 7127d30e..109889f7 100644 --- a/pkg/telemetry/events_test.go +++ b/pkg/telemetry/events_test.go @@ -71,6 +71,48 @@ func TestAuthFailed(t *testing.T) { assert.Contains(t, event.Properties, "duration_ms") } +func TestApplicationCreateCompleted(t *testing.T) { + event := ApplicationCreateCompleted("us-east", "grow", NewFlowTracker()) + assert.Equal(t, EventApplicationCreateCompleted, event.Name) + assert.Equal(t, "us-east", event.Properties["region"]) + assert.Equal(t, "grow", event.Properties["plan"]) + assert.Contains(t, event.Properties, "duration_ms") +} + +func TestApplicationPlanChangeAborted_WithReason(t *testing.T) { + tracker := NewFlowTracker() + tracker.SetStep(StepPlan) + + event := ApplicationPlanChangeAborted(DirectionUpgrade, tracker, AbortReasonAlreadyOnPlan) + assert.Equal(t, EventApplicationPlanChangeAborted, event.Name) + assert.Equal(t, DirectionUpgrade, event.Properties["direction"]) + assert.Equal(t, StepPlan, event.Properties["step"]) + assert.Equal(t, AbortReasonAlreadyOnPlan, event.Properties["reason"]) +} + +func TestApplicationPlanChangeAborted_WithoutReason(t *testing.T) { + event := ApplicationPlanChangeAborted(DirectionDowngrade, NewFlowTracker(), "") + assert.NotContains(t, event.Properties, "reason") +} + +func TestApplicationCreateAborted_WithReason(t *testing.T) { + tracker := NewFlowTracker() + tracker.SetStep(StepTerms) + + event := ApplicationCreateAborted(tracker, AbortReasonDeclinedTerms) + assert.Equal(t, EventApplicationCreateAborted, event.Name) + assert.Equal(t, StepTerms, event.Properties["step"]) + assert.Equal(t, AbortReasonDeclinedTerms, event.Properties["reason"]) +} + +func TestApplicationPlanChangeCompleted(t *testing.T) { + event := ApplicationPlanChangeCompleted(DirectionUpgrade, "free", "grow", NewFlowTracker()) + assert.Equal(t, EventApplicationPlanChangeCompleted, event.Name) + assert.Equal(t, "free", event.Properties["from_plan"]) + assert.Equal(t, "grow", event.Properties["to_plan"]) + assert.Contains(t, event.Properties, "duration_ms") +} + func TestFlowTracker_NilTrackerIsSafe(t *testing.T) { var tracker *FlowTracker tracker.SetStep(StepTerms) diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index d0a4c918..ff0168ec 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net" + "net/http" "runtime" "sync/atomic" @@ -19,8 +20,10 @@ import ( ) const ( - AppName = "cli" - telemetryAnalyticsURL = "https://telemetry-proxy.algolia.com/" + AppName = "cli" + // No trailing slash: analytics-go appends "/v1/batch" to the endpoint. + telemetryAnalyticsURL = "https://telemetry-proxy.algolia.com" + envHeader = "X-Algolia-CLI-Env" ) type telemetryMetadataKey struct{} @@ -68,6 +71,10 @@ func NewAnalyticsTelemetryClient(debug bool) (TelemetryClient, error) { Endpoint: telemetryAnalyticsURL, Logger: newTelemetryLogger(debug), Verbose: debug, + Transport: &envHeaderTransport{ + base: http.DefaultTransport, + env: telemetryEnv(), + }, }) if err != nil { return nil, err @@ -75,6 +82,32 @@ func NewAnalyticsTelemetryClient(debug bool) (TelemetryClient, error) { return &AnalyticsTelemetryClient{client: client}, nil } +// envHeaderTransport adds the X-Algolia-CLI-Env header to every telemetry +// request, so the proxy can route release builds to the production Segment +// source and everything else to the development one. Until the proxy routes +// on the header, all events keep going to the development source. +type envHeaderTransport struct { + base http.RoundTripper + env string +} + +func (t *envHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // RoundTrippers must not mutate the caller's request. + req = req.Clone(req.Context()) + req.Header.Set(envHeader, t.env) + return t.base.RoundTrip(req) +} + +// telemetryEnv reports which environment the events belong to: "prod" for +// release builds (goreleaser injects a semver version), "dev" for source +// builds (the version stays "main"). +func telemetryEnv() string { + if version.Version == "main" { + return "dev" + } + return "prod" +} + // 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() diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go index d9445ce2..95816218 100644 --- a/pkg/telemetry/telemetry_test.go +++ b/pkg/telemetry/telemetry_test.go @@ -2,6 +2,7 @@ package telemetry import ( "context" + "net/http" "sync" "testing" @@ -9,8 +10,46 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/version" ) +// captureTransport records the request it receives without hitting the network. +type captureTransport struct { + req *http.Request +} + +func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + c.req = req + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func TestEnvHeaderTransport_SetsHeaderWithoutMutatingRequest(t *testing.T) { + capture := &captureTransport{} + transport := &envHeaderTransport{base: capture, env: "prod"} + + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/batch", nil) + require.NoError(t, err) + + _, err = transport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "prod", capture.req.Header.Get(envHeader)) + // RoundTrippers must not mutate the caller's request. + assert.Empty(t, req.Header.Get(envHeader)) +} + +func TestTelemetryEnv(t *testing.T) { + orig := version.Version + t.Cleanup(func() { version.Version = orig }) + + version.Version = "main" + assert.Equal(t, "dev", telemetryEnv()) + + version.Version = "1.20.0" + assert.Equal(t, "prod", telemetryEnv()) +} + // Context-related tests. func TestEventMetadataWithGet(t *testing.T) { ctx := context.Background() From 4309960bbf1b2ac71af7f5960fa12330ab4aad8e Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Fri, 12 Jun 2026 13:27:43 -0700 Subject: [PATCH 4/5] feat(identify): send identify in the same process after mid-command login Co-Authored-By: Claude Fable 5 --- pkg/cmd/root/root.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 92b7e5b6..57c28a4c 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -219,6 +219,7 @@ func Execute() (code exitCode) { // includes the update-notifier wait below. cmd, err := rootCmd.ExecuteContextC(ctx) executedCmd, executeErr, elapsed = cmd, err, time.Since(start) + identifyNewlyAuthenticatedUser(ctx, cmd) // Handle eventual errors. if err != nil { if err == cmdutil.ErrSilent { @@ -256,6 +257,33 @@ func Execute() (code exitCode) { return exitOK } +// identifyNewlyAuthenticatedUser re-sends an Identify when the user signed in +// during the command (e.g. `application create` while logged out): the +// Identify from PersistentPreRunE went out anonymous, so the identity would +// otherwise only ship on the next invocation. Runs before the deferred +// Command Completed so that event carries the user too. +func identifyNewlyAuthenticatedUser(ctx context.Context, cmd *cobra.Command) { + if cmd == nil || !cmdutil.ShouldTrackUsage(cmd) { + return + } + // Same gating as trackCommandCompleted: an empty command path means + // PersistentPreRunE never ran, so no login could have happened either. + metadata := telemetry.GetEventMetadata(ctx) + if metadata == nil || metadata.CommandPath == "" || metadata.UserID != "" { + return + } + token := auth.LoadToken() + if token == nil || token.UserID == "" { + return + } + metadata.SetUser(token.UserID, token.Email, token.Name) + client := telemetry.GetTelemetryClient(ctx) + if client == nil { + return + } + _ = client.Identify(ctx) +} + // trackCommandCompleted reports how the command ended: success, failure (with // the class of the error) or user cancellation. func trackCommandCompleted( From 8f6561e7fc8d416c7a7f076025faa5633ea7f0f7 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Fri, 12 Jun 2026 13:27:48 -0700 Subject: [PATCH 5/5] feat(application): show current application before plan change terms Co-Authored-By: Claude Fable 5 --- pkg/cmd/application/planchange/planchange.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/application/planchange/planchange.go b/pkg/cmd/application/planchange/planchange.go index 235c2e36..b51c0da3 100644 --- a/pkg/cmd/application/planchange/planchange.go +++ b/pkg/cmd/application/planchange/planchange.go @@ -223,6 +223,12 @@ func changePlan( ) } + // With --plan the interactive picker (which shows the current application) + // is skipped; show which application the terms apply to before asking. + if opts.Plan != "" { + printCurrentApplication(opts, appID, app) + } + tracker.SetStep(telemetry.StepTerms) accepted, err := confirmToS(opts, *target) if err != nil { @@ -457,7 +463,8 @@ func reportNoCandidates( ) } -// printCurrentApplication prints the current app and plan before the picker. +// printCurrentApplication prints the current app and plan before the picker +// or, with --plan, before the terms confirmation. func printCurrentApplication(opts *Options, appID string, app *dashboard.Application) { cs := opts.IO.ColorScheme() label := cs.Bold(appID)