diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 5dbea27b345..113002ec436 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -453,43 +453,7 @@ func newRootCmd( } } - // Global middleware registration - root. - UseMiddleware("debug", middleware.NewDebugMiddleware). - UseMiddleware("ux", middleware.NewUxMiddleware). - UseMiddlewareWhen("telemetry", middleware.NewTelemetryMiddleware, func(descriptor *actions.ActionDescriptor) bool { - return !descriptor.Options.DisableTelemetry - }). - UseMiddlewareWhen("error", middleware.NewErrorMiddleware, func(descriptor *actions.ActionDescriptor) bool { - return !descriptor.Options.DisableTroubleshooting - }). - UseMiddlewareWhen("loginGuard", middleware.NewLoginGuardMiddleware, func(descriptor *actions.ActionDescriptor) bool { - // Check if the command or any of its parents require login - current := descriptor - for current != nil { - if current.Options != nil && current.Options.RequireLogin { - return true - } - - current = current.Parent() - } - - return false - }). - UseMiddlewareWhen( - "toolFirstRun", - middleware.NewToolFirstRunMiddleware, - func(descriptor *actions.ActionDescriptor) bool { - return isWorkflowCommand(descriptor) - }, - ). - UseMiddlewareWhen( - "toolUpdateCheck", - middleware.NewToolUpdateCheckMiddleware, - func(descriptor *actions.ActionDescriptor) bool { - return isWorkflowCommand(descriptor) - }, - ) + registerGlobalMiddleware(root) ioc.RegisterNamedInstance(rootContainer, "root-cmd", rootCmd) @@ -564,40 +528,29 @@ func newRootCmd( return cmd } -// workflowCommands lists root-level commands where the first-run tool -// check and update notifications add value. Utility commands (auth, -// config, env, extension, etc.) are excluded to avoid blocking and to -// prevent recursive subprocess issues when the tool check spawns -// `azd extension list` (#8052). -var workflowCommands = map[string]struct{}{ - "init": {}, - "up": {}, - "provision": {}, - "deploy": {}, - "down": {}, - "publish": {}, - "build": {}, - "package": {}, - "restore": {}, -} - -// isWorkflowCommand reports whether the command is a primary workflow -// command where a first-run tool check adds value. -func isWorkflowCommand(descriptor *actions.ActionDescriptor) bool { - // Walk up to the root-level subcommand (first segment after "azd"). - current := descriptor - for current.Parent() != nil && current.Parent().Parent() != nil { - current = current.Parent() - } - - if current.Options == nil || current.Options.Command == nil { - return false - } +func registerGlobalMiddleware(root *actions.ActionDescriptor) { + root. + UseMiddleware("debug", middleware.NewDebugMiddleware). + UseMiddleware("ux", middleware.NewUxMiddleware). + UseMiddlewareWhen("telemetry", middleware.NewTelemetryMiddleware, func(descriptor *actions.ActionDescriptor) bool { + return !descriptor.Options.DisableTelemetry + }). + UseMiddlewareWhen("error", middleware.NewErrorMiddleware, func(descriptor *actions.ActionDescriptor) bool { + return !descriptor.Options.DisableTroubleshooting + }). + UseMiddlewareWhen("loginGuard", middleware.NewLoginGuardMiddleware, func(descriptor *actions.ActionDescriptor) bool { + // Check if the command or any of its parents require login + current := descriptor + for current != nil { + if current.Options != nil && current.Options.RequireLogin { + return true + } - name := current.Options.Command.Name() + current = current.Parent() + } - _, ok := workflowCommands[name] - return ok + return false + }) } func getCmdRootHelpFooter(cmd *cobra.Command) string { diff --git a/cli/azd/cmd/root_test.go b/cli/azd/cmd/root_test.go index 4db10a0ae0d..4d0eb9d5eea 100644 --- a/cli/azd/cmd/root_test.go +++ b/cli/azd/cmd/root_test.go @@ -8,11 +8,27 @@ import ( "path/filepath" "testing" + "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/stretchr/testify/require" ) +func TestRegisterGlobalMiddleware(t *testing.T) { + root := actions.NewActionDescriptor("azd", &actions.ActionDescriptorOptions{}) + + registerGlobalMiddleware(root) + + names := make([]string, len(root.Middleware())) + for i, registration := range root.Middleware() { + names[i] = registration.Name + } + + require.Equal(t, []string{"debug", "ux", "telemetry", "error", "loginGuard"}, names) + require.NotContains(t, names, "toolFirstRun") + require.NotContains(t, names, "toolUpdateCheck") +} + func TestRootCmd_CwdRelativePathResolvedToAbsolute(t *testing.T) { // Regression test: a relative -C path must be resolved to an absolute path // before os.Chdir so that the AZD_CWD value propagated to extensions doesn't diff --git a/cli/azd/cmd/telemetry_test.go b/cli/azd/cmd/telemetry_test.go index 18328955e78..b8bf78b3251 100644 --- a/cli/azd/cmd/telemetry_test.go +++ b/cli/azd/cmd/telemetry_test.go @@ -290,7 +290,7 @@ func TestCommandTelemetryCoverage(t *testing.T) { "template source add", // Global telemetry sufficient — command name captures operation "template source list", // Global telemetry sufficient — command name captures operation "template source remove", // Global telemetry sufficient — command name captures operation - "tool", // Parent group — first-run middleware telemetry attaches to invoked subcommand + "tool", // Parent group — no operation-specific telemetry "tool list", // Listing tool registry — global telemetry sufficient "version", // Telemetry explicitly disabled (DisableTelemetry: true) "vs-server", // JSON-RPC server — telemetry handled by rpc.* fields per call diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index ef337d5ffcc..eb6cfe1e1a9 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -47,7 +47,7 @@ integration. | `AZD_FORCE_TTY` | If true, forces `azd` to write terminal-style output. | | `AZD_IN_CLOUDSHELL` | If true, `azd` runs with Azure Cloud Shell specific behavior. | | `AZD_SKIP_UPDATE_CHECK` | If true, skips the out-of-date update check output that is typically printed at the end of the command. | -| `AZD_SKIP_FIRST_RUN` | If true, skips the first-run tool setup experience and background tool update checks. Useful for CI/CD pipelines and automated environments. | +| `AZD_SKIP_FIRST_RUN` | Reserved for the dormant first-run tool setup and background update experience. This variable has no effect while those middleware components are not registered. | | `AZD_CONTAINER_RUNTIME` | The container runtime to use (e.g., `docker`, `podman`). | | `AZD_ALLOW_NON_EMPTY_FOLDER` | If set, allows `azd init` to run in a non-empty directory without prompting. | | `AZD_BUILDER_IMAGE` | The builder docker image used to perform Dockerfile-less builds. | diff --git a/cli/azd/docs/tracing-in-azd.md b/cli/azd/docs/tracing-in-azd.md index 1eeb26f5767..f3dde61f29a 100644 --- a/cli/azd/docs/tracing-in-azd.md +++ b/cli/azd/docs/tracing-in-azd.md @@ -207,11 +207,11 @@ These example PRs include adding both new spans and events and can be used as re ## Tool Command Telemetry -The `azd tool` command group emits telemetry that captures both the **first-run experience adoption funnel** and **per-operation outcomes** for install, upgrade, check, and show. All attributes are attached as **usage attributes** via `tracing.SetUsageAttributes`, which means they appear on the user's actual command span (e.g. `cmd.tool.install`) rather than on a separate child span. +The `azd tool` command group emits telemetry that captures **per-operation outcomes** for install, upgrade, check, and show. The first-run telemetry contract remains defined for a possible future experience but is not currently emitted. All active attributes are attached as **usage attributes** via `tracing.SetUsageAttributes`, which means they appear on the user's actual command span (e.g. `cmd.tool.install`) rather than on a separate child span. -### First-Run Experience +### Dormant First-Run Experience -The first-run middleware (`cmd/middleware/tool_first_run.go`) emits attributes once per `azd` invocation when the `tool` alpha feature is enabled and the command is not a child action. +The first-run middleware (`cmd/middleware/tool_first_run.go`) is not registered in the command middleware chain, so the following reserved attributes are not currently emitted. The definitions remain documented to support a future redesign without changing the telemetry contract. | Attribute | Type | Emitted when | Notes | | --- | --- | --- | --- | diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index 47f001122e0..74a8dcebe62 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -464,13 +464,15 @@ Emitted at provision start by the `microsoft.foundry` provisioning provider (the
Tool Management (azd tool) -Fields for the `azd tool` feature — the first-run experience and `install`/`upgrade`/`check` operations for azd-managed developer tools. These are **distinct** from the [Tool Invocation Attributes](#tool-invocation-attributes-external-cli-tools) above (which describe external processes azd shells out to). +Fields for the `azd tool` feature, including active `install`/`upgrade`/`check` operations and the reserved first-run contract for azd-managed developer tools. These are **distinct** from the [Tool Invocation Attributes](#tool-invocation-attributes-external-cli-tools) above (which describe external processes azd shells out to). > **Privacy:** only built-in tool IDs (e.g. `az-cli`, `vscode-bicep`) and version strings are captured. No file paths, no user-identifiable data, and no raw per-tool error text — failed tool IDs are recorded, but error detail stays with the global error middleware. Built-in tool IDs come from azd's curated tool manifest (run `azd tool list` to see the current set), e.g. `az-cli`, `github-copilot-cli`, `vscode-azure-tools`, `vscode-bicep`, `azure-mcp-server`. -**First-run experience:** +**Dormant first-run experience (reserved):** + +The first-run middleware is not currently registered, so these fields are not emitted. They remain reserved for a possible future redesign. | Field Key | Type | Description | |-----------|------|-------------| @@ -704,7 +706,7 @@ How to find telemetry for a given feature area. Start here if you know the featu | **Self-Update** | `cmd.update` | `update.installMethod`, `update.fromVersion` | Update adoption | | **Hooks** | `hooks.exec` | `hooks.name`, `hooks.type`, `hooks.kind` | Hook usage by type | | **Container Build** | `container.publish`, `container.remotebuild`, `tools.pack.build` | `pack.builder.image` | Build method usage, success rates | -| **Tool Management (`azd tool`)** | `cmd.tool.install`, `cmd.tool.upgrade`, `cmd.tool.uninstall`, `cmd.tool.check` | `tool.id`, `tool.install.strategy`, `tool.firstrun.outcome` | First-run adoption, install/upgrade/uninstall success, upgrade availability | +| **Tool Management (`azd tool`)** | `cmd.tool.install`, `cmd.tool.upgrade`, `cmd.tool.uninstall`, `cmd.tool.check` | `tool.id`, `tool.install.strategy` | Install/upgrade/uninstall success, upgrade availability | ## See Also diff --git a/docs/specs/metrics-audit/feature-telemetry-matrix.md b/docs/specs/metrics-audit/feature-telemetry-matrix.md index 5e9fea1358a..024971b2d32 100644 --- a/docs/specs/metrics-audit/feature-telemetry-matrix.md +++ b/docs/specs/metrics-audit/feature-telemetry-matrix.md @@ -124,7 +124,7 @@ command-specific telemetry fields provide analytical value beyond the command na | Pipeline auth | `pipeline.auth` | `pipeline config` | Distinguishes federated vs client-credentials | | Infra provider | `infra.provider` | `infra generate`, `infra synth`, `provision`, `up`, `down` | provision/up/down: sorted, de-duplicated string slice of resolved providers — `bicep`/`terraform`/`arm`/`pulumi` verbatim, `custom` for extension providers (raw name not emitted); multi-layer projects that combine providers record each distinct value (e.g. `["bicep","terraform"]`). `infra generate`/`synth`: the value read from azure.yaml's `infra.provider` emitted directly as a single string (`bicep`/`terraform`/`arm`/`pulumi`, `auto` when unset, or `custom` for extension providers — raw name not emitted) | | Tool ID | `tool.id` / `tool.ids` | `tool *` | Identifies which managed tool (e.g., bicep, gh, kubectl) the command acted on | -| Tool install metrics | `tool.install.*` | `tool install`, `tool upgrade`, `tool uninstall`, first-run middleware | Success count, failure count, duration, strategy — quantitative install health | +| Tool install metrics | `tool.install.*` | `tool install`, `tool upgrade`, `tool uninstall` | Success count, failure count, duration, strategy — quantitative install health | | Tool upgrade versions | `tool.upgrade.from_version`, `tool.upgrade.to_version` | `tool upgrade` | Tracks adoption of new tool versions | | Provision validation outcome | `validation.provision.outcome` (+ peer fields incl. `validation.provision.check_type`) | `provision` | Distinguishes passed / warnings-accepted / canceled local validation; `check_type` separates the provider-agnostic `provision` dispatch from the Bicep `arm-provision` dispatch (both share the event) | | ARM deployment events | `arm.deploy.*`, `arm.stack.deploy.*`, `arm.whatif.*`, `arm.validate.*` | `provision` | Distinguishes deployment scope (subscription vs resource-group) and operation kind (deploy / stack / what-if / validate) | @@ -150,11 +150,12 @@ captures the operation type, making the attribute redundant: These telemetry surfaces are not tied to a single command — they emit from middleware or shared infrastructure invoked by many commands. They are included here so the -privacy review covers every emission point. +privacy review covers every emission point. Dormant surfaces are retained to document +reserved field contracts. | Subsystem | Trigger | Events | Key Attributes | Notes | |-----------|---------|--------|----------------|-------| -| **Tool first-run middleware** | Wraps every interactive command | (none — enriches the active span) | `tool.firstrun.outcome`, `tool.firstrun.skip_reason`, `tool.firstrun.opt_in`, `tool.firstrun.tools_detected`, `tool.firstrun.tools_offered`, `tool.firstrun.tools_selected`, `tool.firstrun.tools_selected_names`, `tool.firstrun.tools_deselected_names`, `tool.firstrun.install_success_count`, `tool.firstrun.install_failure_count`, `tool.firstrun.install_failed_ids`, `tool.firstrun.install_duration_ms` | Records the first-run consent + tool-install flow; outcome key replaces deprecated boolean `tool.firstrun.completed` | +| **Tool first-run middleware (dormant)** | Not registered | (none) | `tool.firstrun.outcome`, `tool.firstrun.skip_reason`, `tool.firstrun.opt_in`, `tool.firstrun.tools_detected`, `tool.firstrun.tools_offered`, `tool.firstrun.tools_selected`, `tool.firstrun.tools_selected_names`, `tool.firstrun.tools_deselected_names`, `tool.firstrun.install_success_count`, `tool.firstrun.install_failure_count`, `tool.firstrun.install_failed_ids`, `tool.firstrun.install_duration_ms` | Reserved field contract for a possible future redesign; no fields are currently emitted | | **Hooks execution middleware** | Every lifecycle command (provision/deploy/up/down/restore/build/package/publish) | `hooks.exec` | `hooks.name` (hashed unless built-in lifecycle name), `hooks.type` (project / service / layer), `hooks.kind` (script runtime — `sh` / `pwsh` / `js` / `ts` / `python` / `dotnet`) | Layer-scope hooks added with multi-layer provision; pre/post is encoded in `hooks.name` (e.g., `prebuild` / `postbuild`), not in `hooks.kind` | | **Provision validation** | `provision` (all providers, plus Bicep `arm-provision` prior to ARM deploy) | `validation.provision` | `validation.provision.outcome`, plus peer fields covering warnings/errors counts, cancel reason, and `check_type` (dispatch site) | Local-only validation; runs for every provider via the provider-agnostic `provision` dispatch and additionally as Bicep `arm-provision`. `check_type` distinguishes the two emissions so Bicep provisions are not double-counted | | **ARM deployment client** | `provision` (any Bicep flow) | `arm.deploy.subscription`, `arm.deploy.resourcegroup`, `arm.stack.deploy.subscription`, `arm.stack.deploy.resourcegroup`, `arm.whatif.subscription`, `arm.whatif.resourcegroup`, `arm.validate.subscription`, `arm.validate.resourcegroup` | ARM operation status + duration | Per-call instrumentation in the ARM client; covers regular + stack deployments at both scopes | diff --git a/docs/specs/metrics-audit/telemetry-schema.md b/docs/specs/metrics-audit/telemetry-schema.md index d4bd317c0a2..402e4de9f73 100644 --- a/docs/specs/metrics-audit/telemetry-schema.md +++ b/docs/specs/metrics-audit/telemetry-schema.md @@ -285,11 +285,14 @@ The following fields are defined in `fields.go`. ### Tool Management -Telemetry for the `azd tool` feature (first-run experience and tool install / upgrade / -check operations). Only built-in tool IDs (e.g. `az-cli`) and version strings are captured — -no file paths, no user-identifiable data, no raw error text. +Telemetry for active `azd tool` install / upgrade / check operations and the dormant +first-run field contract. Only built-in tool IDs (e.g. `az-cli`) and version strings are +captured — no file paths, no user-identifiable data, no raw error text. -#### Tool first-run +#### Dormant tool first-run (reserved) + +The first-run middleware is not currently registered, so these fields are not emitted. They +remain defined to support a possible future redesign without changing the telemetry contract. | Field | OTel Key | Classification | Purpose | Notes | |-------|----------|----------------|---------|-------|