diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go index 7bcfbe79c..21c10b350 100644 --- a/internal/analytics/analytics.go +++ b/internal/analytics/analytics.go @@ -43,12 +43,12 @@ func NewTracker() *Tracker { return &Tracker{} } -func (t *Tracker) TrackFirstLogin(id string, loginType string) { +func (t *Tracker) TrackFirstLogin(id string, loginType string, properties map[string]string) { eventName := fmt.Sprintf("%s - Auth0 - First Login", eventNamePrefix) - t.track(eventName, id, nil) + t.track(eventName, id, properties) eventName = fmt.Sprintf("%s - Auth0 - First Login - %s", eventNamePrefix, loginType) - t.track(eventName, id, nil) + t.track(eventName, id, properties) } func (t *Tracker) TrackCommandRun(commandPath string, id string, properties map[string]string) { diff --git a/internal/cli/login.go b/internal/cli/login.go index 169fc3ae3..047c38b41 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -377,7 +377,7 @@ func RunLoginAsUser(ctx context.Context, cli *cli, additionalScopes []string, do } cli.renderer.Newline() - cli.tracker.TrackFirstLogin(cli.Config.InstallID, "As-User") + cli.tracker.TrackFirstLogin(cli.Config.InstallID, "As-User", loginTrackingProperties(cli, result.Domain)) if cli.renderer.AgentMode { // The "Successfully logged in" lines above are suppressed in agent mode, so @@ -487,7 +487,7 @@ func RunLoginAsMachineSecret(ctx context.Context, inputs LoginInputs, cli *cli, cli.renderer.Infof("Successfully logged in.") cli.renderer.Infof("Tenant: %s", inputs.Domain) - cli.tracker.TrackFirstLogin(cli.Config.InstallID, "As-Machine") + cli.tracker.TrackFirstLogin(cli.Config.InstallID, "As-Machine", loginTrackingProperties(cli, inputs.Domain)) return nil } @@ -553,7 +553,7 @@ func RunLoginAsMachineJWT(ctx context.Context, inputs LoginInputs, cli *cli, cmd cli.renderer.Infof("Successfully logged in.") cli.renderer.Infof("Tenant: %s", inputs.Domain) - cli.tracker.TrackFirstLogin(cli.Config.InstallID, "As-Machine") + cli.tracker.TrackFirstLogin(cli.Config.InstallID, "As-Machine", loginTrackingProperties(cli, inputs.Domain)) return nil } diff --git a/internal/cli/root.go b/internal/cli/root.go index 7fbba5d11..c8567653d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -738,17 +738,26 @@ func trackCommandOutcome(cli *cli, executionErr error) { } func commandTrackingProperties(cli *cli) map[string]string { - interactive := iostream.IsInputTerminal() && iostream.IsOutputTerminal() - - return map[string]string{ - "interactive": boolString(interactive), - "ci": boolString(isCIEnvironment(os.Getenv)), + return mergeProperties(contextTrackingProperties(cli), map[string]string{ "no_input": boolString(cli.noInput), "output_format": outputFormatForTracking(cli.renderer), "forced": boolString(cli.force), - "agent_client": cli.agentClientName(), "is_api": boolString(isAPICommand(cli.executedCommandPath)), "tenant": cli.tenant, + }) +} + +// loginTrackingProperties attributes a login to its tenant, CI and agent context. +func loginTrackingProperties(cli *cli, domain string) map[string]string { + return mergeProperties(contextTrackingProperties(cli), map[string]string{"tenant": domain}) +} + +// contextTrackingProperties reports how the CLI was invoked, shared by command-run and login events. +func contextTrackingProperties(cli *cli) map[string]string { + return map[string]string{ + "interactive": boolString(iostream.IsInputTerminal() && iostream.IsOutputTerminal()), + "ci": boolString(isCIEnvironment(os.Getenv)), + "agent_client": cli.agentClientName(), } } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index d2d78c61a..b91403ebc 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "os" "strings" "testing" @@ -14,6 +15,7 @@ import ( "github.com/auth0/auth0-cli/internal/config" "github.com/auth0/auth0-cli/internal/display" + "github.com/auth0/auth0-cli/internal/iostream" ) type testManagementError struct { @@ -374,6 +376,60 @@ func TestCommandTrackingProperties(t *testing.T) { }) } +func TestLoginTrackingProperties(t *testing.T) { + tests := []struct { + name string + ciEnv string + wantCI string + }{ + {"outside CI", "", "false"}, + {"inside CI", "true", "true"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for _, envVar := range ciEnvironmentVariables { + t.Setenv(envVar, "") + } + t.Setenv("CI", test.ciEnv) + setNonTerminalIO(t) + + props := loginTrackingProperties(&cli{detectedAgent: "claude-code"}, "example.us.auth0.com") + + assert.Equal(t, map[string]string{ + "tenant": "example.us.auth0.com", + "ci": test.wantCI, + "agent_client": "claude-code", + "interactive": "false", + }, props) + }) + } +} + +func TestLoginAndCommandTrackingPropertiesShareContext(t *testing.T) { + setNonTerminalIO(t) + c := &cli{detectedAgent: "cursor", tenant: "example.us.auth0.com", renderer: &display.Renderer{}} + + login := loginTrackingProperties(c, "example.us.auth0.com") + command := commandTrackingProperties(c) + + for _, key := range []string{"interactive", "ci", "agent_client", "tenant"} { + assert.Equal(t, command[key], login[key], key) + } +} + +// setNonTerminalIO points stdin/stdout at regular files so interactive is "false" regardless of the test runner's TTY. +func setNonTerminalIO(t *testing.T) { + t.Helper() + input, output := iostream.Input, iostream.Output + t.Cleanup(func() { iostream.Input, iostream.Output = input, output }) + + file, err := os.CreateTemp(t.TempDir(), "io") + require.NoError(t, err) + t.Cleanup(func() { _ = file.Close() }) + iostream.Input, iostream.Output = file, file +} + func TestMergeProperties(t *testing.T) { base := map[string]string{"interactive": "true", "success": "true"} override := map[string]string{"success": "false", "error_class": "auth"}