From 535b4942f6b68990f2d16aba45e43b4311a28863 Mon Sep 17 00:00:00 2001 From: Scorfly Date: Sat, 24 Jan 2026 04:09:57 +0100 Subject: [PATCH] feat(echo): add support for Echo v5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: echo integration now requires github.com/labstack/echo/v5 and Go 1.25. Echo v4 is no longer supported. - Bump github.com/labstack/echo from v4.10.1 to v5.0.0. - Set go directive to 1.25.0 in echo/go.mod. - Drop Echo v4-only indirects (gommon, go-colorable, go-isatty, bytebufferpool, fasttemplate, x/crypto, x/net) and update remaining (x/sys, x/text). go.sum updated accordingly. - Use *echo.Context instead of echo.Context in handler and public API (GetHubFromContext, SetHubOnContext, GetSpanFromContext) to match v5’s pointer-based Context. - Update all handler signatures in code and docs from `func(c echo.Context) error` to `func(c *echo.Context) error`. - In v5, Context.Response() returns http.ResponseWriter, not *Response, so ctx.Response().Status is no longer available. - Replace direct ctx.Response().Status with echo.UnwrapResponse(ctx.Response()) to obtain *echo.Response and use resp.Status when UnwrapResponse succeeds and resp.Status != 0. - When UnwrapResponse fails (e.g. middleware replaces the response with a writer that does not unwrap to *echo.Response), leave status at its zero value (0) instead of defaulting to 200. - For handler-returned errors, use echo.HTTPStatusCoder instead of *echo.HTTPError so that both *HTTPError and unexported *httpError (ErrNotFound, ErrMethodNotAllowed, etc.) are handled and the correct status is used for the transaction. - In TestIntegration, skip route registration when Handler is nil so the “404 / no route” case does not call router.GET("", nil). Echo v5’s router rejects Handler == nil and panics with “adding route without handler function”. - Add TestUnwrapResponseError: when the response is wrapped by a writer that does not implement Unwrap() (e.g. &struct{ http.ResponseWriter }{}), UnwrapResponse returns an error; the middleware must not panic and must record http.response.status_code as 0 in the transaction. - README.md and example_test.go: switch imports and examples from echo/v4 to echo/v5 and from echo.Context to *echo.Context. --- echo/README.md | 12 ++++---- echo/example_test.go | 4 +-- echo/go.mod | 15 +++------ echo/go.sum | 35 ++++++--------------- echo/sentryecho.go | 42 +++++++++++++++----------- echo/sentryecho_test.go | 67 +++++++++++++++++++++++++++++++++++------ 6 files changed, 104 insertions(+), 71 deletions(-) diff --git a/echo/README.md b/echo/README.md index b1be6ec3b..c8f25ca61 100644 --- a/echo/README.md +++ b/echo/README.md @@ -24,8 +24,8 @@ import ( "github.com/getsentry/sentry-go" sentryecho "github.com/getsentry/sentry-go/echo" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" + "github.com/labstack/echo/v5" + "github.com/labstack/echo/v5/middleware" ) // To initialize Sentry's handler, you need to initialize Sentry itself beforehand @@ -45,7 +45,7 @@ app.Use(middleware.Recover()) app.Use(sentryecho.New(sentryecho.Options{})) // Set up routes -app.GET("/", func(ctx echo.Context) error { +app.GET("/", func(ctx *echo.Context) error { return ctx.String(http.StatusOK, "Hello, World!") }) @@ -90,7 +90,7 @@ app.Use(sentryecho.New(sentryecho.Options{ })) app.Use(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(ctx echo.Context) error { + return func(ctx *echo.Context) error { if hub := sentryecho.GetHubFromContext(ctx); hub != nil { hub.Scope().SetTag("someRandomTag", "maybeYouNeedIt") } @@ -98,7 +98,7 @@ app.Use(func(next echo.HandlerFunc) echo.HandlerFunc { } }) -app.GET("/", func(ctx echo.Context) error { +app.GET("/", func(ctx *echo.Context) error { if hub := sentryecho.GetHubFromContext(ctx); hub != nil { hub.WithScope(func(scope *sentry.Scope) { scope.SetExtra("unwantedQuery", "someQueryDataMaybe") @@ -108,7 +108,7 @@ app.GET("/", func(ctx echo.Context) error { return ctx.String(http.StatusOK, "Hello, World!") }) -app.GET("/foo", func(ctx echo.Context) error { +app.GET("/foo", func(ctx *echo.Context) error { // sentryecho handler will catch it just fine. Also, because we attached "someRandomTag" // in the middleware before, it will be sent through as well panic("y tho") diff --git a/echo/example_test.go b/echo/example_test.go index e5a94c200..5e41eae35 100644 --- a/echo/example_test.go +++ b/echo/example_test.go @@ -6,13 +6,13 @@ import ( "github.com/getsentry/sentry-go" sentryecho "github.com/getsentry/sentry-go/echo" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" ) func ExampleGetSpanFromContext() { router := echo.New() router.Use(sentryecho.New(sentryecho.Options{})) - router.GET("/", func(c echo.Context) error { + router.GET("/", func(c *echo.Context) error { expensiveThing := func(ctx context.Context) error { span := sentry.StartTransaction(ctx, "expensive_thing") defer span.Finish() diff --git a/echo/go.mod b/echo/go.mod index 1dd65082c..a86721259 100644 --- a/echo/go.mod +++ b/echo/go.mod @@ -1,23 +1,16 @@ module github.com/getsentry/sentry-go/echo -go 1.24.0 +go 1.25.0 replace github.com/getsentry/sentry-go => ../ require ( github.com/getsentry/sentry-go v0.43.0 github.com/google/go-cmp v0.5.9 - github.com/labstack/echo/v4 v4.10.1 + github.com/labstack/echo/v5 v5.0.0 ) require ( - github.com/labstack/gommon v0.4.2 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect ) diff --git a/echo/go.sum b/echo/go.sum index 75eea3604..47cfeb895 100644 --- a/echo/go.sum +++ b/echo/go.sum @@ -4,38 +4,23 @@ github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxI github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/labstack/echo/v4 v4.10.1 h1:rB+D8In9PWjsp1OpHaqK+t04nQv/SBD1IoIcXCg0lpY= -github.com/labstack/echo/v4 v4.10.1/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/labstack/echo/v5 v5.0.0 h1:JHKGrI0cbNsNMyKvranuY0C94O4hSM7yc/HtwcV3Na4= +github.com/labstack/echo/v5 v5.0.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/echo/sentryecho.go b/echo/sentryecho.go index b892577c1..c219d2ac3 100644 --- a/echo/sentryecho.go +++ b/echo/sentryecho.go @@ -7,20 +7,21 @@ import ( "time" "github.com/getsentry/sentry-go" - "github.com/labstack/echo/v4" + "github.com/getsentry/sentry-go/internal/debuglog" + "github.com/labstack/echo/v5" ) const ( // sdkIdentifier is the identifier of the Echo SDK. sdkIdentifier = "sentry.go.echo" - // valuesKey is used as a key to store the Sentry Hub instance on the echo.Context. + // valuesKey is used as a key to store the Sentry Hub instance on the *echo.Context. valuesKey = "sentry" - // transactionKey is used as a key to store the Sentry transaction on the echo.Context. + // transactionKey is used as a key to store the Sentry transaction on the *echo.Context. transactionKey = "sentry_transaction" - // errorKey is used as a key to store the error on the echo.Context. + // errorKey is used as a key to store the error on the *echo.Context. errorKey = "error" ) @@ -57,7 +58,7 @@ func New(options Options) echo.MiddlewareFunc { } func (h *handler) handle(next echo.HandlerFunc) echo.HandlerFunc { - return func(ctx echo.Context) error { + return func(ctx *echo.Context) error { hub := GetHubFromContext(ctx) if hub == nil { hub = sentry.CurrentHub().Clone() @@ -93,15 +94,22 @@ func (h *handler) handle(next echo.HandlerFunc) echo.HandlerFunc { transaction.SetData("http.request.method", r.Method) defer func() { - status := ctx.Response().Status + var status int + if resp, err := echo.UnwrapResponse(ctx.Response()); err == nil && resp.Status != 0 { + status = resp.Status + } if err := ctx.Get(errorKey); err != nil { - if httpError, ok := err.(*echo.HTTPError); ok { - status = httpError.Code + if coder, ok := err.(echo.HTTPStatusCoder); ok { + status = coder.StatusCode() } } - transaction.Status = sentry.HTTPtoSpanStatus(status) - transaction.SetData("http.response.status_code", status) + if status == 0 { + debuglog.Printf("sentryecho: unable to determine HTTP response status code") + } else { + transaction.Status = sentry.HTTPtoSpanStatus(status) + transaction.SetData("http.response.status_code", status) + } transaction.Finish() }() @@ -135,22 +143,22 @@ func (h *handler) recoverWithSentry(hub *sentry.Hub, r *http.Request) { } } -// GetHubFromContext retrieves attached *sentry.Hub instance from echo.Context. -func GetHubFromContext(ctx echo.Context) *sentry.Hub { +// GetHubFromContext retrieves attached *sentry.Hub instance from *echo.Context. +func GetHubFromContext(ctx *echo.Context) *sentry.Hub { if hub, ok := ctx.Get(valuesKey).(*sentry.Hub); ok { return hub } return nil } -// SetHubOnContext attaches *sentry.Hub instance to echo.Context. -func SetHubOnContext(ctx echo.Context, hub *sentry.Hub) { +// SetHubOnContext attaches *sentry.Hub instance to *echo.Context. +func SetHubOnContext(ctx *echo.Context, hub *sentry.Hub) { ctx.Set(valuesKey, hub) } -// GetSpanFromContext retrieves attached *sentry.Span instance from echo.Context. -// If there is no transaction on echo.Context, it will return nil. -func GetSpanFromContext(ctx echo.Context) *sentry.Span { +// GetSpanFromContext retrieves attached *sentry.Span instance from *echo.Context. +// If there is no transaction on *echo.Context, it will return nil. +func GetSpanFromContext(ctx *echo.Context) *sentry.Span { if span, ok := ctx.Get(transactionKey).(*sentry.Span); ok { return span } diff --git a/echo/sentryecho_test.go b/echo/sentryecho_test.go index 0c60cc38e..42a1782f2 100644 --- a/echo/sentryecho_test.go +++ b/echo/sentryecho_test.go @@ -15,7 +15,7 @@ import ( "github.com/getsentry/sentry-go/internal/testutils" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/labstack/echo/v4" + "github.com/labstack/echo/v5" ) func TestIntegration(t *testing.T) { @@ -37,7 +37,7 @@ func TestIntegration(t *testing.T) { RoutePath: "/panic/:id", Method: "GET", WantStatus: 200, - Handler: func(c echo.Context) error { + Handler: func(c *echo.Context) error { panic("test") }, WantEvent: &sentry.Event{ @@ -114,7 +114,7 @@ func TestIntegration(t *testing.T) { Method: "POST", WantStatus: 200, Body: "payload", - Handler: func(c echo.Context) error { + Handler: func(c *echo.Context) error { hub := sentryecho.GetHubFromContext(c) body, err := io.ReadAll(c.Request().Body) if err != nil { @@ -168,7 +168,7 @@ func TestIntegration(t *testing.T) { RoutePath: "/get", Method: "GET", WantStatus: 200, - Handler: func(c echo.Context) error { + Handler: func(c *echo.Context) error { hub := sentryecho.GetHubFromContext(c) hub.CaptureMessage("get") return c.JSON(http.StatusOK, map[string]string{"status": "get"}) @@ -215,7 +215,7 @@ func TestIntegration(t *testing.T) { Method: "POST", WantStatus: 200, Body: largePayload, - Handler: func(c echo.Context) error { + Handler: func(c *echo.Context) error { hub := sentryecho.GetHubFromContext(c) body, err := io.ReadAll(c.Request().Body) if err != nil { @@ -270,7 +270,7 @@ func TestIntegration(t *testing.T) { Method: "POST", WantStatus: 200, Body: "client sends, server ignores, SDK doesn't read", - Handler: func(c echo.Context) error { + Handler: func(c *echo.Context) error { hub := sentryecho.GetHubFromContext(c) hub.CaptureMessage("body ignored") return nil @@ -322,7 +322,7 @@ func TestIntegration(t *testing.T) { RoutePath: "/badreq", Method: "GET", WantStatus: 400, - Handler: func(c echo.Context) error { + Handler: func(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"status": "bad_request"}) }, WantTransaction: &sentry.Event{ @@ -376,6 +376,9 @@ func TestIntegration(t *testing.T) { router.Use(sentryecho.New(sentryecho.Options{})) for _, tt := range tests { + if tt.Handler == nil { + continue // no route to register (e.g. 404 case: path /404/1 must not exist) + } switch tt.Method { case http.MethodGet: router.GET(tt.RoutePath, tt.Handler) @@ -499,7 +502,7 @@ func TestSetHubOnContext(t *testing.T) { hub := sentry.CurrentHub().Clone() router := echo.New() - router.GET("/set-hub", func(c echo.Context) error { + router.GET("/set-hub", func(c *echo.Context) error { sentryecho.SetHubOnContext(c, hub) retrievedHub := sentryecho.GetHubFromContext(c) if retrievedHub == nil { @@ -544,14 +547,14 @@ func TestGetSpanFromContext(t *testing.T) { } router := echo.New() - router.GET("/no-span", func(c echo.Context) error { + router.GET("/no-span", func(c *echo.Context) error { span := sentryecho.GetSpanFromContext(c) if span != nil { t.Error("expecting span to be nil") } return c.NoContent(http.StatusOK) }) - router.GET("/with-span", func(c echo.Context) error { + router.GET("/with-span", func(c *echo.Context) error { span := sentryecho.GetSpanFromContext(c) if span == nil { t.Error("expecting span to not be nil") @@ -598,3 +601,47 @@ func TestGetSpanFromContext(t *testing.T) { } } } + +func TestUnwrapResponseError(t *testing.T) { + ch := make(chan *sentry.Event, 1) + if err := sentry.Init(sentry.ClientOptions{ + EnableTracing: true, + TracesSampleRate: 1.0, + BeforeSendTransaction: func(e *sentry.Event, _ *sentry.EventHint) *sentry.Event { + ch <- e + return e + }, + }); err != nil { + t.Fatal(err) + } + + router := echo.New() + router.Use(sentryecho.New(sentryecho.Options{})) + // ResponseWriter that does not implement Unwrap(), so echo.UnwrapResponse() returns an error. + router.GET("/unwrap-err", func(c *echo.Context) error { + c.SetResponse(&struct{ http.ResponseWriter }{c.Response()}) + return c.JSON(http.StatusOK, "ok") + }) + + srv := httptest.NewServer(router) + defer srv.Close() + + res, err := srv.Client().Get(srv.URL + "/unwrap-err") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", res.StatusCode) + } + + if !sentry.Flush(testutils.FlushTimeout()) { + t.Fatal("Flush timed out") + } + tx := <-ch + + data := tx.Contexts["trace"]["data"].(map[string]any) + if _, ok := data["http.response.status_code"]; ok { + t.Errorf("when UnwrapResponse fails, expected no http.response.status_code to be set") + } +}