diff --git a/cli/azd/internal/cmd/errors.go b/cli/azd/internal/cmd/errors.go index 6db8a8d5462..ecbd4041f07 100644 --- a/cli/azd/internal/cmd/errors.go +++ b/cli/azd/internal/cmd/errors.go @@ -50,13 +50,7 @@ func MapError(err error, span tracing.Span) { errCode = "auth.login_required" } else if errWithSuggestion, ok := errors.AsType[*internal.ErrorWithSuggestion](err); ok { errCode = "error.suggestion" - inner := errWithSuggestion.Unwrap() - if code := classifySentinel(inner); code != "" { - span.SetAttributes(fields.ErrType.String(code)) - } else { - span.SetAttributes( - fields.ErrType.String(errorType(inner))) - } + span.SetAttributes(fields.ErrType.String(classifySuggestionType(errWithSuggestion.Unwrap()))) } else if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { serviceName := "other" statusCode := -1 @@ -190,30 +184,8 @@ func MapError(err error, span tracing.Span) { fields.ServiceCorrelationId.String(authFailedErr.Parsed.CorrelationId)) } errCode = "service.aad.failed" - } else if errors.Is(err, terminal.InterruptErr) { - errCode = "user.canceled" - } else if errors.Is(err, context.Canceled) { - errCode = "user.canceled" - } else if errors.Is(err, context.DeadlineExceeded) { - errCode = "internal.timeout" - } else if errors.Is(err, auth.ErrNoCurrentUser) { - errCode = "auth.not_logged_in" - } else if errors.Is(err, consent.ErrToolExecutionDenied) { - errCode = "user.tool_denied" - } else if errors.Is(err, git.ErrNotRepository) { - errCode = "internal.not_git_repo" - } else if errors.Is(err, azapi.ErrPreviewNotSupported) { - errCode = "internal.preview_not_supported" - } else if errors.Is(err, provisioning.ErrBindMountOperationDisabled) { - errCode = "internal.bind_mount_disabled" - } else if errors.Is(err, update.ErrNeedsElevation) { - errCode = "update.elevationRequired" - } else if errors.Is(err, pipeline.ErrRemoteHostIsNotAzDo) { - errCode = "internal.remote_not_azdo" - } else if errors.Is(err, internal.ErrExtensionNotFound) { - errCode = "internal.extension_not_found" - } else if errors.Is(err, internal.ErrExtensionTokenFailed) { - errCode = "internal.extension_error" + } else if code := classifySentinel(err); code != "" { + errCode = code } else if isNetworkError(err) { errCode = "internal.network" errType := errorType(err) @@ -291,11 +263,128 @@ func classifySentinel(err error) string { return "internal.resource_not_found" case errors.Is(err, internal.ErrOperationCancelled): return "internal.operation_cancelled" + case errors.Is(err, terminal.InterruptErr), + errors.Is(err, context.Canceled): + return "user.canceled" + case errors.Is(err, context.DeadlineExceeded): + return "internal.timeout" + case errors.Is(err, auth.ErrNoCurrentUser): + return "auth.not_logged_in" + case errors.Is(err, consent.ErrToolExecutionDenied): + return "user.tool_denied" + case errors.Is(err, git.ErrNotRepository): + return "internal.not_git_repo" + case errors.Is(err, azapi.ErrPreviewNotSupported): + return "internal.preview_not_supported" + case errors.Is(err, provisioning.ErrBindMountOperationDisabled): + return "internal.bind_mount_disabled" + case errors.Is(err, update.ErrNeedsElevation): + return "update.elevationRequired" + case errors.Is(err, pipeline.ErrRemoteHostIsNotAzDo): + return "internal.remote_not_azdo" default: return "" } } +// classifySuggestionType returns a telemetry error type string for an inner error wrapped by ErrorWithSuggestion. +// It preserves the suggestion result code while improving the error.type attribute when the inner error is structured. +// +// The check order here should match MapError to ensure consistent classification. +// Structured error types are checked first, then sentinels, then network errors, then fallback. +func classifySuggestionType(err error) string { + if updateErr, ok := errors.AsType[*update.UpdateError](err); ok { + return updateErr.Code + } + + if _, ok := errors.AsType[*auth.ReLoginRequiredError](err); ok { + return "auth.login_required" + } + + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + serviceName := "other" + statusCode := -1 + + if respErr.RawResponse != nil { + statusCode = respErr.RawResponse.StatusCode + if respErr.RawResponse.Request != nil { + serviceName, _ = mapService(respErr.RawResponse.Request.Host) + } + } + + return fmt.Sprintf("service.%s.%d", serviceName, statusCode) + } + + if armDeployErr, ok := errors.AsType[*azapi.AzureDeploymentError](err); ok { + operationName := armDeployErr.Operation + if operationName == azapi.DeploymentOperationDeploy { + operationName = "deployment" + } + + return fmt.Sprintf("service.arm.%s.failed", operationName) + } + + if extServiceErr, ok := errors.AsType[*azdext.ServiceError](err); ok { + serviceName := "" + if extServiceErr.ServiceName != "" { + serviceName, _ = mapService(extServiceErr.ServiceName) + } + + switch { + case extServiceErr.ErrorCode != "": + return fmt.Sprintf("ext.service.%s", normalizeCodeSegment(extServiceErr.ErrorCode, "failed")) + case extServiceErr.StatusCode > 0 && serviceName != "": + return fmt.Sprintf("ext.service.%s.%d", serviceName, extServiceErr.StatusCode) + case extServiceErr.StatusCode > 0: + return fmt.Sprintf("ext.service.unknown.%d", extServiceErr.StatusCode) + default: + return "ext.service.unknown.failed" + } + } + + if extLocalErr, ok := errors.AsType[*azdext.LocalError](err); ok { + domain := string(azdext.NormalizeLocalErrorCategory(extLocalErr.Category)) + code := normalizeCodeSegment(extLocalErr.Code, "failed") + + return fmt.Sprintf("ext.%s.%s", domain, code) + } + + if _, ok := errors.AsType[*extensions.ExtensionRunError](err); ok { + return "ext.run.failed" + } + + if toolExecErr, ok := errors.AsType[*exec.ExitError](err); ok { + toolName := "other" + if cmdName := cmdAsName(toolExecErr.Cmd); cmdName != "" { + toolName = cmdName + } + + return fmt.Sprintf("tool.%s.failed", toolName) + } + + if toolCheckErr, ok := errors.AsType[*tools.MissingToolErrors](err); ok { + if len(toolCheckErr.ToolNames) == 1 { + return fmt.Sprintf("tool.%s.missing", toolCheckErr.ToolNames[0]) + } + + return "tool.multiple.missing" + } + + if _, ok := errors.AsType[*auth.AuthFailedError](err); ok { + return "service.aad.failed" + } + + if code := classifySentinel(err); code != "" { + return code + } + + if isNetworkError(err) { + return "internal.network" + } + + return errorType(err) +} + // errorType returns the type name of the given error, unwrapping as needed to find the root cause(s). func errorType(err error) string { if err == nil { diff --git a/cli/azd/internal/cmd/errors_test.go b/cli/azd/internal/cmd/errors_test.go index f6b0341db4c..c0b412ee20e 100644 --- a/cli/azd/internal/cmd/errors_test.go +++ b/cli/azd/internal/cmd/errors_test.go @@ -30,8 +30,10 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/pipeline" + "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/git" "github.com/azure/azure-dev/cli/azd/test/mocks/mocktracing" "github.com/stretchr/testify/require" @@ -403,7 +405,26 @@ func Test_MapError(t *testing.T) { }, wantErrReason: "error.suggestion", wantErrDetails: []attribute.KeyValue{ - fields.ErrType.String("*exported.ResponseError"), + fields.ErrType.String("service.arm.429"), + }, + }, + { + name: "WithSuggestionWrappingArmDeploymentError", + err: &internal.ErrorWithSuggestion{ + Err: &azapi.AzureDeploymentError{ + Operation: azapi.DeploymentOperationDeploy, + Details: &azapi.DeploymentErrorLine{ + Code: "Conflict", + Inner: []*azapi.DeploymentErrorLine{ + {Code: "OutOfCapacity"}, + }, + }, + }, + Suggestion: "Retry in another region.", + }, + wantErrReason: "error.suggestion", + wantErrDetails: []attribute.KeyValue{ + fields.ErrType.String("service.arm.deployment.failed"), }, }, { @@ -815,7 +836,7 @@ func TestMapError_ErrorWithSuggestionSetsErrorType(t *testing.T) { wantErrType: "internal.key_not_found", }, { - name: "ResponseError_falls_back_to_go_type", + name: "ResponseErrorUsesStructuredCategory", err: &internal.ErrorWithSuggestion{ Err: &azcore.ResponseError{ ErrorCode: "QuotaExceeded", @@ -831,7 +852,24 @@ func TestMapError_ErrorWithSuggestionSetsErrorType(t *testing.T) { Suggestion: "Request a quota increase.", }, wantErrCode: "error.suggestion", - wantErrType: "*exported.ResponseError", + wantErrType: "service.arm.429", + }, + { + name: "ArmDeploymentErrorUsesStructuredCategory", + err: &internal.ErrorWithSuggestion{ + Err: &azapi.AzureDeploymentError{ + Operation: azapi.DeploymentOperationDeploy, + Details: &azapi.DeploymentErrorLine{ + Code: "Conflict", + Inner: []*azapi.DeploymentErrorLine{ + {Code: "OutOfCapacity"}, + }, + }, + }, + Suggestion: "Retry in another region.", + }, + wantErrCode: "error.suggestion", + wantErrType: "service.arm.deployment.failed", }, { name: "PlainError_falls_back_to_go_type", @@ -859,6 +897,121 @@ func TestMapError_ErrorWithSuggestionSetsErrorType(t *testing.T) { } } +// Test_ClassifySuggestionType_MatchesMapError verifies that classifySuggestionType produces +// the same errCode as MapError for every structured error type and sentinel. +// This catches drift if someone updates the classification logic in one function but not the other. +func Test_ClassifySuggestionType_MatchesMapError(t *testing.T) { + tests := []struct { + name string + err error + }{ + { + name: "ResponseError", + err: &azcore.ResponseError{ + ErrorCode: "QuotaExceeded", + StatusCode: 429, + RawResponse: &http.Response{ + StatusCode: 429, + Request: &http.Request{ + Method: "POST", + Host: "management.azure.com", + }, + }, + }, + }, + { + name: "ArmDeploymentError", + err: &azapi.AzureDeploymentError{ + Operation: azapi.DeploymentOperationDeploy, + Details: &azapi.DeploymentErrorLine{ + Code: "Conflict", + }, + }, + }, + { + name: "ArmValidationError", + err: &azapi.AzureDeploymentError{ + Operation: azapi.DeploymentOperationValidate, + Details: &azapi.DeploymentErrorLine{Code: "InvalidTemplate"}, + }, + }, + { + name: "ExtServiceError", + err: &azdext.ServiceError{ + Message: "Rate limit", + ErrorCode: "create_agent.RateLimitExceeded", + StatusCode: 429, + ServiceName: "openai.azure.com", + }, + }, + { + name: "ExtLocalError", + err: &azdext.LocalError{ + Message: "invalid config", + Code: "Invalid-Config", + Category: azdext.LocalErrorCategoryValidation, + }, + }, + { + name: "ExtensionRunError", + err: &extensions.ExtensionRunError{ExtensionId: "test", Err: errors.New("fail")}, + }, + { + name: "ExitError", + err: &exec.ExitError{Cmd: "docker", ExitCode: 1}, + }, + { + name: "MissingToolErrors_single", + err: &tools.MissingToolErrors{ToolNames: []string{"docker"}}, + }, + { + name: "MissingToolErrors_multiple", + err: &tools.MissingToolErrors{ToolNames: []string{"docker", "kubectl"}}, + }, + { + name: "AuthFailedError", + err: &auth.AuthFailedError{ + Parsed: &auth.AadErrorResponse{Error: "invalid_grant"}, + }, + }, + // Sentinels + {name: "context.Canceled", err: context.Canceled}, + {name: "context.DeadlineExceeded", err: context.DeadlineExceeded}, + {name: "ErrNoCurrentUser", err: auth.ErrNoCurrentUser}, + {name: "ErrNoProject", err: azdcontext.ErrNoProject}, + {name: "ErrNotFound", err: environment.ErrNotFound}, + {name: "ErrToolExecutionDenied", err: consent.ErrToolExecutionDenied}, + {name: "ErrNotRepository", err: git.ErrNotRepository}, + {name: "ErrPreviewNotSupported", err: azapi.ErrPreviewNotSupported}, + {name: "ErrBindMountDisabled", err: provisioning.ErrBindMountOperationDisabled}, + {name: "ErrRemoteHostIsNotAzDo", err: pipeline.ErrRemoteHostIsNotAzDo}, + {name: "ErrInfraNotProvisioned", err: internal.ErrInfraNotProvisioned}, + {name: "ErrKeyNotFound", err: internal.ErrKeyNotFound}, + {name: "ErrExtensionNotFound", err: internal.ErrExtensionNotFound}, + {name: "ErrOperationCancelled", err: internal.ErrOperationCancelled}, + // Network error + { + name: "DNSError", + err: &net.DNSError{Err: "no such host", Name: "example.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Get errCode from MapError + span := &mocktracing.Span{} + MapError(tt.err, span) + mapErrorCode := span.Status.Description + + // Get code from classifySuggestionType + suggestionCode := classifySuggestionType(tt.err) + + require.Equal(t, mapErrorCode, suggestionCode, + "classifySuggestionType and MapError must produce the same code for %T", tt.err) + }) + } +} + func Test_cmdAsName(t *testing.T) { tests := []struct { name string