Skip to content

aitools: bound best-effort telemetry auth - #7468

Merged
anton-107 merged 1 commit into
mainfrom
aitools/bound-telemetry-auth
Nov 12, 2026
Merged

anton-107 merged 1 commit into
mainfrom
aitools/bound-telemetry-auth

Conversation

@anton-107

@anton-107 anton-107 commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #7467.

Stacked on: aitools: log install-hint telemetry and bound telemetry uploads

Summary

aitools install resolved auth for telemetry in PreRunE, before doing any work, so an unreachable workspace blocked the install for as long as the SDK retried. It now resolves auth in the background while the install runs and waits for it at most 3 s after the command started. Nothing is resolved when DATABRICKS_CLI_DISABLE_TELEMETRY is set (new telemetry.Disabled), and a run that logged no event does not wait.

 aitools install
-  PreRunE: tryConfigureAuth
-    MustAnyClient(cmd)              # blocks while the SDK retries host metadata
-  RunE: install skills
+  PreRunE: startTelemetryAuth       # returns at once
+    if telemetry.Disabled → skip
+    go MustAnyClient(shadow cmd)    # shares cmd's flags (--profile), not its context
+                                    # SkipTokenRefresh: never refreshes an OAuth token
+  RunE wrapper
+    runE: install skills            # runs while auth resolves
+    pending.apply(cmd)
+      no event logged → return
+      wait until start + 3 s, at most
+      succeeded → SetConfigUsed(cmd ctx)
+      failed or not finished → no config, no upload
 root: telemetry.Upload

The background resolution never refreshes an OAuth token. The process may exit before it finishes, and the server rotates the single-use refresh token, so an abandoned refresh could lose the new one and log the user out. The new root.SkipTokenRefresh makes MustWorkspaceClient and MustAccountClient use auth.CredentialsWithoutRefresh, whose databricks-cli strategy passes the new u2m.WithoutRefresh: the cached token is used only while it is valid, and an expired one fails auth, so nothing is uploaded.

The shadow command logs through debugOnlyHandler, so a background warning such as [hostmetadata] failed to fetch host metadata ... shows only with --debug and does not interrupt the install's output or prompts. This relies on root's host-metadata resolver logging through the command's logger (#7390). A panic in the background resolution is recovered and logged at debug level; the install does not fail.

Alternatives considered: resolving auth inside telemetry.Upload would serialize it after the install at exit and drop MustAnyClient's account-client fallback and --profile handling. Bounding a synchronous PreRunE with the SDK's HTTPTimeoutSeconds/RetryTimeoutSeconds would still add that wait to every install before any work starts and keep the hostmetadata warning in its output; resolving in the background overlaps auth with the install instead.

The deprecated experimental aitools skills install alias runs its own install command, so it now copies that command's context back, and the config resolved for telemetry reaches root's upload (before, the alias never uploaded).

Changelog fragment: .nextchanges/cli/aitools-install-telemetry-auth.md.

Evidence

aitools install --skills-only --scope=global --agents=claude-code, skills served locally, fresh HOME and cache per run; before = parent branch binary, after = this branch's head:

DATABRICKS_HOST=https://localhost:1   (connection refused; the SDK retries)
  before: no output, killed by `timeout 120` after 120.0 s
  after:  "Installed 1 skill.", exit 0 in 3.0 s (two runs)
          with DATABRICKS_CLI_DISABLE_TELEMETRY=1: 0.03 s

DATABRICKS_HOST=https://unreachable.test   (fails fast)
  before: "Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s"
          printed above the install output
  after:  no hostmetadata line; exit 0 in 0.1 s

Tests at head, all pass locally on Linux and in CI on Linux, macOS and Windows:

cmd/aitools TestTelemetryAuth*:
  ResolvedConfigReachesTheUpload            config reaches the upload, --profile seen
  FailedResolutionUploadsNothing            a failed auth leaves no config
  NoEventDoesNotWait                        no event logged: no wait, no config
  SlowResolutionIsBounded                   command finishes < 2 s, no config
  FinishedResolutionSurvivesAnExpiredBudget budget 0, ×50
  PanicDoesNotFailTheCommand
  SkippedWhenTelemetryDisabled              resolver never called
  LogsStayOutOfNormalOutput                 background Warn/Error hidden; own Warn still prints
  LogsShowAtDebug                           background lines shown as Debug
  RunEWithoutPreRun
acceptance/cmd/aitools/install-host-metadata
  no hostmetadata line normally; "Debug: [hostmetadata] ..." with --debug
acceptance/cmd/aitools/telemetry/install
  PAT and databricks-cli with a valid cached token: the install event is uploaded
  databricks-cli with an expired cached token: no /oidc/v1/token request, no upload, cache unchanged
libs/auth/u2m TestToken_WithoutRefresh       valid and expiring-soon tokens returned, expired fails; no request
libs/auth TestCredentialChainWithoutRefresh, TestCLICredentialsConfigure_NoRefresh
cmd/root TestSkipTokenRefresh
cmd/aitools TestLegacySkillsInstallKeepsTelemetryAuthConfig   the alias's context gets the resolved config

Merge Danger

Door: two-way

No telemetry schema change, persisted state or config. A revert restores the blocking PreRunE.

Blast Radius: aitools install. root.SkipTokenRefresh is new and only aitools install sets it; without it MustWorkspaceClient, MustAccountClient and PersistentAuth.Token behave as before.

  • Telemetry loss: an install uploads no event when its auth has not resolved by the later of the end of the install and 3 s after the command started.
  • Slow but reachable workspace: the upload still follows a finished resolution with its own timeout (DATABRICKS_CLI_TELEMETRY_TIMEOUT, default 3 s), so after a short install the end can take up to about 6 s. No run waits longer for auth than it did before.
  • Unreachable workspace: every aitools install run that logged an event lasts at least 3 s. Runs with nothing to upload (e.g. a flag-validation error) do not wait. The process exits while the SDK is still retrying, before libs/hostmetadata writes its negative-cache entry, so the next run probes the host again.
  • Expired U2M token, no event: an install whose cached databricks-cli access token has expired uploads no install event, because the background resolution does not refresh it. This is on purpose: a refresh that the exit can cut short is what can strand a rotated refresh token. The next command that authenticates refreshes the token as usual. Before, the install refreshed it in PreRunE and uploaded the event.
  • Expired U2M token without an auth_type: with no auth_type (e.g. DATABRICKS_HOST from the environment, or a hand-written profile), the SDK chain skips a strategy whose dry run fails, so an expired token moves on to the strategies after databricks-cli (metadata service, OIDC, Azure, Google). If one succeeds, e.g. az logged in on an Azure host, the event is uploaded with that identity. The default chain would have refreshed and stopped at databricks-cli.
  • Warnings that can still print: with the cache off or unusable and the databricks-cli strategy, libs/auth ToOAuthArgument looks up host metadata outside the command's logger; SDK warnings logged on its own context.Background() (e.g. missing oidc_endpoint) are not filtered either.
  • Platforms: CI runs the tests above on Linux, macOS and Windows; the timings under Evidence were measured on Linux only.

This pull request and its description were written by Isaac.

