diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 60777958b3..22c492c8c8 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -52,6 +52,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_ENDPOINT: ${{ secrets.POSTHOG_ENDPOINT }} - run: gh release edit v${{ needs.release.outputs.new-release-version }} --draft=false --prerelease env: diff --git a/.goreleaser.yml b/.goreleaser.yml index b1174fd6cf..98e3cef593 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -6,7 +6,7 @@ builds: flags: - -trimpath ldflags: - - -s -w -X github.com/supabase/cli/internal/utils.Version={{.Version}} -X github.com/supabase/cli/internal/utils.SentryDsn={{ .Env.SENTRY_DSN }} + - -s -w -X github.com/supabase/cli/internal/utils.Version={{.Version}} -X github.com/supabase/cli/internal/utils.SentryDsn={{ .Env.SENTRY_DSN }} -X github.com/supabase/cli/internal/utils.PostHogAPIKey={{ .Env.POSTHOG_API_KEY }} -X github.com/supabase/cli/internal/utils.PostHogEndpoint={{ .Env.POSTHOG_ENDPOINT }} env: - CGO_ENABLED=0 targets: diff --git a/cmd/branches.go b/cmd/branches.go index f0888b3429..fee85fccb7 100644 --- a/cmd/branches.go +++ b/cmd/branches.go @@ -201,6 +201,7 @@ var ( func init() { branchFlags := branchesCmd.PersistentFlags() branchFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + markFlagTelemetrySafe(branchFlags.Lookup("project-ref")) createFlags := branchCreateCmd.Flags() createFlags.Var(®ion, "region", "Select a region to deploy the branch database.") createFlags.Var(&size, "size", "Select a desired instance size for the branch database.") diff --git a/cmd/functions.go b/cmd/functions.go index 72ea09da0b..bbf99bb9a8 100644 --- a/cmd/functions.go +++ b/cmd/functions.go @@ -138,7 +138,9 @@ var ( func init() { functionsListCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + markFlagTelemetrySafe(functionsListCmd.Flags().Lookup("project-ref")) functionsDeleteCmd.Flags().StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + markFlagTelemetrySafe(functionsDeleteCmd.Flags().Lookup("project-ref")) deployFlags := functionsDeployCmd.Flags() deployFlags.BoolVar(&useApi, "use-api", false, "Bundle functions server-side without using Docker.") deployFlags.BoolVar(&useDocker, "use-docker", true, "Use Docker to bundle functions.") @@ -150,6 +152,7 @@ func init() { deployFlags.BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.") deployFlags.BoolVar(&prune, "prune", false, "Delete Functions that exist in Supabase project but not locally.") deployFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + markFlagTelemetrySafe(deployFlags.Lookup("project-ref")) deployFlags.StringVar(&importMapPath, "import-map", "", "Path to import map file.") functionsServeCmd.Flags().BoolVar(noVerifyJWT, "no-verify-jwt", false, "Disable JWT verification for the Function.") functionsServeCmd.Flags().StringVar(&envFilePath, "env-file", "", "Path to an env file to be populated to the Function environment.") @@ -162,6 +165,7 @@ func init() { cobra.CheckErr(functionsServeCmd.Flags().MarkHidden("all")) downloadFlags := functionsDownloadCmd.Flags() downloadFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + markFlagTelemetrySafe(downloadFlags.Lookup("project-ref")) downloadFlags.BoolVar(&useLegacyBundle, "legacy-bundle", false, "Use legacy bundling mechanism.") downloadFlags.BoolVar(&useApi, "use-api", false, "Unbundle functions server-side without using Docker.") downloadFlags.BoolVar(&useDocker, "use-docker", true, "Use Docker to unbundle functions client-side.") diff --git a/cmd/gen.go b/cmd/gen.go index 05f34cbea2..c9d2510f5e 100644 --- a/cmd/gen.go +++ b/cmd/gen.go @@ -149,6 +149,7 @@ func init() { typeFlags.Bool("linked", false, "Generate types from the linked project.") typeFlags.String("db-url", "", "Generate types from a database url.") typeFlags.StringVar(&flags.ProjectRef, "project-id", "", "Generate types from a project ID.") + markFlagTelemetrySafe(typeFlags.Lookup("project-id")) genTypesCmd.MarkFlagsMutuallyExclusive("local", "linked", "project-id", "db-url") typeFlags.Var(&lang, "lang", "Output language of the generated types.") typeFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") @@ -162,6 +163,7 @@ func init() { genCmd.AddCommand(genTypesCmd) keyFlags := genKeysCmd.Flags() keyFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + markFlagTelemetrySafe(keyFlags.Lookup("project-ref")) keyFlags.StringSliceVar(&override, "override-name", []string{}, "Override specific variable names.") genCmd.AddCommand(genKeysCmd) signingKeyFlags := genSigningKeyCmd.Flags() diff --git a/cmd/link.go b/cmd/link.go index 026e7e244d..236a2373e6 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -49,6 +49,7 @@ var ( func init() { linkFlags := linkCmd.Flags() linkFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + markFlagTelemetrySafe(linkFlags.Lookup("project-ref")) linkFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.") linkFlags.BoolVar(&skipPooler, "skip-pooler", false, "Use direct connection instead of pooler.") // For some reason, BindPFlag only works for StringVarP instead of StringP diff --git a/cmd/migration.go b/cmd/migration.go index 697f3651d0..cd93012bb3 100644 --- a/cmd/migration.go +++ b/cmd/migration.go @@ -131,6 +131,7 @@ func init() { // Build squash command squashFlags := migrationSquashCmd.Flags() squashFlags.StringVar(&migrationVersion, "version", "", "Squash up to the specified version.") + markFlagTelemetrySafe(squashFlags.Lookup("version")) squashFlags.String("db-url", "", "Squashes migrations of the database specified by the connection string (must be percent-encoded).") squashFlags.Bool("linked", false, "Squashes the migration history of the linked project.") squashFlags.Bool("local", true, "Squashes the migration history of the local database.") diff --git a/cmd/projects.go b/cmd/projects.go index 2b4763c6d1..7b9976f136 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -134,6 +134,7 @@ func init() { createFlags.BoolVarP(&interactive, "interactive", "i", true, "Enables interactive mode.") cobra.CheckErr(createFlags.MarkHidden("interactive")) createFlags.StringVar(&orgId, "org-id", "", "Organization ID to create the project in.") + markFlagTelemetrySafe(createFlags.Lookup("org-id")) createFlags.StringVar(&dbPassword, "db-password", "", "Database password of the project.") createFlags.Var(®ion, "region", "Select a region close to you for the best performance.") createFlags.String("plan", "", "Select a plan that suits your needs.") @@ -143,6 +144,7 @@ func init() { apiKeysFlags := projectsApiKeysCmd.Flags() apiKeysFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + markFlagTelemetrySafe(apiKeysFlags.Lookup("project-ref")) // Add commands to root projectsCmd.AddCommand(projectsCreateCmd) diff --git a/cmd/root.go b/cmd/root.go index b17b6a1fc5..c8c29174eb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,7 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/supabase/cli/internal/debug" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" "golang.org/x/mod/semver" @@ -122,6 +123,24 @@ var ( fmt.Fprintln(os.Stderr, cmd.Root().Short) fmt.Fprintf(os.Stderr, "Using profile: %s (%s)\n", utils.CurrentProfile.Name, utils.CurrentProfile.ProjectHost) } + isTTY := telemetryIsTTY() + isCI := telemetryIsCI() + isAgent := telemetryIsAgent() + envSignals := telemetryEnvSignals() + service, err := telemetry.NewService(fsys, telemetry.Options{ + Now: time.Now, + IsTTY: isTTY, + IsCI: isCI, + IsAgent: isAgent, + EnvSignals: envSignals, + CLIName: utils.Version, + }) + if err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } else { + ctx = telemetry.WithService(ctx, service) + } + ctx = telemetry.WithCommandContext(ctx, commandAnalyticsContext(cmd)) cmd.SetContext(ctx) // Setup sentry last to ignore errors from parsing cli flags apiHost, err := url.Parse(utils.GetSupabaseAPIHost()) @@ -137,11 +156,26 @@ var ( func Execute() { defer recoverAndExit() - if err := rootCmd.Execute(); err != nil { + startedAt := time.Now() + executedCmd, err := rootCmd.ExecuteC() + if executedCmd != nil { + if service := telemetry.FromContext(executedCmd.Context()); service != nil { + _ = service.Capture(executedCmd.Context(), telemetry.EventCommandExecuted, map[string]any{ + telemetry.PropExitCode: exitCode(err), + telemetry.PropDurationMs: time.Since(startedAt).Milliseconds(), + }, nil) + _ = service.Close() + } + } + if err != nil { panic(err) } // Check upgrade last because --version flag is initialised after execute - version, err := checkUpgrade(rootCmd.Context(), afero.NewOsFs()) + ctx := rootCmd.Context() + if executedCmd != nil { + ctx = executedCmd.Context() + } + version, err := checkUpgrade(ctx, afero.NewOsFs()) if err != nil { fmt.Fprintln(utils.GetDebugLogger(), err) } @@ -153,6 +187,13 @@ func Execute() { } } +func exitCode(err error) int { + if err != nil { + return 1 + } + return 0 +} + func checkUpgrade(ctx context.Context, fsys afero.Fs) (string, error) { if shouldFetchRelease(fsys) { version, err := utils.GetLatestRelease(ctx) diff --git a/cmd/root_analytics.go b/cmd/root_analytics.go new file mode 100644 index 0000000000..bf9735ec3c --- /dev/null +++ b/cmd/root_analytics.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "os" + "sort" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/supabase/cli/internal/telemetry" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/agent" + "golang.org/x/term" +) + +const ( + telemetrySafeValueAnnotation = "supabase.com/telemetry-safe-value" + redactedTelemetryValue = "" + maxTelemetryEnvValueLength = 80 +) + +func commandAnalyticsContext(cmd *cobra.Command) telemetry.CommandContext { + return telemetry.CommandContext{ + RunID: uuid.NewString(), + Command: commandName(cmd), + Flags: changedFlagValues(cmd), + } +} + +func commandName(cmd *cobra.Command) string { + path := strings.TrimSpace(cmd.CommandPath()) + rootName := strings.TrimSpace(cmd.Root().Name()) + if path == rootName || path == "" { + return rootName + } + return strings.TrimSpace(strings.TrimPrefix(path, rootName)) +} + +func changedFlagValues(cmd *cobra.Command) map[string]any { + flags := changedFlags(cmd) + if len(flags) == 0 { + return nil + } + values := make(map[string]any, len(flags)) + for _, flag := range flags { + values[flag.Name] = telemetryFlagValue(flag) + } + return values +} + +func changedFlags(cmd *cobra.Command) []*pflag.Flag { + seen := make(map[string]struct{}) + var result []*pflag.Flag + collect := func(flags *pflag.FlagSet) { + if flags == nil { + return + } + flags.Visit(func(flag *pflag.Flag) { + if _, ok := seen[flag.Name]; ok { + return + } + seen[flag.Name] = struct{}{} + result = append(result, flag) + }) + } + for current := cmd; current != nil; current = current.Parent() { + collect(current.PersistentFlags()) + } + collect(cmd.Flags()) + sort.Slice(result, func(i, j int) bool { + return result[i].Name < result[j].Name + }) + return result +} + +func markFlagTelemetrySafe(flag *pflag.Flag) { + if flag == nil { + return + } + if flag.Annotations == nil { + flag.Annotations = map[string][]string{} + } + flag.Annotations[telemetrySafeValueAnnotation] = []string{"true"} +} + +func telemetryFlagValue(flag *pflag.Flag) any { + if flag == nil { + return nil + } + if isTelemetrySafeFlag(flag) || isBooleanFlag(flag) || isEnumFlag(flag) { + return actualTelemetryFlagValue(flag) + } + return redactedTelemetryValue +} + +func isTelemetrySafeFlag(flag *pflag.Flag) bool { + if flag == nil || flag.Annotations == nil { + return false + } + values, ok := flag.Annotations[telemetrySafeValueAnnotation] + return ok && len(values) > 0 && values[0] == "true" +} + +func isBooleanFlag(flag *pflag.Flag) bool { + return flag != nil && flag.Value.Type() == "bool" +} + +func isEnumFlag(flag *pflag.Flag) bool { + if flag == nil { + return false + } + _, ok := flag.Value.(*utils.EnumFlag) + return ok +} + +func actualTelemetryFlagValue(flag *pflag.Flag) any { + if isBooleanFlag(flag) { + value, err := strconv.ParseBool(flag.Value.String()) + if err == nil { + return value + } + } + return flag.Value.String() +} + +func telemetryIsCI() bool { + return os.Getenv("CI") != "" || + os.Getenv("GITHUB_ACTIONS") != "" || + os.Getenv("BUILDKITE") != "" || + os.Getenv("TF_BUILD") != "" || + os.Getenv("JENKINS_URL") != "" || + os.Getenv("GITLAB_CI") != "" +} + +func telemetryIsTTY() bool { + return term.IsTerminal(int(os.Stdout.Fd())) //nolint:gosec // G115: stdout fd is a small int on supported platforms +} + +func telemetryIsAgent() bool { + return agent.IsAgent() +} + +func telemetryEnvSignals() map[string]any { + return envSignals(telemetry.EnvSignalPresenceKeys[:], telemetry.EnvSignalValueKeys[:]) +} + +func envSignals(presenceKeys []string, valueKeys []string) map[string]any { + signals := make(map[string]any, len(presenceKeys)+len(valueKeys)) + for _, key := range presenceKeys { + if hasTelemetryEnvValue(key) { + signals[key] = true + } + } + for _, key := range valueKeys { + if value := telemetryEnvValue(key); value != "" { + signals[key] = value + } + } + if len(signals) == 0 { + return nil + } + return signals +} + +func hasTelemetryEnvValue(key string) bool { + return strings.TrimSpace(os.Getenv(key)) != "" +} + +func telemetryEnvValue(key string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return "" + } + if len(value) > maxTelemetryEnvValueLength { + return value[:maxTelemetryEnvValueLength] + } + return value +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000000..fa7c6f39d7 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/telemetry" + "github.com/supabase/cli/internal/utils" +) + +func clearTelemetryEnv(t *testing.T) { + for _, key := range telemetry.EnvSignalPresenceKeys { + t.Setenv(key, "") + } + for _, key := range telemetry.EnvSignalValueKeys { + t.Setenv(key, "") + } +} + +func TestCommandAnalyticsContext(t *testing.T) { + root := &cobra.Command{Use: "supabase"} + var projectRef string + var password string + var debug bool + output := utils.EnumFlag{ + Allowed: []string{"json", "table"}, + Value: "table", + } + child := &cobra.Command{ + Use: "link", + RunE: func(cmd *cobra.Command, args []string) error { + return nil + }, + } + root.PersistentFlags().BoolVar(&debug, "debug", false, "") + child.Flags().StringVar(&projectRef, "project-ref", "", "") + child.Flags().StringVar(&password, "password", "", "") + child.Flags().Var(&output, "output", "") + child.Flags().AddFlag(root.PersistentFlags().Lookup("debug")) + markFlagTelemetrySafe(child.Flags().Lookup("project-ref")) + root.AddCommand(child) + + require.NoError(t, root.PersistentFlags().Set("debug", "true")) + require.NoError(t, child.Flags().Set("project-ref", "proj_123")) + require.NoError(t, child.Flags().Set("password", "hunter2")) + require.NoError(t, child.Flags().Set("output", "json")) + + ctx := commandAnalyticsContext(child) + + assert.Equal(t, "link", ctx.Command) + assert.Equal(t, map[string]any{ + "debug": true, + "output": "json", + "password": redactedTelemetryValue, + "project-ref": "proj_123", + }, ctx.Flags) + assert.NotContains(t, ctx.Flags, "linked") + assert.NotEmpty(t, ctx.RunID) +} + +func TestCommandName(t *testing.T) { + root := &cobra.Command{Use: "supabase"} + parent := &cobra.Command{Use: "db"} + child := &cobra.Command{Use: "push"} + root.AddCommand(parent) + parent.AddCommand(child) + + assert.Equal(t, "db push", commandName(child)) + assert.Equal(t, "supabase", commandName(root)) +} + +func TestTelemetryIsAgent(t *testing.T) { + t.Run("returns true for agent env", func(t *testing.T) { + clearTelemetryEnv(t) + t.Setenv("CLAUDE_CODE", "1") + utils.AgentMode.Value = "auto" + t.Cleanup(func() { + utils.AgentMode.Value = "auto" + }) + + assert.True(t, telemetryIsAgent()) + }) + + t.Run("returns false with no agent env", func(t *testing.T) { + clearTelemetryEnv(t) + utils.AgentMode.Value = "auto" + t.Cleanup(func() { + utils.AgentMode.Value = "auto" + }) + + assert.False(t, telemetryIsAgent()) + }) +} + +func TestTelemetryEnvSignals(t *testing.T) { + clearTelemetryEnv(t) + t.Setenv("CURSOR_AGENT", "1") + t.Setenv("TERM_PROGRAM", " iTerm.app ") + + signals := telemetryEnvSignals() + + assert.Equal(t, true, signals["CURSOR_AGENT"]) + assert.Equal(t, "iTerm.app", signals["TERM_PROGRAM"]) + assert.NotContains(t, signals, "AI_AGENT") +} + +func TestEnvSignals(t *testing.T) { + clearTelemetryEnv(t) + t.Setenv("AI_AGENT", " ") + t.Setenv("TERM_PROGRAM", " iTerm.app ") + t.Setenv("TERM", strings.Repeat("x", 100)) + + signals := envSignals([]string{"AI_AGENT"}, []string{"TERM_PROGRAM", "TERM"}) + + assert.Equal(t, "iTerm.app", signals["TERM_PROGRAM"]) + assert.Equal(t, strings.Repeat("x", 80), signals["TERM"]) + assert.NotContains(t, signals, "AI_AGENT") +} diff --git a/cmd/sso.go b/cmd/sso.go index cb3d0b83b3..7c8bc9150a 100644 --- a/cmd/sso.go +++ b/cmd/sso.go @@ -152,6 +152,7 @@ var ( func init() { persistentFlags := ssoCmd.PersistentFlags() persistentFlags.StringVar(&flags.ProjectRef, "project-ref", "", "Project ref of the Supabase project.") + markFlagTelemetrySafe(persistentFlags.Lookup("project-ref")) ssoAddFlags := ssoAddCmd.Flags() ssoAddFlags.VarP(&ssoProviderType, "type", "t", "Type of identity provider (according to supported protocol).") ssoAddFlags.StringSliceVar(&ssoDomains, "domains", nil, "Comma separated list of email domains to associate with the added identity provider.") diff --git a/cmd/telemetry.go b/cmd/telemetry.go new file mode 100644 index 0000000000..86902e450f --- /dev/null +++ b/cmd/telemetry.go @@ -0,0 +1,65 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + phtelemetry "github.com/supabase/cli/internal/telemetry" +) + +var telemetryCmd = &cobra.Command{ + GroupID: groupLocalDev, + Use: "telemetry", + Short: "Manage CLI telemetry settings", +} + +var telemetryEnableCmd = &cobra.Command{ + Use: "enable", + Short: "Enable CLI telemetry", + RunE: func(cmd *cobra.Command, args []string) error { + if _, err := phtelemetry.SetEnabled(afero.NewOsFs(), true, time.Now()); err != nil { + return err + } + fmt.Fprintln(os.Stdout, "Telemetry is enabled.") + return nil + }, +} + +var telemetryDisableCmd = &cobra.Command{ + Use: "disable", + Short: "Disable CLI telemetry", + RunE: func(cmd *cobra.Command, args []string) error { + if _, err := phtelemetry.SetEnabled(afero.NewOsFs(), false, time.Now()); err != nil { + return err + } + fmt.Fprintln(os.Stdout, "Telemetry is disabled.") + return nil + }, +} + +var telemetryStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show CLI telemetry status", + RunE: func(cmd *cobra.Command, args []string) error { + state, _, err := phtelemetry.Status(afero.NewOsFs(), time.Now()) + if err != nil { + return err + } + status := "disabled" + if state.Enabled { + status = "enabled" + } + fmt.Fprintf(os.Stdout, "Telemetry is %s.\n", status) + return nil + }, +} + +func init() { + telemetryCmd.AddCommand(telemetryEnableCmd) + telemetryCmd.AddCommand(telemetryDisableCmd) + telemetryCmd.AddCommand(telemetryStatusCmd) + rootCmd.AddCommand(telemetryCmd) +} diff --git a/go.mod b/go.mod index 5aba7d4d1b..208ee1645f 100644 --- a/go.mod +++ b/go.mod @@ -196,6 +196,7 @@ require ( github.com/go-toolsmith/strparse v1.1.0 // indirect github.com/go-toolsmith/typep v1.1.0 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -333,6 +334,7 @@ require ( github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polyfloyd/go-errorlint v1.8.0 // indirect + github.com/posthog/posthog-go v1.11.2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect diff --git a/go.sum b/go.sum index 5fb812d205..f805756939 100644 --- a/go.sum +++ b/go.sum @@ -424,6 +424,8 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= @@ -906,6 +908,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q= github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s= +github.com/posthog/posthog-go v1.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q= +github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v0.9.0-pre1.0.20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= diff --git a/internal/link/link.go b/internal/link/link.go index 6832876ac3..a13af786ac 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" + phtelemetry "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/tenant" "github.com/supabase/cli/pkg/api" @@ -22,7 +23,8 @@ import ( func Run(ctx context.Context, projectRef string, skipPooler bool, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { // 1. Link postgres version - if err := checkRemoteProjectStatus(ctx, projectRef, fsys); err != nil { + project, err := checkRemoteProjectStatus(ctx, projectRef, fsys) + if err != nil { return err } // 2. Check service config @@ -32,7 +34,38 @@ func Run(ctx context.Context, projectRef string, skipPooler bool, fsys afero.Fs, } LinkServices(ctx, projectRef, keys.ServiceRole, skipPooler, fsys) // 3. Save project ref - return utils.WriteFile(utils.ProjectRefPath, []byte(projectRef), fsys) + if err := utils.WriteFile(utils.ProjectRefPath, []byte(projectRef), fsys); err != nil { + return err + } + if project != nil { + if err := phtelemetry.SaveLinkedProject(*project, fsys); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + if service := phtelemetry.FromContext(ctx); service != nil { + if project.OrganizationId != "" { + if err := service.GroupIdentify(phtelemetry.GroupOrganization, project.OrganizationId, map[string]any{ + "organization_slug": project.OrganizationSlug, + }); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + } + if project.Ref != "" { + if err := service.GroupIdentify(phtelemetry.GroupProject, project.Ref, map[string]any{ + "name": project.Name, + "organization_slug": project.OrganizationSlug, + }); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + } + if err := service.Capture(ctx, phtelemetry.EventProjectLinked, nil, map[string]string{ + phtelemetry.GroupOrganization: project.OrganizationId, + phtelemetry.GroupProject: project.Ref, + }); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + } + } + return nil } func LinkServices(ctx context.Context, projectRef, serviceKey string, skipPooler bool, fsys afero.Fs) { @@ -204,25 +237,25 @@ func updatePoolerConfig(config api.SupavisorConfigResponse) { var errProjectPaused = errors.New("project is paused") -func checkRemoteProjectStatus(ctx context.Context, projectRef string, fsys afero.Fs) error { +func checkRemoteProjectStatus(ctx context.Context, projectRef string, fsys afero.Fs) (*api.V1ProjectWithDatabaseResponse, error) { resp, err := utils.GetSupabase().V1GetProjectWithResponse(ctx, projectRef) if err != nil { - return errors.Errorf("failed to retrieve remote project status: %w", err) + return nil, errors.Errorf("failed to retrieve remote project status: %w", err) } switch resp.StatusCode() { case http.StatusNotFound: // Ignore not found error to support linking branch projects - return nil + return nil, nil case http.StatusOK: // resp.JSON200 is not nil, proceed default: - return errors.New("Unexpected error retrieving remote project status: " + string(resp.Body)) + return nil, errors.New("Unexpected error retrieving remote project status: " + string(resp.Body)) } switch resp.JSON200.Status { case api.V1ProjectWithDatabaseResponseStatusINACTIVE: utils.CmdSuggestion = fmt.Sprintf("An admin must unpause it from the Supabase dashboard at %s", utils.Aqua(fmt.Sprintf("%s/project/%s", utils.GetSupabaseDashboardURL(), projectRef))) - return errors.New(errProjectPaused) + return nil, errors.New(errProjectPaused) case api.V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY: // Project is in the desired state, do nothing default: @@ -230,7 +263,7 @@ func checkRemoteProjectStatus(ctx context.Context, projectRef string, fsys afero } // Update postgres image version to match the remote project - return linkPostgresVersion(resp.JSON200.Database.Version, fsys) + return resp.JSON200, linkPostgresVersion(resp.JSON200.Database.Version, fsys) } func linkPostgresVersion(version string, fsys afero.Fs) error { diff --git a/internal/link/link_test.go b/internal/link/link_test.go index bf7c85761a..d92667e64a 100644 --- a/internal/link/link_test.go +++ b/internal/link/link_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "testing" + "time" "github.com/h2non/gock" "github.com/jackc/pgconn" @@ -13,6 +14,8 @@ import ( "github.com/oapi-codegen/nullable" "github.com/spf13/afero" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + phtelemetry "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/testing/fstest" "github.com/supabase/cli/internal/utils" @@ -30,6 +33,38 @@ var dbConfig = pgconn.Config{ Database: "postgres", } +type fakeAnalytics struct { + enabled bool + captures []captureCall + groupIdentifies []groupIdentifyCall +} + +type captureCall struct { + distinctID string + event string + properties map[string]any + groups map[string]string +} + +type groupIdentifyCall struct { + groupType string + groupKey string + properties map[string]any +} + +func (f *fakeAnalytics) Enabled() bool { return f.enabled } +func (f *fakeAnalytics) Capture(distinctID string, event string, properties map[string]any, groups map[string]string) error { + f.captures = append(f.captures, captureCall{distinctID: distinctID, event: event, properties: properties, groups: groups}) + return nil +} +func (f *fakeAnalytics) Identify(distinctID string, properties map[string]any) error { return nil } +func (f *fakeAnalytics) Alias(distinctID string, alias string) error { return nil } +func (f *fakeAnalytics) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { + f.groupIdentifies = append(f.groupIdentifies, groupIdentifyCall{groupType: groupType, groupKey: groupKey, properties: properties}) + return nil +} +func (f *fakeAnalytics) Close() error { return nil } + func TestLinkCommand(t *testing.T) { project := "test-project" // Setup valid access token @@ -42,11 +77,23 @@ func TestLinkCommand(t *testing.T) { t.Cleanup(fstest.MockStdin(t, "\n")) // Setup in-memory fs fsys := afero.NewMemMapFs() + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + analytics := &fakeAnalytics{enabled: true} + service, err := phtelemetry.NewService(fsys, phtelemetry.Options{ + Analytics: analytics, + Now: func() time.Time { return time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) }, + }) + require.NoError(t, err) + ctx := phtelemetry.WithService(context.Background(), service) // Flush pending mocks after test execution defer gock.OffAll() // Mock project status mockPostgres := api.V1ProjectWithDatabaseResponse{ - Status: api.V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY, + Status: api.V1ProjectWithDatabaseResponseStatusACTIVEHEALTHY, + Ref: project, + Name: "My Project", + OrganizationId: "org_123", + OrganizationSlug: "acme", } mockPostgres.Database.Host = utils.GetSupabaseDbHost(project) mockPostgres.Database.Version = "15.1.0.117" @@ -108,7 +155,7 @@ func TestLinkCommand(t *testing.T) { Reply(200). BodyString(storage) // Run test - err := Run(context.Background(), project, false, fsys) + err = Run(ctx, project, false, fsys) // Check error assert.NoError(t, err) assert.Empty(t, apitest.ListUnmatchedRequests()) @@ -128,6 +175,17 @@ func TestLinkCommand(t *testing.T) { postgresVersion, err := afero.ReadFile(fsys, utils.PostgresVersionPath) assert.NoError(t, err) assert.Equal(t, []byte(mockPostgres.Database.Version), postgresVersion) + linkedProject, err := phtelemetry.LoadLinkedProject(fsys) + require.NoError(t, err) + assert.Equal(t, project, linkedProject.Ref) + assert.Equal(t, "org_123", linkedProject.OrganizationID) + require.Len(t, analytics.groupIdentifies, 2) + require.Len(t, analytics.captures, 1) + assert.Equal(t, phtelemetry.EventProjectLinked, analytics.captures[0].event) + assert.Equal(t, map[string]string{ + phtelemetry.GroupOrganization: "org_123", + phtelemetry.GroupProject: project, + }, analytics.captures[0].groups) }) t.Run("ignores error linking services", func(t *testing.T) { @@ -280,7 +338,7 @@ func TestStatusCheck(t *testing.T) { Reply(http.StatusOK). JSON(postgres) // Run test - err := checkRemoteProjectStatus(context.Background(), project, fsys) + _, err := checkRemoteProjectStatus(context.Background(), project, fsys) // Check error assert.NoError(t, err) version, err := afero.ReadFile(fsys, utils.PostgresVersionPath) @@ -299,7 +357,7 @@ func TestStatusCheck(t *testing.T) { Get("/v1/projects/" + project). Reply(http.StatusNotFound) // Run test - err := checkRemoteProjectStatus(context.Background(), project, fsys) + _, err := checkRemoteProjectStatus(context.Background(), project, fsys) // Check error assert.NoError(t, err) exists, err := afero.Exists(fsys, utils.PostgresVersionPath) @@ -319,7 +377,7 @@ func TestStatusCheck(t *testing.T) { Reply(http.StatusOK). JSON(api.V1ProjectWithDatabaseResponse{Status: api.V1ProjectWithDatabaseResponseStatusINACTIVE}) // Run test - err := checkRemoteProjectStatus(context.Background(), project, fsys) + _, err := checkRemoteProjectStatus(context.Background(), project, fsys) // Check error assert.ErrorIs(t, err, errProjectPaused) exists, err := afero.Exists(fsys, utils.PostgresVersionPath) diff --git a/internal/login/login.go b/internal/login/login.go index 6239b500ac..18ba9ee640 100644 --- a/internal/login/login.go +++ b/internal/login/login.go @@ -21,6 +21,7 @@ import ( "github.com/google/uuid" "github.com/spf13/afero" "github.com/supabase/cli/internal/migration/new" + phtelemetry "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/fetcher" ) @@ -32,6 +33,7 @@ type RunParams struct { SessionId string Encryption LoginEncryptor Fsys afero.Fs + GetProfile func(context.Context) (string, error) } type AccessTokenResponse struct { @@ -168,6 +170,7 @@ func Run(ctx context.Context, stdout io.Writer, params RunParams) error { if err := utils.SaveAccessToken(params.Token, params.Fsys); err != nil { return errors.Errorf("cannot save provided token: %w", err) } + handleTelemetryAfterLogin(ctx, params) fmt.Println(loggedInMsg) return nil } @@ -216,6 +219,7 @@ func Run(ctx context.Context, stdout io.Writer, params RunParams) error { if err := utils.SaveAccessToken(decryptedAccessToken, params.Fsys); err != nil { return err } + handleTelemetryAfterLogin(ctx, params) fmt.Fprintf(stdout, "Token %s created successfully.\n\n", utils.Bold(params.TokenName)) fmt.Fprintln(stdout, loggedInMsg) @@ -259,3 +263,42 @@ func generateTokenNameWithFallback() string { } return name } + +func handleTelemetryAfterLogin(ctx context.Context, params RunParams) { + service := phtelemetry.FromContext(ctx) + if service == nil { + return + } + getProfile := params.GetProfile + if getProfile == nil { + getProfile = getProfileGotrueID + } + logger := utils.GetDebugLogger() + if distinctID, err := getProfile(ctx); err == nil { + if err := service.StitchLogin(distinctID); err != nil { + fmt.Fprintln(logger, err) + if err := service.ClearDistinctID(); err != nil { + fmt.Fprintln(logger, err) + } + } + } else { + fmt.Fprintln(logger, err) + if err := service.ClearDistinctID(); err != nil { + fmt.Fprintln(logger, err) + } + } + if err := service.Capture(ctx, phtelemetry.EventLoginCompleted, nil, nil); err != nil { + fmt.Fprintln(logger, err) + } +} + +func getProfileGotrueID(ctx context.Context) (string, error) { + resp, err := utils.GetSupabase().V1GetProfileWithResponse(ctx) + if err != nil { + return "", errors.Errorf("failed to fetch profile: %w", err) + } + if resp.JSON200 == nil { + return "", errors.Errorf("unexpected profile status %d: %s", resp.StatusCode(), string(resp.Body)) + } + return resp.JSON200.GotrueId, nil +} diff --git a/internal/login/login_test.go b/internal/login/login_test.go index ef6772351a..65c6a2605f 100644 --- a/internal/login/login_test.go +++ b/internal/login/login_test.go @@ -3,15 +3,18 @@ package login import ( "bytes" "context" + "errors" "fmt" "io" "os" "testing" + "time" "github.com/h2non/gock" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + phtelemetry "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/credentials" @@ -31,6 +34,52 @@ func (enc *MockEncryption) decryptAccessToken(accessToken string, publicKey stri return enc.token, nil } +type fakeAnalytics struct { + enabled bool + captures []captureCall + identifies []identifyCall + aliases []aliasCall +} + +type captureCall struct { + distinctID string + event string + properties map[string]any +} + +type identifyCall struct { + distinctID string + properties map[string]any +} + +type aliasCall struct { + distinctID string + alias string +} + +func (f *fakeAnalytics) Enabled() bool { return f.enabled } + +func (f *fakeAnalytics) Capture(distinctID string, event string, properties map[string]any, groups map[string]string) error { + f.captures = append(f.captures, captureCall{distinctID: distinctID, event: event, properties: properties}) + return nil +} + +func (f *fakeAnalytics) Identify(distinctID string, properties map[string]any) error { + f.identifies = append(f.identifies, identifyCall{distinctID: distinctID, properties: properties}) + return nil +} + +func (f *fakeAnalytics) Alias(distinctID string, alias string) error { + f.aliases = append(f.aliases, aliasCall{distinctID: distinctID, alias: alias}) + return nil +} + +func (f *fakeAnalytics) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { + return nil +} + +func (f *fakeAnalytics) Close() error { return nil } + func TestLoginCommand(t *testing.T) { keyring.MockInit() @@ -89,3 +138,133 @@ func TestLoginCommand(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) } + +func TestLoginTelemetryStitching(t *testing.T) { + keyring.MockInit() + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + token := string(apitest.RandomAccessToken(t)) + + newService := func(t *testing.T, fsys afero.Fs, analytics *fakeAnalytics) *phtelemetry.Service { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + service, err := phtelemetry.NewService(fsys, phtelemetry.Options{ + Analytics: analytics, + Now: func() time.Time { return now }, + }) + require.NoError(t, err) + return service + } + + t.Run("token login fetches profile and stitches with gotrue_id", func(t *testing.T) { + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + ctx := phtelemetry.WithService(context.Background(), newService(t, fsys, analytics)) + + err := Run(ctx, os.Stdout, RunParams{ + Token: token, + Fsys: fsys, + GetProfile: func(context.Context) (string, error) { + return "user-123", nil + }, + }) + + require.NoError(t, err) + require.Len(t, analytics.aliases, 1) + assert.Equal(t, "user-123", analytics.aliases[0].distinctID) + require.Len(t, analytics.identifies, 1) + assert.Equal(t, "user-123", analytics.identifies[0].distinctID) + require.Len(t, analytics.captures, 1) + assert.Equal(t, phtelemetry.EventLoginCompleted, analytics.captures[0].event) + assert.Equal(t, "user-123", analytics.captures[0].distinctID) + state, err := phtelemetry.LoadState(fsys) + require.NoError(t, err) + assert.Equal(t, "user-123", state.DistinctID) + }) + + t.Run("browser login also stitches with gotrue_id", func(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + ctx := phtelemetry.WithService(context.Background(), newService(t, fsys, analytics)) + + defer gock.OffAll() + gock.New(utils.GetSupabaseAPIHost()). + Get("/platform/cli/login/browser-session"). + Reply(200). + JSON(map[string]any{ + "id": "0b0d48f6-878b-4190-88d7-2ca33ed800bc", + "created_at": "2023-03-28T13:50:14.464Z", + "access_token": "picklerick", + "public_key": "iddqd", + "nonce": "idkfa", + }) + + err = Run(ctx, w, RunParams{ + TokenName: "token_name", + SessionId: "browser-session", + Fsys: fsys, + Encryption: &MockEncryption{publicKey: "public_key", token: token}, + GetProfile: func(context.Context) (string, error) { + return "user-456", nil + }, + }) + + require.NoError(t, err) + require.Len(t, analytics.captures, 1) + assert.Equal(t, "user-456", analytics.captures[0].distinctID) + }) + + t.Run("stale distinct_id is replaced on successful profile lookup", func(t *testing.T) { + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + service := newService(t, fsys, analytics) + state, _, err := phtelemetry.LoadOrCreateState(fsys, now) + require.NoError(t, err) + state.DistinctID = "old-user" + require.NoError(t, phtelemetry.SaveState(state, fsys)) + ctx := phtelemetry.WithService(context.Background(), service) + + err = Run(ctx, os.Stdout, RunParams{ + Token: token, + Fsys: fsys, + GetProfile: func(context.Context) (string, error) { + return "new-user", nil + }, + }) + + require.NoError(t, err) + state, err = phtelemetry.LoadState(fsys) + require.NoError(t, err) + assert.Equal(t, "new-user", state.DistinctID) + }) + + t.Run("profile lookup failure does not fail login and clears stale distinct_id", func(t *testing.T) { + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + service := newService(t, fsys, analytics) + state, _, err := phtelemetry.LoadOrCreateState(fsys, now) + require.NoError(t, err) + state.DistinctID = "old-user" + deviceID := state.DeviceID + require.NoError(t, phtelemetry.SaveState(state, fsys)) + ctx := phtelemetry.WithService(context.Background(), service) + + err = Run(ctx, os.Stdout, RunParams{ + Token: token, + Fsys: fsys, + GetProfile: func(context.Context) (string, error) { + return "", errors.New("profile unavailable") + }, + }) + + require.NoError(t, err) + assert.Empty(t, analytics.aliases) + assert.Empty(t, analytics.identifies) + require.Len(t, analytics.captures, 1) + assert.Equal(t, deviceID, analytics.captures[0].distinctID) + state, err = phtelemetry.LoadState(fsys) + require.NoError(t, err) + assert.Empty(t, state.DistinctID) + }) +} diff --git a/internal/start/start.go b/internal/start/start.go index 5d98941945..846e87e4b7 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -39,6 +39,7 @@ import ( "github.com/supabase/cli/internal/seed/buckets" "github.com/supabase/cli/internal/services" "github.com/supabase/cli/internal/status" + phtelemetry "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/config" @@ -1328,7 +1329,15 @@ EOF return err } } - return start.WaitForHealthyService(ctx, serviceTimeout, started...) + if err := start.WaitForHealthyService(ctx, serviceTimeout, started...); err != nil { + return err + } + if service := phtelemetry.FromContext(ctx); service != nil { + if err := service.Capture(ctx, phtelemetry.EventStackStarted, nil, nil); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + } + return nil } func isContainerExcluded(imageName string, excluded map[string]bool) bool { diff --git a/internal/start/start_test.go b/internal/start/start_test.go index bec6da5cf0..56d237e2b9 100644 --- a/internal/start/start_test.go +++ b/internal/start/start_test.go @@ -7,6 +7,7 @@ import ( "net/http" "regexp" "testing" + "time" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" @@ -18,13 +19,39 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + phtelemetry "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/utils" + supabaseapi "github.com/supabase/cli/pkg/api" "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/pgtest" "github.com/supabase/cli/pkg/storage" ) +type fakeAnalytics struct { + enabled bool + captures []captureCall +} + +type captureCall struct { + distinctID string + event string + properties map[string]any + groups map[string]string +} + +func (f *fakeAnalytics) Enabled() bool { return f.enabled } +func (f *fakeAnalytics) Capture(distinctID string, event string, properties map[string]any, groups map[string]string) error { + f.captures = append(f.captures, captureCall{distinctID: distinctID, event: event, properties: properties, groups: groups}) + return nil +} +func (f *fakeAnalytics) Identify(distinctID string, properties map[string]any) error { return nil } +func (f *fakeAnalytics) Alias(distinctID string, alias string) error { return nil } +func (f *fakeAnalytics) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { + return nil +} +func (f *fakeAnalytics) Close() error { return nil } + func TestStartCommand(t *testing.T) { t.Run("throws error on malformed config", func(t *testing.T) { // Setup in-memory fs @@ -95,6 +122,18 @@ func TestDatabaseStart(t *testing.T) { t.Run("starts database locally", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + analytics := &fakeAnalytics{enabled: true} + service, err := phtelemetry.NewService(fsys, phtelemetry.Options{ + Analytics: analytics, + Now: func() time.Time { return time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) }, + }) + require.NoError(t, err) + require.NoError(t, phtelemetry.SaveLinkedProject(supabaseapi.V1ProjectWithDatabaseResponse{ + Ref: "proj_123", + OrganizationId: "org_123", + }, fsys)) + ctx := phtelemetry.WithService(context.Background(), service) // Setup mock docker require.NoError(t, apitest.MockDocker(utils.Docker)) defer gock.OffAll() @@ -202,10 +241,16 @@ func TestDatabaseStart(t *testing.T) { Reply(http.StatusOK). JSON([]storage.BucketResponse{}) // Run test - err := run(context.Background(), fsys, []string{}, pgconn.Config{Host: utils.DbId}, conn.Intercept) + err = run(ctx, fsys, []string{}, pgconn.Config{Host: utils.DbId}, conn.Intercept) // Check error assert.NoError(t, err) assert.Empty(t, apitest.ListUnmatchedRequests()) + require.Len(t, analytics.captures, 1) + assert.Equal(t, phtelemetry.EventStackStarted, analytics.captures[0].event) + assert.Equal(t, map[string]string{ + phtelemetry.GroupOrganization: "org_123", + phtelemetry.GroupProject: "proj_123", + }, analytics.captures[0].groups) }) t.Run("skips excluded containers", func(t *testing.T) { diff --git a/internal/telemetry/client.go b/internal/telemetry/client.go new file mode 100644 index 0000000000..312eb8b407 --- /dev/null +++ b/internal/telemetry/client.go @@ -0,0 +1,132 @@ +package telemetry + +import ( + "strings" + + "github.com/go-errors/errors" + "github.com/posthog/posthog-go" +) + +type Analytics interface { + Enabled() bool + Capture(distinctID string, event string, properties map[string]any, groups map[string]string) error + Identify(distinctID string, properties map[string]any) error + Alias(distinctID string, alias string) error + GroupIdentify(groupType string, groupKey string, properties map[string]any) error + Close() error +} + +type queueClient interface { + Enqueue(posthog.Message) error + Close() error +} + +type constructor func(apiKey string, config posthog.Config) (queueClient, error) + +type Client struct { + client queueClient + baseProperties posthog.Properties +} + +func NewClient(apiKey string, endpoint string, baseProperties map[string]any, factory constructor) (*Client, error) { + if strings.TrimSpace(apiKey) == "" { + return &Client{baseProperties: makeProperties(baseProperties)}, nil + } + if factory == nil { + factory = func(apiKey string, config posthog.Config) (queueClient, error) { + return posthog.NewWithConfig(apiKey, config) + } + } + config := posthog.Config{} + if endpoint != "" { + config.Endpoint = endpoint + } + client, err := factory(apiKey, config) + if err != nil { + return nil, errors.Errorf("failed to initialize posthog client: %w", err) + } + return &Client{ + client: client, + baseProperties: makeProperties(baseProperties), + }, nil +} + +func (c *Client) Enabled() bool { + return c != nil && c.client != nil +} + +func (c *Client) Capture(distinctID string, event string, properties map[string]any, groups map[string]string) error { + if !c.Enabled() { + return nil + } + msg := posthog.Capture{ + DistinctId: distinctID, + Event: event, + Properties: c.properties(properties), + } + if len(groups) > 0 { + msg.Groups = makeGroups(groups) + } + return c.client.Enqueue(msg) +} + +func (c *Client) Identify(distinctID string, properties map[string]any) error { + if !c.Enabled() { + return nil + } + return c.client.Enqueue(posthog.Identify{ + DistinctId: distinctID, + Properties: c.properties(properties), + }) +} + +func (c *Client) Alias(distinctID string, alias string) error { + if !c.Enabled() { + return nil + } + return c.client.Enqueue(posthog.Alias{ + DistinctId: distinctID, + Alias: alias, + }) +} + +func (c *Client) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { + if !c.Enabled() { + return nil + } + return c.client.Enqueue(posthog.GroupIdentify{ + Type: groupType, + Key: groupKey, + Properties: c.properties(properties), + }) +} + +func (c *Client) Close() error { + if !c.Enabled() { + return nil + } + return c.client.Close() +} + +func (c *Client) properties(properties map[string]any) posthog.Properties { + merged := posthog.NewProperties() + merged.Merge(c.baseProperties) + merged.Merge(makeProperties(properties)) + return merged +} + +func makeProperties(values map[string]any) posthog.Properties { + props := posthog.NewProperties() + for key, value := range values { + props.Set(key, value) + } + return props +} + +func makeGroups(values map[string]string) posthog.Groups { + groups := posthog.NewGroups() + for key, value := range values { + groups.Set(key, value) + } + return groups +} diff --git a/internal/telemetry/client_test.go b/internal/telemetry/client_test.go new file mode 100644 index 0000000000..738a409b47 --- /dev/null +++ b/internal/telemetry/client_test.go @@ -0,0 +1,116 @@ +package telemetry + +import ( + "testing" + + "github.com/posthog/posthog-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeQueue struct { + messages []posthog.Message + closed bool +} + +func (f *fakeQueue) Enqueue(msg posthog.Message) error { + f.messages = append(f.messages, msg) + return nil +} + +func (f *fakeQueue) Close() error { + f.closed = true + return nil +} + +func TestNewClient(t *testing.T) { + t.Run("uses endpoint and enables analytics when key is set", func(t *testing.T) { + var gotKey string + var gotConfig posthog.Config + + client, err := NewClient("phc_test", "https://eu.i.posthog.com", map[string]any{"platform": "cli"}, func(apiKey string, config posthog.Config) (queueClient, error) { + gotKey = apiKey + gotConfig = config + return &fakeQueue{}, nil + }) + + require.NoError(t, err) + assert.True(t, client.Enabled()) + assert.Equal(t, "phc_test", gotKey) + assert.Equal(t, "https://eu.i.posthog.com", gotConfig.Endpoint) + }) + + t.Run("becomes a no-op when key is empty", func(t *testing.T) { + client, err := NewClient("", "https://eu.i.posthog.com", map[string]any{"platform": "cli"}, func(apiKey string, config posthog.Config) (queueClient, error) { + t.Fatalf("constructor should not be called without an api key") + return nil, nil + }) + + require.NoError(t, err) + assert.False(t, client.Enabled()) + assert.NoError(t, client.Capture("device-1", EventCommandExecuted, map[string]any{"command": "login"}, nil)) + assert.NoError(t, client.Close()) + }) +} + +func TestCaptureMergesBasePropertiesAndGroups(t *testing.T) { + queue := &fakeQueue{} + client, err := NewClient("phc_test", "https://eu.i.posthog.com", map[string]any{ + "platform": "cli", + "os": "darwin", + }, func(apiKey string, config posthog.Config) (queueClient, error) { + return queue, nil + }) + require.NoError(t, err) + + err = client.Capture("device-1", EventCommandExecuted, map[string]any{ + "command": "login", + }, map[string]string{ + GroupProject: "proj_123", + }) + + require.NoError(t, err) + require.Len(t, queue.messages, 1) + msg, ok := queue.messages[0].(posthog.Capture) + require.True(t, ok) + assert.Equal(t, "device-1", msg.DistinctId) + assert.Equal(t, EventCommandExecuted, msg.Event) + assert.Equal(t, "cli", msg.Properties["platform"]) + assert.Equal(t, "darwin", msg.Properties["os"]) + assert.Equal(t, "login", msg.Properties["command"]) + assert.Equal(t, posthog.Groups{GroupProject: "proj_123"}, msg.Groups) +} + +func TestIdentifyAliasAndGroupIdentify(t *testing.T) { + queue := &fakeQueue{} + client, err := NewClient("phc_test", "", map[string]any{"platform": "cli"}, func(apiKey string, config posthog.Config) (queueClient, error) { + return queue, nil + }) + require.NoError(t, err) + + require.NoError(t, client.Identify("user-123", map[string]any{"schema_version": 1})) + require.NoError(t, client.Alias("user-123", "device-123")) + require.NoError(t, client.GroupIdentify(GroupOrganization, "org_123", map[string]any{"slug": "acme"})) + require.NoError(t, client.Close()) + + require.Len(t, queue.messages, 3) + + identify, ok := queue.messages[0].(posthog.Identify) + require.True(t, ok) + assert.Equal(t, "user-123", identify.DistinctId) + assert.Equal(t, "cli", identify.Properties["platform"]) + assert.Equal(t, 1, identify.Properties["schema_version"]) + + alias, ok := queue.messages[1].(posthog.Alias) + require.True(t, ok) + assert.Equal(t, "user-123", alias.DistinctId) + assert.Equal(t, "device-123", alias.Alias) + + groupIdentify, ok := queue.messages[2].(posthog.GroupIdentify) + require.True(t, ok) + assert.Equal(t, GroupOrganization, groupIdentify.Type) + assert.Equal(t, "org_123", groupIdentify.Key) + assert.Equal(t, "cli", groupIdentify.Properties["platform"]) + assert.Equal(t, "acme", groupIdentify.Properties["slug"]) + assert.True(t, queue.closed) +} diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go new file mode 100644 index 0000000000..c721b682cc --- /dev/null +++ b/internal/telemetry/events.go @@ -0,0 +1,151 @@ +package telemetry + +// CLI telemetry catalog. +// +// This file is the single place to review what analytics events the CLI sends +// and what metadata may be attached to them. Comments live next to the event, +// property, group, or signal definition they describe so the catalog is easy to +// scan without reading the rest of the implementation. +const ( + // - EventCommandExecuted: sent after a CLI command finishes, whether it + // succeeds or fails. This helps measure command usage, failure rates, and + // runtime. Event-specific properties are PropExitCode (process exit code) + // and PropDurationMs (command runtime in milliseconds). Related groups: + // none added directly by this event. + EventCommandExecuted = "cli_command_executed" + // - EventProjectLinked: sent after the local CLI directory is linked to a + // Supabase project. This helps measure project-linking adoption and connect + // future events to the right project and organization. Event-specific + // properties: none. Related groups: GroupOrganization and GroupProject. + // Related group-identify payloads sent during linking are: + // organization group -> organization_slug, and project group -> name, + // organization_slug. + EventProjectLinked = "cli_project_linked" + // - EventLoginCompleted: sent after a login flow completes successfully. This + // helps measure successful login completion and supports identity stitching + // between anonymous and authenticated usage. Event-specific properties: + // none. Related groups: none added directly by this event. + EventLoginCompleted = "cli_login_completed" + // - EventStackStarted: sent after the local development stack starts + // successfully. This helps measure local development usage and successful + // stack startup. Event-specific properties: none. Related groups: none + // added directly by this event, but linked project groups may still be + // attached when available. + EventStackStarted = "cli_stack_started" +) + +// Shared event properties added to every captured event by Service.Capture. +const ( + // PropPlatform identifies the product source for the event. The CLI always + // sends "cli". + PropPlatform = "platform" + // PropSchemaVersion is the version of the telemetry payload format. This is + // not a database schema version. + PropSchemaVersion = "schema_version" + // PropDeviceID is an anonymous identifier for this CLI installation on this + // machine. + PropDeviceID = "device_id" + // PropSessionID is the PostHog session identifier used to group activity from + // one CLI session together. + PropSessionID = "$session_id" + // PropIsFirstRun is true when the current telemetry state was created during + // this run, which helps distinguish first-time setup from repeat usage. + PropIsFirstRun = "is_first_run" + // PropIsTTY is true when stdout is attached to an interactive terminal. + PropIsTTY = "is_tty" + // PropIsCI is true when the CLI appears to be running in a CI environment. + PropIsCI = "is_ci" + // PropIsAgent is true when the CLI appears to be running under an AI agent or + // automation tool. + PropIsAgent = "is_agent" + // PropOS is the operating system reported by the Go runtime. + PropOS = "os" + // PropArch is the CPU architecture reported by the Go runtime. + PropArch = "arch" + // PropCLIVersion is the version string of the CLI build that sent the event. + PropCLIVersion = "cli_version" + // PropEnvSignals is an optional summary of coarse environment hints. It is + // not a raw dump of environment variables. + PropEnvSignals = "env_signals" + // PropCommandRunID identifies one command invocation and can be used to tie + // together telemetry emitted during a single command run. + PropCommandRunID = "command_run_id" + // PropCommand is the normalized command path, such as "link" or "db push". + PropCommand = "command" + // PropFlags contains changed CLI flags for that command run. Safe flag values + // may be included, while sensitive values are redacted in the command + // telemetry implementation. + PropFlags = "flags" + // PropExitCode is the process exit code for the command that produced the + // event. + PropExitCode = "exit_code" + // PropDurationMs is the command runtime in milliseconds. + PropDurationMs = "duration_ms" +) + +// Group identifiers associate events with higher-level entities in PostHog. +const ( + // GroupOrganization identifies the Supabase organization related to the + // event. + GroupOrganization = "organization" + // GroupProject identifies the Supabase project related to the event. + GroupProject = "project" +) + +var ( + // EnvSignalPresenceKeys lists environment variables whose presence is recorded + // as true inside the "env_signals" property. + EnvSignalPresenceKeys = [...]string{ + // AI tools signals + "CURSOR_AGENT", + "CURSOR_TRACE_ID", + "GEMINI_CLI", + "CODEX_SANDBOX", + "CODEX_CI", + "CODEX_THREAD_ID", + "ANTIGRAVITY_AGENT", + "AUGMENT_AGENT", + "OPENCODE_CLIENT", + "CLAUDECODE", + "CLAUDE_CODE", + "REPL_ID", + "COPILOT_MODEL", + "COPILOT_ALLOW_ALL", + "COPILOT_GITHUB_TOKEN", + // CI signals + "CI", + "GITHUB_ACTIONS", + "BUILDKITE", + "TF_BUILD", + "JENKINS_URL", + "GITLAB_CI", + // Extra signals + "GITHUB_TOKEN", + "GITHUB_HEAD_REF", + "BITBUCKET_CLONE_DIR", + // Supabase environment signals + "SUPABASE_ACCESS_TOKEN", + "SUPABASE_HOME", + "SYSTEMROOT", + "SUPABASE_SSL_DEBUG", + "SUPABASE_CA_SKIP_VERIFY", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NPM_CONFIG_REGISTRY", + "SUPABASE_SERVICE_ROLE_KEY", + "SUPABASE_PROJECT_ID", + "SUPABASE_POSTGRES_URL", + "SUPABASE_ENV", + } + + // EnvSignalValueKeys lists environment variables whose trimmed values may be + // recorded inside the "env_signals" property. + EnvSignalValueKeys = [...]string{ + "AI_AGENT", + "CURSOR_EXTENSION_HOST_ROLE", + "TERM", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "TERM_COLOR_MODE", + } +) diff --git a/internal/telemetry/project.go b/internal/telemetry/project.go new file mode 100644 index 0000000000..63ec40b35f --- /dev/null +++ b/internal/telemetry/project.go @@ -0,0 +1,70 @@ +package telemetry + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/go-errors/errors" + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/api" +) + +type LinkedProject struct { + Ref string `json:"ref"` + Name string `json:"name"` + OrganizationID string `json:"organization_id"` + OrganizationSlug string `json:"organization_slug"` +} + +func linkedProjectPath() string { + return filepath.Join(utils.TempDir, "linked-project.json") +} + +func SaveLinkedProject(project api.V1ProjectWithDatabaseResponse, fsys afero.Fs) error { + linked := LinkedProject{ + Ref: project.Ref, + Name: project.Name, + OrganizationID: project.OrganizationId, + OrganizationSlug: project.OrganizationSlug, + } + contents, err := json.Marshal(linked) + if err != nil { + return errors.Errorf("failed to encode linked project: %w", err) + } + return utils.WriteFile(linkedProjectPath(), contents, fsys) +} + +func LoadLinkedProject(fsys afero.Fs) (LinkedProject, error) { + contents, err := afero.ReadFile(fsys, linkedProjectPath()) + if err != nil { + return LinkedProject{}, err + } + var linked LinkedProject + if err := json.Unmarshal(contents, &linked); err != nil { + return LinkedProject{}, errors.Errorf("failed to parse linked project: %w", err) + } + return linked, nil +} + +func linkedProjectGroups(fsys afero.Fs) map[string]string { + linked, err := LoadLinkedProject(fsys) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return nil + } + groups := make(map[string]string, 2) + if linked.OrganizationID != "" { + groups[GroupOrganization] = linked.OrganizationID + } + if linked.Ref != "" { + groups[GroupProject] = linked.Ref + } + if len(groups) == 0 { + return nil + } + return groups +} diff --git a/internal/telemetry/service.go b/internal/telemetry/service.go new file mode 100644 index 0000000000..8bf920b8d0 --- /dev/null +++ b/internal/telemetry/service.go @@ -0,0 +1,234 @@ +package telemetry + +import ( + "context" + "os" + "runtime" + "time" + + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" +) + +type commandContextKey struct{} +type serviceContextKey struct{} + +type CommandContext struct { + RunID string + Command string + Flags map[string]any + Groups map[string]string +} + +type Options struct { + Analytics Analytics + Now func() time.Time + IsTTY bool + IsCI bool + IsAgent bool + EnvSignals map[string]any + CLIName string + GOOS string + GOARCH string +} + +type Service struct { + fsys afero.Fs + analytics Analytics + now func() time.Time + state State + isFirstRun bool + isTTY bool + isCI bool + isAgent bool + envSignals map[string]any + cliVersion string + goos string + goarch string +} + +func NewService(fsys afero.Fs, opts Options) (*Service, error) { + now := opts.Now + if now == nil { + now = time.Now + } + state, created, err := LoadOrCreateState(fsys, now()) + if err != nil { + return nil, err + } + analytics := opts.Analytics + if analytics == nil { + analytics, err = NewClient(utils.PostHogAPIKey, utils.PostHogEndpoint, nil, nil) + if err != nil { + return nil, err + } + } + cliVersion := opts.CLIName + if cliVersion == "" { + cliVersion = utils.Version + } + goos := opts.GOOS + if goos == "" { + goos = runtime.GOOS + } + goarch := opts.GOARCH + if goarch == "" { + goarch = runtime.GOARCH + } + return &Service{ + fsys: fsys, + analytics: analytics, + now: now, + state: state, + isFirstRun: created, + isTTY: opts.IsTTY, + isCI: opts.IsCI, + isAgent: opts.IsAgent, + envSignals: opts.EnvSignals, + cliVersion: cliVersion, + goos: goos, + goarch: goarch, + }, nil +} + +func WithCommandContext(ctx context.Context, cmd CommandContext) context.Context { + return context.WithValue(ctx, commandContextKey{}, cmd) +} + +func WithService(ctx context.Context, service *Service) context.Context { + return context.WithValue(ctx, serviceContextKey{}, service) +} + +func FromContext(ctx context.Context) *Service { + if ctx == nil { + return nil + } + service, _ := ctx.Value(serviceContextKey{}).(*Service) + return service +} + +// Property catalog: see events.go. +func (s *Service) Capture(ctx context.Context, event string, properties map[string]any, groups map[string]string) error { + if !s.canSend() { + return nil + } + mergedProperties := s.baseProperties() + command := commandContextFrom(ctx) + if command.RunID != "" { + mergedProperties[PropCommandRunID] = command.RunID + } + if command.Command != "" { + mergedProperties[PropCommand] = command.Command + } + if command.Flags != nil { + mergedProperties[PropFlags] = command.Flags + } + for key, value := range properties { + mergedProperties[key] = value + } + return s.analytics.Capture(s.distinctID(), event, mergedProperties, mergeGroups(linkedProjectGroups(s.fsys), mergeGroups(command.Groups, groups))) +} + +func (s *Service) StitchLogin(distinctID string) error { + if s == nil { + return nil + } + if s.canSend() { + if err := s.analytics.Alias(distinctID, s.state.DeviceID); err != nil { + return err + } + if err := s.analytics.Identify(distinctID, nil); err != nil { + return err + } + } + s.state.DistinctID = distinctID + return SaveState(s.state, s.fsys) +} + +func (s *Service) ClearDistinctID() error { + if s == nil { + return nil + } + s.state.DistinctID = "" + return SaveState(s.state, s.fsys) +} + +func (s *Service) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { + if !s.canSend() { + return nil + } + return s.analytics.GroupIdentify(groupType, groupKey, s.basePropertiesWith(properties)) +} + +func (s *Service) Close() error { + if s == nil || s.analytics == nil { + return nil + } + return s.analytics.Close() +} + +func (s *Service) baseProperties() map[string]any { + properties := map[string]any{ + PropPlatform: "cli", + PropSchemaVersion: s.state.SchemaVersion, + PropDeviceID: s.state.DeviceID, + PropSessionID: s.state.SessionID, + PropIsFirstRun: s.isFirstRun, + PropIsTTY: s.isTTY, + PropIsCI: s.isCI, + PropIsAgent: s.isAgent, + PropOS: s.goos, + PropArch: s.goarch, + PropCLIVersion: s.cliVersion, + } + if len(s.envSignals) > 0 { + properties[PropEnvSignals] = s.envSignals + } + return properties +} + +func (s *Service) basePropertiesWith(properties map[string]any) map[string]any { + merged := s.baseProperties() + for key, value := range properties { + merged[key] = value + } + return merged +} + +func (s *Service) distinctID() string { + if s.state.DistinctID != "" { + return s.state.DistinctID + } + return s.state.DeviceID +} + +func commandContextFrom(ctx context.Context) CommandContext { + if ctx == nil { + return CommandContext{} + } + cmd, _ := ctx.Value(commandContextKey{}).(CommandContext) + return cmd +} + +func mergeGroups(existing map[string]string, extra map[string]string) map[string]string { + if len(existing) == 0 && len(extra) == 0 { + return nil + } + merged := make(map[string]string, len(existing)+len(extra)) + for key, value := range existing { + merged[key] = value + } + for key, value := range extra { + merged[key] = value + } + return merged +} + +func (s *Service) canSend() bool { + return s != nil && + s.analytics != nil && + s.analytics.Enabled() && + s.state.Enabled && + os.Getenv("DO_NOT_TRACK") != "1" && + os.Getenv("SUPABASE_TELEMETRY_DISABLED") != "1" +} diff --git a/internal/telemetry/service_test.go b/internal/telemetry/service_test.go new file mode 100644 index 0000000000..d8620c85ff --- /dev/null +++ b/internal/telemetry/service_test.go @@ -0,0 +1,256 @@ +package telemetry + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/pkg/api" +) + +type captureCall struct { + distinctID string + event string + properties map[string]any + groups map[string]string +} + +type identifyCall struct { + distinctID string + properties map[string]any +} + +type aliasCall struct { + distinctID string + alias string +} + +type groupIdentifyCall struct { + groupType string + groupKey string + properties map[string]any +} + +type fakeAnalytics struct { + enabled bool + captures []captureCall + identifies []identifyCall + aliases []aliasCall + groupIdentifies []groupIdentifyCall + closed bool +} + +func (f *fakeAnalytics) Enabled() bool { return f.enabled } + +func (f *fakeAnalytics) Capture(distinctID string, event string, properties map[string]any, groups map[string]string) error { + f.captures = append(f.captures, captureCall{distinctID: distinctID, event: event, properties: properties, groups: groups}) + return nil +} + +func (f *fakeAnalytics) Identify(distinctID string, properties map[string]any) error { + f.identifies = append(f.identifies, identifyCall{distinctID: distinctID, properties: properties}) + return nil +} + +func (f *fakeAnalytics) Alias(distinctID string, alias string) error { + f.aliases = append(f.aliases, aliasCall{distinctID: distinctID, alias: alias}) + return nil +} + +func (f *fakeAnalytics) GroupIdentify(groupType string, groupKey string, properties map[string]any) error { + f.groupIdentifies = append(f.groupIdentifies, groupIdentifyCall{groupType: groupType, groupKey: groupKey, properties: properties}) + return nil +} + +func (f *fakeAnalytics) Close() error { + f.closed = true + return nil +} + +func TestServiceCaptureIncludesBasePropertiesAndCommandContext(t *testing.T) { + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + + service, err := NewService(fsys, Options{ + Analytics: analytics, + Now: func() time.Time { return now }, + IsTTY: true, + IsCI: true, + IsAgent: true, + EnvSignals: map[string]any{ + "CLAUDE_CODE": true, + "TERM_PROGRAM": "iTerm.app", + }, + CLIName: "1.2.3", + GOOS: "darwin", + GOARCH: "arm64", + }) + require.NoError(t, err) + + ctx := WithCommandContext(context.Background(), CommandContext{ + RunID: "run-123", + Command: "login", + Flags: map[string]any{ + "token": "", + }, + }) + + require.NoError(t, service.Capture(ctx, EventCommandExecuted, map[string]any{ + PropDurationMs: 42, + }, nil)) + + require.Len(t, analytics.captures, 1) + call := analytics.captures[0] + assert.NoError(t, uuid.Validate(call.distinctID)) + assert.Equal(t, EventCommandExecuted, call.event) + assert.Equal(t, "cli", call.properties[PropPlatform]) + assert.Equal(t, SchemaVersion, call.properties[PropSchemaVersion]) + assert.Equal(t, true, call.properties[PropIsFirstRun]) + assert.Equal(t, true, call.properties[PropIsTTY]) + assert.Equal(t, true, call.properties[PropIsCI]) + assert.Equal(t, true, call.properties[PropIsAgent]) + assert.Equal(t, map[string]any{ + "CLAUDE_CODE": true, + "TERM_PROGRAM": "iTerm.app", + }, call.properties[PropEnvSignals]) + assert.Equal(t, "darwin", call.properties[PropOS]) + assert.Equal(t, "arm64", call.properties[PropArch]) + assert.Equal(t, "1.2.3", call.properties[PropCLIVersion]) + assert.Equal(t, "run-123", call.properties[PropCommandRunID]) + assert.Equal(t, "login", call.properties[PropCommand]) + assert.Equal(t, map[string]any{"token": ""}, call.properties[PropFlags]) + _, hasFlagsUsed := call.properties["flags_used"] + assert.False(t, hasFlagsUsed) + _, hasFlagValues := call.properties["flag_values"] + assert.False(t, hasFlagValues) + assert.Equal(t, 42, call.properties[PropDurationMs]) +} + +func TestServiceStitchLoginPersistsDistinctID(t *testing.T) { + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + + service, err := NewService(fsys, Options{ + Analytics: analytics, + Now: func() time.Time { return now }, + }) + require.NoError(t, err) + deviceID := service.state.DeviceID + + require.NoError(t, service.StitchLogin("user-123")) + require.NoError(t, service.Capture(context.Background(), EventLoginCompleted, nil, nil)) + + require.Len(t, analytics.aliases, 1) + assert.Equal(t, "user-123", analytics.aliases[0].distinctID) + assert.Equal(t, deviceID, analytics.aliases[0].alias) + require.Len(t, analytics.identifies, 1) + assert.Equal(t, "user-123", analytics.identifies[0].distinctID) + require.Len(t, analytics.captures, 1) + assert.Equal(t, "user-123", analytics.captures[0].distinctID) + + state, err := LoadState(fsys) + require.NoError(t, err) + assert.Equal(t, "user-123", state.DistinctID) +} + +func TestServiceClearDistinctIDFallsBackToDeviceID(t *testing.T) { + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + + service, err := NewService(fsys, Options{ + Analytics: analytics, + Now: func() time.Time { return now }, + }) + require.NoError(t, err) + deviceID := service.state.DeviceID + require.NoError(t, service.StitchLogin("user-123")) + + require.NoError(t, service.ClearDistinctID()) + require.NoError(t, service.Capture(context.Background(), EventLoginCompleted, nil, nil)) + + require.Len(t, analytics.captures, 1) + assert.Equal(t, deviceID, analytics.captures[0].distinctID) + + state, err := LoadState(fsys) + require.NoError(t, err) + assert.Empty(t, state.DistinctID) +} + +func TestServiceCaptureIncludesLinkedProjectGroups(t *testing.T) { + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + require.NoError(t, SaveLinkedProject(api.V1ProjectWithDatabaseResponse{ + Ref: "proj_123", + Name: "My Project", + OrganizationId: "org_123", + OrganizationSlug: "acme", + }, fsys)) + + service, err := NewService(fsys, Options{ + Analytics: analytics, + Now: func() time.Time { return now }, + }) + require.NoError(t, err) + + require.NoError(t, service.Capture(context.Background(), EventStackStarted, nil, nil)) + + require.Len(t, analytics.captures, 1) + assert.Equal(t, map[string]string{ + GroupOrganization: "org_123", + GroupProject: "proj_123", + }, analytics.captures[0].groups) +} + +func TestServiceCaptureHonorsConsentAndEnvOptOut(t *testing.T) { + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + + t.Run("disabled telemetry file suppresses capture", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + require.NoError(t, SaveState(State{ + Enabled: false, + DeviceID: uuid.NewString(), + SessionID: uuid.NewString(), + SessionLastActive: now, + SchemaVersion: SchemaVersion, + }, fsys)) + + service, err := NewService(fsys, Options{ + Analytics: analytics, + Now: func() time.Time { return now }, + }) + require.NoError(t, err) + + require.NoError(t, service.Capture(context.Background(), EventCommandExecuted, nil, nil)) + assert.Empty(t, analytics.captures) + }) + + t.Run("DO_NOT_TRACK suppresses capture", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + t.Setenv("DO_NOT_TRACK", "1") + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + + service, err := NewService(fsys, Options{ + Analytics: analytics, + Now: func() time.Time { return now }, + }) + require.NoError(t, err) + + require.NoError(t, service.Capture(context.Background(), EventCommandExecuted, nil, nil)) + assert.Empty(t, analytics.captures) + }) +} diff --git a/internal/telemetry/state.go b/internal/telemetry/state.go new file mode 100644 index 0000000000..58096c5de4 --- /dev/null +++ b/internal/telemetry/state.go @@ -0,0 +1,115 @@ +package telemetry + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-errors/errors" + "github.com/google/uuid" + "github.com/spf13/afero" + "github.com/supabase/cli/internal/utils" +) + +const SchemaVersion = 1 + +const sessionRotationThreshold = 30 * time.Minute + +type State struct { + Enabled bool `json:"enabled"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + SessionLastActive time.Time `json:"session_last_active"` + DistinctID string `json:"distinct_id,omitempty"` + SchemaVersion int `json:"schema_version"` +} + +func telemetryPath() (string, error) { + if home := strings.TrimSpace(os.Getenv("SUPABASE_HOME")); home != "" { + return filepath.Join(home, "telemetry.json"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", errors.Errorf("failed to get $HOME directory: %w", err) + } + return filepath.Join(home, ".supabase", "telemetry.json"), nil +} + +func LoadState(fsys afero.Fs) (State, error) { + path, err := telemetryPath() + if err != nil { + return State{}, err + } + contents, err := afero.ReadFile(fsys, path) + if err != nil { + return State{}, err + } + var state State + if err := json.Unmarshal(contents, &state); err != nil { + return State{}, errors.Errorf("failed to parse telemetry file: %w", err) + } + return state, nil +} + +func SaveState(state State, fsys afero.Fs) error { + path, err := telemetryPath() + if err != nil { + return err + } + contents, err := json.Marshal(state) + if err != nil { + return errors.Errorf("failed to encode telemetry file: %w", err) + } + return utils.WriteFile(path, contents, fsys) +} + +func LoadOrCreateState(fsys afero.Fs, now time.Time) (State, bool, error) { + state, err := LoadState(fsys) + if err == nil { + if now.UTC().Sub(state.SessionLastActive) > sessionRotationThreshold { + state.SessionID = uuid.NewString() + } + state.SessionLastActive = now.UTC() + return state, false, SaveState(state, fsys) + } + if !errors.Is(err, os.ErrNotExist) { + return State{}, false, err + } + state = State{ + Enabled: true, + DeviceID: uuid.NewString(), + SessionID: uuid.NewString(), + SessionLastActive: now.UTC(), + SchemaVersion: SchemaVersion, + } + return state, true, SaveState(state, fsys) +} + +func Disabled(fsys afero.Fs, now time.Time) (bool, error) { + if os.Getenv("DO_NOT_TRACK") == "1" { + return true, nil + } + if os.Getenv("SUPABASE_TELEMETRY_DISABLED") == "1" { + return true, nil + } + state, _, err := LoadOrCreateState(fsys, now) + if err != nil { + return false, err + } + return !state.Enabled, nil +} + +func SetEnabled(fsys afero.Fs, enabled bool, now time.Time) (State, error) { + state, _, err := LoadOrCreateState(fsys, now) + if err != nil { + return State{}, err + } + state.Enabled = enabled + return state, SaveState(state, fsys) +} + +func Status(fsys afero.Fs, now time.Time) (State, bool, error) { + return LoadOrCreateState(fsys, now) +} diff --git a/internal/telemetry/state_test.go b/internal/telemetry/state_test.go new file mode 100644 index 0000000000..a2a07a5b35 --- /dev/null +++ b/internal/telemetry/state_test.go @@ -0,0 +1,209 @@ +package telemetry + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTelemetryPath(t *testing.T) { + t.Run("uses SUPABASE_HOME when set", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + t.Setenv("HOME", "/tmp/ignored-home") + + path, err := telemetryPath() + + require.NoError(t, err) + assert.Equal(t, "/tmp/supabase-home/telemetry.json", path) + }) + + t.Run("falls back to HOME/.supabase", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "") + t.Setenv("HOME", "/tmp/home") + + path, err := telemetryPath() + + require.NoError(t, err) + assert.Equal(t, "/tmp/home/.supabase/telemetry.json", path) + }) +} + +func TestLoadOrCreateState(t *testing.T) { + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + + t.Run("creates default state and writes it", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.True(t, created) + assert.True(t, state.Enabled) + assert.Equal(t, SchemaVersion, state.SchemaVersion) + assert.Equal(t, now, state.SessionLastActive) + assert.Empty(t, state.DistinctID) + assert.NoError(t, uuid.Validate(state.DeviceID)) + assert.NoError(t, uuid.Validate(state.SessionID)) + + saved, err := LoadState(fsys) + require.NoError(t, err) + assert.Equal(t, state, saved) + }) + + t.Run("updates last active and preserves existing state", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + initial := State{ + Enabled: false, + DeviceID: uuid.NewString(), + SessionID: uuid.NewString(), + SessionLastActive: now.Add(-10 * time.Minute), + DistinctID: "user-123", + SchemaVersion: SchemaVersion, + } + require.NoError(t, SaveState(initial, fsys)) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.False(t, created) + assert.False(t, state.Enabled) + assert.Equal(t, initial.DeviceID, state.DeviceID) + assert.Equal(t, initial.SessionID, state.SessionID) + assert.Equal(t, "user-123", state.DistinctID) + assert.Equal(t, now, state.SessionLastActive) + }) + + t.Run("rotates stale session after inactivity threshold", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + initial := State{ + Enabled: true, + DeviceID: uuid.NewString(), + SessionID: uuid.NewString(), + SessionLastActive: now.Add(-(sessionRotationThreshold + time.Minute)), + DistinctID: "user-123", + SchemaVersion: SchemaVersion, + } + require.NoError(t, SaveState(initial, fsys)) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.False(t, created) + assert.Equal(t, initial.DeviceID, state.DeviceID) + assert.NotEqual(t, initial.SessionID, state.SessionID) + assert.Equal(t, "user-123", state.DistinctID) + assert.Equal(t, now, state.SessionLastActive) + + saved, err := LoadState(fsys) + require.NoError(t, err) + assert.Equal(t, state, saved) + }) +} + +func TestTelemetryDisabled(t *testing.T) { + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + + t.Run("honors DO_NOT_TRACK", func(t *testing.T) { + t.Setenv("DO_NOT_TRACK", "1") + fsys := afero.NewMemMapFs() + + disabled, err := Disabled(fsys, now) + + require.NoError(t, err) + assert.True(t, disabled) + }) + + t.Run("honors SUPABASE_TELEMETRY_DISABLED", func(t *testing.T) { + t.Setenv("SUPABASE_TELEMETRY_DISABLED", "1") + fsys := afero.NewMemMapFs() + + disabled, err := Disabled(fsys, now) + + require.NoError(t, err) + assert.True(t, disabled) + }) + + t.Run("honors disabled state file", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + require.NoError(t, SaveState(State{ + Enabled: false, + DeviceID: uuid.NewString(), + SessionID: uuid.NewString(), + SessionLastActive: now, + SchemaVersion: SchemaVersion, + }, fsys)) + + disabled, err := Disabled(fsys, now) + + require.NoError(t, err) + assert.True(t, disabled) + }) + + t.Run("creates enabled state when missing", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + + disabled, err := Disabled(fsys, now) + + require.NoError(t, err) + assert.False(t, disabled) + }) +} + +func TestSetEnabledAndStatus(t *testing.T) { + now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + + t.Run("disable preserves identity fields", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + initial, _, err := LoadOrCreateState(fsys, now) + require.NoError(t, err) + initial.DistinctID = "user-123" + require.NoError(t, SaveState(initial, fsys)) + + state, err := SetEnabled(fsys, false, now.Add(time.Minute)) + + require.NoError(t, err) + assert.False(t, state.Enabled) + assert.Equal(t, initial.DeviceID, state.DeviceID) + assert.Equal(t, initial.SessionID, state.SessionID) + assert.Equal(t, "user-123", state.DistinctID) + }) + + t.Run("enable flips disabled state back on", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + require.NoError(t, SaveState(State{ + Enabled: false, + DeviceID: uuid.NewString(), + SessionID: uuid.NewString(), + SessionLastActive: now, + SchemaVersion: SchemaVersion, + }, fsys)) + + state, err := SetEnabled(fsys, true, now.Add(time.Minute)) + + require.NoError(t, err) + assert.True(t, state.Enabled) + }) + + t.Run("status creates default state when missing", func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + + state, created, err := Status(fsys, now) + + require.NoError(t, err) + assert.True(t, created) + assert.True(t, state.Enabled) + assert.NoError(t, uuid.Validate(state.DeviceID)) + }) +} diff --git a/internal/utils/agent/agent.go b/internal/utils/agent/agent.go index 37804c965b..65846ea62a 100644 --- a/internal/utils/agent/agent.go +++ b/internal/utils/agent/agent.go @@ -13,10 +13,7 @@ func IsAgent() bool { return true } // Cursor - if os.Getenv("CURSOR_TRACE_ID") != "" { - return true - } - if os.Getenv("CURSOR_AGENT") != "" { + if os.Getenv("CURSOR_AGENT") != "" || os.Getenv("CURSOR_EXTENSION_HOST_ROLE") != "" { return true } // Gemini diff --git a/internal/utils/agent/agent_test.go b/internal/utils/agent/agent_test.go index 4fe2815882..1fc1c8be9b 100644 --- a/internal/utils/agent/agent_test.go +++ b/internal/utils/agent/agent_test.go @@ -11,7 +11,8 @@ func clearAgentEnv(t *testing.T) { t.Helper() for _, key := range []string{ "AI_AGENT", - "CURSOR_TRACE_ID", "CURSOR_AGENT", + "CURSOR_AGENT", + "CURSOR_EXTENSION_HOST_ROLE", "GEMINI_CLI", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", "ANTIGRAVITY_AGENT", @@ -44,11 +45,12 @@ func TestIsAgent(t *testing.T) { }) t.Run("detects Cursor via CURSOR_TRACE_ID", func(t *testing.T) { - t.Setenv("CURSOR_TRACE_ID", "abc123") + t.Setenv("CURSOR_EXTENSION_HOST_ROLE", "agent-exec") assert.True(t, IsAgent()) }) - t.Run("detects Cursor CLI via CURSOR_AGENT", func(t *testing.T) { + t.Run("detects Cursor via CURSOR_AGENT", func(t *testing.T) { + clearAgentEnv(t) t.Setenv("CURSOR_AGENT", "1") assert.True(t, IsAgent()) }) diff --git a/internal/utils/misc.go b/internal/utils/misc.go index e0518acc7f..f947369a15 100644 --- a/internal/utils/misc.go +++ b/internal/utils/misc.go @@ -22,8 +22,10 @@ import ( // Assigned using `-ldflags` https://stackoverflow.com/q/11354518 var ( - Version string - SentryDsn string + Version string + SentryDsn string + PostHogAPIKey string + PostHogEndpoint string ) func ShortContainerImageName(imageName string) string {