Skip to content

Commit 257819c

Browse files
feat(analytics): unify install identity + structured auth telemetry (#212)
1 parent 23a8536 commit 257819c

8 files changed

Lines changed: 1467 additions & 20 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: 112 additions & 4 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,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.
2558
type oauthLoginConfig struct {
@@ -187,6 +220,8 @@ var runAuthLoginTestDeps *runAuthLoginDeps
187220
// override is in effect, else we default to the API-key flow.
188221
func 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

Comments
 (0)