Skip to content
10 changes: 10 additions & 0 deletions cli/azd/cmd/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,16 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error
// Update warning is shown via defer above (runs after invoke completes)

if invokeErr != nil {
// Check if the extension reported a structured error via gRPC.
// This gives us a typed LocalError/ServiceError for telemetry classification
// instead of just a generic exit-code error.
if reportedErr := extension.GetReportedError(); reportedErr != nil {
// Wrap both errors so the chain contains both:
// - reportedErr (LocalError/ServiceError) for telemetry classification
// - invokeErr (ExtensionRunError) for UX middleware handling
return nil, fmt.Errorf("%w: %w", reportedErr, invokeErr)
}

return nil, invokeErr
}

Expand Down
59 changes: 59 additions & 0 deletions cli/azd/cmd/extensions_error_transport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"errors"
"testing"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"github.com/stretchr/testify/require"
)

func TestExtensionReportedError(t *testing.T) {
t.Run("NoErrorReported", func(t *testing.T) {
ext := &extensions.Extension{Id: "test.ext"}
require.Nil(t, ext.GetReportedError())
})

t.Run("LocalErrorReported", func(t *testing.T) {
ext := &extensions.Extension{Id: "test.ext"}
localErr := &azdext.LocalError{
Message: "invalid config",
Code: "invalid_config",
Category: azdext.LocalErrorCategoryValidation,
Suggestion: "Fix the config file",
}
ext.SetReportedError(localErr)

reported := ext.GetReportedError()
require.NotNil(t, reported)

var gotLocal *azdext.LocalError
require.True(t, errors.As(reported, &gotLocal))
require.Equal(t, "invalid_config", gotLocal.Code)
require.Equal(t, azdext.LocalErrorCategoryValidation, gotLocal.Category)
require.Equal(t, "Fix the config file", gotLocal.Suggestion)
})

t.Run("ServiceErrorReported", func(t *testing.T) {
ext := &extensions.Extension{Id: "test.ext"}
svcErr := &azdext.ServiceError{
Message: "not found",
ErrorCode: "NotFound",
StatusCode: 404,
ServiceName: "test.service.com",
}
ext.SetReportedError(svcErr)

reported := ext.GetReportedError()
require.NotNil(t, reported)

var gotSvc *azdext.ServiceError
require.True(t, errors.As(reported, &gotSvc))
require.Equal(t, "NotFound", gotSvc.ErrorCode)
require.Equal(t, 404, gotSvc.StatusCode)
})
}
35 changes: 30 additions & 5 deletions cli/azd/cmd/middleware/ux.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/azure/azure-dev/cli/azd/cmd/actions"
"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/output"
Expand Down Expand Up @@ -49,23 +50,47 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes

// For specific errors, we silent the output display here and let the caller handle it
var unsupportedErr *project.UnsupportedServiceHostError
var extensionRunErr *extensions.ExtensionRunError
if errors.As(err, &extensionRunErr) {
return actionResult, err
}

// Use ErrorWithSuggestion for errors with suggestions (better UX)
// Use ErrorWithSuggestion for errors with suggestions (better UX).
// This catches errors wrapped by the error pipeline's YAML rules
// or other host code that already created an ErrorWithSuggestion.
if errors.As(err, &suggestionErr) {
displayErr := &ux.ErrorWithSuggestion{
Err: suggestionErr.Err,
Message: suggestionErr.Message,
Suggestion: suggestionErr.Suggestion,
Links: suggestionErr.Links,
}
m.console.Message(ctx, "")
m.console.MessageUxItem(ctx, displayErr)
return actionResult, err
}

// Bridge extension errors (LocalError/ServiceError) with suggestions to rich UX.
// Covers both CLI extension commands and gRPC service target errors.
if suggestion := azdext.ErrorSuggestion(err); suggestion != "" {
message := azdext.ErrorMessage(err)
if message == "" {
message = err.Error()
}
displayErr := &ux.ErrorWithSuggestion{
Message: message,
Suggestion: suggestion,
}
m.console.Message(ctx, "")
m.console.MessageUxItem(ctx, displayErr)
return actionResult, err
}

