Skip to content
2 changes: 2 additions & 0 deletions .vscode/cspell.misc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ overrides:
- dependson
- exegraph
- pseudonymizing
- pulumi
- filename: ./README.md
words:
- VSIX
Expand Down Expand Up @@ -133,6 +134,7 @@ overrides:
- preprovision
- postdeploy
- pseudonymized
- pulumi
- remotebuild
- resourcegroup
- springapp
Expand Down
4 changes: 4 additions & 0 deletions cli/azd/cmd/down.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) {
}
slices.Reverse(layers)

// Record the resolved IaC provider (single, or "mixed") 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)
Expand Down
6 changes: 3 additions & 3 deletions cli/azd/cmd/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Comment thread
hemarina marked this conversation as resolved.
"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
}

Expand Down
5 changes: 5 additions & 0 deletions cli/azd/cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ func (u *upAction) Run(ctx context.Context) (*actions.ActionResult, error) {
}
defer func() { _ = infra.Cleanup() }()

// Record the resolved IaC provider (single, or "mixed") on the cmd.up span up front — before
// provider init and the custom-workflow branch — so every up path (including custom workflows
// and early init failures) carries it.
u.provisioningManager.RecordInfraProviderUsage(infra.Options.GetLayers())

// TODO(weilim): remove this once we have decided if it's okay to not set AZURE_SUBSCRIPTION_ID and AZURE_LOCATION
// early in the up workflow in #3745
err = u.provisioningManager.Initialize(ctx, u.projectConfig.Path, infra.Options)
Expand Down
4 changes: 4 additions & 0 deletions cli/azd/internal/cmd/provision_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ func (p *ProvisionAction) provisionLayersGraph(
}, nil
}

// Record the resolved IaC provider (single, or "mixed") on the command span up front, so it is
// present on success, failure, and preview runs alike — scoped to the provisioning lifecycle.
p.provisionManager.RecordInfraProviderUsage(layers)
Comment on lines +87 to +89

// ── provider-agnostic provision validation (once per command) ─────────
// Dispatch extension-registered "provision" checks a single time, before
// the preview / single-layer / multi-layer paths run. This deliberately
Expand Down
18 changes: 17 additions & 1 deletion cli/azd/internal/cmd/up_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)...)
Comment thread
hemarina marked this conversation as resolved.
packageSpan.End()
provisionSpan.SetAttributes(usageAttrs...)
provisionSpan.End()
Expand Down Expand Up @@ -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 {
Comment thread
hemarina marked this conversation as resolved.
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) {
Expand Down
20 changes: 20 additions & 0 deletions cli/azd/internal/cmd/up_graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines +24 to +29
}
}

func TestPhaseTimingBreakdown(t *testing.T) {
t.Parallel()
base := time.Now()
Expand Down
6 changes: 4 additions & 2 deletions cli/azd/internal/tracing/fields/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,11 @@ var (

// Infrastructure command related fields
var (
// The IaC provider used for infrastructure generation.
// The IaC provider. Emitted by `infra generate` / `synth` (the configured `--provider` value,
// for example "bicep", "terraform", "auto") and by provision / up / down (the resolved
// provider, "mixed" when layers differ, or "custom" for a non-built-in extension provider).
Comment on lines +400 to +402
//
// Example: "bicep", "terraform"
// Example: "bicep", "terraform", "arm", "pulumi", "mixed", "custom"
InfraProviderKey = AttributeKey{
Key: attribute.Key("infra.provider"),
Classification: SystemMetadata,
Expand Down
88 changes: 88 additions & 0 deletions cli/azd/pkg/infra/provisioning/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"os"
"path/filepath"

"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"
Expand Down Expand Up @@ -538,3 +540,89 @@ func (m *Manager) newProvider(ctx context.Context) (Provider, error) {

return provider, nil
}

const (
// InfraProviderMixed is the infra.provider telemetry value recorded when a project's
// provisioning layers do not all resolve to the same IaC provider.
InfraProviderMixed = "mixed"

// 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.
Comment thread
hemarina marked this conversation as resolved.
InfraProviderCustom = "custom"
)

// RecordInfraProviderUsage records the resolved IaC provider for a provisioning command
// (provision / up / down) as the infra.provider usage attribute on the ambient command span.
// When every layer resolves to the same provider it records that provider (for example
// "bicep", "terraform", "arm"); non-built-in (extension) providers are bucketed to
// InfraProviderCustom ("custom") so raw user-chosen names are never emitted. When layers
// resolve to different providers it records InfraProviderMixed ("mixed"). 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{}{}
}

// Distinct resolved kinds are collected first so that two different custom providers still
// record "mixed" (bucketing only the single-provider result, not before counting).
switch len(seen) {
case 0:
return
case 1:
for kind := range seen {
tracing.SetUsageAttributes(fields.InfraProviderKey.String(infraProviderTelemetryValue(kind)))
}
default:
tracing.SetUsageAttributes(fields.InfraProviderKey.String(InfraProviderMixed))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit / lowest priority — genuinely just curious, not blocking: is there a reason we prefer collapsing to a single "mixed" value here rather than recording a StringSlice of the actual providers used (e.g. ["bicep","terraform"]), similar to how ProjectServiceLanguagesKey / ProjectServiceHostsKey record the per-service breakdown in project.Parse?

Built-in provider names are a fixed enum (only custom needs bucketing for privacy), so a slice would stay low-cardinality while preserving which combination was mixed. With "mixed" we can segment "single vs mixed" but can't tell bicep+terraform from bicep+arm.

I fully expect almost nobody to actually mix two providers across layers, so this really is a nit — just want to understand the choice.

}
}

// 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
}
Comment thread
hemarina marked this conversation as resolved.
}
Loading
Loading