Skip to content

Commit 78e3907

Browse files
suzuki-safieclaude
andauthored
Leave span status unset for 4xx responses on server spans (#17)
* Leave span status unset for 4xx responses on server spans Per the OTel HTTP semantic conventions, span status for HTTP status codes in the 4xx range must be left unset for SpanKind.SERVER spans. Decide the span status primarily from the status code resolved by echo.ResolveResponseStatus instead of returning Error for any non-nil handler error (e.g. echo.NewHTTPError(400)). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove Echo-specific references from extractor files Keep extrator.go and extrator_test.go free of Echo-specific mentions so they can be copied for use outside of Echo. The Echo-specific scenarios remain covered in otel_test.go. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 41c8bfd commit 78e3907

3 files changed

Lines changed: 110 additions & 6 deletions

File tree

extrator.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -560,27 +560,39 @@ func SpanNameFormatter(v Values) string {
560560
return method
561561
}
562562

563-
// SpanStatus returns the span status code and error description based on the HTTP status code and error.
563+
// SpanStatus returns the span status code and error description for a server span, based on the
564+
// resolved HTTP response status code and the error that occurred while handling the request.
564565
//
565566
// Spec:
566567
//
567568
// Span Status MUST be left unset if HTTP status code was in the 1xx, 2xx or 3xx ranges, unless there was another error
568569
// (e.g., network error receiving the response body; or 3xx codes with max redirects exceeded), in which case status
569570
// MUST be set to Error.
570571
//
572+
// For HTTP status codes in the 4xx range span status MUST be left unset in case of SpanKind.SERVER and SHOULD be set
573+
// to Error in case of SpanKind.CLIENT.
574+
//
571575
// Reference:
572576
// - [Span.Status](https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status)
573577
// - [Recording errors on spans](https://opentelemetry.io/docs/specs/semconv/general/recording-errors/)
574578
func SpanStatus(code int, err error) (codes.Code, string) {
575-
if err != nil { // When the operation ends with an error, instrumentation: SHOULD set the span status code to Error
576-
return codes.Error, err.Error()
577-
}
578-
579579
if code < 100 || code >= 600 {
580580
return codes.Error, fmt.Sprintf("Invalid HTTP status code %d", code)
581581
}
582582
if code >= 500 {
583+
if err != nil {
584+
return codes.Error, err.Error()
585+
}
583586
return codes.Error, ""
584587
}
588+
if code >= 400 {
589+
// this instrumentation creates server spans, and for those the convention is to leave
590+
// the status unset on 4xx responses, even when the error is what the status code was
591+
// resolved from.
592+
return codes.Unset, ""
593+
}
594+
if err != nil { // an error alongside a 1xx-3xx status indicates another error occurred
595+
return codes.Error, err.Error()
596+
}
585597
return codes.Unset, ""
586598
}

extrator_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,13 @@ func TestSpanStatus(t *testing.T) {
701701
expectCode: codes.Error,
702702
expectDesc: "network error",
703703
},
704+
{
705+
name: "error with invalid status code",
706+
whenStatus: 0,
707+
whenError: errors.New("network error"),
708+
expectCode: codes.Error,
709+
expectDesc: "Invalid HTTP status code 0",
710+
},
704711
{
705712
name: "invalid status code below range",
706713
whenStatus: 99,
@@ -729,6 +736,27 @@ func TestSpanStatus(t *testing.T) {
729736
expectCode: codes.Error,
730737
expectDesc: "",
731738
},
739+
{
740+
name: "server error 500 with error keeps error description",
741+
whenStatus: 500,
742+
whenError: errors.New("something failed"),
743+
expectCode: codes.Error,
744+
expectDesc: "something failed",
745+
},
746+
{
747+
name: "client error 404 is left unset for server span",
748+
whenStatus: 404,
749+
whenError: nil,
750+
expectCode: codes.Unset,
751+
expectDesc: "",
752+
},
753+
{
754+
name: "client error 400 with error is left unset for server span",
755+
whenStatus: 400,
756+
whenError: errors.New("invalid request"),
757+
expectCode: codes.Unset,
758+
expectDesc: "",
759+
},
732760
{
733761
name: "informational status 100",
734762
whenStatus: 100,

otel_test.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import (
1313
"github.com/stretchr/testify/assert"
1414
"go.opentelemetry.io/otel"
1515
"go.opentelemetry.io/otel/attribute"
16-
"go.opentelemetry.io/otel/metric"
1716
"go.opentelemetry.io/otel/codes"
17+
"go.opentelemetry.io/otel/metric"
1818
"go.opentelemetry.io/otel/propagation"
1919
"go.opentelemetry.io/otel/semconv/v1.39.0/httpconv"
2020
"go.opentelemetry.io/otel/trace"
@@ -493,6 +493,70 @@ func TestSpanStatusOnHTTP500(t *testing.T) {
493493
}
494494
}
495495

496+
// statusCoderError implements echo.HTTPStatusCoder so echo.ResolveResponseStatus can resolve its status code.
497+
type statusCoderError struct {
498+
code int
499+
msg string
500+
}
501+
502+
func (e *statusCoderError) Error() string { return e.msg }
503+
func (e *statusCoderError) StatusCode() int { return e.code }
504+
505+
func TestSpanStatusOnHTTP4xx(t *testing.T) {
506+
tests := []struct {
507+
name string
508+
handler echo.HandlerFunc
509+
expectStatus int
510+
}{
511+
{
512+
name: "handler writes 400 status code directly",
513+
handler: func(c *echo.Context) error {
514+
return c.String(http.StatusBadRequest, "bad request")
515+
},
516+
expectStatus: http.StatusBadRequest,
517+
},
518+
{
519+
name: "handler returns echo HTTP error with 400",
520+
handler: func(c *echo.Context) error {
521+
return echo.NewHTTPError(http.StatusBadRequest, "bad request")
522+
},
523+
expectStatus: http.StatusBadRequest,
524+
},
525+
{
526+
name: "handler returns HTTPStatusCoder error with 422",
527+
handler: func(c *echo.Context) error {
528+
return &statusCoderError{code: http.StatusUnprocessableEntity, msg: "validation failed"}
529+
},
530+
expectStatus: http.StatusUnprocessableEntity,
531+
},
532+
}
533+
534+
for _, tt := range tests {
535+
t.Run(tt.name, func(t *testing.T) {
536+
exporter := tracetest.NewInMemoryExporter()
537+
tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
538+
539+
e := echo.New()
540+
e.Use(NewMiddlewareWithConfig(Config{
541+
ServerName: "foobar",
542+
TracerProvider: tp,
543+
}))
544+
e.GET("/error", tt.handler)
545+
546+
r := httptest.NewRequest(http.MethodGet, "/error", http.NoBody)
547+
w := httptest.NewRecorder()
548+
e.ServeHTTP(w, r)
549+
550+
assert.Equal(t, tt.expectStatus, w.Result().StatusCode)
551+
552+
spans := exporter.GetSpans()
553+
assert.Len(t, spans, 1)
554+
// per the semantic conventions, span status is left unset for 4xx responses on server spans
555+
assert.Equal(t, codes.Unset, spans[0].Status.Code)
556+
})
557+
}
558+
}
559+
496560
func TestConfig_OnNextError(t *testing.T) {
497561
tests := []struct {
498562
name string

0 commit comments

Comments
 (0)