diff --git a/packages/cmd/login.go b/packages/cmd/login.go index 43e3d36a..979f50d3 100644 --- a/packages/cmd/login.go +++ b/packages/cmd/login.go @@ -242,8 +242,11 @@ var loginCmd = &cobra.Command{ } // Identify the user in PostHog and alias the anonymous machine ID - // so that pre-login CLI events are merged into the same person record - Telemetry.IdentifyUser(userCredentialsToBeStored.Email) + // so that pre-login CLI events are merged into the same person record. + // This call is idempotent (gated on LastIdentifiedEmail in the config), + // and CaptureEvent below will also invoke it as a safety net for users + // who are already logged in on older CLIs that predate IdentifyUser. + Telemetry.IdentifyUserIfNeeded() // clear backed up secrets from prev account util.DeleteBackupSecrets() diff --git a/packages/models/cli.go b/packages/models/cli.go index 0b8c117f..ebd1da09 100644 --- a/packages/models/cli.go +++ b/packages/models/cli.go @@ -17,6 +17,13 @@ type ConfigFile struct { VaultBackendType string `json:"vaultBackendType,omitempty"` VaultBackendPassphrase string `json:"vaultBackendPassphrase,omitempty"` Domains []string `json:"domains,omitempty"` + // LastIdentifiedEmail tracks the most recent email for which a PostHog + // Identify/Alias call has been issued. It is used to ensure that telemetry + // person records are enriched with `email` (and aliased from any anonymous + // machine ID) exactly once per email per machine, even when the login + // happened on an older CLI version that predates the IdentifyUser flow, + // or when the email is changed via `infisical user switch`. + LastIdentifiedEmail string `json:"lastIdentifiedEmail,omitempty"` } type LoggedInUser struct { diff --git a/packages/telemetry/telemetry.go b/packages/telemetry/telemetry.go index d40f5437..e2527c19 100644 --- a/packages/telemetry/telemetry.go +++ b/packages/telemetry/telemetry.go @@ -48,6 +48,14 @@ func (t *Telemetry) CaptureEvent(eventName string, properties posthog.Properties } if t.isEnabled { + // Lazily issue the PostHog Identify/Alias for the current logged-in + // user before capturing the event. This catches the case where the + // user logged in on an older CLI version that predates IdentifyUser + // (so their PostHog person record was never enriched with `email`), + // as well as profile switches via `infisical user switch`. The call + // is idempotent and persists its state in the local config file. + t.IdentifyUserIfNeeded() + t.posthogClient.Enqueue(posthog.Capture{ DistinctId: userIdentity, Event: eventName, @@ -58,35 +66,80 @@ func (t *Telemetry) CaptureEvent(eventName string, properties posthog.Properties } } -// IdentifyUser sends a PostHog identify call to enrich the person record -// with user properties, and aliases the anonymous machine ID to the user's +// IdentifyUserIfNeeded sends a PostHog Identify call to enrich the person +// record with the user's email, and aliases the anonymous machine ID to the // email so that pre-login CLI events are merged into the same person. -func (t *Telemetry) IdentifyUser(email string) { - if !t.isEnabled || email == "" { +// +// The call is idempotent: it tracks the last identified email in the local +// config file (`LastIdentifiedEmail`) and skips Identify/Alias when it has +// already been issued for the current `LoggedInUserEmail`. This ensures a +// single Identify is sent per email per machine, even when: +// - the original login happened on a CLI version that predates IdentifyUser, +// - the user changes profiles via `infisical user switch`, +// - subsequent CLI commands run after the original login. +// +// No Close() is performed here — the caller (typically CaptureEvent) is +// responsible for flushing the PostHog client after enqueueing. +func (t *Telemetry) IdentifyUserIfNeeded() { + if !t.isEnabled { + return + } + + configFile, err := util.GetConfigFile() + if err != nil { + log.Debug().Err(err).Msg("IdentifyUserIfNeeded: failed to read config file") + return + } + + email := configFile.LoggedInUserEmail + if email == "" || email == configFile.LastIdentifiedEmail { return } // Identify the user with their email as the distinctId - t.posthogClient.Enqueue(posthog.Identify{ + if err := t.posthogClient.Enqueue(posthog.Identify{ DistinctId: email, Properties: posthog.NewProperties(). Set("email", email), - }) + }); err != nil { + // If we couldn't even enqueue the Identify (closed client, malformed + // message, etc.), don't persist LastIdentifiedEmail — otherwise the + // guard at the top of this function would lock the user out of ever + // being identified again. Bail out early so the next CLI invocation + // retries naturally. + log.Debug().Err(err).Msgf("IdentifyUserIfNeeded: failed to enqueue Identify [email=%s]", email) + return + } - // Alias the anonymous machine ID to the user's email so that - // any events captured before login are linked to this person + // Alias the anonymous machine ID to the user's email so that any events + // captured before login (or before IdentifyUser was added) are linked to + // the same person record. PostHog only honors the first Alias for a given + // anonymous ID, so subsequent invocations on the same machine are no-ops + // on the server side — which is fine, the persisted LastIdentifiedEmail + // guard prevents us from re-enqueueing them anyway. + // + // We only persist LastIdentifiedEmail after both enqueues succeed; a + // failure on Alias means the anonymous-to-email link wasn't recorded, + // so we want the next invocation to retry it. machineId, err := machineid.ID() if err == nil && machineId != "" { anonymousId := "anonymous_cli_" + machineId - t.posthogClient.Enqueue(posthog.Alias{ + if err := t.posthogClient.Enqueue(posthog.Alias{ DistinctId: email, Alias: anonymousId, - }) + }); err != nil { + log.Debug().Err(err).Msgf("IdentifyUserIfNeeded: failed to enqueue Alias [email=%s] [anonymousId=%s]", email, anonymousId) + return + } } - // Note: no Close() here — the caller is responsible for ensuring - // CaptureEvent (which calls Close) runs after IdentifyUser to flush - // all enqueued events (Identify, Alias, and Capture). + // Persist that we've identified this email so we don't re-fire on the + // next CLI invocation. A failure here is non-fatal — the worst case is + // one extra Identify enqueue on the next run. + configFile.LastIdentifiedEmail = email + if err := util.WriteConfigFile(&configFile); err != nil { + log.Debug().Err(err).Msgf("IdentifyUserIfNeeded: failed to persist LastIdentifiedEmail [email=%s]", email) + } } func (t *Telemetry) GetDistinctId() (string, error) { diff --git a/packages/util/config.go b/packages/util/config.go index 8d44c84d..99bfb47d 100644 --- a/packages/util/config.go +++ b/packages/util/config.go @@ -57,6 +57,7 @@ func WriteInitalConfig(userCredentials *models.UserCredentials) error { VaultBackendType: existingConfigFile.VaultBackendType, VaultBackendPassphrase: existingConfigFile.VaultBackendPassphrase, Domains: existingConfigFile.Domains, + LastIdentifiedEmail: existingConfigFile.LastIdentifiedEmail, } configFileMarshalled, err := json.Marshal(configFile)