Skip to content
12 changes: 12 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type clientConfig struct {
// who pass WithRegistries retain full control over that baseline.
registries []Registry
userAgent string
userAgentSuffix string
baseURL string
credentialResolver func(*url.URL) (*auth.Credential, error)
globalConfig *GlobalConfig
Expand Down Expand Up @@ -74,6 +75,10 @@ func NewClient(opts ...Option) (*Client, error) {
opt(cfg)
}

if cfg.userAgentSuffix != "" {
cfg.userAgent += " " + cfg.userAgentSuffix
}

httpClient := cfg.httpClient
if httpClient == nil {
httpClient = &http.Client{
Expand Down Expand Up @@ -194,3 +199,10 @@ func WithBaseURL(u string) Option {
func WithUserAgent(ua string) Option {
return func(cfg *clientConfig) { cfg.userAgent = ua }
}

// WithUserAgentSuffix appends suffix, separated by a space, to the user agent
// string (the default or one set via WithUserAgent). Like WithUserAgent, it
// has no effect on a custom HTTP client supplied via WithHTTPClient.
func WithUserAgentSuffix(suffix string) Option {
return func(cfg *clientConfig) { cfg.userAgentSuffix = suffix }
}
26 changes: 26 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,29 @@ func TestNewClientWithUserAgent(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "custom-agent/1.0", c.UserAgent())
}

func TestNewClientWithUserAgentSuffix(t *testing.T) {
base, err := dbc.NewClient()
require.NoError(t, err)

c, err := dbc.NewClient(dbc.WithUserAgentSuffix("my-tool/2.0"))
require.NoError(t, err)
assert.Equal(t, base.UserAgent()+" my-tool/2.0", c.UserAgent())

c, err = dbc.NewClient(dbc.WithUserAgentSuffix(""))
require.NoError(t, err)
assert.Equal(t, base.UserAgent(), c.UserAgent(), "empty suffix must not add a trailing space")
}

// The suffix is applied after all options, so it composes with WithUserAgent
// regardless of option order.
func TestNewClientUserAgentSuffixAppliesToCustomUserAgent(t *testing.T) {
for _, opts := range [][]dbc.Option{
{dbc.WithUserAgent("custom-agent/1.0"), dbc.WithUserAgentSuffix("my-tool/2.0")},
{dbc.WithUserAgentSuffix("my-tool/2.0"), dbc.WithUserAgent("custom-agent/1.0")},
} {
c, err := dbc.NewClient(opts...)
require.NoError(t, err)
assert.Equal(t, "custom-agent/1.0 my-tool/2.0", c.UserAgent())
}
}
34 changes: 30 additions & 4 deletions cmd/dbc/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ type NeedsRenderer interface {
}

var (
userAgentSuffix string

dbcClient *dbc.Client
dbcClientErr error
dbcClientOnce = &sync.Once{}
Expand All @@ -93,10 +95,15 @@ func newDefaultClient() (*dbc.Client, error) {

// authHTTPClient returns an *http.Client suitable for auth flows (device-code
// login, license fetch, etc.) that do not need driver registries resolved.
// It bypasses dbc.NewClient so registry misconfiguration — a valid state for
// a user who is about to run `dbc auth login` to fix it — cannot break login.
// It bypasses dbc.NewClient's registry resolution so registry misconfiguration
// — a valid state for a user who is about to run `dbc auth login` to fix it —
// cannot break login. It carries the DBC_USER_AGENT_SUFFIX suffix, if any.
func authHTTPClient() *http.Client {
c, err := dbc.NewClient(dbc.WithBaseURL("https://placeholder.invalid"))
opts := []dbc.Option{dbc.WithBaseURL("https://placeholder.invalid")}
if userAgentSuffix != "" {
opts = append(opts, dbc.WithUserAgentSuffix(userAgentSuffix))
}
c, err := dbc.NewClient(opts...)
if err != nil {
// Impossible in practice: WithBaseURL short-circuits all registry
// validation. Fall back to http.DefaultClient so auth still works.
Expand All @@ -105,6 +112,18 @@ func authHTTPClient() *http.Client {
return c.HTTPClient()
}

// setUserAgentSuffix records the DBC_USER_AGENT_SUFFIX value for every client
// the CLI builds. Must run before the program issues requests, as
// dbc.DefaultClient requires.
func setUserAgentSuffix(suffix string) {
userAgentSuffix = suffix
if suffix != "" {
// Package downloads (PkgInfo.DownloadPackage) use the package-level
// client rather than dbcClient.
dbc.DefaultClient = authHTTPClient()
}
}

// newDBCClient builds a client with the given project registry overrides.
// Callers pass nil/nil for process-wide operations (search, info, install);
// project commands (add, sync, remove) pass the values parsed from dbc.toml.
Expand All @@ -120,6 +139,11 @@ func newDBCClient(projectRegs []dbc.RegistryEntry, projectReplaceDefaults *bool)
opts = append(opts, dbc.WithProjectRegistries(projectRegs, projectReplaceDefaults))
}
}

if userAgentSuffix != "" {
opts = append(opts, dbc.WithUserAgentSuffix(userAgentSuffix))
}

return dbc.NewClient(opts...)
}

Expand Down Expand Up @@ -167,7 +191,6 @@ var getDriverRegistry = func() ([]dbc.Driver, error) {
return dbcClient.Search(context.Background(), "")
}


func findDriver(name string, drivers []dbc.Driver) (dbc.Driver, error) {
idx := slices.IndexFunc(drivers, func(d dbc.Driver) bool {
return d.Path == name
Expand Down Expand Up @@ -464,6 +487,9 @@ func main() {
if jm, ok := m.(interface{ IsJSONMode() bool }); ok && jm.IsJSONMode() {
needsRenderer = false
}

setUserAgentSuffix(os.Getenv("DBC_USER_AGENT_SUFFIX"))

// Work around https://github.com/columnar-tech/dbc/issues/351
usedRenderer := false
if !isatty.IsTerminal(os.Stdout.Fd()) || !needsRenderer {
Expand Down
103 changes: 103 additions & 0 deletions cmd/dbc/user_agent_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright 2026 Columnar Technologies Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/columnar-tech/dbc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func defaultUserAgent() string {
return fmt.Sprintf("dbc-cli/%s (%s; %s)", dbc.Version, runtime.GOOS, runtime.GOARCH)
}

// TestNewDBCClientSendsUserAgent verifies the header that actually reaches the
// server, with and without a custom suffix.
func TestNewDBCClientSendsUserAgent(t *testing.T) {
tests := []struct {
name string
suffix string
want string
}{
{"default", "", defaultUserAgent()},
{"custom", "my-tool/2.0", defaultUserAgent() + " my-tool/2.0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("User-Agent")
}))
t.Cleanup(srv.Close)

t.Setenv("DBC_BASE_URL", srv.URL)
// Keep the expected header independent of the runner's CI env.
t.Setenv("CI", "")
prev := userAgentSuffix
t.Cleanup(func() { userAgentSuffix = prev })
userAgentSuffix = tt.suffix

c, err := newDBCClient(nil, nil)
require.NoError(t, err)
assert.Equal(t, tt.want, c.UserAgent())

resp, err := c.HTTPClient().Get(srv.URL)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, tt.want, got)
})
}
}

// TestSetUserAgentSuffixCoversAuthAndDownloads verifies that the HTTP paths
// outside dbcClient — the auth client and package downloads via
// PkgInfo.DownloadPackage — also send the suffix.
func TestSetUserAgentSuffixCoversAuthAndDownloads(t *testing.T) {
var got []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.Header.Get("User-Agent"))
_, _ = w.Write([]byte("pkg"))
}))
t.Cleanup(srv.Close)
t.Setenv("CI", "")

prevSuffix, prevDefault := userAgentSuffix, dbc.DefaultClient
t.Cleanup(func() { userAgentSuffix, dbc.DefaultClient = prevSuffix, prevDefault })
setUserAgentSuffix("my-tool/2.0")
want := defaultUserAgent() + " my-tool/2.0"

resp, err := authHTTPClient().Get(srv.URL)
require.NoError(t, err)
resp.Body.Close()

u, err := url.Parse(srv.URL + "/pkg.tar.gz")
require.NoError(t, err)
f, err := dbc.PkgInfo{Path: u}.DownloadPackage(func(int64, int64) {})
require.NoError(t, err)
f.Close()
t.Cleanup(func() { os.RemoveAll(filepath.Dir(f.Name())) })

assert.Equal(t, []string{want, want}, got)
}
Loading