Skip to content

Commit 5bff15c

Browse files
committed
feat(auth): identify accounts and add structured login telemetry
Link a successful login (OAuth or API-key) to the account identity via PostHog identify, disclose telemetry + identity linking on first run, and emit structured started/completed/failed events per login attempt with method and failure reason so login funnels are queryable by branch instead of a generic command-complete event.
1 parent f02a158 commit 5bff15c

6 files changed

Lines changed: 648 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ heygen config list # show all settings with sources
190190

191191
## Reporting bugs
192192

193-
- **Quick signal (recommended for agents):** `heygen feedback --rating <1-5> --comment "..."`. Goes to private, anonymous analytics. Safe to run unattended.
193+
- **Quick signal (recommended for agents):** `heygen feedback --rating <1-5> --comment "..."`. Goes to private, anonymous analytics. Safe to run unattended. Usage telemetry is anonymous until you sign in: running `heygen auth login` links it to your account email or username. A one-time notice prints the first time analytics runs; opt out anytime with `HEYGEN_NO_ANALYTICS=1` (or `heygen config set analytics false`).
194194
- **Tracked bug a maintainer should see:** open a [GitHub issue](https://github.com/heygen-com/heygen-cli/issues). This repo is **public**, so review what you paste first and omit anything sensitive (API keys, prompts, internal URLs, personal data).
195195

196196
**Agents:** do not open GitHub issues automatically. Use `heygen feedback` for bug signal; if something seems worth tracking, surface it to the user and let a human file the issue after reviewing it for sensitive content.

cmd/heygen/auth_login.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"strings"
1313
"time"
1414

15+
"github.com/heygen-com/heygen-cli/internal/analytics"
1516
"github.com/heygen-com/heygen-cli/internal/auth"
1617
"github.com/heygen-com/heygen-cli/internal/auth/oauth"
1718
clierrors "github.com/heygen-com/heygen-cli/internal/errors"
@@ -20,6 +21,35 @@ import (
2021
"golang.org/x/term"
2122
)
2223

24+
// loginAnalyticsClient is the subset of *analytics.Client this file calls,
25+
// factored out as an interface so tests can inject a spy without needing a
26+
// real PostHog capture client (internal/analytics's own stubCaptureClient
27+
// is the lower-level equivalent used in that package's tests).
28+
type loginAnalyticsClient interface {
29+
IdentifyAccount(distinctId string)
30+
AuthLoginStarted(method string)
31+
AuthLoginCompleted(method string)
32+
AuthLoginFailed(method, reason string)
33+
}
34+
35+
// loginAnalytics is the process-wide analytics client login telemetry
36+
// calls into. main() sets it to the real client once at startup; left at
37+
// its inert zero value here so any test that builds the login commands
38+
// directly (bypassing main()) gets a no-op, matching analytics.New(_,
39+
// false)'s existing disabled-client contract.
40+
var loginAnalytics loginAnalyticsClient = &analytics.Client{}
41+
42+
// identityKey picks the identity a successful login links analytics to:
43+
// email if present, otherwise username. Deliberately narrower than
44+
// UserInfo.DisplayName() (which also falls back to "first last") — this
45+
// links a PostHog person to a stable login handle, not a display string.
46+
func identityKey(userInfo auth.UserInfo) string {
47+
if userInfo.Email != "" {
48+
return userInfo.Email
49+
}
50+
return userInfo.Username
51+
}
52+
2353
// oauthLoginConfig is overridable in tests so the login flow can be
2454
// driven against an httptest IdP without spawning a real browser.
2555
type oauthLoginConfig struct {
@@ -187,6 +217,8 @@ var runAuthLoginTestDeps *runAuthLoginDeps
187217
// override is in effect, else we default to the API-key flow.
188218
func runAuthLogin(cmd *cobra.Command, ctx *cmdContext, flags authLoginFlags) error {
189219
if flags.deviceCodeMode {
220+
loginAnalytics.AuthLoginStarted("device_code")
221+
loginAnalytics.AuthLoginFailed("device_code", "device_code_unsupported")
190222
return clierrors.NewUsage(
191223
"--device-code is not yet supported; use the default browser flow or --api-key",
192224
)
@@ -219,8 +251,10 @@ func runAuthLogin(cmd *cobra.Command, ctx *cmdContext, flags authLoginFlags) err
219251

220252
switch {
221253
case flags.oauthMode:
254+
loginAnalytics.AuthLoginStarted("oauth")
222255
return deps.runOAuth(cmd, ctx)
223256
case flags.apiKeyMode:
257+
loginAnalytics.AuthLoginStarted("api_key")
224258
return deps.runAPIKey(cmd, ctx)
225259
}
226260

@@ -230,6 +264,8 @@ func runAuthLogin(cmd *cobra.Command, ctx *cmdContext, flags authLoginFlags) err
230264
// keystrokes), and only when nothing in the environment has asked
231265
// us to behave non-interactively.
232266
if deps.stdinIsTerminal() && deps.stdoutIsTerminal() && !deps.nonInteractive() {
267+
// No AuthLoginStarted here — the picker hasn't resolved a method
268+
// yet. A cancel below must emit no started/failed pair at all.
233269
choice, err := deps.runPicker(cmd.Context(), cmd.InOrStdin(), cmd.ErrOrStderr())
234270
if err != nil {
235271
if errors.Is(err, pickerCanceledError{}) || errorsAsPickerCanceled(err) {
@@ -239,8 +275,10 @@ func runAuthLogin(cmd *cobra.Command, ctx *cmdContext, flags authLoginFlags) err
239275
}
240276
switch choice {
241277
case loginChoiceOAuth:
278+
loginAnalytics.AuthLoginStarted("oauth")
242279
return deps.runOAuth(cmd, ctx)
243280
case loginChoiceAPIKey:
281+
loginAnalytics.AuthLoginStarted("api_key")
244282
return deps.runAPIKey(cmd, ctx)
245283
default:
246284
return clierrors.New(fmt.Sprintf("unknown login choice: %d", choice))
@@ -251,6 +289,7 @@ func runAuthLogin(cmd *cobra.Command, ctx *cmdContext, flags authLoginFlags) err
251289
// decision: agents and CI runners feed keys on stdin, and the
252290
// browser-OAuth dance has nowhere to land its loopback callback
253291
// when there's no human to click "Allow".
292+
loginAnalytics.AuthLoginStarted("api_key")
254293
return deps.runAPIKey(cmd, ctx)
255294
}
256295

@@ -273,9 +312,11 @@ func errorsAsPickerCanceled(err error) bool {
273312
func runAPIKeyLogin(cmd *cobra.Command, ctx *cmdContext) error {
274313
key, err := readAPIKey(cmd.InOrStdin(), cmd.ErrOrStderr())
275314
if err != nil {
315+
loginAnalytics.AuthLoginFailed("api_key", "api_key_aborted")
276316
return err
277317
}
278318
if key == "" {
319+
loginAnalytics.AuthLoginFailed("api_key", "api_key_invalid_input")
279320
if stdinIsTerminalFunc() {
280321
return clierrors.NewUsage(
281322
"no API key entered — type your key after the prompt, or paste it.",
@@ -340,6 +381,9 @@ func runAPIKeyLogin(cmd *cobra.Command, ctx *cmdContext) error {
340381
if saveErr := auth.SaveUserInfo(userInfo); saveErr != nil {
341382
fmt.Fprintf(cmd.ErrOrStderr(), "(warning: could not persist user info: %v)\n", saveErr)
342383
}
384+
if id := identityKey(userInfo); id != "" {
385+
loginAnalytics.IdentifyAccount(id)
386+
}
343387
} else if clearErr := auth.ClearUserInfo(); clearErr != nil {
344388
// Probe failed but a stale user block from a prior login may
345389
// still be on disk. Best-effort clear; a failure here is
@@ -372,6 +416,7 @@ func runAPIKeyLogin(cmd *cobra.Command, ctx *cmdContext) error {
372416
if err != nil {
373417
return clierrors.New(fmt.Sprintf("failed to encode response: %v", err))
374418
}
419+
loginAnalytics.AuthLoginCompleted("api_key")
375420
if display := userInfo.DisplayName(); display != "" {
376421
fmt.Fprintf(cmd.ErrOrStderr(), "Logged in as %s\n", display)
377422
}
@@ -389,6 +434,7 @@ func runOAuthLogin(cmd *cobra.Command, ctx *cmdContext, cfg oauthLoginConfig) er
389434
// guard when cfg.OpenBrowser is injected (test path drives the
390435
// callback synthetically). (N1)
391436
if cfg.OpenBrowser == nil && isHeadlessOAuthShell() {
437+
loginAnalytics.AuthLoginFailed("oauth", "headless_shell")
392438
return clierrors.NewUsage(
393439
"cannot complete browser OAuth flow in a headless shell " +
394440
"(no TTY and BROWSER=none / HEYGEN_NO_BROWSER=1).\n" +
@@ -446,18 +492,22 @@ func runOAuthLogin(cmd *cobra.Command, ctx *cmdContext, cfg oauthLoginConfig) er
446492
var loopbackResult oauth.LoopbackResult
447493
select {
448494
case <-cmdCtx.Done():
495+
loginAnalytics.AuthLoginFailed("oauth", "oauth_timeout")
449496
return clierrors.New(fmt.Sprintf("oauth: timed out waiting for browser callback: %v", cmdCtx.Err()))
450497
case loopbackResult = <-results:
451498
}
452499
if loopbackResult.Err != nil {
500+
loginAnalytics.AuthLoginFailed("oauth", "oauth_flow_error")
453501
return clierrors.New(loopbackResult.Err.Error())
454502
}
455503

456504
tok, err := oc.ExchangeAuthorizationCode(cmdCtx, loopbackResult.Code, verifier, loopbackResult.RedirectURI)
457505
if err != nil {
506+
loginAnalytics.AuthLoginFailed("oauth", "token_exchange_failed")
458507
return clierrors.New(fmt.Sprintf("oauth: token exchange failed: %v", err))
459508
}
460509
if tok.AccessToken == "" {
510+
loginAnalytics.AuthLoginFailed("oauth", "token_exchange_failed")
461511
return clierrors.New("oauth: token endpoint returned no access_token")
462512
}
463513

@@ -508,6 +558,9 @@ func runOAuthLogin(cmd *cobra.Command, ctx *cmdContext, cfg oauthLoginConfig) er
508558
if saveErr := auth.SaveUserInfo(userInfo); saveErr != nil {
509559
fmt.Fprintf(cmd.ErrOrStderr(), "(warning: could not persist user info: %v)\n", saveErr)
510560
}
561+
if id := identityKey(userInfo); id != "" {
562+
loginAnalytics.IdentifyAccount(id)
563+
}
511564
} else if clearErr := auth.ClearUserInfo(); clearErr != nil {
512565
// Probe failed AND the file might still hold a stale user block
513566
// from a prior login (different account). Best-effort clear; a
@@ -546,6 +599,7 @@ func runOAuthLogin(cmd *cobra.Command, ctx *cmdContext, cfg oauthLoginConfig) er
546599
if err != nil {
547600
return clierrors.New(fmt.Sprintf("failed to encode response: %v", err))
548601
}
602+
loginAnalytics.AuthLoginCompleted("oauth")
549603
if display := userInfo.DisplayName(); display != "" {
550604
fmt.Fprintf(cmd.ErrOrStderr(), "Logged in as %s\n", display)
551605
} else {

cmd/heygen/auth_login_dispatch_test.go

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,67 @@ import (
44
"context"
55
"errors"
66
"io"
7+
"slices"
78
"strings"
89
"testing"
910

1011
"github.com/spf13/cobra"
1112
)
1213

14+
// loginAnalyticsSpy is a test double for loginAnalyticsClient (U3/U4): it
15+
// records every IdentifyAccount / AuthLoginStarted / AuthLoginCompleted /
16+
// AuthLoginFailed call in order so tests can assert both the exact call
17+
// and how it reconciles against the others.
18+
type loginAnalyticsSpy struct {
19+
identifyCalls []string
20+
started []string
21+
completed []string
22+
failed []loginAnalyticsFailedCall
23+
}
24+
25+
type loginAnalyticsFailedCall struct {
26+
method string
27+
reason string
28+
}
29+
30+
func (s *loginAnalyticsSpy) IdentifyAccount(distinctId string) {
31+
s.identifyCalls = append(s.identifyCalls, distinctId)
32+
}
33+
34+
func (s *loginAnalyticsSpy) AuthLoginStarted(method string) {
35+
s.started = append(s.started, method)
36+
}
37+
38+
func (s *loginAnalyticsSpy) AuthLoginCompleted(method string) {
39+
s.completed = append(s.completed, method)
40+
}
41+
42+
func (s *loginAnalyticsSpy) AuthLoginFailed(method, reason string) {
43+
s.failed = append(s.failed, loginAnalyticsFailedCall{method: method, reason: reason})
44+
}
45+
46+
// withLoginAnalyticsSpy swaps the package-level loginAnalytics for a spy
47+
// for the duration of the test, restoring the previous value on cleanup.
48+
// Every entry point that fires login telemetry (the dispatcher, the two
49+
// runners, or a full cmd.Execute()) reads the same package var, so this
50+
// works regardless of how the test drives the login flow.
51+
func withLoginAnalyticsSpy(t *testing.T) *loginAnalyticsSpy {
52+
t.Helper()
53+
spy := &loginAnalyticsSpy{}
54+
orig := loginAnalytics
55+
loginAnalytics = spy
56+
t.Cleanup(func() { loginAnalytics = orig })
57+
return spy
58+
}
59+
60+
// erroringReader always fails on Read, simulating an aborted stdin read
61+
// (e.g. Ctrl-C mid-input) for the api-key login flow.
62+
type erroringReader struct{}
63+
64+
func (erroringReader) Read(_ []byte) (int, error) {
65+
return 0, errors.New("input aborted")
66+
}
67+
1368
// dispatchSpy records which runner runAuthLogin ended up invoking so
1469
// the table-driven dispatch tests can assert against a stable value
1570
// without spinning up the real OAuth / API-key flows.
@@ -264,3 +319,148 @@ func TestNonInteractiveEnvFunc_ParsesCommonShapes(t *testing.T) {
264319
})
265320
}
266321
}
322+
323+
// U4: AuthLoginStarted fires once per dispatched attempt, after the
324+
// picker/flag resolves a method — using the same stubbed runOAuth/runAPIKey
325+
// as TestRunAuthLogin_Dispatch (so only Started is observable here; the
326+
// real runners' Completed/Failed calls are exercised separately in
327+
// auth_login_oauth_test.go where the real runOAuthLogin/runAPIKeyLogin run).
328+
func TestRunAuthLogin_TelemetryStarted(t *testing.T) {
329+
cases := []struct {
330+
name string
331+
flags authLoginFlags
332+
pickerChoice loginChoice
333+
wantStarted []string
334+
}{
335+
{
336+
name: "--oauth flag",
337+
flags: authLoginFlags{oauthMode: true},
338+
wantStarted: []string{"oauth"},
339+
},
340+
{
341+
name: "--api-key flag",
342+
flags: authLoginFlags{apiKeyMode: true},
343+
wantStarted: []string{"api_key"},
344+
},
345+
{
346+
name: "picker picks oauth",
347+
pickerChoice: loginChoiceOAuth,
348+
wantStarted: []string{"oauth"},
349+
},
350+
{
351+
name: "picker picks api key",
352+
pickerChoice: loginChoiceAPIKey,
353+
wantStarted: []string{"api_key"},
354+
},
355+
}
356+
357+
for _, tc := range cases {
358+
t.Run(tc.name, func(t *testing.T) {
359+
spy := withLoginAnalyticsSpy(t)
360+
dispatchTestSpy := &dispatchSpy{pickerChoice: tc.pickerChoice}
361+
runAuthLoginTestDeps = dispatchTestSpy.deps()
362+
t.Cleanup(func() { runAuthLoginTestDeps = nil })
363+
364+
cmd, ctx := makeDispatchCmd(t)
365+
if err := runAuthLogin(cmd, ctx, tc.flags); err != nil {
366+
t.Fatalf("runAuthLogin: %v", err)
367+
}
368+
369+
if !slices.Equal(spy.started, tc.wantStarted) {
370+
t.Errorf("started = %v, want %v", spy.started, tc.wantStarted)
371+
}
372+
// The stubbed runners never call Completed/Failed themselves —
373+
// only the dispatcher's Started call is under test here.
374+
if len(spy.completed) != 0 {
375+
t.Errorf("completed = %v, want none (runner is stubbed)", spy.completed)
376+
}
377+
if len(spy.failed) != 0 {
378+
t.Errorf("failed = %v, want none (runner is stubbed)", spy.failed)
379+
}
380+
})
381+
}
382+
}
383+
384+
// U4: a picker cancellation must emit no started/failed pair at all —
385+
// Started only fires after a method is chosen.
386+
func TestRunAuthLogin_PickerCancel_NoTelemetry(t *testing.T) {
387+
spy := withLoginAnalyticsSpy(t)
388+
dispatchTestSpy := &dispatchSpy{pickerErr: pickerCanceledError{}}
389+
runAuthLoginTestDeps = dispatchTestSpy.deps()
390+
t.Cleanup(func() { runAuthLoginTestDeps = nil })
391+
392+
cmd, ctx := makeDispatchCmd(t)
393+
if err := runAuthLogin(cmd, ctx, authLoginFlags{}); err == nil {
394+
t.Fatal("want canceled error, got nil")
395+
}
396+
397+
if len(spy.started) != 0 || len(spy.completed) != 0 || len(spy.failed) != 0 {
398+
t.Fatalf("expected no telemetry on picker cancel; started=%v completed=%v failed=%v",
399+
spy.started, spy.completed, spy.failed)
400+
}
401+
}
402+
403+
// U4: --device-code emits started(device_code) then
404+
// failed(device_code, device_code_unsupported) — the reason is explicitly
405+
// named in the plan's enum, so this implementation instruments it rather
406+
// than silently dropping telemetry for the flag.
407+
func TestRunAuthLogin_DeviceCode_Telemetry(t *testing.T) {
408+
spy := withLoginAnalyticsSpy(t)
409+
410+
cmd, ctx := makeDispatchCmd(t)
411+
if err := runAuthLogin(cmd, ctx, authLoginFlags{deviceCodeMode: true}); err == nil {
412+
t.Fatal("want usage error, got nil")
413+
}
414+
415+
if got := spy.started; len(got) != 1 || got[0] != "device_code" {
416+
t.Fatalf("started = %v, want [device_code]", got)
417+
}
418+
if got := spy.failed; len(got) != 1 || got[0] != (loginAnalyticsFailedCall{"device_code", "device_code_unsupported"}) {
419+
t.Fatalf("failed = %v, want [{device_code device_code_unsupported}]", got)
420+
}
421+
}
422+
423+
// U4: --api-key with an aborted stdin read (Ctrl-C mid-input) emits
424+
// started(api_key) then failed(api_key, api_key_aborted).
425+
func TestRunAuthLogin_APIKeyAborted_Telemetry(t *testing.T) {
426+
spy := withLoginAnalyticsSpy(t)
427+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
428+
429+
cmd, ctx := makeDispatchCmd(t)
430+
cmd.SetIn(erroringReader{})
431+
432+
if err := runAuthLogin(cmd, ctx, authLoginFlags{apiKeyMode: true}); err == nil {
433+
t.Fatal("want error, got nil")
434+
}
435+
436+
if got := spy.started; len(got) != 1 || got[0] != "api_key" {
437+
t.Fatalf("started = %v, want [api_key]", got)
438+
}
439+
if got := spy.failed; len(got) != 1 || got[0] != (loginAnalyticsFailedCall{"api_key", "api_key_aborted"}) {
440+
t.Fatalf("failed = %v, want [{api_key api_key_aborted}]", got)
441+
}
442+
if len(spy.completed) != 0 {
443+
t.Fatalf("completed = %v, want none", spy.completed)
444+
}
445+
}
446+
447+
// U4: --api-key with empty stdin emits started(api_key) then
448+
// failed(api_key, api_key_invalid_input).
449+
func TestRunAuthLogin_APIKeyEmptyInput_Telemetry(t *testing.T) {
450+
spy := withLoginAnalyticsSpy(t)
451+
t.Setenv("HEYGEN_CONFIG_DIR", t.TempDir())
452+
453+
cmd, ctx := makeDispatchCmd(t)
454+
cmd.SetIn(strings.NewReader(""))
455+
456+
if err := runAuthLogin(cmd, ctx, authLoginFlags{apiKeyMode: true}); err == nil {
457+
t.Fatal("want error, got nil")
458+
}
459+
460+
if got := spy.started; len(got) != 1 || got[0] != "api_key" {
461+
t.Fatalf("started = %v, want [api_key]", got)
462+
}
463+
if got := spy.failed; len(got) != 1 || got[0] != (loginAnalyticsFailedCall{"api_key", "api_key_invalid_input"}) {
464+
t.Fatalf("failed = %v, want [{api_key api_key_invalid_input}]", got)
465+
}
466+
}

0 commit comments

Comments
 (0)