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
28 changes: 28 additions & 0 deletions cli/azd/cmd/middleware/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"context"
"errors"
"log"
"maps"
"slices"
"strings"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
Expand Down Expand Up @@ -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()...)
Expand Down Expand Up @@ -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))
}
163 changes: 163 additions & 0 deletions cli/azd/cmd/middleware/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
6 changes: 6 additions & 0 deletions cli/azd/internal/tracing/fields/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading