From 406edb8a3e9ef839699b9903dd60d8f4c76217d8 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 22 Jul 2026 16:45:34 +0800 Subject: [PATCH 1/4] feat: add host-validated extension telemetry gRPC service --- cli/azd/cmd/container.go | 1 + cli/azd/cmd/middleware/telemetry.go | 16 ++ cli/azd/cmd/telemetry_test.go | 13 + cli/azd/grpc/proto/telemetry.proto | 33 +++ cli/azd/internal/cmd/from_package_test.go | 54 ++++ cli/azd/internal/cmd/publish.go | 28 +- cli/azd/internal/cmd/service_graph.go | 7 +- .../grpcserver/prompt_service_test.go | 1 + cli/azd/internal/grpcserver/server.go | 4 + cli/azd/internal/grpcserver/server_test.go | 74 +++++- .../internal/grpcserver/telemetry_service.go | 131 +++++++++ .../grpcserver/telemetry_service_test.go | 250 ++++++++++++++++++ cli/azd/internal/tracing/command_usage.go | 147 ++++++++++ .../internal/tracing/command_usage_test.go | 222 ++++++++++++++++ cli/azd/internal/tracing/fields/fields.go | 13 + cli/azd/pkg/azdext/artifact_metadata.go | 9 + cli/azd/pkg/azdext/azd_client.go | 11 + cli/azd/pkg/azdext/telemetry.pb.go | 189 +++++++++++++ cli/azd/pkg/azdext/telemetry/attributes.go | 28 ++ .../pkg/azdext/telemetry/attributes_test.go | 28 ++ cli/azd/pkg/azdext/telemetry_grpc.pb.go | 140 ++++++++++ cli/azd/pkg/project/artifact.go | 7 + 22 files changed, 1397 insertions(+), 9 deletions(-) create mode 100644 cli/azd/grpc/proto/telemetry.proto create mode 100644 cli/azd/internal/cmd/from_package_test.go create mode 100644 cli/azd/internal/grpcserver/telemetry_service.go create mode 100644 cli/azd/internal/grpcserver/telemetry_service_test.go create mode 100644 cli/azd/internal/tracing/command_usage.go create mode 100644 cli/azd/internal/tracing/command_usage_test.go create mode 100644 cli/azd/pkg/azdext/artifact_metadata.go create mode 100644 cli/azd/pkg/azdext/telemetry.pb.go create mode 100644 cli/azd/pkg/azdext/telemetry/attributes.go create mode 100644 cli/azd/pkg/azdext/telemetry/attributes_test.go create mode 100644 cli/azd/pkg/azdext/telemetry_grpc.pb.go diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 4aa90b41155..61a0c5194b2 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -1035,6 +1035,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { ) container.MustRegisterSingleton(grpcserver.NewAiModelService) container.MustRegisterScoped(grpcserver.NewCopilotService) + container.MustRegisterSingleton(grpcserver.NewTelemetryService) // Required for nested actions called from composite actions like 'up' registerAction[*cmd.ProvisionAction](container, "azd-provision-action") diff --git a/cli/azd/cmd/middleware/telemetry.go b/cli/azd/cmd/middleware/telemetry.go index 1a105878c47..7033343dd7b 100644 --- a/cli/azd/cmd/middleware/telemetry.go +++ b/cli/azd/cmd/middleware/telemetry.go @@ -65,6 +65,13 @@ func (m *TelemetryMiddleware) Run(ctx context.Context, next NextFn) (*actions.Ac spanCtx, span := tracing.Start(ctx, eventName) + // Begin a command usage scope keyed by this exact event name. Extensions + // contribute host-validated usage attributes through the telemetry gRPC + // service, which routes them to the current scope. Closing the scope below + // attaches those values to this command span only, so they never leak onto + // synthetic child spans or sibling commands. + usageScope := tracing.BeginCommandUsageScope(eventName) + log.Printf("TraceID: %s", span.SpanContext().TraceID()) if !IsChildAction(ctx) { @@ -103,6 +110,15 @@ func (m *TelemetryMiddleware) Run(ctx context.Context, next NextFn) (*actions.Ac m.setInstalledExtensionsAttributes(span) defer func() { + // Attach any command-scoped usage attributes reported by extensions, + // then close the scope. A close error is unexpected and only logged; + // it never changes the command result. + if commandAttrs, closeErr := tracing.CloseCommandUsageScope(usageScope); closeErr != nil { + log.Printf("closing command usage scope: %v", closeErr) + } else { + span.SetAttributes(commandAttrs...) + } + // Include any usage attributes set span.SetAttributes(tracing.GetUsageAttributes()...) span.SetAttributes(fields.PerfInteractTime.Int64(tracing.InteractTimeMs.Load())) diff --git a/cli/azd/cmd/telemetry_test.go b/cli/azd/cmd/telemetry_test.go index 18328955e78..54b92b6d854 100644 --- a/cli/azd/cmd/telemetry_test.go +++ b/cli/azd/cmd/telemetry_test.go @@ -83,6 +83,19 @@ func TestTelemetryFieldConstants(t *testing.T) { } }) + // Agent deployment mode telemetry field (contributed by extensions through + // the host-validated telemetry service). + t.Run("AgentDeploymentModeField", func(t *testing.T) { + t.Parallel() + require.Equal(t, "agent.deploy.mode", string(fields.AgentDeploymentModeKey.Key)) + require.Equal(t, fields.SystemMetadata, fields.AgentDeploymentModeKey.Classification) + require.Equal(t, fields.FeatureInsight, fields.AgentDeploymentModeKey.Purpose) + require.False(t, fields.AgentDeploymentModeKey.IsMeasurement) + + kv := fields.AgentDeploymentModeKey.StringSlice([]string{"code", "container", "byo_image"}) + require.Equal(t, []string{"code", "container", "byo_image"}, kv.Value.AsStringSlice()) + }) + // Tool command telemetry fields t.Run("ToolFields", func(t *testing.T) { t.Parallel() diff --git a/cli/azd/grpc/proto/telemetry.proto b/cli/azd/grpc/proto/telemetry.proto new file mode 100644 index 00000000000..8c6e20faa96 --- /dev/null +++ b/cli/azd/grpc/proto/telemetry.proto @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +syntax = "proto3"; + +package azdext; + +option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; + +// TelemetryService accepts reviewed, host-owned command usage attribute values +// from authenticated extensions. The host validates the key and value against +// an allowlist and attaches accepted values to the current command telemetry +// event. Extensions cannot choose classification, purpose, hashing, or +// aggregation. +service TelemetryService { + // AddCommandUsageAttribute adds one bounded value to a host-owned command + // usage attribute. Duplicate values are collapsed by the host. + rpc AddCommandUsageAttribute(AddCommandUsageAttributeRequest) + returns (AddCommandUsageAttributeResponse); +} + +message AddCommandUsageAttributeRequest { + // Key must be registered and owned by the host. + string key = 1; + + // Value must be in the host-owned allowed set for key. + string value = 2; +} + +message AddCommandUsageAttributeResponse { + // Accepted is false when the value was rejected because no eligible command + // scope is currently active. A false response is not an error. + bool accepted = 1; +} diff --git a/cli/azd/internal/cmd/from_package_test.go b/cli/azd/internal/cmd/from_package_test.go new file mode 100644 index 00000000000..2f0e528a544 --- /dev/null +++ b/cli/azd/internal/cmd/from_package_test.go @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/project" +) + +// TestDetermineArtifactKind locks --from-package classification, including the +// compound archive suffixes that filepath.Ext cannot recognize. +func TestDetermineArtifactKind(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + archiveNames := []string{ + "pkg.zip", "pkg.tar", "pkg.tgz", "pkg.txz", "pkg.tbz2", + "pkg.tar.gz", "pkg.tar.bz2", "pkg.tar.xz", + "PKG.TAR.GZ", // classification is case-insensitive + } + for _, name := range archiveNames { + p := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(p, []byte("x"), 0o600)) + require.Equalf(t, project.ArtifactKindArchive, determineArtifactKind(p), + "expected archive for %q", name) + } + + // An existing directory is a directory artifact. + require.Equal(t, project.ArtifactKindDirectory, determineArtifactKind(dir)) + + // A path that does not exist is treated as a container image reference. + require.Equal(t, project.ArtifactKindContainer, + determineArtifactKind("myregistry.azurecr.io/app:latest")) + + // An existing non-archive file is also treated as a container reference. + other := filepath.Join(dir, "notanarchive.bin") + require.NoError(t, os.WriteFile(other, []byte("x"), 0o600)) + require.Equal(t, project.ArtifactKindContainer, determineArtifactKind(other)) +} + +// TestFromPackageMetadataConstant keeps the core alias and the SDK constant in +// sync so core and extensions agree on the provenance marker. +func TestFromPackageMetadataConstant(t *testing.T) { + t.Parallel() + require.Equal(t, "azd.fromPackage", project.MetadataKeyFromPackage) + require.Equal(t, azdext.ArtifactMetadataKeyFromPackage, project.MetadataKeyFromPackage) +} diff --git a/cli/azd/internal/cmd/publish.go b/cli/azd/internal/cmd/publish.go index 4d109640e1c..bdae32d64dc 100644 --- a/cli/azd/internal/cmd/publish.go +++ b/cli/azd/internal/cmd/publish.go @@ -9,7 +9,6 @@ import ( "io" "log" "os" - "path/filepath" "slices" "strings" "time" @@ -286,11 +285,16 @@ func (pa *PublishAction) Run(ctx context.Context) (*actions.ActionResult, error) serviceContext := &project.ServiceContext{} if pa.flags.FromPackage != "" { - // --from-package set, skip packaging and create package artifact + // --from-package set, skip packaging and create package artifact. + // Mark it with generic provenance so a service target can tell a + // caller-supplied payload from one azd produced. err = serviceContext.Package.Add(&project.Artifact{ Kind: determineArtifactKind(pa.flags.FromPackage), Location: pa.flags.FromPackage, LocationKind: project.LocationKindLocal, + Metadata: map[string]string{ + project.MetadataKeyFromPackage: "true", + }, }) if err != nil { @@ -411,13 +415,23 @@ func (pa *PublishAction) supportsPublish(ctx context.Context, serviceConfig *pro // 1. Archive (zip file) - checks if it exists and matches popular archive extensions // 2. Directory - checks if it is an existing directory (absolute or relative) // 3. Container - otherwise, it's likely a local container reference +// archiveSuffixes are the file suffixes treated as a code archive for +// --from-package classification. Compound suffixes such as ".tar.gz" are +// matched with strings.HasSuffix because filepath.Ext only returns the final +// segment (".gz") and would misclassify them as a container reference. +var archiveSuffixes = []string{ + ".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz", +} + func determineArtifactKind(fromPackage string) project.ArtifactKind { - // Check if it's an existing file with archive extension + lower := strings.ToLower(fromPackage) + + // Check if it's an existing file with an archive suffix. if info, err := os.Stat(fromPackage); err == nil && !info.IsDir() { - ext := strings.ToLower(filepath.Ext(fromPackage)) - switch ext { - case ".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz": - return project.ArtifactKindArchive + for _, suffix := range archiveSuffixes { + if strings.HasSuffix(lower, suffix) { + return project.ArtifactKindArchive + } } } diff --git a/cli/azd/internal/cmd/service_graph.go b/cli/azd/internal/cmd/service_graph.go index f8adcbfa3aa..0e228159b11 100644 --- a/cli/azd/internal/cmd/service_graph.go +++ b/cli/azd/internal/cmd/service_graph.go @@ -373,11 +373,16 @@ func addServiceStepsToGraph(g *exegraph.Graph, opts serviceGraphOptions) (*servi if opts.fromPackage != "" { // --from-package bypasses the packager and wraps the - // user-supplied artifact directly. + // user-supplied artifact directly. Mark it with generic + // provenance so a service target can distinguish a + // caller-supplied payload from one azd produced. if pkgErr := sc.Package.Add(&project.Artifact{ Kind: determineArtifactKind(opts.fromPackage), Location: opts.fromPackage, LocationKind: project.LocationKindLocal, + Metadata: map[string]string{ + project.MetadataKeyFromPackage: "true", + }, }); pkgErr != nil { return fmt.Errorf("packaging service %s: %w", pkgSvc.Name, pkgErr) } diff --git a/cli/azd/internal/grpcserver/prompt_service_test.go b/cli/azd/internal/grpcserver/prompt_service_test.go index 99868e564c9..5fac998dc8b 100644 --- a/cli/azd/internal/grpcserver/prompt_service_test.go +++ b/cli/azd/internal/grpcserver/prompt_service_test.go @@ -622,6 +622,7 @@ func setupTestServer(t *testing.T, promptSvc azdext.PromptServiceServer) ( azdext.UnimplementedCopilotServiceServer{}, azdext.UnimplementedProvisioningServiceServer{}, azdext.UnimplementedValidationServiceServer{}, + azdext.UnimplementedTelemetryServiceServer{}, ) serverInfo, err := server.Start() diff --git a/cli/azd/internal/grpcserver/server.go b/cli/azd/internal/grpcserver/server.go index 48779be0d8d..7215343dc70 100644 --- a/cli/azd/internal/grpcserver/server.go +++ b/cli/azd/internal/grpcserver/server.go @@ -44,6 +44,7 @@ type Server struct { copilotService azdext.CopilotServiceServer provisioningService azdext.ProvisioningServiceServer validationService azdext.ValidationServiceServer + telemetryService azdext.TelemetryServiceServer } func NewServer( @@ -64,6 +65,7 @@ func NewServer( copilotService azdext.CopilotServiceServer, provisioningService azdext.ProvisioningServiceServer, validationService azdext.ValidationServiceServer, + telemetryService azdext.TelemetryServiceServer, ) *Server { return &Server{ projectService: projectService, @@ -83,6 +85,7 @@ func NewServer( copilotService: copilotService, provisioningService: provisioningService, validationService: validationService, + telemetryService: telemetryService, } } @@ -132,6 +135,7 @@ func (s *Server) Start() (*ServerInfo, error) { azdext.RegisterCopilotServiceServer(s.grpcServer, s.copilotService) azdext.RegisterProvisioningServiceServer(s.grpcServer, s.provisioningService) azdext.RegisterValidationServiceServer(s.grpcServer, s.validationService) + azdext.RegisterTelemetryServiceServer(s.grpcServer, s.telemetryService) serverInfo.Address = fmt.Sprintf("127.0.0.1:%d", randomPort) serverInfo.Port = randomPort diff --git a/cli/azd/internal/grpcserver/server_test.go b/cli/azd/internal/grpcserver/server_test.go index 5289e31344e..43951327022 100644 --- a/cli/azd/internal/grpcserver/server_test.go +++ b/cli/azd/internal/grpcserver/server_test.go @@ -23,10 +23,12 @@ import ( "google.golang.org/protobuf/proto" "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/auth" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/azdext/telemetry" "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/prompt" @@ -53,6 +55,7 @@ func Test_Server_Start(t *testing.T) { azdext.UnimplementedCopilotServiceServer{}, azdext.UnimplementedProvisioningServiceServer{}, azdext.UnimplementedValidationServiceServer{}, + NewTelemetryService(), ) serverInfo, err := server.Start() @@ -119,6 +122,74 @@ func Test_Server_Start(t *testing.T) { require.True(t, ok) require.Equal(t, codes.Unauthenticated, st.Code()) }) + + t.Run("TelemetryAccepted", func(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + stExtension := &extensions.Extension{ + Id: "azd.internal.telemetry", + Capabilities: []extensions.CapabilityType{ + extensions.ServiceTargetProviderCapability, + }, + Namespace: "test", + } + accessToken, err := GenerateExtensionToken(stExtension, serverInfo) + require.NoError(t, err) + + ctx := azdext.WithAccessToken(t.Context(), accessToken) + client, err := azdext.NewAzdClient(azdext.WithAddress(serverInfo.Address)) + require.NoError(t, err) + + // The server runs in-process, so the handler writes to this scope. + scope := tracing.BeginCommandUsageScope("cmd.deploy") + + resp, err := client.Telemetry().AddCommandUsageAttribute(ctx, &azdext.AddCommandUsageAttributeRequest{ + Key: telemetry.AgentDeploymentModeAttribute, + Value: string(telemetry.AgentDeploymentModeCode), + }) + require.NoError(t, err) + require.True(t, resp.Accepted) + + attrs, err := tracing.CloseCommandUsageScope(scope) + require.NoError(t, err) + require.Len(t, attrs, 1) + require.Equal(t, []string{"code"}, attrs[0].Value.AsStringSlice()) + }) + + t.Run("TelemetryMissingCapability", func(t *testing.T) { + // The base extension only declares CustomCommandCapability. + accessToken, err := GenerateExtensionToken(extension, serverInfo) + require.NoError(t, err) + + ctx := azdext.WithAccessToken(t.Context(), accessToken) + client, err := azdext.NewAzdClient(azdext.WithAddress(serverInfo.Address)) + require.NoError(t, err) + + _, err = client.Telemetry().AddCommandUsageAttribute(ctx, &azdext.AddCommandUsageAttributeRequest{ + Key: telemetry.AgentDeploymentModeAttribute, + Value: string(telemetry.AgentDeploymentModeCode), + }) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.PermissionDenied, st.Code()) + }) + + t.Run("TelemetryMissingToken", func(t *testing.T) { + client, err := azdext.NewAzdClient(azdext.WithAddress(serverInfo.Address)) + require.NoError(t, err) + + _, err = client.Telemetry().AddCommandUsageAttribute( + t.Context(), + &azdext.AddCommandUsageAttributeRequest{ + Key: telemetry.AgentDeploymentModeAttribute, + Value: string(telemetry.AgentDeploymentModeCode), + }, + ) + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, codes.Unauthenticated, st.Code()) + }) } // Test_Server_StreamInterceptor validates that the streaming RPC interceptor @@ -142,6 +213,7 @@ func Test_Server_StreamInterceptor(t *testing.T) { azdext.UnimplementedCopilotServiceServer{}, azdext.UnimplementedProvisioningServiceServer{}, azdext.UnimplementedValidationServiceServer{}, + azdext.UnimplementedTelemetryServiceServer{}, ) serverInfo, err := server.Start() @@ -618,7 +690,7 @@ func TestValidateAuthToken_InvalidToken(t *testing.T) { func TestNewServer(t *testing.T) { t.Parallel() - s := NewServer(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + s := NewServer(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) require.NotNil(t, s) assert.Nil(t, s.grpcServer, "grpcServer should be nil before Start") } diff --git a/cli/azd/internal/grpcserver/telemetry_service.go b/cli/azd/internal/grpcserver/telemetry_service.go new file mode 100644 index 00000000000..e96499c223b --- /dev/null +++ b/cli/azd/internal/grpcserver/telemetry_service.go @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package grpcserver + +import ( + "context" + "slices" + + "github.com/azure/azure-dev/cli/azd/internal/tracing" + "github.com/azure/azure-dev/cli/azd/internal/tracing/events" + "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/azdext/telemetry" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// maxTelemetryFieldLength bounds the accepted key and value length. This is a +// defensive limit against oversized input, not a privacy control. The value +// allowlist is the privacy control. +const maxTelemetryFieldLength = 128 + +// commandUsageFieldPolicy is the host-owned policy for one extension-settable +// command usage attribute. +type commandUsageFieldPolicy struct { + key fields.AttributeKey + allowedValues map[string]struct{} + eligibleEvents map[string]struct{} + requiredCapabilities map[extensions.CapabilityType]struct{} +} + +// extensionUsageFields is the allowlist of command usage attributes an +// authenticated extension may contribute. The host owns every field's key, +// classification, allowed values, eligible command events, and required +// capabilities. No extension identifier is hardcoded: eligibility is expressed +// through signed extension capabilities and the fixed value set. +var extensionUsageFields = map[string]commandUsageFieldPolicy{ + telemetry.AgentDeploymentModeAttribute: { + key: fields.AgentDeploymentModeKey, + allowedValues: map[string]struct{}{ + string(telemetry.AgentDeploymentModeCode): {}, + string(telemetry.AgentDeploymentModeContainer): {}, + string(telemetry.AgentDeploymentModeByoImage): {}, + }, + eligibleEvents: map[string]struct{}{ + events.GetCommandEventName("azd deploy"): {}, + events.GetCommandEventName("azd up"): {}, + }, + requiredCapabilities: map[extensions.CapabilityType]struct{}{ + extensions.ServiceTargetProviderCapability: {}, + }, + }, +} + +// telemetryService implements azdext.TelemetryServiceServer. +type telemetryService struct { + azdext.UnimplementedTelemetryServiceServer + fields map[string]commandUsageFieldPolicy +} + +// NewTelemetryService creates the telemetry gRPC service. It holds no injected +// state; the handler reaches the process-global command usage store through the +// tracing package. Returning the interface type lets the IoC container satisfy +// the azdext.TelemetryServiceServer parameter on NewServer without an adapter. +func NewTelemetryService() azdext.TelemetryServiceServer { + return newTelemetryService(extensionUsageFields) +} + +func newTelemetryService(fieldPolicies map[string]commandUsageFieldPolicy) *telemetryService { + return &telemetryService{fields: fieldPolicies} +} + +// AddCommandUsageAttribute validates an authenticated extension's request +// against the host allowlist and, when valid, appends the value to the current +// command usage scope. It fails closed: unknown keys, missing capabilities, +// invalid policies, and disallowed values are all rejected before anything is +// recorded. Rejected caller text is never echoed into the returned error. +func (s *telemetryService) AddCommandUsageAttribute( + ctx context.Context, + req *azdext.AddCommandUsageAttributeRequest, +) (*azdext.AddCommandUsageAttributeResponse, error) { + claims, err := extensions.GetClaimsFromContext(ctx) + if err != nil { + return nil, status.Error(codes.Unauthenticated, "validated extension claims are required") + } + + if req == nil || + req.Key == "" || req.Value == "" || + len(req.Key) > maxTelemetryFieldLength || len(req.Value) > maxTelemetryFieldLength { + return nil, status.Error(codes.InvalidArgument, "telemetry key and value are required") + } + + policy, ok := s.fields[req.Key] + if !ok { + return nil, status.Error(codes.InvalidArgument, "telemetry key is not registered") + } + + if !hasRequiredCapability(claims, policy.requiredCapabilities) { + return nil, status.Error(codes.PermissionDenied, "extension lacks the required capability") + } + + if len(policy.allowedValues) == 0 || len(policy.eligibleEvents) == 0 { + return nil, status.Error(codes.Internal, "telemetry field policy is invalid") + } + + if _, ok := policy.allowedValues[req.Value]; !ok { + return nil, status.Error(codes.InvalidArgument, "telemetry value is not allowed") + } + + accepted := tracing.TryAppendCommandUsageUnique(policy.eligibleEvents, policy.key.Key, req.Value) + + return &azdext.AddCommandUsageAttributeResponse{Accepted: accepted}, nil +} + +// hasRequiredCapability reports whether the signed extension claims carry every +// required capability. Capabilities are part of the host-signed token, so they +// are trustworthy without a separate manager lookup. +func hasRequiredCapability( + claims *extensions.ExtensionClaims, + required map[extensions.CapabilityType]struct{}, +) bool { + for capability := range required { + if !slices.Contains(claims.Capabilities, capability) { + return false + } + } + + return true +} diff --git a/cli/azd/internal/grpcserver/telemetry_service_test.go b/cli/azd/internal/grpcserver/telemetry_service_test.go new file mode 100644 index 00000000000..abea91e2b6c --- /dev/null +++ b/cli/azd/internal/grpcserver/telemetry_service_test.go @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package grpcserver + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "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/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/azdext/telemetry" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" +) + +func claimsContext(caps ...extensions.CapabilityType) context.Context { + claims := &extensions.ExtensionClaims{ + RegisteredClaims: jwt.RegisteredClaims{Subject: "test.extension"}, + Capabilities: caps, + } + return extensions.WithClaimsContext(context.Background(), claims) +} + +func serviceTargetClaims() context.Context { + return claimsContext(extensions.ServiceTargetProviderCapability) +} + +func validRequest(value string) *azdext.AddCommandUsageAttributeRequest { + return &azdext.AddCommandUsageAttributeRequest{ + Key: telemetry.AgentDeploymentModeAttribute, + Value: value, + } +} + +func TestTelemetryService_MissingClaims(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + svc := NewTelemetryService() + _, err := svc.AddCommandUsageAttribute(context.Background(), validRequest("code")) + require.Equal(t, codes.Unauthenticated, status.Code(err)) +} + +func TestTelemetryService_MissingCapability(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + scope := tracing.BeginCommandUsageScope("cmd.deploy") + t.Cleanup(func() { _, _ = tracing.CloseCommandUsageScope(scope) }) + + svc := NewTelemetryService() + // Authenticated but without the service-target-provider capability. + _, err := svc.AddCommandUsageAttribute(claimsContext(), validRequest("code")) + require.Equal(t, codes.PermissionDenied, status.Code(err)) +} + +func TestTelemetryService_InvalidArguments(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + svc := NewTelemetryService() + ctx := serviceTargetClaims() + + cases := map[string]*azdext.AddCommandUsageAttributeRequest{ + "nil request": nil, + "empty key": {Key: "", Value: "code"}, + "empty value": {Key: telemetry.AgentDeploymentModeAttribute, Value: ""}, + "oversize key": {Key: strings.Repeat("k", maxTelemetryFieldLength+1), Value: "code"}, + "oversize value": { + Key: telemetry.AgentDeploymentModeAttribute, + Value: strings.Repeat("v", maxTelemetryFieldLength+1), + }, + "unknown key": {Key: "some.other.key", Value: "code"}, + "invalid enum": validRequest("bogus"), + } + + for name, req := range cases { + t.Run(name, func(t *testing.T) { + _, err := svc.AddCommandUsageAttribute(ctx, req) + require.Equal(t, codes.InvalidArgument, status.Code(err), "case %q", name) + }) + } +} + +func TestTelemetryService_InvalidPolicyIsInternal(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + ctx := serviceTargetClaims() + capSet := map[extensions.CapabilityType]struct{}{ + extensions.ServiceTargetProviderCapability: {}, + } + + t.Run("empty allowed values", func(t *testing.T) { + svc := newTelemetryService(map[string]commandUsageFieldPolicy{ + telemetry.AgentDeploymentModeAttribute: { + key: fields.AgentDeploymentModeKey, + allowedValues: map[string]struct{}{}, + eligibleEvents: map[string]struct{}{"cmd.deploy": {}}, + requiredCapabilities: capSet, + }, + }) + _, err := svc.AddCommandUsageAttribute(ctx, validRequest("code")) + require.Equal(t, codes.Internal, status.Code(err)) + }) + + t.Run("empty eligible events", func(t *testing.T) { + svc := newTelemetryService(map[string]commandUsageFieldPolicy{ + telemetry.AgentDeploymentModeAttribute: { + key: fields.AgentDeploymentModeKey, + allowedValues: map[string]struct{}{"code": {}}, + eligibleEvents: map[string]struct{}{}, + requiredCapabilities: capSet, + }, + }) + _, err := svc.AddCommandUsageAttribute(ctx, validRequest("code")) + require.Equal(t, codes.Internal, status.Code(err)) + }) +} + +func TestTelemetryService_EligibleScopesAccept(t *testing.T) { + for _, eventName := range []string{"cmd.deploy", "cmd.up"} { + t.Run(eventName, func(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + scope := tracing.BeginCommandUsageScope(eventName) + svc := NewTelemetryService() + + resp, err := svc.AddCommandUsageAttribute(serviceTargetClaims(), validRequest("code")) + require.NoError(t, err) + require.True(t, resp.Accepted) + + attrs, err := tracing.CloseCommandUsageScope(scope) + require.NoError(t, err) + require.Len(t, attrs, 1) + require.Equal(t, telemetry.AgentDeploymentModeAttribute, string(attrs[0].Key)) + require.Equal(t, []string{"code"}, attrs[0].Value.AsStringSlice()) + }) + } +} + +func TestTelemetryService_IneligibleScopeNotAccepted(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + scope := tracing.BeginCommandUsageScope("cmd.package") + svc := NewTelemetryService() + + resp, err := svc.AddCommandUsageAttribute(serviceTargetClaims(), validRequest("code")) + require.NoError(t, err) + require.False(t, resp.Accepted) + + attrs, err := tracing.CloseCommandUsageScope(scope) + require.NoError(t, err) + require.Empty(t, attrs) +} + +func TestTelemetryService_NoActiveScopeNotAccepted(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + svc := NewTelemetryService() + resp, err := svc.AddCommandUsageAttribute(serviceTargetClaims(), validRequest("code")) + require.NoError(t, err) + require.False(t, resp.Accepted) +} + +func TestTelemetryService_DuplicateCollapses(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + scope := tracing.BeginCommandUsageScope("cmd.deploy") + svc := NewTelemetryService() + ctx := serviceTargetClaims() + + for i := 0; i < 3; i++ { + resp, err := svc.AddCommandUsageAttribute(ctx, validRequest("code")) + require.NoError(t, err) + require.True(t, resp.Accepted) + } + + attrs, err := tracing.CloseCommandUsageScope(scope) + require.NoError(t, err) + require.Len(t, attrs, 1) + require.Equal(t, []string{"code"}, attrs[0].Value.AsStringSlice()) +} + +func TestTelemetryService_ConcurrentReports(t *testing.T) { + tracing.ResetCommandUsageForTest() + t.Cleanup(tracing.ResetCommandUsageForTest) + + scope := tracing.BeginCommandUsageScope("cmd.up") + svc := NewTelemetryService() + ctx := serviceTargetClaims() + + modes := []string{"code", "container", "byo_image"} + var wg sync.WaitGroup + for i := 0; i < 60; i++ { + value := modes[i%len(modes)] + wg.Add(1) + go func() { + defer wg.Done() + _, err := svc.AddCommandUsageAttribute(ctx, validRequest(value)) + require.NoError(t, err) + }() + } + wg.Wait() + + attrs, err := tracing.CloseCommandUsageScope(scope) + require.NoError(t, err) + require.Len(t, attrs, 1) + require.ElementsMatch(t, modes, attrs[0].Value.AsStringSlice()) +} + +// TestExtensionUsageFieldsInvariants guards the production allowlist so a future +// field cannot accidentally open a free-form or unauthenticated path. +func TestExtensionUsageFieldsInvariants(t *testing.T) { + t.Parallel() + + require.NotEmpty(t, extensionUsageFields) + + for registryKey, policy := range extensionUsageFields { + require.NotEmpty(t, registryKey) + require.Equal(t, registryKey, string(policy.key.Key), + "policy key must match its registry key") + require.NotEmpty(t, string(policy.key.Classification), "classification must be set") + require.NotEmpty(t, string(policy.key.Purpose), "purpose must be set") + + require.NotEmpty(t, policy.allowedValues, "allowed values must be set") + for value := range policy.allowedValues { + require.NotEmpty(t, value, "allowed value must not be empty") + } + + require.NotEmpty(t, policy.eligibleEvents, "eligible events must be set") + for event := range policy.eligibleEvents { + require.NotEmpty(t, event, "eligible event must not be empty") + } + + require.NotEmpty(t, policy.requiredCapabilities, "required capabilities must be set") + } +} diff --git a/cli/azd/internal/tracing/command_usage.go b/cli/azd/internal/tracing/command_usage.go new file mode 100644 index 00000000000..67b4bbfe33c --- /dev/null +++ b/cli/azd/internal/tracing/command_usage.go @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tracing + +import ( + "fmt" + "slices" + "strings" + "sync" + + "go.opentelemetry.io/otel/attribute" +) + +// commandUsage is the process-global command usage scope stack. It mirrors the +// package-global usageVal store: a single process-wide instance reachable from +// the command middleware and from any gRPC handler goroutine without dependency +// injection. Extension-contributed command usage attributes are held here and +// attached to the owning command span when its scope closes. +var commandUsage = commandUsageStore{} + +type commandUsageStore struct { + mu sync.Mutex + nextID uint64 + scopes []*commandUsageScope +} + +type commandUsageScope struct { + id uint64 + eventName string + values map[attribute.Key]map[string]struct{} +} + +// CommandUsageScope is an opaque handle to a pushed command usage scope. The +// command middleware holds it between BeginCommandUsageScope and +// CloseCommandUsageScope. +type CommandUsageScope struct { + id uint64 +} + +// BeginCommandUsageScope pushes a new command usage scope for eventName and +// returns its handle. Every command telemetry middleware invocation begins +// exactly one scope, including nested child actions, so the top of the stack +// always identifies the command that owns any values reported while it runs. +func BeginCommandUsageScope(eventName string) CommandUsageScope { + commandUsage.mu.Lock() + defer commandUsage.mu.Unlock() + + commandUsage.nextID++ + id := commandUsage.nextID + commandUsage.scopes = append(commandUsage.scopes, &commandUsageScope{ + id: id, + eventName: eventName, + values: map[attribute.Key]map[string]struct{}{}, + }) + + return CommandUsageScope{id: id} +} + +// TryAppendCommandUsageUnique appends value to the current (top) scope when its +// exact event name is present in eligibleEvents. It returns false when no scope +// is active or the current scope is not eligible. It never falls back to an +// ancestor scope, so a value reported while an ineligible command (for example +// a synthetic child cmd.package) is on top is discarded rather than attributed +// to a parent command. +func TryAppendCommandUsageUnique( + eligibleEvents map[string]struct{}, + key attribute.Key, + value string, +) bool { + commandUsage.mu.Lock() + defer commandUsage.mu.Unlock() + + if len(commandUsage.scopes) == 0 { + return false + } + + top := commandUsage.scopes[len(commandUsage.scopes)-1] + if _, ok := eligibleEvents[top.eventName]; !ok { + return false + } + + set, ok := top.values[key] + if !ok { + set = map[string]struct{}{} + top.values[key] = set + } + set[value] = struct{}{} + + return true +} + +// CloseCommandUsageScope pops the scope identified by scope and returns its +// accumulated attributes as deterministically sorted string slices. It returns +// an error when the handle does not identify the current top scope, which +// covers a repeated or out-of-order close. On error the stack is left +// unchanged so a balanced close by the true owner still succeeds. +func CloseCommandUsageScope(scope CommandUsageScope) ([]attribute.KeyValue, error) { + commandUsage.mu.Lock() + defer commandUsage.mu.Unlock() + + n := len(commandUsage.scopes) + if n == 0 { + return nil, fmt.Errorf("no active command usage scope to close") + } + + top := commandUsage.scopes[n-1] + if top.id != scope.id { + return nil, fmt.Errorf( + "command usage scope is not the current scope; expected %d, got %d", + top.id, scope.id, + ) + } + + commandUsage.scopes = commandUsage.scopes[:n-1] + + keys := make([]attribute.Key, 0, len(top.values)) + for k := range top.values { + keys = append(keys, k) + } + slices.SortFunc(keys, func(a, b attribute.Key) int { + return strings.Compare(string(a), string(b)) + }) + + attrs := make([]attribute.KeyValue, 0, len(keys)) + for _, k := range keys { + valuesSet := top.values[k] + values := make([]string, 0, len(valuesSet)) + for v := range valuesSet { + values = append(values, v) + } + slices.Sort(values) + attrs = append(attrs, k.StringSlice(values)) + } + + return attrs, nil +} + +// ResetCommandUsageForTest clears all command usage scopes. Command usage state +// is process-global; tests that rely on it must not run with t.Parallel(). +func ResetCommandUsageForTest() { + commandUsage.mu.Lock() + defer commandUsage.mu.Unlock() + + commandUsage.scopes = nil + commandUsage.nextID = 0 +} diff --git a/cli/azd/internal/tracing/command_usage_test.go b/cli/azd/internal/tracing/command_usage_test.go new file mode 100644 index 00000000000..195bf71e149 --- /dev/null +++ b/cli/azd/internal/tracing/command_usage_test.go @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package tracing + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" +) + +const testUsageKey = attribute.Key("agent.deploy.mode") + +func testEligibleEvents() map[string]struct{} { + return map[string]struct{}{ + "cmd.deploy": {}, + "cmd.up": {}, + } +} + +// closeValues closes the scope and returns the string-slice value for the +// telemetry key, or nil when the key was not recorded. +func closeValues(t *testing.T, scope CommandUsageScope) []string { + t.Helper() + + attrs, err := CloseCommandUsageScope(scope) + require.NoError(t, err) + + for _, attr := range attrs { + if attr.Key == testUsageKey { + return attr.Value.AsStringSlice() + } + } + + return nil +} + +func TestCommandUsage_NoActiveScope(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + ok := TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code") + require.False(t, ok) +} + +func TestCommandUsage_EligibleDeployScope(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + scope := BeginCommandUsageScope("cmd.deploy") + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) + require.Equal(t, []string{"code"}, closeValues(t, scope)) +} + +func TestCommandUsage_EligibleUpScope(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + scope := BeginCommandUsageScope("cmd.up") + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "container")) + require.Equal(t, []string{"container"}, closeValues(t, scope)) +} + +func TestCommandUsage_DuplicateValueCollapses(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + scope := BeginCommandUsageScope("cmd.deploy") + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) + require.Equal(t, []string{"code"}, closeValues(t, scope)) +} + +func TestCommandUsage_TwoValuesSortedUnique(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + scope := BeginCommandUsageScope("cmd.up") + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "container")) + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) + // Sorted regardless of insertion order. + require.Equal(t, []string{"code", "container"}, closeValues(t, scope)) +} + +func TestCommandUsage_ConcurrentValues(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + scope := BeginCommandUsageScope("cmd.up") + + modes := []string{"code", "container", "byo_image"} + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + mode := modes[i%len(modes)] + wg.Add(1) + go func() { + defer wg.Done() + TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, mode) + }() + } + wg.Wait() + + require.ElementsMatch(t, modes, closeValues(t, scope)) +} + +func TestCommandUsage_IneligiblePackageScope(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + scope := BeginCommandUsageScope("cmd.package") + require.False(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) + require.Nil(t, closeValues(t, scope)) +} + +func TestCommandUsage_NestedUpThenPackageNoFallback(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + up := BeginCommandUsageScope("cmd.up") + pkg := BeginCommandUsageScope("cmd.package") + + // A report while the ineligible child is on top must not fall back to up. + require.False(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) + + require.Nil(t, closeValues(t, pkg)) + require.Nil(t, closeValues(t, up)) +} + +func TestCommandUsage_NestedUpThenDeployOwnsValue(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + up := BeginCommandUsageScope("cmd.up") + deploy := BeginCommandUsageScope("cmd.deploy") + + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "byo_image")) + + // The value lands only on the child deploy, never the parent up. + require.Equal(t, []string{"byo_image"}, closeValues(t, deploy)) + require.Nil(t, closeValues(t, up)) +} + +func TestCommandUsage_ChildCloseRestoresParentAsCurrent(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + up := BeginCommandUsageScope("cmd.up") + deploy := BeginCommandUsageScope("cmd.deploy") + _, err := CloseCommandUsageScope(deploy) + require.NoError(t, err) + + // After the child closes, up is current again and eligible. + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) + require.Equal(t, []string{"code"}, closeValues(t, up)) +} + +func TestCommandUsage_CloseAfterActionErrorRemovesScope(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + // Simulate a command action that errored: the deferred close still runs. + scope := BeginCommandUsageScope("cmd.deploy") + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) + _, err := CloseCommandUsageScope(scope) + require.NoError(t, err) + + // The next command sees no active scope. + require.False(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) +} + +func TestCommandUsage_RepeatedCloseErrors(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + scope := BeginCommandUsageScope("cmd.deploy") + _, err := CloseCommandUsageScope(scope) + require.NoError(t, err) + + _, err = CloseCommandUsageScope(scope) + require.Error(t, err) +} + +func TestCommandUsage_OutOfOrderCloseErrorsAndPreservesState(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + up := BeginCommandUsageScope("cmd.up") + deploy := BeginCommandUsageScope("cmd.deploy") + + // Closing the parent while the child is on top is rejected. + _, err := CloseCommandUsageScope(up) + require.Error(t, err) + + // State is intact: the child is still current and closes cleanly, then up. + _, err = CloseCommandUsageScope(deploy) + require.NoError(t, err) + _, err = CloseCommandUsageScope(up) + require.NoError(t, err) +} + +func TestCommandUsage_NextCommandHasNoLeakedValue(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + first := BeginCommandUsageScope("cmd.deploy") + require.True(t, TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, "code")) + require.Equal(t, []string{"code"}, closeValues(t, first)) + + // A subsequent unrelated command starts empty. + second := BeginCommandUsageScope("cmd.deploy") + require.Nil(t, closeValues(t, second)) +} + +func TestCommandUsage_CloseWithNoScopeErrors(t *testing.T) { + ResetCommandUsageForTest() + t.Cleanup(ResetCommandUsageForTest) + + _, err := CloseCommandUsageScope(CommandUsageScope{id: 999}) + require.Error(t, err) +} diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index de360f23974..7da1ce090e6 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -8,6 +8,8 @@ import ( "github.com/microsoft/ApplicationInsights-Go/appinsights/contracts" "go.opentelemetry.io/otel/attribute" semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext/telemetry" ) // AttributeKey represents an attribute key with additional metadata. @@ -261,6 +263,17 @@ var ( // Deployment attributes var ( + // AgentDeploymentModeKey records the de-duplicated set of hosted agent + // deployment modes selected during a command. Values are a fixed enum + // (code, container, byo_image) contributed by an authenticated extension + // through the host-validated telemetry service, so the field never carries + // user content, resource names, image references, paths, or identifiers. + AgentDeploymentModeKey = AttributeKey{ + Key: attribute.Key(telemetry.AgentDeploymentModeAttribute), + Classification: SystemMetadata, + Purpose: FeatureInsight, + } + // DeployAttemptKey tracks the retry attempt number for App Service zip deployments. DeployAttemptKey = AttributeKey{ Key: attribute.Key("deploy.appservice.attempt"), diff --git a/cli/azd/pkg/azdext/artifact_metadata.go b/cli/azd/pkg/azdext/artifact_metadata.go new file mode 100644 index 00000000000..55a09245054 --- /dev/null +++ b/cli/azd/pkg/azdext/artifact_metadata.go @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +// ArtifactMetadataKeyFromPackage marks an artifact that was supplied through +// the --from-package option rather than produced by azd. This is deployment +// payload provenance, not telemetry. +const ArtifactMetadataKeyFromPackage = "azd.fromPackage" diff --git a/cli/azd/pkg/azdext/azd_client.go b/cli/azd/pkg/azdext/azd_client.go index a2f6fb9031b..21907a09257 100644 --- a/cli/azd/pkg/azdext/azd_client.go +++ b/cli/azd/pkg/azdext/azd_client.go @@ -262,3 +262,14 @@ func (c *AzdClient) Validation() ValidationServiceClient { return c.validationClient } + +// Telemetry returns the telemetry service client used to contribute +// host-validated command usage attributes. +// +// A fresh client is returned on each call rather than caching it on the +// AzdClient struct. Service target providers can deploy services concurrently, +// so an unsynchronized lazily-written cache field could race on first use. The +// generated client wrapper is cheap and shares the existing connection. +func (c *AzdClient) Telemetry() TelemetryServiceClient { + return NewTelemetryServiceClient(c.connection) +} diff --git a/cli/azd/pkg/azdext/telemetry.pb.go b/cli/azd/pkg/azdext/telemetry.pb.go new file mode 100644 index 00000000000..c2a2c3a87aa --- /dev/null +++ b/cli/azd/pkg/azdext/telemetry.pb.go @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v7.35.0 +// source: telemetry.proto + +package azdext + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AddCommandUsageAttributeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Key must be registered and owned by the host. + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + // Value must be in the host-owned allowed set for key. + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddCommandUsageAttributeRequest) Reset() { + *x = AddCommandUsageAttributeRequest{} + mi := &file_telemetry_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddCommandUsageAttributeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddCommandUsageAttributeRequest) ProtoMessage() {} + +func (x *AddCommandUsageAttributeRequest) ProtoReflect() protoreflect.Message { + mi := &file_telemetry_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddCommandUsageAttributeRequest.ProtoReflect.Descriptor instead. +func (*AddCommandUsageAttributeRequest) Descriptor() ([]byte, []int) { + return file_telemetry_proto_rawDescGZIP(), []int{0} +} + +func (x *AddCommandUsageAttributeRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *AddCommandUsageAttributeRequest) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type AddCommandUsageAttributeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Accepted is false when the value was rejected because no eligible command + // scope is currently active. A false response is not an error. + Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddCommandUsageAttributeResponse) Reset() { + *x = AddCommandUsageAttributeResponse{} + mi := &file_telemetry_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddCommandUsageAttributeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddCommandUsageAttributeResponse) ProtoMessage() {} + +func (x *AddCommandUsageAttributeResponse) ProtoReflect() protoreflect.Message { + mi := &file_telemetry_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddCommandUsageAttributeResponse.ProtoReflect.Descriptor instead. +func (*AddCommandUsageAttributeResponse) Descriptor() ([]byte, []int) { + return file_telemetry_proto_rawDescGZIP(), []int{1} +} + +func (x *AddCommandUsageAttributeResponse) GetAccepted() bool { + if x != nil { + return x.Accepted + } + return false +} + +var File_telemetry_proto protoreflect.FileDescriptor + +const file_telemetry_proto_rawDesc = "" + + "\n" + + "\x0ftelemetry.proto\x12\x06azdext\"I\n" + + "\x1fAddCommandUsageAttributeRequest\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\">\n" + + " AddCommandUsageAttributeResponse\x12\x1a\n" + + "\baccepted\x18\x01 \x01(\bR\baccepted2\x81\x01\n" + + "\x10TelemetryService\x12m\n" + + "\x18AddCommandUsageAttribute\x12'.azdext.AddCommandUsageAttributeRequest\x1a(.azdext.AddCommandUsageAttributeResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" + +var ( + file_telemetry_proto_rawDescOnce sync.Once + file_telemetry_proto_rawDescData []byte +) + +func file_telemetry_proto_rawDescGZIP() []byte { + file_telemetry_proto_rawDescOnce.Do(func() { + file_telemetry_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_telemetry_proto_rawDesc), len(file_telemetry_proto_rawDesc))) + }) + return file_telemetry_proto_rawDescData +} + +var file_telemetry_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_telemetry_proto_goTypes = []any{ + (*AddCommandUsageAttributeRequest)(nil), // 0: azdext.AddCommandUsageAttributeRequest + (*AddCommandUsageAttributeResponse)(nil), // 1: azdext.AddCommandUsageAttributeResponse +} +var file_telemetry_proto_depIdxs = []int32{ + 0, // 0: azdext.TelemetryService.AddCommandUsageAttribute:input_type -> azdext.AddCommandUsageAttributeRequest + 1, // 1: azdext.TelemetryService.AddCommandUsageAttribute:output_type -> azdext.AddCommandUsageAttributeResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_telemetry_proto_init() } +func file_telemetry_proto_init() { + if File_telemetry_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_telemetry_proto_rawDesc), len(file_telemetry_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_telemetry_proto_goTypes, + DependencyIndexes: file_telemetry_proto_depIdxs, + MessageInfos: file_telemetry_proto_msgTypes, + }.Build() + File_telemetry_proto = out.File + file_telemetry_proto_goTypes = nil + file_telemetry_proto_depIdxs = nil +} diff --git a/cli/azd/pkg/azdext/telemetry/attributes.go b/cli/azd/pkg/azdext/telemetry/attributes.go new file mode 100644 index 00000000000..47f748d8895 --- /dev/null +++ b/cli/azd/pkg/azdext/telemetry/attributes.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package telemetry defines the extension telemetry contract values shared by +// the azd host and extensions. Keeping the key and the allowed enum values in +// one dependency-free package prevents core and extension code from duplicating +// telemetry string literals. +package telemetry + +// AgentDeploymentModeAttribute is the command-level usage attribute that +// records the set of hosted agent deployment modes selected during a command. +// Its value is a de-duplicated string slice. +const AgentDeploymentModeAttribute = "agent.deploy.mode" + +// AgentDeploymentMode identifies the selected hosted agent deployment source. +type AgentDeploymentMode string + +const ( + // AgentDeploymentModeCode deploys a source archive. + AgentDeploymentModeCode AgentDeploymentMode = "code" + + // AgentDeploymentModeContainer deploys a container image that azd builds + // and publishes from the project source. + AgentDeploymentModeContainer AgentDeploymentMode = "container" + + // AgentDeploymentModeByoImage deploys a caller-supplied image. + AgentDeploymentModeByoImage AgentDeploymentMode = "byo_image" +) diff --git a/cli/azd/pkg/azdext/telemetry/attributes_test.go b/cli/azd/pkg/azdext/telemetry/attributes_test.go new file mode 100644 index 00000000000..e0ca411e0ab --- /dev/null +++ b/cli/azd/pkg/azdext/telemetry/attributes_test.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package telemetry + +import "testing" + +// TestContractValues locks the public wire values. Downstream telemetry +// pipelines and the host allowlist depend on these exact strings, so a change +// here is a breaking telemetry contract change and must be intentional. +func TestContractValues(t *testing.T) { + t.Parallel() + + if AgentDeploymentModeAttribute != "agent.deploy.mode" { + t.Fatalf("unexpected attribute key: %q", AgentDeploymentModeAttribute) + } + + cases := map[AgentDeploymentMode]string{ + AgentDeploymentModeCode: "code", + AgentDeploymentModeContainer: "container", + AgentDeploymentModeByoImage: "byo_image", + } + for mode, want := range cases { + if string(mode) != want { + t.Errorf("unexpected mode value: got %q, want %q", string(mode), want) + } + } +} diff --git a/cli/azd/pkg/azdext/telemetry_grpc.pb.go b/cli/azd/pkg/azdext/telemetry_grpc.pb.go new file mode 100644 index 00000000000..d93398ee252 --- /dev/null +++ b/cli/azd/pkg/azdext/telemetry_grpc.pb.go @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v7.35.0 +// source: telemetry.proto + +package azdext + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + TelemetryService_AddCommandUsageAttribute_FullMethodName = "/azdext.TelemetryService/AddCommandUsageAttribute" +) + +// TelemetryServiceClient is the client API for TelemetryService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// TelemetryService accepts reviewed, host-owned command usage attribute values +// from authenticated extensions. The host validates the key and value against +// an allowlist and attaches accepted values to the current command telemetry +// event. Extensions cannot choose classification, purpose, hashing, or +// aggregation. +type TelemetryServiceClient interface { + // AddCommandUsageAttribute adds one bounded value to a host-owned command + // usage attribute. Duplicate values are collapsed by the host. + AddCommandUsageAttribute(ctx context.Context, in *AddCommandUsageAttributeRequest, opts ...grpc.CallOption) (*AddCommandUsageAttributeResponse, error) +} + +type telemetryServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewTelemetryServiceClient(cc grpc.ClientConnInterface) TelemetryServiceClient { + return &telemetryServiceClient{cc} +} + +func (c *telemetryServiceClient) AddCommandUsageAttribute(ctx context.Context, in *AddCommandUsageAttributeRequest, opts ...grpc.CallOption) (*AddCommandUsageAttributeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AddCommandUsageAttributeResponse) + err := c.cc.Invoke(ctx, TelemetryService_AddCommandUsageAttribute_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// TelemetryServiceServer is the server API for TelemetryService service. +// All implementations must embed UnimplementedTelemetryServiceServer +// for forward compatibility. +// +// TelemetryService accepts reviewed, host-owned command usage attribute values +// from authenticated extensions. The host validates the key and value against +// an allowlist and attaches accepted values to the current command telemetry +// event. Extensions cannot choose classification, purpose, hashing, or +// aggregation. +type TelemetryServiceServer interface { + // AddCommandUsageAttribute adds one bounded value to a host-owned command + // usage attribute. Duplicate values are collapsed by the host. + AddCommandUsageAttribute(context.Context, *AddCommandUsageAttributeRequest) (*AddCommandUsageAttributeResponse, error) + mustEmbedUnimplementedTelemetryServiceServer() +} + +// UnimplementedTelemetryServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedTelemetryServiceServer struct{} + +func (UnimplementedTelemetryServiceServer) AddCommandUsageAttribute(context.Context, *AddCommandUsageAttributeRequest) (*AddCommandUsageAttributeResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AddCommandUsageAttribute not implemented") +} +func (UnimplementedTelemetryServiceServer) mustEmbedUnimplementedTelemetryServiceServer() {} +func (UnimplementedTelemetryServiceServer) testEmbeddedByValue() {} + +// UnsafeTelemetryServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TelemetryServiceServer will +// result in compilation errors. +type UnsafeTelemetryServiceServer interface { + mustEmbedUnimplementedTelemetryServiceServer() +} + +func RegisterTelemetryServiceServer(s grpc.ServiceRegistrar, srv TelemetryServiceServer) { + // If the following call pancis, it indicates UnimplementedTelemetryServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&TelemetryService_ServiceDesc, srv) +} + +func _TelemetryService_AddCommandUsageAttribute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AddCommandUsageAttributeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TelemetryServiceServer).AddCommandUsageAttribute(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: TelemetryService_AddCommandUsageAttribute_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TelemetryServiceServer).AddCommandUsageAttribute(ctx, req.(*AddCommandUsageAttributeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// TelemetryService_ServiceDesc is the grpc.ServiceDesc for TelemetryService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var TelemetryService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "azdext.TelemetryService", + HandlerType: (*TelemetryServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "AddCommandUsageAttribute", + Handler: _TelemetryService_AddCommandUsageAttribute_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "telemetry.proto", +} diff --git a/cli/azd/pkg/project/artifact.go b/cli/azd/pkg/project/artifact.go index a365c629296..8303e9113f6 100644 --- a/cli/azd/pkg/project/artifact.go +++ b/cli/azd/pkg/project/artifact.go @@ -11,6 +11,7 @@ import ( "slices" "strings" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/output" ) @@ -22,6 +23,12 @@ const ( // MetadataKeyNote adds a note line below the artifact output. MetadataKeyNote = "note" + + // MetadataKeyFromPackage marks an artifact supplied through the + // --from-package option rather than produced by azd. The canonical value + // lives in the SDK so core and extensions share a single definition. This + // is deployment payload provenance, not telemetry. + MetadataKeyFromPackage = azdext.ArtifactMetadataKeyFromPackage ) // ArtifactKind represents well-known artifact types in the Azure Developer CLI From 38fd61a2b615926b6f7edc7a60f15ec4ae4b147c Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 22 Jul 2026 16:56:33 +0800 Subject: [PATCH 2/4] test: add telemetry serialization test and docs --- .../extensions/extension-sdk-reference.md | 16 ++++++++++++++++ .../span_to_envelope_test.go | 19 +++++++++++++++++++ docs/specs/metrics-audit/telemetry-schema.md | 1 + 3 files changed, 36 insertions(+) diff --git a/cli/azd/docs/extensions/extension-sdk-reference.md b/cli/azd/docs/extensions/extension-sdk-reference.md index ba91ef45e3a..eded8ce2a66 100644 --- a/cli/azd/docs/extensions/extension-sdk-reference.md +++ b/cli/azd/docs/extensions/extension-sdk-reference.md @@ -519,9 +519,25 @@ gRPC client connecting to the azd framework. Auto-discovers the socket via | `Extension()` | `ExtensionServiceClient` | | `Account()` | `AccountServiceClient` | | `Ai()` | `AiModelServiceClient` | +| `Telemetry()` | `TelemetryServiceClient` | Always call `defer client.Close()` after creation. +#### TelemetryService + +`Telemetry().AddCommandUsageAttribute(ctx, &azdext.AddCommandUsageAttributeRequest{Key, Value})` +lets an authenticated extension contribute a host-validated command usage +attribute. The host owns the allowlist: it validates the key and value, checks +the extension's declared capabilities, and, when accepted, attaches the value +to the current command telemetry event (for example `cmd.deploy` or `cmd.up`) +as a de-duplicated string slice. Extensions cannot choose classification, +purpose, hashing, or aggregation, and unknown keys or values are rejected. + +The call is best-effort. It returns `Accepted=false` (not an error) when no +eligible command is active, and an older azd host returns `Unimplemented`. +Treat any failure as a no-op and never let it change command behavior. Report +a value as soon as it is known so a later failure still retains it. + ### ConfigHelper ```go diff --git a/cli/azd/internal/telemetry/appinsights-exporter/span_to_envelope_test.go b/cli/azd/internal/telemetry/appinsights-exporter/span_to_envelope_test.go index 3bf8c727722..610ee15b09b 100644 --- a/cli/azd/internal/telemetry/appinsights-exporter/span_to_envelope_test.go +++ b/cli/azd/internal/telemetry/appinsights-exporter/span_to_envelope_test.go @@ -169,6 +169,25 @@ func assertAttributeInPropertiesOrMeasurement( } } +// TestSpanToEnvelope_AgentDeployMode locks the App Insights representation of +// the agent.deploy.mode field. The logical OTel value is a string slice; the +// exporter stores it as JSON-encoded text in the Properties bag, so downstream +// Kusto must parse it as a dynamic array. +func TestSpanToEnvelope_AgentDeployMode(t *testing.T) { + t.Parallel() + + stub := getDefaultSpanStub() + stub.Attributes = []attribute.KeyValue{ + attribute.StringSlice("agent.deploy.mode", []string{"code", "container"}), + } + span := stub.Snapshot() + + envelope := SpanToEnvelope(span) + data := envelope.Data.(*contracts.Data).BaseData.(*contracts.RequestData) + + assert.Equal(t, `["code","container"]`, data.Properties["agent.deploy.mode"]) +} + func getDefaultSpanStub() tracetest.SpanStub { traceId, _ := trace.TraceIDFromHex("68f1c4f4ef5346e69d7f196761d10c68") spanId, _ := trace.SpanIDFromHex("7fbdc197a52f4825877ddd46e4ec7f6c") diff --git a/docs/specs/metrics-audit/telemetry-schema.md b/docs/specs/metrics-audit/telemetry-schema.md index d4bd317c0a2..e218838cee0 100644 --- a/docs/specs/metrics-audit/telemetry-schema.md +++ b/docs/specs/metrics-audit/telemetry-schema.md @@ -88,6 +88,7 @@ These are set once at process startup via `resource.New()` and attached to every | Service languages | `project.service.languages` | SystemMetadata | FeatureInsight | List of languages | | Service language | `project.service.language` | SystemMetadata | PerformanceAndHealth | Single service language | | Platform type | `platform.type` | SystemMetadata | FeatureInsight | e.g. `aca`, `aks` | +| Agent deployment mode | `agent.deploy.mode` | SystemMetadata | FeatureInsight | `string[]`; per-command set of `code`/`container`/`byo_image` contributed by an authenticated extension through the telemetry service; fixed enum, not hashed; App Insights stores JSON text | ### Config and Environment From fbf94b55b03aeee635359d528ddee2f4d591d77f Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 22 Jul 2026 18:06:16 +0800 Subject: [PATCH 3/4] test: modernize concurrency loops in telemetry tests --- cli/azd/internal/grpcserver/telemetry_service_test.go | 10 ++++------ cli/azd/internal/tracing/command_usage_test.go | 8 +++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/cli/azd/internal/grpcserver/telemetry_service_test.go b/cli/azd/internal/grpcserver/telemetry_service_test.go index abea91e2b6c..e2cfe77f10e 100644 --- a/cli/azd/internal/grpcserver/telemetry_service_test.go +++ b/cli/azd/internal/grpcserver/telemetry_service_test.go @@ -182,7 +182,7 @@ func TestTelemetryService_DuplicateCollapses(t *testing.T) { svc := NewTelemetryService() ctx := serviceTargetClaims() - for i := 0; i < 3; i++ { + for range 3 { resp, err := svc.AddCommandUsageAttribute(ctx, validRequest("code")) require.NoError(t, err) require.True(t, resp.Accepted) @@ -204,14 +204,12 @@ func TestTelemetryService_ConcurrentReports(t *testing.T) { modes := []string{"code", "container", "byo_image"} var wg sync.WaitGroup - for i := 0; i < 60; i++ { + for i := range 60 { value := modes[i%len(modes)] - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { _, err := svc.AddCommandUsageAttribute(ctx, validRequest(value)) require.NoError(t, err) - }() + }) } wg.Wait() diff --git a/cli/azd/internal/tracing/command_usage_test.go b/cli/azd/internal/tracing/command_usage_test.go index 195bf71e149..52d8f4cfce7 100644 --- a/cli/azd/internal/tracing/command_usage_test.go +++ b/cli/azd/internal/tracing/command_usage_test.go @@ -92,13 +92,11 @@ func TestCommandUsage_ConcurrentValues(t *testing.T) { modes := []string{"code", "container", "byo_image"} var wg sync.WaitGroup - for i := 0; i < 50; i++ { + for i := range 50 { mode := modes[i%len(modes)] - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { TryAppendCommandUsageUnique(testEligibleEvents(), testUsageKey, mode) - }() + }) } wg.Wait() From 898b2f94a6027664c01c30e9144816e00f60c3fb Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 22 Jul 2026 20:59:27 +0800 Subject: [PATCH 4/4] fix: remove unrelated archive classification changes --- cli/azd/internal/cmd/from_package_test.go | 33 ----------------------- cli/azd/internal/cmd/publish.go | 21 +++++---------- 2 files changed, 6 insertions(+), 48 deletions(-) diff --git a/cli/azd/internal/cmd/from_package_test.go b/cli/azd/internal/cmd/from_package_test.go index 2f0e528a544..dcf87ba5ed4 100644 --- a/cli/azd/internal/cmd/from_package_test.go +++ b/cli/azd/internal/cmd/from_package_test.go @@ -4,8 +4,6 @@ package cmd import ( - "os" - "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -14,37 +12,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/project" ) -// TestDetermineArtifactKind locks --from-package classification, including the -// compound archive suffixes that filepath.Ext cannot recognize. -func TestDetermineArtifactKind(t *testing.T) { - t.Parallel() - dir := t.TempDir() - - archiveNames := []string{ - "pkg.zip", "pkg.tar", "pkg.tgz", "pkg.txz", "pkg.tbz2", - "pkg.tar.gz", "pkg.tar.bz2", "pkg.tar.xz", - "PKG.TAR.GZ", // classification is case-insensitive - } - for _, name := range archiveNames { - p := filepath.Join(dir, name) - require.NoError(t, os.WriteFile(p, []byte("x"), 0o600)) - require.Equalf(t, project.ArtifactKindArchive, determineArtifactKind(p), - "expected archive for %q", name) - } - - // An existing directory is a directory artifact. - require.Equal(t, project.ArtifactKindDirectory, determineArtifactKind(dir)) - - // A path that does not exist is treated as a container image reference. - require.Equal(t, project.ArtifactKindContainer, - determineArtifactKind("myregistry.azurecr.io/app:latest")) - - // An existing non-archive file is also treated as a container reference. - other := filepath.Join(dir, "notanarchive.bin") - require.NoError(t, os.WriteFile(other, []byte("x"), 0o600)) - require.Equal(t, project.ArtifactKindContainer, determineArtifactKind(other)) -} - // TestFromPackageMetadataConstant keeps the core alias and the SDK constant in // sync so core and extensions agree on the provenance marker. func TestFromPackageMetadataConstant(t *testing.T) { diff --git a/cli/azd/internal/cmd/publish.go b/cli/azd/internal/cmd/publish.go index bdae32d64dc..23174b5b073 100644 --- a/cli/azd/internal/cmd/publish.go +++ b/cli/azd/internal/cmd/publish.go @@ -9,6 +9,7 @@ import ( "io" "log" "os" + "path/filepath" "slices" "strings" "time" @@ -415,23 +416,13 @@ func (pa *PublishAction) supportsPublish(ctx context.Context, serviceConfig *pro // 1. Archive (zip file) - checks if it exists and matches popular archive extensions // 2. Directory - checks if it is an existing directory (absolute or relative) // 3. Container - otherwise, it's likely a local container reference -// archiveSuffixes are the file suffixes treated as a code archive for -// --from-package classification. Compound suffixes such as ".tar.gz" are -// matched with strings.HasSuffix because filepath.Ext only returns the final -// segment (".gz") and would misclassify them as a container reference. -var archiveSuffixes = []string{ - ".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz", -} - func determineArtifactKind(fromPackage string) project.ArtifactKind { - lower := strings.ToLower(fromPackage) - - // Check if it's an existing file with an archive suffix. + // Check if it's an existing file with archive extension if info, err := os.Stat(fromPackage); err == nil && !info.IsDir() { - for _, suffix := range archiveSuffixes { - if strings.HasSuffix(lower, suffix) { - return project.ArtifactKindArchive - } + ext := strings.ToLower(filepath.Ext(fromPackage)) + switch ext { + case ".zip", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz": + return project.ArtifactKindArchive } }