@anton-107
anton-107 requested review from a team and lennartkats-db as code owners November 11, 2026 14:24
@anton-107 anton-107 added the ai-auto PRs made by AI loops & factories, label Nov 11, 2026
@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch from 0d29efd to a15150a Compare November 11, 2026 14:32
@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Commit: d13de92e6 | Workflow run

Regression test report

Selected 2 changed tests (limit=20, 0 not selected)

2 tests: regression=2 unreleased=0 coverage=0 failing=0
test branch main (0f407e7) latest (v1.26.0)
TestAccept/cmd/aitools/install-host-metadata ✅ ❌ ❌
TestAccept/cmd/aitools/telemetry/install ✅ ❌ ❌
TestAccept/cmd/aitools/install-host-metadata: branch ✅ | main (0f407e7) ❌ | latest (v1.26.0) ❌

main (0f407e7):

acceptance_test.go:1241: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:42731" ("[DATABRICKS_HOST]")
    acceptance_test.go:1373: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1373
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1114
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:660
        	Error:      	Not equal: 
        	            	expected: "\n=== workspace unreachable: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=claude-code\nInstalling Databricks skills for Claude Code...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== --debug still shows the hostmetadata line, at debug level\n"
        	            	actual  : "\n=== workspace unreachable: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=claude-code\nWarn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s\nInstalling Databricks skills for Claude Code...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== --debug still shows the hostmetadata line, at debug level\ncontains error: 'Warn: [hostmetadata]' was not expected: '16:39:43 Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s pid=8003'\ncontains error: 'Debug: [hostmetadata] failed to fetch host metadata for https://unreachable.test' not found in the output.\n\nExit code: 1\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -4,2 +4,3 @@
        	            	 >>> [CLI] aitools install --skills-only --scope=global --agents=claude-code
        	            	+Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s
        	            	 Installing Databricks skills for Claude Code...
        	            	@@ -10,2 +11,6 @@
        	            	 === --debug still shows the hostmetadata line, at debug level
        	            	+contains error: 'Warn: [hostmetadata]' was not expected: '16:39:43 Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s pid=8003'
        	            	+contains error: 'Debug: [hostmetadata] failed to fetch host metadata for https://unreachable.test' not found in the output.
        	            	 
        	            	+Exit code: 1
        	            	+
        	Test:       	TestAccept/cmd/aitools/install-host-metadata
        	Messages:   	cmd/aitools/install-host-metadata/output.txt vs /tmp/TestAcceptcmdaitoolsinstall-host-metadata85331110/002/output.txt
    acceptance_test.go:1151: 
        LOG.debug: 
        LOG.debug: >>> /tmp/TestAccept1129568913/001/databricks aitools install --skills-only --scope=global --agents=claude-code --debug
        LOG.debug: 16:39:43 Info: start pid=8003 version=1.27.0-dev args="/tmp/TestAccept1129568913/001/databricks, aitools, install, --skills-only, --scope=global, --agents=claude-code, --debug"
        LOG.debug: 16:39:43 Debug: [Local Cache] using cache key: 4615119225336714e069b428ddfc81a32b08b08949c0cb71f8f138452c4e89e4 pid=8003
        LOG.debug: 16:39:43 Debug: [Local Cache] cache miss, computing pid=8003
        LOG.debug: 16:39:43 Debug: GET /.well-known/databricks-config
        LOG.debug: < Error: Get "https://unreachable.test/.well-known/databricks-config": Bad Request pid=8003 sdk=true
        LOG.debug: 16:39:43 Debug: non-retriable error: Get "https://unreachable.test/.well-known/databricks-config": Bad Request pid=8003 sdk=true
        LOG.debug: 16:39:43 Debug: [Local Cache] error while computing: fetching host metadata from "https://unreachable.test/.well-known/databricks-config": Get "https://unreachable.test/.well-known/databricks-config": Bad Request pid=8003
        LOG.debug: 16:39:43 Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s pid=8003
        LOG.debug: 16:39:43 Debug: [hostmetadata] fetch error for https://unreachable.test: fetching host metadata from "https://unreachable.test/.well-known/databricks-config": Get "https://unreachable.test/.well-known/databricks-config": Bad Request pid=8003
        LOG.debug: Installing Databricks skills for Claude Code...
        LOG.debug: Using skills version test-ref
        LOG.debug: Fetching skills manifest...
        LOG.debug: 16:39:43 Debug: Fetching skills manifest from databricks/databricks-agent-skills@test-ref pid=8003
        LOG.debug: 16:39:43 Debug: test-stable v1.0.0 already installed for all agents, skipping pid=8003
        LOG.debug: Installed 1 skill.
        LOG.debug: 16:39:43 Info: completed execution pid=8003 exit_code=0
        LOG.debug: 16:39:43 Debug: POST /telemetry-ext
        LOG.debug: > {
        LOG.debug: >   "items": null,
        LOG.debug: >   "protoLogs": [
        LOG.debug: >     "{\"frontend_log_event_id\":\"0d80bb9c-d0e3-4194-a5aa-a4fd6597ba33\",\"entry\":{\"databricks_cli_log\":{\"... (294 more bytes)"
        LOG.debug: >   ],
        LOG.debug: >   "uploadTime": 1794501583848
        LOG.debug: > }
        LOG.debug: < Error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8003 sdk=true
        LOG.debug: 16:39:43 Debug: non-retriable error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8003 sdk=true
        LOG.debug: 16:39:43 Debug: POST /telemetry-ext
        LOG.debug: > {
        LOG.debug: >   "items": null,
        LOG.debug: >   "protoLogs": [
        LOG.debug: >     "{\"frontend_log_event_id\":\"0d80bb9c-d0e3-4194-a5aa-a4fd6597ba33\",\"entry\":{\"databricks_cli_log\":{\"... (294 more bytes)"
        LOG.debug: >   ],
        LOG.debug: >   "uploadTime": 1794501583850
        LOG.debug: > }
        LOG.debug: < Error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8003 sdk=true
        LOG.debug: 16:39:43 Debug: non-retriable error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8003 sdk=true
        LOG.debug: 16:39:43 Debug: POST /telemetry-ext
        LOG.debug: > {
        LOG.debug: >   "items": null,
        LOG.debug: >   "protoLogs": [
        LOG.debug: >     "{\"frontend_log_event_id\":\"0d80bb9c-d0e3-4194-a5aa-a4fd6597ba33\",\"entry\":{\"databricks_cli_log\":{\"... (294 more bytes)"
        LOG.debug: >   ],
        LOG.debug: >   "uploadTime": 1794501583851
        LOG.debug: > }
        LOG.debug: < Error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8003 sdk=true
        LOG.debug: 16:39:43 Debug: non-retriable error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8003 sdk=true
        LOG.debug: 16:39:43 Info: telemetry upload failed: failed to upload telemetry logs after three attempts pid=8003

latest (v1.26.0):