// ExtensionRunError without suggestion
var extensionRunErr *extensions.ExtensionRunError
if errors.As(err, &extensionRunErr) {
if message := azdext.ErrorMessage(err); message != "" {
m.console.Message(ctx, output.WithErrorFormat("\nERROR: %s", message))
}
return actionResult, err
}

// Build error message for errors without suggestions
errorMessage := &strings.Builder{}
errorMessage.WriteString(output.WithErrorFormat("\nERROR: %s", err.Error()))
Expand Down
62 changes: 62 additions & 0 deletions cli/azd/docs/extensions/extension-framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,68 @@ To enable interaction with `azd` from within the extension, the extension must l

The gRPC client must also include an `authorization` parameter with the value from `AZD_ACCESS_TOKEN`; otherwise, requests will fail due to invalid authorization. Extensions must declare their supported capabilities within the extension registry otherwise certain service operations may fail with a permission denied error.

### Structured Error Reporting (Custom Commands)

For custom command execution, extensions can provide richer telemetry by reporting a structured error to
the azd host via the `ExtensionService.ReportError` gRPC call before exiting with a non-zero status code.

For Go extensions, the recommended approach is to use `azdext.Run`, which handles context creation, structured error
reporting, suggestion rendering, and `os.Exit` automatically:

```go
func main() {
azdext.Run(cmd.NewRootCommand())
}
```

Alternatively, you can use `azdext.ReportError` directly for lower-level control:

```go
func main() {
ctx := azdext.NewContext()
ctx = azdext.WithAccessToken(ctx)
rootCmd := cmd.NewRootCommand()

if err := rootCmd.ExecuteContext(ctx); err != nil {
_ = azdext.ReportError(ctx, err)
os.Exit(1)
}
}
```

Use typed extension errors to control telemetry mapping:

```go
return &azdext.LocalError{
Message: "invalid configuration: missing required field 'name'",
Code: "invalid_config",
Category: "validation",
Suggestion: "Ensure your azure.yaml has a 'name' field defined.",
}
```

```go
return &azdext.ServiceError{
Message: "request failed",
ErrorCode: "TooManyRequests",
StatusCode: 429,
ServiceName: "openai.azure.com",
Suggestion: "Wait a moment and retry the request, or increase your rate limit.",
}
Comment thread
JeffreyCA marked this conversation as resolved.
```

Current telemetry result code conventions:

- Service errors: `ext.service.<service>.<statusCode>`
- Local domain categories:
- `validation` -> `ext.validation.<code>`
- `auth` -> `ext.auth.<code>`
- `dependency` -> `ext.dependency.<code>`
- `compatibility` -> `ext.compatibility.<code>`
- `user` -> `ext.user.<code>`
- `internal` -> `ext.internal.<code>`
- Unknown local categories fall back to `ext.local.<code>`.

#### Go

For extensions built using Go, the `azdext` package provides an `AzdClient` which acts as the gRPC client.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package cmd

