Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions internal/telemetry/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package telemetry

import (
"encoding/json"
"fmt"
"os"
"path/filepath"

Expand Down Expand Up @@ -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 {
Expand Down
126 changes: 126 additions & 0 deletions internal/telemetry/project_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading