diff --git a/internal/branches/create/create.go b/internal/branches/create/create.go index 6a1e75cc54..25273456c1 100644 --- a/internal/branches/create/create.go +++ b/internal/branches/create/create.go @@ -31,8 +31,8 @@ func Run(ctx context.Context, body api.CreateBranchBody, fsys afero.Fs) error { if err != nil { return errors.Errorf("failed to create preview branch: %w", err) } else if resp.JSON201 == nil { - if orgSlug, was402 := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()); was402 { - trackUpgradeSuggested(ctx, "branching_limit", orgSlug) + if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()); isGated { + telemetry.TrackUpgradeSuggested(ctx, "branching_limit", orgSlug) } return errors.Errorf("unexpected create branch status %d: %s", resp.StatusCode(), string(resp.Body)) } @@ -44,12 +44,3 @@ func Run(ctx context.Context, body api.CreateBranchBody, fsys afero.Fs) error { } return utils.EncodeOutput(utils.OutputFormat.Value, os.Stdout, *resp.JSON201) } - -func trackUpgradeSuggested(ctx context.Context, featureKey, orgSlug string) { - if svc := telemetry.FromContext(ctx); svc != nil { - _ = svc.Capture(ctx, telemetry.EventUpgradeSuggested, map[string]any{ - telemetry.PropFeatureKey: featureKey, - telemetry.PropOrgSlug: orgSlug, - }, nil) - } -} diff --git a/internal/branches/update/update.go b/internal/branches/update/update.go index 0e3f2e8d0c..7abbabb9af 100644 --- a/internal/branches/update/update.go +++ b/internal/branches/update/update.go @@ -11,7 +11,6 @@ import ( "github.com/supabase/cli/internal/branches/pause" "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/api" ) @@ -24,8 +23,8 @@ func Run(ctx context.Context, branchId string, body api.UpdateBranchBody, fsys a if err != nil { return errors.Errorf("failed to update preview branch: %w", err) } else if resp.JSON200 == nil { - if orgSlug, was402 := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode()); was402 { - trackUpgradeSuggested(ctx, "branching_persistent", orgSlug) + if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "branching_persistent", resp.StatusCode()); isGated { + telemetry.TrackUpgradeSuggested(ctx, "branching_persistent", orgSlug) } return errors.Errorf("unexpected update branch status %d: %s", resp.StatusCode(), string(resp.Body)) } @@ -36,12 +35,3 @@ func Run(ctx context.Context, branchId string, body api.UpdateBranchBody, fsys a } return utils.EncodeOutput(utils.OutputFormat.Value, os.Stdout, *resp.JSON200) } - -func trackUpgradeSuggested(ctx context.Context, featureKey, orgSlug string) { - if svc := telemetry.FromContext(ctx); svc != nil { - _ = svc.Capture(ctx, telemetry.EventUpgradeSuggested, map[string]any{ - telemetry.PropFeatureKey: featureKey, - telemetry.PropOrgSlug: orgSlug, - }, nil) - } -} diff --git a/internal/sso/create/create.go b/internal/sso/create/create.go index 68babc7429..acf3eb14e5 100644 --- a/internal/sso/create/create.go +++ b/internal/sso/create/create.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/afero" "github.com/supabase/cli/internal/sso/internal/render" "github.com/supabase/cli/internal/sso/internal/saml" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" "github.com/supabase/cli/pkg/cast" @@ -78,10 +79,12 @@ func Run(ctx context.Context, params RunParams) error { } if resp.JSON201 == nil { + if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", resp.StatusCode()); isGated { + telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + } if resp.StatusCode() == http.StatusNotFound { return errors.New("SAML 2.0 support is not enabled for this project. Please enable it through the dashboard") } - return errors.New("Unexpected error adding identity provider: " + string(resp.Body)) } diff --git a/internal/sso/list/list.go b/internal/sso/list/list.go index a517149bed..57ff92dd51 100644 --- a/internal/sso/list/list.go +++ b/internal/sso/list/list.go @@ -7,6 +7,7 @@ import ( "github.com/go-errors/errors" "github.com/supabase/cli/internal/sso/internal/render" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" ) @@ -17,10 +18,12 @@ func Run(ctx context.Context, ref, format string) error { } if resp.JSON200 == nil { + if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode()); isGated { + telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + } if resp.StatusCode() == http.StatusNotFound { return errors.New("Looks like SAML 2.0 support is not enabled for this project. Please use the dashboard to enable it.") } - return errors.New("unexpected error listing identity providers: " + string(resp.Body)) } diff --git a/internal/sso/list/list_test.go b/internal/sso/list/list_test.go index 333ffd4a36..d655ed85a2 100644 --- a/internal/sso/list/list_test.go +++ b/internal/sso/list/list_test.go @@ -2,6 +2,7 @@ package list import ( "context" + "net/http" "testing" "github.com/h2non/gock" @@ -83,6 +84,10 @@ func TestSSOProvidersListCommand(t *testing.T) { Get("/v1/projects/" + projectRef + "/config/auth/sso/providers"). Reply(404). JSON(map[string]string{}) + // SuggestUpgradeOnError triggers on non-2xx; project lookup will 404 + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef). + Reply(http.StatusNotFound) err := Run(context.Background(), projectRef, utils.OutputPretty) diff --git a/internal/sso/remove/remove.go b/internal/sso/remove/remove.go index 9de49be3af..a13d946c82 100644 --- a/internal/sso/remove/remove.go +++ b/internal/sso/remove/remove.go @@ -8,6 +8,7 @@ import ( "github.com/go-errors/errors" "github.com/google/uuid" "github.com/supabase/cli/internal/sso/internal/render" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" ) @@ -23,10 +24,12 @@ func Run(ctx context.Context, ref, providerId, format string) error { } if resp.JSON200 == nil { + if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode()); isGated { + telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + } if resp.StatusCode() == http.StatusNotFound { return errors.Errorf("An identity provider with ID %q could not be found.", providerId) } - return errors.New("Unexpected error removing identity provider: " + string(resp.Body)) } diff --git a/internal/sso/remove/remove_test.go b/internal/sso/remove/remove_test.go index f284f7e06f..899a58ae8c 100644 --- a/internal/sso/remove/remove_test.go +++ b/internal/sso/remove/remove_test.go @@ -3,6 +3,7 @@ package remove import ( "context" "fmt" + "net/http" "testing" "github.com/h2non/gock" @@ -82,6 +83,10 @@ func TestSSOProvidersRemoveCommand(t *testing.T) { Delete("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId). Reply(404). JSON(map[string]string{}) + // SuggestUpgradeOnError triggers on non-2xx; project lookup will 404 + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef). + Reply(http.StatusNotFound) err := Run(context.Background(), projectRef, providerId, utils.OutputPretty) diff --git a/internal/sso/update/update.go b/internal/sso/update/update.go index 941b528bcd..fe9212b6de 100644 --- a/internal/sso/update/update.go +++ b/internal/sso/update/update.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/afero" "github.com/supabase/cli/internal/sso/internal/render" "github.com/supabase/cli/internal/sso/internal/saml" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" "github.com/supabase/cli/pkg/cast" @@ -44,10 +45,12 @@ func Run(ctx context.Context, params RunParams) error { } if getResp.JSON200 == nil { + if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", getResp.StatusCode()); isGated { + telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + } if getResp.StatusCode() == http.StatusNotFound { return errors.Errorf("An identity provider with ID %q could not be found.", parsed) } - return errors.New("unexpected error fetching identity provider: " + string(getResp.Body)) } @@ -123,6 +126,9 @@ func Run(ctx context.Context, params RunParams) error { } if putResp.JSON200 == nil { + if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", putResp.StatusCode()); isGated { + telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug) + } return errors.New("unexpected error fetching identity provider: " + string(putResp.Body)) } diff --git a/internal/sso/update/update_test.go b/internal/sso/update/update_test.go index 6754fd4870..2b03be6435 100644 --- a/internal/sso/update/update_test.go +++ b/internal/sso/update/update_test.go @@ -178,6 +178,10 @@ func TestSSOProvidersUpdateCommand(t *testing.T) { Get("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId). Reply(404). JSON(map[string]string{}) + // SuggestUpgradeOnError triggers on non-2xx; project lookup will 404 + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + projectRef). + Reply(http.StatusNotFound) err := Run(context.Background(), RunParams{ ProjectRef: projectRef, diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go index 30417b6d84..524f1e759b 100644 --- a/internal/telemetry/events.go +++ b/internal/telemetry/events.go @@ -1,5 +1,7 @@ package telemetry +import "context" + // CLI telemetry catalog. // // This file is the single place to review what analytics events the CLI sends @@ -32,12 +34,12 @@ const ( // added directly by this event, but linked project groups may still be // attached when available. EventStackStarted = "cli_stack_started" - // - EventUpgradeSuggested: sent when a CLI command receives a 402 Payment - // Required response and displays a billing upgrade link to the user. - // This helps measure how often users hit plan-gated features and how - // large the upgrade conversion opportunity is. Event-specific properties - // are PropFeatureKey (the entitlement key that was gated) and - // PropOrgSlug (the organization slug, empty if lookup failed). + // - EventUpgradeSuggested: sent when a CLI command hits a plan-gated + // feature and displays a billing upgrade link. This helps identify + // which plan gates users encounter most often so we can improve + // error messages and documentation. Event-specific properties are + // PropFeatureKey (the entitlement key that was gated) and PropOrgSlug + // (the organization slug, empty if lookup failed). EventUpgradeSuggested = "cli_upgrade_suggested" ) @@ -49,6 +51,17 @@ const ( PropOrgSlug = "org_slug" ) +// TrackUpgradeSuggested fires an EventUpgradeSuggested telemetry event. +// Safe to call with any context; no-ops when telemetry is not configured. +func TrackUpgradeSuggested(ctx context.Context, featureKey, orgSlug string) { + if svc := FromContext(ctx); svc != nil { + _ = svc.Capture(ctx, EventUpgradeSuggested, map[string]any{ + PropFeatureKey: featureKey, + PropOrgSlug: orgSlug, + }, nil) + } +} + // Shared event properties added to every captured event by Service.Capture. const ( // PropPlatform identifies the product source for the event. The CLI always diff --git a/internal/utils/plan_gate.go b/internal/utils/plan_gate.go index a02746cfd6..7267a2de39 100644 --- a/internal/utils/plan_gate.go +++ b/internal/utils/plan_gate.go @@ -3,7 +3,6 @@ package utils import ( "context" "fmt" - "net/http" ) func GetOrgSlugFromProjectRef(ctx context.Context, projectRef string) (string, error) { @@ -22,36 +21,33 @@ func GetOrgBillingURL(orgSlug string) string { } // SuggestUpgradeOnError checks if a failed API response is due to plan limitations -// and sets CmdSuggestion with a billing upgrade link. Best-effort: never returns errors. -// Only triggers on 402 Payment Required (not 403, which could be a permissions issue). -// Returns the resolved org slug and true if the status code was 402 (so callers -// can fire telemetry). The org slug may be empty if the project lookup failed. -func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) (string, bool) { - if statusCode != http.StatusPaymentRequired { - return "", false +// by looking up the org's entitlements. Only sets CmdSuggestion when the entitlements +// API confirms the feature is gated (hasAccess == false). Returns the resolved org +// slug and true if a billing suggestion was shown (so callers can fire telemetry). +// Only checks on 4xx client errors; skips 2xx (success) and 5xx (server errors). +func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) (orgSlug string, isGated bool) { + if statusCode < 400 || statusCode >= 500 { + return } orgSlug, err := GetOrgSlugFromProjectRef(ctx, projectRef) if err != nil { - CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(GetSupabaseDashboardURL())) - return "", true + return } - billingURL := GetOrgBillingURL(orgSlug) - resp, err := GetSupabase().V1GetOrganizationEntitlementsWithResponse(ctx, orgSlug) if err != nil || resp.JSON200 == nil { - CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL)) - return orgSlug, true + return } for _, e := range resp.JSON200.Entitlements { if string(e.Feature.Key) == featureKey && !e.HasAccess { + billingURL := GetOrgBillingURL(orgSlug) CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL)) - return orgSlug, true + isGated = true + return } } - CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL)) - return orgSlug, true + return } diff --git a/internal/utils/plan_gate_test.go b/internal/utils/plan_gate_test.go index e0c6c7906a..c00f00a381 100644 --- a/internal/utils/plan_gate_test.go +++ b/internal/utils/plan_gate_test.go @@ -71,20 +71,25 @@ func entitlementsJSON(featureKey string, hasAccess bool) map[string]interface{} } } +// mockEntitlementsCheck sets up gock mocks for project lookup + entitlements. +func mockEntitlementsCheck(ref string, featureKey string, hasAccess bool) { + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref). + Reply(http.StatusOK). + JSON(planGateProjectJSON) + gock.New(DefaultApiHost). + Get("/v1/organizations/my-org/entitlements"). + Reply(http.StatusOK). + JSON(entitlementsJSON(featureKey, hasAccess)) +} + func TestSuggestUpgradeOnError(t *testing.T) { ref := apitest.RandomProjectRef() - t.Run("sets specific suggestion on 402 with gated feature", func(t *testing.T) { + t.Run("sets suggestion on 402 with gated feature", func(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref). - Reply(http.StatusOK). - JSON(planGateProjectJSON) - gock.New(DefaultApiHost). - Get("/v1/organizations/my-org/entitlements"). - Reply(http.StatusOK). - JSON(entitlementsJSON("branching_limit", false)) + mockEntitlementsCheck(ref, "branching_limit", false) slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) assert.True(t, got) assert.Equal(t, "my-org", slug) @@ -92,7 +97,28 @@ func TestSuggestUpgradeOnError(t *testing.T) { assert.Contains(t, CmdSuggestion, "does not have access") }) - t.Run("sets generic suggestion when entitlements lookup fails", func(t *testing.T) { + t.Run("sets suggestion on 400 with gated feature", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + mockEntitlementsCheck(ref, "vanity_subdomain", false) + slug, got := SuggestUpgradeOnError(context.Background(), ref, "vanity_subdomain", http.StatusBadRequest) + assert.True(t, got) + assert.Equal(t, "my-org", slug) + assert.Contains(t, CmdSuggestion, "/org/my-org/billing") + assert.Contains(t, CmdSuggestion, "does not have access") + }) + + t.Run("sets suggestion on 404 with gated feature", func(t *testing.T) { + t.Cleanup(apitest.MockPlatformAPI(t)) + t.Cleanup(func() { CmdSuggestion = "" }) + mockEntitlementsCheck(ref, "auth.saml_2", false) + slug, got := SuggestUpgradeOnError(context.Background(), ref, "auth.saml_2", http.StatusNotFound) + assert.True(t, got) + assert.Equal(t, "my-org", slug) + assert.Contains(t, CmdSuggestion, "/org/my-org/billing") + }) + + t.Run("no suggestion when entitlements lookup fails", func(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) gock.New(DefaultApiHost). @@ -103,61 +129,50 @@ func TestSuggestUpgradeOnError(t *testing.T) { Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusInternalServerError) slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) - assert.True(t, got) + assert.False(t, got) assert.Equal(t, "my-org", slug) - assert.Contains(t, CmdSuggestion, "/org/my-org/billing") - assert.Contains(t, CmdSuggestion, "may require a plan upgrade") + assert.Empty(t, CmdSuggestion) }) - t.Run("sets fallback suggestion when project lookup fails", func(t *testing.T) { + t.Run("no suggestion when project lookup fails", func(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) gock.New(DefaultApiHost). Get("/v1/projects/" + ref). Reply(http.StatusNotFound) slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) - assert.True(t, got) + assert.False(t, got) assert.Empty(t, slug) - assert.Contains(t, CmdSuggestion, "plan upgrade") - assert.Contains(t, CmdSuggestion, GetSupabaseDashboardURL()) - assert.NotContains(t, CmdSuggestion, "/org/") + assert.Empty(t, CmdSuggestion) }) - t.Run("sets generic suggestion when feature has access", func(t *testing.T) { + t.Run("no suggestion when feature has access", func(t *testing.T) { t.Cleanup(apitest.MockPlatformAPI(t)) t.Cleanup(func() { CmdSuggestion = "" }) - gock.New(DefaultApiHost). - Get("/v1/projects/" + ref). - Reply(http.StatusOK). - JSON(planGateProjectJSON) - gock.New(DefaultApiHost). - Get("/v1/organizations/my-org/entitlements"). - Reply(http.StatusOK). - JSON(entitlementsJSON("branching_limit", true)) + mockEntitlementsCheck(ref, "branching_limit", true) slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) - assert.True(t, got) + assert.False(t, got) assert.Equal(t, "my-org", slug) - assert.Contains(t, CmdSuggestion, "/org/my-org/billing") - assert.Contains(t, CmdSuggestion, "may require a plan upgrade") + assert.Empty(t, CmdSuggestion) }) - t.Run("skips suggestion on 403 forbidden", func(t *testing.T) { + t.Run("skips on 503 server error", func(t *testing.T) { CmdSuggestion = "" - _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden) + _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusServiceUnavailable) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) - t.Run("skips suggestion on non-billing status codes", func(t *testing.T) { + t.Run("skips on 200", func(t *testing.T) { CmdSuggestion = "" - _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusInternalServerError) + _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) - t.Run("skips suggestion on success status codes", func(t *testing.T) { + t.Run("skips on 201", func(t *testing.T) { CmdSuggestion = "" - _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK) + _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusCreated) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) diff --git a/internal/vanity_subdomains/activate/activate.go b/internal/vanity_subdomains/activate/activate.go index 87eb748ea9..18b953953b 100644 --- a/internal/vanity_subdomains/activate/activate.go +++ b/internal/vanity_subdomains/activate/activate.go @@ -7,6 +7,7 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" ) @@ -18,6 +19,9 @@ func Run(ctx context.Context, projectRef string, desiredSubdomain string, fsys a if err != nil { return errors.Errorf("failed activate vanity subdomain: %w", err) } else if resp.JSON201 == nil { + if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode()); isGated { + telemetry.TrackUpgradeSuggested(ctx, "vanity_subdomain", orgSlug) + } return errors.Errorf("unexpected activate vanity subdomain status %d: %s", resp.StatusCode(), string(resp.Body)) } if utils.OutputFormat.Value != utils.OutputPretty { diff --git a/internal/vanity_subdomains/check/check.go b/internal/vanity_subdomains/check/check.go index a9ecb639cc..0fa7548c02 100644 --- a/internal/vanity_subdomains/check/check.go +++ b/internal/vanity_subdomains/check/check.go @@ -7,6 +7,7 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" + "github.com/supabase/cli/internal/telemetry" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/api" ) @@ -18,6 +19,9 @@ func Run(ctx context.Context, projectRef string, desiredSubdomain string, fsys a if err != nil { return errors.Errorf("failed to check vanity subdomain: %w", err) } else if resp.JSON201 == nil { + if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "vanity_subdomain", resp.StatusCode()); isGated { + telemetry.TrackUpgradeSuggested(ctx, "vanity_subdomain", orgSlug) + } return errors.Errorf("unexpected check vanity subdomain status %d: %s", resp.StatusCode(), string(resp.Body)) } if utils.OutputFormat.Value != utils.OutputPretty {