Skip to content
11 changes: 10 additions & 1 deletion contrib/gin-gonic/gin/gintrace.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
package gin // import "github.com/DataDog/dd-trace-go/contrib/gin-gonic/gin/v2"

import (
"errors"
"fmt"
"math"

Expand Down Expand Up @@ -53,7 +54,15 @@ func Middleware(service string, opts ...Option) gin.HandlerFunc {
opts = append(opts, httptrace.HeaderTagsFromRequest(c.Request, cfg.headerTags))
span, ctx, finishSpans := httptrace.StartRequestSpan(c.Request, opts...)
defer func() {
finishSpans(c.Writer.Status(), nil)
status := c.Writer.Status()
if cfg.propagateError && cfg.isStatusError(status) {
var err error
for _, e := range c.Errors {
err = errors.Join(err, e.Err)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are already setting gin.errors equal to c.Errors.String() at line 80. Could we use the same strategy here?

Additionally, what if isStatusError and propagateError both return true, but c.Errors has length 0? is that possible?

In that case we should fallback to the previous method as well (crafting a custom error based on the status code), so you should additionally check in the condition && len(c.Errors) > 0

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes, you are right I missed that.

c.Errors has length 0 is possible, in this case err will be nil, and therefore tracer.WithError(err) will do nothing. In this case the custom error based on the status code will be crafted. I think it is exactly what happens in the second test case that I added.
Do you think this behavior is not obvious and the condition should be added ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nevermind, using c.Errors().String(), the condition should be added

finishSpans(status, cfg.isStatusError, tracer.WithError(err))
}
finishSpans(status, cfg.isStatusError)
}()

// pass the span through the request context
Expand Down
81 changes: 77 additions & 4 deletions contrib/gin-gonic/gin/gintrace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,84 @@ func TestError(t *testing.T) {
mt := mocktracer.Start()
defer mt.Stop()

// setup
router := gin.New()
router.Use(Middleware("foobar"))
responseErr := errors.New("oh no")

t.Run("server error", func(*testing.T) {
t.Run("server error - with error propagation", func(*testing.T) {
defer mt.Reset()

router := gin.New()
router.Use(Middleware("foobar", WithErrorPropagation()))

// configure a handler that returns an error and 5xx status code
router.GET("/server_err", func(c *gin.Context) {
c.AbortWithError(500, responseErr)
})
r := httptest.NewRequest("GET", "/server_err", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
response := w.Result()
defer response.Body.Close()
assert.Equal(response.StatusCode, 500)

// verify the errors and status are correct
spans := mt.FinishedSpans()
assert.Len(spans, 1)
if len(spans) < 1 {
t.Fatalf("no spans")
}
span := spans[0]
assert.Equal("http.request", span.OperationName())
assert.Equal("foobar", span.Tag(ext.ServiceName))
assert.Equal("500", span.Tag(ext.HTTPCode))
assert.Equal(fmt.Sprintf("Error #01: %s\n", responseErr), span.Tag("gin.errors"))
// server errors set the ext.ErrorMsg tag
assert.Equal("oh no", span.Tag(ext.ErrorMsg))
assert.Equal(ext.SpanKindServer, span.Tag(ext.SpanKind))
assert.Equal("gin-gonic/gin", span.Tag(ext.Component))
assert.Equal(componentName, span.Integration())
})

t.Run("server error - with error propagation - nil Errors in gin context", func(*testing.T) {
defer mt.Reset()

router := gin.New()
router.Use(Middleware("foobar", WithErrorPropagation()))

// configure a handler that returns an error and 5xx status code
router.GET("/server_err", func(c *gin.Context) {
c.AbortWithStatus(500)
})
r := httptest.NewRequest("GET", "/server_err", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, r)
response := w.Result()
defer response.Body.Close()
assert.Equal(response.StatusCode, 500)

// verify the errors and status are correct
spans := mt.FinishedSpans()
assert.Len(spans, 1)
if len(spans) < 1 {
t.Fatalf("no spans")
}
span := spans[0]
assert.Equal("http.request", span.OperationName())
assert.Equal("foobar", span.Tag(ext.ServiceName))
assert.Equal("500", span.Tag(ext.HTTPCode))
assert.Empty(span.Tag("gin.errors"))
// server errors set the ext.ErrorMsg tag
assert.Equal("500: Internal Server Error", span.Tag(ext.ErrorMsg))
assert.Equal(ext.SpanKindServer, span.Tag(ext.SpanKind))
assert.Equal("gin-gonic/gin", span.Tag(ext.Component))
assert.Equal(componentName, span.Integration())
})

t.Run("server error - without error propagation", func(*testing.T) {
defer mt.Reset()

router := gin.New()
router.Use(Middleware("foobar"))

// configure a handler that returns an error and 5xx status code
router.GET("/server_err", func(c *gin.Context) {
c.AbortWithError(500, responseErr)
Expand Down Expand Up @@ -219,6 +289,9 @@ func TestError(t *testing.T) {
t.Run("client error", func(*testing.T) {
defer mt.Reset()

router := gin.New()
router.Use(Middleware("foobar"))

// configure a handler that returns an error and 4xx status code
router.GET("/client_err", func(c *gin.Context) {
c.AbortWithError(418, responseErr)
Expand Down
44 changes: 34 additions & 10 deletions contrib/gin-gonic/gin/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import (
)

type config struct {
analyticsRate float64
resourceNamer func(c *gin.Context) string
serviceName string
ignoreRequest func(c *gin.Context) bool
headerTags instrumentation.HeaderTags
analyticsRate float64
resourceNamer func(c *gin.Context) string
serviceName string
ignoreRequest func(c *gin.Context) bool
isStatusError func(statusCode int) bool
propagateError bool
headerTags instrumentation.HeaderTags
}

func newConfig(serviceName string) *config {
Expand All @@ -28,11 +30,13 @@ func newConfig(serviceName string) *config {
}
rate := instr.AnalyticsRate(true)
return &config{
analyticsRate: rate,
resourceNamer: defaultResourceNamer,
serviceName: serviceName,
ignoreRequest: func(_ *gin.Context) bool { return false },
headerTags: instr.HTTPHeadersAsTags(),
analyticsRate: rate,
resourceNamer: defaultResourceNamer,
serviceName: serviceName,
ignoreRequest: func(_ *gin.Context) bool { return false },
isStatusError: isServerError,
propagateError: false,
headerTags: instr.HTTPHeadersAsTags(),
}
}

Expand Down Expand Up @@ -79,6 +83,26 @@ func WithResourceNamer(namer func(c *gin.Context) string) OptionFn {
}
}

// WithStatusCheck specifies a function fn which reports whether the passed
// statusCode should be considered an error.
func WithStatusCheck(fn func(statusCode int) bool) OptionFn {
return func(cfg *config) {
cfg.isStatusError = fn
}
}

func isServerError(statusCode int) bool {
return statusCode >= 500 && statusCode < 600
}

// WithErrorPropagation enables the propagation of gin's errors to the span.
// If there are multiple errors in the gin context, they will be all added to the span.
func WithErrorPropagation() OptionFn {
Copy link
Contributor

@rarguelloF rarguelloF Oct 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this name could be slightly confusing to other users (it was not immediate clear to me what it was before reading the description).

WDYT about WithUseGinErrors?

return func(cfg *config) {
cfg.propagateError = true
}
}

// WithHeaderTags enables the integration to attach HTTP request headers as span tags.
// Warning:
// Using this feature can risk exposing sensitive data such as authorization tokens to Datadog.
Expand Down
Loading