Skip to content

Commit 31a59cb

Browse files
pamelachiaavallete
andauthored
feat(telemetry): track upgrade suggestion shown on 402 errors (#5049)
* feat(telemetry): add cli_upgrade_suggested event and return bool from SuggestUpgradeOnError * feat(branches): fire upgrade suggested telemetry on 402 errors * fix(telemetry): return org_slug from SuggestUpgradeOnError and include in event --------- Co-authored-by: Andrew Valleteau <avallete@users.noreply.github.com>
1 parent a6c3ece commit 31a59cb

File tree

5 files changed

+67
-14
lines changed

5 files changed

+67
-14
lines changed

internal/branches/create/create.go

Lines changed: 13 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/spf13/afero"
1010
"github.com/supabase/cli/internal/branches/list"
11+
"github.com/supabase/cli/internal/telemetry"
1112
"github.com/supabase/cli/internal/utils"
1213
"github.com/supabase/cli/internal/utils/flags"
1314
"github.com/supabase/cli/pkg/api"
@@ -30,7 +31,9 @@ func Run(ctx context.Context, body api.CreateBranchBody, fsys afero.Fs) error {
3031
if err != nil {
3132
return errors.Errorf("failed to create preview branch: %w", err)
3233
} else if resp.JSON201 == nil {
33-
utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode())
34+
if orgSlug, was402 := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_limit", resp.StatusCode()); was402 {
35+
trackUpgradeSuggested(ctx, "branching_limit", orgSlug)
36+
}
3437
return errors.Errorf("unexpected create branch status %d: %s", resp.StatusCode(), string(resp.Body))
3538
}
3639

@@ -41,3 +44,12 @@ func Run(ctx context.Context, body api.CreateBranchBody, fsys afero.Fs) error {
4144
}
4245
return utils.EncodeOutput(utils.OutputFormat.Value, os.Stdout, *resp.JSON201)
4346
}
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: 13 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/branches/list"
1111
"github.com/supabase/cli/internal/branches/pause"
12+
"github.com/supabase/cli/internal/telemetry"
1213
"github.com/supabase/cli/internal/utils"
1314
"github.com/supabase/cli/internal/utils/flags"
1415
"github.com/supabase/cli/pkg/api"
@@ -23,7 +24,9 @@ func Run(ctx context.Context, branchId string, body api.UpdateBranchBody, fsys a
2324
if err != nil {
2425
return errors.Errorf("failed to update preview branch: %w", err)
2526
} else if resp.JSON200 == nil {
26-
utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode())
27+
if orgSlug, was402 := utils.SuggestUpgradeOnError(ctx, flags.ProjectRef, "branching_persistent", resp.StatusCode()); was402 {
28+
trackUpgradeSuggested(ctx, "branching_persistent", orgSlug)
29+
}
2730
return errors.Errorf("unexpected update branch status %d: %s", resp.StatusCode(), string(resp.Body))
2831
}
2932
fmt.Fprintln(os.Stderr, "Updated preview branch:")
@@ -33,3 +36,12 @@ func Run(ctx context.Context, branchId string, body api.UpdateBranchBody, fsys a
3336
}
3437
return utils.EncodeOutput(utils.OutputFormat.Value, os.Stdout, *resp.JSON200)
3538
}
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/telemetry/events.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ const (
3232
// added directly by this event, but linked project groups may still be
3333
// attached when available.
3434
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).
41+
EventUpgradeSuggested = "cli_upgrade_suggested"
42+
)
43+
44+
// Properties specific to EventUpgradeSuggested.
45+
const (
46+
// PropFeatureKey is the entitlement key that triggered the upgrade suggestion.
47+
PropFeatureKey = "feature_key"
48+
// PropOrgSlug is the organization slug associated with the project.
49+
PropOrgSlug = "org_slug"
3550
)
3651

3752
// Shared event properties added to every captured event by Service.Capture.

