From ba7eb2925be5a041dc1c554dc689d5069344960f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:26:44 +0000 Subject: [PATCH 1/5] Initial plan From 5fcc4571cba34a994fbeb5fd8c1b215602742869 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:35:31 +0000 Subject: [PATCH 2/5] Emit installed extension IDs and versions in telemetry for all commands Add new telemetry fields `extensions.installed.ids` and `extensions.installed.versions` to capture the list of installed extensions on every command span. This enables tracking which extensions users have installed, even for non-extension commands like `azd up` or `azd deploy`. Co-authored-by: JeffreyCA <9157833+JeffreyCA@users.noreply.github.com> --- cli/azd/cmd/middleware/telemetry.go | 32 +++++++++++ cli/azd/cmd/middleware/telemetry_test.go | 70 +++++++++++++++++++++++ cli/azd/internal/tracing/fields/fields.go | 12 ++++ 3 files changed, 114 insertions(+) diff --git a/cli/azd/cmd/middleware/telemetry.go b/cli/azd/cmd/middleware/telemetry.go index d58ee722214..0a3dcf67348 100644 --- a/cli/azd/cmd/middleware/telemetry.go +++ b/cli/azd/cmd/middleware/telemetry.go @@ -7,6 +7,7 @@ import ( "context" "errors" "log" + "slices" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -97,6 +98,9 @@ func (m *TelemetryMiddleware) Run(ctx context.Context, next NextFn) (*actions.Ac span.SetAttributes(fields.PlatformTypeKey.String(string(platformConfig.Type))) } + // Emit installed extension IDs and versions for all commands + m.setInstalledExtensionsAttributes(span) + defer func() { // Include any usage attributes set span.SetAttributes(tracing.GetUsageAttributes()...) @@ -158,3 +162,31 @@ func (m *TelemetryMiddleware) extensionCmdInfo(extensionId string) (string, []st fullPath := strings.Join(append([]string{"azd", namespacePath}, commandPath...), " ") return events.GetCommandEventName(fullPath), commandFlags } + +// setInstalledExtensionsAttributes emits the list of installed extension IDs and versions as span attributes. +func (m *TelemetryMiddleware) setInstalledExtensionsAttributes(span tracing.Span) { + if m.extensionManager == nil { + return + } + + installed, err := m.extensionManager.ListInstalled() + if err != nil || len(installed) == 0 { + return + } + + ids := make([]string, 0, len(installed)) + for id := range installed { + ids = append(ids, id) + } + slices.Sort(ids) + + versions := make([]string, 0, len(installed)) + for _, id := range ids { + versions = append(versions, installed[id].Version) + } + + span.SetAttributes( + fields.ExtensionsInstalledIds.StringSlice(ids), + fields.ExtensionsInstalledVersions.StringSlice(versions), + ) +} diff --git a/cli/azd/cmd/middleware/telemetry_test.go b/cli/azd/cmd/middleware/telemetry_test.go index e0a04284bf0..1f66091b024 100644 --- a/cli/azd/cmd/middleware/telemetry_test.go +++ b/cli/azd/cmd/middleware/telemetry_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/platform" "github.com/azure/azure-dev/cli/azd/test/mocks" @@ -79,4 +81,72 @@ func Test_Telemetry_Run(t *testing.T) { "Context should be a different instance since telemetry creates a new context", ) }) + + t.Run("WithInstalledExtensions", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + // Set up installed extensions in config + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + userConfig, err := userConfigManager.Load() + require.NoError(t, err) + + installedExtensions := map[string]*extensions.Extension{ + "microsoft.azd.demo": { + Id: "microsoft.azd.demo", + Version: "0.5.0", + }, + "microsoft.azd.ai": { + Id: "microsoft.azd.ai", + Version: "1.2.0", + }, + } + err = userConfig.Set("extension.installed", installedExtensions) + require.NoError(t, err) + + lazyRunner := lazy.NewLazy(func() (*extensions.Runner, error) { + return nil, nil + }) + manager, err := extensions.NewManager(userConfigManager, nil, lazyRunner, mockContext.HttpClient) + require.NoError(t, err) + + options := &Options{ + CommandPath: "azd provision", + Name: "provision", + } + middleware := NewTelemetryMiddleware(options, lazyPlatformConfig, manager) + + ran := false + nextFn := func(ctx context.Context) (*actions.ActionResult, error) { + ran = true + return nil, nil + } + + _, _ = middleware.Run(*mockContext.Context, nextFn) + require.True(t, ran) + + // Verify that installed extensions were listed without error + installed, err := manager.ListInstalled() + require.NoError(t, err) + require.Equal(t, 2, len(installed)) + }) + + t.Run("WithNilExtensionManager", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + options := &Options{ + CommandPath: "azd provision", + Name: "provision", + } + middleware := NewTelemetryMiddleware(options, lazyPlatformConfig, nil) + + ran := false + nextFn := func(ctx context.Context) (*actions.ActionResult, error) { + ran = true + return nil, nil + } + + // Should not panic when extensionManager is nil + _, _ = middleware.Run(*mockContext.Context, nextFn) + require.True(t, ran) + }) } diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 16021d98c2f..f66a1ea3dbb 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -580,6 +580,18 @@ var ( Classification: SystemMetadata, Purpose: FeatureInsight, } + // The list of installed extension identifiers. + ExtensionsInstalledIds = AttributeKey{ + Key: attribute.Key("extensions.installed.ids"), + Classification: SystemMetadata, + Purpose: FeatureInsight, + } + // The list of installed extension versions. + ExtensionsInstalledVersions = AttributeKey{ + Key: attribute.Key("extensions.installed.versions"), + Classification: SystemMetadata, + Purpose: FeatureInsight, + } ) // Update related fields From 442fdc4a222c99147902a60225f07c5d5094fd97 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Wed, 4 Mar 2026 01:00:22 +0000 Subject: [PATCH 3/5] Update telemetry to emit installed extensions as "id@version" format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/middleware/telemetry.go | 23 ++++----- cli/azd/cmd/middleware/telemetry_test.go | 61 ++++++++++++++++------- cli/azd/internal/tracing/fields/fields.go | 12 ++--- 3 files changed, 54 insertions(+), 42 deletions(-) diff --git a/cli/azd/cmd/middleware/telemetry.go b/cli/azd/cmd/middleware/telemetry.go index 0a3dcf67348..bfd05f31175 100644 --- a/cli/azd/cmd/middleware/telemetry.go +++ b/cli/azd/cmd/middleware/telemetry.go @@ -163,7 +163,8 @@ func (m *TelemetryMiddleware) extensionCmdInfo(extensionId string) (string, []st return events.GetCommandEventName(fullPath), commandFlags } -// setInstalledExtensionsAttributes emits the list of installed extension IDs and versions as span attributes. +// setInstalledExtensionsAttributes emits the list of installed extensions as span attributes. +// Each entry is formatted as "id@version" (e.g. "microsoft.azd.ai@1.2.0"). func (m *TelemetryMiddleware) setInstalledExtensionsAttributes(span tracing.Span) { if m.extensionManager == nil { return @@ -174,19 +175,13 @@ func (m *TelemetryMiddleware) setInstalledExtensionsAttributes(span tracing.Span return } - ids := make([]string, 0, len(installed)) - for id := range installed { - ids = append(ids, id) - } - slices.Sort(ids) - - versions := make([]string, 0, len(installed)) - for _, id := range ids { - versions = append(versions, installed[id].Version) + entries := make([]string, 0, len(installed)) + for id, ext := range installed { + if ext != nil { + entries = append(entries, id+"@"+ext.Version) + } } + slices.Sort(entries) - span.SetAttributes( - fields.ExtensionsInstalledIds.StringSlice(ids), - fields.ExtensionsInstalledVersions.StringSlice(versions), - ) + span.SetAttributes(fields.ExtensionsInstalled.StringSlice(entries)) } diff --git a/cli/azd/cmd/middleware/telemetry_test.go b/cli/azd/cmd/middleware/telemetry_test.go index 1f66091b024..9750e2de84e 100644 --- a/cli/azd/cmd/middleware/telemetry_test.go +++ b/cli/azd/cmd/middleware/telemetry_test.go @@ -13,7 +13,9 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/platform" "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mocktracing" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" ) func Test_Telemetry_Run(t *testing.T) { @@ -90,6 +92,8 @@ func Test_Telemetry_Run(t *testing.T) { userConfig, err := userConfigManager.Load() require.NoError(t, err) + // Use extensions whose alphabetical order differs from insertion order + // to verify sorting behavior installedExtensions := map[string]*extensions.Extension{ "microsoft.azd.demo": { Id: "microsoft.azd.demo", @@ -115,38 +119,57 @@ func Test_Telemetry_Run(t *testing.T) { } middleware := NewTelemetryMiddleware(options, lazyPlatformConfig, manager) - ran := false - nextFn := func(ctx context.Context) (*actions.ActionResult, error) { - ran = true - return nil, nil + // Call the method directly with a mock span to verify attributes + span := &mocktracing.Span{} + middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) + var installedAttr *attribute.KeyValue + for i := range span.Attributes { + if span.Attributes[i].Key == "extension.installed" { + installedAttr = &span.Attributes[i] + break + } } + require.NotNil(t, installedAttr, "extension.installed attribute should be set") + require.Equal(t, + []string{"microsoft.azd.ai@1.2.0", "microsoft.azd.demo@0.5.0"}, + installedAttr.Value.AsStringSlice(), + ) + }) - _, _ = middleware.Run(*mockContext.Context, nextFn) - require.True(t, ran) + t.Run("WithNoInstalledExtensions", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) - // Verify that installed extensions were listed without error - installed, err := manager.ListInstalled() + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + + lazyRunner := lazy.NewLazy(func() (*extensions.Runner, error) { + return nil, nil + }) + manager, err := extensions.NewManager(userConfigManager, nil, lazyRunner, mockContext.HttpClient) require.NoError(t, err) - require.Equal(t, 2, len(installed)) + + options := &Options{ + CommandPath: "azd provision", + Name: "provision", + } + middleware := NewTelemetryMiddleware(options, lazyPlatformConfig, manager) + + span := &mocktracing.Span{} + middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) + + require.Empty(t, span.Attributes, "no attributes should be set when no extensions are installed") }) t.Run("WithNilExtensionManager", func(t *testing.T) { - mockContext := mocks.NewMockContext(context.Background()) - options := &Options{ CommandPath: "azd provision", Name: "provision", } middleware := NewTelemetryMiddleware(options, lazyPlatformConfig, nil) - ran := false - nextFn := func(ctx context.Context) (*actions.ActionResult, error) { - ran = true - return nil, nil - } - // Should not panic when extensionManager is nil - _, _ = middleware.Run(*mockContext.Context, nextFn) - require.True(t, ran) + span := &mocktracing.Span{} + middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) + + require.Empty(t, span.Attributes, "no attributes should be set when manager is nil") }) } diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index f66a1ea3dbb..d06a48e9659 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -580,15 +580,9 @@ var ( Classification: SystemMetadata, Purpose: FeatureInsight, } - // The list of installed extension identifiers. - ExtensionsInstalledIds = AttributeKey{ - Key: attribute.Key("extensions.installed.ids"), - Classification: SystemMetadata, - Purpose: FeatureInsight, - } - // The list of installed extension versions. - ExtensionsInstalledVersions = AttributeKey{ - Key: attribute.Key("extensions.installed.versions"), + // The list of installed extensions, each formatted as "id@version". + ExtensionsInstalled = AttributeKey{ + Key: attribute.Key("extension.installed"), Classification: SystemMetadata, Purpose: FeatureInsight, } From 67b12834b8a426ad26615881f757f0a0fb2aeafb Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 6 Mar 2026 18:43:45 +0000 Subject: [PATCH 4/5] Emit empty slice when zero extensions installed --- cli/azd/cmd/middleware/telemetry.go | 2 +- cli/azd/cmd/middleware/telemetry_test.go | 39 +++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/middleware/telemetry.go b/cli/azd/cmd/middleware/telemetry.go index bfd05f31175..af83545a31c 100644 --- a/cli/azd/cmd/middleware/telemetry.go +++ b/cli/azd/cmd/middleware/telemetry.go @@ -171,7 +171,7 @@ func (m *TelemetryMiddleware) setInstalledExtensionsAttributes(span tracing.Span } installed, err := m.extensionManager.ListInstalled() - if err != nil || len(installed) == 0 { + if err != nil { return } diff --git a/cli/azd/cmd/middleware/telemetry_test.go b/cli/azd/cmd/middleware/telemetry_test.go index 9750e2de84e..93da8bd3d83 100644 --- a/cli/azd/cmd/middleware/telemetry_test.go +++ b/cli/azd/cmd/middleware/telemetry_test.go @@ -156,7 +156,44 @@ func Test_Telemetry_Run(t *testing.T) { span := &mocktracing.Span{} middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) - require.Empty(t, span.Attributes, "no attributes should be set when no extensions are installed") + require.Len(t, span.Attributes, 1, "extension.installed attribute should be set") + require.Equal(t, "extension.installed", string(span.Attributes[0].Key)) + require.Empty(t, span.Attributes[0].Value.AsStringSlice(), "should be an empty slice when no extensions are installed") + }) + + t.Run("WithAllNilExtensionEntries", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + userConfig, err := userConfigManager.Load() + require.NoError(t, err) + + // Simulate corrupted config where all extension values are nil + installedExtensions := map[string]*extensions.Extension{ + "microsoft.azd.demo": nil, + "microsoft.azd.ai": nil, + } + err = userConfig.Set("extension.installed", installedExtensions) + require.NoError(t, err) + + lazyRunner := lazy.NewLazy(func() (*extensions.Runner, error) { + return nil, nil + }) + manager, err := extensions.NewManager(userConfigManager, nil, lazyRunner, mockContext.HttpClient) + require.NoError(t, err) + + options := &Options{ + CommandPath: "azd provision", + Name: "provision", + } + middleware := NewTelemetryMiddleware(options, lazyPlatformConfig, manager) + + span := &mocktracing.Span{} + middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) + + require.Len(t, span.Attributes, 1, "extension.installed attribute should be set") + require.Equal(t, "extension.installed", string(span.Attributes[0].Key)) + require.Empty(t, span.Attributes[0].Value.AsStringSlice(), "should be an empty slice when all entries are nil") }) t.Run("WithNilExtensionManager", func(t *testing.T) { From 21bb9359dd94238db42401b93088817185e40200 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 6 Mar 2026 23:30:28 +0000 Subject: [PATCH 5/5] Address feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/middleware/telemetry.go | 7 +-- cli/azd/cmd/middleware/telemetry_test.go | 59 ++++++++++++++++++------ 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/cli/azd/cmd/middleware/telemetry.go b/cli/azd/cmd/middleware/telemetry.go index af83545a31c..900375e0732 100644 --- a/cli/azd/cmd/middleware/telemetry.go +++ b/cli/azd/cmd/middleware/telemetry.go @@ -7,6 +7,7 @@ import ( "context" "errors" "log" + "maps" "slices" "strings" @@ -172,16 +173,16 @@ func (m *TelemetryMiddleware) setInstalledExtensionsAttributes(span tracing.Span installed, err := m.extensionManager.ListInstalled() if err != nil { + log.Printf("failed to list installed extensions: %v", err) return } entries := make([]string, 0, len(installed)) - for id, ext := range installed { - if ext != nil { + for _, id := range slices.Sorted(maps.Keys(installed)) { + if ext := installed[id]; ext != nil { entries = append(entries, id+"@"+ext.Version) } } - slices.Sort(entries) span.SetAttributes(fields.ExtensionsInstalled.StringSlice(entries)) } diff --git a/cli/azd/cmd/middleware/telemetry_test.go b/cli/azd/cmd/middleware/telemetry_test.go index 93da8bd3d83..3b9f0fd19aa 100644 --- a/cli/azd/cmd/middleware/telemetry_test.go +++ b/cli/azd/cmd/middleware/telemetry_test.go @@ -122,13 +122,7 @@ func Test_Telemetry_Run(t *testing.T) { // Call the method directly with a mock span to verify attributes span := &mocktracing.Span{} middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) - var installedAttr *attribute.KeyValue - for i := range span.Attributes { - if span.Attributes[i].Key == "extension.installed" { - installedAttr = &span.Attributes[i] - break - } - } + installedAttr := findAttribute(span.Attributes, "extension.installed") require.NotNil(t, installedAttr, "extension.installed attribute should be set") require.Equal(t, []string{"microsoft.azd.ai@1.2.0", "microsoft.azd.demo@0.5.0"}, @@ -156,9 +150,9 @@ func Test_Telemetry_Run(t *testing.T) { span := &mocktracing.Span{} middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) - require.Len(t, span.Attributes, 1, "extension.installed attribute should be set") - require.Equal(t, "extension.installed", string(span.Attributes[0].Key)) - require.Empty(t, span.Attributes[0].Value.AsStringSlice(), "should be an empty slice when no extensions are installed") + installedAttr := findAttribute(span.Attributes, "extension.installed") + require.NotNil(t, installedAttr, "extension.installed attribute should be set") + require.Empty(t, installedAttr.Value.AsStringSlice(), "should be an empty slice when no extensions are installed") }) t.Run("WithAllNilExtensionEntries", func(t *testing.T) { @@ -191,9 +185,9 @@ func Test_Telemetry_Run(t *testing.T) { span := &mocktracing.Span{} middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) - require.Len(t, span.Attributes, 1, "extension.installed attribute should be set") - require.Equal(t, "extension.installed", string(span.Attributes[0].Key)) - require.Empty(t, span.Attributes[0].Value.AsStringSlice(), "should be an empty slice when all entries are nil") + installedAttr := findAttribute(span.Attributes, "extension.installed") + require.NotNil(t, installedAttr, "extension.installed attribute should be set") + require.Empty(t, installedAttr.Value.AsStringSlice(), "should be an empty slice when all entries are nil") }) t.Run("WithNilExtensionManager", func(t *testing.T) { @@ -209,4 +203,43 @@ func Test_Telemetry_Run(t *testing.T) { require.Empty(t, span.Attributes, "no attributes should be set when manager is nil") }) + + t.Run("WithListInstalledError", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + userConfig, err := userConfigManager.Load() + require.NoError(t, err) + + // Set a malformed value to cause ListInstalled to fail deserialization + err = userConfig.Set("extension.installed", "not-a-map") + require.NoError(t, err) + + lazyRunner := lazy.NewLazy(func() (*extensions.Runner, error) { + return nil, nil + }) + manager, err := extensions.NewManager(userConfigManager, nil, lazyRunner, mockContext.HttpClient) + require.NoError(t, err) + + options := &Options{ + CommandPath: "azd provision", + Name: "provision", + } + middleware := NewTelemetryMiddleware(options, lazyPlatformConfig, manager) + + span := &mocktracing.Span{} + middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) + + require.Empty(t, span.Attributes, "no attributes should be set when ListInstalled fails") + }) +} + +// findAttribute searches for an attribute by key and returns a pointer to it, or nil if not found. +func findAttribute(attrs []attribute.KeyValue, key attribute.Key) *attribute.KeyValue { + for i := range attrs { + if attrs[i].Key == key { + return &attrs[i] + } + } + return nil }