From d846d98c67d9e8e540d2d8c26146b530b751463c Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Wed, 10 Jun 2026 15:57:04 -0700 Subject: [PATCH] feat(telemetry): send the build environment to the telemetry proxy - every telemetry request carries an X-Algolia-CLI-Env header (prod for release builds, where goreleaser injects a semver version; dev for source builds) so the proxy can route release events to the production Segment source; until the proxy routes on the header, everything keeps going to the development source - the transport clones the request before adding the header (RoundTrippers must not mutate the caller's request) - drop the trailing slash of the telemetry endpoint: analytics-go appends /v1/batch, which produced a double slash in the URL Co-Authored-By: Claude Fable 5 --- pkg/telemetry/telemetry.go | 37 +++++++++++++++++++++++++++++-- pkg/telemetry/telemetry_test.go | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index d0a4c918..ff0168ec 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net" + "net/http" "runtime" "sync/atomic" @@ -19,8 +20,10 @@ import ( ) const ( - AppName = "cli" - telemetryAnalyticsURL = "https://telemetry-proxy.algolia.com/" + AppName = "cli" + // No trailing slash: analytics-go appends "/v1/batch" to the endpoint. + telemetryAnalyticsURL = "https://telemetry-proxy.algolia.com" + envHeader = "X-Algolia-CLI-Env" ) type telemetryMetadataKey struct{} @@ -68,6 +71,10 @@ func NewAnalyticsTelemetryClient(debug bool) (TelemetryClient, error) { Endpoint: telemetryAnalyticsURL, Logger: newTelemetryLogger(debug), Verbose: debug, + Transport: &envHeaderTransport{ + base: http.DefaultTransport, + env: telemetryEnv(), + }, }) if err != nil { return nil, err @@ -75,6 +82,32 @@ func NewAnalyticsTelemetryClient(debug bool) (TelemetryClient, error) { return &AnalyticsTelemetryClient{client: client}, nil } +// envHeaderTransport adds the X-Algolia-CLI-Env header to every telemetry +// request, so the proxy can route release builds to the production Segment +// source and everything else to the development one. Until the proxy routes +// on the header, all events keep going to the development source. +type envHeaderTransport struct { + base http.RoundTripper + env string +} + +func (t *envHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // RoundTrippers must not mutate the caller's request. + req = req.Clone(req.Context()) + req.Header.Set(envHeader, t.env) + return t.base.RoundTrip(req) +} + +// telemetryEnv reports which environment the events belong to: "prod" for +// release builds (goreleaser injects a semver version), "dev" for source +// builds (the version stays "main"). +func telemetryEnv() string { + if version.Version == "main" { + return "dev" + } + return "prod" +} + // anonymousID is a unique identifier for an anonymous user of the CLI (basically the hash of the mac address) func anonymousID() string { addrs, err := net.Interfaces() diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go index d9445ce2..95816218 100644 --- a/pkg/telemetry/telemetry_test.go +++ b/pkg/telemetry/telemetry_test.go @@ -2,6 +2,7 @@ package telemetry import ( "context" + "net/http" "sync" "testing" @@ -9,8 +10,46 @@ import ( "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/algolia/cli/pkg/version" ) +// captureTransport records the request it receives without hitting the network. +type captureTransport struct { + req *http.Request +} + +func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + c.req = req + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func TestEnvHeaderTransport_SetsHeaderWithoutMutatingRequest(t *testing.T) { + capture := &captureTransport{} + transport := &envHeaderTransport{base: capture, env: "prod"} + + req, err := http.NewRequest(http.MethodPost, "https://example.com/v1/batch", nil) + require.NoError(t, err) + + _, err = transport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, "prod", capture.req.Header.Get(envHeader)) + // RoundTrippers must not mutate the caller's request. + assert.Empty(t, req.Header.Get(envHeader)) +} + +func TestTelemetryEnv(t *testing.T) { + orig := version.Version + t.Cleanup(func() { version.Version = orig }) + + version.Version = "main" + assert.Equal(t, "dev", telemetryEnv()) + + version.Version = "1.20.0" + assert.Equal(t, "prod", telemetryEnv()) +} + // Context-related tests. func TestEventMetadataWithGet(t *testing.T) { ctx := context.Background()