diff --git a/internal/branches/create/create.go b/internal/branches/create/create.go index 50e731d803..6a1e75cc54 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 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)) } @@ -41,3 +44,12 @@ 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 8ad8c1e381..0e3f2e8d0c 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 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)) } fmt.Fprintln(os.Stderr, "Updated preview branch:") @@ -33,3 +36,12 @@ 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/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..a02746cfd6 100644 --- a/internal/utils/plan_gate.go +++ b/internal/utils/plan_gate.go @@ -24,15 +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). -func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) { +// 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 + 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 +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 + 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 + return orgSlug, true } } CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL)) + return orgSlug, true } diff --git a/internal/utils/plan_gate_test.go b/internal/utils/plan_gate_test.go index dee3ef7865..e0c6c7906a 100644 --- a/internal/utils/plan_gate_test.go +++ b/internal/utils/plan_gate_test.go @@ -85,7 +85,9 @@ 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) + 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") }) @@ -100,7 +102,9 @@ 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) + 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") }) @@ -111,7 +115,9 @@ func TestSuggestUpgradeOnError(t *testing.T) { gock.New(DefaultApiHost). Get("/v1/projects/" + ref). Reply(http.StatusNotFound) - 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/") @@ -128,26 +134,31 @@ 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) + 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 = "" - 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) }) }