Skip to content

Commit 47426cd

Browse files
authored
feat(telemetry): stitch CLI identity from API response header (#5054)
2 parents e5fdfe9 + a271d96 commit 47426cd

6 files changed

Lines changed: 168 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/signal"
1111
"strings"
12+
"sync"
1213
"time"
1314

1415
"github.com/getsentry/sentry-go"
@@ -140,6 +141,18 @@ var (
140141
} else {
141142
ctx = telemetry.WithService(ctx, service)
142143
}
144+
if service != nil {
145+
var stitchOnce sync.Once
146+
utils.OnGotrueID = func(gotrueID string) {
147+
if service.NeedsIdentityStitch() {
148+
stitchOnce.Do(func() {
149+
if err := service.StitchLogin(gotrueID); err != nil {
150+
fmt.Fprintln(utils.GetDebugLogger(), err)
151+
}
152+
})
153+
}
154+
}
155+
}
143156
ctx = telemetry.WithCommandContext(ctx, commandAnalyticsContext(cmd))
144157
cmd.SetContext(ctx)
145158
// Setup sentry last to ignore errors from parsing cli flags

internal/telemetry/service.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ func (s *Service) ClearDistinctID() error {
153153
return SaveState(s.state, s.fsys)
154154
}
155155

156+
func (s *Service) NeedsIdentityStitch() bool {
157+
return s != nil && s.state.DistinctID == "" && s.canSend()
158+
}
159+
156160
func (s *Service) GroupIdentify(groupType string, groupKey string, properties map[string]any) error {
157161
if !s.canSend() {
158162
return nil

internal/telemetry/service_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,28 @@ func TestServiceCaptureIncludesLinkedProjectGroups(t *testing.T) {
213213
}, analytics.captures[0].groups)
214214
}
215215

216+
func TestServiceNeedsIdentityStitch(t *testing.T) {
217+
now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC)
218+
t.Setenv("SUPABASE_HOME", "/tmp/supabase-home")
219+
fsys := afero.NewMemMapFs()
220+
analytics := &fakeAnalytics{enabled: true}
221+
222+
service, err := NewService(fsys, Options{
223+
Analytics: analytics,
224+
Now: func() time.Time { return now },
225+
})
226+
require.NoError(t, err)
227+
228+
t.Run("true when DistinctID is empty", func(t *testing.T) {
229+
assert.True(t, service.NeedsIdentityStitch())
230+
})
231+
232+
t.Run("false after StitchLogin", func(t *testing.T) {
233+
require.NoError(t, service.StitchLogin("user-123"))
234+
assert.False(t, service.NeedsIdentityStitch())
235+
})
236+
}
237+
216238
func TestServiceCaptureHonorsConsentAndEnvOptOut(t *testing.T) {
217239
now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC)
218240

internal/utils/api.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const (
2121
DNS_OVER_HTTPS = "https"
2222
)
2323

