Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 22 additions & 69 deletions cli/azd/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions cli/azd/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/cmd/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
6 changes: 3 additions & 3 deletions cli/azd/docs/tracing-in-azd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- | --- |
Expand Down
8 changes: 5 additions & 3 deletions docs/reference/telemetry-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,13 +464,15 @@ Emitted at provision start by the `microsoft.foundry` provisioning provider (the
<details>
<summary><strong>Tool Management (<code>azd tool</code>)</strong><a id="tool-management"></a></summary>

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 |
|-----------|------|-------------|
Expand Down Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions docs/specs/metrics-audit/feature-telemetry-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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 |
Expand Down
11 changes: 7 additions & 4 deletions docs/specs/metrics-audit/telemetry-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|----------|----------------|---------|-------|
Expand Down
Loading