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
13 changes: 2 additions & 11 deletions internal/branches/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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)
}
}
14 changes: 2 additions & 12 deletions internal/branches/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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))
}
Expand All @@ -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)
}
}
5 changes: 4 additions & 1 deletion internal/sso/create/create.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/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"
Expand Down Expand Up @@ -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))
}

Expand Down
5 changes: 4 additions & 1 deletion internal/sso/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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))
}

Expand Down
5 changes: 5 additions & 0 deletions internal/sso/list/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package list

import (
"context"
"net/http"
"testing"

"github.com/h2non/gock"
Expand Down Expand Up @@ -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)

Expand Down
5 changes: 4 additions & 1 deletion internal/sso/remove/remove.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/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"
)
Expand All @@ -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))
}

Expand Down
5 changes: 5 additions & 0 deletions internal/sso/remove/remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package remove
import (
"context"
"fmt"
"net/http"
"testing"

"github.com/h2non/gock"
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion internal/sso/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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))
}

Expand Down
4 changes: 4 additions & 0 deletions internal/sso/update/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 19 additions & 6 deletions internal/telemetry/events.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"
)

Expand All @@ -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
Expand Down
30 changes: 13 additions & 17 deletions internal/utils/plan_gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package utils
import (
"context"
"fmt"
"net/http"
)

func GetOrgSlugFromProjectRef(ctx context.Context, projectRef string) (string, error) {
Expand All @@ -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
}
Loading
Loading