acceptance_test.go:1241: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:46773" ("[DATABRICKS_HOST]")
    acceptance_test.go:1373: 
        	Error Trace:	/home/runner/work/cli/cli/libs/testdiff/testdiff.go:22
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1373
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:1114
        	            				/home/runner/work/cli/cli/acceptance/acceptance_test.go:660
        	Error:      	Not equal: 
        	            	expected: "\n=== workspace unreachable: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=claude-code\nInstalling Databricks skills for Claude Code...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== --debug still shows the hostmetadata line, at debug level\n"
        	            	actual  : "\n=== workspace unreachable: the background auth resolution prints nothing\n\n>>> [CLI] aitools install --skills-only --scope=global --agents=claude-code\nWarn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s\nInstalling Databricks skills for Claude Code...\nUsing skills version test-ref\nFetching skills manifest...\nInstalled 1 skill.\n\n=== --debug still shows the hostmetadata line, at debug level\ncontains error: 'Warn: [hostmetadata]' was not expected: '16:39:46 Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s pid=8199'\ncontains error: 'Debug: [hostmetadata] failed to fetch host metadata for https://unreachable.test' not found in the output.\n\nExit code: 1\n"
        	            	
        	            	Diff:
        	            	--- Expected
        	            	+++ Actual
        	            	@@ -4,2 +4,3 @@
        	            	 >>> [CLI] aitools install --skills-only --scope=global --agents=claude-code
        	            	+Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s
        	            	 Installing Databricks skills for Claude Code...
        	            	@@ -10,2 +11,6 @@
        	            	 === --debug still shows the hostmetadata line, at debug level
        	            	+contains error: 'Warn: [hostmetadata]' was not expected: '16:39:46 Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s pid=8199'
        	            	+contains error: 'Debug: [hostmetadata] failed to fetch host metadata for https://unreachable.test' not found in the output.
        	            	 
        	            	+Exit code: 1
        	            	+
        	Test:       	TestAccept/cmd/aitools/install-host-metadata
        	Messages:   	cmd/aitools/install-host-metadata/output.txt vs /tmp/TestAcceptcmdaitoolsinstall-host-metadata885050183/002/output.txt
    acceptance_test.go:1151: 
        LOG.debug: 
        LOG.debug: >>> /home/runner/work/cli/cli/acceptance/build/linux_amd64/1.26.0/databricks aitools install --skills-only --scope=global --agents=claude-code --debug
        LOG.debug: 16:39:46 Info: start pid=8199 version=1.26.0 args="/home/runner/work/cli/cli/acceptance/build/linux_amd64/1.26.0/databricks, aitools, install, --skills-only, --scope=global, --agents=claude-code, --debug"
        LOG.debug: 16:39:46 Debug: [Local Cache] using cache key: 4615119225336714e069b428ddfc81a32b08b08949c0cb71f8f138452c4e89e4 pid=8199
        LOG.debug: 16:39:46 Debug: [Local Cache] cache miss, computing pid=8199
        LOG.debug: 16:39:46 Debug: GET /.well-known/databricks-config
        LOG.debug: < Error: Get "https://unreachable.test/.well-known/databricks-config": Bad Request pid=8199 sdk=true
        LOG.debug: 16:39:46 Debug: non-retriable error: Get "https://unreachable.test/.well-known/databricks-config": Bad Request pid=8199 sdk=true
        LOG.debug: 16:39:46 Debug: [Local Cache] error while computing: fetching host metadata from "https://unreachable.test/.well-known/databricks-config": Get "https://unreachable.test/.well-known/databricks-config": Bad Request pid=8199
        LOG.debug: 16:39:46 Warn: [hostmetadata] failed to fetch host metadata for https://unreachable.test, will skip for 1m0s pid=8199
        LOG.debug: 16:39:46 Debug: [hostmetadata] fetch error for https://unreachable.test: fetching host metadata from "https://unreachable.test/.well-known/databricks-config": Get "https://unreachable.test/.well-known/databricks-config": Bad Request pid=8199
        LOG.debug: Installing Databricks skills for Claude Code...
        LOG.debug: Using skills version test-ref
        LOG.debug: Fetching skills manifest...
        LOG.debug: 16:39:46 Debug: Fetching skills manifest from databricks/databricks-agent-skills@test-ref pid=8199
        LOG.debug: 16:39:46 Debug: test-stable v1.0.0 already installed for all agents, skipping pid=8199
        LOG.debug: Installed 1 skill.
        LOG.debug: 16:39:46 Info: completed execution pid=8199 exit_code=0
        LOG.debug: 16:39:46 Debug: POST /telemetry-ext
        LOG.debug: > {
        LOG.debug: >   "items": null,
        LOG.debug: >   "protoLogs": [
        LOG.debug: >     "{\"frontend_log_event_id\":\"6545f8b0-e865-49dc-9769-1e05bcb9810f\",\"entry\":{\"databricks_cli_log\":{\"... (290 more bytes)"
        LOG.debug: >   ],
        LOG.debug: >   "uploadTime": 1794501586673
        LOG.debug: > }
        LOG.debug: < Error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8199 sdk=true
        LOG.debug: 16:39:46 Debug: non-retriable error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8199 sdk=true
        LOG.debug: 16:39:46 Debug: POST /telemetry-ext
        LOG.debug: > {
        LOG.debug: >   "items": null,
        LOG.debug: >   "protoLogs": [
        LOG.debug: >     "{\"frontend_log_event_id\":\"6545f8b0-e865-49dc-9769-1e05bcb9810f\",\"entry\":{\"databricks_cli_log\":{\"... (290 more bytes)"
        LOG.debug: >   ],
        LOG.debug: >   "uploadTime": 1794501586679
        LOG.debug: > }
        LOG.debug: < Error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8199 sdk=true
        LOG.debug: 16:39:46 Debug: non-retriable error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8199 sdk=true
        LOG.debug: 16:39:46 Debug: POST /telemetry-ext
        LOG.debug: > {
        LOG.debug: >   "items": null,
        LOG.debug: >   "protoLogs": [
        LOG.debug: >     "{\"frontend_log_event_id\":\"6545f8b0-e865-49dc-9769-1e05bcb9810f\",\"entry\":{\"databricks_cli_log\":{\"... (290 more bytes)"
        LOG.debug: >   ],
        LOG.debug: >   "uploadTime": 1794501586682
        LOG.debug: > }
        LOG.debug: < Error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8199 sdk=true
        LOG.debug: 16:39:46 Debug: non-retriable error: Post "https://unreachable.test/telemetry-ext": Bad Request pid=8199 sdk=true
        LOG.debug: 16:39:46 Info: telemetry upload failed: failed to upload telemetry logs after three attempts pid=8199
TestAccept/cmd/aitools/telemetry/install: branch ✅ | main (0f407e7) ❌ | latest (v1.26.0) ❌

main (0f407e7):

acceptance_test.go:1241: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:35889" ("[DATABRICKS_HOST]")
    acceptance_test.go:1373: Diff:
        --- cmd/aitools/telemetry/install/output.txt
        +++ /tmp/TestAcceptcmdaitoolstelemetryinstall2661191119/002/output.txt
        @@ -30,6 +30,22 @@
         Installed 1 skill.
         
         >>> print_requests.py //oidc/v1/token //telemetry-ext
        +{
        +  "method": "POST",
        +  "path": "/oidc/v1/token",
        +  "raw_body": "client_id=databricks-cli&grant_type=refresh_token&refresh_token=test-refresh-token"
        +}
        +{
        +  "method": "POST",
        +  "path": "/telemetry-ext",
        +  "body": {
        +    "uploadTime": [UNIX_TIME_MILLIS],
        +    "items": [],
        +    "protoLogs": [
        +      "{\"frontend_log_event_id\":\"[UUID][0]\",\"entry\":{\"databricks_cli_log\":{\"execution_context\":{\"cmd_exec_id\":\"[UUID][1]\",\"version\":\"[CLI_VERSION]\",\"command\":\"aitools_install\",\"operating_system\":\"linux\",\"execution_time_ms\":5,\"exit_code\":0},\"aitools_install_event\":{\"agents\":[\"CLAUDE_CODE\"],\"scope\":\"GLOBAL\",\"error_category\":\"TYPE_UNSPECIFIED\"}}}}"
        +    ]
        +  }
        +}
         
         >>> gron.py [TEST_TMP_DIR]/home/.databricks/token-cache.json
        -json.tokens.test-profile.access_token = "expired-access-token";
        +json.tokens.test-profile.access_token = "oauth-token";
        
