Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ overrides:
- userosscache
- docstates
- dylib
- filename: cli/azd/pkg/azdext/trace_context.go
words:
- traceparent
ignorePaths:
- "**/*_test.go"
- "**/mock*.go"
Expand Down
4 changes: 4 additions & 0 deletions cli/azd/grpc/proto/event.proto
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ message EventMessage {
InvokeServiceHandler invoke_service_handler = 5;
ServiceHandlerStatus service_handler_status = 6;
}

// W3C traceparent format for distributed tracing propagation.
// Format: "00-{traceId}-{spanId}-{flags}"
string trace_parent = 98;
}

// Client subscribes to project-related events
Expand Down
8 changes: 8 additions & 0 deletions cli/azd/grpc/proto/framework_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ service FrameworkService {
// Envelope for all possible framework service messages (requests and responses)
message FrameworkServiceMessage {
string request_id = 1;
// W3C Trace Context traceparent header value for distributed tracing.
// Format: "00-{trace-id}-{span-id}-{trace-flags}"
// See: https://www.w3.org/TR/trace-context/
string trace_parent = 98;
FrameworkServiceErrorMessage error = 99;
oneof message_type {
RegisterFrameworkServiceRequest register_framework_service_request = 2;
Expand All @@ -41,6 +45,10 @@ message FrameworkServiceMessage {
message FrameworkServiceErrorMessage {
string message = 1;
string details = 2;
// Structured error information for telemetry
string error_code = 3; // Error code from the service (e.g., "Conflict", "NotFound")
int32 status_code = 4; // HTTP status code (e.g., 409, 404, 500)
string service_name = 5; // Service name for telemetry (e.g., "ai.azure.com", "cognitiveservices")
}

// Request to register a framework service provider
Expand Down
8 changes: 8 additions & 0 deletions cli/azd/grpc/proto/service_target.proto
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ service ServiceTargetService {
// Envelope for all possible service target messages (requests and responses)
message ServiceTargetMessage {
string request_id = 1;
// W3C Trace Context traceparent header value for distributed tracing.
// Format: "00-{trace-id}-{span-id}-{trace-flags}"
// See: https://www.w3.org/TR/trace-context/
string trace_parent = 98;
ServiceTargetErrorMessage error = 99;
oneof message_type {
RegisterServiceTargetRequest register_service_target_request = 2;
Expand Down Expand Up @@ -83,6 +87,10 @@ message RegisterServiceTargetResponse {
message ServiceTargetErrorMessage {
string message = 2;
string details = 3;
// Structured error information for telemetry
string error_code = 4; // Error code from the service (e.g., "Conflict", "NotFound")
int32 status_code = 5; // HTTP status code (e.g., 409, 404, 500)
string service_name = 6; // Service name for telemetry (e.g., "ai.azure.com", "cognitiveservices")
}

// GetTargetResource request and response
Expand Down
20 changes: 20 additions & 0 deletions cli/azd/internal/cmd/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
"github.com/azure/azure-dev/cli/azd/pkg/auth"
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"go.opentelemetry.io/otel/attribute"
Expand All @@ -33,6 +34,7 @@ func MapError(err error, span tracing.Span) {
var toolExecErr *exec.ExitError
var authFailedErr *auth.AuthFailedError
var extensionRunErr *extensions.ExtensionRunError
var extRespErr *azdext.ExtensionResponseError
if errors.As(err, &respErr) {
serviceName := "other"
statusCode := -1
Expand Down Expand Up @@ -84,6 +86,24 @@ func MapError(err error, span tracing.Span) {
errCode = "service.arm.deployment.failed"
} else if errors.As(err, &extensionRunErr) {
errCode = "ext.run.failed"
} else if errors.As(err, &extRespErr) {
// Handle structured errors from extensions (e.g., service target providers)
if extRespErr.HasServiceInfo() {
// Extension provided service information - use it for telemetry
serviceName, hostDomain := mapService(extRespErr.ServiceName)
errDetails = append(errDetails,
fields.ServiceName.String(serviceName),
fields.ServiceHost.String(hostDomain),
fields.ServiceStatusCode.Int(extRespErr.StatusCode),
)
if extRespErr.ErrorCode != "" {
errDetails = append(errDetails, fields.ServiceErrorCode.String(extRespErr.ErrorCode))
}
errCode = fmt.Sprintf("ext.service.%s.%d", serviceName, extRespErr.StatusCode)
} else {
// Extension error without service info
errCode = "ext.service.failed"
}
} else if errors.As(err, &toolExecErr) {
toolName := "other"
cmdName := cmdAsName(toolExecErr.Cmd)
Expand Down
18 changes: 18 additions & 0 deletions cli/azd/internal/cmd/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
"github.com/azure/azure-dev/cli/azd/pkg/auth"
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/test/mocks/mocktracing"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -148,6 +149,23 @@ func Test_MapError(t *testing.T) {
fields.ErrorKey(fields.ServiceCorrelationId.Key).String("12345"),
},
},
{
name: "WithExtensionResponseError",
err: &azdext.ExtensionResponseError{
Message: "Rate limit exceeded",
Details: "Too many requests",
ErrorCode: "RateLimitExceeded",
StatusCode: 429,
ServiceName: "openai.azure.com",
},
wantErrReason: "ext.service.openai.429",
wantErrDetails: []attribute.KeyValue{
fields.ErrorKey(fields.ServiceName.Key).String("openai"),
fields.ErrorKey(fields.ServiceHost.Key).String("openai.azure.com"),
fields.ErrorKey(fields.ServiceStatusCode.Key).Int(429),
fields.ErrorKey(fields.ServiceErrorCode.Key).String("RateLimitExceeded"),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions cli/azd/internal/tracing/fields/domains.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ var Domains = []Domain{
{"azurefd.net", "frontdoor"},
{"scm.azurewebsites.net", "kudu"},
{"azurewebsites.net", "websites"},
{"services.ai.azure.com", "ai"},
{"cognitiveservices.azure.com", "cognitiveservices"},
{"openai.azure.com", "openai"},
{"blob.core.windows.net", "blob"},
{"cloudapp.azure.com", "vm"},
{"cloudapp.net", "vm"},
Expand Down
17 changes: 14 additions & 3 deletions cli/azd/pkg/azdext/event.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions cli/azd/pkg/azdext/event_message_envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ func (ops *EventMessageEnvelope) SetError(msg *EventMessage, err error) {
// No-op: EventMessage uses status strings, not Error field
}

// GetTraceParent returns the W3C traceparent value from the message for distributed tracing.
func (ops *EventMessageEnvelope) GetTraceParent(msg *EventMessage) string {
return msg.GetTraceParent()
}

// SetTraceParent sets the W3C traceparent value on the message for distributed tracing.
func (ops *EventMessageEnvelope) SetTraceParent(msg *EventMessage, traceParent string) {
msg.TraceParent = traceParent
}

// GetInnerMessage returns the inner message from the oneof field
func (ops *EventMessageEnvelope) GetInnerMessage(msg *EventMessage) any {
// The MessageType field is a oneof wrapper. We need to extract the actual inner message.
Expand Down
154 changes: 154 additions & 0 deletions cli/azd/pkg/azdext/extension_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package azdext

import (
"errors"
"fmt"

"github.com/Azure/azure-sdk-for-go/sdk/azcore"
)

// ExtensionResponseError represents an HTTP response error returned from an extension over gRPC.
// It mirrors azcore.ResponseError and preserves structured error information for telemetry purposes.
type ExtensionResponseError 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 name for telemetry (e.g., "ai.azure.com")
ServiceName string
}

// Error implements the error interface
func (e *ExtensionResponseError) Error() string {
if e.Details != "" {
return fmt.Sprintf("%s: %s", e.Message, e.Details)
}
return e.Message
}

// HasServiceInfo returns true if the error contains service information for telemetry
func (e *ExtensionResponseError) HasServiceInfo() bool {
return e.StatusCode > 0 && e.ServiceName != ""
}

// errorMessage defines the common interface for protobuf error messages
// This allows us to write generic unwrap logic for any generated proto message
type errorMessage interface {
comparable
GetMessage() string
GetDetails() string
GetErrorCode() string
GetStatusCode() int32
GetServiceName() string
}

// errorInfo is a helper struct to hold extracted error information
// before converting to a specific protobuf message type
type errorInfo struct {
message string
details string
errorCode string
statusCode int32
service string
}

// captureErrorInfo extracts structured error information from a Go error.
// It handles nil errors, ExtensionResponseError, and azcore.ResponseError.
func captureErrorInfo(err error) errorInfo {
if err == nil {
return errorInfo{}
}

// Default to the error string
info := errorInfo{message: err.Error()}

// If it's already an ExtensionResponseError, preserve all fields including Details
var extErr *ExtensionResponseError
if errors.As(err, &extErr) {
info.message = extErr.Message
info.details = extErr.Details
info.errorCode = extErr.ErrorCode
//nolint:gosec // G115: HTTP status codes are well within int32 range
info.statusCode = int32(extErr.StatusCode)
info.service = extErr.ServiceName
return info
}

// Try to extract structured error information from Azure SDK errors
var respErr *azcore.ResponseError
if errors.As(err, &respErr) {
info.errorCode = respErr.ErrorCode
//nolint:gosec // G115: HTTP status codes are well within int32 range
info.statusCode = int32(respErr.StatusCode)
if respErr.RawResponse != nil && respErr.RawResponse.Request != nil {
info.service = respErr.RawResponse.Request.Host
}
}

return info
}

// WrapErrorForServiceTarget wraps a Go error into a ServiceTargetErrorMessage for transmission over gRPC.
func WrapErrorForServiceTarget(err error) *ServiceTargetErrorMessage {
info := captureErrorInfo(err)
if info.message == "" {
return nil
}

return &ServiceTargetErrorMessage{
Message: info.message,
Details: info.details,
ErrorCode: info.errorCode,
StatusCode: info.statusCode,
ServiceName: info.service,
}
}

// WrapErrorForFrameworkService wraps a Go error into a FrameworkServiceErrorMessage for transmission over gRPC.
func WrapErrorForFrameworkService(err error) *FrameworkServiceErrorMessage {
info := captureErrorInfo(err)
if info.message == "" {
return nil
}

return &FrameworkServiceErrorMessage{
Message: info.message,
Details: info.details,
ErrorCode: info.errorCode,
StatusCode: info.statusCode,
ServiceName: info.service,
}
}

// unwrapError is a generic helper to convert protobuf error messages back to Go errors
func unwrapError[T errorMessage](msg T) error {
var zero T
if msg == zero || msg.GetMessage() == "" {
return nil
}

return &ExtensionResponseError{
Message: msg.GetMessage(),
Details: msg.GetDetails(),
ErrorCode: msg.GetErrorCode(),
StatusCode: int(msg.GetStatusCode()),
ServiceName: msg.GetServiceName(),
}
}

// UnwrapErrorFromServiceTarget converts a ServiceTargetErrorMessage back to a Go error.
func UnwrapErrorFromServiceTarget(msg *ServiceTargetErrorMessage) error {
return unwrapError(msg)
}

// UnwrapErrorFromFrameworkService converts a FrameworkServiceErrorMessage back to a Go error.
func UnwrapErrorFromFrameworkService(msg *FrameworkServiceErrorMessage) error {
return unwrapError(msg)
}
Loading
Loading