Skip to content
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
1 change: 1 addition & 0 deletions cli/azd/docs/extensions/extension-framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,7 @@ Extensions can declare the following capabilities in their manifest:
- **`provisioning-provider`**: Provide a custom infrastructure provisioning experience (alternative to Bicep / Terraform)
- **`validation-provider`**: Contribute validation checks to azd's provision validation and future validation pipelines
- **`metadata`**: Provide comprehensive metadata about commands and configuration schemas
- **`telemetry`**: Report usage attributes declared in the extension's official registry entry (see [Extension Telemetry Fields](./extension-telemetry-fields.md))

#### Complete Extension Manifest Example

Expand Down
42 changes: 42 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,51 @@ 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().ReportUsageAttribute(ctx, &azdext.ReportUsageAttributeRequest{Key, Value})`
lets an authenticated extension report one usage attribute value. The
extension declares the key and its closed value set in its entry in the
official azd registry; `azd` core owns no product-specific fields, so adding a
new field is a registry change rather than a core release.

The host validates every call and fails closed. It requires the extension to
be installed from the official `azd` registry, to carry the `telemetry`
capability, and to have declared both the key and the exact value. Accepted
values are recorded on a dedicated `ext.usage` span that shares the command's
trace, so downstream queries join it to the originating command on
`operation_Id`. Extensions cannot choose the span, classification, purpose,
hashing, or aggregation.

Declare fields in the registry entry alongside the capability:

```json
{
"version": "1.0.0",
"capabilities": ["telemetry"],
"telemetry": [
{
"key": "ext.contoso.tools.deploy.mode",
"allowedValues": ["code", "container"]
}
]
}
```

Keys must be namespaced as `ext.<extension id>.<segment>`, and values are
limited to lowercase alphanumerics with `_`, `-`, and `.` so registry names,
URLs, and paths cannot be smuggled through a value. See
[Extension Telemetry Fields](./extension-telemetry-fields.md) for the full
declaration rules and review process.

The call is best-effort. 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
106 changes: 106 additions & 0 deletions cli/azd/docs/extensions/extension-telemetry-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Extension Telemetry Fields

This guide is for extension authors who need `azd` to record a bounded usage
signal on their behalf — for example, which deployment mode a user picked.

`azd` core owns no product-specific telemetry fields. Every field an extension
can report is declared in that extension's entry in the official `azd`
registry, and `azd` only enforces the declaration. Adding a field is therefore
a registry pull request in this repository, not a core release.

See [ADR-001](../../../../docs/architecture/adr-001-extension-telemetry-declarations.md)
for the reasoning behind this design.

## What you can and cannot report

| | |
|---|---|
| You choose | The key name and the closed set of values it may take |
| `azd` chooses | Which span the value lands on, its classification, and how it is exported |
| Always rejected | Free-form values, values outside your declared set, keys you did not declare |

Values must be a closed enum because that is what makes a privacy review
possible. A shape-only rule (length and charset) would let someone pack a
container image reference into a value and leak a private registry name, so
the charset excludes `:` and `/` and the host compares each value against
your declaration.

## Step 1: Declare the capability

Add `telemetry` to `capabilities` in your `extension.yaml`:

```yaml
capabilities:
- custom-commands
- telemetry
```

## Step 2: Declare the fields in the registry entry

Add a `telemetry` array to your version entry in the official registry
(`cli/azd/extensions/registry.json`). Unlike `capabilities`, this is not
copied from `extension.yaml` — it only exists in the reviewed registry entry:

```json
{
"version": "1.0.0",
"capabilities": ["custom-commands", "telemetry"],
"telemetry": [
{
"key": "ext.contoso.tools.deploy.mode",
"allowedValues": ["code", "container", "unknown"]
}
]
}
```

Rules enforced when the registry is validated:

| Rule | Limit |
|---|---|
| Key namespace | Must start with `ext.<your extension id>.` |
| Key length | 128 characters |
| Fields per version | 16 |
| Values per field | 1–32, unique |
| Value length | 64 characters |
| Value charset | Lowercase alphanumerics plus `_`, `-`, `.` |
| Capability | `telemetry` must be present when fields are declared |

The pull request that adds these values is the privacy review. Expect
reviewers to ask what each value means and why it is needed.

## Step 3: Report from your extension

```go
_, err := client.Telemetry().ReportUsageAttribute(
ctx,
&azdext.ReportUsageAttributeRequest{
Key: "ext.contoso.tools.deploy.mode",
Value: "container",
},
)
```

Treat the call as best-effort. Older `azd` hosts return `Unimplemented`, and
every rejection is a plain error. Never let the result change command
behavior, and never retry. Report the value as soon as it is known so a later
failure in your command still keeps the signal.

## Why a call might be rejected

| Status | Cause |
|---|---|
| `Unauthenticated` | The request did not carry the host-issued extension token |
| `PermissionDenied` | The extension was not installed from the official `azd` registry, is missing the `telemetry` capability, or is not installed |
| `FailedPrecondition` | The stored declaration no longer passes validation |
| `InvalidArgument` | The key was not declared, or the value is not in the declared set |

Error messages never echo the key or value you sent, so use the status code
plus your own declaration to diagnose.

## Where the data lands

Each accepted value becomes an `ext.usage` span carrying `extension.id`,
`extension.version`, and your attribute. The span shares the command's trace,
so it joins to the originating command on `operation_Id` in Application
Insights.
14 changes: 10 additions & 4 deletions cli/azd/extensions/extension.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
"capabilities": {
"type": "array",
"title": "Capabilities",
"description": "List of capabilities provided by the extension. Supported values: custom-commands, lifecycle-events, mcp-server, service-target-provider, framework-service-provider, provisioning-provider, validation-provider, metadata. Select one or more from the allowed list. Each value must be unique. Not required for extension packs, which declare dependencies instead and have no executable.",
"description": "List of capabilities provided by the extension. Supported values: custom-commands, lifecycle-events, mcp-server, service-target-provider, framework-service-provider, provisioning-provider, validation-provider, metadata, telemetry. Select one or more from the allowed list. Each value must be unique. Not required for extension packs, which declare dependencies instead and have no executable.",
"minItems": 1,
"uniqueItems": true,
"items": {
Expand Down Expand Up @@ -159,11 +159,17 @@
"title": "Validation Provider",
"description": "Validation provider enables extensions to contribute checks to azd validation pipelines."
},
{
"type": "string",
"const": "metadata",
{
"type": "string",
"const": "metadata",
"title": "Metadata",
"description": "Metadata capability enables extensions to provide comprehensive metadata about their commands and capabilities via a metadata command."
},
{
"type": "string",
"const": "telemetry",
"title": "Telemetry",
"description": "Telemetry capability enables extensions to report usage attributes declared in their official registry entry. Only extensions installed from the official azd registry may report."
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,10 @@ func addOrUpdateExtension(
Dependencies: extensionMetadata.Dependencies,
Providers: extensionMetadata.Providers,
Artifacts: artifacts,
// Telemetry declarations are authored directly in the
// registry and reviewed there, so carry them forward
// instead of dropping them on republish.
Telemetry: v.Telemetry,
}

return
Expand Down
39 changes: 36 additions & 3 deletions cli/azd/extensions/registry.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,42 @@
"service-target-provider",
"framework-service-provider",
"provisioning-provider",
"validation-provider",
"metadata"
]
"validation-provider",
"metadata",
"telemetry"
]
}
},
"telemetry": {
"type": "array",
"description": "Usage attributes this version may report through the telemetry service. Requires the 'telemetry' capability.",
"maxItems": 16,
"items": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Telemetry attribute name, namespaced as 'ext.<extension id>.<segment>'.",
"maxLength": 128,
"pattern": "^ext\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
},
"allowedValues": {
"type": "array",
"description": "Closed set of values that may be reported for this key. Free-form values are always rejected.",
"minItems": 1,
"maxItems": 32,
"uniqueItems": true,
"items": {
"type": "string",
"maxLength": 64,
"pattern": "^[a-z0-9]([a-z0-9_.-]*[a-z0-9])?$"
}
}
},
"required": [
"key",
"allowedValues"
]
}
},
"usage": {
Expand Down
31 changes: 31 additions & 0 deletions cli/azd/grpc/proto/telemetry.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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 usage attribute values from authenticated
// extensions. Every key and value must appear in the telemetry declaration
// the extension published in the official azd registry, which is where those
// fields are reviewed. The host decides which telemetry span a value lands on
// and owns classification, purpose, hashing, and aggregation.
service TelemetryService {
// ReportUsageAttribute reports one declared usage attribute value.
rpc ReportUsageAttribute(ReportUsageAttributeRequest)
returns (ReportUsageAttributeResponse);
Comment on lines +15 to +17
}

message ReportUsageAttributeRequest {
// Key must match a telemetry field the extension declared in the registry.
string key = 1;

// Value must be in the allowed set the extension declared for key.
string value = 2;
}

message ReportUsageAttributeResponse {
// Accepted reports whether the host recorded the value.
bool accepted = 1;
}
1 change: 1 addition & 0 deletions cli/azd/internal/grpcserver/extension_claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func GenerateExtensionToken(extension *extensions.Extension, serverInfo *ServerI
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 1)),
},
Capabilities: extension.Capabilities,
Source: extension.Source,
}

jwtToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(serverInfo.SigningKey)
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
15 changes: 11 additions & 4 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 All @@ -97,10 +100,12 @@ func (s *Server) Start() (*ServerInfo, error) {
s.grpcServer = grpc.NewServer(
grpc.ChainUnaryInterceptor(
s.errorWrappingInterceptor(),
s.traceContextInterceptor(),
s.tokenAuthInterceptor(&serverInfo),
),
grpc.ChainStreamInterceptor(
s.errorWrappingStreamInterceptor(),
s.traceContextStreamInterceptor(),
s.tokenAuthStreamInterceptor(&serverInfo),
),
)
Expand Down Expand Up @@ -132,6 +137,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 Expand Up @@ -248,7 +254,7 @@ func (s *Server) tokenAuthStreamInterceptor(serverInfo *ServerInfo) grpc.StreamS
}

// Wrap the stream to inject validated claims into its context
wrappedStream := &authenticatedStream{
wrappedStream := &contextStream{
ServerStream: ss,
ctx: ctx,
}
Expand All @@ -257,13 +263,14 @@ func (s *Server) tokenAuthStreamInterceptor(serverInfo *ServerInfo) grpc.StreamS
}
}

// authenticatedStream wraps a grpc.ServerStream to provide a context with validated claims.
type authenticatedStream struct {
// contextStream wraps a grpc.ServerStream to override the context seen by the
// handler, for example to carry validated claims or trace context.
type contextStream struct {
grpc.ServerStream
ctx context.Context
}

func (s *authenticatedStream) Context() context.Context {
func (s *contextStream) Context() context.Context {
return s.ctx
}

Expand Down
Loading
Loading