FAIL

latest (v1.26.0):

acceptance_test.go:1241: Substituted DATABRICKS_SKILLS_BASE_URL "$DATABRICKS_HOST" -> "http://127.0.0.1:44007" ("[DATABRICKS_HOST]")
    acceptance_test.go:1373: Diff:
        --- cmd/aitools/telemetry/install/output.txt
        +++ /tmp/TestAcceptcmdaitoolstelemetryinstall3363141050/002/output.txt
        @@ -30,6 +30,22 @@
         Installed 1 skill.
         
         >>> print_requests.py //oidc/v1/token //telemetry-ext
        +{
        +  "method": "POST",
        +  "path": "/oidc/v1/token",
        +  "raw_body": "client_id=databricks-cli&grant_type=refresh_token&refresh_token=test-refresh-token"
        +}
        +{
        +  "method": "POST",
        +  "path": "/telemetry-ext",
        +  "body": {
        +    "uploadTime": [UNIX_TIME_MILLIS],
        +    "items": [],
        +    "protoLogs": [
        +      "{\"frontend_log_event_id\":\"[UUID][0]\",\"entry\":{\"databricks_cli_log\":{\"execution_context\":{\"cmd_exec_id\":\"[UUID][1]\",\"version\":\"[CLI_VERSION]\",\"command\":\"aitools_install\",\"operating_system\":\"linux\",\"execution_time_ms\":8,\"exit_code\":0},\"aitools_install_event\":{\"agents\":[\"CLAUDE_CODE\"],\"scope\":\"GLOBAL\",\"error_category\":\"TYPE_UNSPECIFIED\"}}}}"
        +    ]
        +  }
        +}
         
         >>> gron.py [TEST_TMP_DIR]/home/.databricks/token-cache.json
        -json.tokens.test-profile.access_token = "expired-access-token";
        +json.tokens.test-profile.access_token = "oauth-token";
        
FAIL

@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Integration test report

Commit: d13de92

Run: 43260194666

Env 🔄​flaky 💚​RECOVERED ✅​pass 🙈​skip Time
💚​ aws linux-2core-8gb 1 270 17 10:00
💚​ aws-windows-latest-4core-16gb 1 272 15 7:47
🔄​ azure linux-2core-8gb 1 1 268 17 10:54
💚​ azure-windows-latest-4core-16gb 1 271 15 9:12
💚​ gcp linux-2core-8gb 1 270 17 7:16
💚​ gcp-windows-latest-4core-16gb 1 272 15 6:45
Test Name aws linux-2core-8gb aws-windows-latest-4core-16gb azure linux-2core-8gb azure-windows-latest-4core-16gb gcp linux-2core-8gb gcp-windows-latest-4core-16gb
💚​ TestAccept 💚​R 💚​R 💚​R 💚​R 💚​R 💚​R
🔄​ TestSyncNestedSpacePlusAndHashAreEscapedSync ✅​p ✅​p 🔄​f ✅​p ✅​p ✅​p
Top 11 slowest tests (at least 2 minutes):
duration env testname
4:09 azure-windows-latest-4core-16gb TestAccept
3:54 gcp linux-2core-8gb TestAccept
3:50 aws linux-2core-8gb TestAccept
3:49 azure linux-2core-8gb TestAccept
3:42 aws-windows-latest-4core-16gb TestAccept
3:37 gcp-windows-latest-4core-16gb TestAccept
3:22 azure-windows-latest-4core-16gb TestFilerWorkspaceFilesExtensionsRead
3:01 azure-windows-latest-4core-16gb TestFilerWorkspaceFilesExtensionsStat
2:36 azure-windows-latest-4core-16gb TestFilerWorkspaceFilesExtensionsReadDir
2:05 aws linux-2core-8gb TestFilerWorkspaceFilesExtensionsReadDir
2:00 azure-windows-latest-4core-16gb TestImportDir

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review from simonfaltum's review agent (pr-review skill) at a15150a. Each finding was verified against the code; simonfaltum may follow up.

Not approving yet: 1 blocking issue(s), tagged [blocking]; 1 open design question.

This PR stops aitools install from blocking on an unreachable workspace. Telemetry auth now resolves in the background on a shadow command, the wait is capped at 3 s from start, and its logs show only with --debug. The approach works as described and CI is green on all platforms. One blocking concern remains: when the wait ends while an OAuth refresh is still running, the CLI exits before saving the new single-use refresh token, so the user can be logged out. Two smaller suggestions are to skip the wait when no event was logged and to add an end-to-end upload test, plus a design question about reusing Upload's existing timeout instead of adding a second one.

Questions

  1. libs/telemetry.Upload already bounds config resolution and authentication within its own timeout (newAPIClientWithin and authenticate). Did you consider putting an unresolved config (with --profile and env applied) on the context and letting Upload resolve it? Or moving this helper into root or libs/telemetry so other commands can reuse it? Either would remove the shadow command, goroutine handoff, and log handler from cmd/aitools. The trade-offs are less overlap with the install, MustAnyClient's account-client fallback, and where hostmetadata warnings surface. Which approach do you prefer, and why?

Comments outside the changed lines

  • [nit] .github/PULL_REQUEST_TEMPLATE.md: The description uses ## Summary, ## Evidence, and ## Merge Danger. .agents/skills/pr-checklist/SKILL.md asks agent-written PRs to follow .github/PULL_REQUEST_TEMPLATE.md exactly (## Changes, ## Why, ## Tests, with no invented sections) and to keep the summary concise. Please move the content under those headings and trim it.

Comment thread cmd/aitools/telemetry.go Outdated
log.Debugf(sctx, "auth for telemetry panicked: %v", r)
}
}()
resolve(shadow, args)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] The background resolution can refresh a U2M token: PersistentAuth.refresh posts to the token endpoint and then stores the result. The process exits without waiting for it once apply gives up. Example: databricks-cli auth, an expired access token, a slow but reachable workspace, and a short --skills-only install where the host-metadata fetch plus the refresh straddle the 3 s deadline. The server rotates the single-use refresh token (cmd/auth/README.md), the new one is never stored, and the next command fails until databricks auth login. Before this PR the refresh always finished in PreRunE. A best-effort telemetry path should not be able to log the user out. Please keep the refresh out of the goroutine that gets abandoned. For example, resolve only the config in the background and authenticate on the main goroutine in apply once it has resolved, so a reachable host makes this one short request. Otherwise, get an explicit sign-off from the auth owners that this risk is acceptable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e503a62. The background resolution never refreshes a token now. A new root.SkipTokenRefresh context option makes MustWorkspaceClient/MustAccountClient use auth.CredentialsWithoutRefresh, whose databricks-cli strategy passes the new u2m.WithoutRefresh: PersistentAuth.Token returns the cached access token only while it is valid (it skips the proactive refresh too) and returns ErrRefreshDisabled for an expired one. A failed resolution now leaves no config on the command, so Upload does not retry auth (or refresh) at exit either. An expired token therefore just drops the event, and the next command that authenticates refreshes it as usual.

