From fa4926269c2fa803b1651a69ad1e06d0d2886335 Mon Sep 17 00:00:00 2001 From: Sean Oliver <882952+seanoliver@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:20:06 -0700 Subject: [PATCH 1/2] feat(telemetry): attach org/project groups to all CLI events Only ~19% of CLI events had PostHog group properties ($group_0, $group_1) because groups were only set during `supabase link`. Commands using --project-ref without linking sent events invisible to group analytics. Add EnsureProjectGroupsCached which resolves and caches project metadata (including org ID) in linked-project.json when a project ref is available. The cache is checked before every cli_command_executed event, so the API call only happens once per unique project ref. Closes GROWTH-761 --- cmd/root.go | 1 + internal/telemetry/project.go | 29 ++++++ internal/telemetry/project_test.go | 152 +++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 internal/telemetry/project_test.go diff --git a/cmd/root.go b/cmd/root.go index ae2966ab97..6faaa11ac5 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 { + telemetry.EnsureProjectGroupsCached(executedCmd.Context(), flags.ProjectRef, afero.NewOsFs()) _ = service.Capture(executedCmd.Context(), telemetry.EventCommandExecuted, map[string]any{ telemetry.PropExitCode: exitCode(err), telemetry.PropDurationMs: time.Since(startedAt).Milliseconds(), diff --git a/internal/telemetry/project.go b/internal/telemetry/project.go index 63ec40b35f..84fb033733 100644 --- a/internal/telemetry/project.go +++ b/internal/telemetry/project.go @@ -1,7 +1,9 @@ package telemetry import ( + "context" "encoding/json" + "fmt" "os" "path/filepath" @@ -48,6 +50,33 @@ func LoadLinkedProject(fsys afero.Fs) (LinkedProject, error) { return linked, nil } +// EnsureProjectGroupsCached fetches project metadata from the API and caches it +// in linked-project.json when a project ref is available but no matching cache +// exists. This ensures linkedProjectGroups returns org/project groups for all +// events, not just those fired after `supabase link`. +// +// Best-effort: silently returns on any error so telemetry never breaks commands. +func EnsureProjectGroupsCached(ctx context.Context, projectRef string, fsys afero.Fs) { + if projectRef == "" { + return + } + // Already cached and matches current ref? Nothing to do. + if existing, err := LoadLinkedProject(fsys); err == nil && existing.Ref == projectRef { + return + } + resp, err := utils.GetSupabase().V1GetProjectWithResponse(ctx, projectRef) + if err != nil { + fmt.Fprintln(utils.GetDebugLogger(), err) + return + } + if resp.JSON200 == nil { + return + } + if err := SaveLinkedProject(*resp.JSON200, fsys); 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..0e122a1f0b --- /dev/null +++ b/internal/telemetry/project_test.go @@ -0,0 +1,152 @@ +package telemetry + +import ( + "context" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/internal/testing/apitest" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/api" +) + +func TestEnsureProjectGroupsCached(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + + projectJSON := map[string]interface{}{ + "ref": "proj_abc", + "organization_id": "org_123", + "organization_slug": "acme", + "name": "My Project", + "region": "us-east-1", + "created_at": "2024-01-01T00:00:00Z", + "status": "ACTIVE_HEALTHY", + "database": map[string]interface{}{"host": "db.example.supabase.co", "version": "15.1.0.117"}, + } + + t.Run("skips when project ref is empty", func(t *testing.T) { + fsys := afero.NewMemMapFs() + EnsureProjectGroupsCached(context.Background(), "", fsys) + _, err := LoadLinkedProject(fsys) + assert.Error(t, err) + }) + + t.Run("skips when cache already matches", func(t *testing.T) { + fsys := afero.NewMemMapFs() + require.NoError(t, SaveLinkedProject(api.V1ProjectWithDatabaseResponse{ + Ref: "proj_abc", + Name: "My Project", + OrganizationId: "org_123", + OrganizationSlug: "acme", + }, fsys)) + // No gock mocks — any API call would panic + EnsureProjectGroupsCached(context.Background(), "proj_abc", fsys) + linked, err := LoadLinkedProject(fsys) + require.NoError(t, err) + assert.Equal(t, "org_123", linked.OrganizationID) + }) + + t.Run("fetches and caches when no cache exists", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(utils.DefaultApiHost). + Get("/v1/projects/proj_abc"). + Reply(http.StatusOK). + JSON(projectJSON) + + fsys := afero.NewMemMapFs() + EnsureProjectGroupsCached(context.Background(), "proj_abc", 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) + }) + + t.Run("updates cache when ref differs", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(utils.DefaultApiHost). + Get("/v1/projects/proj_xyz"). + Reply(http.StatusOK). + JSON(map[string]interface{}{ + "ref": "proj_xyz", + "organization_id": "org_456", + "organization_slug": "other", + "name": "Other Project", + "region": "eu-west-1", + "created_at": "2024-06-01T00:00:00Z", + "status": "ACTIVE_HEALTHY", + "database": map[string]interface{}{"host": "db.other.supabase.co", "version": "15.1.0.117"}, + }) + + fsys := afero.NewMemMapFs() + require.NoError(t, SaveLinkedProject(api.V1ProjectWithDatabaseResponse{ + Ref: "proj_abc", + Name: "My Project", + OrganizationId: "org_123", + OrganizationSlug: "acme", + }, fsys)) + + EnsureProjectGroupsCached(context.Background(), "proj_xyz", fsys) + + linked, err := LoadLinkedProject(fsys) + require.NoError(t, err) + assert.Equal(t, "proj_xyz", linked.Ref) + assert.Equal(t, "org_456", linked.OrganizationID) + }) + + t.Run("no-ops on API error", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(utils.DefaultApiHost). + Get("/v1/projects/proj_bad"). + ReplyError(assert.AnError) + + fsys := afero.NewMemMapFs() + EnsureProjectGroupsCached(context.Background(), "proj_bad", fsys) + + _, err := LoadLinkedProject(fsys) + assert.Error(t, err) // no cache written + }) + + t.Run("no-ops on 404", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(utils.DefaultApiHost). + Get("/v1/projects/proj_missing"). + Reply(http.StatusNotFound) + + fsys := afero.NewMemMapFs() + EnsureProjectGroupsCached(context.Background(), "proj_missing", fsys) + + _, err := LoadLinkedProject(fsys) + assert.Error(t, err) // no cache written + }) +} + +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(api.V1ProjectWithDatabaseResponse{ + Ref: "proj_abc", + Name: "My Project", + OrganizationId: "org_123", + OrganizationSlug: "acme", + }, fsys)) + groups := linkedProjectGroups(fsys) + assert.Equal(t, map[string]string{ + GroupOrganization: "org_123", + GroupProject: "proj_abc", + }, groups) + }) +} From 8e108f0cf608a3e144ba56f7fe7d8c0725d8db64 Mon Sep 17 00:00:00 2001 From: Sean Oliver <882952+seanoliver@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:36:16 -0700 Subject: [PATCH 2/2] fix: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard against log.Fatalln crash: check auth token before calling GetSupabase(), and move the API call to cmd/root.go where it belongs - Don't overwrite existing linked-project.json cache — supabase link is the authoritative source, we only fill the gap when no cache exists - Fire GroupIdentify for org and project after caching, matching the link flow so PostHog has group metadata - Restructure so telemetry package has no API dependencies (pure caching + PostHog calls), making tests reliable without gock/mocks --- cmd/root.go | 31 +++++- internal/telemetry/project.go | 50 +++++---- internal/telemetry/project_test.go | 158 ++++++++++++----------------- 3 files changed, 126 insertions(+), 113 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 6faaa11ac5..f8468a3e58 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -173,7 +173,7 @@ func Execute() { executedCmd, err := rootCmd.ExecuteC() if executedCmd != nil { if service := telemetry.FromContext(executedCmd.Context()); service != nil { - telemetry.EnsureProjectGroupsCached(executedCmd.Context(), flags.ProjectRef, afero.NewOsFs()) + ensureProjectGroupsCached(executedCmd.Context(), service) _ = service.Capture(executedCmd.Context(), telemetry.EventCommandExecuted, map[string]any{ telemetry.PropExitCode: exitCode(err), telemetry.PropDurationMs: time.Since(startedAt).Milliseconds(), @@ -201,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 84fb033733..e85e72c1f8 100644 --- a/internal/telemetry/project.go +++ b/internal/telemetry/project.go @@ -1,7 +1,6 @@ package telemetry import ( - "context" "encoding/json" "fmt" "os" @@ -50,30 +49,41 @@ func LoadLinkedProject(fsys afero.Fs) (LinkedProject, error) { return linked, nil } -// EnsureProjectGroupsCached fetches project metadata from the API and caches it -// in linked-project.json when a project ref is available but no matching cache -// exists. This ensures linkedProjectGroups returns org/project groups for all -// events, not just those fired after `supabase link`. +// 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. // -// Best-effort: silently returns on any error so telemetry never breaks commands. -func EnsureProjectGroupsCached(ctx context.Context, projectRef string, fsys afero.Fs) { - if projectRef == "" { - return - } - // Already cached and matches current ref? Nothing to do. - if existing, err := LoadLinkedProject(fsys); err == nil && existing.Ref == projectRef { - return - } - resp, err := utils.GetSupabase().V1GetProjectWithResponse(ctx, projectRef) - if err != nil { +// 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) - return } - if resp.JSON200 == nil { + if service == nil { return } - if err := SaveLinkedProject(*resp.JSON200, fsys); err != nil { - fmt.Fprintln(utils.GetDebugLogger(), err) + 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) + } } } diff --git a/internal/telemetry/project_test.go b/internal/telemetry/project_test.go index 0e122a1f0b..faefb87747 100644 --- a/internal/telemetry/project_test.go +++ b/internal/telemetry/project_test.go @@ -1,128 +1,107 @@ package telemetry import ( - "context" - "net/http" "testing" + "time" - "github.com/h2non/gock" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/supabase/cli/internal/testing/apitest" - "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" ) -func TestEnsureProjectGroupsCached(t *testing.T) { +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") - projectJSON := map[string]interface{}{ - "ref": "proj_abc", - "organization_id": "org_123", - "organization_slug": "acme", - "name": "My Project", - "region": "us-east-1", - "created_at": "2024-01-01T00:00:00Z", - "status": "ACTIVE_HEALTHY", - "database": map[string]interface{}{"host": "db.example.supabase.co", "version": "15.1.0.117"}, - } - - t.Run("skips when project ref is empty", func(t *testing.T) { + t.Run("false when no cache", func(t *testing.T) { fsys := afero.NewMemMapFs() - EnsureProjectGroupsCached(context.Background(), "", fsys) - _, err := LoadLinkedProject(fsys) - assert.Error(t, err) + assert.False(t, HasLinkedProject(fsys)) }) - t.Run("skips when cache already matches", func(t *testing.T) { + t.Run("true when cache exists", func(t *testing.T) { fsys := afero.NewMemMapFs() - require.NoError(t, SaveLinkedProject(api.V1ProjectWithDatabaseResponse{ - Ref: "proj_abc", - Name: "My Project", - OrganizationId: "org_123", - OrganizationSlug: "acme", - }, fsys)) - // No gock mocks — any API call would panic - EnsureProjectGroupsCached(context.Background(), "proj_abc", fsys) - linked, err := LoadLinkedProject(fsys) - require.NoError(t, err) - assert.Equal(t, "org_123", linked.OrganizationID) + require.NoError(t, SaveLinkedProject(testProject, fsys)) + assert.True(t, HasLinkedProject(fsys)) }) +} - t.Run("fetches and caches when no cache exists", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/proj_abc"). - Reply(http.StatusOK). - JSON(projectJSON) +func TestCacheProjectAndIdentifyGroups(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + t.Run("writes cache file", func(t *testing.T) { fsys := afero.NewMemMapFs() - EnsureProjectGroupsCached(context.Background(), "proj_abc", fsys) + 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("updates cache when ref differs", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/proj_xyz"). - Reply(http.StatusOK). - JSON(map[string]interface{}{ - "ref": "proj_xyz", - "organization_id": "org_456", - "organization_slug": "other", - "name": "Other Project", - "region": "eu-west-1", - "created_at": "2024-06-01T00:00:00Z", - "status": "ACTIVE_HEALTHY", - "database": map[string]interface{}{"host": "db.other.supabase.co", "version": "15.1.0.117"}, - }) - + t.Run("fires GroupIdentify for org and project", func(t *testing.T) { fsys := afero.NewMemMapFs() - require.NoError(t, SaveLinkedProject(api.V1ProjectWithDatabaseResponse{ - Ref: "proj_abc", - Name: "My Project", - OrganizationId: "org_123", - OrganizationSlug: "acme", - }, fsys)) + analytics := &fakeAnalytics{enabled: true} + service := newTestService(t, fsys, analytics) - EnsureProjectGroupsCached(context.Background(), "proj_xyz", fsys) + CacheProjectAndIdentifyGroups(testProject, service, fsys) - linked, err := LoadLinkedProject(fsys) - require.NoError(t, err) - assert.Equal(t, "proj_xyz", linked.Ref) - assert.Equal(t, "org_456", linked.OrganizationID) - }) + require.Len(t, analytics.groupIdentifies, 2) - t.Run("no-ops on API error", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/proj_bad"). - ReplyError(assert.AnError) + 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() - EnsureProjectGroupsCached(context.Background(), "proj_bad", fsys) + CacheProjectAndIdentifyGroups(testProject, nil, fsys) - _, err := LoadLinkedProject(fsys) - assert.Error(t, err) // no cache written + // Cache should still be written + linked, err := LoadLinkedProject(fsys) + require.NoError(t, err) + assert.Equal(t, "proj_abc", linked.Ref) }) - t.Run("no-ops on 404", func(t *testing.T) { - t.Cleanup(apitest.MockPlatformAPI(t)) - gock.New(utils.DefaultApiHost). - Get("/v1/projects/proj_missing"). - Reply(http.StatusNotFound) - + t.Run("skips GroupIdentify for empty org ID", func(t *testing.T) { fsys := afero.NewMemMapFs() - EnsureProjectGroupsCached(context.Background(), "proj_missing", fsys) - - _, err := LoadLinkedProject(fsys) - assert.Error(t, err) // no cache written + 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) }) } @@ -137,12 +116,7 @@ func TestLinkedProjectGroups(t *testing.T) { t.Run("returns groups from cache", func(t *testing.T) { fsys := afero.NewMemMapFs() - require.NoError(t, SaveLinkedProject(api.V1ProjectWithDatabaseResponse{ - Ref: "proj_abc", - Name: "My Project", - OrganizationId: "org_123", - OrganizationSlug: "acme", - }, fsys)) + require.NoError(t, SaveLinkedProject(testProject, fsys)) groups := linkedProjectGroups(fsys) assert.Equal(t, map[string]string{ GroupOrganization: "org_123",