Skip to content

Commit a4e2c19

Browse files
authored
feat(extensions): classify source telemetry (#9452)
* feat(extensions): classify source telemetry * test(extensions): expand telemetry command coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d743ee8d-6f2c-4244-9ca9-b64fb20643d1 --------- Copilot-Session: d743ee8d-6f2c-4244-9ca9-b64fb20643d1
1 parent cfc1664 commit a4e2c19

24 files changed

Lines changed: 1020 additions & 131 deletions

cli/azd/cmd/extension.go

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,6 +1440,7 @@ func (a *extensionInstallAction) cleanupBundleInstall(ctx context.Context) {
14401440
continue
14411441
}
14421442
ext.Source = extensions.BundleSourceName
1443+
ext.SourceCategory = extensions.SourceCategoryBundle
14431444
if err := a.extensionManager.UpdateInstalled(ext); err != nil {
14441445
log.Printf("failed to mark extension %q as bundle-installed: %v", ext.Id, err)
14451446
}
@@ -2316,7 +2317,11 @@ func (a *extensionUpgradeAction) upgradeOneExtension(
23162317
isJsonOutput bool,
23172318
) extensions.UpgradeResult {
23182319
startTime := time.Now()
2319-
baseResult := extensions.UpgradeResult{ExtensionId: extensionId}
2320+
baseResult := extensions.UpgradeResult{
2321+
ExtensionId: extensionId,
2322+
FromSourceCategory: extensions.SourceCategoryUnknown,
2323+
ToSourceCategory: extensions.SourceCategoryUnknown,
2324+
}
23202325

23212326
// Start a telemetry span for this individual extension update.
23222327
ctx, span := tracing.Start(ctx, events.ExtensionUpdateEvent)
@@ -2330,8 +2335,8 @@ func (a *extensionUpgradeAction) upgradeOneExtension(
23302335
fields.ExtensionVersionTo.String(
23312336
baseResult.ToVersion,
23322337
),
2333-
fields.ExtensionSource.String(
2334-
baseResult.ToSource,
2338+
fields.ExtensionSourceCategory.String(
2339+
string(baseResult.ToSourceCategory),
23352340
),
23362341
fields.ExtensionUpdateDurationMs.Int64(elapsed),
23372342
fields.ExtensionUpdateOutcome.String(
@@ -2402,6 +2407,8 @@ func (a *extensionUpgradeAction) upgradeOneExtension(
24022407
}
24032408
baseResult.FromVersion = installed.Version
24042409
baseResult.FromSource = installed.Source
2410+
baseResult.FromSourceCategory = installed.SourceCategoryOrUnknown()
2411+
baseResult.ToSourceCategory = baseResult.FromSourceCategory
24052412

24062413
// Extensions installed from a self-contained bundle have no live registry to
24072414
// upgrade against. Skip them gracefully and direct the user to reinstall with
@@ -2607,6 +2614,7 @@ func (a *extensionUpgradeAction) upgradeOneExtension(
26072614
targetSemver, targetErr := semver.NewVersion(targetVersionStr)
26082615

26092616
baseResult.ToSource = newSource
2617+
baseResult.ToSourceCategory = selectedExt.SourceCategoryOrUnknown()
26102618

26112619
// Compare versions
26122620
if installedErr == nil && targetErr == nil && installedSemver.GreaterThan(targetSemver) {
@@ -2693,7 +2701,8 @@ func (a *extensionUpgradeAction) upgradeOneExtension(
26932701
emitPromotionEvent(
26942702
ctx, extensionId,
26952703
installed.Version, extVersion.Version,
2696-
oldSource, newSource,
2704+
installed.SourceCategoryOrUnknown(),
2705+
selectedExt.SourceCategoryOrUnknown(),
26972706
)
26982707
if !isJsonOutput {
26992708
a.displayPromotionWarning(
@@ -2946,16 +2955,16 @@ func emitPromotionEvent(
29462955
extensionId string,
29472956
fromVersion string,
29482957
toVersion string,
2949-
oldSource string,
2950-
newSource string,
2958+
oldSourceCategory extensions.SourceCategory,
2959+
newSourceCategory extensions.SourceCategory,
29512960
) {
29522961
_, promSpan := tracing.Start(ctx, events.ExtensionPromoteEvent)
29532962
promSpan.SetAttributes(
29542963
fields.ExtensionId.String(extensionId),
29552964
fields.ExtensionVersionFrom.String(fromVersion),
29562965
fields.ExtensionVersionTo.String(toVersion),
2957-
fields.ExtensionSourceFrom.String(oldSource),
2958-
fields.ExtensionSourceTo.String(newSource),
2966+
fields.ExtensionSourceCategoryFrom.String(string(oldSourceCategory)),
2967+
fields.ExtensionSourceCategoryTo.String(string(newSourceCategory)),
29592968
)
29602969
promSpan.SetStatus(codes.Ok, "")
29612970
promSpan.End()
@@ -3138,6 +3147,9 @@ func (a *extensionSourceAddAction) Run(ctx context.Context) (*actions.ActionResu
31383147
if err != nil {
31393148
return nil, fmt.Errorf("failed adding extension source: %w", err)
31403149
}
3150+
tracing.SetUsageAttributes(
3151+
fields.ExtensionSourceCategory.String(string(extensions.ClassifySource(sourceConfig))),
3152+
)
31413153

31423154
return &actions.ActionResult{
31433155
Message: &actions.ResultMessage{

cli/azd/cmd/extension_bundle_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,7 @@ func TestCleanupBundleInstall_RepointsInstalledToBundle(t *testing.T) {
821821
installed, err := action.extensionManager.GetInstalled(extensions.FilterOptions{Id: "test.ext"})
822822
require.NoError(t, err)
823823
require.Equal(t, extensions.BundleSourceName, installed.Source)
824+
require.Equal(t, extensions.SourceCategoryBundle, installed.SourceCategory)
824825
}
825826

826827
func TestExtensionList_SurfacesBundleInstalledExtension(t *testing.T) {

cli/azd/cmd/extension_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020
"github.com/azure/azure-dev/cli/azd/cmd/actions"
2121
"github.com/azure/azure-dev/cli/azd/internal"
2222
"github.com/azure/azure-dev/cli/azd/internal/agent/consent"
23+
"github.com/azure/azure-dev/cli/azd/internal/tracing"
24+
"github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
2325
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
2426
"github.com/azure/azure-dev/cli/azd/pkg/config"
2527
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
@@ -1571,6 +1573,38 @@ func Test_ExtensionSourceAddAction_EmptyNameError(t *testing.T) {
15711573
require.Error(t, err)
15721574
}
15731575

1576+
func Test_ExtensionSourceAddAction_EmitsSourceCategory(t *testing.T) {
1577+
tracing.ResetUsageAttributesForTest()
1578+
t.Cleanup(tracing.ResetUsageAttributesForTest)
1579+
1580+
sm, cfgMgr := newTestSourceManager(t)
1581+
cfg := config.NewEmptyConfig()
1582+
cfgMgr.On("Load").Return(cfg, nil)
1583+
cfgMgr.On("Save", mock.Anything).Return(nil)
1584+
1585+
action := &extensionSourceAddAction{
1586+
sourceManager: sm,
1587+
console: mockinput.NewMockConsole(),
1588+
flags: &extensionSourceAddFlags{
1589+
name: "private-source",
1590+
location: writeRegistryFile(t),
1591+
kind: string(extensions.SourceKindFile),
1592+
},
1593+
}
1594+
_, err := action.Run(t.Context())
1595+
require.NoError(t, err)
1596+
1597+
var category string
1598+
for _, attr := range tracing.GetUsageAttributes() {
1599+
if attr.Key == fields.ExtensionSourceCategory.Key {
1600+
category = attr.Value.AsString()
1601+
}
1602+
require.NotContains(t, attr.Value.Emit(), action.flags.location)
1603+
require.NotContains(t, attr.Value.Emit(), action.flags.name)
1604+
}
1605+
require.Equal(t, string(extensions.SourceCategoryLocal), category)
1606+
}
1607+
15741608
func Test_ExtensionSourceListAction_DefaultSource(t *testing.T) {
15751609
t.Parallel()
15761610
sm, cfgMgr := newTestSourceManager(t)

cli/azd/cmd/extension_upgrade_test.go

Lines changed: 177 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,22 @@ import (
1515
"testing"
1616

1717
"github.com/azure/azure-dev/cli/azd/internal"
18+
"github.com/azure/azure-dev/cli/azd/internal/tracing/events"
19+
"github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
1820
"github.com/azure/azure-dev/cli/azd/pkg/config"
1921
"github.com/azure/azure-dev/cli/azd/pkg/exec"
2022
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
23+
"github.com/azure/azure-dev/cli/azd/pkg/input"
2124
"github.com/azure/azure-dev/cli/azd/pkg/lazy"
2225
"github.com/azure/azure-dev/cli/azd/pkg/output"
2326
"github.com/azure/azure-dev/cli/azd/test/mocks"
2427
"github.com/azure/azure-dev/cli/azd/test/mocks/mockinput"
2528
"github.com/stretchr/testify/assert"
2629
"github.com/stretchr/testify/require"
30+
"go.opentelemetry.io/otel"
31+
"go.opentelemetry.io/otel/attribute"
32+
tracesdk "go.opentelemetry.io/otel/sdk/trace"
33+
"go.opentelemetry.io/otel/sdk/trace/tracetest"
2734
)
2835

2936
// ---------------------------------------------------------------------------
@@ -439,30 +446,39 @@ func TestUpgradeOneExtension(t *testing.T) {
439446
const registryURL = "https://test.example.com/registry.json"
440447

441448
tests := []struct {
442-
name string
443-
extensionId string
444-
installed map[string]*extensions.Extension
445-
registry extensions.Registry
446-
flags extensionUpgradeFlags
447-
wantStatus extensions.UpgradeStatus
448-
wantErr string
449-
wantErrSubstr string
450-
wantSkipReason string
449+
name string
450+
extensionId string
451+
installed map[string]*extensions.Extension
452+
registry extensions.Registry
453+
flags extensionUpgradeFlags
454+
wantStatus extensions.UpgradeStatus
455+
wantErr string
456+
wantErrSubstr string
457+
wantSkipReason string
458+
wantFromSourceCategory extensions.SourceCategory
459+
wantToSourceCategory extensions.SourceCategory
451460
}{
452461
{
453462
name: "skip_already_up_to_date",
454463
extensionId: "ext-a",
455464
installed: map[string]*extensions.Extension{
456-
"ext-a": {Id: "ext-a", Version: "1.0.0", Source: "test"},
465+
"ext-a": {
466+
Id: "ext-a",
467+
Version: "1.0.0",
468+
Source: "test",
469+
SourceCategory: extensions.SourceCategoryLocal,
470+
},
457471
},
458472
registry: testRegistry(
459473
testExtMeta("ext-a", "1.0.0", "test"),
460474
),
461475
flags: extensionUpgradeFlags{
462476
global: &internal.GlobalCommandOptions{NoPrompt: true},
463477
},
464-
wantStatus: extensions.UpgradeStatusSkipped,
465-
wantSkipReason: "already up to date",
478+
wantStatus: extensions.UpgradeStatusSkipped,
479+
wantSkipReason: "already up to date",
480+
wantFromSourceCategory: extensions.SourceCategoryLocal,
481+
wantToSourceCategory: extensions.SourceCategoryOther,
466482
},
467483
{
468484
name: "skip_installed_is_newer",
@@ -483,14 +499,21 @@ func TestUpgradeOneExtension(t *testing.T) {
483499
name: "skipped_delisted_extension",
484500
extensionId: "missing-ext",
485501
installed: map[string]*extensions.Extension{
486-
"missing-ext": {Id: "missing-ext", Version: "1.0.0", Source: "test"},
502+
"missing-ext": {
503+
Id: "missing-ext",
504+
Version: "1.0.0",
505+
Source: "test",
506+
SourceCategory: extensions.SourceCategoryDev,
507+
},
487508
},
488509
registry: testRegistry(), // empty registry
489510
flags: extensionUpgradeFlags{
490511
global: &internal.GlobalCommandOptions{NoPrompt: true},
491512
},
492-
wantStatus: extensions.UpgradeStatusSkipped,
493-
wantSkipReason: "extension no longer available in any configured registry",
513+
wantStatus: extensions.UpgradeStatusSkipped,
514+
wantSkipReason: "extension no longer available in any configured registry",
515+
wantFromSourceCategory: extensions.SourceCategoryDev,
516+
wantToSourceCategory: extensions.SourceCategoryDev,
494517
},
495518
{
496519
name: "failed_no_stored_or_main_source_match",
@@ -662,10 +685,149 @@ func TestUpgradeOneExtension(t *testing.T) {
662685
if tt.wantSkipReason != "" {
663686
assert.Equal(t, tt.wantSkipReason, result.SkipReason)
664687
}
688+
if tt.wantFromSourceCategory != "" {
689+
assert.Equal(t, tt.wantFromSourceCategory, result.FromSourceCategory)
690+
}
691+
if tt.wantToSourceCategory != "" {
692+
assert.Equal(t, tt.wantToSourceCategory, result.ToSourceCategory)
693+
}
665694
})
666695
}
667696
}
668697

698+
func TestExtensionLifecycleTelemetrySpans(t *testing.T) {
699+
recorder := tracetest.NewSpanRecorder()
700+
provider := tracesdk.NewTracerProvider(tracesdk.WithSpanProcessor(recorder))
701+
previousProvider := otel.GetTracerProvider()
702+
otel.SetTracerProvider(provider)
703+
t.Cleanup(func() { otel.SetTracerProvider(previousProvider) })
704+
705+
t.Run("UnresolvedUpgradeUsesPersistedCategory", func(t *testing.T) {
706+
const registryURL = "https://private.example/registry.json"
707+
const sourceName = "private-source"
708+
709+
mockContext := mocks.NewMockContext(t.Context())
710+
manager, sourceManager := createUpgradeTestManager(
711+
t,
712+
mockContext,
713+
map[string]*extensions.Extension{
714+
"missing-ext": {
715+
Id: "missing-ext",
716+
Version: "1.0.0",
717+
Source: sourceName,
718+
SourceCategory: extensions.SourceCategoryDev,
719+
},
720+
},
721+
registryURL,
722+
testRegistry(),
723+
)
724+
action := &extensionUpgradeAction{
725+
args: []string{"missing-ext"},
726+
flags: &extensionUpgradeFlags{
727+
global: &internal.GlobalCommandOptions{NoPrompt: true},
728+
},
729+
formatter: &output.JsonFormatter{},
730+
writer: &bytes.Buffer{},
731+
console: mockinput.NewMockConsole(),
732+
sourceManager: sourceManager,
733+
extensionManager: manager,
734+
}
735+
736+
result := action.upgradeOneExtension(t.Context(), "missing-ext", 0, nil, true)
737+
require.Equal(t, extensions.UpgradeStatusSkipped, result.Status)
738+
739+
span := extensionEndedSpan(t, recorder, events.ExtensionUpdateEvent)
740+
require.Equal(
741+
t,
742+
string(extensions.SourceCategoryDev),
743+
extensionSpanAttribute(t, span.Attributes(), fields.ExtensionSourceCategory.Key).Value.AsString(),
744+
)
745+
for _, attr := range span.Attributes() {
746+
require.NotContains(t, attr.Value.Emit(), sourceName)
747+
require.NotContains(t, attr.Value.Emit(), registryURL)
748+
}
749+
})
750+
751+
t.Run("PromotionUsesFixedCategories", func(t *testing.T) {
752+
emitPromotionEvent(
753+
t.Context(),
754+
"test.extension",
755+
"1.0.0",
756+
"1.1.0",
757+
extensions.SourceCategoryDev,
758+
extensions.SourceCategoryAzd,
759+
)
760+
761+
span := extensionEndedSpan(t, recorder, events.ExtensionPromoteEvent)
762+
require.Equal(
763+
t,
764+
string(extensions.SourceCategoryDev),
765+
extensionSpanAttribute(t, span.Attributes(), fields.ExtensionSourceCategoryFrom.Key).Value.AsString(),
766+
)
767+
require.Equal(
768+
t,
769+
string(extensions.SourceCategoryAzd),
770+
extensionSpanAttribute(t, span.Attributes(), fields.ExtensionSourceCategoryTo.Key).Value.AsString(),
771+
)
772+
})
773+
}
774+
775+
func TestDisplayPromotionWarning(t *testing.T) {
776+
t.Parallel()
777+
778+
console := mockinput.NewMockConsole()
779+
action := &extensionUpgradeAction{console: console}
780+
action.displayPromotionWarning(
781+
t.Context(),
782+
"Updating test.extension extension",
783+
"test.extension",
784+
"1.0.0",
785+
"1.1.0",
786+
"dev",
787+
"azd",
788+
)
789+
790+
require.Len(t, console.SpinnerOps(), 1)
791+
require.Equal(t, input.StepWarning, console.SpinnerOps()[0].Format)
792+
rendered := strings.Join(console.Output(), "\n")
793+
require.Contains(t, rendered, "Updated test.extension extension")
794+
require.Contains(t, rendered, "1.0.0")
795+
require.Contains(t, rendered, "1.1.0")
796+
require.Contains(t, rendered, "promoted from the dev registry")
797+
require.Contains(t, rendered, "official azd registry")
798+
require.Contains(t, rendered, "azd extension install test.extension --source dev")
799+
}
800+
801+
func extensionEndedSpan(
802+
t *testing.T,
803+
recorder *tracetest.SpanRecorder,
804+
name string,
805+
) tracesdk.ReadOnlySpan {
806+
t.Helper()
807+
for _, span := range recorder.Ended() {
808+
if span.Name() == name {
809+
return span
810+
}
811+
}
812+
require.FailNow(t, "telemetry span not found", "name: %s", name)
813+
return nil
814+
}
815+
816+
func extensionSpanAttribute(
817+
t *testing.T,
818+
attributes []attribute.KeyValue,
819+
key attribute.Key,
820+
) attribute.KeyValue {
821+
t.Helper()
822+
for _, attr := range attributes {
823+
if attr.Key == key {
824+
return attr
825+
}
826+
}
827+
require.FailNow(t, "telemetry attribute not found", "key: %s", key)
828+
return attribute.KeyValue{}
829+
}
830+
669831
// TestUpgradeAction_MixedBatch tests a batch with some skip, some fail.
670832
func TestUpgradeAction_MixedBatch(t *testing.T) {
671833
t.Parallel()

0 commit comments

Comments
 (0)