Tests: TestToken_WithoutRefresh (no token request, nothing stored), TestCLICredentialsConfigure_NoRefresh, TestCredentialChainWithoutRefresh, TestTelemetryAuthFailedResolutionUploadsNothing, and acceptance/cmd/aitools/telemetry/install, which installs with an expired cached token and checks that there is no /oidc/v1/token request and no upload, and that the cache is unchanged. Without SkipTokenRefresh that case shows the refresh POST.

Comment thread cmd/aitools/telemetry.go
if p == nil {
return
}
if !p.wait() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nice to have] apply waits up to the budget even when the run logged no telemetry event, and Upload then returns at len(l.logs) == 0. On an unreachable workspace, install --path ./dir, the flag-validation errors, "no agents found", and "Cancelled." all return before logInstallEvent's defer, yet still sit until 3 s after start for nothing. Return early when len(telemetry.BufferedEvents(cmd.Context())) == 0. That check is correct here because the install event is logged in a defer inside runE. Also update BufferedEvents' doc, which says only tests use it. Returning early also exits sooner while a refresh may be in flight, so this is safest once the refresh no longer runs in the background goroutine.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e503a62. apply returns before waiting when telemetry.BufferedEvents is empty (covered by TestTelemetryAuthNoEventDoesNotWait), and BufferedEvents' doc now mentions this use. Exiting early can no longer cut a refresh short, since the background resolution never refreshes.

export DATABRICKS_HOST=https://unreachable.test

title "workspace unreachable: the background auth resolution prints nothing\n"
trace $CLI aitools install --skills-only --scope=global --agents=claude-code

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nice to have] The success path, where the real MustAnyClient runs on the shadow command and the copied config reaches root's Upload, is covered only by a unit test with a fake resolver. If the shadow stopped seeing --profile, or root uploaded from a different context, aitools install would silently stop uploading its event and no test would fail. Please add a case under acceptance/cmd/aitools/telemetry/, which already records /telemetry-ext and has print_telemetry. It would run aitools install --skills-only --scope=global --agents=claude-code against the mock server, with this test's manifest stubs, and print the uploaded aitools_install_event.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added acceptance/cmd/aitools/telemetry/install in e503a62. It runs aitools install --skills-only --scope=global --agents=claude-code against the mock server and prints the uploaded aitools_install_event for PAT auth and for a databricks-cli profile passed with --profile and a valid cached token. A third case uses an expired cached token and checks that nothing is refreshed or uploaded.

While there: the new base includes #7003 (title adds its own trailing newline), so I dropped the \n from this script's titles, which had made the test fail on the restacked head.

Comment thread cmd/aitools/telemetry.go Outdated
Comment on lines +21 to +38
// telemetryAuthBudget bounds how long a command waits, at the end of its run,
// for the best-effort auth resolution that lets its telemetry upload. The
// resolution runs in the background while the command works, so the command
// waits only for the part of it that outlasts its run, and only until this
// budget after its start; a resolution still unfinished at the later of the
// end of the run and that deadline drops the events. Matches the upload's own
// default timeout. A resolution that does finish is followed by the upload,
// which has its own timeout, so after a short run a slow but reachable
// workspace can add up to about twice this.
//
// While the workspace stays unreachable, every run takes at least this long
// (the budget starts when the command does, so a run that takes longer waits
// nothing at the end), including runs that buffered no event and upload
// nothing, such as a flag-validation error: the SDK retries the host-metadata
// request on connection errors, so the fetch outlasts the budget and the
// process exits before libs/hostmetadata writes its negative-cache entry, and
// the next run probes the host again.
var telemetryAuthBudget = 3 * time.Second

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The doc comments on telemetryAuthBudget (lines 21-37) and startTelemetryAuth (lines 70-87) are each about 17 lines and re-derive the timing and risk analysis from the PR description. .agents/rules/comments.md asks for concise doc comments that give the contract and any surprising behavior, not a re-spec. Please trim each to a few lines: what the budget bounds and that an unfinished resolution drops the event, and why the shadow command and the debug-only logger exist. The risk matrix can stay in the PR description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed both in e503a62. telemetryAuthBudget now states what it bounds and that an unfinished resolution drops the events. startTelemetryAuth covers why the shadow command and the debug-only logger exist and that it never refreshes a token. The timing and risk analysis stays in the PR description.

Comment thread cmd/aitools/telemetry.go Outdated
}
}()
resolve(shadow, args)
if sctx := shadow.Context(); cmdctx.HasConfigUsed(sctx) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] if sctx := shadow.Context(); ... declares a second sctx inside the goroutine, while the deferred recover above logs on the outer one. Two contexts under one name invite a later edit to use the wrong one. Please rename it (e.g. resolved) or call shadow.Context() directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to resolved in e503a62.

@anton-107
anton-107 force-pushed the aitools/install-hint-telemetry branch from 2ca5f55 to 0326a2e Compare November 11, 2026 16:15
@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch from a15150a to 86362f1 Compare November 11, 2026 16:16
@anton-107
anton-107 force-pushed the aitools/install-hint-telemetry branch from 0326a2e to 673bfd5 Compare November 11, 2026 16:46
@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch from 86362f1 to 3147276 Compare November 11, 2026 16:46
@anton-107
anton-107 force-pushed the aitools/install-hint-telemetry branch from 673bfd5 to e1c80b3 Compare November 11, 2026 16:56
@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch from 3147276 to 920dbc0 Compare November 11, 2026 16:56
@anton-107
anton-107 force-pushed the aitools/install-hint-telemetry branch from e1c80b3 to 71b4c1a Compare November 11, 2026 17:08
@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch 2 times, most recently from 95aeec8 to e503a62 Compare November 11, 2026 17:10
@anton-107

Copy link
Copy Markdown
Contributor Author

Thanks for the review. The inline threads have replies; here are the two review-level points.

Question 1 (resolving in Upload or root instead): I'd keep the shadow-command approach in cmd/aitools for now:

  • It overlaps auth resolution with the install. Resolving in Upload would serialize it after the install at exit, so a slow host adds its full latency to every run.
  • It keeps MustAnyClient's behavior: the account-client fallback and --profile handling (including ProfileAuthLoaders precedence), with no second resolution path to keep in sync.
  • It keeps hostmetadata warnings out of the install's output through the shadow's debug-only logger. In Upload they would print at exit unless filtered there too.

Generalizing it into root or libs/telemetry stays possible once a second command needs it, and the no-refresh part (root.SkipTokenRefresh) already lives in root.

PR template nit: this PR is part of a stack whose bodies all use one format (Summary / Evidence / Merge Danger). I'd rather not rewrite all of them, unless you feel strongly about it.

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review from simonfaltum's review agent (pr-review skill) at e503a62. Each finding was verified against the code; simonfaltum may follow up.

Not approving yet: 2 open design questions.