import (
"fmt"
"net/url"
"strings"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/fatih/color"
Expand Down Expand Up @@ -34,6 +36,26 @@ including blob, tree, raw, and API URLs. Handles branch names containing slashes
// Get the GitHub URL from the first argument
githubUrl := args[0]

parsed, parseErr := url.ParseRequestURI(githubUrl)
if parseErr != nil || parsed == nil {
return &azdext.LocalError{
Message: "invalid GitHub URL: expected an absolute URL",
Code: "invalid_github_url",
Category: azdext.LocalErrorCategoryValidation,
Suggestion: "Use a full URL like https://github.com/Azure/azure-dev",
}
}

scheme := strings.ToLower(parsed.Scheme)
if scheme != "http" && scheme != "https" {
return &azdext.LocalError{
Message: "invalid GitHub URL scheme: supported schemes are http and https",
Code: "unsupported_url_scheme",
Category: azdext.LocalErrorCategoryValidation,
Suggestion: "Change the URL to start with https://",
}
}

// Call the ParseGitHubUrl RPC method
response, err := azdClient.Project().ParseGitHubUrl(ctx, &azdext.ParseGitHubUrlRequest{
Url: githubUrl,
Expand Down
21 changes: 2 additions & 19 deletions cli/azd/extensions/microsoft.azd.demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,10 @@
package main

import (
"context"
"os"

"github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.demo/internal/cmd"
"github.com/fatih/color"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
)

func init() {
forceColorVal, has := os.LookupEnv("FORCE_COLOR")
if has && forceColorVal == "1" {
color.NoColor = false
}
}

func main() {
// Execute the root command
ctx := context.Background()
rootCmd := cmd.NewRootCommand()

if err := rootCmd.ExecuteContext(ctx); err != nil {
color.Red("Error: %v", err)
os.Exit(1)
}
azdext.Run(cmd.NewRootCommand())
}
86 changes: 47 additions & 39 deletions cli/azd/grpc/proto/errors.proto
Original file line number Diff line number Diff line change
@@ -1,39 +1,47 @@
// 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";

// ErrorOrigin indicates where an error originated from.
// This helps with telemetry categorization and error handling strategies.
enum ErrorOrigin {
ERROR_ORIGIN_UNSPECIFIED = 0; // Default/unknown origin
ERROR_ORIGIN_LOCAL = 1; // Local errors: config, filesystem, auth state, validation, etc.
ERROR_ORIGIN_SERVICE = 2; // HTTP/gRPC upstream service errors
ERROR_ORIGIN_TOOL = 3; // Subprocess, plugin, or external tool errors
}

// ServiceErrorDetail contains structured error information from an HTTP/gRPC service.
// Used when ErrorOrigin is ERROR_ORIGIN_SERVICE.
message ServiceErrorDetail {
string error_code = 1; // Error code from the service (e.g., "Conflict", "NotFound", "RateLimitExceeded")
int32 status_code = 2; // HTTP status code (e.g., 409, 404, 500)
string service_name = 3; // Service host/name for telemetry (e.g., "ai.azure.com", "management.azure.com")
}

// ExtensionError is a unified error message that can represent errors from different sources.
// It provides structured error information for telemetry and error handling.
message ExtensionError {
string message = 2; // Human-readable error message
string details = 3; // Additional error details
ErrorOrigin origin = 4; // Where the error originated from

// Source-specific structured details. Only one should be set based on origin.
oneof source {
ServiceErrorDetail service_error = 10;
// ToolErrorDetail tool_error = 11; // Reserved for future use
// LocalErrorDetail local_error = 12; // Reserved for future use
}
}
// 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";

// ErrorOrigin indicates where an error originated from.
// This helps with telemetry categorization and error handling strategies.
enum ErrorOrigin {
ERROR_ORIGIN_UNSPECIFIED = 0; // Default/unknown origin
ERROR_ORIGIN_LOCAL = 1; // Local errors: config, filesystem, auth state, validation, etc.
ERROR_ORIGIN_SERVICE = 2; // HTTP/gRPC upstream service errors
ERROR_ORIGIN_TOOL = 3; // Subprocess, plugin, or external tool errors
}

// ServiceErrorDetail contains structured error information from an HTTP/gRPC service.
// Used when ErrorOrigin is ERROR_ORIGIN_SERVICE.
message ServiceErrorDetail {
string error_code = 1; // Error code from the service (e.g., "Conflict", "NotFound", "RateLimitExceeded")
int32 status_code = 2; // HTTP status code (e.g., 409, 404, 500)
string service_name = 3; // Service host/name for telemetry (e.g., "ai.azure.com", "management.azure.com")
}

// LocalErrorDetail contains structured error information for local/validation failures.
// Used when ErrorOrigin is ERROR_ORIGIN_LOCAL.
message LocalErrorDetail {
string code = 1; // Extension-defined error code (e.g., "invalid_config")
string category = 2; // Error category (e.g., "user", "validation", "dependency", "internal")
}

// ExtensionError is a unified error message that can represent errors from different sources.
// It provides structured error information for telemetry and error handling.
message ExtensionError {
reserved 1, 3; // Reserved for future use; do not reuse.
string message = 2; // Human-readable error message
ErrorOrigin origin = 4; // Where the error originated from
string suggestion = 5; // Optional user-facing suggestion for resolving the error

// Source-specific structured details. Only one should be set based on origin.
oneof source {
ServiceErrorDetail service_error = 10;
LocalErrorDetail local_error = 11;
// ToolErrorDetail tool_error = 12;
}
}
Loading
Loading