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..5d4293f5818 --- /dev/null +++ b/cmd/aitools/telemetry.go @@ -0,0 +1,99 @@ +package aitools + +import ( + "context" + "slices" + + "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/aitools/agents" + "github.com/databricks/cli/libs/aitools/installer" + "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: scopeType(opts.Scope), + Experimental: opts.Experimental, + }, + }) +} + +// agentsField returns the deduped agent enums from the plan, sorted so the +// same set of agents always produces the same array on the analytics side. +func agentsField(plan []agentPlanItem) []protos.AitoolsAgentType { + if len(plan) == 0 { + return nil + } + out := make([]protos.AitoolsAgentType, 0, len(plan)) + seen := make(map[protos.AitoolsAgentType]struct{}, len(plan)) + for _, it := range plan { + if it.agent == nil { + continue + } + t := agentType(it.agent.Name) + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + out = append(out, t) + } + slices.Sort(out) + return out +} + +// agentType maps a CLI agent registry name to its telemetry enum. Every agent +// in agents.Registry must have a case here; TestAgentTypeCoversRegistry fails +// if one is missing. The default maps genuinely unknown names to Unspecified +// so an older proto never drops an install. +func agentType(name string) protos.AitoolsAgentType { + switch name { + case agents.NameClaudeCode: + return protos.AitoolsAgentTypeClaudeCode + case agents.NameCursor: + return protos.AitoolsAgentTypeCursor + case agents.NameCodex: + return protos.AitoolsAgentTypeCodex + case agents.NameOpenCode: + return protos.AitoolsAgentTypeOpenCode + case agents.NameCopilot: + return protos.AitoolsAgentTypeCopilot + case agents.NameAntigravity: + return protos.AitoolsAgentTypeAntigravity + default: + return protos.AitoolsAgentTypeUnspecified + } +} + +// scopeType maps a resolved install scope to its telemetry enum. +func scopeType(scope string) protos.AitoolsInstallScope { + switch scope { + case installer.ScopeGlobal: + return protos.AitoolsInstallScopeGlobal + case installer.ScopeProject: + return protos.AitoolsInstallScopeProject + default: + return protos.AitoolsInstallScopeUnspecified + } +} diff --git a/cmd/aitools/telemetry_test.go b/cmd/aitools/telemetry_test.go new file mode 100644 index 00000000000..85705161955 --- /dev/null +++ b/cmd/aitools/telemetry_test.go @@ -0,0 +1,86 @@ +package aitools + +import ( + "testing" + + "github.com/databricks/cli/libs/aitools/agents" + "github.com/databricks/cli/libs/aitools/installer" + "github.com/databricks/cli/libs/telemetry/protos" + "github.com/stretchr/testify/assert" +) + +func TestAgentsField(t *testing.T) { + cases := []struct { + name string + plan []agentPlanItem + want []protos.AitoolsAgentType + }{ + { + name: "empty plan", + plan: nil, + want: nil, + }, + { + name: "single agent", + plan: []agentPlanItem{{agent: &agents.Agent{Name: agents.NameClaudeCode}}}, + want: []protos.AitoolsAgentType{protos.AitoolsAgentTypeClaudeCode}, + }, + { + 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: []protos.AitoolsAgentType{ + protos.AitoolsAgentTypeClaudeCode, + protos.AitoolsAgentTypeCodex, + protos.AitoolsAgentTypeCursor, + }, + }, + { + name: "duplicates are deduplicated", + plan: []agentPlanItem{ + {agent: &agents.Agent{Name: agents.NameClaudeCode}}, + {agent: &agents.Agent{Name: agents.NameClaudeCode}}, + }, + want: []protos.AitoolsAgentType{protos.AitoolsAgentTypeClaudeCode}, + }, + { + name: "nil agent entries are skipped", + plan: []agentPlanItem{ + {agent: nil}, + {agent: &agents.Agent{Name: agents.NameCursor}}, + }, + want: []protos.AitoolsAgentType{protos.AitoolsAgentTypeCursor}, + }, + { + name: "unknown agent maps to unspecified", + plan: []agentPlanItem{{agent: &agents.Agent{Name: "future-agent"}}}, + want: []protos.AitoolsAgentType{protos.AitoolsAgentTypeUnspecified}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := agentsField(tc.plan) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestAgentTypeCoversRegistry fails when an agent is added to agents.Registry +// without a matching telemetry enum in agentType. This is the guard against +// silently logging a new agent as UNSPECIFIED. +func TestAgentTypeCoversRegistry(t *testing.T) { + for _, a := range agents.Registry { + assert.NotEqualf(t, protos.AitoolsAgentTypeUnspecified, agentType(a.Name), + "agent %q has no telemetry enum: add a case to agentType and a value to "+ + "AitoolsAgentType in enum.proto (Universe) + aitools_install.go (CLI)", a.Name) + } +} + +func TestScopeType(t *testing.T) { + assert.Equal(t, protos.AitoolsInstallScopeGlobal, scopeType(installer.ScopeGlobal)) + assert.Equal(t, protos.AitoolsInstallScopeProject, scopeType(installer.ScopeProject)) + assert.Equal(t, protos.AitoolsInstallScopeUnspecified, scopeType("")) +} diff --git a/libs/telemetry/protos/aitools_install.go b/libs/telemetry/protos/aitools_install.go new file mode 100644 index 00000000000..ea78dc532f8 --- /dev/null +++ b/libs/telemetry/protos/aitools_install.go @@ -0,0 +1,39 @@ +package protos + +// AitoolsAgentType mirrors AitoolsAgentType.Type in the databricks_cli +// lumberjack proto. AitoolsAgentTypeUnspecified absorbs agents a newer CLI +// knows about but the server proto does not yet. +type AitoolsAgentType string + +const ( + AitoolsAgentTypeUnspecified AitoolsAgentType = "TYPE_UNSPECIFIED" + AitoolsAgentTypeClaudeCode AitoolsAgentType = "CLAUDE_CODE" + AitoolsAgentTypeCursor AitoolsAgentType = "CURSOR" + AitoolsAgentTypeCodex AitoolsAgentType = "CODEX" + AitoolsAgentTypeOpenCode AitoolsAgentType = "OPENCODE" + AitoolsAgentTypeCopilot AitoolsAgentType = "COPILOT" + AitoolsAgentTypeAntigravity AitoolsAgentType = "ANTIGRAVITY" +) + +// AitoolsInstallScope mirrors AitoolsInstallScope.Type in the databricks_cli +// lumberjack proto. +type AitoolsInstallScope string + +const ( + AitoolsInstallScopeUnspecified AitoolsInstallScope = "TYPE_UNSPECIFIED" + AitoolsInstallScopeGlobal AitoolsInstallScope = "GLOBAL" + AitoolsInstallScopeProject AitoolsInstallScope = "PROJECT" +) + +// AitoolsInstallEvent is emitted on every execution of the `databricks aitools +// install` command. +type AitoolsInstallEvent struct { + // Agents the user targeted, deduplicated and sorted for stable output. + Agents []AitoolsAgentType `json:"agents,omitempty"` + + // Scope the skills were installed into. + Scope AitoolsInstallScope `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"` }