Skip to content

otelgin: client-initiated request termination is recorded as a 500 with span status Error and no error.type #9374

Description

@danielloader

Split out from #9358 as requested in #9358 (comment). Same defect class as the otelhttp report, confirmed independently against otelgin.

Symptom

When a client disconnects mid-request, the handler observes the cancelled request context and typically returns an error, which surfaces as a 500. otelgin then records http.response.status_code=500 and sets the span status to Error, and sets no error.type at all, so a client disconnect is indistinguishable from a genuine server fault.

Middleware reads the status from Gin's own c.Writer.Status() after c.Next() and never inspects c.Request.Context().Err():

c.Next()
status := c.Writer.Status()
span.SetStatus(sc.Status(status))
span.SetAttributes(sc.ResponseTraceAttrs(semconv.ResponseTelemetry{
StatusCode: status,
WriteBytes: int64(c.Writer.Size()),
})...)
if len(c.Errors) > 0 {
span.SetStatus(codes.Error, c.Errors.String())
if len(c.Errors) == 1 {
span.SetAttributes(otelsemconv.ErrorType(c.Errors[0].Err))
} else {
span.SetAttributes(otelsemconv.ErrorTypeOther)
}
}

otelgin does set error.type, but only from c.Errors. A client disconnect does not populate c.Errors, so that path does not fire. Because the middleware uses Gin's ResponseWriter rather than a wrapper, there is also no observed response-write error, and the request body is not wrapped, so there is no observed body-read error either. The metric path (sc.RecordMetrics) carries StatusCode only and no error.type.

Reproduction

Confirmed on main at 5f30960 with a real HTTP/1.1 disconnect. The handler waits on c.Request.Context().Done() and then writes 500:

router.Use(otelgin.Middleware("srv", otelgin.WithTracerProvider(tp)))
router.GET("/hello", func(c *gin.Context) {
    <-c.Request.Context().Done()
    c.String(http.StatusInternalServerError, "cancelled")
})

The client issues the request with a cancellable context and cancels it after 100ms. Recorded span:

span name   = "GET /hello"
span status = Error ""
attr http.route                 = /hello
attr http.response.body.size    = 9
attr http.response.status_code  = 500

No error.type. Identical outcome to the otelhttp reproduction in #9358.

Suggested fix

Mirroring the triage in #9358 (comment), adapted to Gin:

  1. Prefer an observed failure cause over the bare status code — request-context error, and any response-write or request-body-read error if otelgin starts observing those.
  2. Set span status to Error and add a predictable, low-cardinality error.type for that cause.
  3. Preserve the observed/selected HTTP status code; for an error status with no more specific cause, use the status-code string as error.type. Keep the existing c.Errors classification working, and decide precedence between a c.Errors entry and a request-context error.
  4. Add error.type to the metric attributes recorded by sc.RecordMetrics, per the HTTP metric conventions.
  5. Add a real disconnect regression test so cancellation is detected before the middleware returns.

Note on shared code

otelgin/internal/semconv/server.go is generated from internal/shared/semconv/server.go.tmpl and is byte-identical to the copy vendored into otelhttp (verified by diff). The same is true for otelmux, otelecho and otelrestful. So whatever error.type classification lands in the shared template is picked up by all of them, but each middleware still needs its own wiring to detect and pass the failure cause, plus its own regression test.

Relevant requirements: HTTP span conventions, HTTP metric conventions, and the dropped-connection example.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions