Skip to content

Commit 7476ee0

Browse files
pamelachiaavallete
andauthored
feat(plan-gate): wire billing links into remaining plan-gated commands (#5066)
* refactor(plan-gate): check entitlements on any non-2xx, only show link when confirmed gated Previously SuggestUpgradeOnError only triggered on 402. Many plan-gated endpoints return 400 or 404 instead. Now checks entitlements on any non-2xx and only sets CmdSuggestion when hasAccess is confirmed false, preventing false positives. * refactor(branches): rename was402 to isGated in upgrade check Aligns variable name with the new semantics: SuggestUpgradeOnError now checks entitlements on any non-2xx, not just 402. * feat(sso): wire billing link into sso commands when plan-gated Check org entitlements for auth.saml_2 on any non-2xx response from SSO provider endpoints. Shows billing upgrade link when the feature is confirmed gated, before falling through to existing error handling. * feat(vanity-subdomains): wire billing link into vanity-subdomain commands when plan-gated Check org entitlements for vanity_subdomain on any non-2xx response. The platform API returns 400 when vanity subdomains are not available on the org's plan; the entitlements check confirms the gate before showing a billing upgrade link. * docs(telemetry): clean up EventUpgradeSuggested comment * refactor(plan-gate): use named returns for readability * fix(plan-gate): use correct projectRef in branches/update, restrict check to 4xx Two fixes: - branches/update passed flags.ProjectRef instead of the local projectRef resolved from the branch ID, causing entitlements to look up the wrong org - Restrict SuggestUpgradeOnError to 4xx client errors only (skip 2xx success and 5xx server errors) to avoid unnecessary API calls on server outages * refactor(telemetry): extract shared TrackUpgradeSuggested helper Replace 8 identical per-package trackUpgradeSuggested functions with a single exported telemetry.TrackUpgradeSuggested. Avoids utils->telemetry import cycle by keeping the helper in the telemetry package. --------- Co-authored-by: Andrew Valleteau <avallete@users.noreply.github.com>
1 parent eaad255 commit 7476ee0

14 files changed

Lines changed: 128 additions & 86 deletions

File tree

internal/branches/create/create.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ func Run(ctx context.Context, body api.CreateBranchBody, fsys afero.Fs) error {
3131
if err != nil {
3232
return errors.Errorf("failed to create preview branch: %w", err)
3333
} else if resp.JSON201 == nil {
34-
if orgSlug, was402 := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()); was402 {
35-
trackUpgradeSuggested(ctx, "branching_limit", orgSlug)
34+
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()); isGated {
35+
telemetry.TrackUpgradeSuggested(ctx, "branching_limit", orgSlug)
3636
}
3737
return errors.Errorf("unexpected create branch status %d: %s", resp.StatusCode(), string(resp.Body))
3838
}
@@ -44,12 +44,3 @@ func Run(ctx context.Context, body api.CreateBranchBody, fsys afero.Fs) error {
4444
}
4545
return utils.EncodeOutput(utils.OutputFormat.Value, os.Stdout, *resp.JSON201)
4646
}
47-
48-
func trackUpgradeSuggested(ctx context.Context, featureKey, orgSlug string) {
49-
if svc := telemetry.FromContext(ctx); svc != nil {
50-
_ = svc.Capture(ctx, telemetry.EventUpgradeSuggested, map[string]any{
51-
telemetry.PropFeatureKey: featureKey,
52-
telemetry.PropOrgSlug: orgSlug,
53-
}, nil)
54-
}
55-
}

internal/branches/update/update.go

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"github.com/supabase/cli/internal/branches/pause"
1212
"github.com/supabase/cli/internal/telemetry"
1313
"github.com/supabase/cli/internal/utils"
14-
"github.com/supabase/cli/internal/utils/flags"
1514
"github.com/supabase/cli/pkg/api"
1615
)
1716

@@ -24,8 +23,8 @@ func Run(ctx context.Context, branchId string, body api.UpdateBranchBody, fsys a
2423
if err != nil {
2524
return errors.Errorf("failed to update preview branch: %w", err)
2625
} else if resp.JSON200 == nil {
27-
if orgSlug, was402 := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode()); was402 {
28-
trackUpgradeSuggested(ctx, "branching_persistent", orgSlug)
26+
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, projectRef, "branching_persistent", resp.StatusCode()); isGated {
27+
telemetry.TrackUpgradeSuggested(ctx, "branching_persistent", orgSlug)
2928
}
3029
return errors.Errorf("unexpected update branch status %d: %s", resp.StatusCode(), string(resp.Body))
3130
}
@@ -36,12 +35,3 @@ func Run(ctx context.Context, branchId string, body api.UpdateBranchBody, fsys a
3635
}
3736
return utils.EncodeOutput(utils.OutputFormat.Value, os.Stdout, *resp.JSON200)
3837
}
39-
40-
func trackUpgradeSuggested(ctx context.Context, featureKey, orgSlug string) {
41-
if svc := telemetry.FromContext(ctx); svc != nil {
42-
_ = svc.Capture(ctx, telemetry.EventUpgradeSuggested, map[string]any{
43-
telemetry.PropFeatureKey: featureKey,
44-
telemetry.PropOrgSlug: orgSlug,
45-
}, nil)
46-
}
47-
}

internal/sso/create/create.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/spf13/afero"
1010
"github.com/supabase/cli/internal/sso/internal/render"
1111
"github.com/supabase/cli/internal/sso/internal/saml"
12+
"github.com/supabase/cli/internal/telemetry"
1213
"github.com/supabase/cli/internal/utils"
1314
"github.com/supabase/cli/pkg/api"
1415
"github.com/supabase/cli/pkg/cast"
@@ -78,10 +79,12 @@ func Run(ctx context.Context, params RunParams) error {
7879
}
7980

8081
if resp.JSON201 == nil {
82+
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", resp.StatusCode()); isGated {
83+
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
84+
}
8185
if resp.StatusCode() == http.StatusNotFound {
8286
return errors.New("SAML 2.0 support is not enabled for this project. Please enable it through the dashboard")
8387
}
84-
8588
return errors.New("Unexpected error adding identity provider: " + string(resp.Body))
8689
}
8790

internal/sso/list/list.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/go-errors/errors"
99
"github.com/supabase/cli/internal/sso/internal/render"
10+
"github.com/supabase/cli/internal/telemetry"
1011
"github.com/supabase/cli/internal/utils"
1112
)
1213

@@ -17,10 +18,12 @@ func Run(ctx context.Context, ref, format string) error {
1718
}
1819

1920
if resp.JSON200 == nil {
21+
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode()); isGated {
22+
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
23+
}
2024
if resp.StatusCode() == http.StatusNotFound {
2125
return errors.New("Looks like SAML 2.0 support is not enabled for this project. Please use the dashboard to enable it.")
2226
}
23-
2427
return errors.New("unexpected error listing identity providers: " + string(resp.Body))
2528
}
2629

internal/sso/list/list_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package list
22

33
import (
44
"context"
5+
"net/http"
56
"testing"
67

78
"github.com/h2non/gock"
@@ -83,6 +84,10 @@ func TestSSOProvidersListCommand(t *testing.T) {
8384
Get("/v1/projects/" + projectRef + "/config/auth/sso/providers").
8485
Reply(404).
8586
JSON(map[string]string{})
87+
// SuggestUpgradeOnError triggers on non-2xx; project lookup will 404
88+
gock.New(utils.DefaultApiHost).
89+
Get("/v1/projects/" + projectRef).
90+
Reply(http.StatusNotFound)
8691

8792
err := Run(context.Background(), projectRef, utils.OutputPretty)
8893

internal/sso/remove/remove.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/go-errors/errors"
99
"github.com/google/uuid"
1010
"github.com/supabase/cli/internal/sso/internal/render"
11+
"github.com/supabase/cli/internal/telemetry"
1112
"github.com/supabase/cli/internal/utils"
1213
"github.com/supabase/cli/pkg/api"
1314
)
@@ -23,10 +24,12 @@ func Run(ctx context.Context, ref, providerId, format string) error {
2324
}
2425

2526
if resp.JSON200 == nil {
27+
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, ref, "auth.saml_2", resp.StatusCode()); isGated {
28+
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
29+
}
2630
if resp.StatusCode() == http.StatusNotFound {
2731
return errors.Errorf("An identity provider with ID %q could not be found.", providerId)
2832
}
29-
3033
return errors.New("Unexpected error removing identity provider: " + string(resp.Body))
3134
}
3235

