From 0952854db5de8bcb3c29477963b6ed28a09a2c57 Mon Sep 17 00:00:00 2001 From: Sprite Date: Thu, 9 Jul 2026 20:24:57 +0000 Subject: [PATCH 1/7] Send Fly-Client-Agent header with metrics requests Uses client-signals DetectOnce() to include the agent name header on metrics POST requests to flyctl-metrics. This enables the metrics service to distinguish agent-driven vs human-driven CLI commands. Works in both interactive (forked subprocess) and non-interactive paths since env vars are inherited and DetectOnce() caches its result. Co-Authored-By: Claude Opus 4.6 --- internal/metrics/db.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/metrics/db.go b/internal/metrics/db.go index f4ad136937..f8cbfbe70a 100644 --- a/internal/metrics/db.go +++ b/internal/metrics/db.go @@ -11,6 +11,7 @@ import ( "time" "github.com/PuerkitoBio/rehttp" + clientsignals "github.com/superfly/client-signals/go" "github.com/superfly/flyctl/internal/buildinfo" "github.com/superfly/flyctl/internal/cmdutil" "github.com/superfly/flyctl/internal/config" @@ -112,6 +113,11 @@ func sendMetricsRequest(ctx context.Context, endpoint, token, userAgent string, request.Header.Set("Authorization", "Bearer "+token) request.Header.Set("User-Agent", userAgent) + signals := clientsignals.DetectOnce() + if signals.Agent != "" { + request.Header.Set("Fly-Client-Agent", signals.Agent) + } + client := createHTTPClient() resp, err := client.Do(request) From 54e32ffd4560f25d887b3d7ba2e545790069bbff Mon Sep 17 00:00:00 2001 From: Sprite Date: Thu, 9 Jul 2026 20:41:32 +0000 Subject: [PATCH 2/7] Add ClientAgent field to deploy/launch metrics payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always detects signals via clientsignals.DetectOnce() and stores the agent name on context — independent of the client-signals feature flag (which only gates API request headers). Deploy and launch commands read the agent from context to populate the new ClientAgent payload field. Co-Authored-By: Claude Opus 4.6 --- internal/cmdutil/preparers/preparers.go | 9 +++++++-- internal/command/deploy/deploy.go | 1 + internal/command/launch/cmd.go | 1 + internal/metrics/db.go | 6 ------ internal/metrics/helpers.go | 15 +++++++++++++++ 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/cmdutil/preparers/preparers.go b/internal/cmdutil/preparers/preparers.go index 13f803916b..d5b0f57e88 100644 --- a/internal/cmdutil/preparers/preparers.go +++ b/internal/cmdutil/preparers/preparers.go @@ -19,6 +19,7 @@ import ( "github.com/superfly/flyctl/internal/instrument" "github.com/superfly/flyctl/internal/launchdarkly" "github.com/superfly/flyctl/internal/logger" + "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/internal/state" "github.com/superfly/flyctl/internal/uiex" mpgv1 "github.com/superfly/flyctl/internal/uiex/mpg/v1" @@ -66,10 +67,14 @@ func InitClient(ctx context.Context) (context.Context, error) { } logger.Debugf("client-signals-enabled feature flag is: %v", clientSignalsEnabled) + // Always detect signals for metrics (we control both sides). + // The feature flag only gates whether headers are sent on API requests. + detected := clientsignals.DetectOnce() + ctx = metrics.WithClientAgent(ctx, detected.Agent) + var signals *clientsignals.Signals if clientSignalsEnabled { - s := clientsignals.DetectOnce() - signals = &s + signals = &detected } if flyutil.ClientFromContext(ctx) == nil { diff --git a/internal/command/deploy/deploy.go b/internal/command/deploy/deploy.go index 82fc37a8e8..7c16bb3e0c 100644 --- a/internal/command/deploy/deploy.go +++ b/internal/command/deploy/deploy.go @@ -517,6 +517,7 @@ func deployToMachines( startTime := time.Now() var status metrics.DeployStatusPayload + status.ClientAgent = metrics.ClientAgentFromContext(ctx) metrics.Started(ctx, "deploy") // TODO: remove this once there is nothing upstream using it diff --git a/internal/command/launch/cmd.go b/internal/command/launch/cmd.go index 8ffe362623..03127c8c06 100644 --- a/internal/command/launch/cmd.go +++ b/internal/command/launch/cmd.go @@ -303,6 +303,7 @@ func run(ctx context.Context) (err error) { startTime := time.Now() var status metrics.LaunchStatusPayload + status.ClientAgent = metrics.ClientAgentFromContext(ctx) metrics.Started(ctx, "launch") var state *launchState = nil diff --git a/internal/metrics/db.go b/internal/metrics/db.go index f8cbfbe70a..f4ad136937 100644 --- a/internal/metrics/db.go +++ b/internal/metrics/db.go @@ -11,7 +11,6 @@ import ( "time" "github.com/PuerkitoBio/rehttp" - clientsignals "github.com/superfly/client-signals/go" "github.com/superfly/flyctl/internal/buildinfo" "github.com/superfly/flyctl/internal/cmdutil" "github.com/superfly/flyctl/internal/config" @@ -113,11 +112,6 @@ func sendMetricsRequest(ctx context.Context, endpoint, token, userAgent string, request.Header.Set("Authorization", "Bearer "+token) request.Header.Set("User-Agent", userAgent) - signals := clientsignals.DetectOnce() - if signals.Agent != "" { - request.Header.Set("Fly-Client-Agent", signals.Agent) - } - client := createHTTPClient() resp, err := client.Do(request) diff --git a/internal/metrics/helpers.go b/internal/metrics/helpers.go index 7a9b023f98..ad86a4c846 100644 --- a/internal/metrics/helpers.go +++ b/internal/metrics/helpers.go @@ -80,6 +80,7 @@ type LaunchStatusPayload struct { ScannerFamily string `json:"scanner_family"` FlyctlVersion string `json:"flyctlVersion"` + ClientAgent string `json:"clientAgent,omitempty"` } func LaunchStatus(ctx context.Context, payload LaunchStatusPayload) { @@ -113,6 +114,7 @@ type DeployStatusPayload struct { Strategy string `json:"strategy"` FlyctlVersion string `json:"flyctlVersion"` + ClientAgent string `json:"clientAgent,omitempty"` } func DeployStatus(ctx context.Context, payload DeployStatusPayload) { @@ -159,6 +161,19 @@ func StartTiming(ctx context.Context, metricSlug string) func() { } } +type clientAgentKey struct{} + +// WithClientAgent stores the detected agent name in context. +func WithClientAgent(ctx context.Context, agent string) context.Context { + return context.WithValue(ctx, clientAgentKey{}, agent) +} + +// ClientAgentFromContext returns the agent name stored in context, or "". +func ClientAgentFromContext(ctx context.Context) string { + val, _ := ctx.Value(clientAgentKey{}).(string) + return val +} + type disableFlushMetricsKey struct{} // WithDisableFlushMetrics returns a context with a flag that disables From d404b659d3e5562dd81566581b894db2a5d5d240 Mon Sep 17 00:00:00 2001 From: Sprite Date: Thu, 9 Jul 2026 21:01:41 +0000 Subject: [PATCH 3/7] Add ClientAgent field to deploy/launch metrics payloads Always detects client signals and stores the agent name on context, independent of the client-signals feature flag (which only gates API request headers). Deploy and launch commands read the agent from context to populate the new ClientAgent payload field, which flyctl-metrics can use as a Prometheus label. Co-Authored-By: Claude Opus 4.6 --- internal/cmdutil/preparers/preparers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cmdutil/preparers/preparers.go b/internal/cmdutil/preparers/preparers.go index d5b0f57e88..b6ec70d253 100644 --- a/internal/cmdutil/preparers/preparers.go +++ b/internal/cmdutil/preparers/preparers.go @@ -67,8 +67,8 @@ func InitClient(ctx context.Context) (context.Context, error) { } logger.Debugf("client-signals-enabled feature flag is: %v", clientSignalsEnabled) - // Always detect signals for metrics (we control both sides). - // The feature flag only gates whether headers are sent on API requests. + // Always detect for metrics (we control both sides); the feature flag + // only gates whether Fly-Client-* headers are sent on API requests. detected := clientsignals.DetectOnce() ctx = metrics.WithClientAgent(ctx, detected.Agent) From e57e5cc081c6b30db76d54df87f4f3937a1448af Mon Sep 17 00:00:00 2001 From: Sprite Date: Thu, 9 Jul 2026 21:27:53 +0000 Subject: [PATCH 4/7] Add ClientAgent field to deploy/launch metrics payloads When client signals are enabled, stores the detected agent name on context in InitClient. Deploy and launch commands read it to populate the new ClientAgent payload field, which flyctl-metrics uses as a Prometheus label. Co-Authored-By: Claude Opus 4.6 --- internal/cmdutil/preparers/preparers.go | 9 +++------ internal/metrics/helpers.go | 2 -- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/internal/cmdutil/preparers/preparers.go b/internal/cmdutil/preparers/preparers.go index b6ec70d253..5e0ce744f9 100644 --- a/internal/cmdutil/preparers/preparers.go +++ b/internal/cmdutil/preparers/preparers.go @@ -67,14 +67,11 @@ func InitClient(ctx context.Context) (context.Context, error) { } logger.Debugf("client-signals-enabled feature flag is: %v", clientSignalsEnabled) - // Always detect for metrics (we control both sides); the feature flag - // only gates whether Fly-Client-* headers are sent on API requests. - detected := clientsignals.DetectOnce() - ctx = metrics.WithClientAgent(ctx, detected.Agent) - var signals *clientsignals.Signals if clientSignalsEnabled { - signals = &detected + s := clientsignals.DetectOnce() + signals = &s + ctx = metrics.WithClientAgent(ctx, s.Agent) } if flyutil.ClientFromContext(ctx) == nil { diff --git a/internal/metrics/helpers.go b/internal/metrics/helpers.go index ad86a4c846..9a3ccdbdf5 100644 --- a/internal/metrics/helpers.go +++ b/internal/metrics/helpers.go @@ -163,12 +163,10 @@ func StartTiming(ctx context.Context, metricSlug string) func() { type clientAgentKey struct{} -// WithClientAgent stores the detected agent name in context. func WithClientAgent(ctx context.Context, agent string) context.Context { return context.WithValue(ctx, clientAgentKey{}, agent) } -// ClientAgentFromContext returns the agent name stored in context, or "". func ClientAgentFromContext(ctx context.Context) string { val, _ := ctx.Value(clientAgentKey{}).(string) return val From 1386a0c2fa0fe7b4dbaafc0151f15bcf83c9a98d Mon Sep 17 00:00:00 2001 From: Sprite Date: Thu, 9 Jul 2026 22:10:50 +0000 Subject: [PATCH 5/7] Add operator and agent_name to deploy/launch metrics payloads Call clientsignals.DetectOnce() directly in deploy and launch commands to classify the operator as interactive/ci/agent, and capture the agent name when present. Revert the context-plumbing approach in preparers. Co-Authored-By: Claude Opus 4.6 --- internal/cmdutil/preparers/preparers.go | 2 -- internal/command/deploy/deploy.go | 3 ++- internal/command/launch/cmd.go | 3 ++- internal/metrics/helpers.go | 27 +++++++++++++++---------- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/internal/cmdutil/preparers/preparers.go b/internal/cmdutil/preparers/preparers.go index 5e0ce744f9..13f803916b 100644 --- a/internal/cmdutil/preparers/preparers.go +++ b/internal/cmdutil/preparers/preparers.go @@ -19,7 +19,6 @@ import ( "github.com/superfly/flyctl/internal/instrument" "github.com/superfly/flyctl/internal/launchdarkly" "github.com/superfly/flyctl/internal/logger" - "github.com/superfly/flyctl/internal/metrics" "github.com/superfly/flyctl/internal/state" "github.com/superfly/flyctl/internal/uiex" mpgv1 "github.com/superfly/flyctl/internal/uiex/mpg/v1" @@ -71,7 +70,6 @@ func InitClient(ctx context.Context) (context.Context, error) { if clientSignalsEnabled { s := clientsignals.DetectOnce() signals = &s - ctx = metrics.WithClientAgent(ctx, s.Agent) } if flyutil.ClientFromContext(ctx) == nil { diff --git a/internal/command/deploy/deploy.go b/internal/command/deploy/deploy.go index 7c16bb3e0c..584cd033b9 100644 --- a/internal/command/deploy/deploy.go +++ b/internal/command/deploy/deploy.go @@ -9,6 +9,7 @@ import ( "github.com/logrusorgru/aurora" "github.com/spf13/cobra" + clientsignals "github.com/superfly/client-signals/go" "github.com/superfly/fly-go/flaps" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/build/imgsrc" @@ -517,7 +518,7 @@ func deployToMachines( startTime := time.Now() var status metrics.DeployStatusPayload - status.ClientAgent = metrics.ClientAgentFromContext(ctx) + status.Operator, status.AgentName = metrics.OperatorFromSignals(clientsignals.DetectOnce()) metrics.Started(ctx, "deploy") // TODO: remove this once there is nothing upstream using it diff --git a/internal/command/launch/cmd.go b/internal/command/launch/cmd.go index 03127c8c06..9b73b8d2ff 100644 --- a/internal/command/launch/cmd.go +++ b/internal/command/launch/cmd.go @@ -15,6 +15,7 @@ import ( "github.com/logrusorgru/aurora" "github.com/samber/lo" "github.com/spf13/cobra" + clientsignals "github.com/superfly/client-signals/go" "github.com/superfly/flyctl/gql" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/appsecrets" @@ -303,7 +304,7 @@ func run(ctx context.Context) (err error) { startTime := time.Now() var status metrics.LaunchStatusPayload - status.ClientAgent = metrics.ClientAgentFromContext(ctx) + status.Operator, status.AgentName = metrics.OperatorFromSignals(clientsignals.DetectOnce()) metrics.Started(ctx, "launch") var state *launchState = nil diff --git a/internal/metrics/helpers.go b/internal/metrics/helpers.go index 9a3ccdbdf5..1bab57b120 100644 --- a/internal/metrics/helpers.go +++ b/internal/metrics/helpers.go @@ -6,6 +6,7 @@ import ( "sync" "time" + clientsignals "github.com/superfly/client-signals/go" "github.com/superfly/flyctl/terminal" ) @@ -80,7 +81,8 @@ type LaunchStatusPayload struct { ScannerFamily string `json:"scanner_family"` FlyctlVersion string `json:"flyctlVersion"` - ClientAgent string `json:"clientAgent,omitempty"` + Operator string `json:"operator,omitempty"` + AgentName string `json:"agentName,omitempty"` } func LaunchStatus(ctx context.Context, payload LaunchStatusPayload) { @@ -114,7 +116,8 @@ type DeployStatusPayload struct { Strategy string `json:"strategy"` FlyctlVersion string `json:"flyctlVersion"` - ClientAgent string `json:"clientAgent,omitempty"` + Operator string `json:"operator,omitempty"` + AgentName string `json:"agentName,omitempty"` } func DeployStatus(ctx context.Context, payload DeployStatusPayload) { @@ -161,15 +164,17 @@ func StartTiming(ctx context.Context, metricSlug string) func() { } } -type clientAgentKey struct{} - -func WithClientAgent(ctx context.Context, agent string) context.Context { - return context.WithValue(ctx, clientAgentKey{}, agent) -} - -func ClientAgentFromContext(ctx context.Context) string { - val, _ := ctx.Value(clientAgentKey{}).(string) - return val +// OperatorFromSignals returns an operator classification and, when the +// operator is "agent", the agent name. Precedence: agent > ci > interactive. +func OperatorFromSignals(s clientsignals.Signals) (operator, agentName string) { + switch { + case s.Agent != "": + return "agent", s.Agent + case s.CI: + return "ci", "" + default: + return "interactive", "" + } } type disableFlushMetricsKey struct{} From efb29633f51d13a2b0314a11c8ef898cbe5183d6 Mon Sep 17 00:00:00 2001 From: Sprite Date: Fri, 10 Jul 2026 14:47:37 +0000 Subject: [PATCH 6/7] Remove redundant clientsignals import alias The package already declares itself as clientsignals, so the explicit alias is unnecessary. Matches the import style in preparers.go. Co-Authored-By: Claude Opus 4.6 --- internal/command/deploy/deploy.go | 2 +- internal/command/launch/cmd.go | 2 +- internal/metrics/helpers.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/command/deploy/deploy.go b/internal/command/deploy/deploy.go index 584cd033b9..837a9ebfcc 100644 --- a/internal/command/deploy/deploy.go +++ b/internal/command/deploy/deploy.go @@ -9,7 +9,7 @@ import ( "github.com/logrusorgru/aurora" "github.com/spf13/cobra" - clientsignals "github.com/superfly/client-signals/go" + "github.com/superfly/client-signals/go" "github.com/superfly/fly-go/flaps" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/build/imgsrc" diff --git a/internal/command/launch/cmd.go b/internal/command/launch/cmd.go index 9b73b8d2ff..e9df05598b 100644 --- a/internal/command/launch/cmd.go +++ b/internal/command/launch/cmd.go @@ -15,7 +15,7 @@ import ( "github.com/logrusorgru/aurora" "github.com/samber/lo" "github.com/spf13/cobra" - clientsignals "github.com/superfly/client-signals/go" + "github.com/superfly/client-signals/go" "github.com/superfly/flyctl/gql" "github.com/superfly/flyctl/internal/appconfig" "github.com/superfly/flyctl/internal/appsecrets" diff --git a/internal/metrics/helpers.go b/internal/metrics/helpers.go index 1bab57b120..f5975788a9 100644 --- a/internal/metrics/helpers.go +++ b/internal/metrics/helpers.go @@ -6,7 +6,7 @@ import ( "sync" "time" - clientsignals "github.com/superfly/client-signals/go" + "github.com/superfly/client-signals/go" "github.com/superfly/flyctl/terminal" ) From ca8a5510963afe4f57348de6d6e6fb44254c99de Mon Sep 17 00:00:00 2001 From: Sprite Date: Fri, 10 Jul 2026 15:12:25 +0000 Subject: [PATCH 7/7] Fix operator classification precedence CI now takes priority over agent (an agent running in CI is "ci"). Interactive requires the terminal check to be true. Falls back to "unknown" when no signals match. Co-Authored-By: Claude Opus 4.6 --- internal/metrics/helpers.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/metrics/helpers.go b/internal/metrics/helpers.go index f5975788a9..d02a6fa1a5 100644 --- a/internal/metrics/helpers.go +++ b/internal/metrics/helpers.go @@ -165,15 +165,18 @@ func StartTiming(ctx context.Context, metricSlug string) func() { } // OperatorFromSignals returns an operator classification and, when the -// operator is "agent", the agent name. Precedence: agent > ci > interactive. +// operator is "agent", the agent name. Precedence: ci > agent > interactive. +// Falls back to "unknown" when none of the signals match. func OperatorFromSignals(s clientsignals.Signals) (operator, agentName string) { switch { - case s.Agent != "": - return "agent", s.Agent case s.CI: return "ci", "" - default: + case s.Agent != "": + return "agent", s.Agent + case s.Interactive: return "interactive", "" + default: + return "unknown", "" } }