diff --git a/gin/sentrygin.go b/gin/sentrygin.go index cd7e9f42d..98803cf7c 100644 --- a/gin/sentrygin.go +++ b/gin/sentrygin.go @@ -2,6 +2,7 @@ package sentrygin import ( "context" + "fmt" "net" "net/http" "os" @@ -46,15 +47,33 @@ func New(options Options) gin.HandlerFunc { }).handle } -func (h *handler) handle(ctx *gin.Context) { - hub := sentry.GetHubFromContext(ctx.Request.Context()) +func (h *handler) handle(c *gin.Context) { + ctx := c.Request.Context() + hub := sentry.GetHubFromContext(ctx) if hub == nil { hub = sentry.CurrentHub().Clone() + ctx = sentry.SetHubOnContext(ctx, hub) } - hub.Scope().SetRequest(ctx.Request) - ctx.Set(valuesKey, hub) - defer h.recoverWithSentry(hub, ctx.Request) - ctx.Next() + options := []sentry.SpanOption{ + sentry.WithOpName("http.server"), + sentry.ContinueFromRequest(c.Request), + sentry.WithTransactionSource(sentry.SourceURL), + } + + transaction := sentry.StartTransaction(ctx, + fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path), + options..., + ) + defer func() { + transaction.Status = sentry.HTTPtoSpanStatus(c.Writer.Status()) + transaction.Finish() + }() + + c.Request = c.Request.WithContext(transaction.Context()) + hub.Scope().SetRequest(c.Request) + c.Set(valuesKey, hub) + defer h.recoverWithSentry(hub, c.Request) + c.Next() } func (h *handler) recoverWithSentry(hub *sentry.Hub, r *http.Request) { diff --git a/gin/sentrygin_test.go b/gin/sentrygin_test.go new file mode 100644 index 000000000..1d493cb0a --- /dev/null +++ b/gin/sentrygin_test.go @@ -0,0 +1,371 @@ +package sentrygin_test + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/getsentry/sentry-go" + sentrygin "github.com/getsentry/sentry-go/gin" + "github.com/gin-gonic/gin" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestIntegration(t *testing.T) { + largePayload := strings.Repeat("Large", 3*1024) // 15 KB + + tests := []struct { + Path string + Method string + WantStatus int + Body string + Handler gin.HandlerFunc + + WantEvent *sentry.Event + WantTransaction *sentry.Event + }{ + { + Path: "/panic", + Method: "GET", + WantStatus: 200, + Handler: func(c *gin.Context) { + panic("test") + }, + WantTransaction: &sentry.Event{ + Level: sentry.LevelInfo, + Type: "transaction", + Transaction: "GET /panic", + Request: &sentry.Request{ + URL: "/panic", + Method: "GET", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "User-Agent": "Go-http-client/1.1", + }, + }, + TransactionInfo: &sentry.TransactionInfo{Source: "url"}, + }, + WantEvent: &sentry.Event{ + Level: sentry.LevelFatal, + Message: "test", + Request: &sentry.Request{ + URL: "/panic", + Method: "GET", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "User-Agent": "Go-http-client/1.1", + }, + }, + }, + }, + { + Path: "/post", + Method: "POST", + WantStatus: 200, + Body: "payload", + Handler: func(c *gin.Context) { + hub := sentry.GetHubFromContext(c.Request.Context()) + body, err := io.ReadAll(c.Request.Body) + if err != nil { + t.Error(err) + } + hub.CaptureMessage("post: " + string(body)) + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }, + 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: "url"}, + }, + WantEvent: &sentry.Event{ + Level: sentry.LevelInfo, + Message: "post: payload", + Request: &sentry.Request{ + URL: "/post", + Method: "POST", + Data: "payload", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "Content-Length": "7", + "User-Agent": "Go-http-client/1.1", + }, + }, + }, + }, + { + Path: "/get", + Method: "GET", + WantStatus: 200, + Handler: func(c *gin.Context) { + hub := sentry.GetHubFromContext(c.Request.Context()) + hub.CaptureMessage("get") + c.JSON(http.StatusOK, gin.H{"status": "get"}) + }, + 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: "url"}, + }, + WantEvent: &sentry.Event{ + Level: sentry.LevelInfo, + Message: "get", + Request: &sentry.Request{ + URL: "/get", + Method: "GET", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "User-Agent": "Go-http-client/1.1", + }, + }, + }, + }, + { + Path: "/post/large", + Method: "POST", + WantStatus: 200, + Body: largePayload, + Handler: func(c *gin.Context) { + hub := sentry.GetHubFromContext(c.Request.Context()) + body, err := io.ReadAll(c.Request.Body) + if err != nil { + t.Error(err) + } + hub.CaptureMessage(fmt.Sprintf("post: %d KB", len(body)/1024)) + }, + 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: "url"}, + }, + WantEvent: &sentry.Event{ + Level: sentry.LevelInfo, + Message: "post: 15 KB", + Request: &sentry.Request{ + URL: "/post/large", + Method: "POST", + // Actual request body omitted because too large. + Data: "", + Headers: map[string]string{ + "Accept-Encoding": "gzip", + "Content-Length": "15360", + "User-Agent": "Go-http-client/1.1", + }, + }, + }, + }, + { + Path: "/post/body-ignored", + Method: "POST", + WantStatus: 200, + Body: "client sends, server ignores, SDK doesn't read", + Handler: func(c *gin.Context) { + hub := sentry.GetHubFromContext(c.Request.Context()) + hub.CaptureMessage("body ignored") + }, + 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: "url"}, + }, + WantEvent: &sentry.Event{ + Level: sentry.LevelInfo, + Message: "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": "46", + "User-Agent": "Go-http-client/1.1", + }, + }, + }, + }, + { + Path: "/badreq", + Method: "GET", + WantStatus: 400, + Handler: func(c *gin.Context) { + c.JSON(http.StatusBadRequest, gin.H{"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: "url"}, + }, + 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, + BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + eventsCh <- event + return event + }, + BeforeSendTransaction: func(tx *sentry.Event, hint *sentry.EventHint) *sentry.Event { + transactionsCh <- tx + return tx + }, + }) + if err != nil { + t.Fatal(err) + } + + router := gin.New() + router.Use(sentrygin.New(sentrygin.Options{})) + + for _, tt := range tests { + router.Handle(tt.Method, tt.Path, tt.Handler) + } + + srv := httptest.NewServer(router) + defer srv.Close() + + c := srv.Client() + 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 + wantRequest.URL = srv.URL + wantRequest.URL + wantRequest.Headers["Host"] = srv.Listener.Addr().String() + 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.Path, strings.NewReader(tt.Body)) + if err != nil { + t.Fatal(err) + } + res, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + if res.StatusCode != tt.WantStatus { + t.Errorf("Status code = %d expected: %d", res.StatusCode, tt.WantStatus) + } + res.Body.Close() + } + + if ok := sentry.Flush(time.Second); !ok { + t.Fatal("sentry.Flush timed out") + } + close(eventsCh) + var got []*sentry.Event + for e := range eventsCh { + got = append(got, e) + } + opts := cmp.Options{ + cmpopts.IgnoreFields( + sentry.Event{}, + "Contexts", "EventID", "Extra", "Platform", "Modules", + "Release", "Sdk", "ServerName", "Tags", "Timestamp", + "sdkMetaData", + ), + cmpopts.IgnoreFields( + sentry.Request{}, + "Env", + ), + } + 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) + } +} diff --git a/tracing.go b/tracing.go index 4c6747353..41d7a4316 100644 --- a/tracing.go +++ b/tracing.go @@ -971,3 +971,41 @@ func StartTransaction(ctx context.Context, name string, options ...SpanOption) * options..., ) } + +// HTTPtoSpanStatus converts an HTTP status code to a SpanStatus. +func HTTPtoSpanStatus(code int) SpanStatus { + if code < http.StatusBadRequest { + return SpanStatusOK + } + if http.StatusBadRequest <= code && code < http.StatusInternalServerError { + switch code { + case http.StatusForbidden: + return SpanStatusPermissionDenied + case http.StatusNotFound: + return SpanStatusNotFound + case http.StatusTooManyRequests: + return SpanStatusResourceExhausted + case http.StatusRequestEntityTooLarge: + return SpanStatusFailedPrecondition + case http.StatusUnauthorized: + return SpanStatusUnauthenticated + case http.StatusConflict: + return SpanStatusAlreadyExists + default: + return SpanStatusInvalidArgument + } + } + if http.StatusInternalServerError <= code && code < 600 { + switch code { + case http.StatusGatewayTimeout: + return SpanStatusDeadlineExceeded + case http.StatusNotImplemented: + return SpanStatusUnimplemented + case http.StatusServiceUnavailable: + return SpanStatusUnavailable + default: + return SpanStatusInternalError + } + } + return SpanStatusUnknown +}