diff --git a/packages/cmd/agent.go b/packages/cmd/agent.go index 93ce80e5..d884a2cd 100644 --- a/packages/cmd/agent.go +++ b/packages/cmd/agent.go @@ -47,6 +47,11 @@ import ( const DEFAULT_INFISICAL_CLOUD_URL = "https://app.infisical.com" +// AGENT_USER_AGENT identifies agent traffic to the backend so it can be +// attributed separately from interactive CLI usage (which sends api.USER_AGENT). +// The backend matches on the "infisical-agent" prefix. +var AGENT_USER_AGENT = "infisical-agent/" + util.CLI_VERSION + const CACHE_TYPE_KUBERNETES = "kubernetes" const DYNAMIC_SECRET_LEASE_TEMPLATE = "dynamic-secret-lease-%s-%s-%s-%s-%s-%s" @@ -988,7 +993,7 @@ func dynamicSecretTemplateFunction(accessToken string, dynamicSecretManager *Dyn temporaryInfisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, - UserAgent: api.USER_AGENT, + UserAgent: AGENT_USER_AGENT, AutoTokenRefresh: false, RetryRequestsConfig: agentManager.SdkRetryConfig(), }) @@ -1190,7 +1195,7 @@ func NewAgentManager(options NewAgentMangerOptions) *AgentManager { agentManager.infisicalClient = infisicalSdk.NewInfisicalClient(ctx, infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, - UserAgent: api.USER_AGENT, // ? Should we perhaps use a different user agent for the Agent for better analytics? + UserAgent: AGENT_USER_AGENT, AutoTokenRefresh: true, CustomHeaders: customHeaders, RetryRequestsConfig: retryConfig, @@ -1510,7 +1515,7 @@ func revokeDynamicSecretLease(accessToken, projectSlug, environment, secretPath, temporaryInfisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, - UserAgent: api.USER_AGENT, + UserAgent: AGENT_USER_AGENT, AutoTokenRefresh: false, CustomHeaders: customHeaders, RetryRequestsConfig: retryConfig, @@ -1633,7 +1638,7 @@ func (tm *AgentManager) RevokeCredentials() error { temporaryInfisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, - UserAgent: api.USER_AGENT, + UserAgent: AGENT_USER_AGENT, AutoTokenRefresh: false, CustomHeaders: customHeaders, }) @@ -1663,7 +1668,7 @@ func (tm *AgentManager) RevokeCredentials() error { if !slices.Contains(deletedTokens, token) { temporaryInfisicalClient := infisicalSdk.NewInfisicalClient(context.Background(), infisicalSdk.Config{ SiteUrl: config.INFISICAL_URL, - UserAgent: api.USER_AGENT, + UserAgent: AGENT_USER_AGENT, AutoTokenRefresh: false, CustomHeaders: customHeaders, }) diff --git a/packages/cmd/root.go b/packages/cmd/root.go index fa103824..e943cd10 100644 --- a/packages/cmd/root.go +++ b/packages/cmd/root.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "strconv" "strings" "time" @@ -130,10 +131,14 @@ func init() { RootCmd.PersistentFlags().StringP("log-level", "l", "", "log level (trace, debug, info, warn, error, fatal)") RootCmd.PersistentFlags().StringVar(&logFormat, "log-format", "", "log output format: console (default, colored), plain (no color), json (structured). Set NO_COLOR=1 to disable colors in console mode. Can also set via LOG_FORMAT env var.") RootCmd.PersistentFlags().StringVar(&logDestination, "log-destination", "", "log output destination: stderr (default), stdout. Can also set via LOG_DESTINATION env var.") - RootCmd.PersistentFlags().Bool("telemetry", true, "Infisical collects non-sensitive telemetry data to enhance features and improve user experience. Participation is voluntary") + RootCmd.PersistentFlags().Bool("telemetry", true, "Infisical collects non-sensitive telemetry data to enhance features and improve user experience. Participation is voluntary. Can also opt out by setting the INFISICAL_TELEMETRY_ENABLED environment variable to false.") RootCmd.PersistentFlags().StringVar(&config.INFISICAL_URL, "domain", fmt.Sprintf("%s/api", util.INFISICAL_DEFAULT_US_URL), "Point the CLI to your Infisical instance (e.g., https://eu.infisical.com for EU Cloud, or https://your-instance.com for self-hosted). Can also set via INFISICAL_DOMAIN environment variable or the 'domain' field in .infisical.json. Required for non-US Cloud users.") RootCmd.PersistentFlags().Bool("silent", false, "Disable output of tip/info messages. Useful when running in scripts or CI/CD pipelines.") RootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + // The Telemetry instance is constructed in init(), before cobra has + // parsed argv, so the --telemetry flag can only be applied here. + Telemetry.SetEnabled(resolveTelemetryEnabled(cmd)) + silent, err := cmd.Flags().GetBool("silent") if err != nil { util.HandleError(err) @@ -158,8 +163,35 @@ func init() { } - isTelemetryOn, _ := RootCmd.PersistentFlags().GetBool("telemetry") - Telemetry = telemetry.NewTelemetry(isTelemetryOn) + // The --telemetry flag cannot be read here: argv has not been parsed yet, so + // GetBool would always return the registered default. Construct the instance + // from the environment opt-out alone and apply the flag in PersistentPreRun. + Telemetry = telemetry.NewTelemetry(telemetryEnabledFromEnv()) +} + +// telemetryEnabledFromEnv reads the INFISICAL_TELEMETRY_ENABLED environment +// variable. Anything that does not parse as a bool (including unset) leaves +// telemetry enabled. +func telemetryEnabledFromEnv() bool { + if value := os.Getenv(util.INFISICAL_TELEMETRY_ENABLED_NAME); value != "" { + if enabled, err := strconv.ParseBool(value); err == nil { + return enabled + } + } + return true +} + +// resolveTelemetryEnabled resolves the telemetry opt-out by precedence: an +// explicitly set --telemetry flag wins, then the INFISICAL_TELEMETRY_ENABLED +// environment variable, then the default (enabled). Must run after flag +// parsing (PersistentPreRun, not init) so cmd.Flags() is reliable. +func resolveTelemetryEnabled(cmd *cobra.Command) bool { + if cmd.Flags().Changed("telemetry") { + if enabled, err := cmd.Flags().GetBool("telemetry"); err == nil { + return enabled + } + } + return telemetryEnabledFromEnv() } func initLog() { diff --git a/packages/telemetry/telemetry.go b/packages/telemetry/telemetry.go index b568304f..d67d1ecc 100644 --- a/packages/telemetry/telemetry.go +++ b/packages/telemetry/telemetry.go @@ -71,6 +71,13 @@ func (t *Telemetry) CaptureEvent(eventName string, properties posthog.Properties } if orgId := t.resolveOrganizationId(); orgId != "" { + // Set the organization both as a flat property (so property-based + // filters and joins against backend events work) and as a PostHog + // group (for group analytics). + if capture.Properties == nil { + capture.Properties = posthog.NewProperties() + } + capture.Properties.Set("organizationId", orgId) capture.Groups = posthog.NewGroups().Set("organization", orgId) } @@ -80,6 +87,15 @@ func (t *Telemetry) CaptureEvent(eventName string, properties posthog.Properties } } +// SetEnabled turns event capture on or off. It exists so the opt-out surfaces +// (--telemetry flag, INFISICAL_TELEMETRY_ENABLED env var) can be applied after +// cobra has actually parsed argv: the Telemetry instance is constructed in +// init(), before flag values are available. Capture can never be enabled +// without a PostHog client (i.e. when no API key was compiled in). +func (t *Telemetry) SetEnabled(enabled bool) { + t.isEnabled = enabled && t.posthogClient != nil +} + // SetActor records who the command is acting as, for commands that resolve their // own credential and so have nothing in the environment to read. func (t *Telemetry) SetActor(identityId, orgId string) { @@ -89,14 +105,44 @@ func (t *Telemetry) SetActor(identityId, orgId string) { // Once an actor is set, its organization is authoritative even when empty: falling // back to the environment would group the event under a different actor's org. +// +// The resolution priority mirrors GetDistinctId: SetActor, then the logged-in +// user's session JWT, then a machine-identity access token from the environment. +// A logged-in user is authoritative even when their token carries no +// organization, for the same reason as SetActor: their events are attributed to +// their email, so grouping them under an env-token's org would be wrong. func (t *Telemetry) resolveOrganizationId() string { if t.attachedIdentityId != "" || t.attachedOrgId != "" { return t.attachedOrgId } + if orgId, ok := loggedInUserOrganizationId(); ok { + return orgId + } _, orgId := machineIdentityClaimsFromEnv() return orgId } +// loggedInUserOrganizationId reads the `organizationId` claim from the +// logged-in user's session JWT in the system keyring. The second return value +// reports whether a logged-in user exists at all (their org claim may still be +// empty, e.g. before an organization is selected). Best-effort and silent on +// failure, like the rest of the telemetry claim parsing: nothing from the +// token itself is kept and no server call is made. +func loggedInUserOrganizationId() (orgId string, loggedIn bool) { + configFile, err := util.GetConfigFile() + if err != nil || configFile.LoggedInUserEmail == "" { + return "", false + } + + userCreds, err := util.GetUserCredsFromKeyRing(configFile.LoggedInUserEmail) + if err != nil { + return "", true + } + + _, orgId = IdentityClaimsFromToken(userCreds.JTWToken) + return orgId, true +} + // 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. diff --git a/packages/util/constants.go b/packages/util/constants.go index 764ad3ca..d0eba301 100644 --- a/packages/util/constants.go +++ b/packages/util/constants.go @@ -11,6 +11,7 @@ const ( INFISICAL_ENVIRONMENT_NAME = "INFISICAL_ENVIRONMENT" INFISICAL_SECRET_PATH_NAME = "INFISICAL_SECRET_PATH" INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME = "INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN" + INFISICAL_TELEMETRY_ENABLED_NAME = "INFISICAL_TELEMETRY_ENABLED" // Agent proxy (connect) INFISICAL_AGENT_PROXY_ADDRESS_NAME = "INFISICAL_AGENT_PROXY_ADDRESS"