From 82c345835cbc74712f1caf8c865ce780af99217f Mon Sep 17 00:00:00 2001 From: Chris Stephens Date: Tue, 27 Oct 2026 11:31:08 +0000 Subject: [PATCH 1/3] Attribute files/v2 SDK requests to the CLI command in the user agent Requests made through the modular Go SDK (files/v2, used by the Volumes filer) carried no product or command segments, so usage telemetry could not attribute them to the CLI, bundle deploys, or a specific command. Set the "cli" product on the modular SDK at startup, and copy the segments the CLI attaches to the classic SDK context (cmd, cmd-exec-id, interactive, engine, ...) into the modular SDK's process-wide segments when the files/v2 client is built. Co-authored-by: Isaac --- cmd/root/user_agent.go | 3 ++ libs/filer/files_client.go | 2 + libs/filer/files_client_user_agent.go | 49 ++++++++++++++++++++++ libs/filer/files_client_user_agent_test.go | 45 ++++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 libs/filer/files_client_user_agent.go create mode 100644 libs/filer/files_client_user_agent_test.go diff --git a/cmd/root/user_agent.go b/cmd/root/user_agent.go index 1e35d2cefd1..aa8b1ab23b2 100644 --- a/cmd/root/user_agent.go +++ b/cmd/root/user_agent.go @@ -3,8 +3,11 @@ package root import ( "github.com/databricks/cli/internal/build" "github.com/databricks/databricks-sdk-go/useragent" + "github.com/databricks/sdk-go/core/clientinfo" ) func init() { useragent.WithProduct("cli", build.GetInfo().Version) + // The classic SDK panics above on an invalid version, so this cannot fail. + _ = clientinfo.SetProduct("cli", build.GetInfo().Version) } diff --git a/libs/filer/files_client.go b/libs/filer/files_client.go index 1638acb73bd..90737e9934e 100644 --- a/libs/filer/files_client.go +++ b/libs/filer/files_client.go @@ -190,6 +190,8 @@ func newTransferClient(n int) *http.Client { // config, reusing its auth instead of re-reading a profile. cfg is passed by // pointer because config.Config embeds a sync.Mutex and must not be copied. func newFilesAPIClient(ctx context.Context, cfg *config.Config) (*files.Client, error) { + registerUserAgentForFilesAPI(ctx) + copts := []client.Option{ client.WithHost(cfg.Host), client.WithCredentials(configCredentials{cfg: cfg}), diff --git a/libs/filer/files_client_user_agent.go b/libs/filer/files_client_user_agent.go new file mode 100644 index 00000000000..2ab268b0dc5 --- /dev/null +++ b/libs/filer/files_client_user_agent.go @@ -0,0 +1,49 @@ +package filer + +import ( + "context" + "strings" + + "github.com/databricks/cli/libs/log" + "github.com/databricks/databricks-sdk-go/useragent" + "github.com/databricks/sdk-go/core/clientinfo" +) + +// filesAPIOwnedUserAgentKeys are segments that only describe the classic SDK +// or that the files/v2 client already renders on its own (from the +// environment or its credentials). Copying them would misreport or duplicate +// them. +var filesAPIOwnedUserAgentKeys = map[string]bool{ + "databricks-sdk-go": true, + "go": true, + "os": true, + "auth": true, + "cicd": true, + "agent": true, + "runtime": true, + "meta-harness": true, +} + +// registerUserAgentForFilesAPI copies the segments the CLI attaches to the +// classic SDK context (cmd, cmd-exec-id, interactive, engine, ...) into the +// files/v2 client's process-wide user agent, so that files/v2 requests are +// attributed to the same command as the rest of the CLI's traffic. +// +// files/v2 has no request-scoped segments. A CLI process runs a single +// command, so process-wide segments carry the same information. +func registerUserAgentForFilesAPI(ctx context.Context) { + rendered := map[string]bool{} + for s := range strings.FieldsSeq(clientinfo.Default().String()) { + rendered[s] = true + } + + for s := range strings.FieldsSeq(useragent.FromContext(ctx)) { + key, value, ok := strings.Cut(s, "/") + if !ok || rendered[s] || filesAPIOwnedUserAgentKeys[key] { + continue + } + if err := clientinfo.AddToDefault(key, value); err != nil { + log.Debugf(ctx, "Not adding %q to the files/v2 user agent: %s", s, err) + } + } +} diff --git a/libs/filer/files_client_user_agent_test.go b/libs/filer/files_client_user_agent_test.go new file mode 100644 index 00000000000..bd45d9905f3 --- /dev/null +++ b/libs/filer/files_client_user_agent_test.go @@ -0,0 +1,45 @@ +package filer + +import ( + "strings" + "testing" + + "github.com/databricks/cli/libs/testserver" + "github.com/databricks/databricks-sdk-go" + "github.com/databricks/databricks-sdk-go/useragent" + files "github.com/databricks/sdk-go/files/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilesAPIClientCarriesCommandUserAgent(t *testing.T) { + var gotUserAgent string + server := testserver.New(t) + server.Handle("HEAD", "/api/2.0/fs/files/{path...}", func(req testserver.Request) any { + gotUserAgent = req.Headers.Get("User-Agent") + return "" + }) + + workspaceClient, err := databricks.NewWorkspaceClient(&databricks.Config{ + Host: server.URL, + Token: "token", + }) + require.NoError(t, err) + + // The registered segments are process-wide, so the values are unique to + // this test to keep it independent of other tests in the package. + ctx := useragent.InContext(t.Context(), "cmd", "test_files-user-agent") + ctx = useragent.InContext(ctx, "engine", "direct") + + client, err := newFilesAPIClient(ctx, workspaceClient.Config) + require.NoError(t, err) + + filePath := "/Volumes/main/schema/volume/file" + _, err = client.GetFileMetadata(ctx, files.GetFileMetadataRequest{FilePath: &filePath}) + require.NoError(t, err) + + assert.Contains(t, gotUserAgent, " cmd/test_files-user-agent engine/direct") + assert.NotContains(t, gotUserAgent, "databricks-sdk-go/") + assert.Equal(t, 1, strings.Count(gotUserAgent, " auth/"), gotUserAgent) + assert.Equal(t, 1, strings.Count(gotUserAgent, " go/"), gotUserAgent) +} From 763eb1cf844aaf73d3cc49f7f50b7b50e2064684 Mon Sep 17 00:00:00 2001 From: Chris Stephens Date: Tue, 27 Oct 2026 11:57:32 +0000 Subject: [PATCH 2/3] Tighten files/v2 user agent comments Co-authored-by: Isaac --- libs/filer/files_client_user_agent.go | 16 +++++----------- libs/filer/files_client_user_agent_test.go | 3 +-- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/libs/filer/files_client_user_agent.go b/libs/filer/files_client_user_agent.go index 2ab268b0dc5..ce273c26b09 100644 --- a/libs/filer/files_client_user_agent.go +++ b/libs/filer/files_client_user_agent.go @@ -9,10 +9,8 @@ import ( "github.com/databricks/sdk-go/core/clientinfo" ) -// filesAPIOwnedUserAgentKeys are segments that only describe the classic SDK -// or that the files/v2 client already renders on its own (from the -// environment or its credentials). Copying them would misreport or duplicate -// them. +// filesAPIOwnedUserAgentKeys are classic-SDK-only segments or segments that +// files/v2 renders itself, so copying them would misreport or duplicate them. var filesAPIOwnedUserAgentKeys = map[string]bool{ "databricks-sdk-go": true, "go": true, @@ -24,13 +22,9 @@ var filesAPIOwnedUserAgentKeys = map[string]bool{ "meta-harness": true, } -// registerUserAgentForFilesAPI copies the segments the CLI attaches to the -// classic SDK context (cmd, cmd-exec-id, interactive, engine, ...) into the -// files/v2 client's process-wide user agent, so that files/v2 requests are -// attributed to the same command as the rest of the CLI's traffic. -// -// files/v2 has no request-scoped segments. A CLI process runs a single -// command, so process-wide segments carry the same information. +// registerUserAgentForFilesAPI copies the CLI's classic SDK context segments +// (cmd, engine, ...) into the files/v2 user agent. files/v2 only supports +// process-wide segments, which is equivalent for a single-command process. func registerUserAgentForFilesAPI(ctx context.Context) { rendered := map[string]bool{} for s := range strings.FieldsSeq(clientinfo.Default().String()) { diff --git a/libs/filer/files_client_user_agent_test.go b/libs/filer/files_client_user_agent_test.go index bd45d9905f3..9d4d3956179 100644 --- a/libs/filer/files_client_user_agent_test.go +++ b/libs/filer/files_client_user_agent_test.go @@ -26,8 +26,7 @@ func TestFilesAPIClientCarriesCommandUserAgent(t *testing.T) { }) require.NoError(t, err) - // The registered segments are process-wide, so the values are unique to - // this test to keep it independent of other tests in the package. + // Segments are process-wide, so use values unique to this test. ctx := useragent.InContext(t.Context(), "cmd", "test_files-user-agent") ctx = useragent.InContext(ctx, "engine", "direct") From c48b7202784430524e6562063a3a477517755970 Mon Sep 17 00:00:00 2001 From: Chris Stephens Date: Tue, 27 Oct 2026 12:36:48 +0000 Subject: [PATCH 3/3] Only set the cli product on the modular SDK user agent Drop copying classic-SDK context segments into the files/v2 user agent. Command and engine attribution on the modular SDK is left to follow-ups. Co-authored-by: Isaac --- libs/filer/files_client.go | 2 - libs/filer/files_client_user_agent.go | 43 --------------------- libs/filer/files_client_user_agent_test.go | 44 ---------------------- 3 files changed, 89 deletions(-) delete mode 100644 libs/filer/files_client_user_agent.go delete mode 100644 libs/filer/files_client_user_agent_test.go diff --git a/libs/filer/files_client.go b/libs/filer/files_client.go index 90737e9934e..1638acb73bd 100644 --- a/libs/filer/files_client.go +++ b/libs/filer/files_client.go @@ -190,8 +190,6 @@ func newTransferClient(n int) *http.Client { // config, reusing its auth instead of re-reading a profile. cfg is passed by // pointer because config.Config embeds a sync.Mutex and must not be copied. func newFilesAPIClient(ctx context.Context, cfg *config.Config) (*files.Client, error) { - registerUserAgentForFilesAPI(ctx) - copts := []client.Option{ client.WithHost(cfg.Host), client.WithCredentials(configCredentials{cfg: cfg}), diff --git a/libs/filer/files_client_user_agent.go b/libs/filer/files_client_user_agent.go deleted file mode 100644 index ce273c26b09..00000000000 --- a/libs/filer/files_client_user_agent.go +++ /dev/null @@ -1,43 +0,0 @@ -package filer - -import ( - "context" - "strings" - - "github.com/databricks/cli/libs/log" - "github.com/databricks/databricks-sdk-go/useragent" - "github.com/databricks/sdk-go/core/clientinfo" -) - -// filesAPIOwnedUserAgentKeys are classic-SDK-only segments or segments that -// files/v2 renders itself, so copying them would misreport or duplicate them. -var filesAPIOwnedUserAgentKeys = map[string]bool{ - "databricks-sdk-go": true, - "go": true, - "os": true, - "auth": true, - "cicd": true, - "agent": true, - "runtime": true, - "meta-harness": true, -} - -// registerUserAgentForFilesAPI copies the CLI's classic SDK context segments -// (cmd, engine, ...) into the files/v2 user agent. files/v2 only supports -// process-wide segments, which is equivalent for a single-command process. -func registerUserAgentForFilesAPI(ctx context.Context) { - rendered := map[string]bool{} - for s := range strings.FieldsSeq(clientinfo.Default().String()) { - rendered[s] = true - } - - for s := range strings.FieldsSeq(useragent.FromContext(ctx)) { - key, value, ok := strings.Cut(s, "/") - if !ok || rendered[s] || filesAPIOwnedUserAgentKeys[key] { - continue - } - if err := clientinfo.AddToDefault(key, value); err != nil { - log.Debugf(ctx, "Not adding %q to the files/v2 user agent: %s", s, err) - } - } -} diff --git a/libs/filer/files_client_user_agent_test.go b/libs/filer/files_client_user_agent_test.go deleted file mode 100644 index 9d4d3956179..00000000000 --- a/libs/filer/files_client_user_agent_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package filer - -import ( - "strings" - "testing" - - "github.com/databricks/cli/libs/testserver" - "github.com/databricks/databricks-sdk-go" - "github.com/databricks/databricks-sdk-go/useragent" - files "github.com/databricks/sdk-go/files/v2" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFilesAPIClientCarriesCommandUserAgent(t *testing.T) { - var gotUserAgent string - server := testserver.New(t) - server.Handle("HEAD", "/api/2.0/fs/files/{path...}", func(req testserver.Request) any { - gotUserAgent = req.Headers.Get("User-Agent") - return "" - }) - - workspaceClient, err := databricks.NewWorkspaceClient(&databricks.Config{ - Host: server.URL, - Token: "token", - }) - require.NoError(t, err) - - // Segments are process-wide, so use values unique to this test. - ctx := useragent.InContext(t.Context(), "cmd", "test_files-user-agent") - ctx = useragent.InContext(ctx, "engine", "direct") - - client, err := newFilesAPIClient(ctx, workspaceClient.Config) - require.NoError(t, err) - - filePath := "/Volumes/main/schema/volume/file" - _, err = client.GetFileMetadata(ctx, files.GetFileMetadataRequest{FilePath: &filePath}) - require.NoError(t, err) - - assert.Contains(t, gotUserAgent, " cmd/test_files-user-agent engine/direct") - assert.NotContains(t, gotUserAgent, "databricks-sdk-go/") - assert.Equal(t, 1, strings.Count(gotUserAgent, " auth/"), gotUserAgent) - assert.Equal(t, 1, strings.Count(gotUserAgent, " go/"), gotUserAgent) -}