diff --git a/cmd/aitools/install.go b/cmd/aitools/install.go index eca459e14e2..033611da8bc 100644 --- a/cmd/aitools/install.go +++ b/cmd/aitools/install.go @@ -59,8 +59,10 @@ func NewInstallCmd() *cobra.Command { var projectFlag, globalFlag bool cmd := &cobra.Command{ - Use: "install", - Short: "Install Databricks skills and plugins for coding agents", + Use: "install", + // Resolve auth best-effort so telemetry can upload; see tryConfigureAuth. + PreRunE: tryConfigureAuth, + Short: "Install Databricks skills and plugins for coding agents", Long: `Install Databricks skills and plugins for detected coding agents. By default this installs the databricks plugin through each agent's own CLI @@ -150,6 +152,11 @@ Supported agents: Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Anti } } + defer logInstallEvent(ctx, plan, installOpts{ + Scope: opts.Scope, + Experimental: opts.IncludeExperimental, + }) + return executePlan(ctx, src, plan, opts) }, } diff --git a/cmd/aitools/install_test.go b/cmd/aitools/install_test.go index bd3394eae37..ec70e36772f 100644 --- a/cmd/aitools/install_test.go +++ b/cmd/aitools/install_test.go @@ -12,6 +12,7 @@ import ( "github.com/databricks/cli/libs/aitools/agents" "github.com/databricks/cli/libs/aitools/installer" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/telemetry" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -238,7 +239,7 @@ func TestInstallSkillsOnlyAllAgents(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--skills-only"}) @@ -253,7 +254,7 @@ func TestInstallSkillsOnlySpecificSkills(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--skills-only", "--skills", "databricks,databricks-apps"}) @@ -267,7 +268,7 @@ func TestInstallSkillsOnlyExperimental(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--skills-only", "--experimental"}) @@ -288,7 +289,7 @@ func TestInstallPluginFirstDefault(t *testing.T) { ctx, stderr := cmdio.NewTestContextWithStderr(t.Context()) cmd := NewInstallCmd() - cmd.SetContext(ctx) + cmd.SetContext(telemetry.WithNewLogger(ctx)) require.NoError(t, cmd.Execute()) require.Len(t, *plugins, 1) @@ -327,7 +328,7 @@ func TestInstallInteractivePickerAndConfirm(t *testing.T) { go drainReader(test.Stderr) cmd := NewInstallCmd() - cmd.SetContext(ctx) + cmd.SetContext(telemetry.WithNewLogger(ctx)) errc := make(chan error, 1) go func() { errc <- cmd.RunE(cmd, nil) }() @@ -350,7 +351,7 @@ func TestInstallExplicitAgentWorksUndetected(t *testing.T) { t.Setenv("DATABRICKS_SKILLS_REF", "v0.2.6") plugins := setupPluginMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--agents", "codex"}) @@ -463,7 +464,7 @@ func TestInstallScopeFlag(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs(tt.args) @@ -503,7 +504,7 @@ func TestInstallNoFlagNonInteractiveUsesGlobal(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := NewInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--skills-only"}) diff --git a/cmd/aitools/legacy_skills_test.go b/cmd/aitools/legacy_skills_test.go index 250d910960b..3f6098f5e90 100644 --- a/cmd/aitools/legacy_skills_test.go +++ b/cmd/aitools/legacy_skills_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/telemetry" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -13,7 +14,7 @@ func TestLegacySkillsInstallDelegatesToInstall(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := newLegacySkillsInstallCmd() cmd.SetContext(ctx) @@ -28,7 +29,7 @@ func TestLegacySkillsInstallForwardsSkillName(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := newLegacySkillsInstallCmd() cmd.SetContext(ctx) @@ -43,7 +44,7 @@ func TestLegacySkillsInstallExecuteNoArgs(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := newLegacySkillsInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{}) @@ -60,7 +61,7 @@ func TestLegacySkillsInstallExecuteWithSkillName(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := newLegacySkillsInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"databricks"}) @@ -76,7 +77,7 @@ func TestLegacySkillsInstallForwardsExperimental(t *testing.T) { setupTestAgents(t) calls := setupInstallMock(t) - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := newLegacySkillsInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"--experimental"}) @@ -89,7 +90,7 @@ func TestLegacySkillsInstallForwardsExperimental(t *testing.T) { } func TestLegacySkillsInstallExecuteRejectsTwoArgs(t *testing.T) { - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := newLegacySkillsInstallCmd() cmd.SetContext(ctx) cmd.SetArgs([]string{"a", "b"}) @@ -111,7 +112,7 @@ func TestLegacySkillsListDelegatesToListFn(t *testing.T) { return nil } - ctx := cmdio.MockDiscard(t.Context()) + ctx := telemetry.WithNewLogger(cmdio.MockDiscard(t.Context())) cmd := newLegacySkillsListCmd() cmd.SetContext(ctx) diff --git a/cmd/aitools/telemetry.go b/cmd/aitools/telemetry.go new file mode 100644 index 00000000000..38c2166a4c5 --- /dev/null +++ b/cmd/aitools/telemetry.go @@ -0,0 +1,62 @@ +package aitools + +import ( + "context" + "slices" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/telemetry" + "github.com/databricks/cli/libs/telemetry/protos" + "github.com/spf13/cobra" +) + +// tryConfigureAuth resolves auth config onto the context so telemetry can +// upload at command exit. It does not fail if auth is not configured. +func tryConfigureAuth(cmd *cobra.Command, args []string) error { + ctx := root.SkipPrompt(cmd.Context()) + ctx = root.SkipLoadBundle(ctx) + cmd.SetContext(ctx) + _, _ = root.MustAnyClient(cmd, args) + return nil +} + +// installOpts is the subset of install options telemetry records, kept narrow +// so every field that leaves the machine is visible here. +type installOpts struct { + Scope string + Experimental bool +} + +// logInstallEvent buffers an install event; cmd/root uploads it at exit. +func logInstallEvent(ctx context.Context, plan []agentPlanItem, opts installOpts) { + telemetry.Log(ctx, protos.DatabricksCliLog{ + AitoolsInstallEvent: &protos.AitoolsInstallEvent{ + Agents: agentsField(plan), + Scope: opts.Scope, + Experimental: opts.Experimental, + }, + }) +} + +// agentsField returns the deduped agent names from the plan, sorted so the +// same set of agents always produces the same array on the analytics side. +func agentsField(plan []agentPlanItem) []string { + if len(plan) == 0 { + return nil + } + names := make([]string, 0, len(plan)) + seen := make(map[string]struct{}, len(plan)) + for _, it := range plan { + if it.agent == nil { + continue + } + name := it.agent.Name + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + names = append(names, name) + } + slices.Sort(names) + return names +} diff --git a/cmd/aitools/telemetry_test.go b/cmd/aitools/telemetry_test.go new file mode 100644 index 00000000000..dd7796074f4 --- /dev/null +++ b/cmd/aitools/telemetry_test.go @@ -0,0 +1,58 @@ +package aitools + +import ( + "testing" + + "github.com/databricks/cli/libs/aitools/agents" + "github.com/stretchr/testify/assert" +) + +func TestAgentsField(t *testing.T) { + cases := []struct { + name string + plan []agentPlanItem + want []string + }{ + { + name: "empty plan", + plan: nil, + want: nil, + }, + { + name: "single agent", + plan: []agentPlanItem{{agent: &agents.Agent{Name: agents.NameClaudeCode}}}, + want: []string{"claude-code"}, + }, + { + name: "multiple agents are sorted", + plan: []agentPlanItem{ + {agent: &agents.Agent{Name: agents.NameCursor}}, + {agent: &agents.Agent{Name: agents.NameClaudeCode}}, + {agent: &agents.Agent{Name: agents.NameCodex}}, + }, + want: []string{"claude-code", "codex", "cursor"}, + }, + { + name: "duplicates are deduplicated", + plan: []agentPlanItem{ + {agent: &agents.Agent{Name: agents.NameClaudeCode}}, + {agent: &agents.Agent{Name: agents.NameClaudeCode}}, + }, + want: []string{"claude-code"}, + }, + { + name: "nil agent entries are skipped", + plan: []agentPlanItem{ + {agent: nil}, + {agent: &agents.Agent{Name: agents.NameCursor}}, + }, + want: []string{"cursor"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := agentsField(tc.plan) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/libs/telemetry/protos/aitools_install.go b/libs/telemetry/protos/aitools_install.go new file mode 100644 index 00000000000..12ab606a75a --- /dev/null +++ b/libs/telemetry/protos/aitools_install.go @@ -0,0 +1,19 @@ +package protos + +// AitoolsInstallEvent is emitted on every execution of the `databricks aitools +// install` command. Every field is drawn from a closed set defined in the +// CLI, so the event carries no free text and no user data. +type AitoolsInstallEvent struct { + // Agent names the user targeted, deduplicated and sorted so the array is + // stable across invocations. Values come from the CLI's closed agent + // registry: claude-code, cursor, codex, opencode, github-copilot, + // antigravity. + Agents []string `json:"agents,omitempty"` + + // Install scope: "project" (installed under the current working directory) + // or "global" (installed for the whole user). + Scope string `json:"scope,omitempty"` + + // Whether the user passed --experimental to include experimental skills. + Experimental bool `json:"experimental,omitempty"` +} diff --git a/libs/telemetry/protos/frontend_log.go b/libs/telemetry/protos/frontend_log.go index 676f72055da..b642e9db97a 100644 --- a/libs/telemetry/protos/frontend_log.go +++ b/libs/telemetry/protos/frontend_log.go @@ -21,4 +21,5 @@ type DatabricksCliLog struct { BundleDeployEvent *BundleDeployEvent `json:"bundle_deploy_event,omitempty"` SshTunnelEvent *SshTunnelEvent `json:"ssh_tunnel_event,omitempty"` BundleConfigRemoteSyncEvent *BundleConfigRemoteSyncEvent `json:"bundle_config_remote_sync_event,omitempty"` + AitoolsInstallEvent *AitoolsInstallEvent `json:"aitools_install_event,omitempty"` }