diff --git a/cmd/root.go b/cmd/root.go index ae2966ab97..f8468a3e58 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -173,6 +173,7 @@ func Execute() { executedCmd, err := rootCmd.ExecuteC() if executedCmd != nil { if service := telemetry.FromContext(executedCmd.Context()); service != nil { + ensureProjectGroupsCached(executedCmd.Context(), service) _ = service.Capture(executedCmd.Context(), telemetry.EventCommandExecuted, map[string]any{ telemetry.PropExitCode: exitCode(err), telemetry.PropDurationMs: time.Since(startedAt).Milliseconds(), @@ -200,6 +201,35 @@ func Execute() { } } +// ensureProjectGroupsCached populates the telemetry linked-project cache when +// a project ref is available but no cache exists. This ensures org/project +// PostHog groups are attached to all CLI events, not just those after `supabase link`. +// +// Does not overwrite an existing cache — `supabase link` is the authoritative source. +// Checks auth before calling the API to avoid the log.Fatalln in GetSupabase(). +func ensureProjectGroupsCached(ctx context.Context, service *telemetry.Service) { + ref := flags.ProjectRef + if ref == "" { + return + } + fsys := afero.NewOsFs() + if telemetry.HasLinkedProject(fsys) { + return + } + if _, err := utils.LoadAccessTokenFS(fsys); err != nil { + return + } + resp, err := utils.GetSupabase().V1GetProjectWithResponse(ctx, ref) + if err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + return + } + if resp.JSON200 == nil { + return + } + telemetry.CacheProjectAndIdentifyGroups(*resp.JSON200, service, fsys) +} + func exitCode(err error) int { if err != nil { return 1 diff --git a/internal/telemetry/project.go b/internal/telemetry/project.go index 63ec40b35f..e85e72c1f8 100644 --- a/internal/telemetry/project.go +++ b/internal/telemetry/project.go @@ -2,6 +2,7 @@ package telemetry import ( "encoding/json" + "fmt" "os" "path/filepath" @@ -48,6 +49,44 @@ func LoadLinkedProject(fsys afero.Fs) (LinkedProject, error) { return linked, nil } +// HasLinkedProject reports whether a cached linked-project.json exists. +func HasLinkedProject(fsys afero.Fs) bool { + _, err := LoadLinkedProject(fsys) + return err == nil +} + +// CacheProjectAndIdentifyGroups writes project metadata to linked-project.json +// and fires GroupIdentify for the org and project so PostHog has group metadata. +// This matches the behavior of the `supabase link` flow. +// +// The caller is responsible for fetching the project from the API and checking +// auth — this function only handles caching and PostHog group identification. +// +// Best-effort: logs errors to debug output, never returns them. +func CacheProjectAndIdentifyGroups(project api.V1ProjectWithDatabaseResponse, service *Service, fsys afero.Fs) { + if err := SaveLinkedProject(project, fsys); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + if service == nil { + return + } + if project.OrganizationId != "" { + if err := service.GroupIdentify(GroupOrganization, project.OrganizationId, map[string]any{ + "organization_slug": project.OrganizationSlug, + }); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + } + if project.Ref != "" { + if err := service.GroupIdentify(GroupProject, project.Ref, map[string]any{ + "name": project.Name, + "organization_slug": project.OrganizationSlug, + }); err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + } + } +} + func linkedProjectGroups(fsys afero.Fs) map[string]string { linked, err := LoadLinkedProject(fsys) if err != nil { diff --git a/internal/telemetry/project_test.go b/internal/telemetry/project_test.go new file mode 100644 index 0000000000..faefb87747 --- /dev/null +++ b/internal/telemetry/project_test.go @@ -0,0 +1,126 @@ +package telemetry + +import ( + "testing" + "time" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/pkg/api" +) + +var testProject = api.V1ProjectWithDatabaseResponse{ + Ref: "proj_abc", + Name: "My Project", + OrganizationId: "org_123", + OrganizationSlug: "acme", +} + +func newTestService(t *testing.T, fsys afero.Fs, analytics *fakeAnalytics) *Service { + t.Helper() + service, err := NewService(fsys, Options{ + Analytics: analytics, + Now: func() time.Time { return time.Date(2026, time.April, 15, 12, 0, 0, 0, time.UTC) }, + }) + require.NoError(t, err) + return service +} + +func TestHasLinkedProject(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + + t.Run("false when no cache", func(t *testing.T) { + fsys := afero.NewMemMapFs() + assert.False(t, HasLinkedProject(fsys)) + }) + + t.Run("true when cache exists", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, SaveLinkedProject(testProject, fsys)) + assert.True(t, HasLinkedProject(fsys)) + }) +} + +func TestCacheProjectAndIdentifyGroups(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + + t.Run("writes cache file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + CacheProjectAndIdentifyGroups(testProject, nil, fsys) + + linked, err := LoadLinkedProject(fsys) + require.NoError(t, err) + assert.Equal(t, "proj_abc", linked.Ref) + assert.Equal(t, "org_123", linked.OrganizationID) + assert.Equal(t, "acme", linked.OrganizationSlug) + assert.Equal(t, "My Project", linked.Name) + }) + + t.Run("fires GroupIdentify for org and project", func(t *testing.T) { + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + service := newTestService(t, fsys, analytics) + + CacheProjectAndIdentifyGroups(testProject, service, fsys) + + require.Len(t, analytics.groupIdentifies, 2) + + orgCall := analytics.groupIdentifies[0] + assert.Equal(t, GroupOrganization, orgCall.groupType) + assert.Equal(t, "org_123", orgCall.groupKey) + assert.Equal(t, "acme", orgCall.properties["organization_slug"]) + + projCall := analytics.groupIdentifies[1] + assert.Equal(t, GroupProject, projCall.groupType) + assert.Equal(t, "proj_abc", projCall.groupKey) + assert.Equal(t, "My Project", projCall.properties["name"]) + assert.Equal(t, "acme", projCall.properties["organization_slug"]) + }) + + t.Run("skips GroupIdentify when service is nil", func(t *testing.T) { + fsys := afero.NewMemMapFs() + CacheProjectAndIdentifyGroups(testProject, nil, fsys) + + // Cache should still be written + linked, err := LoadLinkedProject(fsys) + require.NoError(t, err) + assert.Equal(t, "proj_abc", linked.Ref) + }) + + t.Run("skips GroupIdentify for empty org ID", func(t *testing.T) { + fsys := afero.NewMemMapFs() + analytics := &fakeAnalytics{enabled: true} + service := newTestService(t, fsys, analytics) + + noOrgProject := api.V1ProjectWithDatabaseResponse{ + Ref: "proj_abc", + Name: "My Project", + } + CacheProjectAndIdentifyGroups(noOrgProject, service, fsys) + + // Only project GroupIdentify, no org + require.Len(t, analytics.groupIdentifies, 1) + assert.Equal(t, GroupProject, analytics.groupIdentifies[0].groupType) + }) +} + +func TestLinkedProjectGroups(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + + t.Run("returns nil when no cache", func(t *testing.T) { + fsys := afero.NewMemMapFs() + groups := linkedProjectGroups(fsys) + assert.Nil(t, groups) + }) + + t.Run("returns groups from cache", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, SaveLinkedProject(testProject, fsys)) + groups := linkedProjectGroups(fsys) + assert.Equal(t, map[string]string{ + GroupOrganization: "org_123", + GroupProject: "proj_abc", + }, groups) + }) +}