This PR stops aitools install from blocking on an unreachable workspace. Telemetry auth now runs in the background and is waited for at most 3 s, its logs show only with --debug, and it never refreshes an OAuth token. All earlier review comments are addressed, including the abandoned-refresh risk, and CI is green on Linux, macOS and Windows with the new tests running on all three. Two design questions remain: dropping install events for users whose cached U2M token has expired, and whether a bounded synchronous resolution would be simpler. The rest are small doc and test nits.

Questions

  1. With SkipTokenRefresh, a databricks-cli user whose cached access token has expired (these last about an hour) uploads no aitools_install_event. The base branch refreshed in PreRunE and uploaded it. Since aitools install often runs on its own, how large and how skewed do you expect that share to be? Would you accept it and state the expected share under Merge Danger, or authenticate with refresh on the main goroutine in apply once the background resolution has resolved the host, so a reachable host costs one token request at exit?
  2. Your earlier reply explains why you prefer the shadow command over resolving in Upload. Did you also consider keeping the synchronous PreRunE resolution but bounding it with the SDK's HTTPTimeoutSeconds and RetryTimeoutSeconds, as cmd/auth/profiles.go already does for unreachable hosts? The host-metadata fetch goes through the SDK client that honors both. That would keep normal token refresh, and so the expired-token events, and remove the goroutine, shadow command, debug-only handler and the new no-refresh mode. The costs are that auth no longer overlaps with the install and the hostmetadata warning stays visible. Which trade-off do you prefer?

Comments outside the changed lines

  • [nit] cmd/aitools/telemetry.go: PR description: Evidence says "Tests at head, all pass (Linux only)", and Merge Danger lists "Untested: Windows and macOS (the 3 s timing, goroutines at process exit)". CI's task test (macos, direct) and task test (windows, direct) jobs ran and passed TestTelemetryAuth*, TestToken_WithoutRefresh, TestAccept/cmd/aitools/install-host-metadata and TestAccept/cmd/aitools/telemetry/install. Please update both sections so reviewers don't count a risk that CI already partly covers.

Agreeing with existing threads

  • [nit] PR body does not follow .github/PULL_REQUEST_TEMPLATE.md: #7468 (review)

Comment thread cmd/root/auth_options.go Outdated
return ok && skipLoadBundle
}

type skipTokenRefresh int

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] type skipTokenRefresh int goes against .agents/rules/style-guide-go.md, which says to use empty struct types for context keys and lists type myKeyType int as BAD. The neighbouring skipPrompt and skipLoadBundle keys use the old pattern, so this only affects the new key. Suggest type skipTokenRefreshKey struct{} with context.WithValue(ctx, skipTokenRefreshKey{}, true).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to type skipTokenRefreshKey struct{} in 76d9ee3. The neighbouring keys are left as they are.

Comment thread cmd/root/auth_options.go Outdated
Comment on lines +51 to +56
// SkipTokenRefresh makes [MustWorkspaceClient] and [MustAccountClient] use a
// cached OAuth token only while it is valid and fail instead of refreshing it.
// It is for best-effort auth that the command may abandon at exit, where an
// unfinished refresh could lose the server's rotated single-use refresh token.
// It does not reach a bundle's workspace client; combine it with
// [SkipLoadBundle].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The doc says SkipTokenRefresh makes MustWorkspaceClient and MustAccountClient fail instead of refreshing, and names only the bundle exception. The interactive profile pickers build a fresh databricks.Config{Profile: profile} without CredentialsWithoutRefresh: workspaceClientOrPrompt at cmd/root/auth.go:352 and accountClientOrPrompt at :170. A caller that sets SkipTokenRefresh without SkipPrompt can therefore still refresh, and lose the rotated refresh token, on an interactive terminal. aitools sets SkipPrompt, so nothing is wrong today. Please say in the doc that it must also be combined with SkipPrompt, or apply the no-refresh credentials to the picked-profile config.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 76d9ee3. The doc now says SkipTokenRefresh applies only to the config that MustWorkspaceClient/MustAccountClient build, that a bundle's workspace client and an interactively picked profile still refresh, and that callers must combine it with SkipLoadBundle and SkipPrompt. aitools sets both.

Comment thread libs/auth/credentials.go
Comment on lines +53 to +56
// CredentialsWithoutRefresh returns the CLI credentials chain with a
// databricks-cli strategy that uses the cached OAuth token only while it is
// valid and never refreshes it (see [u2m.WithoutRefresh]). For best-effort
// auth that the command may abandon at exit, such as telemetry's.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] With no auth_type (for example DATABRICKS_HOST from the environment, or a hand-written profile), the SDK chain ignores each strategy's dry-run error and tries the next one (credentialsChain.Configure). An expired cached U2M token now fails the databricks-cli dry run with ErrRefreshDisabled, so the chain moves on to the metadata-service, OIDC, Azure and Google strategies. For example, on an Azure host with az logged in, the background goroutine runs az account get-access-token and uploads telemetry as the az identity. The default chain would have refreshed and stopped at databricks-cli. That is acceptable for best-effort telemetry, but please mention it in this doc comment and in Merge Danger so later callers of CredentialsWithoutRefresh know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented in 76d9ee3 on CredentialsWithoutRefresh. With auth_type = databricks-cli an expired token fails auth. Without an auth_type, the chain skips the failed dry run and moves on to the metadata-service, OIDC, Azure and Google strategies, and the first one that succeeds is used (e.g. az on an Azure host). The default chain would have refreshed and stopped at databricks-cli. The same behavior is now a Merge Danger bullet.

Comment thread libs/auth/credentials_test.go Outdated
require.NoError(t, err)

// WithOAuthArgument, WithTokenStore and WithoutRefresh.
assert.Len(t, receivedOpts, 3)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] assert.Len(t, receivedOpts, 3) still passes if the noRefresh branch appended a different option, or if a refactor reordered the list, so the no-refresh behavior could disappear without this test failing. One way to test the behavior instead: pass receivedOpts plus a u2m.WithTokenStore holding an expired token to u2m.NewPersistentAuth, and assert that Token() returns u2m.ErrRefreshDisabled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strengthened in 76d9ee3. The test now passes the options Configure returned, plus a u2m.WithTokenStore holding an expired token, to u2m.NewPersistentAuth, and asserts that Token() returns u2m.ErrRefreshDisabled. Replacing WithoutRefresh with another option in Configure makes it fail.

@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch from e503a62 to 76d9ee3 Compare November 12, 2026 00:05
@anton-107

Copy link
Copy Markdown
Contributor Author

Thanks. Both questions are answered below; the inline nits are fixed in 76d9ee3.

Question 1 (expired cached token uploads no event): I accept that. An install whose cached databricks-cli access token has expired now uploads no install event, and Merge Danger says so. Not refreshing at exit is on purpose: this PR removes exactly that refresh, because a refresh the exit can cut short is what can strand a rotated refresh token. Refreshing on the main goroutine in apply would bring back that refresh during the exit path, and the next command that authenticates refreshes the token anyway.

Question 2 (bounded synchronous PreRunE): I'd keep the background resolution. Bounding PreRunE with HTTPTimeoutSeconds/RetryTimeoutSeconds still adds that bounded wait to every install before any work starts, and it keeps the hostmetadata warning in the install's output. The current approach overlaps auth with the install, so a run that takes longer than the budget waits for nothing at the end.

