diff --git a/cli/azd/cmd/middleware/telemetry.go b/cli/azd/cmd/middleware/telemetry.go index d58ee722214..900375e0732 100644 --- a/cli/azd/cmd/middleware/telemetry.go +++ b/cli/azd/cmd/middleware/telemetry.go @@ -7,6 +7,8 @@ import ( "context" "errors" "log" + "maps" + "slices" "strings" "github.com/Azure/azure-sdk-for-go/sdk/azcore" @@ -97,6 +99,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 +163,26 @@ 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 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 + } + + 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 := range slices.Sorted(maps.Keys(installed)) { + if ext := installed[id]; ext != nil { + entries = append(entries, id+"@"+ext.Version) + } + } + + 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 e0a04284bf0..3b9f0fd19aa 100644 --- a/cli/azd/cmd/middleware/telemetry_test.go +++ b/cli/azd/cmd/middleware/telemetry_test.go @@ -8,10 +8,14 @@ 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" + "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) { @@ -79,4 +83,163 @@ 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) + + // 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", + 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) + + // Call the method directly with a mock span to verify attributes + span := &mocktracing.Span{} + middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) + 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"}, + installedAttr.Value.AsStringSlice(), + ) + }) + + t.Run("WithNoInstalledExtensions", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + 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) + + options := &Options{ + CommandPath: "azd provision", + Name: "provision", + } + middleware := NewTelemetryMiddleware(options, lazyPlatformConfig, manager) + + span := &mocktracing.Span{} + middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) + + 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) { + 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) + + 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) { + options := &Options{ + CommandPath: "azd provision", + Name: "provision", + } + middleware := NewTelemetryMiddleware(options, lazyPlatformConfig, nil) + + // Should not panic when extensionManager is nil + span := &mocktracing.Span{} + middleware.(*TelemetryMiddleware).setInstalledExtensionsAttributes(span) + + 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 } diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 16021d98c2f..d06a48e9659 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -580,6 +580,12 @@ var ( Classification: SystemMetadata, Purpose: FeatureInsight, } + // The list of installed extensions, each formatted as "id@version". + ExtensionsInstalled = AttributeKey{ + Key: attribute.Key("extension.installed"), + Classification: SystemMetadata, + Purpose: FeatureInsight, + } ) // Update related fields