From 922a1c7636737c2298bb95fddab24563adc33588 Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Mon, 16 Mar 2026 16:29:29 +0800 Subject: [PATCH 1/3] feat: add custom User-Agent header to identify CLI traffic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets User-Agent to "coingecko-cli/{version}" on all HTTP API requests and WebSocket handshakes, enabling server-side traffic attribution. User-Agent is a struct field on api.Client and ws.Client, injected via the factory functions in client_factory.go — no mutable package-level state. --- cmd/client_factory.go | 11 +++++++++-- cmd/dryrun.go | 1 + internal/api/client.go | 4 ++++ internal/api/client_test.go | 29 +++++++++++++++++++++++++++++ internal/ws/client.go | 16 ++++++++++++---- 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/cmd/client_factory.go b/cmd/client_factory.go index efcf4a2..c1fa0a8 100644 --- a/cmd/client_factory.go +++ b/cmd/client_factory.go @@ -8,10 +8,15 @@ import ( "github.com/coingecko/coingecko-cli/internal/ws" ) +// userAgent is the User-Agent header sent with all API and WebSocket requests. +var userAgent = "coingecko-cli/" + version + // newAPIClient is the factory used by command handlers to create API clients. // Tests override this to inject httptest-backed clients. var newAPIClient = func(cfg *config.Config) *api.Client { - return api.NewClient(cfg) + c := api.NewClient(cfg) + c.UserAgent = userAgent + return c } // loadConfig is the function used by command handlers to load configuration. @@ -27,5 +32,7 @@ type Streamer interface { // newStreamer is the factory used by command handlers to create WebSocket clients. // Tests override this to inject test doubles. var newStreamer = func(cfg *config.Config, coinIDs []string) Streamer { - return ws.NewClient(cfg, coinIDs) + c := ws.NewClient(cfg, coinIDs) + c.UserAgent = userAgent + return c } diff --git a/cmd/dryrun.go b/cmd/dryrun.go index 12155fc..236760b 100644 --- a/cmd/dryrun.go +++ b/cmd/dryrun.go @@ -81,6 +81,7 @@ func printDryRunFull(cfg *config.Config, cmdName, opKey, endpoint string, params headers[headerKey] = masked } headers["Accept"] = "application/json" + headers["User-Agent"] = userAgent out := dryRunOutput{ Method: "GET", diff --git a/internal/api/client.go b/internal/api/client.go index c371d75..ce499e0 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -170,6 +170,7 @@ type Client struct { http *http.Client baseURLVal string // override; empty = use cfg.BaseURL() cfg *config.Config + UserAgent string // sent with every request; set by cmd layer } func NewClient(cfg *config.Config) *Client { @@ -206,6 +207,9 @@ func (c *Client) get(ctx context.Context, path string, result any) error { } c.cfg.ApplyAuth(req) req.Header.Set("Accept", "application/json") + if c.UserAgent != "" { + req.Header.Set("User-Agent", c.UserAgent) + } resp, err := c.http.Do(req) if err != nil { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index fe790c1..80aa6c3 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -21,6 +21,35 @@ func testClient(handler http.HandlerFunc) (*Client, *httptest.Server) { return c, srv } +func TestUserAgentHeader(t *testing.T) { + var gotUA string + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + w.WriteHeader(200) + _, _ = w.Write([]byte("{}")) + }) + defer srv.Close() + + c.UserAgent = "coingecko-cli/v1.2.3" + var result map[string]any + _ = c.get(context.Background(), "/test", &result) + assert.Equal(t, "coingecko-cli/v1.2.3", gotUA) +} + +func TestUserAgentHeaderOmittedWhenEmpty(t *testing.T) { + var gotUA string + c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + w.WriteHeader(200) + _, _ = w.Write([]byte("{}")) + }) + defer srv.Close() + + var result map[string]any + _ = c.get(context.Background(), "/test", &result) + assert.Equal(t, "Go-http-client/1.1", gotUA) // default Go UA when not set +} + func TestAuthHeadersSent(t *testing.T) { var gotHeader string c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/ws/client.go b/internal/ws/client.go index 9d58883..a01e2c3 100644 --- a/internal/ws/client.go +++ b/internal/ws/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "math/rand/v2" + "net/http" "sync" "sync/atomic" "time" @@ -41,9 +42,10 @@ type CoinUpdate struct { // Client manages a WebSocket connection to CoinGecko's streaming API. type Client struct { - cfg *config.Config - coinIDs []string - wsURL string + cfg *config.Config + coinIDs []string + wsURL string + UserAgent string // sent with WebSocket handshake; set by cmd layer conn *websocket.Conn updates chan *CoinUpdate @@ -134,11 +136,17 @@ func (c *Client) Close() error { func (c *Client) connect(ctx context.Context) error { url := c.wsURL + "?x_cg_pro_api_key=" + c.cfg.APIKey + var header http.Header + if c.UserAgent != "" { + header = http.Header{} + header.Set("User-Agent", c.UserAgent) + } + dialer := websocket.Dialer{ HandshakeTimeout: 10 * time.Second, } - conn, _, err := dialer.DialContext(ctx, url, nil) + conn, _, err := dialer.DialContext(ctx, url, header) if err != nil { return err } From cb510115c915002f832272bc4859d7cfae4231af Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Mon, 16 Mar 2026 16:41:54 +0800 Subject: [PATCH 2/3] test: add WebSocket User-Agent handshake test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the coverage gap noted in the review — verifies User-Agent header is sent during the WebSocket upgrade handshake. --- internal/ws/client_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/ws/client_test.go b/internal/ws/client_test.go index 80f6846..d8cc2a1 100644 --- a/internal/ws/client_test.go +++ b/internal/ws/client_test.go @@ -379,6 +379,44 @@ func TestConnect_APIKeyInQueryParam(t *testing.T) { require.NoError(t, client.Close()) } +func TestConnect_UserAgentHeader(t *testing.T) { + var gotUA string + var mu sync.Mutex + + srv := newTestWSServer(t, func(conn *websocket.Conn) { + happyHandshake(t, conn) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }) + // Wrap handler to capture User-Agent from the handshake request. + origHandler := srv.Config.Handler + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + gotUA = r.Header.Get("User-Agent") + mu.Unlock() + origHandler.ServeHTTP(w, r) + }) + + client := NewClient(paidCfg(), []string{"bitcoin"}) + client.SetURL(wsURL(srv)) + client.UserAgent = "coingecko-cli/v1.2.3" + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := client.Connect(ctx) + require.NoError(t, err) + + mu.Lock() + assert.Equal(t, "coingecko-cli/v1.2.3", gotUA) + mu.Unlock() + + require.NoError(t, client.Close()) +} + func TestCloseWithoutConnect(t *testing.T) { client := NewClient(paidCfg(), []string{"bitcoin"}) // Close() should not hang if Connect() was never called. From bd2ae83179ddb971b74e583161a49cd1fddadcf0 Mon Sep 17 00:00:00 2001 From: khooihongzhe Date: Mon, 16 Mar 2026 16:45:05 +0800 Subject: [PATCH 3/3] fix: remove unnecessary empty-string guards on UserAgent UserAgent is always set by the factory in client_factory.go, so the != "" checks were dead code adding unnecessary complexity. --- internal/api/client.go | 4 +--- internal/api/client_test.go | 14 -------------- internal/ws/client.go | 7 ++----- 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/internal/api/client.go b/internal/api/client.go index ce499e0..c358883 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -207,9 +207,7 @@ func (c *Client) get(ctx context.Context, path string, result any) error { } c.cfg.ApplyAuth(req) req.Header.Set("Accept", "application/json") - if c.UserAgent != "" { - req.Header.Set("User-Agent", c.UserAgent) - } + req.Header.Set("User-Agent", c.UserAgent) resp, err := c.http.Do(req) if err != nil { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index 80aa6c3..b3add9b 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -36,20 +36,6 @@ func TestUserAgentHeader(t *testing.T) { assert.Equal(t, "coingecko-cli/v1.2.3", gotUA) } -func TestUserAgentHeaderOmittedWhenEmpty(t *testing.T) { - var gotUA string - c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { - gotUA = r.Header.Get("User-Agent") - w.WriteHeader(200) - _, _ = w.Write([]byte("{}")) - }) - defer srv.Close() - - var result map[string]any - _ = c.get(context.Background(), "/test", &result) - assert.Equal(t, "Go-http-client/1.1", gotUA) // default Go UA when not set -} - func TestAuthHeadersSent(t *testing.T) { var gotHeader string c, srv := testClient(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/ws/client.go b/internal/ws/client.go index a01e2c3..d5be4bf 100644 --- a/internal/ws/client.go +++ b/internal/ws/client.go @@ -136,11 +136,8 @@ func (c *Client) Close() error { func (c *Client) connect(ctx context.Context) error { url := c.wsURL + "?x_cg_pro_api_key=" + c.cfg.APIKey - var header http.Header - if c.UserAgent != "" { - header = http.Header{} - header.Set("User-Agent", c.UserAgent) - } + header := http.Header{} + header.Set("User-Agent", c.UserAgent) dialer := websocket.Dialer{ HandshakeTimeout: 10 * time.Second,