Skip to content
Merged
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
14 changes: 13 additions & 1 deletion internal/branches/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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))
}

Expand All @@ -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)
}
}
14 changes: 13 additions & 1 deletion internal/branches/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:")
Expand All @@ -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)
}
}
15 changes: 15 additions & 0 deletions internal/telemetry/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 8 additions & 5 deletions internal/utils/plan_gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,34 @@ 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)

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
}
25 changes: 18 additions & 7 deletions internal/utils/plan_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Expand All @@ -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")
})
Expand All @@ -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/")
Expand All @@ -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)
})
}
Loading