Outside-diff nit: updated. Evidence now says the tests pass locally on Linux and in CI on Linux, macOS and Windows, and the "Untested" bullet now says that only the Evidence timings were measured on Linux alone.

@anton-107
anton-107 force-pushed the aitools/install-hint-telemetry branch from 71b4c1a to 29d7bbc Compare November 12, 2026 00:15
@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch from 76d9ee3 to 7e03112 Compare November 12, 2026 00:15

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review from simonfaltum's review agent (pr-review skill) at 7e03112. Each finding was verified against the code; simonfaltum may follow up.

Not approving yet: 2 open design questions.

This PR stops aitools install from blocking on an unreachable workspace. Telemetry auth now resolves in the background with a 3 s cap, never refreshes an OAuth token, and logs only at debug level. Every earlier review thread is addressed at this head, the new tests pass under the race detector, and CI is green on Linux, macOS and Windows. What's left is non-blocking: drop the PreRunE/RunE split, fix a test that calls t.Fatal on a background goroutine, close a pre-existing upload gap in the deprecated skills install alias, and three test-convention nits. There are also two follow-up questions on design trade-offs you've already accepted.

Questions

  1. You've accepted that an install whose cached databricks-cli access token has expired uploads no install event. Databricks OAuth access tokens are short-lived and aitools install often runs on its own, so the loss may skew install metrics toward PAT users and people who ran another CLI command recently. Do you have a rough sense of that share? Would you add a note under Merge Danger that metrics built on this event undercount those users, or is leaving it unquantified fine for now?
  2. Your replies explain why you kept the background resolution rather than resolving in Upload or bounding a synchronous PreRunE with the SDK's timeouts. That reasoning is only in PR comments right now. Would you add a short note on the rejected alternatives to the description so it stays with the change, or is the current Merge Danger section enough?

Comment thread cmd/aitools/telemetry.go
Comment on lines +37 to +48
func withTelemetryAuth(cmd *cobra.Command) {
var pending *pendingAuth
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
pending = startTelemetryAuth(cmd, args)
return nil
}
runE := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
err := runE(cmd, args)
pending.apply(cmd)
return err
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nice to have] withTelemetryAuth starts the resolution in PreRunE, waits for it in the RunE wrapper, and passes pending between them through the closure. Cobra runs only ValidateRequiredFlags/ValidateFlagGroups between the two hooks, so the split gains no overlap. It does overwrite any PreRunE the command has: one added to install later would be silently dropped. It also creates the nil-pending case that TestTelemetryAuthRunEWithoutPreRun exists for. Suggest doing both in the wrapper (p := startTelemetryAuth(cmd, args); err := runE(cmd, args); p.apply(cmd); return err) and dropping the PreRunE assignment and that test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather leave the split in this PR. #7469, stacked on this one, builds on the wrapper's current shape: withTelemetryAuth returns a handle and the exit wait becomes opt-in, and it rewrites exactly these lines. Moving the start into the RunE wrapper here makes #7469 conflict on restack. The behavior is the same either way, since cobra runs only flag validation between the two hooks. This can be folded in on top of the stack in a follow-up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our review found this too: three of our reviewers flagged the replaced PreRunE. One note: #7470 applies this helper to four more commands through instrumentCommand. So a PreRunE that someone later adds to update, uninstall, list or version is dropped with no error. If you keep the split, panic when cmd.PreRunE is already set, so that the conflict fails at once.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I tried the guard: withTelemetryAuth panicking when PreRun or PreRunE is already set. It conflicts with #7469, which rewrites these same lines to return the telemetryAuth handle, so I left it out of this PR. None of the commands that #7470 wraps (install, update, uninstall, list, version) has a PreRun or PreRunE today, so nothing is dropped now. The guard, or moving the start into the RunE wrapper, fits a follow-up on top of the stack, where instrumentCommand applies the helper to all five commands.

Comment thread cmd/aitools/telemetry_auth_test.go Outdated
}

func TestTelemetryAuthRunEWithoutPreRun(t *testing.T) {
setTelemetryAuth(t, time.Second, func(cmd *cobra.Command, args []string) error { t.Fatal("must not resolve"); return nil })

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This resolver calls t.Fatal, but resolvers only run on the background goroutine. If a change made it run, t.Fatal would fire off the test goroutine, probably after the test returned, since RunE does not wait when pending is nil. The test would then pass, or panic with "Fail in goroutine after TestTelemetryAuthRunEWithoutPreRun has completed", instead of failing cleanly. Record an atomic.Bool in the resolver and assert it on the test goroutine, as TestTelemetryAuthSkippedWhenTelemetryDisabled does with called. This goes away if the PreRunE split is removed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d25f879. The resolver now records the call on a buffered channel, and the test asserts on the test goroutine (in a t.Cleanup) that nothing was recorded, so a stray call fails the test cleanly instead of calling t.Fatal off-goroutine.

Comment thread cmd/aitools/telemetry.go
return
}
if p.cfg != nil {
cmd.SetContext(cmdctx.SetConfigUsed(cmd.Context(), p.cfg))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This existed before this PR, but it shows here: apply puts the config on the context of the command it wraps. The deprecated experimental aitools skills install alias builds its own NewInstallCmd() and runs installCmd.Execute() (cmd/aitools/legacy_skills.go:45-58), while root uploads from the alias command's context. So the alias always ends with "no auth config available; skipping telemetry upload", and on an unreachable host it first waits up to 3 s for a config it then drops. A one-line fix in the alias: err := installCmd.Execute(); cmd.SetContext(installCmd.Context()); return err.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d25f879. After installCmd.Execute(), the alias now copies installCmd.Context() back onto its own command, so the config resolved for telemetry reaches root's upload. TestLegacySkillsInstallKeepsTelemetryAuthConfig covers it, and it fails without the fix.

Comment thread cmd/root/auth_options.go
// It applies to that config only. A bundle's workspace client and the config
// of a profile picked interactively still refresh, so combine it with
// [SkipLoadBundle] and [SkipPrompt].
func SkipTokenRefresh(ctx context.Context) context.Context {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] SkipTokenRefresh/shouldSkipTokenRefresh have no test, while SkipPrompt and SkipLoadBundle each have one in cmd/root/auth_options_test.go. .agents/rules/testing.md asks to extend the test file when adding functionality. Add a TestSkipTokenRefresh that mirrors the two existing tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestSkipTokenRefresh in cmd/root/auth_options_test.go in d25f879, mirroring the two existing tests.

write_token_cache expired-access-token 2000-01-01T00:00:00Z
trace $CLI aitools install --skills-only --scope=global --agents=claude-code --profile test-profile
trace print_requests.py //oidc/v1/token //telemetry-ext
trace jq -r '.tokens["test-profile"].access_token' "$HOME/.databricks/token-cache.json"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] .agents/rules/testing.md prefers gron.py | grep <field> over inline jq paths for single-value lookups, because the gron line names the path in output.txt. Suggest trace gron.py "$HOME/.databricks/token-cache.json" | grep access_token, then regenerate the output with -update.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to trace gron.py "$HOME/.databricks/token-cache.json" | grep access_token and regenerated the output in d25f879.

