diff --git a/CHANGELOG.md b/CHANGELOG.md index c03277717..c21aa40c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ ### Features -- accept `interface{}` for span data values ([#784](https://github.com/getsentry/sentry-go/pull/784)) +- Accept `interface{}` for span data values ([#784](https://github.com/getsentry/sentry-go/pull/784)) +- Automatic transactions for Echo integration ([#722](https://github.com/getsentry/sentry-go/pull/722)) ## 0.27.0 diff --git a/_examples/echo/main.go b/_examples/echo/main.go index 5d971f3b4..77de46888 100644 --- a/_examples/echo/main.go +++ b/_examples/echo/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "net/http" @@ -52,6 +53,24 @@ func main() { hub.CaptureMessage("User provided unwanted query string, but we recovered just fine") }) } + + expensiveThing := func(ctx context.Context) error { + span := sentry.StartTransaction(ctx, "expensive_thing") + defer span.Finish() + // do resource intensive thing + return nil + } + + // Acquire transaction on current hub that's created by the SDK. + // Be careful, it might be a nil value if you didn't set up sentryecho middleware. + sentrySpan := sentryecho.GetSpanFromContext(ctx) + // Pass in the `.Context()` method from `*sentry.Span` struct. + // The `context.Context` instance inherits the context from `echo.Context`. + err := expensiveThing(sentrySpan.Context()) + if err != nil { + return err + } + return ctx.String(http.StatusOK, "Hello, World!") }) diff --git a/echo/example_test.go b/echo/example_test.go new file mode 100644 index 000000000..e5a94c200 --- /dev/null +++ b/echo/example_test.go @@ -0,0 +1,35 @@ +package sentryecho_test + +import ( + "context" + "net/http" + + "github.com/getsentry/sentry-go" + sentryecho "github.com/getsentry/sentry-go/echo" + "github.com/labstack/echo/v4" +) + +func ExampleGetSpanFromContext() { + router := echo.New() + router.Use(sentryecho.New(sentryecho.Options{})) + router.GET("/", func(c echo.Context) error { + expensiveThing := func(ctx context.Context) error { + span := sentry.StartTransaction(ctx, "expensive_thing") + defer span.Finish() + // do resource intensive thing + return nil + } + + // Acquire transaction on current hub that's created by the SDK. + // Be careful, it might be a nil value if you didn't set up sentryecho middleware. + sentrySpan := sentryecho.GetSpanFromContext(c) + // Pass in the `.Context()` method from `*sentry.Span` struct. + // The `context.Context` instance inherits the context from `echo.Context`. + err := expensiveThing(sentrySpan.Context()) + if err != nil { + return err + } + + return c.NoContent(http.StatusOK) + }) +} diff --git a/echo/sentryecho.go b/echo/sentryecho.go index 46fe96526..54691d79e 100644 --- a/echo/sentryecho.go +++ b/echo/sentryecho.go @@ -2,6 +2,7 @@ package sentryecho import ( "context" + "fmt" "net/http" "time" @@ -13,6 +14,7 @@ import ( const sdkIdentifier = "sentry.go.echo" const valuesKey = "sentry" +const transactionKey = "sentry_transaction" type handler struct { repanic bool @@ -22,7 +24,7 @@ type handler struct { type Options struct { // Repanic configures whether Sentry should repanic after recovery, in most cases it should be set to true, - // as echo includes it's own Recover middleware what handles http responses. + // as echo includes its own Recover middleware what handles http responses. Repanic bool // WaitForDelivery configures whether you want to block the request before moving forward with the response. // Because Echo's Recover handler doesn't restart the application, @@ -57,10 +59,51 @@ func (h *handler) handle(next echo.HandlerFunc) echo.HandlerFunc { client.SetSDKIdentifier(sdkIdentifier) } - hub.Scope().SetRequest(ctx.Request()) + r := ctx.Request() + + transactionName := r.URL.Path + transactionSource := sentry.SourceURL + + if path := ctx.Path(); path != "" { + transactionName = path + transactionSource = sentry.SourceRoute + } + + options := []sentry.SpanOption{ + sentry.WithOpName("http.server"), + sentry.ContinueFromRequest(r), + sentry.WithTransactionSource(transactionSource), + } + + transaction := sentry.StartTransaction( + sentry.SetHubOnContext(r.Context(), hub), + fmt.Sprintf("%s %s", r.Method, transactionName), + options..., + ) + + defer func() { + if err := ctx.Get("error"); err != nil { + if httpError, ok := err.(*echo.HTTPError); ok { + transaction.Status = sentry.HTTPtoSpanStatus(httpError.Code) + } + } else { + transaction.Status = sentry.HTTPtoSpanStatus(ctx.Response().Status) + } + transaction.Finish() + }() + + hub.Scope().SetRequest(r) ctx.Set(valuesKey, hub) - defer h.recoverWithSentry(hub, ctx.Request()) - return next(ctx) + ctx.Set(transactionKey, transaction) + defer h.recoverWithSentry(hub, r) + + err := next(ctx) + if err != nil { + // Store the error so it can be used in the deferred function + ctx.Set("error", err) + } + + return err } } @@ -86,3 +129,12 @@ func GetHubFromContext(ctx echo.Context) *sentry.Hub { } return nil } + +// 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 + } + return nil +} diff --git a/echo/sentryecho_test.go b/echo/sentryecho_test.go index 3a99aa660..402c1ca44 100644 --- a/echo/sentryecho_test.go +++ b/echo/sentryecho_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -28,7 +29,8 @@ func TestIntegration(t *testing.T) { Body string Handler echo.HandlerFunc - WantEvent *sentry.Event + WantEvent *sentry.Event + WantTransaction *sentry.Event }{ { RequestPath: "/panic/1", @@ -50,6 +52,20 @@ func TestIntegration(t *testing.T) { }, }, }, + WantTransaction: &sentry.Event{ + Level: sentry.LevelInfo, + Type: "transaction", + Transaction: "GET /panic/:id", + Request: &sentry.Request{ + URL: "/panic/1", + Method: "GET", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "User-Agent": "Go-http-client/1.1", + }, + }, + TransactionInfo: &sentry.TransactionInfo{Source: "route"}, + }, }, { RequestPath: "/404/1", @@ -58,6 +74,20 @@ func TestIntegration(t *testing.T) { WantStatus: 404, Handler: nil, WantEvent: nil, + WantTransaction: &sentry.Event{ + Level: sentry.LevelInfo, + Type: "transaction", + Transaction: "GET /404/1", + Request: &sentry.Request{ + URL: "/404/1", + Method: "GET", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "User-Agent": "Go-http-client/1.1", + }, + }, + TransactionInfo: &sentry.TransactionInfo{Source: "route"}, + }, }, { RequestPath: "/post", @@ -88,6 +118,22 @@ func TestIntegration(t *testing.T) { }, }, }, + WantTransaction: &sentry.Event{ + Level: sentry.LevelInfo, + Type: "transaction", + Transaction: "POST /post", + Request: &sentry.Request{ + URL: "/post", + Method: "POST", + Data: "payload", + Headers: map[string]string{ + "Content-Length": "7", + "Accept-Encoding": "gzip", + "User-Agent": "Go-http-client/1.1", + }, + }, + TransactionInfo: &sentry.TransactionInfo{Source: "route"}, + }, }, { RequestPath: "/get", @@ -111,6 +157,20 @@ func TestIntegration(t *testing.T) { }, }, }, + WantTransaction: &sentry.Event{ + Level: sentry.LevelInfo, + Type: "transaction", + Transaction: "GET /get", + Request: &sentry.Request{ + URL: "/get", + Method: "GET", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "User-Agent": "Go-http-client/1.1", + }, + }, + TransactionInfo: &sentry.TransactionInfo{Source: "route"}, + }, }, { RequestPath: "/post/large", @@ -142,6 +202,21 @@ func TestIntegration(t *testing.T) { }, }, }, + WantTransaction: &sentry.Event{ + Level: sentry.LevelInfo, + Type: "transaction", + Transaction: "POST /post/large", + Request: &sentry.Request{ + URL: "/post/large", + Method: "POST", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "Content-Length": strconv.Itoa(len(largePayload)), + "User-Agent": "Go-http-client/1.1", + }, + }, + TransactionInfo: &sentry.TransactionInfo{Source: "route"}, + }, }, { RequestPath: "/post/body-ignored", @@ -169,6 +244,23 @@ func TestIntegration(t *testing.T) { }, }, }, + WantTransaction: &sentry.Event{ + Level: sentry.LevelInfo, + Type: "transaction", + Transaction: "POST /post/body-ignored", + Request: &sentry.Request{ + URL: "/post/body-ignored", + Method: "POST", + // Actual request body omitted because not read. + Data: "", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "Content-Length": strconv.Itoa(len("client sends, server ignores, SDK doesn't read")), + "User-Agent": "Go-http-client/1.1", + }, + }, + TransactionInfo: &sentry.TransactionInfo{Source: "route"}, + }, }, { RequestPath: "/badreq", @@ -178,11 +270,27 @@ func TestIntegration(t *testing.T) { Handler: func(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"status": "bad_request"}) }, + WantTransaction: &sentry.Event{ + Level: sentry.LevelInfo, + Type: "transaction", + Transaction: "GET /badreq", + Request: &sentry.Request{ + URL: "/badreq", + Method: "GET", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "User-Agent": "Go-http-client/1.1", + }, + }, + TransactionInfo: &sentry.TransactionInfo{Source: "route"}, + }, WantEvent: nil, }, } eventsCh := make(chan *sentry.Event, len(tests)) + transactionsCh := make(chan *sentry.Event, len(tests)) + err := sentry.Init(sentry.ClientOptions{ EnableTracing: true, TracesSampleRate: 1.0, @@ -190,6 +298,10 @@ func TestIntegration(t *testing.T) { eventsCh <- event return event }, + BeforeSendTransaction: func(tx *sentry.Event, hint *sentry.EventHint) *sentry.Event { + transactionsCh <- tx + return tx + }, }) if err != nil { t.Fatal(err) @@ -214,6 +326,9 @@ func TestIntegration(t *testing.T) { c.Timeout = time.Second var want []*sentry.Event + var wantTrans []*sentry.Event + var wantCodes []sentry.SpanStatus + for _, tt := range tests { if tt.WantEvent != nil && tt.WantEvent.Request != nil { wantRequest := tt.WantEvent.Request @@ -222,6 +337,12 @@ func TestIntegration(t *testing.T) { want = append(want, tt.WantEvent) } + wantTransaction := tt.WantTransaction.Request + wantTransaction.URL = srv.URL + wantTransaction.URL + wantTransaction.Headers["Host"] = srv.Listener.Addr().String() + wantTrans = append(wantTrans, tt.WantTransaction) + wantCodes = append(wantCodes, sentry.HTTPtoSpanStatus(tt.WantStatus)) + req, err := http.NewRequest(tt.Method, srv.URL+tt.RequestPath, strings.NewReader(tt.Body)) if err != nil { t.Fatal(err) @@ -262,4 +383,97 @@ func TestIntegration(t *testing.T) { if diff := cmp.Diff(want, got, opts); diff != "" { t.Fatalf("Events mismatch (-want +got):\n%s", diff) } + + close(transactionsCh) + var gott []*sentry.Event + var statusCodes []sentry.SpanStatus + for e := range transactionsCh { + gott = append(gott, e) + statusCodes = append(statusCodes, e.Contexts["trace"]["status"].(sentry.SpanStatus)) + } + + optstrans := cmp.Options{ + cmpopts.IgnoreFields( + sentry.Event{}, + "Contexts", "EventID", "Platform", "Modules", + "Release", "Sdk", "ServerName", "Timestamp", + "sdkMetaData", "StartTime", "Spans", + ), + cmpopts.IgnoreFields( + sentry.Request{}, + "Env", + ), + } + if diff := cmp.Diff(wantTrans, gott, optstrans); diff != "" { + t.Fatalf("Transaction mismatch (-want +got):\n%s", diff) + } + + if diff := cmp.Diff(wantCodes, statusCodes, cmp.Options{}); diff != "" { + t.Fatalf("Transaction status codes mismatch (-want +got):\n%s", diff) + } +} + +func TestGetTransactionFromContext(t *testing.T) { + err := sentry.Init(sentry.ClientOptions{ + EnableTracing: true, + TracesSampleRate: 1.0, + }) + if err != nil { + t.Fatal(err) + } + + router := echo.New() + router.GET("/no-transaction", func(c echo.Context) error { + transaction := sentryecho.GetSpanFromContext(c) + if transaction != nil { + t.Error("expecting transaction to be nil") + } + return c.NoContent(http.StatusOK) + }) + router.GET("/with-transaction", func(c echo.Context) error { + transaction := sentryecho.GetSpanFromContext(c) + if transaction == nil { + t.Error("expecting transaction to be not nil") + } + return c.NoContent(http.StatusOK) + }, sentryecho.New(sentryecho.Options{})) + + srv := httptest.NewServer(router) + defer srv.Close() + + c := srv.Client() + + tests := []struct { + RequestPath string + }{ + { + RequestPath: "/no-transaction", + }, + { + RequestPath: "/with-transaction", + }, + } + c.Timeout = time.Second + + for _, tt := range tests { + req, err := http.NewRequest("GET", srv.URL+tt.RequestPath, nil) + if err != nil { + t.Fatal(err) + } + res, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != 200 { + t.Errorf("Status code = %d expected: %d", res.StatusCode, 200) + } + err = res.Body.Close() + if err != nil { + t.Fatal(err) + } + + if ok := sentry.Flush(testutils.FlushTimeout()); !ok { + t.Fatal("sentry.Flush timed out") + } + } }