diff --git a/commands/root.go b/commands/root.go index 491788296..829c515e9 100644 --- a/commands/root.go +++ b/commands/root.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "slices" + "strconv" "strings" "github.com/fatih/color" @@ -19,6 +20,7 @@ import ( "github.com/spf13/viper" "github.com/upsun/cli/internal" + "github.com/upsun/cli/internal/auth" "github.com/upsun/cli/internal/config" "github.com/upsun/cli/internal/config/alt" "github.com/upsun/cli/internal/legacy" @@ -36,6 +38,26 @@ func Execute(cnf *config.Config) error { } ctx := vendorization.WithVendorAssets(config.ToContext(context.Background(), cnf), assets) + + // Extract the event name (command name) for analytics tracking. + // Respect user opt-out via DO_NOT_TRACK or {PREFIX}DISABLE_TELEMETRY. + dnt, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")) + telemetryDisabled := dnt || os.Getenv(cnf.Application.EnvPrefix+"DISABLE_TELEMETRY") == "1" + + if !telemetryDisabled { + var eventName string + if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { + eventName = os.Args[1] + } + ctx = auth.WithEventName(ctx, eventName) + + // Determine if running in interactive mode. + // Check environment variable first (available before flag parsing). + // The --no-interaction flag is handled later by Cobra, but env var is checked here. + interactive := os.Getenv(cnf.Application.EnvPrefix+"NO_INTERACTION") != "1" + ctx = auth.WithInteractive(ctx, interactive) + } + return newRootCommand(cnf, assets).ExecuteContext(ctx) } diff --git a/internal/auth/client.go b/internal/auth/client.go index 8d1f77129..9a44df3b3 100644 --- a/internal/auth/client.go +++ b/internal/auth/client.go @@ -7,6 +7,7 @@ import ( "golang.org/x/oauth2" + "github.com/upsun/cli/internal/config" "github.com/upsun/cli/internal/legacy" ) @@ -37,13 +38,30 @@ func NewLegacyCLIClient(ctx context.Context, wrapper *legacy.CLIWrapper) (*Legac baseRT = rt } + // Build the transport chain: + // EventTransport (adds X-CLI-Event + User-Agent) + // -> Transport (handles 401 retry) + // -> oauth2.Transport (adds Authorization) + // -> baseRT (http.DefaultTransport or custom) + authTransport := &Transport{ + refresher: refresher, + base: &oauth2.Transport{ + Source: ts, + Base: baseRT, + }, + } + + var userAgent string + if cnf := config.FromContext(ctx); cnf != nil { + userAgent = cnf.UserAgent() + } + httpClient := &http.Client{ - Transport: &Transport{ - refresher: refresher, - base: &oauth2.Transport{ - Source: ts, - Base: baseRT, - }, + Transport: &EventTransport{ + Base: authTransport, + EventName: EventNameFromContext(ctx), + Interactive: InteractiveFromContext(ctx), + UserAgent: userAgent, }, } diff --git a/internal/auth/event_transport.go b/internal/auth/event_transport.go new file mode 100644 index 000000000..f3ba661f1 --- /dev/null +++ b/internal/auth/event_transport.go @@ -0,0 +1,70 @@ +package auth + +import ( + "context" + "fmt" + "net/http" +) + +// eventCtxKey is the context key for storing the event name. +type eventCtxKey struct{} + +// interactiveCtxKey is the context key for storing the interactive mode flag. +type interactiveCtxKey struct{} + +// WithEventName returns a new context that carries the provided event name. +func WithEventName(ctx context.Context, name string) context.Context { + return context.WithValue(ctx, eventCtxKey{}, name) +} + +// EventNameFromContext retrieves an event name previously stored with WithEventName. +// It returns an empty string if none is set. +func EventNameFromContext(ctx context.Context) string { + v, _ := ctx.Value(eventCtxKey{}).(string) + return v +} + +// WithInteractive returns a new context that carries the interactive mode flag. +func WithInteractive(ctx context.Context, interactive bool) context.Context { + return context.WithValue(ctx, interactiveCtxKey{}, interactive) +} + +// InteractiveFromContext retrieves the interactive flag previously stored with WithInteractive. +// It returns true (the default) if none is set. +func InteractiveFromContext(ctx context.Context) bool { + v, ok := ctx.Value(interactiveCtxKey{}).(bool) + if !ok { + return true // default to interactive + } + return v +} + +// EventTransport wraps an http.RoundTripper to add event tracking headers. +type EventTransport struct { + // Base is the underlying RoundTripper to use for requests. + Base http.RoundTripper + + // EventName is the command name for the X-CLI-Event header. + // If empty, no header is added. + EventName string + + // Interactive indicates whether the CLI is running in interactive mode. + Interactive bool + + // UserAgent is the User-Agent string to send. + // If empty, or a User-Agent is already set on the request, no header is added. + UserAgent string +} + +// RoundTrip adds the X-CLI-Event and User-Agent headers to the request. +func (t *EventTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.EventName != "" { + // Format: command=; interactive= + headerValue := fmt.Sprintf("command=%s; interactive=%t", t.EventName, t.Interactive) + req.Header.Set("X-CLI-Event", headerValue) + } + if t.UserAgent != "" && req.Header.Get("User-Agent") == "" { + req.Header.Set("User-Agent", t.UserAgent) + } + return t.Base.RoundTrip(req) +} diff --git a/internal/auth/event_transport_test.go b/internal/auth/event_transport_test.go new file mode 100644 index 000000000..90da67603 --- /dev/null +++ b/internal/auth/event_transport_test.go @@ -0,0 +1,178 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventTransport_RoundTrip(t *testing.T) { + cases := []struct { + name string + eventName string + interactive bool + userAgent string + existingUserAgent string + wantEventHeader string + wantUserAgent string + }{ + { + name: "sets headers with interactive true", + eventName: "backup:restore", + interactive: true, + userAgent: "Upsun-CLI/1.0.0", + wantEventHeader: "command=backup:restore; interactive=true", + wantUserAgent: "Upsun-CLI/1.0.0", + }, + { + name: "sets headers with interactive false", + eventName: "backup:restore", + interactive: false, + userAgent: "Upsun-CLI/1.0.0", + wantEventHeader: "command=backup:restore; interactive=false", + wantUserAgent: "Upsun-CLI/1.0.0", + }, + { + name: "sets only event header when user agent is empty", + eventName: "project:info", + interactive: true, + userAgent: "", + wantEventHeader: "command=project:info; interactive=true", + wantUserAgent: "Go-http-client/1.1", // Go's default User-Agent + }, + { + name: "sets only user agent when event name is empty", + eventName: "", + interactive: true, + userAgent: "Upsun-CLI/1.0.0", + wantEventHeader: "", + wantUserAgent: "Upsun-CLI/1.0.0", + }, + { + name: "does not set event header when event name is empty", + eventName: "", + interactive: false, + userAgent: "", + wantEventHeader: "", + wantUserAgent: "Go-http-client/1.1", // Go's default User-Agent + }, + { + name: "does not override existing user agent", + eventName: "init", + interactive: true, + userAgent: "Upsun-CLI/1.0.0", + existingUserAgent: "Custom-Agent/2.0", + wantEventHeader: "command=init; interactive=true", + wantUserAgent: "Custom-Agent/2.0", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var receivedEventHeader, receivedUserAgent string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedEventHeader = r.Header.Get("X-CLI-Event") + receivedUserAgent = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + transport := &EventTransport{ + Base: http.DefaultTransport, + EventName: tc.eventName, + Interactive: tc.interactive, + UserAgent: tc.userAgent, + } + + client := &http.Client{Transport: transport} + + req, err := http.NewRequest(http.MethodGet, server.URL, http.NoBody) + require.NoError(t, err) + + if tc.existingUserAgent != "" { + req.Header.Set("User-Agent", tc.existingUserAgent) + } + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, tc.wantEventHeader, receivedEventHeader) + assert.Equal(t, tc.wantUserAgent, receivedUserAgent) + }) + } +} + +func TestWithEventName(t *testing.T) { + cases := []struct { + name string + eventName string + }{ + { + name: "stores and retrieves event name", + eventName: "backup:restore", + }, + { + name: "handles empty event name", + eventName: "", + }, + { + name: "handles command with namespace", + eventName: "project:info", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + ctx = WithEventName(ctx, tc.eventName) + + got := EventNameFromContext(ctx) + assert.Equal(t, tc.eventName, got) + }) + } +} + +func TestEventNameFromContext_EmptyContext(t *testing.T) { + ctx := context.Background() + got := EventNameFromContext(ctx) + assert.Equal(t, "", got) +} + +func TestWithInteractive(t *testing.T) { + cases := []struct { + name string + interactive bool + }{ + { + name: "stores interactive true", + interactive: true, + }, + { + name: "stores interactive false", + interactive: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + ctx = WithInteractive(ctx, tc.interactive) + + got := InteractiveFromContext(ctx) + assert.Equal(t, tc.interactive, got) + }) + } +} + +func TestInteractiveFromContext_EmptyContext(t *testing.T) { + ctx := context.Background() + got := InteractiveFromContext(ctx) + assert.True(t, got, "default should be interactive=true") +} diff --git a/internal/legacy/legacy.go b/internal/legacy/legacy.go index 559ef8534..3f19697b8 100644 --- a/internal/legacy/legacy.go +++ b/internal/legacy/legacy.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" "time" @@ -158,6 +159,10 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { PHPVersion, c.Version, )) + // Pass the event name (command name) to the legacy CLI for analytics tracking. + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + cmd.Env = append(cmd.Env, envPrefix+"EVENT_NAME="+args[0]) + } if err := cmd.Run(); err != nil { return fmt.Errorf("could not run PHP CLI command: %w", err) } diff --git a/legacy/src/EventHeaderMiddleware.php b/legacy/src/EventHeaderMiddleware.php new file mode 100644 index 000000000..abcf76dac --- /dev/null +++ b/legacy/src/EventHeaderMiddleware.php @@ -0,0 +1,48 @@ +isTelemetryDisabled()) { + return $next($request, $options); + } + $eventName = $this->config->getEventName(); + if ($eventName !== null) { + $interactive = $this->config->isInteractive() ? 'true' : 'false'; + $headerValue = sprintf('command=%s; interactive=%s', $eventName, $interactive); + $request = $request->withHeader('X-CLI-Event', $headerValue); + } + return $next($request, $options); + }; + } + + /** + * Checks if telemetry is disabled via DO_NOT_TRACK or {PREFIX}DISABLE_TELEMETRY. + */ + private function isTelemetryDisabled(): bool + { + $dnt = getenv('DO_NOT_TRACK'); + if ($dnt !== false && filter_var($dnt, FILTER_VALIDATE_BOOLEAN)) { + return true; + } + $prefix = $this->config->getStr('application.env_prefix'); + return getenv($prefix . 'DISABLE_TELEMETRY') === '1'; + } +} diff --git a/legacy/src/Service/Api.php b/legacy/src/Service/Api.php index d96c106a3..2146584b4 100644 --- a/legacy/src/Service/Api.php +++ b/legacy/src/Service/Api.php @@ -27,6 +27,7 @@ use Platformsh\Cli\Event\EnvironmentsChangedEvent; use Platformsh\Cli\Event\LoginRequiredEvent; use Platformsh\Cli\Exception\ProcessFailedException; +use Platformsh\Cli\EventHeaderMiddleware; use Platformsh\Cli\GuzzleDebugMiddleware; use Platformsh\Cli\Model\Route; use Platformsh\Cli\Util\NestedArrayUtil; @@ -329,6 +330,8 @@ private function getConnectorOptions(): array // Add middlewares. $connectorOptions['middlewares'] = []; + // Add event tracking header for analytics. + $connectorOptions['middlewares'][] = new EventHeaderMiddleware($this->config); // Debug responses. $connectorOptions['middlewares'][] = new GuzzleDebugMiddleware($this->output, $this->config->getBool('api.debug')); // Handle 403 errors. diff --git a/legacy/src/Service/Config.php b/legacy/src/Service/Config.php index dc24e54a2..4dc6cb690 100644 --- a/legacy/src/Service/Config.php +++ b/legacy/src/Service/Config.php @@ -650,6 +650,28 @@ public function isWrapped(): bool return getenv($this->getStr('application.env_prefix') . 'WRAPPED') === '1'; } + /** + * Returns the event name for analytics tracking. + * + * The event name is typically the command name (e.g., "backup:restore") + * passed from the Go wrapper via an environment variable. + */ + public function getEventName(): ?string + { + $value = getenv($this->getStr('application.env_prefix') . 'EVENT_NAME'); + return $value !== false ? $value : null; + } + + /** + * Returns whether the CLI is running in interactive mode. + * + * This is determined by the NO_INTERACTION environment variable. + */ + public function isInteractive(): bool + { + return getenv($this->getStr('application.env_prefix') . 'NO_INTERACTION') !== '1'; + } + /** * Returns all the current configuration. * diff --git a/legacy/tests/ConfigTest.php b/legacy/tests/ConfigTest.php index 8427112dd..a51b968ed 100644 --- a/legacy/tests/ConfigTest.php +++ b/legacy/tests/ConfigTest.php @@ -125,4 +125,42 @@ public function testGetWritableUserDir(): void $home = $config->getHomeDirectory(); $this->assertEquals($home . DIRECTORY_SEPARATOR . 'mock-cli-user-config', $config->getWritableUserDir()); } + + /** + * Test getEventName() reads the event name from the environment variable. + */ + public function testGetEventName(): void + { + $config = new Config([], $this->configFile); + + // Ensure no event name is set initially. + putenv('MOCK_CLI_EVENT_NAME'); + $this->assertNull($config->getEventName()); + + // Set the event name. + putenv('MOCK_CLI_EVENT_NAME=backup:restore'); + $this->assertEquals('backup:restore', $config->getEventName()); + + // Clean up. + putenv('MOCK_CLI_EVENT_NAME'); + } + + /** + * Test isInteractive() reads the NO_INTERACTION environment variable. + */ + public function testIsInteractive(): void + { + $config = new Config([], $this->configFile); + + // Default is interactive. + putenv('MOCK_CLI_NO_INTERACTION'); + $this->assertTrue($config->isInteractive()); + + // Set non-interactive mode. + putenv('MOCK_CLI_NO_INTERACTION=1'); + $this->assertFalse($config->isInteractive()); + + // Clean up. + putenv('MOCK_CLI_NO_INTERACTION'); + } } diff --git a/legacy/tests/EventHeaderMiddlewareTest.php b/legacy/tests/EventHeaderMiddlewareTest.php new file mode 100644 index 000000000..2032721a0 --- /dev/null +++ b/legacy/tests/EventHeaderMiddlewareTest.php @@ -0,0 +1,192 @@ +configFile = __DIR__ . '/data/mock-cli-config.yaml'; + } + + public function testMiddlewareAddsEventHeaderInteractive(): void + { + putenv('MOCK_CLI_EVENT_NAME=backup:restore'); + putenv('MOCK_CLI_NO_INTERACTION'); + try { + $config = new Config([], $this->configFile); + $middleware = new EventHeaderMiddleware($config); + + $request = new Request('GET', 'https://api.example.com/test'); + + $capturedRequest = null; + $handler = function (Request $req, array $options) use (&$capturedRequest) { + $capturedRequest = $req; + return new Response(200); + }; + + $wrappedHandler = $middleware($handler); + $wrappedHandler($request, []); + + $this->assertNotNull($capturedRequest); + $this->assertEquals( + 'command=backup:restore; interactive=true', + $capturedRequest->getHeaderLine('X-CLI-Event') + ); + } finally { + putenv('MOCK_CLI_EVENT_NAME'); + putenv('MOCK_CLI_NO_INTERACTION'); + } + } + + public function testMiddlewareAddsEventHeaderNonInteractive(): void + { + putenv('MOCK_CLI_EVENT_NAME=project:info'); + putenv('MOCK_CLI_NO_INTERACTION=1'); + try { + $config = new Config([], $this->configFile); + $middleware = new EventHeaderMiddleware($config); + + $request = new Request('GET', 'https://api.example.com/test'); + + $capturedRequest = null; + $handler = function (Request $req, array $options) use (&$capturedRequest) { + $capturedRequest = $req; + return new Response(200); + }; + + $wrappedHandler = $middleware($handler); + $wrappedHandler($request, []); + + $this->assertNotNull($capturedRequest); + $this->assertEquals( + 'command=project:info; interactive=false', + $capturedRequest->getHeaderLine('X-CLI-Event') + ); + } finally { + putenv('MOCK_CLI_EVENT_NAME'); + putenv('MOCK_CLI_NO_INTERACTION'); + } + } + + public function testMiddlewareDoesNotAddHeaderWhenEventNameIsEmpty(): void + { + putenv('MOCK_CLI_EVENT_NAME'); + + $config = new Config([], $this->configFile); + $middleware = new EventHeaderMiddleware($config); + + $request = new Request('GET', 'https://api.example.com/test'); + + $capturedRequest = null; + $handler = function (Request $req, array $options) use (&$capturedRequest) { + $capturedRequest = $req; + return new Response(200); + }; + + $wrappedHandler = $middleware($handler); + $wrappedHandler($request, []); + + $this->assertNotNull($capturedRequest); + $this->assertFalse($capturedRequest->hasHeader('X-CLI-Event')); + } + + public function testMiddlewarePreservesExistingHeaders(): void + { + putenv('MOCK_CLI_EVENT_NAME=project:info'); + putenv('MOCK_CLI_NO_INTERACTION'); + try { + $config = new Config([], $this->configFile); + $middleware = new EventHeaderMiddleware($config); + + $request = new Request('GET', 'https://api.example.com/test', [ + 'Authorization' => 'Bearer token123', + 'Content-Type' => 'application/json', + ]); + + $capturedRequest = null; + $handler = function (Request $req, array $options) use (&$capturedRequest) { + $capturedRequest = $req; + return new Response(200); + }; + + $wrappedHandler = $middleware($handler); + $wrappedHandler($request, []); + + $this->assertNotNull($capturedRequest); + $this->assertEquals( + 'command=project:info; interactive=true', + $capturedRequest->getHeaderLine('X-CLI-Event') + ); + $this->assertEquals('Bearer token123', $capturedRequest->getHeaderLine('Authorization')); + $this->assertEquals('application/json', $capturedRequest->getHeaderLine('Content-Type')); + } finally { + putenv('MOCK_CLI_EVENT_NAME'); + putenv('MOCK_CLI_NO_INTERACTION'); + } + } + + public function testMiddlewareRespectsDoNotTrack(): void + { + putenv('MOCK_CLI_EVENT_NAME=backup:restore'); + putenv('DO_NOT_TRACK=1'); + try { + $config = new Config([], $this->configFile); + $middleware = new EventHeaderMiddleware($config); + + $request = new Request('GET', 'https://api.example.com/test'); + + $capturedRequest = null; + $handler = function (Request $req, array $options) use (&$capturedRequest) { + $capturedRequest = $req; + return new Response(200); + }; + + $wrappedHandler = $middleware($handler); + $wrappedHandler($request, []); + + $this->assertNotNull($capturedRequest); + $this->assertFalse($capturedRequest->hasHeader('X-CLI-Event')); + } finally { + putenv('MOCK_CLI_EVENT_NAME'); + putenv('DO_NOT_TRACK'); + } + } + + public function testMiddlewareRespectsDisableTelemetry(): void + { + putenv('MOCK_CLI_EVENT_NAME=backup:restore'); + putenv('MOCK_CLI_DISABLE_TELEMETRY=1'); + try { + $config = new Config([], $this->configFile); + $middleware = new EventHeaderMiddleware($config); + + $request = new Request('GET', 'https://api.example.com/test'); + + $capturedRequest = null; + $handler = function (Request $req, array $options) use (&$capturedRequest) { + $capturedRequest = $req; + return new Response(200); + }; + + $wrappedHandler = $middleware($handler); + $wrappedHandler($request, []); + + $this->assertNotNull($capturedRequest); + $this->assertFalse($capturedRequest->hasHeader('X-CLI-Event')); + } finally { + putenv('MOCK_CLI_EVENT_NAME'); + putenv('MOCK_CLI_DISABLE_TELEMETRY'); + } + } +}