internal/sso/remove/remove_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package remove
33
import (
44
"context"
55
"fmt"
6+
"net/http"
67
"testing"
78

89
"github.com/h2non/gock"
@@ -82,6 +83,10 @@ func TestSSOProvidersRemoveCommand(t *testing.T) {
8283
Delete("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
8384
Reply(404).
8485
JSON(map[string]string{})
86+
// SuggestUpgradeOnError triggers on non-2xx; project lookup will 404
87+
gock.New(utils.DefaultApiHost).
88+
Get("/v1/projects/" + projectRef).
89+
Reply(http.StatusNotFound)
8590

8691
err := Run(context.Background(), projectRef, providerId, utils.OutputPretty)
8792

internal/sso/update/update.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/spf13/afero"
1111
"github.com/supabase/cli/internal/sso/internal/render"
1212
"github.com/supabase/cli/internal/sso/internal/saml"
13+
"github.com/supabase/cli/internal/telemetry"
1314
"github.com/supabase/cli/internal/utils"
1415
"github.com/supabase/cli/pkg/api"
1516
"github.com/supabase/cli/pkg/cast"
@@ -44,10 +45,12 @@ func Run(ctx context.Context, params RunParams) error {
4445
}
4546

4647
if getResp.JSON200 == nil {
48+
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", getResp.StatusCode()); isGated {
49+
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
50+
}
4751
if getResp.StatusCode() == http.StatusNotFound {
4852
return errors.Errorf("An identity provider with ID %q could not be found.", parsed)
4953
}
50-
5154
return errors.New("unexpected error fetching identity provider: " + string(getResp.Body))
5255
}
5356

@@ -123,6 +126,9 @@ func Run(ctx context.Context, params RunParams) error {
123126
}
124127

125128
if putResp.JSON200 == nil {
129+
if orgSlug, isGated := utils.SuggestUpgradeOnError(ctx, params.ProjectRef, "auth.saml_2", putResp.StatusCode()); isGated {
130+
telemetry.TrackUpgradeSuggested(ctx, "auth.saml_2", orgSlug)
131+
}
126132
return errors.New("unexpected error fetching identity provider: " + string(putResp.Body))
127133
}
128134

internal/sso/update/update_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,10 @@ func TestSSOProvidersUpdateCommand(t *testing.T) {
178178
Get("/v1/projects/" + projectRef + "/config/auth/sso/providers/" + providerId).
179179
Reply(404).
180180
JSON(map[string]string{})
181+
// SuggestUpgradeOnError triggers on non-2xx; project lookup will 404
182+
gock.New(utils.DefaultApiHost).
183+
Get("/v1/projects/" + projectRef).
184+
Reply(http.StatusNotFound)
181185

182186
err := Run(context.Background(), RunParams{
183187
ProjectRef: projectRef,

internal/telemetry/events.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package telemetry
22

3+
import "context"
4+
35
// CLI telemetry catalog.
46
//
57
// This file is the single place to review what analytics events the CLI sends
@@ -32,12 +34,12 @@ const (
3234
// added directly by this event, but linked project groups may still be
3335
// attached when available.
3436
EventStackStarted = "cli_stack_started"
35-
// - EventUpgradeSuggested: sent when a CLI command receives a 402 Payment
36-
// Required response and displays a billing upgrade link to the user.
37-
// This helps measure how often users hit plan-gated features and how
38-
// large the upgrade conversion opportunity is. Event-specific properties
39-
// are PropFeatureKey (the entitlement key that was gated) and
40-
// PropOrgSlug (the organization slug, empty if lookup failed).
37+
// - EventUpgradeSuggested: sent when a CLI command hits a plan-gated
38+
// feature and displays a billing upgrade link. This helps identify
39+
// which plan gates users encounter most often so we can improve
40+
// error messages and documentation. Event-specific properties are
41+
// PropFeatureKey (the entitlement key that was gated) and PropOrgSlug
42+
// (the organization slug, empty if lookup failed).
4143
EventUpgradeSuggested = "cli_upgrade_suggested"
4244
)
4345

@@ -49,6 +51,17 @@ const (
4951
PropOrgSlug = "org_slug"
5052
)
5153

54+
// TrackUpgradeSuggested fires an EventUpgradeSuggested telemetry event.
55+
// Safe to call with any context; no-ops when telemetry is not configured.
56+
func TrackUpgradeSuggested(ctx context.Context, featureKey, orgSlug string) {
57+
if svc := FromContext(ctx); svc != nil {
58+
_ = svc.Capture(ctx, EventUpgradeSuggested, map[string]any{
59+
PropFeatureKey: featureKey,
60+
PropOrgSlug: orgSlug,
61+
}, nil)
62+
}
63+
}
64+
5265
// Shared event properties added to every captured event by Service.Capture.
5366
const (
5467
// PropPlatform identifies the product source for the event. The CLI always

0 commit comments

Comments
 (0)