From 1ff9b59f81f43473332a08cc2aacbcec204cdf1a Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 8 Apr 2026 16:23:36 +0900 Subject: [PATCH 1/3] feat(telemetry): add cli_upgrade_suggested event and return bool from SuggestUpgradeOnError --- internal/telemetry/events.go | 15 +++++++++++++++ internal/utils/plan_gate.go | 12 +++++++----- internal/utils/plan_gate_test.go | 21 ++++++++++++++------- 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/internal/telemetry/events.go b/internal/telemetry/events.go index c721b682cc..30417b6d84 100644 --- a/internal/telemetry/events.go +++ b/internal/telemetry/events.go @@ -32,6 +32,21 @@ 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 = "cli_upgrade_suggested" +) + +// Properties specific to EventUpgradeSuggested. +const ( + // PropFeatureKey is the entitlement key that triggered the upgrade suggestion. + PropFeatureKey = "feature_key" + // PropOrgSlug is the organization slug associated with the project. + PropOrgSlug = "org_slug" ) // Shared event properties added to every captured event by Service.Capture. diff --git a/internal/utils/plan_gate.go b/internal/utils/plan_gate.go index f5c1842845..f0dc7ee37b 100644 --- a/internal/utils/plan_gate.go +++ b/internal/utils/plan_gate.go @@ -24,15 +24,16 @@ 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). -func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) { +// Returns true if the status code was 402 (so callers can fire telemetry). +func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) bool { if statusCode != http.StatusPaymentRequired { - return + return false } orgSlug, err := GetOrgSlugFromProjectRef(ctx, projectRef) if err != nil { CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(GetSupabaseDashboardURL())) - return + return true } billingURL := GetOrgBillingURL(orgSlug) @@ -40,15 +41,16 @@ func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, s 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 + return true } for _, e := range resp.JSON200.Entitlements { if string(e.Feature.Key) == featureKey && !e.HasAccess { CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL)) - return + return true } } CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL)) + return true } diff --git a/internal/utils/plan_gate_test.go b/internal/utils/plan_gate_test.go index dee3ef7865..ccbb2af156 100644 --- a/internal/utils/plan_gate_test.go +++ b/internal/utils/plan_gate_test.go @@ -85,7 +85,8 @@ func TestSuggestUpgradeOnError(t *testing.T) { Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusOK). JSON(entitlementsJSON("branching_limit", false)) - SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + assert.True(t, got) assert.Contains(t, CmdSuggestion, "/org/my-org/billing") assert.Contains(t, CmdSuggestion, "does not have access") }) @@ -100,7 +101,8 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusInternalServerError) - SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + assert.True(t, got) assert.Contains(t, CmdSuggestion, "/org/my-org/billing") assert.Contains(t, CmdSuggestion, "may require a plan upgrade") }) @@ -111,7 +113,8 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/projects/" + ref). Reply(http.StatusNotFound) - SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + assert.True(t, got) assert.Contains(t, CmdSuggestion, "plan upgrade") assert.Contains(t, CmdSuggestion, GetSupabaseDashboardURL()) assert.NotContains(t, CmdSuggestion, "/org/") @@ -128,26 +131,30 @@ func TestSuggestUpgradeOnError(t *testing.T) { Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusOK). JSON(entitlementsJSON("branching_limit", true)) - SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + assert.True(t, got) assert.Contains(t, CmdSuggestion, "/org/my-org/billing") assert.Contains(t, CmdSuggestion, "may require a plan upgrade") }) t.Run("skips suggestion on 403 forbidden", func(t *testing.T) { CmdSuggestion = "" - SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden) + got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden) + assert.False(t, got) assert.Empty(t, CmdSuggestion) }) t.Run("skips suggestion on non-billing status codes", func(t *testing.T) { CmdSuggestion = "" - SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusInternalServerError) + got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusInternalServerError) + assert.False(t, got) assert.Empty(t, CmdSuggestion) }) t.Run("skips suggestion on success status codes", func(t *testing.T) { CmdSuggestion = "" - SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK) + got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK) + assert.False(t, got) assert.Empty(t, CmdSuggestion) }) } From 112960055950261dba80d229ab13c9fff0ebd53a Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 8 Apr 2026 16:23:40 +0900 Subject: [PATCH 2/3] feat(branches): fire upgrade suggested telemetry on 402 errors --- internal/branches/create/create.go | 13 ++++++++++++- internal/branches/update/update.go | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/internal/branches/create/create.go b/internal/branches/create/create.go index 50e731d803..e13e8f7241 100644 --- a/internal/branches/create/create.go +++ b/internal/branches/create/create.go @@ -8,6 +8,7 @@ import ( "github.com/go-errors/errors" "github.com/spf13/afero" "github.com/supabase/cli/internal/branches/list" + "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" @@ -30,7 +31,9 @@ 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 { - utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()) + if utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()) { + trackUpgradeSuggested(ctx, "branching_limit") + } return errors.Errorf("unexpected create branch status %d: %s", resp.StatusCode(), string(resp.Body)) } @@ -41,3 +44,11 @@ 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 string) { + if svc := telemetry.FromContext(ctx); svc != nil { + _ = svc.Capture(ctx, telemetry.EventUpgradeSuggested, map[string]any{ + telemetry.PropFeatureKey: featureKey, + }, nil) + } +} diff --git a/internal/branches/update/update.go b/internal/branches/update/update.go index 8ad8c1e381..25a456e272 100644 --- a/internal/branches/update/update.go +++ b/internal/branches/update/update.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/afero" "github.com/supabase/cli/internal/branches/list" "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" @@ -23,7 +24,9 @@ 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 { - utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode()) + if utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode()) { + trackUpgradeSuggested(ctx, "branching_persistent") + } return errors.Errorf("unexpected update branch status %d: %s", resp.StatusCode(), string(resp.Body)) } fmt.Fprintln(os.Stderr, "Updated preview branch:") @@ -33,3 +36,11 @@ 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 string) { + if svc := telemetry.FromContext(ctx); svc != nil { + _ = svc.Capture(ctx, telemetry.EventUpgradeSuggested, map[string]any{ + telemetry.PropFeatureKey: featureKey, + }, nil) + } +} From d7121fe0d92a0baf5f1d093b548a280079580ec1 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Wed, 8 Apr 2026 16:27:21 +0900 Subject: [PATCH 3/3] fix(telemetry): return org_slug from SuggestUpgradeOnError and include in event --- internal/branches/create/create.go | 7 ++++--- internal/branches/update/update.go | 7 ++++--- internal/utils/plan_gate.go | 15 ++++++++------- internal/utils/plan_gate_test.go | 18 +++++++++++------- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/internal/branches/create/create.go b/internal/branches/create/create.go index e13e8f7241..6a1e75cc54 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 utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()) { - trackUpgradeSuggested(ctx, "branching_limit") + if orgSlug, was402 := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()); was402 { + trackUpgradeSuggested(ctx, "branching_limit", orgSlug) } return errors.Errorf("unexpected create branch status %d: %s", resp.StatusCode(), string(resp.Body)) } @@ -45,10 +45,11 @@ 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 string) { +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 25a456e272..0e3f2e8d0c 100644 --- a/internal/branches/update/update.go +++ b/internal/branches/update/update.go @@ -24,8 +24,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 utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode()) { - trackUpgradeSuggested(ctx, "branching_persistent") + if orgSlug, was402 := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode()); was402 { + trackUpgradeSuggested(ctx, "branching_persistent", orgSlug) } return errors.Errorf("unexpected update branch status %d: %s", resp.StatusCode(), string(resp.Body)) } @@ -37,10 +37,11 @@ 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 string) { +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/utils/plan_gate.go b/internal/utils/plan_gate.go index f0dc7ee37b..a02746cfd6 100644 --- a/internal/utils/plan_gate.go +++ b/internal/utils/plan_gate.go @@ -24,16 +24,17 @@ 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 true if the status code was 402 (so callers can fire telemetry). -func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) bool { +// 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 + return "", false } 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 "", true } billingURL := GetOrgBillingURL(orgSlug) @@ -41,16 +42,16 @@ func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, s 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 true + return orgSlug, true } for _, e := range resp.JSON200.Entitlements { if string(e.Feature.Key) == featureKey && !e.HasAccess { CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL)) - return true + return orgSlug, true } } CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL)) - return true + return orgSlug, true } diff --git a/internal/utils/plan_gate_test.go b/internal/utils/plan_gate_test.go index ccbb2af156..e0c6c7906a 100644 --- a/internal/utils/plan_gate_test.go +++ b/internal/utils/plan_gate_test.go @@ -85,8 +85,9 @@ func TestSuggestUpgradeOnError(t *testing.T) { Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusOK). JSON(entitlementsJSON("branching_limit", false)) - got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) 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") }) @@ -101,8 +102,9 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusInternalServerError) - got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) assert.True(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") }) @@ -113,8 +115,9 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/projects/" + ref). Reply(http.StatusNotFound) - got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) assert.True(t, got) + assert.Empty(t, slug) assert.Contains(t, CmdSuggestion, "plan upgrade") assert.Contains(t, CmdSuggestion, GetSupabaseDashboardURL()) assert.NotContains(t, CmdSuggestion, "/org/") @@ -131,29 +134,30 @@ func TestSuggestUpgradeOnError(t *testing.T) { Get("/v1/organizations/my-org/entitlements"). Reply(http.StatusOK). JSON(entitlementsJSON("branching_limit", true)) - got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) + slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired) assert.True(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") }) t.Run("skips suggestion on 403 forbidden", func(t *testing.T) { CmdSuggestion = "" - got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden) + _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) t.Run("skips suggestion on non-billing status codes", func(t *testing.T) { CmdSuggestion = "" - got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusInternalServerError) + _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusInternalServerError) assert.False(t, got) assert.Empty(t, CmdSuggestion) }) t.Run("skips suggestion on success status codes", func(t *testing.T) { CmdSuggestion = "" - got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK) + _, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK) assert.False(t, got) assert.Empty(t, CmdSuggestion) })