@@ -0,0 +1,217 @@
package aitools

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The tests for the new code in cmd/aitools/telemetry.go live in a new telemetry_auth_test.go with no matching source file. .agents/rules/testing.md asks for one test file per source file, extended as the source grows. Either move the auth code (telemetryAuthBudget through debugOnlyHandler) into cmd/aitools/telemetry_auth.go, which keeps telemetry.go about building events, or move these tests into telemetry_test.go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather not move them in this PR. #7469 and #7470, stacked on this one, extend both the auth code in telemetry.go and telemetry_auth_test.go, so moving either here makes both PRs conflict on restack. It's a pure move, so it fits better as a follow-up on top of the stack (e.g. the auth code into telemetry_auth.go, keeping telemetry.go about building events).

@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch from 7e03112 to d25f879 Compare November 12, 2026 10:14
@anton-107

Copy link
Copy Markdown
Contributor Author

Thanks. Answers to the two questions are below; the inline threads have replies, with fixes in d25f879.

Question 1: I'm fine leaving it unquantified. Merge Danger already says that an install whose cached databricks-cli access token has expired uploads no install event.

Question 2: added. The description now has a short paragraph on the rejected alternatives (resolving in Upload; bounding a synchronous PreRunE with the SDK timeouts) and why.

@anton-107
anton-107 force-pushed the aitools/install-hint-telemetry branch from 29d7bbc to 2c02b39 Compare November 12, 2026 10:23
@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch from d25f879 to fa4413b Compare November 12, 2026 10:23

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Approval from simonfaltum's review agent at fa4413b, on Simon's decision.

The design questions from the earlier automated reviews no longer block this PR: your answers are accepted, and any still-open ones are non-blocking. The changes since the last automated review (7e03112 → fa4413b) address its comments.

@rugpanov rugpanov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also found by our reviewers, already asked in simonfaltum's review (Question 1): an install whose cached databricks-cli token has expired uploads no install event. Our note: our devil's-advocate reviewer built the alternative that passes the unresolved config to Upload (about 22 lines). It reported the same exit time as this PR with the PR's own repro (3.37 s and 3.38 s against a host that refuses the connection), and it keeps the install event when the cached token has expired.

# Isolate HOME so the real machine's agents and skills are not seen.
sethome home
# Agent detection needs ~/.claude; prefer USERPROFILE on Windows.
mkdir -p "${USERPROFILE:-$HOME}/.claude"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] .agents/rules/testing.md says: "Commit static test inputs into the acceptance test directory; do not create them in script at test time." #7467 fixed the same pattern in apps-init-tip with a committed home/.claude/.gitkeep. This script and acceptance/cmd/aitools/telemetry/install/script:4 still run mkdir -p "${USERPROFILE:-$HOME}/.claude". Commit home/.claude/.gitkeep in both directories and remove the mkdir.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 03fa288. Both install-host-metadata and telemetry/install now commit home/.claude/.gitkeep, and the mkdir is gone from their scripts.

title "--debug still shows the hostmetadata line, at debug level"
# A fresh cache, so the first run's negative-cache entry does not skip the fetch.
export DATABRICKS_CACHE_DIR="$PWD/debug-cache"
$CLI aitools install --skills-only --scope=global --agents=claude-code --debug 2>&1 | grep -o 'Debug: \[hostmetadata\] failed to fetch host metadata for [^,]*'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] .agents/rules/testing.md asks to use the acceptance/bin/ helpers before inline grep or jq pipelines. This line pipes the --debug run through grep -o without trace. Use trace $CLI ... --debug &> LOG.debug, and then contains.py 'failed to fetch host metadata' < LOG.debug.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 03fa288. The --debug run is now traced into LOG.debug, and contains.py checks it for Debug: [hostmetadata] failed to fetch host metadata for https://unreachable.test and for no Warn: [hostmetadata] line. I redirect its stdout to /dev/null because contains.py echoes its input, and the full debug log has timestamps and a pid. If the line is missing or still at Warn, the test fails with Exit code: 1; I checked this by inverting the assertion.

@anton-107
anton-107 force-pushed the aitools/install-hint-telemetry branch 2 times, most recently from 19f3090 to 96084b5 Compare November 12, 2026 12:30
@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch 2 times, most recently from 0b368c6 to 03fa288 Compare November 12, 2026 12:33
@anton-107

Copy link
Copy Markdown
Contributor Author

Re the review summary (expired cached token): see my answer to Question 1 in simonfaltum's review (#7468 (comment)); Merge Danger states the trade-off. Passing the unresolved config to Upload keeps the event because Upload then refreshes the token, but under Upload's own timeout. A refresh still running when that timeout expires is abandoned at exit, so it can strand a rotated refresh token. Avoiding that is the point of this PR. The exit times match because both paths are bounded by about the same 3 s.

@anton-107
anton-107 changed the base branch from aitools/install-hint-telemetry to main November 12, 2026 16:22
`aitools install` resolved auth for telemetry in PreRunE, before doing any work, so an unreachable workspace blocked the install for as long as the SDK retried. It now resolves auth in the background while the install runs and waits for it at most 3 s after the command started. Nothing is resolved when `DATABRICKS_CLI_DISABLE_TELEMETRY` is set (new `telemetry.Disabled`), and a run that logged no event does not wait.

```diff
 aitools install
-  PreRunE: tryConfigureAuth
-    MustAnyClient(cmd)              # blocks while the SDK retries host metadata
-  RunE: install skills
+  PreRunE: startTelemetryAuth       # returns at once
+    if telemetry.Disabled → skip
+    go MustAnyClient(shadow cmd)    # shares cmd's flags (--profile), not its context
+                                    # SkipTokenRefresh: never refreshes an OAuth token
+  RunE wrapper
+    runE: install skills            # runs while auth resolves
+    pending.apply(cmd)
+      no event logged → return
+      wait until start + 3 s, at most
+      succeeded → SetConfigUsed(cmd ctx)
+      failed or not finished → no config, no upload
 root: telemetry.Upload
```

The background resolution never refreshes an OAuth token. The process may exit before it finishes, and the server rotates the single-use refresh token, so an abandoned refresh could lose the new one and log the user out. The new `root.SkipTokenRefresh` makes `MustWorkspaceClient` and `MustAccountClient` use `auth.CredentialsWithoutRefresh`, whose databricks-cli strategy passes the new `u2m.WithoutRefresh`: the cached token is used only while it is valid, and an expired one fails auth, so nothing is uploaded.

The shadow command logs through `debugOnlyHandler`, so a background warning such as `[hostmetadata] failed to fetch host metadata ...` shows only with `--debug` and does not interrupt the install's output or prompts. This relies on root's host-metadata resolver logging through the command's logger (#7390). A panic in the background resolution is recovered and logged at debug level; the install does not fail.

Changelog fragment: `.nextchanges/cli/aitools-install-telemetry-auth.md`.

Co-authored-by: Isaac <no-reply@databricks.com>
@anton-107
anton-107 force-pushed the aitools/bound-telemetry-auth branch from 03fa288 to d13de92 Compare November 12, 2026 16:33
@anton-107
anton-107 added this pull request to the merge queue Nov 12, 2026
Merged via the queue into main with commit e11d32c Nov 12, 2026
29 checks passed
@anton-107
anton-107 deleted the aitools/bound-telemetry-auth branch November 12, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-auto PRs made by AI loops & factories,

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants