diff --git a/.vscode/cspell.misc.yaml b/.vscode/cspell.misc.yaml index 4517fcbdac0..81e6e4f27aa 100644 --- a/.vscode/cspell.misc.yaml +++ b/.vscode/cspell.misc.yaml @@ -44,6 +44,7 @@ overrides: - dependson - exegraph - pseudonymizing + - pulumi - filename: ./README.md words: - VSIX @@ -133,6 +134,7 @@ overrides: - preprovision - postdeploy - pseudonymized + - pulumi - remotebuild - resourcegroup - springapp diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 27c854fca80..b01ac4f2619 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -133,6 +133,10 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) { } slices.Reverse(layers) + // Record the resolved IaC provider(s) on the command span up front, so it is + // present on success and failure alike (no-op when there are no layers). + a.provisionManager.RecordInfraProviderUsage(layers) + for _, layer := range layers { if downLayer != "" || len(layers) > 1 { a.console.EnsureBlankLine(ctx) diff --git a/cli/azd/cmd/telemetry_test.go b/cli/azd/cmd/telemetry_test.go index 188b7cc2450..0c98d970136 100644 --- a/cli/azd/cmd/telemetry_test.go +++ b/cli/azd/cmd/telemetry_test.go @@ -199,7 +199,7 @@ func TestCommandTelemetryCoverage(t *testing.T) { "auth login", // auth.method "build", // (via hooks middleware) "deploy", // infra.provider, service attributes (via hooks middleware) - "down", // infra.provider (via hooks middleware) + "down", // infra.provider (resolved provider, via provisioning manager) "env list", // env.count "extension install", // extension.source.kind "extension list", // extension.source.kind @@ -210,14 +210,14 @@ func TestCommandTelemetryCoverage(t *testing.T) { "init", // init.method, appinit.* fields "package", // (via hooks middleware) "pipeline config", // pipeline.provider, pipeline.auth - "provision", // infra.provider (via hooks middleware) + "provision", // infra.provider (resolved provider, via provisioning manager) "restore", // (via hooks middleware) "tool check", // tool.check.updates_available "tool install", // tool.id(s), tool.dry_run, tool.install.* aggregate + per-tool fields "tool show", // tool.id "tool uninstall", // tool.id(s), tool.dry_run, tool.install.* aggregate + per-tool fields "tool upgrade", // tool.id(s), tool.dry_run, tool.install.* aggregate + tool.upgrade.* versions - "up", // infra.provider (via hooks middleware, composes provision+deploy) + "up", // infra.provider (via provisioning manager; composes provision+deploy) "update", // update.* fields } diff --git a/cli/azd/cmd/up.go b/cli/azd/cmd/up.go index ab3345ef02e..8574c4f1429 100644 --- a/cli/azd/cmd/up.go +++ b/cli/azd/cmd/up.go @@ -187,6 +187,15 @@ func (u *upAction) Run(ctx context.Context) (*actions.ActionResult, error) { } layers := infra.Options.GetLayers() + + // Record the resolved IaC provider(s) as a usage attribute on the cmd.up span. This is scoped + // to the unified-graph path rather than set before the custom-workflow branch on purpose: + // custom `workflows.up` runs `azd package`, `azd provision`, and `azd deploy` as in-process + // child commands that each attach the ambient usage attributes to their own spans, so recording + // it earlier would leak infra.provider onto the package/deploy spans and let a nested + // single-layer `provision` overwrite the aggregated value. Custom workflows instead carry + // infra.provider on their own child `azd provision` span. + u.provisioningManager.RecordInfraProviderUsage(layers) return u.upGraph.Run(ctx, layers, &u.flags.DeployFlags, u.flags.flagSet, startTime) } diff --git a/cli/azd/internal/cmd/provision.go b/cli/azd/internal/cmd/provision.go index 6a4340f7fdd..eb8c558a41e 100644 --- a/cli/azd/internal/cmd/provision.go +++ b/cli/azd/internal/cmd/provision.go @@ -291,6 +291,11 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error layers = []provisioning.Options{layerOption} } + // Record the resolved IaC provider(s) on the cmd.provision span up front — before the + // preview/multi-layer validation and provider work — so the attribute is present on success, + // failure, and preview runs alike, scoped to the provisioning lifecycle. + p.provisionManager.RecordInfraProviderUsage(layers) + if previewMode && len(layers) > 1 { return nil, &internal.ErrorWithSuggestion{ Err: internal.ErrPreviewMultipleLayers, diff --git a/cli/azd/internal/cmd/up_graph.go b/cli/azd/internal/cmd/up_graph.go index 76f5c6946d4..b4b28a6293e 100644 --- a/cli/azd/internal/cmd/up_graph.go +++ b/cli/azd/internal/cmd/up_graph.go @@ -34,6 +34,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/spf13/pflag" + "go.opentelemetry.io/otel/attribute" ) // UpGraphAction is the single implementation of `azd up`'s default path: it @@ -168,7 +169,9 @@ func (u *UpGraphAction) Run( // any values set during Run. Globals (e.g. SubscriptionIdKey) are // applied automatically by wrapperSpan.End(). usageAttrs := tracing.GetUsageAttributes() - packageSpan.SetAttributes(usageAttrs...) + // infra.provider is scoped to the provisioning lifecycle; keep it off the synthetic + // cmd.package span (it belongs on cmd.provision and the parent cmd.up span only). + packageSpan.SetAttributes(usageAttributesExcluding(usageAttrs, fields.InfraProviderKey.Key)...) packageSpan.End() provisionSpan.SetAttributes(usageAttrs...) provisionSpan.End() @@ -669,6 +672,19 @@ func phaseTimingBreakdown(steps []exegraph.StepTiming) string { return strings.Join(lines, "\n") } +// usageAttributesExcluding returns usageAttrs without the attribute matching exclude. It keeps +// provisioning-scoped attributes (for example infra.provider) off the synthetic cmd.package span. +func usageAttributesExcluding(usageAttrs []attribute.KeyValue, exclude attribute.Key) []attribute.KeyValue { + filtered := make([]attribute.KeyValue, 0, len(usageAttrs)) + for _, attr := range usageAttrs { + if attr.Key != exclude { + filtered = append(filtered, attr) + } + } + + return filtered +} + // phaseDurations computes the wall-clock duration for provisioning and deploying phases. // Returns zero durations for phases that were not executed. func phaseDurations(steps []exegraph.StepTiming) (provision, deploy time.Duration) { diff --git a/cli/azd/internal/cmd/up_graph_test.go b/cli/azd/internal/cmd/up_graph_test.go index e39cea392fa..cf80db9ceb4 100644 --- a/cli/azd/internal/cmd/up_graph_test.go +++ b/cli/azd/internal/cmd/up_graph_test.go @@ -7,9 +7,29 @@ import ( "testing" "time" + "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" "github.com/azure/azure-dev/cli/azd/pkg/exegraph" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" ) +func TestUsageAttributesExcluding(t *testing.T) { + t.Parallel() + + attrs := []attribute.KeyValue{ + fields.InfraProviderKey.String("bicep"), + fields.PerfProvisionDurationMs.Int64(42), + } + + got := usageAttributesExcluding(attrs, fields.InfraProviderKey.Key) + + require.Len(t, got, 1) + require.Equal(t, fields.PerfProvisionDurationMs.Key, got[0].Key) + for _, a := range got { + require.NotEqual(t, fields.InfraProviderKey.Key, a.Key, "infra.provider must be excluded from cmd.package span") + } +} + func TestPhaseTimingBreakdown(t *testing.T) { t.Parallel() base := time.Now() diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 61f985560bb..b7908fb4a9e 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -397,9 +397,14 @@ var ( // Infrastructure command related fields var ( - // The IaC provider used for infrastructure generation. + // The IaC provider. Emitted by `infra generate` / `synth` as a single string (the value read + // from azure.yaml's `infra.provider`, for example "bicep", "terraform", or "auto" when unset) + // and by provision / up / down as a sorted, de-duplicated string slice of the resolved + // provider(s) — built-in kinds verbatim, or "custom" for a non-built-in extension provider. + // Multi-layer projects that combine providers record each distinct value (for example + // ["bicep", "terraform"]). // - // Example: "bicep", "terraform" + // Example: "bicep", "terraform", "arm", "pulumi", "custom", ["bicep", "terraform"] InfraProviderKey = AttributeKey{ Key: attribute.Key("infra.provider"), Classification: SystemMetadata, diff --git a/cli/azd/pkg/infra/provisioning/manager.go b/cli/azd/pkg/infra/provisioning/manager.go index 5d9b81caf0c..c2346d03c5a 100644 --- a/cli/azd/pkg/infra/provisioning/manager.go +++ b/cli/azd/pkg/infra/provisioning/manager.go @@ -8,9 +8,13 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "path/filepath" + "slices" + "github.com/azure/azure-dev/cli/azd/internal/tracing" + "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azsdk/storage" @@ -538,3 +542,90 @@ func (m *Manager) newProvider(ctx context.Context) (Provider, error) { return provider, nil } + +const ( + // InfraProviderCustom is the infra.provider telemetry value recorded for a provider that is + // not one of the built-in kinds (for example an extension-registered provider). The raw name + // is never emitted because it may embed user-chosen project or organization identifiers. + InfraProviderCustom = "custom" +) + +// RecordInfraProviderUsage records the resolved IaC provider(s) for a provisioning command +// (provision / up / down) as the infra.provider usage attribute on the ambient command span. +// The attribute is a sorted, de-duplicated string slice of the distinct providers the command's +// layers resolve to: built-in kinds are recorded verbatim (for example "bicep", "terraform", +// "arm"), while non-built-in (extension) providers are bucketed to InfraProviderCustom ("custom") +// so raw user-chosen names are never emitted. A single-provider project records a one-element +// slice (for example ["bicep"]); a multi-layer project that mixes providers records each distinct +// value (for example ["bicep", "terraform"]) rather than a lossy "mixed" marker, preserving which +// providers were combined while staying low-cardinality. Layers that leave the provider +// unspecified resolve through the manager's default provider. It is a no-op when no provider can +// be resolved. +// +// Callers must invoke this once per command, before provider work begins, so the attribute is +// present on success, failure, and preview spans alike. The value is computed deterministically +// from configuration (rather than racing concurrent per-layer resolution). +func (m *Manager) RecordInfraProviderUsage(layers []Options) { + // The default provider is resolved lazily and at most once per call: every unspecified layer + // resolves to the same default, so caching keeps the value deterministic and avoids repeating + // resolver work (which may do I/O) per layer. + var cachedDefault ProviderKind + defaultResolved := false + defaultFailed := false + + seen := map[ProviderKind]struct{}{} + for _, layer := range layers { + kind := layer.Provider + if kind == NotSpecified { + if m.defaultProvider == nil || defaultFailed { + continue + } + + if !defaultResolved { + resolved, err := m.defaultProvider() + if err != nil { + defaultFailed = true + continue + } + + cachedDefault = resolved + defaultResolved = true + } + + kind = cachedDefault + } + + if kind == NotSpecified { + continue + } + + seen[kind] = struct{}{} + } + + if len(seen) == 0 { + return + } + + // Map the distinct resolved kinds to telemetry-safe values (built-ins verbatim, extensions + // bucketed to InfraProviderCustom), then de-duplicate and sort so the recorded slice is + // deterministic. De-duplicating after bucketing means two different extension providers both + // collapse to a single "custom" entry. + values := map[string]struct{}{} + for kind := range seen { + values[infraProviderTelemetryValue(kind)] = struct{}{} + } + + tracing.SetUsageAttributes(fields.InfraProviderKey.StringSlice(slices.Sorted(maps.Keys(values)))) +} + +// infraProviderTelemetryValue maps a provider kind to a value that is safe to emit raw. Built-in +// kinds are returned verbatim; any other (custom / extension-registered) provider is bucketed to +// InfraProviderCustom so a user-chosen provider name is never sent as telemetry. +func infraProviderTelemetryValue(kind ProviderKind) string { + switch kind { + case Bicep, Terraform, Arm, Pulumi: + return string(kind) + default: + return InfraProviderCustom + } +} diff --git a/cli/azd/pkg/infra/provisioning/manager_test.go b/cli/azd/pkg/infra/provisioning/manager_test.go index bcb5f0e0e36..18af9131879 100644 --- a/cli/azd/pkg/infra/provisioning/manager_test.go +++ b/cli/azd/pkg/infra/provisioning/manager_test.go @@ -10,6 +10,8 @@ import ( "testing" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/policy" + "github.com/azure/azure-dev/cli/azd/internal/tracing" + "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/cloud" @@ -395,3 +397,140 @@ func registerContainerDependencies(mockContext *mocks.MockContext, env *environm func defaultProvider() (provisioning.ProviderKind, error) { return provisioning.Bicep, nil } + +func TestRecordInfraProviderUsage(t *testing.T) { + failingResolver := func() (provisioning.ProviderKind, error) { + return provisioning.NotSpecified, errors.New("no default provider") + } + unspecifiedResolver := func() (provisioning.ProviderKind, error) { + return provisioning.NotSpecified, nil + } + + tests := []struct { + name string + layers []provisioning.Options + defaultProvider provisioning.DefaultProviderResolver + expected []string // nil means no infra.provider attribute is recorded + }{ + { + name: "single explicit bicep", + layers: []provisioning.Options{{Provider: provisioning.Bicep}}, + defaultProvider: defaultProvider, + expected: []string{"bicep"}, + }, + { + name: "single explicit terraform", + layers: []provisioning.Options{{Provider: provisioning.Terraform}}, + defaultProvider: defaultProvider, + expected: []string{"terraform"}, + }, + { + name: "unspecified resolves through default", + layers: []provisioning.Options{{Provider: provisioning.NotSpecified}}, + defaultProvider: defaultProvider, + expected: []string{"bicep"}, + }, + { + name: "uniform provider across layers", + layers: []provisioning.Options{ + {Provider: provisioning.Bicep}, + {Provider: provisioning.Bicep}, + }, + defaultProvider: defaultProvider, + expected: []string{"bicep"}, + }, + { + name: "different providers across layers record each provider", + layers: []provisioning.Options{ + {Provider: provisioning.Bicep}, + {Provider: provisioning.Terraform}, + }, + defaultProvider: defaultProvider, + expected: []string{"bicep", "terraform"}, + }, + { + name: "single explicit arm", + layers: []provisioning.Options{{Provider: provisioning.Arm}}, + defaultProvider: defaultProvider, + expected: []string{"arm"}, + }, + { + name: "custom provider is bucketed", + layers: []provisioning.Options{{Provider: provisioning.ProviderKind("my-extension-provider")}}, + defaultProvider: defaultProvider, + expected: []string{provisioning.InfraProviderCustom}, + }, + { + name: "built-in plus custom records both", + layers: []provisioning.Options{ + {Provider: provisioning.Bicep}, + {Provider: provisioning.ProviderKind("my-extension-provider")}, + }, + defaultProvider: defaultProvider, + expected: []string{"bicep", provisioning.InfraProviderCustom}, + }, + { + name: "two distinct custom providers collapse to custom", + layers: []provisioning.Options{ + {Provider: provisioning.ProviderKind("vendor.one")}, + {Provider: provisioning.ProviderKind("vendor.two")}, + }, + defaultProvider: defaultProvider, + expected: []string{provisioning.InfraProviderCustom}, + }, + { + name: "no layers records nothing", + layers: nil, + defaultProvider: defaultProvider, + expected: nil, + }, + { + name: "unspecified with nil resolver records nothing", + layers: []provisioning.Options{{Provider: provisioning.NotSpecified}}, + defaultProvider: nil, + expected: nil, + }, + { + name: "unspecified with failing resolver records nothing", + layers: []provisioning.Options{{Provider: provisioning.NotSpecified}}, + defaultProvider: failingResolver, + expected: nil, + }, + { + name: "unspecified resolving to NotSpecified records nothing", + layers: []provisioning.Options{{Provider: provisioning.NotSpecified}}, + defaultProvider: unspecifiedResolver, + expected: nil, + }, + } + + // Usage attributes are process-global, so these subtests must not run in parallel. + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tracing.ResetUsageAttributesForTest() + t.Cleanup(tracing.ResetUsageAttributesForTest) + + // RecordInfraProviderUsage only reads the manager's default provider, so the remaining + // dependencies are intentionally nil. + mgr := provisioning.NewManager(nil, tt.defaultProvider, nil, nil, nil, nil, nil, nil) + mgr.RecordInfraProviderUsage(tt.layers) + + var got []string + var found bool + for _, attr := range tracing.GetUsageAttributes() { + if attr.Key == fields.InfraProviderKey.Key { + got = attr.Value.AsStringSlice() + found = true + } + } + + if tt.expected == nil { + require.False(t, found, "expected no infra.provider attribute, got %v", got) + return + } + + require.True(t, found, "expected infra.provider attribute to be recorded") + require.Equal(t, tt.expected, got) + }) + } +} diff --git a/cli/azd/test/functional/telemetry_test.go b/cli/azd/test/functional/telemetry_test.go index f91fa1475a2..edba6923200 100644 --- a/cli/azd/test/functional/telemetry_test.go +++ b/cli/azd/test/functional/telemetry_test.go @@ -331,6 +331,20 @@ func Test_CLI_Telemetry_NestedCommands(t *testing.T) { upAttrs := attributesMap(cmdSpans["cmd.up"].Attributes) require.Contains(t, upAttrs, fields.CmdArgsCount.Key) require.Equal(t, float64(0), upAttrs[fields.CmdArgsCount.Key]) + + // infra.provider is scoped to the provisioning lifecycle. The minimal project has no explicit + // provider, so it resolves to the default ("bicep"). It is recorded as a slice of the resolved + // providers and must be present with that value on cmd.provision and the parent cmd.up span, + // and absent from the synthetic cmd.package span (packaging is unrelated to the IaC provider). + // Recorded up front on the common `up` path, so it is present even though provisioning + // subsequently fails. + require.NotContains(t, attributesMap(cmdSpans["cmd.package"].Attributes), fields.InfraProviderKey.Key, + "infra.provider must not be recorded on the synthetic cmd.package span") + provisionAttrs := attributesMap(cmdSpans["cmd.provision"].Attributes) + require.ElementsMatch(t, []string{"bicep"}, provisionAttrs[fields.InfraProviderKey.Key], + "cmd.provision should carry the resolved infra.provider") + require.ElementsMatch(t, []string{"bicep"}, upAttrs[fields.InfraProviderKey.Key], + "cmd.up should carry the resolved infra.provider") } func Test_Telemetry_AlphaFeatures_Enabled(t *testing.T) { diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index 3ce70fc5f4c..3d6f4775044 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -345,7 +345,7 @@ Set **only when an external command-line tool invocation fails**, during error c | Field Key | Type | Description | |-----------|------|-------------| -| `infra.provider` | string | `bicep`, `terraform`, `auto` | +| `infra.provider` | string or string[] | provision/up/down: sorted, de-duplicated string slice of resolved providers — `bicep`/`terraform`/`arm`/`pulumi`, or `custom` (extension providers; raw name not emitted); multi-layer projects record each distinct value (e.g. `["bicep","terraform"]`). generate/synth: the value read from azure.yaml's `infra.provider` as a single string (`bicep`/`terraform`, `auto` when unset, or a raw custom name) |
@@ -593,6 +593,18 @@ The `execution.environment` field identifies where azd is running. Format: `