From caa9fdca37973d86c724ee2c82c5b871371239f1 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 20 Feb 2026 22:18:20 +0000 Subject: [PATCH 01/10] Add azdext.Run lifecycle and structured extension error transport --- cli/azd/cmd/extensions.go | 66 +++++++++ .../cmd/extensions_error_transport_test.go | 72 ++++++++++ cli/azd/cmd/middleware/ux.go | 5 + .../docs/extensions/extension-framework.md | 51 +++++++ .../internal/cmd/gh_url_parse.go | 22 +++ cli/azd/extensions/microsoft.azd.demo/main.go | 21 +-- cli/azd/grpc/proto/errors.proto | 86 ++++++----- cli/azd/internal/cmd/errors.go | 35 ++++- cli/azd/internal/cmd/errors_test.go | 40 +++++- cli/azd/internal/tracing/fields/fields.go | 17 +++ cli/azd/pkg/azdext/errors.pb.go | 136 ++++++++++++++---- cli/azd/pkg/azdext/extension_error.go | 76 ++++++++-- .../pkg/azdext/extension_error_category.go | 47 ++++++ .../azdext/extension_error_category_test.go | 53 +++++++ cli/azd/pkg/azdext/extension_error_report.go | 75 ++++++++++ .../pkg/azdext/extension_error_report_test.go | 41 ++++++ cli/azd/pkg/azdext/extension_error_test.go | 35 ++++- cli/azd/pkg/azdext/run.go | 100 +++++++++++++ 18 files changed, 869 insertions(+), 109 deletions(-) create mode 100644 cli/azd/cmd/extensions_error_transport_test.go create mode 100644 cli/azd/pkg/azdext/extension_error_category.go create mode 100644 cli/azd/pkg/azdext/extension_error_category_test.go create mode 100644 cli/azd/pkg/azdext/extension_error_report.go create mode 100644 cli/azd/pkg/azdext/extension_error_report_test.go create mode 100644 cli/azd/pkg/azdext/run.go diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index e2956b0177f..78a0ada1817 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -14,6 +14,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal/grpcserver" "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/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/extensions" @@ -232,6 +233,18 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error fmt.Sprintf("AZD_ACCESS_TOKEN=%s", jwtToken), ) + // Create a temp file for the extension to write structured error info into. + // The path is passed via AZD_ERROR_FILE; the extension calls azdext.ReportError + // (automatically via azdext.Run) which serializes a LocalError/ServiceError as + // protojson into this file on failure. + errorFileEnv, cleanupErrorFile, err := createExtensionErrorFileEnv() + if err != nil { + log.Printf("failed to create extension error file: %v", err) + } else { + defer cleanupErrorFile() + allEnv = append(allEnv, errorFileEnv) + } + // Propagate trace context to the extension process if traceEnv := tracing.Environ(ctx); len(traceEnv) > 0 { allEnv = append(allEnv, traceEnv...) @@ -249,12 +262,65 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error // Update warning is shown via defer above (runs after invoke completes) if invokeErr != nil { + // Read the structured error the extension wrote to the error file. + // This gives us a typed LocalError/ServiceError for telemetry classification + // instead of just a generic exit-code error. + reportedErr, readErr := readReportedExtensionErrorFromEnv(allEnv) + if readErr != nil { + log.Printf("failed to read reported extension error: %v", readErr) + } else if reportedErr != nil { + // Wrap both errors so the chain contains both: + // - reportedErr (LocalError/ServiceError) for telemetry classification + // - invokeErr (ExtensionRunError) for UxMiddleware suppression + return nil, fmt.Errorf("%w: %w", reportedErr, invokeErr) + } + return nil, invokeErr } return nil, nil } +// createExtensionErrorFileEnv creates a temp file for extension error reporting and returns +// the formatted env var (AZD_ERROR_FILE=) to pass to the extension process. +func createExtensionErrorFileEnv() (envVar string, cleanup func(), err error) { + errorFile, err := os.CreateTemp("", "azd-ext-error-*.json") + if err != nil { + return "", func() {}, err + } + + errorFilePath := errorFile.Name() + if closeErr := errorFile.Close(); closeErr != nil { + return "", func() {}, closeErr + } + + cleanup = func() { + if removeErr := os.Remove(errorFilePath); removeErr != nil && !os.IsNotExist(removeErr) { + log.Printf("failed to remove extension error file: %v", removeErr) + } + } + + return fmt.Sprintf("%s=%s", azdext.ExtensionErrorFileEnv, errorFilePath), cleanup, nil +} + +// readReportedExtensionErrorFromEnv extracts the error file path from the env slice +// and reads the structured error written by the extension (if any). +func readReportedExtensionErrorFromEnv(env []string) (error, error) { + errorFilePath := "" + for _, envVar := range env { + if after, ok := strings.CutPrefix(envVar, azdext.ExtensionErrorFileEnv+"="); ok { + errorFilePath = after + break + } + } + + if errorFilePath == "" { + return nil, nil + } + + return azdext.ReadErrorFile(errorFilePath) +} + // updateCheckOutcome holds the result of an async update check type updateCheckOutcome struct { shouldShow bool diff --git a/cli/azd/cmd/extensions_error_transport_test.go b/cli/azd/cmd/extensions_error_transport_test.go new file mode 100644 index 00000000000..30c7151409e --- /dev/null +++ b/cli/azd/cmd/extensions_error_transport_test.go @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "os" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestReadReportedExtensionErrorFromEnv(t *testing.T) { + t.Run("NoEnvVar", func(t *testing.T) { + err, readErr := readReportedExtensionErrorFromEnv(nil) + require.NoError(t, readErr) + require.Nil(t, err) + }) + + t.Run("ValidErrorFile", func(t *testing.T) { + path := t.TempDir() + "/ext-error.json" + writeErr := azdext.WriteErrorFile(path, &azdext.LocalError{ + Message: "invalid config", + Code: "invalid_config", + Category: azdext.LocalErrorCategoryValidation, + }) + require.NoError(t, writeErr) + + err, readErr := readReportedExtensionErrorFromEnv([]string{ + "OTHER=1", + azdext.ExtensionErrorFileEnv + "=" + path, + }) + require.NoError(t, readErr) + + var localErr *azdext.LocalError + require.True(t, errors.As(err, &localErr)) + require.Equal(t, "invalid_config", localErr.Code) + require.Equal(t, azdext.LocalErrorCategoryValidation, localErr.Category) + }) + + t.Run("InvalidErrorFileContent", func(t *testing.T) { + path := t.TempDir() + "/ext-error-invalid.json" + require.NoError(t, osWriteFile(path, []byte("{invalid-json"))) + + err, readErr := readReportedExtensionErrorFromEnv([]string{ + azdext.ExtensionErrorFileEnv + "=" + path, + }) + require.Nil(t, err) + require.Error(t, readErr) + require.True(t, strings.Contains(readErr.Error(), "unmarshal extension error file")) + }) +} + +func TestCreateExtensionErrorFileEnv(t *testing.T) { + envVar, cleanup, err := createExtensionErrorFileEnv() + require.NoError(t, err) + require.NotEmpty(t, envVar) + require.True(t, strings.HasPrefix(envVar, azdext.ExtensionErrorFileEnv+"=")) + + path := strings.TrimPrefix(envVar, azdext.ExtensionErrorFileEnv+"=") + require.FileExists(t, path) + + cleanup() + require.NoFileExists(t, path) +} + +func osWriteFile(path string, content []byte) error { + return os.WriteFile(path, content, 0o600) +} diff --git a/cli/azd/cmd/middleware/ux.go b/cli/azd/cmd/middleware/ux.go index 31c24c4470d..95221eaee3f 100644 --- a/cli/azd/cmd/middleware/ux.go +++ b/cli/azd/cmd/middleware/ux.go @@ -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" @@ -74,6 +75,10 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes errorMessage.WriteString(output.WithErrorFormat("\nTraceID: %s", errorWithTraceId.TraceId)) } + if suggestion := azdext.ErrorSuggestion(err); suggestion != "" { + errorMessage.WriteString("\nSuggestion: " + suggestion) + } + errMessage := errorMessage.String() if errors.As(err, &unsupportedErr) { diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index be08a031c90..2b43ac8c573 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -1115,6 +1115,7 @@ When `azd` invokes an extension command, the following steps occur: 2. `azd` invokes your command, passing all arguments and flags: - An environment variable named `AZD_SERVER` is set with the server address and random port (e.g., `localhost:12345`). - An `azd` access token environment variable `AZD_ACCESS_TOKEN` is set which is a JWT token signed with a randomly generated key good for the lifetime of the command. The token includes claims that identify each unique extensions and its supported capabilities. + - An environment variable named `AZD_ERROR_FILE` is set with a temporary file path that extensions can use to report a structured error on command failure. - Additional environment variables from the current `azd` environment are also set. 3. The extension command can communicate with `azd` through [extension framework gRPC services](#grpc-services). 4. `azd` waits for the extension command to complete: @@ -1124,6 +1125,56 @@ 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 payload to +`AZD_ERROR_FILE` before exiting with a non-zero status code. + +For Go extensions, use `azdext.ReportError` from your command entry point: + +```go +func main() { + ctx := azdext.NewContext() + rootCmd := cmd.NewRootCommand() + + if err := rootCmd.ExecuteContext(ctx); err != nil { + _ = azdext.ReportError(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", +} +``` + +```go +return &azdext.ServiceError{ + Message: "request failed", + ErrorCode: "TooManyRequests", + StatusCode: 429, + ServiceName: "openai.azure.com", +} +``` + +Current telemetry result code conventions: + +- Service errors: `ext.service..` +- Local domain categories: + - `validation` -> `ext.validation.` + - `auth` -> `ext.auth.` + - `dependency` -> `ext.dependency.` + - `compatibility` -> `ext.compatibility.` + - `user` -> `ext.user.` + - `internal` -> `ext.internal.` +- Unknown local categories fall back to `ext.local.`. + #### Go For extensions built using Go, the `azdext` package provides an `AzdClient` which acts as the gRPC client. diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/gh_url_parse.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/gh_url_parse.go index e69e4232c47..1ac9b9d35d6 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/gh_url_parse.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/gh_url_parse.go @@ -5,6 +5,8 @@ package cmd import ( "fmt" + "net/url" + "strings" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" @@ -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, diff --git a/cli/azd/extensions/microsoft.azd.demo/main.go b/cli/azd/extensions/microsoft.azd.demo/main.go index b013bd28a0b..eb4598919c0 100644 --- a/cli/azd/extensions/microsoft.azd.demo/main.go +++ b/cli/azd/extensions/microsoft.azd.demo/main.go @@ -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()) } diff --git a/cli/azd/grpc/proto/errors.proto b/cli/azd/grpc/proto/errors.proto index 7ba02f4eb43..cd7bb5cff4b 100644 --- a/cli/azd/grpc/proto/errors.proto +++ b/cli/azd/grpc/proto/errors.proto @@ -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; + } +} diff --git a/cli/azd/internal/cmd/errors.go b/cli/azd/internal/cmd/errors.go index 1b5f20b1238..507847e463f 100644 --- a/cli/azd/internal/cmd/errors.go +++ b/cli/azd/internal/cmd/errors.go @@ -14,6 +14,7 @@ import ( "net" "path/filepath" "reflect" + "regexp" "strings" "github.com/AlecAivazis/survey/v2/terminal" @@ -41,6 +42,7 @@ func MapError(err error, span tracing.Span) { var armDeployErr *azapi.AzureDeploymentError var authFailedErr *auth.AuthFailedError var extServiceErr *azdext.ServiceError + var extLocalErr *azdext.LocalError // external tool errors var toolExecErr *exec.ExitError @@ -112,8 +114,6 @@ func MapError(err error, span tracing.Span) { operation = "deployment" } errCode = fmt.Sprintf("service.arm.%s.failed", operation) - } else if errors.As(err, &extensionRunErr) { - errCode = "ext.run.failed" } else if errors.As(err, &extServiceErr) { // Handle structured service errors from extensions if extServiceErr.StatusCode > 0 && extServiceErr.ServiceName != "" { @@ -130,6 +130,18 @@ func MapError(err error, span tracing.Span) { } else { errCode = "ext.service.failed" } + } else if errors.As(err, &extLocalErr) { + domain := string(azdext.NormalizeLocalErrorCategory(extLocalErr.Category)) + code := normalizeCodeSegment(extLocalErr.Code, "failed") + + errDetails = append(errDetails, + fields.LocalErrorCategory.String(domain), + fields.LocalErrorCode.String(code), + ) + + errCode = fmt.Sprintf("ext.%s.%s", domain, code) + } else if errors.As(err, &extensionRunErr) { + errCode = "ext.run.failed" } else if errors.As(err, &toolExecErr) { toolName := "other" cmdName := cmdAsName(toolExecErr.Cmd) @@ -323,3 +335,22 @@ func cmdAsName(cmd string) string { return strings.ToLower(cmd) } + +var codeSegmentRegex = regexp.MustCompile(`[^a-z0-9_]+`) + +func normalizeCodeSegment(value string, fallback string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return fallback + } + + value = strings.ReplaceAll(value, "-", "_") + value = strings.ReplaceAll(value, ".", "_") + value = codeSegmentRegex.ReplaceAllString(value, "_") + value = strings.Trim(value, "_") + if value == "" { + return fallback + } + + return value +} diff --git a/cli/azd/internal/cmd/errors_test.go b/cli/azd/internal/cmd/errors_test.go index 95438bf7075..dc29cce4f5c 100644 --- a/cli/azd/internal/cmd/errors_test.go +++ b/cli/azd/internal/cmd/errors_test.go @@ -190,7 +190,6 @@ func Test_MapError(t *testing.T) { name: "WithExtServiceError", err: &azdext.ServiceError{ Message: "Rate limit exceeded", - Details: "Too many requests", ErrorCode: "RateLimitExceeded", StatusCode: 429, ServiceName: "openai.azure.com", @@ -248,6 +247,45 @@ func Test_MapError(t *testing.T) { fields.ErrType.String("*errors.errorString"), }, }, + { + name: "WithExtLocalError", + err: &azdext.LocalError{ + Message: "invalid manifest", + Code: "Invalid-Config", + Category: azdext.LocalErrorCategoryValidation, + }, + wantErrReason: "ext.validation.invalid_config", + wantErrDetails: []attribute.KeyValue{ + attribute.String("error.ext.category", "validation"), + attribute.String("error.ext.code", "invalid_config"), + }, + }, + { + name: "WithExtLocalErrorUnknownCategory", + err: &azdext.LocalError{ + Message: "some local failure", + Code: "Something-Bad", + Category: azdext.LocalErrorCategory("custom"), + }, + wantErrReason: "ext.local.something_bad", + wantErrDetails: []attribute.KeyValue{ + attribute.String("error.ext.category", "local"), + attribute.String("error.ext.code", "something_bad"), + }, + }, + { + name: "WithExtLocalErrorAuthDomain", + err: &azdext.LocalError{ + Message: "token expired", + Code: "token_expired", + Category: azdext.LocalErrorCategoryAuth, + }, + wantErrReason: "ext.auth.token_expired", + wantErrDetails: []attribute.KeyValue{ + attribute.String("error.ext.category", "auth"), + attribute.String("error.ext.code", "token_expired"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 32b6d039840..2b0aeb24338 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -348,6 +348,23 @@ var ( } ) +// Local extension error related fields. +var ( + // Local extension error category. + LocalErrorCategory = AttributeKey{ + Key: attribute.Key("local.category"), + Classification: SystemMetadata, + Purpose: PerformanceAndHealth, + } + + // Local extension error code. + LocalErrorCode = AttributeKey{ + Key: attribute.Key("local.code"), + Classification: SystemMetadata, + Purpose: PerformanceAndHealth, + } +) + // Service related fields. var ( // Hostname of the service. diff --git a/cli/azd/pkg/azdext/errors.pb.go b/cli/azd/pkg/azdext/errors.pb.go index ce3e177a5ab..73e58b9b090 100644 --- a/cli/azd/pkg/azdext/errors.pb.go +++ b/cli/azd/pkg/azdext/errors.pb.go @@ -140,18 +140,73 @@ func (x *ServiceErrorDetail) GetServiceName() string { return "" } +// LocalErrorDetail contains structured error information for local/validation failures. +// Used when ErrorOrigin is ERROR_ORIGIN_LOCAL. +type LocalErrorDetail struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` // Extension-defined error code (e.g., "invalid_config") + Category string `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"` // Error category (e.g., "user", "validation", "dependency", "internal") + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalErrorDetail) Reset() { + *x = LocalErrorDetail{} + mi := &file_errors_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalErrorDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalErrorDetail) ProtoMessage() {} + +func (x *LocalErrorDetail) ProtoReflect() protoreflect.Message { + mi := &file_errors_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 LocalErrorDetail.ProtoReflect.Descriptor instead. +func (*LocalErrorDetail) Descriptor() ([]byte, []int) { + return file_errors_proto_rawDescGZIP(), []int{1} +} + +func (x *LocalErrorDetail) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *LocalErrorDetail) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + // ExtensionError is a unified error message that can represent errors from different sources. // It provides structured error information for telemetry and error handling. type ExtensionError struct { - state protoimpl.MessageState `protogen:"open.v1"` - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // Human-readable error message - Details string `protobuf:"bytes,3,opt,name=details,proto3" json:"details,omitempty"` // Additional error details - Origin ErrorOrigin `protobuf:"varint,4,opt,name=origin,proto3,enum=azdext.ErrorOrigin" json:"origin,omitempty"` // Where the error originated from + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // Human-readable error message + Origin ErrorOrigin `protobuf:"varint,4,opt,name=origin,proto3,enum=azdext.ErrorOrigin" json:"origin,omitempty"` // Where the error originated from + Suggestion string `protobuf:"bytes,5,opt,name=suggestion,proto3" json:"suggestion,omitempty"` // Optional user-facing suggestion for resolving the error // Source-specific structured details. Only one should be set based on origin. // // Types that are valid to be assigned to Source: // // *ExtensionError_ServiceError + // *ExtensionError_LocalError Source isExtensionError_Source `protobuf_oneof:"source"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -159,7 +214,7 @@ type ExtensionError struct { func (x *ExtensionError) Reset() { *x = ExtensionError{} - mi := &file_errors_proto_msgTypes[1] + mi := &file_errors_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -171,7 +226,7 @@ func (x *ExtensionError) String() string { func (*ExtensionError) ProtoMessage() {} func (x *ExtensionError) ProtoReflect() protoreflect.Message { - mi := &file_errors_proto_msgTypes[1] + mi := &file_errors_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -184,7 +239,7 @@ func (x *ExtensionError) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionError.ProtoReflect.Descriptor instead. func (*ExtensionError) Descriptor() ([]byte, []int) { - return file_errors_proto_rawDescGZIP(), []int{1} + return file_errors_proto_rawDescGZIP(), []int{2} } func (x *ExtensionError) GetMessage() string { @@ -194,18 +249,18 @@ func (x *ExtensionError) GetMessage() string { return "" } -func (x *ExtensionError) GetDetails() string { +func (x *ExtensionError) GetOrigin() ErrorOrigin { if x != nil { - return x.Details + return x.Origin } - return "" + return ErrorOrigin_ERROR_ORIGIN_UNSPECIFIED } -func (x *ExtensionError) GetOrigin() ErrorOrigin { +func (x *ExtensionError) GetSuggestion() string { if x != nil { - return x.Origin + return x.Suggestion } - return ErrorOrigin_ERROR_ORIGIN_UNSPECIFIED + return "" } func (x *ExtensionError) GetSource() isExtensionError_Source { @@ -224,6 +279,15 @@ func (x *ExtensionError) GetServiceError() *ServiceErrorDetail { return nil } +func (x *ExtensionError) GetLocalError() *LocalErrorDetail { + if x != nil { + if x, ok := x.Source.(*ExtensionError_LocalError); ok { + return x.LocalError + } + } + return nil +} + type isExtensionError_Source interface { isExtensionError_Source() } @@ -232,8 +296,14 @@ type ExtensionError_ServiceError struct { ServiceError *ServiceErrorDetail `protobuf:"bytes,10,opt,name=service_error,json=serviceError,proto3,oneof"` } +type ExtensionError_LocalError struct { + LocalError *LocalErrorDetail `protobuf:"bytes,11,opt,name=local_error,json=localError,proto3,oneof"` // ToolErrorDetail tool_error = 12; +} + func (*ExtensionError_ServiceError) isExtensionError_Source() {} +func (*ExtensionError_LocalError) isExtensionError_Source() {} + var File_errors_proto protoreflect.FileDescriptor const file_errors_proto_rawDesc = "" + @@ -244,14 +314,21 @@ const file_errors_proto_rawDesc = "" + "error_code\x18\x01 \x01(\tR\terrorCode\x12\x1f\n" + "\vstatus_code\x18\x02 \x01(\x05R\n" + "statusCode\x12!\n" + - "\fservice_name\x18\x03 \x01(\tR\vserviceName\"\xbe\x01\n" + + "\fservice_name\x18\x03 \x01(\tR\vserviceName\"B\n" + + "\x10LocalErrorDetail\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x1a\n" + + "\bcategory\x18\x02 \x01(\tR\bcategory\"\x8d\x02\n" + "\x0eExtensionError\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\x12\x18\n" + - "\adetails\x18\x03 \x01(\tR\adetails\x12+\n" + - "\x06origin\x18\x04 \x01(\x0e2\x13.azdext.ErrorOriginR\x06origin\x12A\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12+\n" + + "\x06origin\x18\x04 \x01(\x0e2\x13.azdext.ErrorOriginR\x06origin\x12\x1e\n" + + "\n" + + "suggestion\x18\x05 \x01(\tR\n" + + "suggestion\x12A\n" + "\rservice_error\x18\n" + - " \x01(\v2\x1a.azdext.ServiceErrorDetailH\x00R\fserviceErrorB\b\n" + - "\x06source*t\n" + + " \x01(\v2\x1a.azdext.ServiceErrorDetailH\x00R\fserviceError\x12;\n" + + "\vlocal_error\x18\v \x01(\v2\x18.azdext.LocalErrorDetailH\x00R\n" + + "localErrorB\b\n" + + "\x06sourceJ\x04\b\x01\x10\x02J\x04\b\x03\x10\x04*t\n" + "\vErrorOrigin\x12\x1c\n" + "\x18ERROR_ORIGIN_UNSPECIFIED\x10\x00\x12\x16\n" + "\x12ERROR_ORIGIN_LOCAL\x10\x01\x12\x18\n" + @@ -271,20 +348,22 @@ func file_errors_proto_rawDescGZIP() []byte { } var file_errors_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_errors_proto_goTypes = []any{ (ErrorOrigin)(0), // 0: azdext.ErrorOrigin (*ServiceErrorDetail)(nil), // 1: azdext.ServiceErrorDetail - (*ExtensionError)(nil), // 2: azdext.ExtensionError + (*LocalErrorDetail)(nil), // 2: azdext.LocalErrorDetail + (*ExtensionError)(nil), // 3: azdext.ExtensionError } var file_errors_proto_depIdxs = []int32{ 0, // 0: azdext.ExtensionError.origin:type_name -> azdext.ErrorOrigin 1, // 1: azdext.ExtensionError.service_error:type_name -> azdext.ServiceErrorDetail - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 2, // 2: azdext.ExtensionError.local_error:type_name -> azdext.LocalErrorDetail + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_errors_proto_init() } @@ -292,8 +371,9 @@ func file_errors_proto_init() { if File_errors_proto != nil { return } - file_errors_proto_msgTypes[1].OneofWrappers = []any{ + file_errors_proto_msgTypes[2].OneofWrappers = []any{ (*ExtensionError_ServiceError)(nil), + (*ExtensionError_LocalError)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -301,7 +381,7 @@ func file_errors_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_errors_proto_rawDesc), len(file_errors_proto_rawDesc)), NumEnums: 1, - NumMessages: 2, + NumMessages: 3, NumExtensions: 0, NumServices: 0, }, diff --git a/cli/azd/pkg/azdext/extension_error.go b/cli/azd/pkg/azdext/extension_error.go index 28b45eef2fa..5c61a00ea5b 100644 --- a/cli/azd/pkg/azdext/extension_error.go +++ b/cli/azd/pkg/azdext/extension_error.go @@ -5,7 +5,6 @@ package azdext import ( "errors" - "fmt" "github.com/Azure/azure-sdk-for-go/sdk/azcore" ) @@ -15,21 +14,36 @@ import ( type ServiceError struct { // Message is the human-readable error message Message string - // Details contains additional error details - Details string // ErrorCode is the error code from the service (e.g., "Conflict", "NotFound") ErrorCode string // StatusCode is the HTTP status code (e.g., 409, 404, 500) StatusCode int // ServiceName is the service host/name for telemetry (e.g., "ai.azure.com") ServiceName string + // Suggestion contains optional user-facing remediation guidance. + Suggestion string } -// Error implements the error interface +// LocalError represents non-service extension errors, such as validation/config failures. +type LocalError struct { + // Message is the human-readable error message + Message string + // Code is an extension-defined machine-readable error code (lowercase snake_case, e.g. "missing_subscription_id"). + // It appears in telemetry as the last segment of ext... + Code string + // Category classifies the local error (for example: user, validation, dependency) + Category LocalErrorCategory + // Suggestion contains optional user-facing remediation guidance. + Suggestion string +} + +// Error implements the error interface. +func (e *LocalError) Error() string { + return e.Message +} + +// Error implements the error interface. func (e *ServiceError) Error() string { - if e.Details != "" { - return fmt.Sprintf("%s: %s", e.Message, e.Details) - } return e.Message } @@ -49,7 +63,7 @@ func WrapError(err error) *ExtensionError { var extServiceErr *ServiceError if errors.As(err, &extServiceErr) { extErr.Message = extServiceErr.Message - extErr.Details = extServiceErr.Details + extErr.Suggestion = extServiceErr.Suggestion extErr.Origin = ErrorOrigin_ERROR_ORIGIN_SERVICE extErr.Source = &ExtensionError_ServiceError{ ServiceError: &ServiceErrorDetail{ @@ -62,6 +76,21 @@ func WrapError(err error) *ExtensionError { return extErr } + var extLocalErr *LocalError + if errors.As(err, &extLocalErr) { + normalizedCategory := NormalizeLocalErrorCategory(extLocalErr.Category) + extErr.Message = extLocalErr.Message + extErr.Suggestion = extLocalErr.Suggestion + extErr.Origin = ErrorOrigin_ERROR_ORIGIN_LOCAL + extErr.Source = &ExtensionError_LocalError{ + LocalError: &LocalErrorDetail{ + Code: extLocalErr.Code, + Category: string(normalizedCategory), + }, + } + return extErr + } + // Try to detect Azure SDK errors var respErr *azcore.ResponseError if errors.As(err, &respErr) { @@ -94,16 +123,37 @@ func UnwrapError(msg *ExtensionError) error { if svcErr := msg.GetServiceError(); svcErr != nil { return &ServiceError{ Message: msg.GetMessage(), - Details: msg.GetDetails(), ErrorCode: svcErr.GetErrorCode(), StatusCode: int(svcErr.GetStatusCode()), ServiceName: svcErr.GetServiceName(), + Suggestion: msg.GetSuggestion(), + } + } + + if localErr := msg.GetLocalError(); localErr != nil { + normalizedCategory := ParseLocalErrorCategory(localErr.GetCategory()) + return &LocalError{ + Message: msg.GetMessage(), + Code: localErr.GetCode(), + Category: normalizedCategory, + Suggestion: msg.GetSuggestion(), } } - // Return a generic service error with just the message/details - return &ServiceError{ - Message: msg.GetMessage(), - Details: msg.GetDetails(), + if msg.GetOrigin() == ErrorOrigin_ERROR_ORIGIN_LOCAL { + return &LocalError{ + Message: msg.GetMessage(), + Category: LocalErrorCategoryLocal, + Suggestion: msg.GetSuggestion(), + } } + + if msg.GetOrigin() == ErrorOrigin_ERROR_ORIGIN_SERVICE { + return &ServiceError{ + Message: msg.GetMessage(), + Suggestion: msg.GetSuggestion(), + } + } + + return errors.New(msg.GetMessage()) } diff --git a/cli/azd/pkg/azdext/extension_error_category.go b/cli/azd/pkg/azdext/extension_error_category.go new file mode 100644 index 00000000000..fd4a4628976 --- /dev/null +++ b/cli/azd/pkg/azdext/extension_error_category.go @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import "strings" + +// LocalErrorCategory is the canonical category type for extension local errors. +// Keep values aligned with telemetry ResultCode families in internal/cmd/errors.go. +type LocalErrorCategory string + +const ( + LocalErrorCategoryValidation LocalErrorCategory = "validation" + LocalErrorCategoryAuth LocalErrorCategory = "auth" + LocalErrorCategoryDependency LocalErrorCategory = "dependency" + LocalErrorCategoryCompatibility LocalErrorCategory = "compatibility" + LocalErrorCategoryUser LocalErrorCategory = "user" + LocalErrorCategoryInternal LocalErrorCategory = "internal" + LocalErrorCategoryLocal LocalErrorCategory = "local" +) + +// NormalizeLocalErrorCategory validates a typed category value, returning the canonical constant. +// Unknown values are collapsed to LocalErrorCategoryLocal. +func NormalizeLocalErrorCategory(category LocalErrorCategory) LocalErrorCategory { + switch LocalErrorCategory(strings.ToLower(strings.TrimSpace(string(category)))) { + case LocalErrorCategoryValidation: + return LocalErrorCategoryValidation + case LocalErrorCategoryAuth: + return LocalErrorCategoryAuth + case LocalErrorCategoryDependency: + return LocalErrorCategoryDependency + case LocalErrorCategoryCompatibility: + return LocalErrorCategoryCompatibility + case LocalErrorCategoryUser: + return LocalErrorCategoryUser + case LocalErrorCategoryInternal: + return LocalErrorCategoryInternal + default: + return LocalErrorCategoryLocal + } +} + +// ParseLocalErrorCategory parses a raw category string (e.g. from proto deserialization) +// into its canonical LocalErrorCategory constant. Unknown values map to LocalErrorCategoryLocal. +func ParseLocalErrorCategory(category string) LocalErrorCategory { + return NormalizeLocalErrorCategory(LocalErrorCategory(category)) +} diff --git a/cli/azd/pkg/azdext/extension_error_category_test.go b/cli/azd/pkg/azdext/extension_error_category_test.go new file mode 100644 index 00000000000..a6281d6f69b --- /dev/null +++ b/cli/azd/pkg/azdext/extension_error_category_test.go @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import "testing" + +func TestNormalizeLocalErrorCategory(t *testing.T) { + tests := []struct { + name string + input LocalErrorCategory + expected LocalErrorCategory + }{ + {name: "validation", input: "validation", expected: LocalErrorCategoryValidation}, + {name: "auth", input: "auth", expected: LocalErrorCategoryAuth}, + {name: "dependency", input: "dependency", expected: LocalErrorCategoryDependency}, + {name: "compatibility", input: "compatibility", expected: LocalErrorCategoryCompatibility}, + {name: "user", input: "user", expected: LocalErrorCategoryUser}, + {name: "internal", input: "internal", expected: LocalErrorCategoryInternal}, + {name: "dot variant", input: "dependency.error", expected: LocalErrorCategoryLocal}, + {name: "dash variant", input: "compatibility-check", expected: LocalErrorCategoryLocal}, + {name: "unknown", input: "custom", expected: LocalErrorCategoryLocal}, + {name: "empty", input: "", expected: LocalErrorCategoryLocal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeLocalErrorCategory(tt.input); got != tt.expected { + t.Fatalf("NormalizeLocalErrorCategory(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestParseLocalErrorCategory(t *testing.T) { + tests := []struct { + name string + input string + expected LocalErrorCategory + }{ + {name: "known", input: "validation", expected: LocalErrorCategoryValidation}, + {name: "unknown", input: "custom", expected: LocalErrorCategoryLocal}, + {name: "empty", input: "", expected: LocalErrorCategoryLocal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ParseLocalErrorCategory(tt.input); got != tt.expected { + t.Fatalf("ParseLocalErrorCategory(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/cli/azd/pkg/azdext/extension_error_report.go b/cli/azd/pkg/azdext/extension_error_report.go new file mode 100644 index 00000000000..0a5b4598104 --- /dev/null +++ b/cli/azd/pkg/azdext/extension_error_report.go @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "errors" + "fmt" + "os" + + "google.golang.org/protobuf/encoding/protojson" +) + +const ExtensionErrorFileEnv = "AZD_ERROR_FILE" + +// ReportError writes a structured extension error to the path provided in AZD_ERROR_FILE. +// It is a no-op when the environment variable is not set. +func ReportError(err error) error { + path := os.Getenv(ExtensionErrorFileEnv) + if path == "" { + return nil + } + + return WriteErrorFile(path, err) +} + +// WriteErrorFile writes a structured extension error to a file path. +func WriteErrorFile(path string, err error) error { + if err == nil || path == "" { + return nil + } + + extErr := WrapError(err) + if extErr == nil { + return nil + } + + content, marshalErr := protojson.Marshal(extErr) + if marshalErr != nil { + return fmt.Errorf("marshal extension error: %w", marshalErr) + } + + if writeErr := os.WriteFile(path, content, 0o600); writeErr != nil { + return fmt.Errorf("write extension error file: %w", writeErr) + } + + return nil +} + +// ReadErrorFile reads and parses a structured extension error from file. +// Returns (nil, nil) when the file does not exist or is empty. +func ReadErrorFile(path string) (error, error) { + if path == "" { + return nil, nil + } + + content, readErr := os.ReadFile(path) + if errors.Is(readErr, os.ErrNotExist) { + return nil, nil + } + if readErr != nil { + return nil, fmt.Errorf("read extension error file: %w", readErr) + } + + if len(content) == 0 { + return nil, nil + } + + msg := &ExtensionError{} + if unmarshalErr := protojson.Unmarshal(content, msg); unmarshalErr != nil { + return nil, fmt.Errorf("unmarshal extension error file: %w", unmarshalErr) + } + + return UnwrapError(msg), nil +} diff --git a/cli/azd/pkg/azdext/extension_error_report_test.go b/cli/azd/pkg/azdext/extension_error_report_test.go new file mode 100644 index 00000000000..68ec4a36e59 --- /dev/null +++ b/cli/azd/pkg/azdext/extension_error_report_test.go @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWriteReadErrorFile(t *testing.T) { + t.Run("RoundTripLocalError", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "ext-error.json") + + writeErr := WriteErrorFile(path, &LocalError{ + Message: "invalid config", + Code: "invalid_config", + Category: LocalErrorCategoryValidation, + Suggestion: "Add missing field in config", + }) + require.NoError(t, writeErr) + + err, readErr := ReadErrorFile(path) + require.NoError(t, readErr) + require.NotNil(t, err) + + var localErr *LocalError + require.ErrorAs(t, err, &localErr) + require.Equal(t, "invalid_config", localErr.Code) + require.Equal(t, LocalErrorCategoryValidation, localErr.Category) + require.Equal(t, "Add missing field in config", localErr.Suggestion) + }) + + t.Run("MissingFile", func(t *testing.T) { + err, readErr := ReadErrorFile(filepath.Join(t.TempDir(), "missing.json")) + require.NoError(t, readErr) + require.Nil(t, err) + }) +} diff --git a/cli/azd/pkg/azdext/extension_error_test.go b/cli/azd/pkg/azdext/extension_error_test.go index 20881c1c551..12c7f95ffba 100644 --- a/cli/azd/pkg/azdext/extension_error_test.go +++ b/cli/azd/pkg/azdext/extension_error_test.go @@ -32,24 +32,21 @@ func TestExtensionError_RoundTrip(t *testing.T) { assert.Equal(t, ErrorOrigin_ERROR_ORIGIN_UNSPECIFIED, protoErr.GetOrigin()) assert.Nil(t, protoErr.GetSource()) - // Unspecified origin falls back to ExtServiceError - var svcErr *ServiceError - require.ErrorAs(t, goErr, &svcErr) - assert.Equal(t, "simple error", svcErr.Message) + assert.Equal(t, "simple error", goErr.Error()) }, }, { name: "ExtServiceError", inputErr: &ServiceError{ Message: "Rate limit exceeded", - Details: "Too many requests", ErrorCode: "RateLimitExceeded", StatusCode: 429, ServiceName: "openai.azure.com", + Suggestion: "Retry with exponential backoff", }, verify: func(t *testing.T, protoErr *ExtensionError, goErr error) { assert.Equal(t, "Rate limit exceeded", protoErr.GetMessage()) - assert.Equal(t, "Too many requests", protoErr.GetDetails()) + assert.Equal(t, "Retry with exponential backoff", protoErr.GetSuggestion()) assert.Equal(t, ErrorOrigin_ERROR_ORIGIN_SERVICE, protoErr.GetOrigin()) svcDetail := protoErr.GetServiceError() @@ -61,10 +58,34 @@ func TestExtensionError_RoundTrip(t *testing.T) { var svcErr *ServiceError require.ErrorAs(t, goErr, &svcErr) assert.Equal(t, "Rate limit exceeded", svcErr.Message) - assert.Equal(t, "Too many requests", svcErr.Details) assert.Equal(t, "RateLimitExceeded", svcErr.ErrorCode) assert.Equal(t, 429, svcErr.StatusCode) assert.Equal(t, "openai.azure.com", svcErr.ServiceName) + assert.Equal(t, "Retry with exponential backoff", svcErr.Suggestion) + }, + }, + { + name: "ExtLocalError", + inputErr: &LocalError{ + Message: "invalid config", + Code: "invalid_config", + Category: LocalErrorCategoryValidation, + Suggestion: "Add the missing required field", + }, + verify: func(t *testing.T, protoErr *ExtensionError, goErr error) { + assert.Equal(t, ErrorOrigin_ERROR_ORIGIN_LOCAL, protoErr.GetOrigin()) + assert.Equal(t, "Add the missing required field", protoErr.GetSuggestion()) + + localDetail := protoErr.GetLocalError() + require.NotNil(t, localDetail) + assert.Equal(t, "invalid_config", localDetail.GetCode()) + assert.Equal(t, "validation", localDetail.GetCategory()) + + var localErr *LocalError + require.ErrorAs(t, goErr, &localErr) + assert.Equal(t, "invalid_config", localErr.Code) + assert.Equal(t, LocalErrorCategoryValidation, localErr.Category) + assert.Equal(t, "Add the missing required field", localErr.Suggestion) }, }, { diff --git a/cli/azd/pkg/azdext/run.go b/cli/azd/pkg/azdext/run.go new file mode 100644 index 00000000000..d374360b6db --- /dev/null +++ b/cli/azd/pkg/azdext/run.go @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "context" + "errors" + "fmt" + "log" + "os" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// RunOption configures the behavior of [Run]. +type RunOption func(*runConfig) + +type runConfig struct { + preExecute func(ctx context.Context, cmd *cobra.Command) error +} + +// WithPreExecute registers a hook that runs after context creation but before +// command execution. If the hook returns a non-nil error, Run prints it and +// exits. This is useful for extensions that need special setup such as +// dual-mode host detection or working-directory changes. +func WithPreExecute(fn func(ctx context.Context, cmd *cobra.Command) error) RunOption { + return func(c *runConfig) { c.preExecute = fn } +} + +// Run is the standard entry point for azd extensions. It handles all lifecycle +// boilerplate that every extension needs: +// - FORCE_COLOR environment variable → color.NoColor +// - cobra SilenceErrors (Run controls error output) +// - Context creation with tracing propagation +// - Command execution +// - Structured error reporting via AZD_ERROR_FILE +// - Error + suggestion display +// - os.Exit on failure +// +// A typical extension main.go becomes: +// +// func main() { +// azdext.Run(cmd.NewRootCommand()) +// } +func Run(rootCmd *cobra.Command, opts ...RunOption) { + if v, ok := os.LookupEnv("FORCE_COLOR"); ok && v == "1" { + color.NoColor = false + } + + rootCmd.SilenceErrors = true + + ctx := NewContext() + + var cfg runConfig + for _, o := range opts { + o(&cfg) + } + + if cfg.preExecute != nil { + if err := cfg.preExecute(ctx, rootCmd); err != nil { + printError(err) + os.Exit(1) + } + } + + if err := rootCmd.ExecuteContext(ctx); err != nil { + if reportErr := ReportError(err); reportErr != nil { + log.Printf("warning: failed to report structured error: %v", reportErr) + } + + printError(err) + os.Exit(1) + } +} + +func printError(err error) { + color.Red("Error: %v", err) + + if s := ErrorSuggestion(err); s != "" { + fmt.Fprintln(os.Stderr, "Suggestion: "+s) + } +} + +// ErrorSuggestion extracts the Suggestion field from a [LocalError] or [ServiceError]. +// Returns an empty string if the error has no suggestion. +func ErrorSuggestion(err error) string { + var localErr *LocalError + if errors.As(err, &localErr) && localErr.Suggestion != "" { + return localErr.Suggestion + } + + var svcErr *ServiceError + if errors.As(err, &svcErr) && svcErr.Suggestion != "" { + return svcErr.Suggestion + } + + return "" +} From 0c46263ebb01205a38b6d5841a4d74d9a0f9c735 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 20 Feb 2026 23:31:03 +0000 Subject: [PATCH 02/10] Rename fields --- cli/azd/internal/cmd/errors.go | 4 ++-- cli/azd/internal/tracing/fields/fields.go | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cli/azd/internal/cmd/errors.go b/cli/azd/internal/cmd/errors.go index 507847e463f..7c380707450 100644 --- a/cli/azd/internal/cmd/errors.go +++ b/cli/azd/internal/cmd/errors.go @@ -135,8 +135,8 @@ func MapError(err error, span tracing.Span) { code := normalizeCodeSegment(extLocalErr.Code, "failed") errDetails = append(errDetails, - fields.LocalErrorCategory.String(domain), - fields.LocalErrorCode.String(code), + fields.ExtErrorCategory.String(domain), + fields.ExtErrorCode.String(code), ) errCode = fmt.Sprintf("ext.%s.%s", domain, code) diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 2b0aeb24338..4894fa70020 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -348,18 +348,18 @@ var ( } ) -// Local extension error related fields. +// Extension error related fields. var ( - // Local extension error category. - LocalErrorCategory = AttributeKey{ - Key: attribute.Key("local.category"), + // Extension error category. + ExtErrorCategory = AttributeKey{ + Key: attribute.Key("ext.category"), Classification: SystemMetadata, Purpose: PerformanceAndHealth, } - // Local extension error code. - LocalErrorCode = AttributeKey{ - Key: attribute.Key("local.code"), + // Extension error code. + ExtErrorCode = AttributeKey{ + Key: attribute.Key("ext.code"), Classification: SystemMetadata, Purpose: PerformanceAndHealth, } From a9420406a1b21bd26a6f83ae2af6cc965635d9cd Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Sat, 21 Feb 2026 01:12:26 +0000 Subject: [PATCH 03/10] Address comments --- cli/azd/cmd/extensions.go | 3 +++ .../cmd/extensions_error_transport_test.go | 5 +++-- cli/azd/cmd/middleware/ux.go | 5 ++++- .../docs/extensions/extension-framework.md | 19 +++++++++++++++---- cli/azd/pkg/azdext/extension_error_report.go | 5 ++++- cli/azd/pkg/azdext/run.go | 14 ++++++++++++-- 6 files changed, 41 insertions(+), 10 deletions(-) diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index 78a0ada1817..8ab0564de2a 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -291,6 +291,9 @@ func createExtensionErrorFileEnv() (envVar string, cleanup func(), err error) { errorFilePath := errorFile.Name() if closeErr := errorFile.Close(); closeErr != nil { + if removeErr := os.Remove(errorFilePath); removeErr != nil && !os.IsNotExist(removeErr) { + log.Printf("failed to remove extension error file after close error: %v", removeErr) + } return "", func() {}, closeErr } diff --git a/cli/azd/cmd/extensions_error_transport_test.go b/cli/azd/cmd/extensions_error_transport_test.go index 30c7151409e..e3843f99eee 100644 --- a/cli/azd/cmd/extensions_error_transport_test.go +++ b/cli/azd/cmd/extensions_error_transport_test.go @@ -6,6 +6,7 @@ package cmd import ( "errors" "os" + "path/filepath" "strings" "testing" @@ -21,7 +22,7 @@ func TestReadReportedExtensionErrorFromEnv(t *testing.T) { }) t.Run("ValidErrorFile", func(t *testing.T) { - path := t.TempDir() + "/ext-error.json" + path := filepath.Join(t.TempDir(), "ext-error.json") writeErr := azdext.WriteErrorFile(path, &azdext.LocalError{ Message: "invalid config", Code: "invalid_config", @@ -42,7 +43,7 @@ func TestReadReportedExtensionErrorFromEnv(t *testing.T) { }) t.Run("InvalidErrorFileContent", func(t *testing.T) { - path := t.TempDir() + "/ext-error-invalid.json" + path := filepath.Join(t.TempDir(), "ext-error-invalid.json") require.NoError(t, osWriteFile(path, []byte("{invalid-json"))) err, readErr := readReportedExtensionErrorFromEnv([]string{ diff --git a/cli/azd/cmd/middleware/ux.go b/cli/azd/cmd/middleware/ux.go index 95221eaee3f..08319b59d1b 100644 --- a/cli/azd/cmd/middleware/ux.go +++ b/cli/azd/cmd/middleware/ux.go @@ -76,7 +76,10 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes } if suggestion := azdext.ErrorSuggestion(err); suggestion != "" { - errorMessage.WriteString("\nSuggestion: " + suggestion) + if !strings.HasPrefix(suggestion, "Suggestion: ") { + suggestion = "Suggestion: " + suggestion + } + errorMessage.WriteString("\n" + suggestion) } errMessage := errorMessage.String() diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index 2b43ac8c573..b49e0ddd0a0 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -1130,7 +1130,16 @@ The gRPC client must also include an `authorization` parameter with the value fr For custom command execution, extensions can provide richer telemetry by reporting a structured error payload to `AZD_ERROR_FILE` before exiting with a non-zero status code. -For Go extensions, use `azdext.ReportError` from your command entry point: +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() { @@ -1148,9 +1157,10 @@ Use typed extension errors to control telemetry mapping: ```go return &azdext.LocalError{ - Message: "invalid configuration: missing required field 'name'", - Code: "invalid_config", - Category: "validation", + Message: "invalid configuration: missing required field 'name'", + Code: "invalid_config", + Category: "validation", + Suggestion: "Ensure your azure.yaml has a 'name' field defined.", } ``` @@ -1160,6 +1170,7 @@ return &azdext.ServiceError{ ErrorCode: "TooManyRequests", StatusCode: 429, ServiceName: "openai.azure.com", + Suggestion: "Wait a moment and retry the request, or increase your rate limit.", } ``` diff --git a/cli/azd/pkg/azdext/extension_error_report.go b/cli/azd/pkg/azdext/extension_error_report.go index 0a5b4598104..e3cb267f7b0 100644 --- a/cli/azd/pkg/azdext/extension_error_report.go +++ b/cli/azd/pkg/azdext/extension_error_report.go @@ -67,7 +67,10 @@ func ReadErrorFile(path string) (error, error) { } msg := &ExtensionError{} - if unmarshalErr := protojson.Unmarshal(content, msg); unmarshalErr != nil { + unmarshalOpts := protojson.UnmarshalOptions{ + DiscardUnknown: true, + } + if unmarshalErr := unmarshalOpts.Unmarshal(content, msg); unmarshalErr != nil { return nil, fmt.Errorf("unmarshal extension error file: %w", unmarshalErr) } diff --git a/cli/azd/pkg/azdext/run.go b/cli/azd/pkg/azdext/run.go index d374360b6db..c0fd6026ead 100644 --- a/cli/azd/pkg/azdext/run.go +++ b/cli/azd/pkg/azdext/run.go @@ -9,6 +9,7 @@ import ( "fmt" "log" "os" + "strings" "github.com/fatih/color" "github.com/spf13/cobra" @@ -60,6 +61,9 @@ func Run(rootCmd *cobra.Command, opts ...RunOption) { if cfg.preExecute != nil { if err := cfg.preExecute(ctx, rootCmd); err != nil { + if reportErr := ReportError(err); reportErr != nil { + log.Printf("warning: failed to report structured error: %v", reportErr) + } printError(err) os.Exit(1) } @@ -76,10 +80,16 @@ func Run(rootCmd *cobra.Command, opts ...RunOption) { } func printError(err error) { - color.Red("Error: %v", err) + redStderr := color.New(color.FgRed) + redStderr.EnableColor() + redStderr.SetWriter(os.Stderr) + redStderr.Fprintf(os.Stderr, "Error: %v\n", err) if s := ErrorSuggestion(err); s != "" { - fmt.Fprintln(os.Stderr, "Suggestion: "+s) + if !strings.HasPrefix(s, "Suggestion: ") { + s = "Suggestion: " + s + } + fmt.Fprintln(os.Stderr, s) } } From 095b537e0b4d97ff0b420de736628c5c2c2def89 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 23 Feb 2026 17:21:24 +0000 Subject: [PATCH 04/10] Address comments --- cli/azd/cmd/extensions.go | 30 +++++++------------ .../cmd/extensions_error_transport_test.go | 22 +++++++------- 2 files changed, 21 insertions(+), 31 deletions(-) diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index 8ab0564de2a..c3beb6a611f 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -237,7 +237,7 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error // The path is passed via AZD_ERROR_FILE; the extension calls azdext.ReportError // (automatically via azdext.Run) which serializes a LocalError/ServiceError as // protojson into this file on failure. - errorFileEnv, cleanupErrorFile, err := createExtensionErrorFileEnv() + errorFileEnv, errorFilePath, cleanupErrorFile, err := createExtensionErrorFileEnv() if err != nil { log.Printf("failed to create extension error file: %v", err) } else { @@ -265,7 +265,7 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error // Read the structured error the extension wrote to the error file. // This gives us a typed LocalError/ServiceError for telemetry classification // instead of just a generic exit-code error. - reportedErr, readErr := readReportedExtensionErrorFromEnv(allEnv) + reportedErr, readErr := readReportedExtensionError(errorFilePath) if readErr != nil { log.Printf("failed to read reported extension error: %v", readErr) } else if reportedErr != nil { @@ -282,19 +282,19 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error } // createExtensionErrorFileEnv creates a temp file for extension error reporting and returns -// the formatted env var (AZD_ERROR_FILE=) to pass to the extension process. -func createExtensionErrorFileEnv() (envVar string, cleanup func(), err error) { +// the formatted env var (AZD_ERROR_FILE=), the file path, and a cleanup function. +func createExtensionErrorFileEnv() (envVar string, errorFilePath string, cleanup func(), err error) { errorFile, err := os.CreateTemp("", "azd-ext-error-*.json") if err != nil { - return "", func() {}, err + return "", "", func() {}, err } - errorFilePath := errorFile.Name() + errorFilePath = errorFile.Name() if closeErr := errorFile.Close(); closeErr != nil { if removeErr := os.Remove(errorFilePath); removeErr != nil && !os.IsNotExist(removeErr) { log.Printf("failed to remove extension error file after close error: %v", removeErr) } - return "", func() {}, closeErr + return "", "", func() {}, closeErr } cleanup = func() { @@ -303,20 +303,12 @@ func createExtensionErrorFileEnv() (envVar string, cleanup func(), err error) { } } - return fmt.Sprintf("%s=%s", azdext.ExtensionErrorFileEnv, errorFilePath), cleanup, nil + return fmt.Sprintf("%s=%s", azdext.ExtensionErrorFileEnv, errorFilePath), errorFilePath, cleanup, nil } -// readReportedExtensionErrorFromEnv extracts the error file path from the env slice -// and reads the structured error written by the extension (if any). -func readReportedExtensionErrorFromEnv(env []string) (error, error) { - errorFilePath := "" - for _, envVar := range env { - if after, ok := strings.CutPrefix(envVar, azdext.ExtensionErrorFileEnv+"="); ok { - errorFilePath = after - break - } - } - +// readReportedExtensionError reads the structured error written by the extension to the +// given file path (if any). +func readReportedExtensionError(errorFilePath string) (error, error) { if errorFilePath == "" { return nil, nil } diff --git a/cli/azd/cmd/extensions_error_transport_test.go b/cli/azd/cmd/extensions_error_transport_test.go index e3843f99eee..3938526e17d 100644 --- a/cli/azd/cmd/extensions_error_transport_test.go +++ b/cli/azd/cmd/extensions_error_transport_test.go @@ -14,9 +14,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestReadReportedExtensionErrorFromEnv(t *testing.T) { - t.Run("NoEnvVar", func(t *testing.T) { - err, readErr := readReportedExtensionErrorFromEnv(nil) +func TestReadReportedExtensionError(t *testing.T) { + t.Run("EmptyPath", func(t *testing.T) { + err, readErr := readReportedExtensionError("") require.NoError(t, readErr) require.Nil(t, err) }) @@ -30,10 +30,7 @@ func TestReadReportedExtensionErrorFromEnv(t *testing.T) { }) require.NoError(t, writeErr) - err, readErr := readReportedExtensionErrorFromEnv([]string{ - "OTHER=1", - azdext.ExtensionErrorFileEnv + "=" + path, - }) + err, readErr := readReportedExtensionError(path) require.NoError(t, readErr) var localErr *azdext.LocalError @@ -46,9 +43,7 @@ func TestReadReportedExtensionErrorFromEnv(t *testing.T) { path := filepath.Join(t.TempDir(), "ext-error-invalid.json") require.NoError(t, osWriteFile(path, []byte("{invalid-json"))) - err, readErr := readReportedExtensionErrorFromEnv([]string{ - azdext.ExtensionErrorFileEnv + "=" + path, - }) + err, readErr := readReportedExtensionError(path) require.Nil(t, err) require.Error(t, readErr) require.True(t, strings.Contains(readErr.Error(), "unmarshal extension error file")) @@ -56,13 +51,16 @@ func TestReadReportedExtensionErrorFromEnv(t *testing.T) { } func TestCreateExtensionErrorFileEnv(t *testing.T) { - envVar, cleanup, err := createExtensionErrorFileEnv() + envVar, errorFilePath, cleanup, err := createExtensionErrorFileEnv() require.NoError(t, err) require.NotEmpty(t, envVar) require.True(t, strings.HasPrefix(envVar, azdext.ExtensionErrorFileEnv+"=")) + require.NotEmpty(t, errorFilePath) + require.FileExists(t, errorFilePath) + // Verify envVar and errorFilePath are consistent path := strings.TrimPrefix(envVar, azdext.ExtensionErrorFileEnv+"=") - require.FileExists(t, path) + require.Equal(t, errorFilePath, path) cleanup() require.NoFileExists(t, path) From 6737c541eeb589224531138176709548e22bfa9c Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 23 Feb 2026 17:41:19 +0000 Subject: [PATCH 05/10] Modern go cleanup --- .../cmd/extensions_error_transport_test.go | 6 ++-- cli/azd/internal/cmd/errors.go | 8 +++-- .../pkg/azdext/extension_error_category.go | 34 ++++++++++--------- cli/azd/pkg/azdext/run.go | 1 - 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/cli/azd/cmd/extensions_error_transport_test.go b/cli/azd/cmd/extensions_error_transport_test.go index 3938526e17d..1547e2d7483 100644 --- a/cli/azd/cmd/extensions_error_transport_test.go +++ b/cli/azd/cmd/extensions_error_transport_test.go @@ -41,7 +41,7 @@ func TestReadReportedExtensionError(t *testing.T) { t.Run("InvalidErrorFileContent", func(t *testing.T) { path := filepath.Join(t.TempDir(), "ext-error-invalid.json") - require.NoError(t, osWriteFile(path, []byte("{invalid-json"))) + require.NoError(t, os.WriteFile(path, []byte("{invalid-json"), 0o600)) err, readErr := readReportedExtensionError(path) require.Nil(t, err) @@ -66,6 +66,4 @@ func TestCreateExtensionErrorFileEnv(t *testing.T) { require.NoFileExists(t, path) } -func osWriteFile(path string, content []byte) error { - return os.WriteFile(path, content, 0o600) -} + diff --git a/cli/azd/internal/cmd/errors.go b/cli/azd/internal/cmd/errors.go index 7c380707450..b87e9eb0745 100644 --- a/cli/azd/internal/cmd/errors.go +++ b/cli/azd/internal/cmd/errors.go @@ -336,7 +336,10 @@ func cmdAsName(cmd string) string { return strings.ToLower(cmd) } -var codeSegmentRegex = regexp.MustCompile(`[^a-z0-9_]+`) +var ( + codeSegmentRegex = regexp.MustCompile(`[^a-z0-9_]+`) + codeSegmentReplacer = strings.NewReplacer("-", "_", ".", "_") +) func normalizeCodeSegment(value string, fallback string) string { value = strings.ToLower(strings.TrimSpace(value)) @@ -344,8 +347,7 @@ func normalizeCodeSegment(value string, fallback string) string { return fallback } - value = strings.ReplaceAll(value, "-", "_") - value = strings.ReplaceAll(value, ".", "_") + value = codeSegmentReplacer.Replace(value) value = codeSegmentRegex.ReplaceAllString(value, "_") value = strings.Trim(value, "_") if value == "" { diff --git a/cli/azd/pkg/azdext/extension_error_category.go b/cli/azd/pkg/azdext/extension_error_category.go index fd4a4628976..48251102e5a 100644 --- a/cli/azd/pkg/azdext/extension_error_category.go +++ b/cli/azd/pkg/azdext/extension_error_category.go @@ -3,7 +3,10 @@ package azdext -import "strings" +import ( + "slices" + "strings" +) // LocalErrorCategory is the canonical category type for extension local errors. // Keep values aligned with telemetry ResultCode families in internal/cmd/errors.go. @@ -19,25 +22,24 @@ const ( LocalErrorCategoryLocal LocalErrorCategory = "local" ) +// knownCategories lists all recognized local error categories (excluding the fallback LocalErrorCategoryLocal). +var knownCategories = []LocalErrorCategory{ + LocalErrorCategoryValidation, + LocalErrorCategoryAuth, + LocalErrorCategoryDependency, + LocalErrorCategoryCompatibility, + LocalErrorCategoryUser, + LocalErrorCategoryInternal, +} + // NormalizeLocalErrorCategory validates a typed category value, returning the canonical constant. // Unknown values are collapsed to LocalErrorCategoryLocal. func NormalizeLocalErrorCategory(category LocalErrorCategory) LocalErrorCategory { - switch LocalErrorCategory(strings.ToLower(strings.TrimSpace(string(category)))) { - case LocalErrorCategoryValidation: - return LocalErrorCategoryValidation - case LocalErrorCategoryAuth: - return LocalErrorCategoryAuth - case LocalErrorCategoryDependency: - return LocalErrorCategoryDependency - case LocalErrorCategoryCompatibility: - return LocalErrorCategoryCompatibility - case LocalErrorCategoryUser: - return LocalErrorCategoryUser - case LocalErrorCategoryInternal: - return LocalErrorCategoryInternal - default: - return LocalErrorCategoryLocal + normalized := LocalErrorCategory(strings.ToLower(strings.TrimSpace(string(category)))) + if slices.Contains(knownCategories, normalized) { + return normalized } + return LocalErrorCategoryLocal } // ParseLocalErrorCategory parses a raw category string (e.g. from proto deserialization) diff --git a/cli/azd/pkg/azdext/run.go b/cli/azd/pkg/azdext/run.go index c0fd6026ead..f90a16ad532 100644 --- a/cli/azd/pkg/azdext/run.go +++ b/cli/azd/pkg/azdext/run.go @@ -82,7 +82,6 @@ func Run(rootCmd *cobra.Command, opts ...RunOption) { func printError(err error) { redStderr := color.New(color.FgRed) redStderr.EnableColor() - redStderr.SetWriter(os.Stderr) redStderr.Fprintf(os.Stderr, "Error: %v\n", err) if s := ErrorSuggestion(err); s != "" { From 242f333553538f33ea60887d091cecd07f02074f Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 23 Feb 2026 20:51:55 +0000 Subject: [PATCH 06/10] Enhance error handling and UX for extension errors; improve error message extraction --- cli/azd/cmd/extensions.go | 2 +- .../cmd/extensions_error_transport_test.go | 2 - cli/azd/cmd/middleware/ux.go | 41 +++++++++++++------ cli/azd/pkg/azdext/run.go | 31 +++++++++++++- 4 files changed, 59 insertions(+), 17 deletions(-) diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index c3beb6a611f..87d0ca0a5da 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -271,7 +271,7 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error } else if reportedErr != nil { // Wrap both errors so the chain contains both: // - reportedErr (LocalError/ServiceError) for telemetry classification - // - invokeErr (ExtensionRunError) for UxMiddleware suppression + // - invokeErr (ExtensionRunError) for UX middleware handling return nil, fmt.Errorf("%w: %w", reportedErr, invokeErr) } diff --git a/cli/azd/cmd/extensions_error_transport_test.go b/cli/azd/cmd/extensions_error_transport_test.go index 1547e2d7483..ff9bcb88f64 100644 --- a/cli/azd/cmd/extensions_error_transport_test.go +++ b/cli/azd/cmd/extensions_error_transport_test.go @@ -65,5 +65,3 @@ func TestCreateExtensionErrorFileEnv(t *testing.T) { cleanup() require.NoFileExists(t, path) } - - diff --git a/cli/azd/cmd/middleware/ux.go b/cli/azd/cmd/middleware/ux.go index 08319b59d1b..2c6ce73b980 100644 --- a/cli/azd/cmd/middleware/ux.go +++ b/cli/azd/cmd/middleware/ux.go @@ -50,12 +50,10 @@ 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, @@ -67,6 +65,32 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes 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.MessageUxItem(ctx, displayErr) + return actionResult, err + } + + // TODO: verify + // ExtensionRunError without suggestion: render the error message. + // New extensions stay silent (AZD_ERROR_FILE was set), so the host must render. + 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())) @@ -75,13 +99,6 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes errorMessage.WriteString(output.WithErrorFormat("\nTraceID: %s", errorWithTraceId.TraceId)) } - if suggestion := azdext.ErrorSuggestion(err); suggestion != "" { - if !strings.HasPrefix(suggestion, "Suggestion: ") { - suggestion = "Suggestion: " + suggestion - } - errorMessage.WriteString("\n" + suggestion) - } - errMessage := errorMessage.String() if errors.As(err, &unsupportedErr) { diff --git a/cli/azd/pkg/azdext/run.go b/cli/azd/pkg/azdext/run.go index f90a16ad532..778142d7bdd 100644 --- a/cli/azd/pkg/azdext/run.go +++ b/cli/azd/pkg/azdext/run.go @@ -64,7 +64,13 @@ func Run(rootCmd *cobra.Command, opts ...RunOption) { if reportErr := ReportError(err); reportErr != nil { log.Printf("warning: failed to report structured error: %v", reportErr) } - printError(err) + + // When AZD_ERROR_FILE is set, the host owns all error rendering. + // When not set (older azd), print error + suggestion here. + if os.Getenv(ExtensionErrorFileEnv) == "" { + printError(err) + } + os.Exit(1) } } @@ -74,7 +80,12 @@ func Run(rootCmd *cobra.Command, opts ...RunOption) { log.Printf("warning: failed to report structured error: %v", reportErr) } - printError(err) + // When AZD_ERROR_FILE is set, the host owns all error rendering. + // When not set (older azd), print error + suggestion here. + if os.Getenv(ExtensionErrorFileEnv) == "" { + printError(err) + } + os.Exit(1) } } @@ -107,3 +118,19 @@ func ErrorSuggestion(err error) string { return "" } + +// ErrorMessage extracts the user-friendly Message field from a [LocalError] or [ServiceError]. +// Returns an empty string if the error is not an extension error type. +func ErrorMessage(err error) string { + var localErr *LocalError + if errors.As(err, &localErr) && localErr.Message != "" { + return localErr.Message + } + + var svcErr *ServiceError + if errors.As(err, &svcErr) && svcErr.Message != "" { + return svcErr.Message + } + + return "" +} From e229672cb6f936aef40807dc812e0c621cdd26d4 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Mon, 23 Feb 2026 20:53:56 +0000 Subject: [PATCH 07/10] Update comment --- cli/azd/cmd/middleware/ux.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cli/azd/cmd/middleware/ux.go b/cli/azd/cmd/middleware/ux.go index 2c6ce73b980..a470b719a43 100644 --- a/cli/azd/cmd/middleware/ux.go +++ b/cli/azd/cmd/middleware/ux.go @@ -80,9 +80,7 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes return actionResult, err } - // TODO: verify - // ExtensionRunError without suggestion: render the error message. - // New extensions stay silent (AZD_ERROR_FILE was set), so the host must render. + // ExtensionRunError without suggestion var extensionRunErr *extensions.ExtensionRunError if errors.As(err, &extensionRunErr) { if message := azdext.ErrorMessage(err); message != "" { From a328e975814f79e31022fbb67fc5b8c84315ba7f Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 24 Feb 2026 17:56:00 +0000 Subject: [PATCH 08/10] Add back newline and include `WithAccessToken` in `Run` --- cli/azd/cmd/extensions.go | 1 + cli/azd/cmd/middleware/ux.go | 2 ++ cli/azd/pkg/azdext/run.go | 2 ++ 3 files changed, 5 insertions(+) diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index 87d0ca0a5da..cd08e80ddff 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -241,6 +241,7 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error if err != nil { log.Printf("failed to create extension error file: %v", err) } else { + log.Printf("%s=%s", azdext.ExtensionErrorFileEnv, errorFilePath) defer cleanupErrorFile() allEnv = append(allEnv, errorFileEnv) } diff --git a/cli/azd/cmd/middleware/ux.go b/cli/azd/cmd/middleware/ux.go index a470b719a43..793ad969fe3 100644 --- a/cli/azd/cmd/middleware/ux.go +++ b/cli/azd/cmd/middleware/ux.go @@ -61,6 +61,7 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes Suggestion: suggestionErr.Suggestion, Links: suggestionErr.Links, } + m.console.Message(ctx, "") m.console.MessageUxItem(ctx, displayErr) return actionResult, err } @@ -76,6 +77,7 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes Message: message, Suggestion: suggestion, } + m.console.Message(ctx, "") m.console.MessageUxItem(ctx, displayErr) return actionResult, err } diff --git a/cli/azd/pkg/azdext/run.go b/cli/azd/pkg/azdext/run.go index 778142d7bdd..2d397a65de8 100644 --- a/cli/azd/pkg/azdext/run.go +++ b/cli/azd/pkg/azdext/run.go @@ -35,6 +35,7 @@ func WithPreExecute(fn func(ctx context.Context, cmd *cobra.Command) error) RunO // - FORCE_COLOR environment variable → color.NoColor // - cobra SilenceErrors (Run controls error output) // - Context creation with tracing propagation +// - gRPC access token injection via [WithAccessToken] // - Command execution // - Structured error reporting via AZD_ERROR_FILE // - Error + suggestion display @@ -53,6 +54,7 @@ func Run(rootCmd *cobra.Command, opts ...RunOption) { rootCmd.SilenceErrors = true ctx := NewContext() + ctx = WithAccessToken(ctx) var cfg runConfig for _, o := range opts { From 279590601c8cdf523f7c60f72630487495bbb4f7 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 24 Feb 2026 23:39:54 +0000 Subject: [PATCH 09/10] Report errors via standard gRPC --- cli/azd/cmd/extensions.go | 56 +-------- .../cmd/extensions_error_transport_test.go | 88 ++++++------- .../docs/extensions/extension-framework.md | 8 +- cli/azd/grpc/proto/extension.proto | 46 ++++--- .../internal/grpcserver/extension_service.go | 24 ++++ cli/azd/pkg/azdext/azd_client.go | 2 +- cli/azd/pkg/azdext/extension.pb.go | 119 +++++++++++++++--- cli/azd/pkg/azdext/extension_error_report.go | 68 +++------- .../pkg/azdext/extension_error_report_test.go | 29 ++--- cli/azd/pkg/azdext/extension_grpc.pb.go | 44 ++++++- cli/azd/pkg/azdext/extension_host.go | 2 +- cli/azd/pkg/azdext/extension_host_test.go | 9 ++ cli/azd/pkg/azdext/run.go | 16 +-- cli/azd/pkg/extensions/extension.go | 17 +++ 14 files changed, 307 insertions(+), 221 deletions(-) diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index cd08e80ddff..9184cca1368 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -14,7 +14,6 @@ import ( "github.com/azure/azure-dev/cli/azd/internal/grpcserver" "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/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/extensions" @@ -233,19 +232,6 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error fmt.Sprintf("AZD_ACCESS_TOKEN=%s", jwtToken), ) - // Create a temp file for the extension to write structured error info into. - // The path is passed via AZD_ERROR_FILE; the extension calls azdext.ReportError - // (automatically via azdext.Run) which serializes a LocalError/ServiceError as - // protojson into this file on failure. - errorFileEnv, errorFilePath, cleanupErrorFile, err := createExtensionErrorFileEnv() - if err != nil { - log.Printf("failed to create extension error file: %v", err) - } else { - log.Printf("%s=%s", azdext.ExtensionErrorFileEnv, errorFilePath) - defer cleanupErrorFile() - allEnv = append(allEnv, errorFileEnv) - } - // Propagate trace context to the extension process if traceEnv := tracing.Environ(ctx); len(traceEnv) > 0 { allEnv = append(allEnv, traceEnv...) @@ -263,13 +249,10 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error // Update warning is shown via defer above (runs after invoke completes) if invokeErr != nil { - // Read the structured error the extension wrote to the error file. + // 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. - reportedErr, readErr := readReportedExtensionError(errorFilePath) - if readErr != nil { - log.Printf("failed to read reported extension error: %v", readErr) - } else if reportedErr != nil { + 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 @@ -282,41 +265,6 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error return nil, nil } -// createExtensionErrorFileEnv creates a temp file for extension error reporting and returns -// the formatted env var (AZD_ERROR_FILE=), the file path, and a cleanup function. -func createExtensionErrorFileEnv() (envVar string, errorFilePath string, cleanup func(), err error) { - errorFile, err := os.CreateTemp("", "azd-ext-error-*.json") - if err != nil { - return "", "", func() {}, err - } - - errorFilePath = errorFile.Name() - if closeErr := errorFile.Close(); closeErr != nil { - if removeErr := os.Remove(errorFilePath); removeErr != nil && !os.IsNotExist(removeErr) { - log.Printf("failed to remove extension error file after close error: %v", removeErr) - } - return "", "", func() {}, closeErr - } - - cleanup = func() { - if removeErr := os.Remove(errorFilePath); removeErr != nil && !os.IsNotExist(removeErr) { - log.Printf("failed to remove extension error file: %v", removeErr) - } - } - - return fmt.Sprintf("%s=%s", azdext.ExtensionErrorFileEnv, errorFilePath), errorFilePath, cleanup, nil -} - -// readReportedExtensionError reads the structured error written by the extension to the -// given file path (if any). -func readReportedExtensionError(errorFilePath string) (error, error) { - if errorFilePath == "" { - return nil, nil - } - - return azdext.ReadErrorFile(errorFilePath) -} - // updateCheckOutcome holds the result of an async update check type updateCheckOutcome struct { shouldShow bool diff --git a/cli/azd/cmd/extensions_error_transport_test.go b/cli/azd/cmd/extensions_error_transport_test.go index ff9bcb88f64..71be22c80da 100644 --- a/cli/azd/cmd/extensions_error_transport_test.go +++ b/cli/azd/cmd/extensions_error_transport_test.go @@ -5,63 +5,55 @@ package cmd import ( "errors" - "os" - "path/filepath" - "strings" "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 TestReadReportedExtensionError(t *testing.T) { - t.Run("EmptyPath", func(t *testing.T) { - err, readErr := readReportedExtensionError("") - require.NoError(t, readErr) - require.Nil(t, err) +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("ValidErrorFile", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "ext-error.json") - writeErr := azdext.WriteErrorFile(path, &azdext.LocalError{ - Message: "invalid config", - Code: "invalid_config", - Category: azdext.LocalErrorCategoryValidation, - }) - require.NoError(t, writeErr) - - err, readErr := readReportedExtensionError(path) - require.NoError(t, readErr) - - var localErr *azdext.LocalError - require.True(t, errors.As(err, &localErr)) - require.Equal(t, "invalid_config", localErr.Code) - require.Equal(t, azdext.LocalErrorCategoryValidation, localErr.Category) + 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("InvalidErrorFileContent", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "ext-error-invalid.json") - require.NoError(t, os.WriteFile(path, []byte("{invalid-json"), 0o600)) - - err, readErr := readReportedExtensionError(path) - require.Nil(t, err) - require.Error(t, readErr) - require.True(t, strings.Contains(readErr.Error(), "unmarshal extension error file")) + 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) }) } - -func TestCreateExtensionErrorFileEnv(t *testing.T) { - envVar, errorFilePath, cleanup, err := createExtensionErrorFileEnv() - require.NoError(t, err) - require.NotEmpty(t, envVar) - require.True(t, strings.HasPrefix(envVar, azdext.ExtensionErrorFileEnv+"=")) - require.NotEmpty(t, errorFilePath) - require.FileExists(t, errorFilePath) - - // Verify envVar and errorFilePath are consistent - path := strings.TrimPrefix(envVar, azdext.ExtensionErrorFileEnv+"=") - require.Equal(t, errorFilePath, path) - - cleanup() - require.NoFileExists(t, path) -} diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index b49e0ddd0a0..9b8369e7eb9 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -1115,7 +1115,6 @@ When `azd` invokes an extension command, the following steps occur: 2. `azd` invokes your command, passing all arguments and flags: - An environment variable named `AZD_SERVER` is set with the server address and random port (e.g., `localhost:12345`). - An `azd` access token environment variable `AZD_ACCESS_TOKEN` is set which is a JWT token signed with a randomly generated key good for the lifetime of the command. The token includes claims that identify each unique extensions and its supported capabilities. - - An environment variable named `AZD_ERROR_FILE` is set with a temporary file path that extensions can use to report a structured error on command failure. - Additional environment variables from the current `azd` environment are also set. 3. The extension command can communicate with `azd` through [extension framework gRPC services](#grpc-services). 4. `azd` waits for the extension command to complete: @@ -1127,8 +1126,8 @@ The gRPC client must also include an `authorization` parameter with the value fr ### Structured Error Reporting (Custom Commands) -For custom command execution, extensions can provide richer telemetry by reporting a structured error payload to -`AZD_ERROR_FILE` before exiting with a non-zero status code. +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: @@ -1144,10 +1143,11 @@ 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(err) + _ = azdext.ReportError(ctx, err) os.Exit(1) } } diff --git a/cli/azd/grpc/proto/extension.proto b/cli/azd/grpc/proto/extension.proto index 3542e661c18..c0265091170 100644 --- a/cli/azd/grpc/proto/extension.proto +++ b/cli/azd/grpc/proto/extension.proto @@ -1,17 +1,29 @@ -// 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"; - -// Top-level service for extension registration and lifecycle -service ExtensionService { - // Signal that the extension is done registering all capabilities - rpc Ready(ReadyRequest) returns (ReadyResponse); -} - -message ReadyRequest {} - -message ReadyResponse {} +// 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"; + +import "errors.proto"; + +// Top-level service for extension registration and lifecycle +service ExtensionService { + // Signal that the extension is done registering all capabilities + rpc Ready(ReadyRequest) returns (ReadyResponse); + + // Report a structured error from the extension back to the host. + // Called by the extension SDK before the process exits on failure. + rpc ReportError(ReportErrorRequest) returns (ReportErrorResponse); +} + +message ReadyRequest {} + +message ReadyResponse {} + +message ReportErrorRequest { + ExtensionError error = 1; +} + +message ReportErrorResponse {} diff --git a/cli/azd/internal/grpcserver/extension_service.go b/cli/azd/internal/grpcserver/extension_service.go index 77b503ebabc..1d4f2c54dc9 100644 --- a/cli/azd/internal/grpcserver/extension_service.go +++ b/cli/azd/internal/grpcserver/extension_service.go @@ -50,3 +50,27 @@ func (s *ExtensionService) Ready(ctx context.Context, req *azdext.ReadyRequest) return &azdext.ReadyResponse{}, nil } + +// ReportError receives a structured error from the extension and stores it +// so the host can retrieve it after the extension process exits. +func (s *ExtensionService) ReportError( + ctx context.Context, req *azdext.ReportErrorRequest, +) (*azdext.ReportErrorResponse, error) { + extensionClaims, err := extensions.GetClaimsFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get extension claims: %w", err) + } + + extension, err := s.extensionManager.GetInstalled(extensions.FilterOptions{ + Id: extensionClaims.Subject, + }) + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "failed to get extension: %s", err.Error()) + } + + if req.GetError() != nil { + extension.SetReportedError(azdext.UnwrapError(req.GetError())) + } + + return &azdext.ReportErrorResponse{}, nil +} diff --git a/cli/azd/pkg/azdext/azd_client.go b/cli/azd/pkg/azdext/azd_client.go index d42a2608536..22557bd687f 100644 --- a/cli/azd/pkg/azdext/azd_client.go +++ b/cli/azd/pkg/azdext/azd_client.go @@ -174,7 +174,7 @@ func (c *AzdClient) Container() ContainerServiceClient { } // Extension returns the extension service client. -func (c *AzdClient) extensionService() ExtensionServiceClient { +func (c *AzdClient) Extension() ExtensionServiceClient { if c.extensionClient == nil { c.extensionClient = NewExtensionServiceClient(c.connection) } diff --git a/cli/azd/pkg/azdext/extension.pb.go b/cli/azd/pkg/azdext/extension.pb.go index 69494a2cff9..9ba2c933f0d 100644 --- a/cli/azd/pkg/azdext/extension.pb.go +++ b/cli/azd/pkg/azdext/extension.pb.go @@ -96,15 +96,99 @@ func (*ReadyResponse) Descriptor() ([]byte, []int) { return file_extension_proto_rawDescGZIP(), []int{1} } +type ReportErrorRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error *ExtensionError `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportErrorRequest) Reset() { + *x = ReportErrorRequest{} + mi := &file_extension_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportErrorRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportErrorRequest) ProtoMessage() {} + +func (x *ReportErrorRequest) ProtoReflect() protoreflect.Message { + mi := &file_extension_proto_msgTypes[2] + 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 ReportErrorRequest.ProtoReflect.Descriptor instead. +func (*ReportErrorRequest) Descriptor() ([]byte, []int) { + return file_extension_proto_rawDescGZIP(), []int{2} +} + +func (x *ReportErrorRequest) GetError() *ExtensionError { + if x != nil { + return x.Error + } + return nil +} + +type ReportErrorResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportErrorResponse) Reset() { + *x = ReportErrorResponse{} + mi := &file_extension_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportErrorResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportErrorResponse) ProtoMessage() {} + +func (x *ReportErrorResponse) ProtoReflect() protoreflect.Message { + mi := &file_extension_proto_msgTypes[3] + 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 ReportErrorResponse.ProtoReflect.Descriptor instead. +func (*ReportErrorResponse) Descriptor() ([]byte, []int) { + return file_extension_proto_rawDescGZIP(), []int{3} +} + var File_extension_proto protoreflect.FileDescriptor const file_extension_proto_rawDesc = "" + "\n" + - "\x0fextension.proto\x12\x06azdext\"\x0e\n" + + "\x0fextension.proto\x12\x06azdext\x1a\ferrors.proto\"\x0e\n" + "\fReadyRequest\"\x0f\n" + - "\rReadyResponse2H\n" + + "\rReadyResponse\"B\n" + + "\x12ReportErrorRequest\x12,\n" + + "\x05error\x18\x01 \x01(\v2\x16.azdext.ExtensionErrorR\x05error\"\x15\n" + + "\x13ReportErrorResponse2\x90\x01\n" + "\x10ExtensionService\x124\n" + - "\x05Ready\x12\x14.azdext.ReadyRequest\x1a\x15.azdext.ReadyResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" + "\x05Ready\x12\x14.azdext.ReadyRequest\x1a\x15.azdext.ReadyResponse\x12F\n" + + "\vReportError\x12\x1a.azdext.ReportErrorRequest\x1a\x1b.azdext.ReportErrorResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" var ( file_extension_proto_rawDescOnce sync.Once @@ -118,19 +202,25 @@ func file_extension_proto_rawDescGZIP() []byte { return file_extension_proto_rawDescData } -var file_extension_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_extension_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_extension_proto_goTypes = []any{ - (*ReadyRequest)(nil), // 0: azdext.ReadyRequest - (*ReadyResponse)(nil), // 1: azdext.ReadyResponse + (*ReadyRequest)(nil), // 0: azdext.ReadyRequest + (*ReadyResponse)(nil), // 1: azdext.ReadyResponse + (*ReportErrorRequest)(nil), // 2: azdext.ReportErrorRequest + (*ReportErrorResponse)(nil), // 3: azdext.ReportErrorResponse + (*ExtensionError)(nil), // 4: azdext.ExtensionError } var file_extension_proto_depIdxs = []int32{ - 0, // 0: azdext.ExtensionService.Ready:input_type -> azdext.ReadyRequest - 1, // 1: azdext.ExtensionService.Ready:output_type -> azdext.ReadyResponse - 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 + 4, // 0: azdext.ReportErrorRequest.error:type_name -> azdext.ExtensionError + 0, // 1: azdext.ExtensionService.Ready:input_type -> azdext.ReadyRequest + 2, // 2: azdext.ExtensionService.ReportError:input_type -> azdext.ReportErrorRequest + 1, // 3: azdext.ExtensionService.Ready:output_type -> azdext.ReadyResponse + 3, // 4: azdext.ExtensionService.ReportError:output_type -> azdext.ReportErrorResponse + 3, // [3:5] is the sub-list for method output_type + 1, // [1:3] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_extension_proto_init() } @@ -138,13 +228,14 @@ func file_extension_proto_init() { if File_extension_proto != nil { return } + file_errors_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_extension_proto_rawDesc), len(file_extension_proto_rawDesc)), NumEnums: 0, - NumMessages: 2, + NumMessages: 4, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/azd/pkg/azdext/extension_error_report.go b/cli/azd/pkg/azdext/extension_error_report.go index e3cb267f7b0..1573fa06ae1 100644 --- a/cli/azd/pkg/azdext/extension_error_report.go +++ b/cli/azd/pkg/azdext/extension_error_report.go @@ -4,30 +4,22 @@ package azdext import ( - "errors" + "context" "fmt" "os" - - "google.golang.org/protobuf/encoding/protojson" ) -const ExtensionErrorFileEnv = "AZD_ERROR_FILE" - -// ReportError writes a structured extension error to the path provided in AZD_ERROR_FILE. -// It is a no-op when the environment variable is not set. -func ReportError(err error) error { - path := os.Getenv(ExtensionErrorFileEnv) - if path == "" { +// ReportError sends a structured extension error to the azd host via gRPC. +// It creates a temporary gRPC client using the AZD_SERVER environment variable. +// Returns nil if AZD_SERVER is not set (extension running outside azd). +func ReportError(ctx context.Context, err error) error { + if err == nil { return nil } - return WriteErrorFile(path, err) -} - -// WriteErrorFile writes a structured extension error to a file path. -func WriteErrorFile(path string, err error) error { - if err == nil || path == "" { - return nil + server := os.Getenv("AZD_SERVER") + if server == "" { + return fmt.Errorf("AZD_SERVER not set") } extErr := WrapError(err) @@ -35,44 +27,16 @@ func WriteErrorFile(path string, err error) error { return nil } - content, marshalErr := protojson.Marshal(extErr) - if marshalErr != nil { - return fmt.Errorf("marshal extension error: %w", marshalErr) + client, clientErr := NewAzdClient(WithAddress(server)) + if clientErr != nil { + return fmt.Errorf("create gRPC client for error report: %w", clientErr) } + defer client.Close() - if writeErr := os.WriteFile(path, content, 0o600); writeErr != nil { - return fmt.Errorf("write extension error file: %w", writeErr) + req := &ReportErrorRequest{Error: extErr} + if _, rpcErr := client.Extension().ReportError(ctx, req); rpcErr != nil { + return fmt.Errorf("report error via gRPC: %w", rpcErr) } return nil } - -// ReadErrorFile reads and parses a structured extension error from file. -// Returns (nil, nil) when the file does not exist or is empty. -func ReadErrorFile(path string) (error, error) { - if path == "" { - return nil, nil - } - - content, readErr := os.ReadFile(path) - if errors.Is(readErr, os.ErrNotExist) { - return nil, nil - } - if readErr != nil { - return nil, fmt.Errorf("read extension error file: %w", readErr) - } - - if len(content) == 0 { - return nil, nil - } - - msg := &ExtensionError{} - unmarshalOpts := protojson.UnmarshalOptions{ - DiscardUnknown: true, - } - if unmarshalErr := unmarshalOpts.Unmarshal(content, msg); unmarshalErr != nil { - return nil, fmt.Errorf("unmarshal extension error file: %w", unmarshalErr) - } - - return UnwrapError(msg), nil -} diff --git a/cli/azd/pkg/azdext/extension_error_report_test.go b/cli/azd/pkg/azdext/extension_error_report_test.go index 68ec4a36e59..1b31956f2bf 100644 --- a/cli/azd/pkg/azdext/extension_error_report_test.go +++ b/cli/azd/pkg/azdext/extension_error_report_test.go @@ -4,38 +4,35 @@ package azdext import ( - "path/filepath" "testing" "github.com/stretchr/testify/require" ) -func TestWriteReadErrorFile(t *testing.T) { - t.Run("RoundTripLocalError", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "ext-error.json") - - writeErr := WriteErrorFile(path, &LocalError{ +func TestWrapUnwrapErrorRoundTrip(t *testing.T) { + t.Run("LocalError", func(t *testing.T) { + original := &LocalError{ Message: "invalid config", Code: "invalid_config", Category: LocalErrorCategoryValidation, Suggestion: "Add missing field in config", - }) - require.NoError(t, writeErr) + } + + proto := WrapError(original) + require.NotNil(t, proto) - err, readErr := ReadErrorFile(path) - require.NoError(t, readErr) - require.NotNil(t, err) + unwrapped := UnwrapError(proto) + require.NotNil(t, unwrapped) var localErr *LocalError - require.ErrorAs(t, err, &localErr) + require.ErrorAs(t, unwrapped, &localErr) require.Equal(t, "invalid_config", localErr.Code) require.Equal(t, LocalErrorCategoryValidation, localErr.Category) require.Equal(t, "Add missing field in config", localErr.Suggestion) }) - t.Run("MissingFile", func(t *testing.T) { - err, readErr := ReadErrorFile(filepath.Join(t.TempDir(), "missing.json")) - require.NoError(t, readErr) - require.Nil(t, err) + t.Run("NilError", func(t *testing.T) { + proto := WrapError(nil) + require.Nil(t, proto) }) } diff --git a/cli/azd/pkg/azdext/extension_grpc.pb.go b/cli/azd/pkg/azdext/extension_grpc.pb.go index f238e4448e1..8c743f5db13 100644 --- a/cli/azd/pkg/azdext/extension_grpc.pb.go +++ b/cli/azd/pkg/azdext/extension_grpc.pb.go @@ -22,7 +22,8 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - ExtensionService_Ready_FullMethodName = "/azdext.ExtensionService/Ready" + ExtensionService_Ready_FullMethodName = "/azdext.ExtensionService/Ready" + ExtensionService_ReportError_FullMethodName = "/azdext.ExtensionService/ReportError" ) // ExtensionServiceClient is the client API for ExtensionService service. @@ -33,6 +34,9 @@ const ( type ExtensionServiceClient interface { // Signal that the extension is done registering all capabilities Ready(ctx context.Context, in *ReadyRequest, opts ...grpc.CallOption) (*ReadyResponse, error) + // Report a structured error from the extension back to the host. + // Called by the extension SDK before the process exits on failure. + ReportError(ctx context.Context, in *ReportErrorRequest, opts ...grpc.CallOption) (*ReportErrorResponse, error) } type extensionServiceClient struct { @@ -53,6 +57,16 @@ func (c *extensionServiceClient) Ready(ctx context.Context, in *ReadyRequest, op return out, nil } +func (c *extensionServiceClient) ReportError(ctx context.Context, in *ReportErrorRequest, opts ...grpc.CallOption) (*ReportErrorResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReportErrorResponse) + err := c.cc.Invoke(ctx, ExtensionService_ReportError_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ExtensionServiceServer is the server API for ExtensionService service. // All implementations must embed UnimplementedExtensionServiceServer // for forward compatibility. @@ -61,6 +75,9 @@ func (c *extensionServiceClient) Ready(ctx context.Context, in *ReadyRequest, op type ExtensionServiceServer interface { // Signal that the extension is done registering all capabilities Ready(context.Context, *ReadyRequest) (*ReadyResponse, error) + // Report a structured error from the extension back to the host. + // Called by the extension SDK before the process exits on failure. + ReportError(context.Context, *ReportErrorRequest) (*ReportErrorResponse, error) mustEmbedUnimplementedExtensionServiceServer() } @@ -74,6 +91,9 @@ type UnimplementedExtensionServiceServer struct{} func (UnimplementedExtensionServiceServer) Ready(context.Context, *ReadyRequest) (*ReadyResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Ready not implemented") } +func (UnimplementedExtensionServiceServer) ReportError(context.Context, *ReportErrorRequest) (*ReportErrorResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ReportError not implemented") +} func (UnimplementedExtensionServiceServer) mustEmbedUnimplementedExtensionServiceServer() {} func (UnimplementedExtensionServiceServer) testEmbeddedByValue() {} @@ -113,6 +133,24 @@ func _ExtensionService_Ready_Handler(srv interface{}, ctx context.Context, dec f return interceptor(ctx, in, info, handler) } +func _ExtensionService_ReportError_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReportErrorRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExtensionServiceServer).ReportError(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ExtensionService_ReportError_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExtensionServiceServer).ReportError(ctx, req.(*ReportErrorRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ExtensionService_ServiceDesc is the grpc.ServiceDesc for ExtensionService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -124,6 +162,10 @@ var ExtensionService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Ready", Handler: _ExtensionService_Ready_Handler, }, + { + MethodName: "ReportError", + Handler: _ExtensionService_ReportError_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "extension.proto", diff --git a/cli/azd/pkg/azdext/extension_host.go b/cli/azd/pkg/azdext/extension_host.go index 5131090628d..21db530e592 100644 --- a/cli/azd/pkg/azdext/extension_host.go +++ b/cli/azd/pkg/azdext/extension_host.go @@ -330,7 +330,7 @@ func (er *ExtensionHost) Run(ctx context.Context) error { } func callReady(ctx context.Context, client *AzdClient) error { - _, err := client.extensionService().Ready(ctx, &ReadyRequest{}) + _, err := client.Extension().Ready(ctx, &ReadyRequest{}) if err == nil { return nil } diff --git a/cli/azd/pkg/azdext/extension_host_test.go b/cli/azd/pkg/azdext/extension_host_test.go index 418e9683533..19fa6ba6d77 100644 --- a/cli/azd/pkg/azdext/extension_host_test.go +++ b/cli/azd/pkg/azdext/extension_host_test.go @@ -32,6 +32,15 @@ func (m *MockExtensionServiceClient) Ready( return args.Get(0).(*ReadyResponse), args.Error(1) } +func (m *MockExtensionServiceClient) ReportError( + ctx context.Context, + in *ReportErrorRequest, + opts ...grpc.CallOption, +) (*ReportErrorResponse, error) { + args := m.Called(ctx, in, opts) + return args.Get(0).(*ReportErrorResponse), args.Error(1) +} + // MockServiceTargetRegistrar implements serviceTargetRegistrar using testify/mock type MockServiceTargetRegistrar struct { mock.Mock diff --git a/cli/azd/pkg/azdext/run.go b/cli/azd/pkg/azdext/run.go index 2d397a65de8..7938ab8fea2 100644 --- a/cli/azd/pkg/azdext/run.go +++ b/cli/azd/pkg/azdext/run.go @@ -37,7 +37,7 @@ func WithPreExecute(fn func(ctx context.Context, cmd *cobra.Command) error) RunO // - Context creation with tracing propagation // - gRPC access token injection via [WithAccessToken] // - Command execution -// - Structured error reporting via AZD_ERROR_FILE +// - Structured error reporting via gRPC ReportError // - Error + suggestion display // - os.Exit on failure // @@ -63,13 +63,8 @@ func Run(rootCmd *cobra.Command, opts ...RunOption) { if cfg.preExecute != nil { if err := cfg.preExecute(ctx, rootCmd); err != nil { - if reportErr := ReportError(err); reportErr != nil { + if reportErr := ReportError(ctx, err); reportErr != nil { log.Printf("warning: failed to report structured error: %v", reportErr) - } - - // When AZD_ERROR_FILE is set, the host owns all error rendering. - // When not set (older azd), print error + suggestion here. - if os.Getenv(ExtensionErrorFileEnv) == "" { printError(err) } @@ -78,13 +73,8 @@ func Run(rootCmd *cobra.Command, opts ...RunOption) { } if err := rootCmd.ExecuteContext(ctx); err != nil { - if reportErr := ReportError(err); reportErr != nil { + if reportErr := ReportError(ctx, err); reportErr != nil { log.Printf("warning: failed to report structured error: %v", reportErr) - } - - // When AZD_ERROR_FILE is set, the host owns all error rendering. - // When not set (older azd), print error + suggestion here. - if os.Getenv(ExtensionErrorFileEnv) == "" { printError(err) } diff --git a/cli/azd/pkg/extensions/extension.go b/cli/azd/pkg/extensions/extension.go index 43d1d2370a0..a83c4e0a4b9 100644 --- a/cli/azd/pkg/extensions/extension.go +++ b/cli/azd/pkg/extensions/extension.go @@ -34,6 +34,9 @@ type Extension struct { readySignal chan error // consolidated channel, buffered with capacity 1 readyOnce sync.Once // ensures signal is sent only once initialized bool + + reportedError error // structured error reported by the extension via gRPC + errorMu sync.Mutex // guards reportedError } // init initializes the extension's buffers and signals. @@ -114,3 +117,17 @@ func (e *Extension) StdErr() *output.DynamicMultiWriter { e.ensureInit() return e.stderr } + +// SetReportedError stores a structured error reported by the extension via gRPC. +func (e *Extension) SetReportedError(err error) { + e.errorMu.Lock() + defer e.errorMu.Unlock() + e.reportedError = err +} + +// GetReportedError returns the structured error reported by the extension, if any. +func (e *Extension) GetReportedError() error { + e.errorMu.Lock() + defer e.errorMu.Unlock() + return e.reportedError +} From c35b320ed31ea26f06d319d78a1dae537f50f117 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Wed, 25 Feb 2026 00:03:51 +0000 Subject: [PATCH 10/10] Reuse existing error fields and update `ErrorKey` to conditionally prepend `error.` --- cli/azd/internal/cmd/errors.go | 4 +-- cli/azd/internal/cmd/errors_test.go | 12 ++++----- cli/azd/internal/tracing/fields/fields.go | 24 +++++------------ cli/azd/internal/tracing/fields/key.go | 6 ++++- cli/azd/internal/tracing/fields/key_test.go | 30 +++++++++++++++++++++ 5 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 cli/azd/internal/tracing/fields/key_test.go diff --git a/cli/azd/internal/cmd/errors.go b/cli/azd/internal/cmd/errors.go index b87e9eb0745..fa71179a988 100644 --- a/cli/azd/internal/cmd/errors.go +++ b/cli/azd/internal/cmd/errors.go @@ -135,8 +135,8 @@ func MapError(err error, span tracing.Span) { code := normalizeCodeSegment(extLocalErr.Code, "failed") errDetails = append(errDetails, - fields.ExtErrorCategory.String(domain), - fields.ExtErrorCode.String(code), + fields.ErrCategory.String(domain), + fields.ErrCode.String(code), ) errCode = fmt.Sprintf("ext.%s.%s", domain, code) diff --git a/cli/azd/internal/cmd/errors_test.go b/cli/azd/internal/cmd/errors_test.go index dc29cce4f5c..2d18aaa46b8 100644 --- a/cli/azd/internal/cmd/errors_test.go +++ b/cli/azd/internal/cmd/errors_test.go @@ -256,8 +256,8 @@ func Test_MapError(t *testing.T) { }, wantErrReason: "ext.validation.invalid_config", wantErrDetails: []attribute.KeyValue{ - attribute.String("error.ext.category", "validation"), - attribute.String("error.ext.code", "invalid_config"), + fields.ErrorKey(fields.ErrCategory.Key).String("validation"), + fields.ErrorKey(fields.ErrCode.Key).String("invalid_config"), }, }, { @@ -269,8 +269,8 @@ func Test_MapError(t *testing.T) { }, wantErrReason: "ext.local.something_bad", wantErrDetails: []attribute.KeyValue{ - attribute.String("error.ext.category", "local"), - attribute.String("error.ext.code", "something_bad"), + fields.ErrorKey(fields.ErrCategory.Key).String("local"), + fields.ErrorKey(fields.ErrCode.Key).String("something_bad"), }, }, { @@ -282,8 +282,8 @@ func Test_MapError(t *testing.T) { }, wantErrReason: "ext.auth.token_expired", wantErrDetails: []attribute.KeyValue{ - attribute.String("error.ext.category", "auth"), - attribute.String("error.ext.code", "token_expired"), + fields.ErrorKey(fields.ErrCategory.Key).String("auth"), + fields.ErrorKey(fields.ErrCode.Key).String("token_expired"), }, }, } diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 4894fa70020..19673db2367 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -319,6 +319,13 @@ const ServiceNameAzd = "azd" // Error related fields var ( + // Error category that classifies an error. + ErrCategory = AttributeKey{ + Key: attribute.Key("error.category"), + Classification: SystemMetadata, + Purpose: PerformanceAndHealth, + } + // Error code that describes an error. ErrCode = AttributeKey{ Key: attribute.Key("error.code"), @@ -348,23 +355,6 @@ var ( } ) -// Extension error related fields. -var ( - // Extension error category. - ExtErrorCategory = AttributeKey{ - Key: attribute.Key("ext.category"), - Classification: SystemMetadata, - Purpose: PerformanceAndHealth, - } - - // Extension error code. - ExtErrorCode = AttributeKey{ - Key: attribute.Key("ext.code"), - Classification: SystemMetadata, - Purpose: PerformanceAndHealth, - } -) - // Service related fields. var ( // Hostname of the service. diff --git a/cli/azd/internal/tracing/fields/key.go b/cli/azd/internal/tracing/fields/key.go index 2a318afab5c..c376c7fa3d6 100644 --- a/cli/azd/internal/tracing/fields/key.go +++ b/cli/azd/internal/tracing/fields/key.go @@ -45,7 +45,11 @@ func Sha256Hash(val string) string { return hash } -// ErrorKey returns a new Key with "error." prefix appended. +// ErrorKey returns a new Key with "error." prefix prepended. +// Keys that already have the "error." prefix are returned as-is. func ErrorKey(k attribute.Key) attribute.Key { + if strings.HasPrefix(string(k), "error.") { + return k + } return attribute.Key("error." + string(k)) } diff --git a/cli/azd/internal/tracing/fields/key_test.go b/cli/azd/internal/tracing/fields/key_test.go new file mode 100644 index 00000000000..fa36368f3ee --- /dev/null +++ b/cli/azd/internal/tracing/fields/key_test.go @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package fields + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" +) + +func TestErrorKey(t *testing.T) { + tests := []struct { + name string + key attribute.Key + want attribute.Key + }{ + {"PrependsPrefixToNonErrorKey", "service.name", "error.service.name"}, + {"SkipsPrefixForErrorKey", "error.code", "error.code"}, + {"SkipsPrefixForNestedErrorKey", "error.category", "error.category"}, + {"PrependsPrefixToEmptyKey", "", "error."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, ErrorKey(tt.key)) + }) + } +}