Skip to content
Open
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
1 change: 1 addition & 0 deletions cli/azd/cmd/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions cli/azd/cmd/middleware/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()))
Expand Down
13 changes: 13 additions & 0 deletions cli/azd/cmd/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
16 changes: 16 additions & 0 deletions cli/azd/docs/extensions/extension-sdk-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions cli/azd/grpc/proto/telemetry.proto
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 21 additions & 0 deletions cli/azd/internal/cmd/from_package_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/project"
)

// 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)
Comment on lines +15 to +19
require.Equal(t, azdext.ArtifactMetadataKeyFromPackage, project.MetadataKeyFromPackage)
}
7 changes: 6 additions & 1 deletion cli/azd/internal/cmd/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,11 +286,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 {
Expand Down
7 changes: 6 additions & 1 deletion cli/azd/internal/cmd/service_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Comment thread
huimiu marked this conversation as resolved.
}); pkgErr != nil {
return fmt.Errorf("packaging service %s: %w", pkgSvc.Name, pkgErr)
}
Expand Down
1 change: 1 addition & 0 deletions cli/azd/internal/grpcserver/prompt_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ func setupTestServer(t *testing.T, promptSvc azdext.PromptServiceServer) (
azdext.UnimplementedCopilotServiceServer{},
azdext.UnimplementedProvisioningServiceServer{},
azdext.UnimplementedValidationServiceServer{},
azdext.UnimplementedTelemetryServiceServer{},
)

serverInfo, err := server.Start()
Expand Down
4 changes: 4 additions & 0 deletions cli/azd/internal/grpcserver/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ type Server struct {
copilotService azdext.CopilotServiceServer
provisioningService azdext.ProvisioningServiceServer
validationService azdext.ValidationServiceServer
telemetryService azdext.TelemetryServiceServer
}

func NewServer(
Expand All @@ -64,6 +65,7 @@ func NewServer(
copilotService azdext.CopilotServiceServer,
provisioningService azdext.ProvisioningServiceServer,
validationService azdext.ValidationServiceServer,
telemetryService azdext.TelemetryServiceServer,
) *Server {
return &Server{
projectService: projectService,
Expand All @@ -83,6 +85,7 @@ func NewServer(
copilotService: copilotService,
provisioningService: provisioningService,
validationService: validationService,
telemetryService: telemetryService,
}
}

Expand Down Expand Up @@ -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
Expand Down
74 changes: 73 additions & 1 deletion cli/azd/internal/grpcserver/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -53,6 +55,7 @@ func Test_Server_Start(t *testing.T) {
azdext.UnimplementedCopilotServiceServer{},
azdext.UnimplementedProvisioningServiceServer{},
azdext.UnimplementedValidationServiceServer{},
NewTelemetryService(),
)

serverInfo, err := server.Start()
Expand Down Expand Up @@ -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
Expand All @@ -142,6 +213,7 @@ func Test_Server_StreamInterceptor(t *testing.T) {
azdext.UnimplementedCopilotServiceServer{},
azdext.UnimplementedProvisioningServiceServer{},
azdext.UnimplementedValidationServiceServer{},
azdext.UnimplementedTelemetryServiceServer{},
)

serverInfo, err := server.Start()
Expand Down Expand Up @@ -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")
}
Expand Down
Loading
Loading