24+
var OnGotrueID func(string)
25+
2426
var (
2527
clientOnce sync.Once
2628
apiClient *supabase.ClientWithResponses
@@ -123,8 +125,13 @@ func GetSupabase() *supabase.ClientWithResponses {
123125
if t, ok := http.DefaultTransport.(*http.Transport); ok {
124126
t.DialContext = withFallbackDNS(t.DialContext)
125127
}
128+
transport := &identityTransport{
129+
RoundTripper: http.DefaultTransport,
130+
onGotrueID: &OnGotrueID,
131+
}
126132
apiClient, err = supabase.NewClientWithResponses(
127133
GetSupabaseAPIHost(),
134+
supabase.WithHTTPClient(&http.Client{Transport: transport}),
128135
supabase.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error {
129136
req.Header.Set("Authorization", "Bearer "+token)
130137
req.Header.Set("User-Agent", "SupabaseCLI/"+Version)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package utils
2+
3+
import "net/http"
4+
5+
const HeaderGotrueID = "X-Gotrue-Id"
6+
7+
type identityTransport struct {
8+
http.RoundTripper
9+
onGotrueID *func(string)
10+
}
11+
12+
func (t *identityTransport) RoundTrip(req *http.Request) (*http.Response, error) {
13+
resp, err := t.RoundTripper.RoundTrip(req)
14+
if err != nil {
15+
return resp, err
16+
}
17+
if id := resp.Header.Get(HeaderGotrueID); id != "" && t.onGotrueID != nil && *t.onGotrueID != nil {
18+
(*t.onGotrueID)(id)
19+
}
20+
return resp, err
21+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package utils
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestIdentityTransport_CapturesGotrueIdHeader(t *testing.T) {
11+
var captured string
12+
cb := func(id string) { captured = id }
13+
transport := &identityTransport{
14+
RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) {
15+
return &http.Response{
16+
StatusCode: 200,
17+
Header: http.Header{"X-Gotrue-Id": []string{"user-abc-123"}},
18+
}, nil
19+
}),
20+
onGotrueID: &cb,
21+
}
22+
req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil)
23+
resp, err := transport.RoundTrip(req)
24+
assert.NoError(t, err)
25+
assert.Equal(t, 200, resp.StatusCode)
26+
assert.Equal(t, "user-abc-123", captured)
27+
}
28+
29+
func TestIdentityTransport_IgnoresWhenHeaderMissing(t *testing.T) {
30+
var captured string
31+
cb := func(id string) { captured = id }
32+
transport := &identityTransport{
33+
RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) {
34+
return &http.Response{
35+
StatusCode: 200,
36+
Header: http.Header{},
37+
}, nil
38+
}),
39+
onGotrueID: &cb,
40+
}
41+
req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil)
42+
_, err := transport.RoundTrip(req)
43+
assert.NoError(t, err)
44+
assert.Empty(t, captured)
45+
}
46+
47+
func TestIdentityTransport_NilCallbackDoesNotPanic(t *testing.T) {
48+
transport := &identityTransport{
49+
RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) {
50+
return &http.Response{
51+
StatusCode: 200,
52+
Header: http.Header{"X-Gotrue-Id": []string{"user-abc-123"}},
53+
}, nil
54+
}),
55+
onGotrueID: nil,
56+
}
57+
req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil)
58+
resp, err := transport.RoundTrip(req)
59+
assert.NoError(t, err)
60+
assert.Equal(t, 200, resp.StatusCode)
61+
}
62+
63+
func TestIdentityTransport_NilFuncBehindPointerDoesNotPanic(t *testing.T) {
64+
var cb func(string) // nil func
65+
transport := &identityTransport{
66+
RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) {
67+
return &http.Response{
68+
StatusCode: 200,
69+
Header: http.Header{"X-Gotrue-Id": []string{"user-abc-123"}},
70+
}, nil
71+
}),
72+
onGotrueID: &cb,
73+
}
74+
req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil)
75+
resp, err := transport.RoundTrip(req)
76+
assert.NoError(t, err)
77+
assert.Equal(t, 200, resp.StatusCode)
78+
}
79+
80+
func TestIdentityTransport_InnerTransportError(t *testing.T) {
81+
var captured string
82+
cb := func(id string) { captured = id }
83+
transport := &identityTransport{
84+
RoundTripper: roundTripFunc(func(req *http.Request) (*http.Response, error) {
85+
return nil, assert.AnError
86+
}),
87+
onGotrueID: &cb,
88+
}
89+
req, _ := http.NewRequest("GET", "https://api.supabase.io/v1/projects", nil)
90+
resp, err := transport.RoundTrip(req)
91+
assert.Error(t, err)
92+
assert.Nil(t, resp)
93+
assert.Empty(t, captured)
94+
}
95+
96+
// roundTripFunc is a test helper to create inline RoundTrippers.
97+
type roundTripFunc func(*http.Request) (*http.Response, error)
98+
99+
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
100+
return f(req)
101+
}

0 commit comments

Comments
 (0)