@@ -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.
2555type oauthLoginConfig struct {
@@ -187,6 +217,8 @@ var runAuthLoginTestDeps *runAuthLoginDeps
187217// override is in effect, else we default to the API-key flow.
188218func 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 {
273312func 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 {
0 commit comments