@@ -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,38 @@ 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+ // Lowercased so this joins with hyperframes-oss's own identify call
47+ // regardless of the account's stored casing — an uppercase-email user would
48+ // otherwise split across two PostHog profiles.
49+ func identityKey (userInfo auth.UserInfo ) string {
50+ if userInfo .Email != "" {
51+ return strings .ToLower (userInfo .Email )
52+ }
53+ return strings .ToLower (userInfo .Username )
54+ }
55+
2356// oauthLoginConfig is overridable in tests so the login flow can be
2457// driven against an httptest IdP without spawning a real browser.
2558type oauthLoginConfig struct {
@@ -187,6 +220,8 @@ var runAuthLoginTestDeps *runAuthLoginDeps
187220// override is in effect, else we default to the API-key flow.
188221func runAuthLogin (cmd * cobra.Command , ctx * cmdContext , flags authLoginFlags ) error {
189222 if flags .deviceCodeMode {
223+ loginAnalytics .AuthLoginStarted ("device_code" )
224+ loginAnalytics .AuthLoginFailed ("device_code" , "device_code_unsupported" )
190225 return clierrors .NewUsage (
191226 "--device-code is not yet supported; use the default browser flow or --api-key" ,
192227 )
@@ -219,8 +254,10 @@ func runAuthLogin(cmd *cobra.Command, ctx *cmdContext, flags authLoginFlags) err
219254
220255 switch {
221256 case flags .oauthMode :
257+ loginAnalytics .AuthLoginStarted ("oauth" )
222258 return deps .runOAuth (cmd , ctx )
223259 case flags .apiKeyMode :
260+ loginAnalytics .AuthLoginStarted ("api_key" )
224261 return deps .runAPIKey (cmd , ctx )
225262 }
226263
@@ -230,6 +267,8 @@ func runAuthLogin(cmd *cobra.Command, ctx *cmdContext, flags authLoginFlags) err
230267 // keystrokes), and only when nothing in the environment has asked
231268 // us to behave non-interactively.
232269 if deps .stdinIsTerminal () && deps .stdoutIsTerminal () && ! deps .nonInteractive () {
270+ // No AuthLoginStarted here — the picker hasn't resolved a method
271+ // yet. A cancel below must emit no started/failed pair at all.
233272 choice , err := deps .runPicker (cmd .Context (), cmd .InOrStdin (), cmd .ErrOrStderr ())
234273 if err != nil {
235274 if errors .Is (err , pickerCanceledError {}) || errorsAsPickerCanceled (err ) {
@@ -239,8 +278,10 @@ func runAuthLogin(cmd *cobra.Command, ctx *cmdContext, flags authLoginFlags) err
239278 }
240279 switch choice {
241280 case loginChoiceOAuth :
281+ loginAnalytics .AuthLoginStarted ("oauth" )
242282 return deps .runOAuth (cmd , ctx )
243283 case loginChoiceAPIKey :
284+ loginAnalytics .AuthLoginStarted ("api_key" )
244285 return deps .runAPIKey (cmd , ctx )
245286 default :
246287 return clierrors .New (fmt .Sprintf ("unknown login choice: %d" , choice ))
@@ -251,6 +292,7 @@ func runAuthLogin(cmd *cobra.Command, ctx *cmdContext, flags authLoginFlags) err
251292 // decision: agents and CI runners feed keys on stdin, and the
252293 // browser-OAuth dance has nowhere to land its loopback callback
253294 // when there's no human to click "Allow".
295+ loginAnalytics .AuthLoginStarted ("api_key" )
254296 return deps .runAPIKey (cmd , ctx )
255297}
256298
@@ -270,12 +312,30 @@ func errorsAsPickerCanceled(err error) bool {
270312// cleared so the file holds at most one of api_key / oauth (the
271313// single-credential normalization invariant). Pre-this-change users
272314// with both blocks self-heal on their next login.
273- func runAPIKeyLogin (cmd * cobra.Command , ctx * cmdContext ) error {
315+ func runAPIKeyLogin (cmd * cobra.Command , ctx * cmdContext ) (err error ) {
316+ // reported tracks whether this attempt already emitted a terminal
317+ // analytics event (AuthLoginCompleted/AuthLoginFailed) on one of the
318+ // explicitly-instrumented paths below. The deferred check is a
319+ // safety net for every OTHER error return in this function (e.g. a
320+ // future early return nobody remembers to instrument): without it,
321+ // the AuthLoginStarted event the caller already fired would never
322+ // reconcile.
323+ reported := false
324+ defer func () {
325+ if err != nil && ! reported {
326+ loginAnalytics .AuthLoginFailed ("api_key" , "internal_error" )
327+ }
328+ }()
329+
274330 key , err := readAPIKey (cmd .InOrStdin (), cmd .ErrOrStderr ())
275331 if err != nil {
332+ reported = true
333+ loginAnalytics .AuthLoginFailed ("api_key" , "api_key_aborted" )
276334 return err
277335 }
278336 if key == "" {
337+ reported = true
338+ loginAnalytics .AuthLoginFailed ("api_key" , "api_key_invalid_input" )
279339 if stdinIsTerminalFunc () {
280340 return clierrors .NewUsage (
281341 "no API key entered — type your key after the prompt, or paste it." ,
@@ -340,6 +400,9 @@ func runAPIKeyLogin(cmd *cobra.Command, ctx *cmdContext) error {
340400 if saveErr := auth .SaveUserInfo (userInfo ); saveErr != nil {
341401 fmt .Fprintf (cmd .ErrOrStderr (), "(warning: could not persist user info: %v)\n " , saveErr )
342402 }
403+ if id := identityKey (userInfo ); id != "" {
404+ loginAnalytics .IdentifyAccount (id )
405+ }
343406 } else if clearErr := auth .ClearUserInfo (); clearErr != nil {
344407 // Probe failed but a stale user block from a prior login may
345408 // still be on disk. Best-effort clear; a failure here is
@@ -375,12 +438,35 @@ func runAPIKeyLogin(cmd *cobra.Command, ctx *cmdContext) error {
375438 if display := userInfo .DisplayName (); display != "" {
376439 fmt .Fprintf (cmd .ErrOrStderr (), "Logged in as %s\n " , display )
377440 }
378- return ctx .formatter .Data (data , "" , nil )
441+ // Only report the login as completed once the response has actually
442+ // been written out successfully: if the formatter fails (e.g. a
443+ // broken pipe on stdout), the command still exits non-zero and the
444+ // deferred check above reports AuthLoginFailed instead.
445+ if err = ctx .formatter .Data (data , "" , nil ); err != nil {
446+ return err
447+ }
448+ reported = true
449+ loginAnalytics .AuthLoginCompleted ("api_key" )
450+ return nil
379451}
380452
381453// runOAuthLogin drives the browser + PKCE + loopback dance and persists
382454// the resulting tokens.
383- func runOAuthLogin (cmd * cobra.Command , ctx * cmdContext , cfg oauthLoginConfig ) error {
455+ func runOAuthLogin (cmd * cobra.Command , ctx * cmdContext , cfg oauthLoginConfig ) (err error ) {
456+ // reported tracks whether this attempt already emitted a terminal
457+ // analytics event (AuthLoginCompleted/AuthLoginFailed) on one of the
458+ // explicitly-instrumented paths below. The deferred check is a
459+ // safety net for every OTHER error return in this function (e.g. a
460+ // future early return nobody remembers to instrument): without it,
461+ // the AuthLoginStarted event the caller already fired would never
462+ // reconcile.
463+ reported := false
464+ defer func () {
465+ if err != nil && ! reported {
466+ loginAnalytics .AuthLoginFailed ("oauth" , "internal_error" )
467+ }
468+ }()
469+
384470 // Headless-shell fast-fail: when explicit --oauth lands in a shell
385471 // with no TTY on stdin AND the environment opts out of opening a
386472 // browser (BROWSER=none or HEYGEN_NO_BROWSER=1), the callback can
@@ -389,6 +475,8 @@ func runOAuthLogin(cmd *cobra.Command, ctx *cmdContext, cfg oauthLoginConfig) er
389475 // guard when cfg.OpenBrowser is injected (test path drives the
390476 // callback synthetically). (N1)
391477 if cfg .OpenBrowser == nil && isHeadlessOAuthShell () {
478+ reported = true
479+ loginAnalytics .AuthLoginFailed ("oauth" , "headless_shell" )
392480 return clierrors .NewUsage (
393481 "cannot complete browser OAuth flow in a headless shell " +
394482 "(no TTY and BROWSER=none / HEYGEN_NO_BROWSER=1).\n " +
@@ -446,18 +534,26 @@ func runOAuthLogin(cmd *cobra.Command, ctx *cmdContext, cfg oauthLoginConfig) er
446534 var loopbackResult oauth.LoopbackResult
447535 select {
448536 case <- cmdCtx .Done ():
537+ reported = true
538+ loginAnalytics .AuthLoginFailed ("oauth" , "oauth_timeout" )
449539 return clierrors .New (fmt .Sprintf ("oauth: timed out waiting for browser callback: %v" , cmdCtx .Err ()))
450540 case loopbackResult = <- results :
451541 }
452542 if loopbackResult .Err != nil {
543+ reported = true
544+ loginAnalytics .AuthLoginFailed ("oauth" , "oauth_flow_error" )
453545 return clierrors .New (loopbackResult .Err .Error ())
454546 }
455547
456548 tok , err := oc .ExchangeAuthorizationCode (cmdCtx , loopbackResult .Code , verifier , loopbackResult .RedirectURI )
457549 if err != nil {
550+ reported = true
551+ loginAnalytics .AuthLoginFailed ("oauth" , "token_exchange_failed" )
458552 return clierrors .New (fmt .Sprintf ("oauth: token exchange failed: %v" , err ))
459553 }
460554 if tok .AccessToken == "" {
555+ reported = true
556+ loginAnalytics .AuthLoginFailed ("oauth" , "token_exchange_failed" )
461557 return clierrors .New ("oauth: token endpoint returned no access_token" )
462558 }
463559
@@ -508,6 +604,9 @@ func runOAuthLogin(cmd *cobra.Command, ctx *cmdContext, cfg oauthLoginConfig) er
508604 if saveErr := auth .SaveUserInfo (userInfo ); saveErr != nil {
509605 fmt .Fprintf (cmd .ErrOrStderr (), "(warning: could not persist user info: %v)\n " , saveErr )
510606 }
607+ if id := identityKey (userInfo ); id != "" {
608+ loginAnalytics .IdentifyAccount (id )
609+ }
511610 } else if clearErr := auth .ClearUserInfo (); clearErr != nil {
512611 // Probe failed AND the file might still hold a stale user block
513612 // from a prior login (different account). Best-effort clear; a
@@ -551,7 +650,16 @@ func runOAuthLogin(cmd *cobra.Command, ctx *cmdContext, cfg oauthLoginConfig) er
551650 } else {
552651 fmt .Fprintln (cmd .ErrOrStderr (), "Logged in." )
553652 }
554- return ctx .formatter .Data (data , "" , nil )
653+ // Only report the login as completed once the response has actually
654+ // been written out successfully: if the formatter fails (e.g. a
655+ // broken pipe on stdout), the command still exits non-zero and the
656+ // deferred check above reports AuthLoginFailed instead.
657+ if err = ctx .formatter .Data (data , "" , nil ); err != nil {
658+ return err
659+ }
660+ reported = true
661+ loginAnalytics .AuthLoginCompleted ("oauth" )
662+ return nil
555663}
556664
557665// lookupCurrentUser GETs /v3/users/me with the freshly minted Bearer
0 commit comments