internal/utils/plan_gate.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,31 +24,34 @@ func GetOrgBillingURL(orgSlug string) string {
2424
// SuggestUpgradeOnError checks if a failed API response is due to plan limitations
2525
// and sets CmdSuggestion with a billing upgrade link. Best-effort: never returns errors.
2626
// Only triggers on 402 Payment Required (not 403, which could be a permissions issue).
27-
func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) {
27+
// Returns the resolved org slug and true if the status code was 402 (so callers
28+
// can fire telemetry). The org slug may be empty if the project lookup failed.
29+
func SuggestUpgradeOnError(ctx context.Context, projectRef, featureKey string, statusCode int) (string, bool) {
2830
if statusCode != http.StatusPaymentRequired {
29-
return
31+
return "", false
3032
}
3133

3234
orgSlug, err := GetOrgSlugFromProjectRef(ctx, projectRef)
3335
if err != nil {
3436
CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(GetSupabaseDashboardURL()))
35-
return
37+
return "", true
3638
}
3739

3840
billingURL := GetOrgBillingURL(orgSlug)
3941

4042
resp, err := GetSupabase().V1GetOrganizationEntitlementsWithResponse(ctx, orgSlug)
4143
if err != nil || resp.JSON200 == nil {
4244
CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL))
43-
return
45+
return orgSlug, true
4446
}
4547

4648
for _, e := range resp.JSON200.Entitlements {
4749
if string(e.Feature.Key) == featureKey && !e.HasAccess {
4850
CmdSuggestion = fmt.Sprintf("Your organization does not have access to this feature. Upgrade your plan: %s", Bold(billingURL))
49-
return
51+
return orgSlug, true
5052
}
5153
}
5254

5355
CmdSuggestion = fmt.Sprintf("This feature may require a plan upgrade. Manage billing: %s", Bold(billingURL))
56+
return orgSlug, true
5457
}

internal/utils/plan_gate_test.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ func TestSuggestUpgradeOnError(t *testing.T) {
8585
Get("/v1/organizations/my-org/entitlements").
8686
Reply(http.StatusOK).
8787
JSON(entitlementsJSON("branching_limit", false))
88-
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
88+
slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
89+
assert.True(t, got)
90+
assert.Equal(t, "my-org", slug)
8991
assert.Contains(t, CmdSuggestion, "/org/my-org/billing")
9092
assert.Contains(t, CmdSuggestion, "does not have access")
9193
})
@@ -100,7 +102,9 @@ func TestSuggestUpgradeOnError(t *testing.T) {
100102
gock.New(DefaultApiHost).
101103
Get("/v1/organizations/my-org/entitlements").
102104
Reply(http.StatusInternalServerError)
103-
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
105+
slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
106+
assert.True(t, got)
107+
assert.Equal(t, "my-org", slug)
104108
assert.Contains(t, CmdSuggestion, "/org/my-org/billing")
105109
assert.Contains(t, CmdSuggestion, "may require a plan upgrade")
106110
})
@@ -111,7 +115,9 @@ func TestSuggestUpgradeOnError(t *testing.T) {
111115
gock.New(DefaultApiHost).
112116
Get("/v1/projects/" + ref).
113117
Reply(http.StatusNotFound)
114-
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
118+
slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
119+
assert.True(t, got)
120+
assert.Empty(t, slug)
115121
assert.Contains(t, CmdSuggestion, "plan upgrade")
116122
assert.Contains(t, CmdSuggestion, GetSupabaseDashboardURL())
117123
assert.NotContains(t, CmdSuggestion, "/org/")
@@ -128,26 +134,31 @@ func TestSuggestUpgradeOnError(t *testing.T) {
128134
Get("/v1/organizations/my-org/entitlements").
129135
Reply(http.StatusOK).
130136
JSON(entitlementsJSON("branching_limit", true))
131-
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
137+
slug, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusPaymentRequired)
138+
assert.True(t, got)
139+
assert.Equal(t, "my-org", slug)
132140
assert.Contains(t, CmdSuggestion, "/org/my-org/billing")
133141
assert.Contains(t, CmdSuggestion, "may require a plan upgrade")
134142
})
135143

136144
t.Run("skips suggestion on 403 forbidden", func(t *testing.T) {
137145
CmdSuggestion = ""
138-
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden)
146+
_, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusForbidden)
147+
assert.False(t, got)
139148
assert.Empty(t, CmdSuggestion)
140149
})
141150

142151
t.Run("skips suggestion on non-billing status codes", func(t *testing.T) {
143152
CmdSuggestion = ""
144-
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusInternalServerError)
153+
_, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusInternalServerError)
154+
assert.False(t, got)
145155
assert.Empty(t, CmdSuggestion)
146156
})
147157

148158
t.Run("skips suggestion on success status codes", func(t *testing.T) {
149159
CmdSuggestion = ""
150-
SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK)
160+
_, got := SuggestUpgradeOnError(context.Background(), ref, "branching_limit", http.StatusOK)
161+
assert.False(t, got)
151162
assert.Empty(t, CmdSuggestion)
152163
})
153164
}

0 commit comments

Comments
 (0)