From 123c12144ed46699dbc9e33a10a17af77207bd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Ribi=C4=87?= Date: Wed, 25 Dec 2024 01:33:51 +0100 Subject: [PATCH 01/39] initial grpc work --- logrus/README.md | 1 - sentry.go | 3 +++ tracing.go | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/logrus/README.md b/logrus/README.md index b4444530d..961171a3a 100644 --- a/logrus/README.md +++ b/logrus/README.md @@ -88,4 +88,3 @@ sentryHook.AddTags(map[string]string{ ## Notes - Always call Flush to ensure all events are sent to Sentry before program termination - diff --git a/sentry.go b/sentry.go index 49c172318..afa1a2d8a 100644 --- a/sentry.go +++ b/sentry.go @@ -12,6 +12,9 @@ const SDKVersion = "0.30.0" // sentry-go SDK. const apiVersion = "7" +// DefaultFlushTimeout is the default timeout used for flushing events. +const DefaultFlushTimeout = 2 * time.Second + // Init initializes the SDK with options. The returned error is non-nil if // options is invalid, for instance if a malformed DSN is provided. func Init(options ClientOptions) error { diff --git a/tracing.go b/tracing.go index 0c5877c59..7fe798175 100644 --- a/tracing.go +++ b/tracing.go @@ -30,6 +30,7 @@ const ( SpanOriginStdLib = "auto.http.stdlib" SpanOriginIris = "auto.http.iris" SpanOriginNegroni = "auto.http.negroni" + SpanOriginGrpc = "auto.http.grpc" ) // A Span is the building block of a Sentry transaction. Spans build up a tree From 0712d4cf2f9c70194f1c6d46fb8689051c622ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Ribi=C4=87?= Date: Wed, 25 Dec 2024 01:33:59 +0100 Subject: [PATCH 02/39] initial grpc work --- grpc/README.MD | 164 ++++++++++++++++++++++++++++ grpc/client.go | 120 +++++++++++++++++++++ grpc/go.mod | 18 ++++ grpc/go.sum | 48 +++++++++ grpc/server.go | 286 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 636 insertions(+) create mode 100644 grpc/README.MD create mode 100644 grpc/client.go create mode 100644 grpc/go.mod create mode 100644 grpc/go.sum create mode 100644 grpc/server.go diff --git a/grpc/README.MD b/grpc/README.MD new file mode 100644 index 000000000..a4598b1da --- /dev/null +++ b/grpc/README.MD @@ -0,0 +1,164 @@ +

+ + + +
+

+ +# Official Sentry gRPC Interceptor for Sentry-go SDK + +**go.dev:** [https://pkg.go.dev/github.com/getsentry/sentry-go/grpc](https://pkg.go.dev/github.com/getsentry/sentry-go/grpc) + +**Example:** https://github.com/getsentry/sentry-go/tree/master/_examples/grpc + + +## Installation + +```sh +go get github.com/getsentry/sentry-go/grpc +``` + +## Server-Side Usage + +```go +import ( + "fmt" + "net" + + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" + + "github.com/getsentry/sentry-go" + sentrygrpc "github.com/getsentry/sentry-go/grpc" +) + +func main() { + // Initialize Sentry + if err := sentry.Init(sentry.ClientOptions{ + Dsn: "your-public-dsn", + }); err != nil { + fmt.Printf("Sentry initialization failed: %v\n", err) + } + + // Create gRPC server with Sentry interceptors + server := grpc.NewServer( + sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{ + Repanic: true, + WaitForDelivery: true, + }), + sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{ + Repanic: true, + WaitForDelivery: true, + }), + ) + + // Register reflection for debugging + reflection.Register(server) + + // Start the server + listener, err := net.Listen("tcp", ":50051") + if err != nil { + sentry.CaptureException(err) + fmt.Printf("Failed to listen: %v\n", err) + return + } + + fmt.Println("Server running...") + if err := server.Serve(listener); err != nil { + sentry.CaptureException(err) + } +} +``` + + +## Client-Side Usage + +```go +import ( + "context" + "fmt" + + "google.golang.org/grpc" + + "github.com/getsentry/sentry-go" + sentrygrpc "github.com/getsentry/sentry-go/grpc" +) + +func main() { + // Initialize Sentry + if err := sentry.Init(sentry.ClientOptions{ + Dsn: "your-public-dsn", + }); err != nil { + fmt.Printf("Sentry initialization failed: %v\n", err) + } + + // Create gRPC client with Sentry interceptors + conn, err := grpc.Dial( + "localhost:50051", + grpc.WithInsecure(), + grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor(sentrygrpc.ClientOptions{})), + grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor(sentrygrpc.ClientOptions{})), + ) + if err != nil { + sentry.CaptureException(err) + fmt.Printf("Failed to connect: %v\n", err) + return + } + defer conn.Close() + + client := NewYourServiceClient(conn) + + // Make a request + _, err = client.YourMethod(context.Background(), &YourRequest{}) + if err != nil { + sentry.CaptureException(err) + fmt.Printf("Error calling method: %v\n", err) + } +} +``` + +## Configuration + +Both the server and client interceptors accept options for customization: + +### Server Options + +```go +type ServerOptions struct { + // Repanic determines whether the application should re-panic after recovery. + Repanic bool + + // WaitForDelivery determines if the interceptor should block until events are sent to Sentry. + WaitForDelivery bool + + // Timeout sets the maximum duration for Sentry event delivery. + Timeout time.Duration + + // ReportOn defines the conditions under which errors are reported to Sentry. + ReportOn func(error) bool + + // CaptureRequestBody determines whether to capture and send request bodies to Sentry. + CaptureRequestBody bool + + // OperationName overrides the default operation name (grpc.server). + OperationName string +} +``` + +### Client Options + +```go +type ClientOptions struct { + // ReportOn defines the conditions under which errors are reported to Sentry. + ReportOn func(error) bool + + // OperationName overrides the default operation name (grpc.client). + OperationName string +} +``` + +## Notes + +- The interceptors automatically create and manage a Sentry *Hub for each gRPC request or stream. +- Use the Sentry SDK’s context-based APIs to capture exceptions and add additional context. +- Ensure you handle the context correctly to propagate tracing information across requests. diff --git a/grpc/client.go b/grpc/client.go new file mode 100644 index 000000000..e16e4ee63 --- /dev/null +++ b/grpc/client.go @@ -0,0 +1,120 @@ +package sentrygrpc + +import ( + "context" + + "github.com/getsentry/sentry-go" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +const defaultClientOperationName = "grpc.client" + +type ClientOptions struct { + // ReportOn defines the conditions under which errors are reported to Sentry. + ReportOn func(error) bool + + // OperationName overrides the default operation name (grpc.client). + OperationName string +} + +func (o *ClientOptions) SetDefaults() { + if o.ReportOn == nil { + o.ReportOn = func(err error) bool { + return true + } + } + if o.OperationName == "" { + o.OperationName = defaultClientOperationName + } +} + +func createOrUpdateMetadata(ctx context.Context, span *sentry.Span) context.Context { + md, ok := metadata.FromOutgoingContext(ctx) + if ok { + md = md.Copy() + md.Append(sentry.SentryTraceHeader, span.ToSentryTrace()) + md.Append(sentry.SentryBaggageHeader, span.ToBaggage()) + } else { + md = metadata.Pairs( + sentry.SentryTraceHeader, span.ToSentryTrace(), + sentry.SentryBaggageHeader, span.ToBaggage(), + ) + } + return metadata.NewOutgoingContext(ctx, md) +} + +func getOrCreateHub(ctx context.Context) (*sentry.Hub, context.Context) { + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = sentry.CurrentHub().Clone() + ctx = sentry.SetHubOnContext(ctx, hub) + } + return hub, ctx +} + +func UnaryClientInterceptor(o ClientOptions) grpc.UnaryClientInterceptor { + o.SetDefaults() + return func(ctx context.Context, + method string, + req, reply interface{}, + cc *grpc.ClientConn, + invoker grpc.UnaryInvoker, + callOpts ...grpc.CallOption) error { + + hub, ctx := getOrCreateHub(ctx) + + span := sentry.StartSpan(ctx, o.OperationName, sentry.WithDescription(method)) + span.SetData("grpc.request.method", method) + ctx = span.Context() + + ctx = createOrUpdateMetadata(ctx, span) + defer span.Finish() + + err := invoker(ctx, method, req, reply, cc, callOpts...) + + if err != nil && o.ReportOn(err) { + hub.WithScope(func(scope *sentry.Scope) { + scope.SetTag("grpc.method", method) + scope.SetContext("request", map[string]any{ + "method": method, + "request": req, + }) + hub.CaptureException(err) + }) + } + + return err + } +} + +func StreamClientInterceptor(o ClientOptions) grpc.StreamClientInterceptor { + o.SetDefaults() + return func(ctx context.Context, + desc *grpc.StreamDesc, + cc *grpc.ClientConn, + method string, + streamer grpc.Streamer, + callOpts ...grpc.CallOption) (grpc.ClientStream, error) { + + hub, ctx := getOrCreateHub(ctx) + + span := sentry.StartSpan(ctx, o.OperationName, sentry.WithDescription(method)) + span.SetData("grpc.request.method", method) + ctx = span.Context() + + ctx = createOrUpdateMetadata(ctx, span) + defer span.Finish() + + clientStream, err := streamer(ctx, desc, cc, method, callOpts...) + + if err != nil && o.ReportOn(err) { + hub.WithScope(func(scope *sentry.Scope) { + scope.SetTag("grpc.method", method) + hub.CaptureException(err) + }) + } + + return clientStream, err + } +} diff --git a/grpc/go.mod b/grpc/go.mod new file mode 100644 index 000000000..72975cf25 --- /dev/null +++ b/grpc/go.mod @@ -0,0 +1,18 @@ +module github.com/getsentry/sentry-go/grpc + +go 1.22 + +replace github.com/getsentry/sentry-go => ../ + +require ( + github.com/getsentry/sentry-go v0.30.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 + google.golang.org/grpc v1.69.2 +) + +require ( + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect +) diff --git a/grpc/go.sum b/grpc/go.sum new file mode 100644 index 000000000..162aadade --- /dev/null +++ b/grpc/go.sum @@ -0,0 +1,48 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +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/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= +google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +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/grpc/server.go b/grpc/server.go new file mode 100644 index 000000000..343ce7058 --- /dev/null +++ b/grpc/server.go @@ -0,0 +1,286 @@ +package sentrygrpc + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/getsentry/sentry-go" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const ( + sdkIdentifier = "sentry.go.grpc" + defaultServerOperationName = "grpc.server" +) + +type ServerOptions struct { + // Repanic determines whether the application should re-panic after recovery. + Repanic bool + + // WaitForDelivery determines if the interceptor should block until events are sent to Sentry. + WaitForDelivery bool + + // Timeout sets the maximum duration for Sentry event delivery. + Timeout time.Duration + + // ReportOn defines the conditions under which errors are reported to Sentry. + ReportOn func(error) bool + + // CaptureRequestBody determines whether to capture and send request bodies to Sentry. + CaptureRequestBody bool + + // OperationName overrides the default operation name (grpc.server). + OperationName string +} + +func (o *ServerOptions) SetDefaults() { + if o.ReportOn == nil { + o.ReportOn = func(err error) bool { + return true + } + } + + if o.Timeout == 0 { + o.Timeout = sentry.DefaultFlushTimeout + } + + if o.OperationName == "" { + o.OperationName = defaultServerOperationName + } +} + +func recoverWithSentry(ctx context.Context, hub *sentry.Hub, o ServerOptions) { + if r := recover(); r != nil { + eventID := hub.RecoverWithContext(ctx, r) + + if eventID != nil && o.WaitForDelivery { + hub.Flush(o.Timeout) + } + + if o.Repanic { + panic(r) + } + } +} + +func reportErrorToSentry(hub *sentry.Hub, err error, methodName string, req interface{}, md map[string]string) { + hub.WithScope(func(scope *sentry.Scope) { + scope.SetExtras(map[string]any{ + "grpc.method": methodName, + "grpc.error": err.Error(), + }) + + if req != nil { + scope.SetExtra("request", req) + } + + if len(md) > 0 { + scope.SetExtra("metadata", md) + } + + defer hub.CaptureException(err) + + statusErr, ok := status.FromError(err) + if !ok { + return + } + + for _, detail := range statusErr.Details() { + debugInfo, ok := detail.(*errdetails.DebugInfo) + if !ok { + continue + } + hub.AddBreadcrumb(&sentry.Breadcrumb{ + Type: "debug", + Category: "grpc.server", + Message: debugInfo.Detail, + Data: map[string]any{"stackTrace": strings.Join(debugInfo.StackEntries, "\n")}, + Level: sentry.LevelError, + Timestamp: time.Now(), + }, nil) + } + }) +} + +func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { + opts.SetDefaults() + + return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = sentry.CurrentHub().Clone() + } + + if client := hub.Client(); client != nil { + client.SetSDKIdentifier(sdkIdentifier) + } + + md, ok := metadata.FromIncomingContext(ctx) + var sentryTraceHeader, sentryBaggageHeader string + data := make(map[string]string) + if ok { + sentryTraceHeader = getFirstHeader(md, sentry.SentryTraceHeader) + sentryBaggageHeader = getFirstHeader(md, sentry.SentryBaggageHeader) + + for k, v := range md { + data[k] = strings.Join(v, ",") + } + } + + options := []sentry.SpanOption{ + sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), + sentry.WithOpName("http.server"), + sentry.WithDescription(info.FullMethod), + sentry.WithTransactionSource(sentry.SourceURL), + } + + transaction := sentry.StartTransaction( + sentry.SetHubOnContext(ctx, hub), + fmt.Sprintf("%s %s", "UnaryServerInterceptor", info.FullMethod), + options..., + ) + + transaction.SetData("http.request.method", info.FullMethod) + + ctx = transaction.Context() + defer transaction.Finish() + + if opts.CaptureRequestBody { + // Marshal from proto.Message to bytes? Slow? + // hub.Scope().SetRequestBody(req) + } + + defer recoverWithSentry(ctx, hub, opts) + + resp, err := handler(ctx, req) + if err != nil && opts.ReportOn(err) { + reportErrorToSentry(hub, err, info.FullMethod, req, data) + + transaction.Sampled = sentry.SampledTrue + } + + statusCode := status.Code(err) + transaction.Status = toSpanStatus(statusCode) + transaction.SetData("http.response.status_code", statusCode.String()) + + return resp, err + } +} + +// StreamServerInterceptor provides Sentry integration for streaming gRPC calls. +func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { + opts.SetDefaults() + return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + ctx := ss.Context() + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = sentry.CurrentHub().Clone() + } + + if client := hub.Client(); client != nil { + client.SetSDKIdentifier(sdkIdentifier) + } + + md, ok := metadata.FromIncomingContext(ctx) + var sentryTraceHeader, sentryBaggageHeader string + data := make(map[string]string) + if ok { + sentryTraceHeader = getFirstHeader(md, sentry.SentryTraceHeader) + sentryBaggageHeader = getFirstHeader(md, sentry.SentryBaggageHeader) + + for k, v := range md { + data[k] = strings.Join(v, ",") + } + } + + options := []sentry.SpanOption{ + sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), + sentry.WithOpName("http.server"), + sentry.WithDescription(info.FullMethod), + sentry.WithTransactionSource(sentry.SourceURL), + } + + transaction := sentry.StartTransaction( + sentry.SetHubOnContext(ctx, hub), + fmt.Sprintf("%s %s", "StreamServerInterceptor", info.FullMethod), + options..., + ) + + transaction.SetData("grpc.method", info.FullMethod) + ctx = transaction.Context() + defer transaction.Finish() + + stream := wrapServerStream(ss, ctx) + + defer recoverWithSentry(ctx, hub, opts) + + err := handler(srv, stream) + if err != nil && opts.ReportOn(err) { + reportErrorToSentry(hub, err, info.FullMethod, nil, data) + + transaction.Sampled = sentry.SampledTrue + } + + statusCode := status.Code(err) + transaction.Status = toSpanStatus(statusCode) + transaction.SetData("grpc.status", statusCode.String()) + + return err + } +} + +func getFirstHeader(md metadata.MD, key string) string { + if values := md.Get(key); len(values) > 0 { + return values[0] + } + return "" +} + +// wrapServerStream wraps a grpc.ServerStream, allowing you to inject a custom context. +func wrapServerStream(ss grpc.ServerStream, ctx context.Context) grpc.ServerStream { + return &wrappedServerStream{ServerStream: ss, ctx: ctx} +} + +// wrappedServerStream is a wrapper around grpc.ServerStream that overrides the Context method. +type wrappedServerStream struct { + grpc.ServerStream + ctx context.Context +} + +// Context returns the custom context for the stream. +func (w *wrappedServerStream) Context() context.Context { + return w.ctx +} + +var codeToSpanStatus = map[codes.Code]sentry.SpanStatus{ + codes.OK: sentry.SpanStatusOK, + codes.Canceled: sentry.SpanStatusCanceled, + codes.Unknown: sentry.SpanStatusUnknown, + codes.InvalidArgument: sentry.SpanStatusInvalidArgument, + codes.DeadlineExceeded: sentry.SpanStatusDeadlineExceeded, + codes.NotFound: sentry.SpanStatusNotFound, + codes.AlreadyExists: sentry.SpanStatusAlreadyExists, + codes.PermissionDenied: sentry.SpanStatusPermissionDenied, + codes.ResourceExhausted: sentry.SpanStatusResourceExhausted, + codes.FailedPrecondition: sentry.SpanStatusFailedPrecondition, + codes.Aborted: sentry.SpanStatusAborted, + codes.OutOfRange: sentry.SpanStatusOutOfRange, + codes.Unimplemented: sentry.SpanStatusUnimplemented, + codes.Internal: sentry.SpanStatusInternalError, + codes.Unavailable: sentry.SpanStatusUnavailable, + codes.DataLoss: sentry.SpanStatusDataLoss, + codes.Unauthenticated: sentry.SpanStatusUnauthenticated, +} + +func toSpanStatus(code codes.Code) sentry.SpanStatus { + if status, ok := codeToSpanStatus[code]; ok { + return status + } + return sentry.SpanStatusUndefined +} From cc9547cccbec9bd3a7b79d28fa9b5fb10ce8cf49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Ribi=C4=87?= Date: Fri, 27 Dec 2024 09:51:45 +0100 Subject: [PATCH 03/39] add grpc integration --- .craft.yml | 3 + _examples/grpc_client/main.go | 116 ++++++ _examples/grpc_server/example.proto | 21 + _examples/grpc_server/examplepb/example.pb.go | 191 +++++++++ .../grpc_server/examplepb/example_grpc.pb.go | 158 ++++++++ _examples/grpc_server/main.go | 95 +++++ grpc/client.go | 15 +- grpc/client_test.go | 235 ++++++++++++ grpc/go.mod | 5 + grpc/go.sum | 17 + grpc/server.go | 10 +- grpc/server_test.go | 362 ++++++++++++++++++ mocks_test.go => mocks.go | 1 - otel/event_processor_test.go | 2 +- otel/helpers_test.go | 36 -- otel/span_processor_test.go | 6 +- 16 files changed, 1222 insertions(+), 51 deletions(-) create mode 100644 _examples/grpc_client/main.go create mode 100644 _examples/grpc_server/example.proto create mode 100644 _examples/grpc_server/examplepb/example.pb.go create mode 100644 _examples/grpc_server/examplepb/example_grpc.pb.go create mode 100644 _examples/grpc_server/main.go create mode 100644 grpc/client_test.go create mode 100644 grpc/server_test.go rename mocks_test.go => mocks.go (99%) diff --git a/.craft.yml b/.craft.yml index 5786bba22..803938afa 100644 --- a/.craft.yml +++ b/.craft.yml @@ -35,6 +35,9 @@ targets: - name: github tagPrefix: zerolog/v tagOnly: true + - name: github + tagPrefix: grpc/v + tagOnly: true - name: registry sdks: github:getsentry/sentry-go: diff --git a/_examples/grpc_client/main.go b/_examples/grpc_client/main.go new file mode 100644 index 000000000..144ad59d2 --- /dev/null +++ b/_examples/grpc_client/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "grpcdemo/cmd/server/examplepb" + + "github.com/getsentry/sentry-go" + sentrygrpc "github.com/getsentry/sentry-go/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +const grpcServerAddress = "localhost:50051" + +func main() { + // Initialize Sentry + err := sentry.Init(sentry.ClientOptions{ + Dsn: "", + TracesSampleRate: 1.0, + }) + if err != nil { + log.Fatalf("sentry.Init: %s", err) + } + defer sentry.Flush(2 * time.Second) + + // Create a connection to the gRPC server with Sentry interceptors + conn, err := grpc.NewClient( + grpcServerAddress, + grpc.WithTransportCredentials(insecure.NewCredentials()), // Use TLS in production + grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor(sentrygrpc.ClientOptions{})), + grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor(sentrygrpc.ClientOptions{})), + ) + if err != nil { + log.Fatalf("Failed to connect to gRPC server: %s", err) + } + defer conn.Close() + + // Create a client for the ExampleService + client := examplepb.NewExampleServiceClient(conn) + + // Perform Unary call + fmt.Println("Performing Unary Call:") + unaryExample(client) + + // Perform Streaming call + fmt.Println("\nPerforming Streaming Call:") + streamExample(client) +} + +func unaryExample(client examplepb.ExampleServiceClient) { + ctx := context.Background() + + // Add metadata to the context + ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs( + "custom-header", "value", + )) + + req := &examplepb.ExampleRequest{ + Message: "Hello, server!", // Change to "error" to simulate an error + } + + res, err := client.UnaryExample(ctx, req) + if err != nil { + fmt.Printf("Unary Call Error: %v\n", err) + sentry.CaptureException(err) + return + } + + fmt.Printf("Unary Response: %s\n", res.Message) +} + +func streamExample(client examplepb.ExampleServiceClient) { + ctx := context.Background() + + // Add metadata to the context + ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs( + "streaming-header", "stream-value", + )) + + stream, err := client.StreamExample(ctx) + if err != nil { + fmt.Printf("Failed to establish stream: %v\n", err) + sentry.CaptureException(err) + return + } + + // Send multiple messages in the stream + messages := []string{"Message 1", "Message 2", "error", "Message 4"} + for _, msg := range messages { + err := stream.Send(&examplepb.ExampleRequest{Message: msg}) + if err != nil { + fmt.Printf("Stream Send Error: %v\n", err) + sentry.CaptureException(err) + return + } + } + + // Close the stream for sending + stream.CloseSend() + + // Receive responses from the server + for { + res, err := stream.Recv() + if err != nil { + fmt.Printf("Stream Recv Error: %v\n", err) + sentry.CaptureException(err) + break + } + fmt.Printf("Stream Response: %s\n", res.Message) + } +} diff --git a/_examples/grpc_server/example.proto b/_examples/grpc_server/example.proto new file mode 100644 index 000000000..356d58f11 --- /dev/null +++ b/_examples/grpc_server/example.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package main; + +option go_package = "github.com/your-username/your-repo/examplepb;examplepb"; + +// ExampleService defines the gRPC service. +service ExampleService { + rpc UnaryExample(ExampleRequest) returns (ExampleResponse); + rpc StreamExample(stream ExampleRequest) returns (stream ExampleResponse); +} + +// ExampleRequest is the request message. +message ExampleRequest { + string message = 1; +} + +// ExampleResponse is the response message. +message ExampleResponse { + string message = 1; +} diff --git a/_examples/grpc_server/examplepb/example.pb.go b/_examples/grpc_server/examplepb/example.pb.go new file mode 100644 index 000000000..84d8b8fbb --- /dev/null +++ b/_examples/grpc_server/examplepb/example.pb.go @@ -0,0 +1,191 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.1 +// protoc v5.29.2 +// source: example.proto + +package examplepb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ExampleRequest is the request message. +type ExampleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExampleRequest) Reset() { + *x = ExampleRequest{} + mi := &file_example_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExampleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExampleRequest) ProtoMessage() {} + +func (x *ExampleRequest) ProtoReflect() protoreflect.Message { + mi := &file_example_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExampleRequest.ProtoReflect.Descriptor instead. +func (*ExampleRequest) Descriptor() ([]byte, []int) { + return file_example_proto_rawDescGZIP(), []int{0} +} + +func (x *ExampleRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// ExampleResponse is the response message. +type ExampleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExampleResponse) Reset() { + *x = ExampleResponse{} + mi := &file_example_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExampleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExampleResponse) ProtoMessage() {} + +func (x *ExampleResponse) ProtoReflect() protoreflect.Message { + mi := &file_example_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExampleResponse.ProtoReflect.Descriptor instead. +func (*ExampleResponse) Descriptor() ([]byte, []int) { + return file_example_proto_rawDescGZIP(), []int{1} +} + +func (x *ExampleResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_example_proto protoreflect.FileDescriptor + +var file_example_proto_rawDesc = []byte{ + 0x0a, 0x0d, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x2a, 0x0a, 0x0e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x22, 0x2b, 0x0a, 0x0f, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x8f, + 0x01, 0x0a, 0x0e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x12, 0x3b, 0x0a, 0x0c, 0x55, 0x6e, 0x61, 0x72, 0x79, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, + 0x65, 0x12, 0x14, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, + 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, + 0x0a, 0x0d, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x12, + 0x14, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x61, + 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, + 0x42, 0x38, 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x79, + 0x6f, 0x75, 0x72, 0x2d, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x2f, 0x79, 0x6f, 0x75, + 0x72, 0x2d, 0x72, 0x65, 0x70, 0x6f, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x70, 0x62, + 0x3b, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_example_proto_rawDescOnce sync.Once + file_example_proto_rawDescData = file_example_proto_rawDesc +) + +func file_example_proto_rawDescGZIP() []byte { + file_example_proto_rawDescOnce.Do(func() { + file_example_proto_rawDescData = protoimpl.X.CompressGZIP(file_example_proto_rawDescData) + }) + return file_example_proto_rawDescData +} + +var file_example_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_example_proto_goTypes = []any{ + (*ExampleRequest)(nil), // 0: main.ExampleRequest + (*ExampleResponse)(nil), // 1: main.ExampleResponse +} +var file_example_proto_depIdxs = []int32{ + 0, // 0: main.ExampleService.UnaryExample:input_type -> main.ExampleRequest + 0, // 1: main.ExampleService.StreamExample:input_type -> main.ExampleRequest + 1, // 2: main.ExampleService.UnaryExample:output_type -> main.ExampleResponse + 1, // 3: main.ExampleService.StreamExample:output_type -> main.ExampleResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_example_proto_init() } +func file_example_proto_init() { + if File_example_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_example_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_example_proto_goTypes, + DependencyIndexes: file_example_proto_depIdxs, + MessageInfos: file_example_proto_msgTypes, + }.Build() + File_example_proto = out.File + file_example_proto_rawDesc = nil + file_example_proto_goTypes = nil + file_example_proto_depIdxs = nil +} diff --git a/_examples/grpc_server/examplepb/example_grpc.pb.go b/_examples/grpc_server/examplepb/example_grpc.pb.go new file mode 100644 index 000000000..56f4b3504 --- /dev/null +++ b/_examples/grpc_server/examplepb/example_grpc.pb.go @@ -0,0 +1,158 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.2 +// source: example.proto + +package examplepb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ExampleService_UnaryExample_FullMethodName = "/main.ExampleService/UnaryExample" + ExampleService_StreamExample_FullMethodName = "/main.ExampleService/StreamExample" +) + +// ExampleServiceClient is the client API for ExampleService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ExampleService defines the gRPC service. +type ExampleServiceClient interface { + UnaryExample(ctx context.Context, in *ExampleRequest, opts ...grpc.CallOption) (*ExampleResponse, error) + StreamExample(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExampleRequest, ExampleResponse], error) +} + +type exampleServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewExampleServiceClient(cc grpc.ClientConnInterface) ExampleServiceClient { + return &exampleServiceClient{cc} +} + +func (c *exampleServiceClient) UnaryExample(ctx context.Context, in *ExampleRequest, opts ...grpc.CallOption) (*ExampleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExampleResponse) + err := c.cc.Invoke(ctx, ExampleService_UnaryExample_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *exampleServiceClient) StreamExample(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExampleRequest, ExampleResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ExampleService_ServiceDesc.Streams[0], ExampleService_StreamExample_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExampleRequest, ExampleResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ExampleService_StreamExampleClient = grpc.BidiStreamingClient[ExampleRequest, ExampleResponse] + +// ExampleServiceServer is the server API for ExampleService service. +// All implementations must embed UnimplementedExampleServiceServer +// for forward compatibility. +// +// ExampleService defines the gRPC service. +type ExampleServiceServer interface { + UnaryExample(context.Context, *ExampleRequest) (*ExampleResponse, error) + StreamExample(grpc.BidiStreamingServer[ExampleRequest, ExampleResponse]) error + mustEmbedUnimplementedExampleServiceServer() +} + +// UnimplementedExampleServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedExampleServiceServer struct{} + +func (UnimplementedExampleServiceServer) UnaryExample(context.Context, *ExampleRequest) (*ExampleResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnaryExample not implemented") +} +func (UnimplementedExampleServiceServer) StreamExample(grpc.BidiStreamingServer[ExampleRequest, ExampleResponse]) error { + return status.Errorf(codes.Unimplemented, "method StreamExample not implemented") +} +func (UnimplementedExampleServiceServer) mustEmbedUnimplementedExampleServiceServer() {} +func (UnimplementedExampleServiceServer) testEmbeddedByValue() {} + +// UnsafeExampleServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ExampleServiceServer will +// result in compilation errors. +type UnsafeExampleServiceServer interface { + mustEmbedUnimplementedExampleServiceServer() +} + +func RegisterExampleServiceServer(s grpc.ServiceRegistrar, srv ExampleServiceServer) { + // If the following call pancis, it indicates UnimplementedExampleServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ExampleService_ServiceDesc, srv) +} + +func _ExampleService_UnaryExample_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExampleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExampleServiceServer).UnaryExample(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ExampleService_UnaryExample_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExampleServiceServer).UnaryExample(ctx, req.(*ExampleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ExampleService_StreamExample_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(ExampleServiceServer).StreamExample(&grpc.GenericServerStream[ExampleRequest, ExampleResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ExampleService_StreamExampleServer = grpc.BidiStreamingServer[ExampleRequest, ExampleResponse] + +// ExampleService_ServiceDesc is the grpc.ServiceDesc for ExampleService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ExampleService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "main.ExampleService", + HandlerType: (*ExampleServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "UnaryExample", + Handler: _ExampleService_UnaryExample_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamExample", + Handler: _ExampleService_StreamExample_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "example.proto", +} diff --git a/_examples/grpc_server/main.go b/_examples/grpc_server/main.go new file mode 100644 index 000000000..defe1b09a --- /dev/null +++ b/_examples/grpc_server/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "fmt" + "grpcdemo/cmd/server/examplepb" + "log" + "net" + "time" + + "github.com/getsentry/sentry-go" + sentrygrpc "github.com/getsentry/sentry-go/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +const grpcPort = ":50051" + +// ExampleServiceServer is the server implementation for the ExampleService. +type ExampleServiceServer struct { + examplepb.UnimplementedExampleServiceServer +} + +// UnaryExample handles unary gRPC requests. +func (s *ExampleServiceServer) UnaryExample(ctx context.Context, req *examplepb.ExampleRequest) (*examplepb.ExampleResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + fmt.Printf("Received Unary Request: %v\nMetadata: %v\n", req.Message, md) + + // Simulate an error for demonstration + if req.Message == "error" { + return nil, fmt.Errorf("simulated unary error") + } + + return &examplepb.ExampleResponse{Message: fmt.Sprintf("Hello, %s!", req.Message)}, nil +} + +// StreamExample handles bidirectional streaming gRPC requests. +func (s *ExampleServiceServer) StreamExample(stream examplepb.ExampleService_StreamExampleServer) error { + for { + req, err := stream.Recv() + if err != nil { + fmt.Printf("Stream Recv Error: %v\n", err) + return err + } + + fmt.Printf("Received Stream Message: %v\n", req.Message) + + if req.Message == "error" { + return fmt.Errorf("simulated stream error") + } + + err = stream.Send(&examplepb.ExampleResponse{Message: fmt.Sprintf("Echo: %s", req.Message)}) + if err != nil { + fmt.Printf("Stream Send Error: %v\n", err) + return err + } + } +} + +func main() { + // Initialize Sentry + err := sentry.Init(sentry.ClientOptions{ + Dsn: "", + TracesSampleRate: 1.0, + }) + if err != nil { + log.Fatalf("sentry.Init: %s", err) + } + defer sentry.Flush(2 * time.Second) + + // Create a new gRPC server with Sentry interceptors + server := grpc.NewServer( + grpc.UnaryInterceptor(sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{ + Repanic: true, + CaptureRequestBody: true, + })), + grpc.StreamInterceptor(sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{ + Repanic: true, + })), + ) + + // Register the ExampleService + examplepb.RegisterExampleServiceServer(server, &ExampleServiceServer{}) + + // Start the server + listener, err := net.Listen("tcp", grpcPort) + if err != nil { + log.Fatalf("Failed to listen on port %s: %v", grpcPort, err) + } + + fmt.Printf("gRPC server is running on %s\n", grpcPort) + if err := server.Serve(listener); err != nil { + log.Fatalf("Failed to serve: %v", err) + } +} diff --git a/grpc/client.go b/grpc/client.go index e16e4ee63..1601ffa19 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +// Part of this code is derived from [github.com/johnbellone/grpc-middleware-sentry], licensed under the Apache 2.0 License. + package sentrygrpc import ( @@ -35,12 +38,14 @@ func createOrUpdateMetadata(ctx context.Context, span *sentry.Span) context.Cont md = md.Copy() md.Append(sentry.SentryTraceHeader, span.ToSentryTrace()) md.Append(sentry.SentryBaggageHeader, span.ToBaggage()) - } else { - md = metadata.Pairs( - sentry.SentryTraceHeader, span.ToSentryTrace(), - sentry.SentryBaggageHeader, span.ToBaggage(), - ) + return metadata.NewOutgoingContext(ctx, md) } + + md = metadata.Pairs( + sentry.SentryTraceHeader, span.ToSentryTrace(), + sentry.SentryBaggageHeader, span.ToBaggage(), + ) + return metadata.NewOutgoingContext(ctx, md) } diff --git a/grpc/client_test.go b/grpc/client_test.go new file mode 100644 index 000000000..404145ede --- /dev/null +++ b/grpc/client_test.go @@ -0,0 +1,235 @@ +package sentrygrpc_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/getsentry/sentry-go" + sentrygrpc "github.com/getsentry/sentry-go/grpc" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +const defaultClientOperationName = "grpc.client" + +func TestClientOptions_SetDefaults(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + options sentrygrpc.ClientOptions + assertions func(t *testing.T, options sentrygrpc.ClientOptions) + }{ + "Defaults are set when fields are empty": { + options: sentrygrpc.ClientOptions{}, + assertions: func(t *testing.T, options sentrygrpc.ClientOptions) { + assert.NotNil(t, options.ReportOn, "ReportOn should be set to default function") + assert.Equal(t, defaultClientOperationName, options.OperationName, "OperationName should be set to default value") + }, + }, + "Custom ReportOn is preserved": { + options: sentrygrpc.ClientOptions{ + ReportOn: func(err error) bool { + return err.Error() == "custom error" + }, + }, + assertions: func(t *testing.T, options sentrygrpc.ClientOptions) { + assert.NotNil(t, options.ReportOn, "ReportOn should not be nil") + err := errors.New("random error") + assert.False(t, options.ReportOn(err), "ReportOn should return false for random error") + }, + }, + "Custom OperationName is preserved": { + options: sentrygrpc.ClientOptions{ + OperationName: "custom.operation", + }, + assertions: func(t *testing.T, options sentrygrpc.ClientOptions) { + assert.Equal(t, "custom.operation", options.OperationName, "OperationName should be set to custom value") + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + test.options.SetDefaults() + test.assertions(t, test.options) + }) + } +} + +func TestUnaryClientInterceptor(t *testing.T) { + tests := map[string]struct { + invoker grpc.UnaryInvoker + options sentrygrpc.ClientOptions + expectedErr error + assertions func(t *testing.T, transport *sentry.TransportMock) + }{ + "Default behavior, no error": { + invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return nil + }, + options: sentrygrpc.ClientOptions{}, + assertions: func(t *testing.T, transport *sentry.TransportMock) { + assert.Empty(t, transport.Events(), "No events should be captured") + }, + }, + "Error is reported": { + invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return errors.New("test error") + }, + options: sentrygrpc.ClientOptions{}, + expectedErr: errors.New("test error"), + assertions: func(t *testing.T, transport *sentry.TransportMock) { + events := transport.Events() + assert.Len(t, events, 1, "One event should be captured") + assert.Equal(t, "test error", events[0].Exception[0].Value, "Captured exception should match the error") + }, + }, + "Metadata propagation": { + invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + md, ok := metadata.FromOutgoingContext(ctx) + assert.True(t, ok, "Metadata should be present in the outgoing context") + assert.Contains(t, md, sentry.SentryTraceHeader, "Metadata should contain Sentry trace header") + assert.Contains(t, md, sentry.SentryBaggageHeader, "Metadata should contain Sentry baggage header") + return nil + }, + options: sentrygrpc.ClientOptions{}, + assertions: func(t *testing.T, transport *sentry.TransportMock) {}, + }, + "Custom ReportOn behavior": { + invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return errors.New("test error") + }, + options: sentrygrpc.ClientOptions{ + ReportOn: func(err error) bool { + return err.Error() == "specific error" + }, + }, + expectedErr: errors.New("test error"), + assertions: func(t *testing.T, transport *sentry.TransportMock) { + assert.Empty(t, transport.Events(), "No events should be captured due to custom ReportOn") + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + transport := &sentry.TransportMock{} + sentry.Init(sentry.ClientOptions{ + Transport: transport, + }) + + interceptor := sentrygrpc.UnaryClientInterceptor(test.options) + + // Execute the interceptor + err := interceptor(context.Background(), "/test.Service/TestMethod", struct{}{}, struct{}{}, nil, test.invoker) + + if test.expectedErr != nil { + assert.Equal(t, test.expectedErr, err, "Expected error mismatch") + } else { + assert.NoError(t, err, "Expected no error") + } + + sentry.Flush(2 * time.Second) + + // Pass the transport to the assertions to verify captured events. + test.assertions(t, transport) + }) + } +} + +func TestStreamClientInterceptor(t *testing.T) { + tests := map[string]struct { + streamer grpc.Streamer + options sentrygrpc.ClientOptions + expectedErr error + assertions func(t *testing.T, transport *sentry.TransportMock) + streamDesc *grpc.StreamDesc + }{ + "Default behavior, no error": { + streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, nil + }, + options: sentrygrpc.ClientOptions{}, + streamDesc: &grpc.StreamDesc{ + ClientStreams: true, + }, + assertions: func(t *testing.T, transport *sentry.TransportMock) { + assert.Empty(t, transport.Events(), "No events should be captured") + }, + }, + "Error is reported": { + streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, errors.New("test stream error") + }, + options: sentrygrpc.ClientOptions{}, + expectedErr: errors.New("test stream error"), + streamDesc: &grpc.StreamDesc{}, + assertions: func(t *testing.T, transport *sentry.TransportMock) { + events := transport.Events() + assert.Len(t, events, 1, "One event should be captured") + assert.Equal(t, "test stream error", events[0].Exception[0].Value, "Captured exception should match the error") + }, + }, + "Metadata propagation": { + streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { + md, ok := metadata.FromOutgoingContext(ctx) + assert.True(t, ok, "Metadata should be present in the outgoing context") + assert.Contains(t, md, sentry.SentryTraceHeader, "Metadata should contain Sentry trace header") + assert.Contains(t, md, sentry.SentryBaggageHeader, "Metadata should contain Sentry baggage header") + return nil, nil + }, + options: sentrygrpc.ClientOptions{}, + streamDesc: &grpc.StreamDesc{ + ClientStreams: true, + }, + assertions: func(t *testing.T, transport *sentry.TransportMock) {}, + }, + "Custom ReportOn behavior": { + streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, errors.New("test stream error") + }, + options: sentrygrpc.ClientOptions{ + ReportOn: func(err error) bool { + return err.Error() == "specific error" + }, + }, + expectedErr: errors.New("test stream error"), + streamDesc: &grpc.StreamDesc{}, + assertions: func(t *testing.T, transport *sentry.TransportMock) { + assert.Empty(t, transport.Events(), "No events should be captured due to custom ReportOn") + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // Reinitialize the transport for each test to ensure isolation. + transport := &sentry.TransportMock{} + sentry.Init(sentry.ClientOptions{ + Transport: transport, + }) + + interceptor := sentrygrpc.StreamClientInterceptor(test.options) + + // Execute the interceptor + clientStream, err := interceptor(context.Background(), test.streamDesc, nil, "/test.Service/TestMethod", test.streamer) + + if test.expectedErr != nil { + assert.Equal(t, test.expectedErr, err, "Expected error mismatch") + } else { + assert.NoError(t, err, "Expected no error") + } + + sentry.Flush(2 * time.Second) + + assert.Nil(t, clientStream, "ClientStream should be nil in this test scenario") + // Pass the transport to the assertions to verify captured events. + test.assertions(t, transport) + }) + } +} diff --git a/grpc/go.mod b/grpc/go.mod index 72975cf25..879cf6eb0 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -6,13 +6,18 @@ replace github.com/getsentry/sentry-go => ../ require ( github.com/getsentry/sentry-go v0.30.0 + github.com/stretchr/testify v1.8.2 google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 google.golang.org/grpc v1.69.2 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/net v0.30.0 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/text v0.19.0 // indirect google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/grpc/go.sum b/grpc/go.sum index 162aadade..c27b84b3f 100644 --- a/grpc/go.sum +++ b/grpc/go.sum @@ -1,3 +1,5 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -12,12 +14,23 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= @@ -44,5 +57,9 @@ google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/grpc/server.go b/grpc/server.go index 343ce7058..e8d9dc0d4 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -69,7 +69,7 @@ func recoverWithSentry(ctx context.Context, hub *sentry.Hub, o ServerOptions) { } } -func reportErrorToSentry(hub *sentry.Hub, err error, methodName string, req interface{}, md map[string]string) { +func reportErrorToSentry(hub *sentry.Hub, err error, methodName string, req any, md map[string]string) { hub.WithScope(func(scope *sentry.Scope) { scope.SetExtras(map[string]any{ "grpc.method": methodName, @@ -111,7 +111,7 @@ func reportErrorToSentry(hub *sentry.Hub, err error, methodName string, req inte func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { opts.SetDefaults() - return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { hub := sentry.GetHubFromContext(ctx) if hub == nil { hub = sentry.CurrentHub().Clone() @@ -135,7 +135,7 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), - sentry.WithOpName("http.server"), + sentry.WithOpName(opts.OperationName), sentry.WithDescription(info.FullMethod), sentry.WithTransactionSource(sentry.SourceURL), } @@ -176,7 +176,7 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { // StreamServerInterceptor provides Sentry integration for streaming gRPC calls. func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { opts.SetDefaults() - return func(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { ctx := ss.Context() hub := sentry.GetHubFromContext(ctx) if hub == nil { @@ -201,7 +201,7 @@ func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), - sentry.WithOpName("http.server"), + sentry.WithOpName(opts.OperationName), sentry.WithDescription(info.FullMethod), sentry.WithTransactionSource(sentry.SourceURL), } diff --git a/grpc/server_test.go b/grpc/server_test.go new file mode 100644 index 000000000..933b865e2 --- /dev/null +++ b/grpc/server_test.go @@ -0,0 +1,362 @@ +package sentrygrpc_test + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/getsentry/sentry-go" + sentrygrpc "github.com/getsentry/sentry-go/grpc" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const defaultServerOperationName = "grpc.server" + +func TestServerOptions_SetDefaults(t *testing.T) { + t.Parallel() + tests := map[string]struct { + options sentrygrpc.ServerOptions + assertions func(t *testing.T, options sentrygrpc.ServerOptions) + }{ + "Defaults are set when fields are empty": { + options: sentrygrpc.ServerOptions{}, + assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { + assert.NotNil(t, options.ReportOn, "ReportOn should be set to default function") + assert.Equal(t, sentry.DefaultFlushTimeout, options.Timeout, "Timeout should be set to default value") + assert.Equal(t, defaultServerOperationName, options.OperationName, "OperationName should be set to default value") + }, + }, + "Custom ReportOn is preserved": { + options: sentrygrpc.ServerOptions{ + ReportOn: func(err error) bool { + return err.Error() == "specific error" + }, + }, + assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { + assert.NotNil(t, options.ReportOn, "ReportOn should not be nil") + err := errors.New("random error") + assert.False(t, options.ReportOn(err), "ReportOn should return false for random error") + err = errors.New("specific error") + assert.True(t, options.ReportOn(err), "ReportOn should return true for specific error") + }, + }, + "Custom Timeout is preserved": { + options: sentrygrpc.ServerOptions{ + Timeout: 5 * time.Second, + }, + assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { + assert.Equal(t, 5*time.Second, options.Timeout, "Timeout should be set to custom value") + }, + }, + "Custom OperationName is preserved": { + options: sentrygrpc.ServerOptions{ + OperationName: "custom.operation", + }, + assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { + assert.Equal(t, "custom.operation", options.OperationName, "OperationName should be set to custom value") + }, + }, + "CaptureRequestBody remains unchanged": { + options: sentrygrpc.ServerOptions{ + CaptureRequestBody: true, + }, + assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { + assert.True(t, options.CaptureRequestBody, "CaptureRequestBody should remain true") + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + test.options.SetDefaults() + + test.assertions(t, test.options) + }) + } +} + +func TestUnaryServerInterceptor(t *testing.T) { + tests := map[string]struct { + options sentrygrpc.ServerOptions + handler grpc.UnaryHandler + expectedErr string + wantException string + wantTransaction *sentry.Event + assertTransaction bool + }{ + "Handle panic and re-panic": { + options: sentrygrpc.ServerOptions{Repanic: true}, + handler: func(ctx context.Context, req any) (any, error) { + panic("test panic") + }, + }, + "Report error with transaction": { + options: sentrygrpc.ServerOptions{ + ReportOn: func(err error) bool { + return true + }, + }, + handler: func(ctx context.Context, req any) (any, error) { + return nil, status.Error(codes.Internal, "handler error") + }, + expectedErr: "rpc error: code = Internal desc = handler error", + wantException: "rpc error: code = Internal desc = handler error", + assertTransaction: true, + }, + "Do not report error when ReportOn returns false": { + options: sentrygrpc.ServerOptions{ + ReportOn: func(err error) bool { + return false + }, + }, + handler: func(ctx context.Context, req any) (any, error) { + return nil, status.Error(codes.Internal, "handler error not reported") + }, + expectedErr: "rpc error: code = Internal desc = handler error not reported", + assertTransaction: true, + }, + "Capture request body when enabled": { + options: sentrygrpc.ServerOptions{ + CaptureRequestBody: true, + ReportOn: func(err error) bool { + return true + }, + }, + handler: func(ctx context.Context, req any) (any, error) { + return nil, status.Error(codes.InvalidArgument, "invalid request body") + }, + expectedErr: "rpc error: code = InvalidArgument desc = invalid request body", + wantException: "rpc error: code = InvalidArgument desc = invalid request body", + assertTransaction: true, + }, + "Custom operation name is used in transaction": { + options: sentrygrpc.ServerOptions{ + OperationName: "CustomUnaryOperation", + ReportOn: func(err error) bool { + return true + }, + }, + handler: func(ctx context.Context, req any) (any, error) { + return nil, status.Error(codes.PermissionDenied, "access denied") + }, + expectedErr: "rpc error: code = PermissionDenied desc = access denied", + wantException: "rpc error: code = PermissionDenied desc = access denied", + assertTransaction: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + eventsCh := make(chan *sentry.Event, 1) + transactionsCh := make(chan *sentry.Event, 1) + + err := sentry.Init(sentry.ClientOptions{ + BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event { + eventsCh <- event + return event + }, + BeforeSendTransaction: func(tx *sentry.Event, hint *sentry.EventHint) *sentry.Event { + fmt.Println("Transaction: ", tx.Transaction) + transactionsCh <- tx + return tx + }, + EnableTracing: true, + TracesSampleRate: 1.0, + }) + if err != nil { + t.Fatal(err) + } + + interceptor := sentrygrpc.UnaryServerInterceptor(test.options) + + defer func() { + if r := recover(); r != nil { + // Assert the panic message for tests with repanic enabled + if test.options.Repanic { + assert.Equal(t, "test panic", r, "Expected panic to propagate with message 'test panic'") + } + } + }() + + _, err = interceptor(context.Background(), nil, &grpc.UnaryServerInfo{ + FullMethod: "TestService.Method", + }, test.handler) + + if test.expectedErr != "" { + assert.EqualError(t, err, test.expectedErr) + } else { + assert.NoError(t, err) + } + + if test.wantException != "" { + close(eventsCh) + var gotEvent *sentry.Event + for e := range eventsCh { + gotEvent = e + } + + assert.NotNil(t, gotEvent, "Expected an event") + assert.Len(t, gotEvent.Exception, 1, "Expected one exception in the event") + assert.Equal(t, test.wantException, gotEvent.Exception[0].Value, "Exception values should match") + } + + if test.assertTransaction { + close(transactionsCh) + var gotTransaction *sentry.Event + for tx := range transactionsCh { + fmt.Println("Transaction: ", tx.Transaction) + gotTransaction = tx + } + assert.NotNil(t, gotTransaction, "Expected a transaction") + assert.Equal(t, fmt.Sprintf("UnaryServerInterceptor %s", "TestService.Method"), gotTransaction.Transaction, "Transaction names should match") + } + + sentry.Flush(2 * time.Second) + }) + } +} + +// wrappedServerStream is a wrapper around grpc.ServerStream that overrides the Context method. +type wrappedServerStream struct { + grpc.ServerStream + ctx context.Context +} + +// Context returns the custom context for the stream. +func (w *wrappedServerStream) Context() context.Context { + return w.ctx +} + +func TestStreamServerInterceptor(t *testing.T) { + t.Parallel() + tests := map[string]struct { + options sentrygrpc.ServerOptions + handler grpc.StreamHandler + expectedErr string + expectedMetadata bool + expectedEvent bool + }{ + "Default behavior, no error": { + options: sentrygrpc.ServerOptions{}, + handler: func(srv any, stream grpc.ServerStream) error { + return nil + }, + expectedErr: "", + expectedMetadata: false, + expectedEvent: false, + }, + "Handler returns an error": { + options: sentrygrpc.ServerOptions{ + ReportOn: func(err error) bool { + return true + }, + }, + handler: func(srv any, stream grpc.ServerStream) error { + return status.Error(codes.Internal, "stream error") + }, + expectedErr: "rpc error: code = Internal desc = stream error", + expectedMetadata: false, + expectedEvent: true, + }, + "Repanic is enabled": { + options: sentrygrpc.ServerOptions{ + Repanic: true, + }, + handler: func(srv any, stream grpc.ServerStream) error { + panic("test panic") + }, + expectedErr: "", + expectedMetadata: false, + expectedEvent: false, // The panic is re-raised, event capture may depend on hub state + }, + "Metadata is propagated": { + options: sentrygrpc.ServerOptions{}, + handler: func(srv any, stream grpc.ServerStream) error { + md, ok := metadata.FromIncomingContext(stream.Context()) + if !ok || len(md) == 0 { + return status.Error(codes.InvalidArgument, "metadata missing") + } + return nil + }, + expectedErr: "", + expectedMetadata: true, + expectedEvent: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + eventsCh := make(chan *sentry.Event, 1) + transactionsCh := make(chan *sentry.Event, 1) + + err := sentry.Init(sentry.ClientOptions{ + 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) + } + defer sentry.Flush(2 * time.Second) + + interceptor := sentrygrpc.StreamServerInterceptor(test.options) + + // Simulate a server stream + stream := &wrappedServerStream{ + ServerStream: nil, // Mock implementation or use a testing framework + ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")), + } + + var recovered interface{} + func() { + defer func() { + recovered = recover() + }() + err = interceptor(nil, stream, &grpc.StreamServerInfo{FullMethod: "TestService.StreamMethod"}, test.handler) + }() + + if test.expectedErr != "" { + assert.EqualError(t, err, test.expectedErr) + } else { + assert.NoError(t, err) + } + + if test.expectedMetadata { + md, ok := metadata.FromIncomingContext(stream.Context()) + assert.True(t, ok, "Expected metadata to be propagated in context") + assert.Contains(t, md, "key", "Expected metadata to include 'key'") + } + + if test.expectedEvent { + close(eventsCh) + var gotEvent *sentry.Event + for e := range eventsCh { + gotEvent = e + } + assert.NotNil(t, gotEvent, "Expected an event to be captured") + } else { + assert.Empty(t, eventsCh, "Expected no event to be captured") + } + + if test.options.Repanic { + assert.NotNil(t, recovered, "Expected panic to be re-raised") + assert.Equal(t, "test panic", recovered, "Panic value should match") + } + }) + } +} diff --git a/mocks_test.go b/mocks.go similarity index 99% rename from mocks_test.go rename to mocks.go index 5cc127e1b..61731464d 100644 --- a/mocks_test.go +++ b/mocks.go @@ -43,4 +43,3 @@ func (t *TransportMock) Events() []*Event { return t.events } func (t *TransportMock) Close() {} - diff --git a/otel/event_processor_test.go b/otel/event_processor_test.go index 43df64a9a..28116d27b 100644 --- a/otel/event_processor_test.go +++ b/otel/event_processor_test.go @@ -34,7 +34,7 @@ func TestLinkTraceContextToErrorEventSetsContext(t *testing.T) { hub.Scope(), ) - transport := client.Transport.(*TransportMock) + transport := client.Transport.(*sentry.TransportMock) events := transport.Events() assertEqual(t, len(events), 1) err := events[0] diff --git a/otel/helpers_test.go b/otel/helpers_test.go index 227f1a804..09f648c58 100644 --- a/otel/helpers_test.go +++ b/otel/helpers_test.go @@ -3,9 +3,7 @@ package sentryotel import ( "encoding/hex" "sort" - "sync" "testing" - "time" "github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go/internal/otel/baggage" @@ -112,37 +110,3 @@ func otelSpanIDFromHex(s string) trace.SpanID { } return spanID } - -// FIXME(anton): TransportMock is copied from mocks_test.go -// I don't see an easy way right now to reuse this struct in "sentry" and -// "sentryotel" packages: it naturally depends on "sentry", but tests in "sentry" -// package also depend on it, so if we move it to a new package, we'll get an -// import cycle. -// Alternatively, it could be made public on "sentry" package, but it doesn't -// feel right. - -type TransportMock struct { - mu sync.Mutex - events []*sentry.Event - lastEvent *sentry.Event -} - -func (t *TransportMock) Configure(options sentry.ClientOptions) {} -func (t *TransportMock) SendEvent(event *sentry.Event) { - t.mu.Lock() - defer t.mu.Unlock() - t.events = append(t.events, event) - t.lastEvent = event -} -func (t *TransportMock) Flush(timeout time.Duration) bool { - return true -} -func (t *TransportMock) Events() []*sentry.Event { - t.mu.Lock() - defer t.mu.Unlock() - return t.events -} - -func (t *TransportMock) Close() {} - -// diff --git a/otel/span_processor_test.go b/otel/span_processor_test.go index 03c9796cd..ecd4a0e2d 100644 --- a/otel/span_processor_test.go +++ b/otel/span_processor_test.go @@ -40,15 +40,15 @@ func emptyContextWithSentry() context.Context { Release: "1.2.3", EnableTracing: true, TracesSampleRate: 1.0, - Transport: &TransportMock{}, + Transport: &sentry.TransportMock{}, }) hub := sentry.NewHub(client, sentry.NewScope()) return sentry.SetHubOnContext(context.Background(), hub) } -func getSentryTransportFromContext(ctx context.Context) *TransportMock { +func getSentryTransportFromContext(ctx context.Context) *sentry.TransportMock { hub := sentry.GetHubFromContext(ctx) - transport, ok := hub.Client().Transport.(*TransportMock) + transport, ok := hub.Client().Transport.(*sentry.TransportMock) if !ok { log.Fatal( "Cannot get mock transport from context", From a904dfda02fc9193fa4fdfab494ffb0c5e9f4aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Ribi=C4=87?= Date: Fri, 27 Dec 2024 09:57:35 +0100 Subject: [PATCH 04/39] update grpc dependencies --- _examples/grpc_client/main.go | 116 ----------- _examples/grpc_server/example.proto | 21 -- _examples/grpc_server/examplepb/example.pb.go | 191 ------------------ .../grpc_server/examplepb/example_grpc.pb.go | 158 --------------- _examples/grpc_server/main.go | 95 --------- grpc/go.mod | 10 +- grpc/go.sum | 20 +- 7 files changed, 15 insertions(+), 596 deletions(-) delete mode 100644 _examples/grpc_client/main.go delete mode 100644 _examples/grpc_server/example.proto delete mode 100644 _examples/grpc_server/examplepb/example.pb.go delete mode 100644 _examples/grpc_server/examplepb/example_grpc.pb.go delete mode 100644 _examples/grpc_server/main.go diff --git a/_examples/grpc_client/main.go b/_examples/grpc_client/main.go deleted file mode 100644 index 144ad59d2..000000000 --- a/_examples/grpc_client/main.go +++ /dev/null @@ -1,116 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "time" - - "grpcdemo/cmd/server/examplepb" - - "github.com/getsentry/sentry-go" - sentrygrpc "github.com/getsentry/sentry-go/grpc" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/metadata" -) - -const grpcServerAddress = "localhost:50051" - -func main() { - // Initialize Sentry - err := sentry.Init(sentry.ClientOptions{ - Dsn: "", - TracesSampleRate: 1.0, - }) - if err != nil { - log.Fatalf("sentry.Init: %s", err) - } - defer sentry.Flush(2 * time.Second) - - // Create a connection to the gRPC server with Sentry interceptors - conn, err := grpc.NewClient( - grpcServerAddress, - grpc.WithTransportCredentials(insecure.NewCredentials()), // Use TLS in production - grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor(sentrygrpc.ClientOptions{})), - grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor(sentrygrpc.ClientOptions{})), - ) - if err != nil { - log.Fatalf("Failed to connect to gRPC server: %s", err) - } - defer conn.Close() - - // Create a client for the ExampleService - client := examplepb.NewExampleServiceClient(conn) - - // Perform Unary call - fmt.Println("Performing Unary Call:") - unaryExample(client) - - // Perform Streaming call - fmt.Println("\nPerforming Streaming Call:") - streamExample(client) -} - -func unaryExample(client examplepb.ExampleServiceClient) { - ctx := context.Background() - - // Add metadata to the context - ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs( - "custom-header", "value", - )) - - req := &examplepb.ExampleRequest{ - Message: "Hello, server!", // Change to "error" to simulate an error - } - - res, err := client.UnaryExample(ctx, req) - if err != nil { - fmt.Printf("Unary Call Error: %v\n", err) - sentry.CaptureException(err) - return - } - - fmt.Printf("Unary Response: %s\n", res.Message) -} - -func streamExample(client examplepb.ExampleServiceClient) { - ctx := context.Background() - - // Add metadata to the context - ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs( - "streaming-header", "stream-value", - )) - - stream, err := client.StreamExample(ctx) - if err != nil { - fmt.Printf("Failed to establish stream: %v\n", err) - sentry.CaptureException(err) - return - } - - // Send multiple messages in the stream - messages := []string{"Message 1", "Message 2", "error", "Message 4"} - for _, msg := range messages { - err := stream.Send(&examplepb.ExampleRequest{Message: msg}) - if err != nil { - fmt.Printf("Stream Send Error: %v\n", err) - sentry.CaptureException(err) - return - } - } - - // Close the stream for sending - stream.CloseSend() - - // Receive responses from the server - for { - res, err := stream.Recv() - if err != nil { - fmt.Printf("Stream Recv Error: %v\n", err) - sentry.CaptureException(err) - break - } - fmt.Printf("Stream Response: %s\n", res.Message) - } -} diff --git a/_examples/grpc_server/example.proto b/_examples/grpc_server/example.proto deleted file mode 100644 index 356d58f11..000000000 --- a/_examples/grpc_server/example.proto +++ /dev/null @@ -1,21 +0,0 @@ -syntax = "proto3"; - -package main; - -option go_package = "github.com/your-username/your-repo/examplepb;examplepb"; - -// ExampleService defines the gRPC service. -service ExampleService { - rpc UnaryExample(ExampleRequest) returns (ExampleResponse); - rpc StreamExample(stream ExampleRequest) returns (stream ExampleResponse); -} - -// ExampleRequest is the request message. -message ExampleRequest { - string message = 1; -} - -// ExampleResponse is the response message. -message ExampleResponse { - string message = 1; -} diff --git a/_examples/grpc_server/examplepb/example.pb.go b/_examples/grpc_server/examplepb/example.pb.go deleted file mode 100644 index 84d8b8fbb..000000000 --- a/_examples/grpc_server/examplepb/example.pb.go +++ /dev/null @@ -1,191 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.1 -// protoc v5.29.2 -// source: example.proto - -package examplepb - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// ExampleRequest is the request message. -type ExampleRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExampleRequest) Reset() { - *x = ExampleRequest{} - mi := &file_example_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExampleRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExampleRequest) ProtoMessage() {} - -func (x *ExampleRequest) ProtoReflect() protoreflect.Message { - mi := &file_example_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExampleRequest.ProtoReflect.Descriptor instead. -func (*ExampleRequest) Descriptor() ([]byte, []int) { - return file_example_proto_rawDescGZIP(), []int{0} -} - -func (x *ExampleRequest) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -// ExampleResponse is the response message. -type ExampleResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ExampleResponse) Reset() { - *x = ExampleResponse{} - mi := &file_example_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ExampleResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ExampleResponse) ProtoMessage() {} - -func (x *ExampleResponse) ProtoReflect() protoreflect.Message { - mi := &file_example_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ExampleResponse.ProtoReflect.Descriptor instead. -func (*ExampleResponse) Descriptor() ([]byte, []int) { - return file_example_proto_rawDescGZIP(), []int{1} -} - -func (x *ExampleResponse) GetMessage() string { - if x != nil { - return x.Message - } - return "" -} - -var File_example_proto protoreflect.FileDescriptor - -var file_example_proto_rawDesc = []byte{ - 0x0a, 0x0d, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, - 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x2a, 0x0a, 0x0e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0x2b, 0x0a, 0x0f, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x8f, - 0x01, 0x0a, 0x0e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x12, 0x3b, 0x0a, 0x0c, 0x55, 0x6e, 0x61, 0x72, 0x79, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, - 0x65, 0x12, 0x14, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, - 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, - 0x0a, 0x0d, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x12, - 0x14, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x61, - 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, - 0x42, 0x38, 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x79, - 0x6f, 0x75, 0x72, 0x2d, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x2f, 0x79, 0x6f, 0x75, - 0x72, 0x2d, 0x72, 0x65, 0x70, 0x6f, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x70, 0x62, - 0x3b, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, -} - -var ( - file_example_proto_rawDescOnce sync.Once - file_example_proto_rawDescData = file_example_proto_rawDesc -) - -func file_example_proto_rawDescGZIP() []byte { - file_example_proto_rawDescOnce.Do(func() { - file_example_proto_rawDescData = protoimpl.X.CompressGZIP(file_example_proto_rawDescData) - }) - return file_example_proto_rawDescData -} - -var file_example_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_example_proto_goTypes = []any{ - (*ExampleRequest)(nil), // 0: main.ExampleRequest - (*ExampleResponse)(nil), // 1: main.ExampleResponse -} -var file_example_proto_depIdxs = []int32{ - 0, // 0: main.ExampleService.UnaryExample:input_type -> main.ExampleRequest - 0, // 1: main.ExampleService.StreamExample:input_type -> main.ExampleRequest - 1, // 2: main.ExampleService.UnaryExample:output_type -> main.ExampleResponse - 1, // 3: main.ExampleService.StreamExample:output_type -> main.ExampleResponse - 2, // [2:4] is the sub-list for method output_type - 0, // [0:2] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_example_proto_init() } -func file_example_proto_init() { - if File_example_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: file_example_proto_rawDesc, - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_example_proto_goTypes, - DependencyIndexes: file_example_proto_depIdxs, - MessageInfos: file_example_proto_msgTypes, - }.Build() - File_example_proto = out.File - file_example_proto_rawDesc = nil - file_example_proto_goTypes = nil - file_example_proto_depIdxs = nil -} diff --git a/_examples/grpc_server/examplepb/example_grpc.pb.go b/_examples/grpc_server/examplepb/example_grpc.pb.go deleted file mode 100644 index 56f4b3504..000000000 --- a/_examples/grpc_server/examplepb/example_grpc.pb.go +++ /dev/null @@ -1,158 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.2 -// source: example.proto - -package examplepb - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - ExampleService_UnaryExample_FullMethodName = "/main.ExampleService/UnaryExample" - ExampleService_StreamExample_FullMethodName = "/main.ExampleService/StreamExample" -) - -// ExampleServiceClient is the client API for ExampleService service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -// -// ExampleService defines the gRPC service. -type ExampleServiceClient interface { - UnaryExample(ctx context.Context, in *ExampleRequest, opts ...grpc.CallOption) (*ExampleResponse, error) - StreamExample(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExampleRequest, ExampleResponse], error) -} - -type exampleServiceClient struct { - cc grpc.ClientConnInterface -} - -func NewExampleServiceClient(cc grpc.ClientConnInterface) ExampleServiceClient { - return &exampleServiceClient{cc} -} - -func (c *exampleServiceClient) UnaryExample(ctx context.Context, in *ExampleRequest, opts ...grpc.CallOption) (*ExampleResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(ExampleResponse) - err := c.cc.Invoke(ctx, ExampleService_UnaryExample_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -func (c *exampleServiceClient) StreamExample(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExampleRequest, ExampleResponse], error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &ExampleService_ServiceDesc.Streams[0], ExampleService_StreamExample_FullMethodName, cOpts...) - if err != nil { - return nil, err - } - x := &grpc.GenericClientStream[ExampleRequest, ExampleResponse]{ClientStream: stream} - return x, nil -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ExampleService_StreamExampleClient = grpc.BidiStreamingClient[ExampleRequest, ExampleResponse] - -// ExampleServiceServer is the server API for ExampleService service. -// All implementations must embed UnimplementedExampleServiceServer -// for forward compatibility. -// -// ExampleService defines the gRPC service. -type ExampleServiceServer interface { - UnaryExample(context.Context, *ExampleRequest) (*ExampleResponse, error) - StreamExample(grpc.BidiStreamingServer[ExampleRequest, ExampleResponse]) error - mustEmbedUnimplementedExampleServiceServer() -} - -// UnimplementedExampleServiceServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedExampleServiceServer struct{} - -func (UnimplementedExampleServiceServer) UnaryExample(context.Context, *ExampleRequest) (*ExampleResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method UnaryExample not implemented") -} -func (UnimplementedExampleServiceServer) StreamExample(grpc.BidiStreamingServer[ExampleRequest, ExampleResponse]) error { - return status.Errorf(codes.Unimplemented, "method StreamExample not implemented") -} -func (UnimplementedExampleServiceServer) mustEmbedUnimplementedExampleServiceServer() {} -func (UnimplementedExampleServiceServer) testEmbeddedByValue() {} - -// UnsafeExampleServiceServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to ExampleServiceServer will -// result in compilation errors. -type UnsafeExampleServiceServer interface { - mustEmbedUnimplementedExampleServiceServer() -} - -func RegisterExampleServiceServer(s grpc.ServiceRegistrar, srv ExampleServiceServer) { - // If the following call pancis, it indicates UnimplementedExampleServiceServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&ExampleService_ServiceDesc, srv) -} - -func _ExampleService_UnaryExample_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(ExampleRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(ExampleServiceServer).UnaryExample(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: ExampleService_UnaryExample_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(ExampleServiceServer).UnaryExample(ctx, req.(*ExampleRequest)) - } - return interceptor(ctx, in, info, handler) -} - -func _ExampleService_StreamExample_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(ExampleServiceServer).StreamExample(&grpc.GenericServerStream[ExampleRequest, ExampleResponse]{ServerStream: stream}) -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type ExampleService_StreamExampleServer = grpc.BidiStreamingServer[ExampleRequest, ExampleResponse] - -// ExampleService_ServiceDesc is the grpc.ServiceDesc for ExampleService service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var ExampleService_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "main.ExampleService", - HandlerType: (*ExampleServiceServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "UnaryExample", - Handler: _ExampleService_UnaryExample_Handler, - }, - }, - Streams: []grpc.StreamDesc{ - { - StreamName: "StreamExample", - Handler: _ExampleService_StreamExample_Handler, - ServerStreams: true, - ClientStreams: true, - }, - }, - Metadata: "example.proto", -} diff --git a/_examples/grpc_server/main.go b/_examples/grpc_server/main.go deleted file mode 100644 index defe1b09a..000000000 --- a/_examples/grpc_server/main.go +++ /dev/null @@ -1,95 +0,0 @@ -package main - -import ( - "context" - "fmt" - "grpcdemo/cmd/server/examplepb" - "log" - "net" - "time" - - "github.com/getsentry/sentry-go" - sentrygrpc "github.com/getsentry/sentry-go/grpc" - "google.golang.org/grpc" - "google.golang.org/grpc/metadata" -) - -const grpcPort = ":50051" - -// ExampleServiceServer is the server implementation for the ExampleService. -type ExampleServiceServer struct { - examplepb.UnimplementedExampleServiceServer -} - -// UnaryExample handles unary gRPC requests. -func (s *ExampleServiceServer) UnaryExample(ctx context.Context, req *examplepb.ExampleRequest) (*examplepb.ExampleResponse, error) { - md, _ := metadata.FromIncomingContext(ctx) - fmt.Printf("Received Unary Request: %v\nMetadata: %v\n", req.Message, md) - - // Simulate an error for demonstration - if req.Message == "error" { - return nil, fmt.Errorf("simulated unary error") - } - - return &examplepb.ExampleResponse{Message: fmt.Sprintf("Hello, %s!", req.Message)}, nil -} - -// StreamExample handles bidirectional streaming gRPC requests. -func (s *ExampleServiceServer) StreamExample(stream examplepb.ExampleService_StreamExampleServer) error { - for { - req, err := stream.Recv() - if err != nil { - fmt.Printf("Stream Recv Error: %v\n", err) - return err - } - - fmt.Printf("Received Stream Message: %v\n", req.Message) - - if req.Message == "error" { - return fmt.Errorf("simulated stream error") - } - - err = stream.Send(&examplepb.ExampleResponse{Message: fmt.Sprintf("Echo: %s", req.Message)}) - if err != nil { - fmt.Printf("Stream Send Error: %v\n", err) - return err - } - } -} - -func main() { - // Initialize Sentry - err := sentry.Init(sentry.ClientOptions{ - Dsn: "", - TracesSampleRate: 1.0, - }) - if err != nil { - log.Fatalf("sentry.Init: %s", err) - } - defer sentry.Flush(2 * time.Second) - - // Create a new gRPC server with Sentry interceptors - server := grpc.NewServer( - grpc.UnaryInterceptor(sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{ - Repanic: true, - CaptureRequestBody: true, - })), - grpc.StreamInterceptor(sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{ - Repanic: true, - })), - ) - - // Register the ExampleService - examplepb.RegisterExampleServiceServer(server, &ExampleServiceServer{}) - - // Start the server - listener, err := net.Listen("tcp", grpcPort) - if err != nil { - log.Fatalf("Failed to listen on port %s: %v", grpcPort, err) - } - - fmt.Printf("gRPC server is running on %s\n", grpcPort) - if err := server.Serve(listener); err != nil { - log.Fatalf("Failed to serve: %v", err) - } -} diff --git a/grpc/go.mod b/grpc/go.mod index 879cf6eb0..00114b6c2 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -7,7 +7,7 @@ replace github.com/getsentry/sentry-go => ../ require ( github.com/getsentry/sentry-go v0.30.0 github.com/stretchr/testify v1.8.2 - google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 + google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 google.golang.org/grpc v1.69.2 ) @@ -15,9 +15,9 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.30.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect - google.golang.org/protobuf v1.35.1 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/protobuf v1.36.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/grpc/go.sum b/grpc/go.sum index c27b84b3f..e43760af9 100644 --- a/grpc/go.sum +++ b/grpc/go.sum @@ -45,18 +45,18 @@ go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HY go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= 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/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 2f3c7c59c5ce7f71ad0cbfdd21659ae7ac86faa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Ribi=C4=87?= Date: Fri, 27 Dec 2024 09:58:17 +0100 Subject: [PATCH 05/39] Update grpc example structure --- _examples/grpc/client/main.go | 116 +++++++++++ _examples/grpc/server/example.proto | 21 ++ _examples/grpc/server/examplepb/example.pb.go | 191 ++++++++++++++++++ .../grpc/server/examplepb/example_grpc.pb.go | 158 +++++++++++++++ _examples/grpc/server/main.go | 95 +++++++++ 5 files changed, 581 insertions(+) create mode 100644 _examples/grpc/client/main.go create mode 100644 _examples/grpc/server/example.proto create mode 100644 _examples/grpc/server/examplepb/example.pb.go create mode 100644 _examples/grpc/server/examplepb/example_grpc.pb.go create mode 100644 _examples/grpc/server/main.go diff --git a/_examples/grpc/client/main.go b/_examples/grpc/client/main.go new file mode 100644 index 000000000..144ad59d2 --- /dev/null +++ b/_examples/grpc/client/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "grpcdemo/cmd/server/examplepb" + + "github.com/getsentry/sentry-go" + sentrygrpc "github.com/getsentry/sentry-go/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +const grpcServerAddress = "localhost:50051" + +func main() { + // Initialize Sentry + err := sentry.Init(sentry.ClientOptions{ + Dsn: "", + TracesSampleRate: 1.0, + }) + if err != nil { + log.Fatalf("sentry.Init: %s", err) + } + defer sentry.Flush(2 * time.Second) + + // Create a connection to the gRPC server with Sentry interceptors + conn, err := grpc.NewClient( + grpcServerAddress, + grpc.WithTransportCredentials(insecure.NewCredentials()), // Use TLS in production + grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor(sentrygrpc.ClientOptions{})), + grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor(sentrygrpc.ClientOptions{})), + ) + if err != nil { + log.Fatalf("Failed to connect to gRPC server: %s", err) + } + defer conn.Close() + + // Create a client for the ExampleService + client := examplepb.NewExampleServiceClient(conn) + + // Perform Unary call + fmt.Println("Performing Unary Call:") + unaryExample(client) + + // Perform Streaming call + fmt.Println("\nPerforming Streaming Call:") + streamExample(client) +} + +func unaryExample(client examplepb.ExampleServiceClient) { + ctx := context.Background() + + // Add metadata to the context + ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs( + "custom-header", "value", + )) + + req := &examplepb.ExampleRequest{ + Message: "Hello, server!", // Change to "error" to simulate an error + } + + res, err := client.UnaryExample(ctx, req) + if err != nil { + fmt.Printf("Unary Call Error: %v\n", err) + sentry.CaptureException(err) + return + } + + fmt.Printf("Unary Response: %s\n", res.Message) +} + +func streamExample(client examplepb.ExampleServiceClient) { + ctx := context.Background() + + // Add metadata to the context + ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs( + "streaming-header", "stream-value", + )) + + stream, err := client.StreamExample(ctx) + if err != nil { + fmt.Printf("Failed to establish stream: %v\n", err) + sentry.CaptureException(err) + return + } + + // Send multiple messages in the stream + messages := []string{"Message 1", "Message 2", "error", "Message 4"} + for _, msg := range messages { + err := stream.Send(&examplepb.ExampleRequest{Message: msg}) + if err != nil { + fmt.Printf("Stream Send Error: %v\n", err) + sentry.CaptureException(err) + return + } + } + + // Close the stream for sending + stream.CloseSend() + + // Receive responses from the server + for { + res, err := stream.Recv() + if err != nil { + fmt.Printf("Stream Recv Error: %v\n", err) + sentry.CaptureException(err) + break + } + fmt.Printf("Stream Response: %s\n", res.Message) + } +} diff --git a/_examples/grpc/server/example.proto b/_examples/grpc/server/example.proto new file mode 100644 index 000000000..356d58f11 --- /dev/null +++ b/_examples/grpc/server/example.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package main; + +option go_package = "github.com/your-username/your-repo/examplepb;examplepb"; + +// ExampleService defines the gRPC service. +service ExampleService { + rpc UnaryExample(ExampleRequest) returns (ExampleResponse); + rpc StreamExample(stream ExampleRequest) returns (stream ExampleResponse); +} + +// ExampleRequest is the request message. +message ExampleRequest { + string message = 1; +} + +// ExampleResponse is the response message. +message ExampleResponse { + string message = 1; +} diff --git a/_examples/grpc/server/examplepb/example.pb.go b/_examples/grpc/server/examplepb/example.pb.go new file mode 100644 index 000000000..84d8b8fbb --- /dev/null +++ b/_examples/grpc/server/examplepb/example.pb.go @@ -0,0 +1,191 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.1 +// protoc v5.29.2 +// source: example.proto + +package examplepb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// ExampleRequest is the request message. +type ExampleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExampleRequest) Reset() { + *x = ExampleRequest{} + mi := &file_example_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExampleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExampleRequest) ProtoMessage() {} + +func (x *ExampleRequest) ProtoReflect() protoreflect.Message { + mi := &file_example_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExampleRequest.ProtoReflect.Descriptor instead. +func (*ExampleRequest) Descriptor() ([]byte, []int) { + return file_example_proto_rawDescGZIP(), []int{0} +} + +func (x *ExampleRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// ExampleResponse is the response message. +type ExampleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExampleResponse) Reset() { + *x = ExampleResponse{} + mi := &file_example_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExampleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExampleResponse) ProtoMessage() {} + +func (x *ExampleResponse) ProtoReflect() protoreflect.Message { + mi := &file_example_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExampleResponse.ProtoReflect.Descriptor instead. +func (*ExampleResponse) Descriptor() ([]byte, []int) { + return file_example_proto_rawDescGZIP(), []int{1} +} + +func (x *ExampleResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +var File_example_proto protoreflect.FileDescriptor + +var file_example_proto_rawDesc = []byte{ + 0x0a, 0x0d, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, + 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x2a, 0x0a, 0x0e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x22, 0x2b, 0x0a, 0x0f, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x32, 0x8f, + 0x01, 0x0a, 0x0e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x12, 0x3b, 0x0a, 0x0c, 0x55, 0x6e, 0x61, 0x72, 0x79, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, + 0x65, 0x12, 0x14, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, + 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, + 0x0a, 0x0d, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x12, + 0x14, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x6d, 0x61, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x61, + 0x6d, 0x70, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, + 0x42, 0x38, 0x5a, 0x36, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x79, + 0x6f, 0x75, 0x72, 0x2d, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x2f, 0x79, 0x6f, 0x75, + 0x72, 0x2d, 0x72, 0x65, 0x70, 0x6f, 0x2f, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x70, 0x62, + 0x3b, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, +} + +var ( + file_example_proto_rawDescOnce sync.Once + file_example_proto_rawDescData = file_example_proto_rawDesc +) + +func file_example_proto_rawDescGZIP() []byte { + file_example_proto_rawDescOnce.Do(func() { + file_example_proto_rawDescData = protoimpl.X.CompressGZIP(file_example_proto_rawDescData) + }) + return file_example_proto_rawDescData +} + +var file_example_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_example_proto_goTypes = []any{ + (*ExampleRequest)(nil), // 0: main.ExampleRequest + (*ExampleResponse)(nil), // 1: main.ExampleResponse +} +var file_example_proto_depIdxs = []int32{ + 0, // 0: main.ExampleService.UnaryExample:input_type -> main.ExampleRequest + 0, // 1: main.ExampleService.StreamExample:input_type -> main.ExampleRequest + 1, // 2: main.ExampleService.UnaryExample:output_type -> main.ExampleResponse + 1, // 3: main.ExampleService.StreamExample:output_type -> main.ExampleResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_example_proto_init() } +func file_example_proto_init() { + if File_example_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_example_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_example_proto_goTypes, + DependencyIndexes: file_example_proto_depIdxs, + MessageInfos: file_example_proto_msgTypes, + }.Build() + File_example_proto = out.File + file_example_proto_rawDesc = nil + file_example_proto_goTypes = nil + file_example_proto_depIdxs = nil +} diff --git a/_examples/grpc/server/examplepb/example_grpc.pb.go b/_examples/grpc/server/examplepb/example_grpc.pb.go new file mode 100644 index 000000000..56f4b3504 --- /dev/null +++ b/_examples/grpc/server/examplepb/example_grpc.pb.go @@ -0,0 +1,158 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.29.2 +// source: example.proto + +package examplepb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ExampleService_UnaryExample_FullMethodName = "/main.ExampleService/UnaryExample" + ExampleService_StreamExample_FullMethodName = "/main.ExampleService/StreamExample" +) + +// ExampleServiceClient is the client API for ExampleService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// ExampleService defines the gRPC service. +type ExampleServiceClient interface { + UnaryExample(ctx context.Context, in *ExampleRequest, opts ...grpc.CallOption) (*ExampleResponse, error) + StreamExample(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExampleRequest, ExampleResponse], error) +} + +type exampleServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewExampleServiceClient(cc grpc.ClientConnInterface) ExampleServiceClient { + return &exampleServiceClient{cc} +} + +func (c *exampleServiceClient) UnaryExample(ctx context.Context, in *ExampleRequest, opts ...grpc.CallOption) (*ExampleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExampleResponse) + err := c.cc.Invoke(ctx, ExampleService_UnaryExample_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *exampleServiceClient) StreamExample(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExampleRequest, ExampleResponse], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &ExampleService_ServiceDesc.Streams[0], ExampleService_StreamExample_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[ExampleRequest, ExampleResponse]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ExampleService_StreamExampleClient = grpc.BidiStreamingClient[ExampleRequest, ExampleResponse] + +// ExampleServiceServer is the server API for ExampleService service. +// All implementations must embed UnimplementedExampleServiceServer +// for forward compatibility. +// +// ExampleService defines the gRPC service. +type ExampleServiceServer interface { + UnaryExample(context.Context, *ExampleRequest) (*ExampleResponse, error) + StreamExample(grpc.BidiStreamingServer[ExampleRequest, ExampleResponse]) error + mustEmbedUnimplementedExampleServiceServer() +} + +// UnimplementedExampleServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedExampleServiceServer struct{} + +func (UnimplementedExampleServiceServer) UnaryExample(context.Context, *ExampleRequest) (*ExampleResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnaryExample not implemented") +} +func (UnimplementedExampleServiceServer) StreamExample(grpc.BidiStreamingServer[ExampleRequest, ExampleResponse]) error { + return status.Errorf(codes.Unimplemented, "method StreamExample not implemented") +} +func (UnimplementedExampleServiceServer) mustEmbedUnimplementedExampleServiceServer() {} +func (UnimplementedExampleServiceServer) testEmbeddedByValue() {} + +// UnsafeExampleServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ExampleServiceServer will +// result in compilation errors. +type UnsafeExampleServiceServer interface { + mustEmbedUnimplementedExampleServiceServer() +} + +func RegisterExampleServiceServer(s grpc.ServiceRegistrar, srv ExampleServiceServer) { + // If the following call pancis, it indicates UnimplementedExampleServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ExampleService_ServiceDesc, srv) +} + +func _ExampleService_UnaryExample_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExampleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExampleServiceServer).UnaryExample(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ExampleService_UnaryExample_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExampleServiceServer).UnaryExample(ctx, req.(*ExampleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ExampleService_StreamExample_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(ExampleServiceServer).StreamExample(&grpc.GenericServerStream[ExampleRequest, ExampleResponse]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type ExampleService_StreamExampleServer = grpc.BidiStreamingServer[ExampleRequest, ExampleResponse] + +// ExampleService_ServiceDesc is the grpc.ServiceDesc for ExampleService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ExampleService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "main.ExampleService", + HandlerType: (*ExampleServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "UnaryExample", + Handler: _ExampleService_UnaryExample_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamExample", + Handler: _ExampleService_StreamExample_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "example.proto", +} diff --git a/_examples/grpc/server/main.go b/_examples/grpc/server/main.go new file mode 100644 index 000000000..defe1b09a --- /dev/null +++ b/_examples/grpc/server/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "fmt" + "grpcdemo/cmd/server/examplepb" + "log" + "net" + "time" + + "github.com/getsentry/sentry-go" + sentrygrpc "github.com/getsentry/sentry-go/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +const grpcPort = ":50051" + +// ExampleServiceServer is the server implementation for the ExampleService. +type ExampleServiceServer struct { + examplepb.UnimplementedExampleServiceServer +} + +// UnaryExample handles unary gRPC requests. +func (s *ExampleServiceServer) UnaryExample(ctx context.Context, req *examplepb.ExampleRequest) (*examplepb.ExampleResponse, error) { + md, _ := metadata.FromIncomingContext(ctx) + fmt.Printf("Received Unary Request: %v\nMetadata: %v\n", req.Message, md) + + // Simulate an error for demonstration + if req.Message == "error" { + return nil, fmt.Errorf("simulated unary error") + } + + return &examplepb.ExampleResponse{Message: fmt.Sprintf("Hello, %s!", req.Message)}, nil +} + +// StreamExample handles bidirectional streaming gRPC requests. +func (s *ExampleServiceServer) StreamExample(stream examplepb.ExampleService_StreamExampleServer) error { + for { + req, err := stream.Recv() + if err != nil { + fmt.Printf("Stream Recv Error: %v\n", err) + return err + } + + fmt.Printf("Received Stream Message: %v\n", req.Message) + + if req.Message == "error" { + return fmt.Errorf("simulated stream error") + } + + err = stream.Send(&examplepb.ExampleResponse{Message: fmt.Sprintf("Echo: %s", req.Message)}) + if err != nil { + fmt.Printf("Stream Send Error: %v\n", err) + return err + } + } +} + +func main() { + // Initialize Sentry + err := sentry.Init(sentry.ClientOptions{ + Dsn: "", + TracesSampleRate: 1.0, + }) + if err != nil { + log.Fatalf("sentry.Init: %s", err) + } + defer sentry.Flush(2 * time.Second) + + // Create a new gRPC server with Sentry interceptors + server := grpc.NewServer( + grpc.UnaryInterceptor(sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{ + Repanic: true, + CaptureRequestBody: true, + })), + grpc.StreamInterceptor(sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{ + Repanic: true, + })), + ) + + // Register the ExampleService + examplepb.RegisterExampleServiceServer(server, &ExampleServiceServer{}) + + // Start the server + listener, err := net.Listen("tcp", grpcPort) + if err != nil { + log.Fatalf("Failed to listen on port %s: %v", grpcPort, err) + } + + fmt.Printf("gRPC server is running on %s\n", grpcPort) + if err := server.Serve(listener); err != nil { + log.Fatalf("Failed to serve: %v", err) + } +} From 77a6c95180412cd1aa65a6f8bb94901a0b7fda65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Ribi=C4=87?= Date: Fri, 27 Dec 2024 10:03:41 +0100 Subject: [PATCH 06/39] fix tests --- grpc/client_test.go | 3 --- grpc/server_test.go | 12 ++++-------- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/grpc/client_test.go b/grpc/client_test.go index 404145ede..8e51414ee 100644 --- a/grpc/client_test.go +++ b/grpc/client_test.go @@ -16,8 +16,6 @@ import ( const defaultClientOperationName = "grpc.client" func TestClientOptions_SetDefaults(t *testing.T) { - t.Parallel() - tests := map[string]struct { options sentrygrpc.ClientOptions assertions func(t *testing.T, options sentrygrpc.ClientOptions) @@ -53,7 +51,6 @@ func TestClientOptions_SetDefaults(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - t.Parallel() test.options.SetDefaults() test.assertions(t, test.options) diff --git a/grpc/server_test.go b/grpc/server_test.go index 933b865e2..a11d46652 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -19,7 +19,6 @@ import ( const defaultServerOperationName = "grpc.server" func TestServerOptions_SetDefaults(t *testing.T) { - t.Parallel() tests := map[string]struct { options sentrygrpc.ServerOptions assertions func(t *testing.T, options sentrygrpc.ServerOptions) @@ -74,8 +73,6 @@ func TestServerOptions_SetDefaults(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - t.Parallel() - test.options.SetDefaults() test.assertions(t, test.options) @@ -236,7 +233,6 @@ func (w *wrappedServerStream) Context() context.Context { } func TestStreamServerInterceptor(t *testing.T) { - t.Parallel() tests := map[string]struct { options sentrygrpc.ServerOptions handler grpc.StreamHandler @@ -275,7 +271,7 @@ func TestStreamServerInterceptor(t *testing.T) { }, expectedErr: "", expectedMetadata: false, - expectedEvent: false, // The panic is re-raised, event capture may depend on hub state + expectedEvent: true, }, "Metadata is propagated": { options: sentrygrpc.ServerOptions{}, @@ -294,7 +290,6 @@ func TestStreamServerInterceptor(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { - t.Parallel() eventsCh := make(chan *sentry.Event, 1) transactionsCh := make(chan *sentry.Event, 1) @@ -312,13 +307,12 @@ func TestStreamServerInterceptor(t *testing.T) { if err != nil { t.Fatal(err) } - defer sentry.Flush(2 * time.Second) interceptor := sentrygrpc.StreamServerInterceptor(test.options) // Simulate a server stream stream := &wrappedServerStream{ - ServerStream: nil, // Mock implementation or use a testing framework + ServerStream: nil, ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")), } @@ -357,6 +351,8 @@ func TestStreamServerInterceptor(t *testing.T) { assert.NotNil(t, recovered, "Expected panic to be re-raised") assert.Equal(t, "test panic", recovered, "Panic value should match") } + + sentry.Flush(2 * time.Second) }) } } From 16ee674d0af18779e25b688470be8e55a91838c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Ribi=C4=87?= Date: Fri, 27 Dec 2024 10:07:00 +0100 Subject: [PATCH 07/39] add span origin --- grpc/server.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/grpc/server.go b/grpc/server.go index e8d9dc0d4..016db7316 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -138,6 +138,7 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { sentry.WithOpName(opts.OperationName), sentry.WithDescription(info.FullMethod), sentry.WithTransactionSource(sentry.SourceURL), + sentry.WithSpanOrigin(sentry.SpanOriginGrpc), } transaction := sentry.StartTransaction( @@ -204,6 +205,7 @@ func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { sentry.WithOpName(opts.OperationName), sentry.WithDescription(info.FullMethod), sentry.WithTransactionSource(sentry.SourceURL), + sentry.WithSpanOrigin(sentry.SpanOriginGrpc), } transaction := sentry.StartTransaction( From 2f47b4373320fc0965db0126fc55c359c6ac3750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Ribi=C4=87?= Date: Fri, 27 Dec 2024 10:23:04 +0100 Subject: [PATCH 08/39] downgrade grpc dependency version --- grpc/go.mod | 6 +++--- grpc/go.sum | 33 ++++----------------------------- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/grpc/go.mod b/grpc/go.mod index 00114b6c2..194669eb1 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -1,14 +1,14 @@ module github.com/getsentry/sentry-go/grpc -go 1.22 +go 1.21 replace github.com/getsentry/sentry-go => ../ require ( github.com/getsentry/sentry-go v0.30.0 - github.com/stretchr/testify v1.8.2 + github.com/stretchr/testify v1.10.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 - google.golang.org/grpc v1.69.2 + google.golang.org/grpc v1.67.3 ) require ( diff --git a/grpc/go.sum b/grpc/go.sum index e43760af9..ebaece8c9 100644 --- a/grpc/go.sum +++ b/grpc/go.sum @@ -1,19 +1,10 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -26,23 +17,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= @@ -53,13 +29,12 @@ golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A= google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= -google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= -google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8= +google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 8c75a66a32db66eac6354045a486d66db1cc70c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emir=20Ribi=C4=87?= Date: Fri, 27 Dec 2024 22:40:29 +0100 Subject: [PATCH 09/39] update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f6b7237..b9d754c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ ### Features -Add ability to override `hub` in `context` for integrations that use custom context ([#931](https://github.com/getsentry/sentry-go/pull/931)) +- Add ability to override `hub` in `context` for integrations that use custom context ([#931](https://github.com/getsentry/sentry-go/pull/931)) + +- Add `grpc` integration ([#938](https://github.com/getsentry/sentry-go/pull/938)) ## 0.30.0 From c7616c73aa21109a0ef9b15a799d3996833b9303 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis Date: Tue, 29 Apr 2025 10:52:19 +0200 Subject: [PATCH 10/39] chore: remove operation name option from server --- grpc/server.go | 22 +++------------------ grpc/server_test.go | 47 --------------------------------------------- 2 files changed, 3 insertions(+), 66 deletions(-) diff --git a/grpc/server.go b/grpc/server.go index 016db7316..73f8ace8f 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -31,12 +31,6 @@ type ServerOptions struct { // ReportOn defines the conditions under which errors are reported to Sentry. ReportOn func(error) bool - - // CaptureRequestBody determines whether to capture and send request bodies to Sentry. - CaptureRequestBody bool - - // OperationName overrides the default operation name (grpc.server). - OperationName string } func (o *ServerOptions) SetDefaults() { @@ -49,10 +43,6 @@ func (o *ServerOptions) SetDefaults() { if o.Timeout == 0 { o.Timeout = sentry.DefaultFlushTimeout } - - if o.OperationName == "" { - o.OperationName = defaultServerOperationName - } } func recoverWithSentry(ctx context.Context, hub *sentry.Hub, o ServerOptions) { @@ -135,7 +125,7 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), - sentry.WithOpName(opts.OperationName), + sentry.WithOpName(defaultServerOperationName), sentry.WithDescription(info.FullMethod), sentry.WithTransactionSource(sentry.SourceURL), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), @@ -152,11 +142,6 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { ctx = transaction.Context() defer transaction.Finish() - if opts.CaptureRequestBody { - // Marshal from proto.Message to bytes? Slow? - // hub.Scope().SetRequestBody(req) - } - defer recoverWithSentry(ctx, hub, opts) resp, err := handler(ctx, req) @@ -202,7 +187,6 @@ func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), - sentry.WithOpName(opts.OperationName), sentry.WithDescription(info.FullMethod), sentry.WithTransactionSource(sentry.SourceURL), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), @@ -281,8 +265,8 @@ var codeToSpanStatus = map[codes.Code]sentry.SpanStatus{ } func toSpanStatus(code codes.Code) sentry.SpanStatus { - if status, ok := codeToSpanStatus[code]; ok { - return status + if spanStatus, ok := codeToSpanStatus[code]; ok { + return spanStatus } return sentry.SpanStatusUndefined } diff --git a/grpc/server_test.go b/grpc/server_test.go index a11d46652..cabb9ec80 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -16,8 +16,6 @@ import ( "google.golang.org/grpc/status" ) -const defaultServerOperationName = "grpc.server" - func TestServerOptions_SetDefaults(t *testing.T) { tests := map[string]struct { options sentrygrpc.ServerOptions @@ -28,7 +26,6 @@ func TestServerOptions_SetDefaults(t *testing.T) { assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { assert.NotNil(t, options.ReportOn, "ReportOn should be set to default function") assert.Equal(t, sentry.DefaultFlushTimeout, options.Timeout, "Timeout should be set to default value") - assert.Equal(t, defaultServerOperationName, options.OperationName, "OperationName should be set to default value") }, }, "Custom ReportOn is preserved": { @@ -53,22 +50,6 @@ func TestServerOptions_SetDefaults(t *testing.T) { assert.Equal(t, 5*time.Second, options.Timeout, "Timeout should be set to custom value") }, }, - "Custom OperationName is preserved": { - options: sentrygrpc.ServerOptions{ - OperationName: "custom.operation", - }, - assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { - assert.Equal(t, "custom.operation", options.OperationName, "OperationName should be set to custom value") - }, - }, - "CaptureRequestBody remains unchanged": { - options: sentrygrpc.ServerOptions{ - CaptureRequestBody: true, - }, - assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { - assert.True(t, options.CaptureRequestBody, "CaptureRequestBody should remain true") - }, - }, } for name, test := range tests { @@ -120,34 +101,6 @@ func TestUnaryServerInterceptor(t *testing.T) { expectedErr: "rpc error: code = Internal desc = handler error not reported", assertTransaction: true, }, - "Capture request body when enabled": { - options: sentrygrpc.ServerOptions{ - CaptureRequestBody: true, - ReportOn: func(err error) bool { - return true - }, - }, - handler: func(ctx context.Context, req any) (any, error) { - return nil, status.Error(codes.InvalidArgument, "invalid request body") - }, - expectedErr: "rpc error: code = InvalidArgument desc = invalid request body", - wantException: "rpc error: code = InvalidArgument desc = invalid request body", - assertTransaction: true, - }, - "Custom operation name is used in transaction": { - options: sentrygrpc.ServerOptions{ - OperationName: "CustomUnaryOperation", - ReportOn: func(err error) bool { - return true - }, - }, - handler: func(ctx context.Context, req any) (any, error) { - return nil, status.Error(codes.PermissionDenied, "access denied") - }, - expectedErr: "rpc error: code = PermissionDenied desc = access denied", - wantException: "rpc error: code = PermissionDenied desc = access denied", - assertTransaction: true, - }, } for name, test := range tests { From 2142591bdbf81dfa3e4dfdec20be13ffb3cee3c1 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis Date: Fri, 2 May 2025 11:34:35 +0200 Subject: [PATCH 11/39] chore: improve test coverage --- grpc/client_test.go | 20 +++++++++++++++++++- grpc/server_test.go | 38 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/grpc/client_test.go b/grpc/client_test.go index b6d4dd082..b65b8a216 100644 --- a/grpc/client_test.go +++ b/grpc/client_test.go @@ -47,6 +47,18 @@ func TestClientOptions_SetDefaults(t *testing.T) { assert.Equal(t, "custom.operation", options.OperationName, "OperationName should be set to custom value") }, }, + "Both custom ReportOn and OperationName are preserved": { + options: sentrygrpc.ClientOptions{ + ReportOn: func(err error) bool { + return false + }, + OperationName: "custom.operation", + }, + assertions: func(t *testing.T, options sentrygrpc.ClientOptions) { + assert.Equal(t, "custom.operation", options.OperationName) + assert.False(t, options.ReportOn(errors.New("any error"))) + }, + }, } for name, test := range tests { @@ -60,12 +72,14 @@ func TestClientOptions_SetDefaults(t *testing.T) { func TestUnaryClientInterceptor(t *testing.T) { tests := map[string]struct { + ctx context.Context invoker grpc.UnaryInvoker options sentrygrpc.ClientOptions expectedErr error assertions func(t *testing.T, transport *sentry.MockTransport) }{ "Default behavior, no error": { + ctx: context.Background(), invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { return nil }, @@ -75,6 +89,7 @@ func TestUnaryClientInterceptor(t *testing.T) { }, }, "Error is reported": { + ctx: context.Background(), invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { return errors.New("test error") }, @@ -87,17 +102,20 @@ func TestUnaryClientInterceptor(t *testing.T) { }, }, "Metadata propagation": { + ctx: metadata.NewOutgoingContext(context.Background(), metadata.Pairs("existing", "value")), invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { md, ok := metadata.FromOutgoingContext(ctx) assert.True(t, ok, "Metadata should be present in the outgoing context") assert.Contains(t, md, sentry.SentryTraceHeader, "Metadata should contain Sentry trace header") assert.Contains(t, md, sentry.SentryBaggageHeader, "Metadata should contain Sentry baggage header") + assert.Contains(t, md, "existing", "Metadata should contain key") return nil }, options: sentrygrpc.ClientOptions{}, assertions: func(t *testing.T, transport *sentry.MockTransport) {}, }, "Custom ReportOn behavior": { + ctx: context.Background(), invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { return errors.New("test error") }, @@ -123,7 +141,7 @@ func TestUnaryClientInterceptor(t *testing.T) { interceptor := sentrygrpc.UnaryClientInterceptor(test.options) // Execute the interceptor - err := interceptor(context.Background(), "/test.Service/TestMethod", struct{}{}, struct{}{}, nil, test.invoker) + err := interceptor(test.ctx, "/test.Service/TestMethod", struct{}{}, struct{}{}, nil, test.invoker) if test.expectedErr != nil { assert.Equal(t, test.expectedErr, err, "Expected error mismatch") diff --git a/grpc/server_test.go b/grpc/server_test.go index cabb9ec80..12df9a93d 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "google.golang.org/genproto/googleapis/rpc/errdetails" "testing" "time" @@ -16,6 +17,15 @@ import ( "google.golang.org/grpc/status" ) +func errorWithDebugInfo() error { + st := status.New(codes.Internal, "debug info error") + stWithDetails, _ := st.WithDetails(&errdetails.DebugInfo{ + Detail: "debugging something broke", + StackEntries: []string{"main.go:10", "server.go:15"}, + }) + return stWithDetails.Err() +} + func TestServerOptions_SetDefaults(t *testing.T) { tests := map[string]struct { options sentrygrpc.ServerOptions @@ -65,13 +75,17 @@ func TestUnaryServerInterceptor(t *testing.T) { tests := map[string]struct { options sentrygrpc.ServerOptions handler grpc.UnaryHandler + ctx context.Context expectedErr string wantException string wantTransaction *sentry.Event + expectedMetadata string assertTransaction bool }{ "Handle panic and re-panic": { - options: sentrygrpc.ServerOptions{Repanic: true}, + options: sentrygrpc.ServerOptions{Repanic: true}, + ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("md", "some")), + expectedMetadata: "some", handler: func(ctx context.Context, req any) (any, error) { panic("test panic") }, @@ -82,6 +96,7 @@ func TestUnaryServerInterceptor(t *testing.T) { return true }, }, + ctx: context.Background(), handler: func(ctx context.Context, req any) (any, error) { return nil, status.Error(codes.Internal, "handler error") }, @@ -95,12 +110,28 @@ func TestUnaryServerInterceptor(t *testing.T) { return false }, }, + ctx: context.Background(), handler: func(ctx context.Context, req any) (any, error) { return nil, status.Error(codes.Internal, "handler error not reported") }, expectedErr: "rpc error: code = Internal desc = handler error not reported", assertTransaction: true, }, + "Report error with DebugInfo breadcrumb": { + options: sentrygrpc.ServerOptions{ + ReportOn: func(err error) bool { + return true + }, + }, + ctx: context.Background(), + handler: func(ctx context.Context, req any) (any, error) { + return nil, errorWithDebugInfo() + }, + expectedErr: "rpc error: code = Internal desc = debug info error", + wantException: "rpc error: code = Internal desc = debug info error", + assertTransaction: true, + expectedMetadata: "", // optional + }, } for name, test := range tests { @@ -136,7 +167,7 @@ func TestUnaryServerInterceptor(t *testing.T) { } }() - _, err = interceptor(context.Background(), nil, &grpc.UnaryServerInfo{ + _, err = interceptor(test.ctx, nil, &grpc.UnaryServerInfo{ FullMethod: "TestService.Method", }, test.handler) @@ -156,6 +187,9 @@ func TestUnaryServerInterceptor(t *testing.T) { assert.NotNil(t, gotEvent, "Expected an event") assert.Len(t, gotEvent.Exception, 1, "Expected one exception in the event") assert.Equal(t, test.wantException, gotEvent.Exception[0].Value, "Exception values should match") + if test.expectedMetadata != "" { + assert.Equal(t, gotEvent.Extra["md"], test.expectedMetadata) + } } if test.assertTransaction { From 8b79e6eb54acc6aea66111ddfabb8054955690a6 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis Date: Tue, 6 May 2025 12:48:15 +0200 Subject: [PATCH 12/39] remove error capture --- grpc/client.go | 70 ++------------------ grpc/client_test.go | 155 +++----------------------------------------- 2 files changed, 16 insertions(+), 209 deletions(-) diff --git a/grpc/client.go b/grpc/client.go index 1601ffa19..610506b32 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -13,25 +13,6 @@ import ( const defaultClientOperationName = "grpc.client" -type ClientOptions struct { - // ReportOn defines the conditions under which errors are reported to Sentry. - ReportOn func(error) bool - - // OperationName overrides the default operation name (grpc.client). - OperationName string -} - -func (o *ClientOptions) SetDefaults() { - if o.ReportOn == nil { - o.ReportOn = func(err error) bool { - return true - } - } - if o.OperationName == "" { - o.OperationName = defaultClientOperationName - } -} - func createOrUpdateMetadata(ctx context.Context, span *sentry.Span) context.Context { md, ok := metadata.FromOutgoingContext(ctx) if ok { @@ -49,77 +30,38 @@ func createOrUpdateMetadata(ctx context.Context, span *sentry.Span) context.Cont return metadata.NewOutgoingContext(ctx, md) } -func getOrCreateHub(ctx context.Context) (*sentry.Hub, context.Context) { - hub := sentry.GetHubFromContext(ctx) - if hub == nil { - hub = sentry.CurrentHub().Clone() - ctx = sentry.SetHubOnContext(ctx, hub) - } - return hub, ctx -} - -func UnaryClientInterceptor(o ClientOptions) grpc.UnaryClientInterceptor { - o.SetDefaults() +func UnaryClientInterceptor() grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, callOpts ...grpc.CallOption) error { - - hub, ctx := getOrCreateHub(ctx) - - span := sentry.StartSpan(ctx, o.OperationName, sentry.WithDescription(method)) + span := sentry.StartSpan(ctx, defaultClientOperationName, sentry.WithDescription(method)) span.SetData("grpc.request.method", method) ctx = span.Context() ctx = createOrUpdateMetadata(ctx, span) defer span.Finish() - err := invoker(ctx, method, req, reply, cc, callOpts...) - - if err != nil && o.ReportOn(err) { - hub.WithScope(func(scope *sentry.Scope) { - scope.SetTag("grpc.method", method) - scope.SetContext("request", map[string]any{ - "method": method, - "request": req, - }) - hub.CaptureException(err) - }) - } - - return err + return invoker(ctx, method, req, reply, cc, callOpts...) } } -func StreamClientInterceptor(o ClientOptions) grpc.StreamClientInterceptor { - o.SetDefaults() +func StreamClientInterceptor() grpc.StreamClientInterceptor { return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, callOpts ...grpc.CallOption) (grpc.ClientStream, error) { - - hub, ctx := getOrCreateHub(ctx) - - span := sentry.StartSpan(ctx, o.OperationName, sentry.WithDescription(method)) + span := sentry.StartSpan(ctx, defaultClientOperationName, sentry.WithDescription(method)) span.SetData("grpc.request.method", method) ctx = span.Context() ctx = createOrUpdateMetadata(ctx, span) defer span.Finish() - clientStream, err := streamer(ctx, desc, cc, method, callOpts...) - - if err != nil && o.ReportOn(err) { - hub.WithScope(func(scope *sentry.Scope) { - scope.SetTag("grpc.method", method) - hub.CaptureException(err) - }) - } - - return clientStream, err + return streamer(ctx, desc, cc, method, callOpts...) } } diff --git a/grpc/client_test.go b/grpc/client_test.go index b65b8a216..048e5269f 100644 --- a/grpc/client_test.go +++ b/grpc/client_test.go @@ -2,7 +2,6 @@ package sentrygrpc_test import ( "context" - "errors" "testing" "time" @@ -13,94 +12,21 @@ import ( "google.golang.org/grpc/metadata" ) -const defaultClientOperationName = "grpc.client" - -func TestClientOptions_SetDefaults(t *testing.T) { - tests := map[string]struct { - options sentrygrpc.ClientOptions - assertions func(t *testing.T, options sentrygrpc.ClientOptions) - }{ - "Defaults are set when fields are empty": { - options: sentrygrpc.ClientOptions{}, - assertions: func(t *testing.T, options sentrygrpc.ClientOptions) { - assert.NotNil(t, options.ReportOn, "ReportOn should be set to default function") - assert.Equal(t, defaultClientOperationName, options.OperationName, "OperationName should be set to default value") - }, - }, - "Custom ReportOn is preserved": { - options: sentrygrpc.ClientOptions{ - ReportOn: func(err error) bool { - return err.Error() == "custom error" - }, - }, - assertions: func(t *testing.T, options sentrygrpc.ClientOptions) { - assert.NotNil(t, options.ReportOn, "ReportOn should not be nil") - err := errors.New("random error") - assert.False(t, options.ReportOn(err), "ReportOn should return false for random error") - }, - }, - "Custom OperationName is preserved": { - options: sentrygrpc.ClientOptions{ - OperationName: "custom.operation", - }, - assertions: func(t *testing.T, options sentrygrpc.ClientOptions) { - assert.Equal(t, "custom.operation", options.OperationName, "OperationName should be set to custom value") - }, - }, - "Both custom ReportOn and OperationName are preserved": { - options: sentrygrpc.ClientOptions{ - ReportOn: func(err error) bool { - return false - }, - OperationName: "custom.operation", - }, - assertions: func(t *testing.T, options sentrygrpc.ClientOptions) { - assert.Equal(t, "custom.operation", options.OperationName) - assert.False(t, options.ReportOn(errors.New("any error"))) - }, - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - - test.options.SetDefaults() - test.assertions(t, test.options) - }) - } -} - func TestUnaryClientInterceptor(t *testing.T) { tests := map[string]struct { - ctx context.Context - invoker grpc.UnaryInvoker - options sentrygrpc.ClientOptions - expectedErr error - assertions func(t *testing.T, transport *sentry.MockTransport) + ctx context.Context + invoker grpc.UnaryInvoker + assertions func(t *testing.T, transport *sentry.MockTransport) }{ "Default behavior, no error": { ctx: context.Background(), invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { return nil }, - options: sentrygrpc.ClientOptions{}, assertions: func(t *testing.T, transport *sentry.MockTransport) { assert.Empty(t, transport.Events(), "No events should be captured") }, }, - "Error is reported": { - ctx: context.Background(), - invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { - return errors.New("test error") - }, - options: sentrygrpc.ClientOptions{}, - expectedErr: errors.New("test error"), - assertions: func(t *testing.T, transport *sentry.MockTransport) { - events := transport.Events() - assert.Len(t, events, 1, "One event should be captured") - assert.Equal(t, "test error", events[0].Exception[0].Value, "Captured exception should match the error") - }, - }, "Metadata propagation": { ctx: metadata.NewOutgoingContext(context.Background(), metadata.Pairs("existing", "value")), invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { @@ -111,24 +37,8 @@ func TestUnaryClientInterceptor(t *testing.T) { assert.Contains(t, md, "existing", "Metadata should contain key") return nil }, - options: sentrygrpc.ClientOptions{}, assertions: func(t *testing.T, transport *sentry.MockTransport) {}, }, - "Custom ReportOn behavior": { - ctx: context.Background(), - invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { - return errors.New("test error") - }, - options: sentrygrpc.ClientOptions{ - ReportOn: func(err error) bool { - return err.Error() == "specific error" - }, - }, - expectedErr: errors.New("test error"), - assertions: func(t *testing.T, transport *sentry.MockTransport) { - assert.Empty(t, transport.Events(), "No events should be captured due to custom ReportOn") - }, - }, } for name, test := range tests { @@ -138,16 +48,10 @@ func TestUnaryClientInterceptor(t *testing.T) { Transport: transport, }) - interceptor := sentrygrpc.UnaryClientInterceptor(test.options) + interceptor := sentrygrpc.UnaryClientInterceptor() // Execute the interceptor - err := interceptor(test.ctx, "/test.Service/TestMethod", struct{}{}, struct{}{}, nil, test.invoker) - - if test.expectedErr != nil { - assert.Equal(t, test.expectedErr, err, "Expected error mismatch") - } else { - assert.NoError(t, err, "Expected no error") - } + interceptor(test.ctx, "/test.Service/TestMethod", struct{}{}, struct{}{}, nil, test.invoker) sentry.Flush(2 * time.Second) @@ -159,17 +63,14 @@ func TestUnaryClientInterceptor(t *testing.T) { func TestStreamClientInterceptor(t *testing.T) { tests := map[string]struct { - streamer grpc.Streamer - options sentrygrpc.ClientOptions - expectedErr error - assertions func(t *testing.T, transport *sentry.MockTransport) - streamDesc *grpc.StreamDesc + streamer grpc.Streamer + assertions func(t *testing.T, transport *sentry.MockTransport) + streamDesc *grpc.StreamDesc }{ "Default behavior, no error": { streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { return nil, nil }, - options: sentrygrpc.ClientOptions{}, streamDesc: &grpc.StreamDesc{ ClientStreams: true, }, @@ -177,19 +78,6 @@ func TestStreamClientInterceptor(t *testing.T) { assert.Empty(t, transport.Events(), "No events should be captured") }, }, - "Error is reported": { - streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { - return nil, errors.New("test stream error") - }, - options: sentrygrpc.ClientOptions{}, - expectedErr: errors.New("test stream error"), - streamDesc: &grpc.StreamDesc{}, - assertions: func(t *testing.T, transport *sentry.MockTransport) { - events := transport.Events() - assert.Len(t, events, 1, "One event should be captured") - assert.Equal(t, "test stream error", events[0].Exception[0].Value, "Captured exception should match the error") - }, - }, "Metadata propagation": { streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { md, ok := metadata.FromOutgoingContext(ctx) @@ -198,27 +86,11 @@ func TestStreamClientInterceptor(t *testing.T) { assert.Contains(t, md, sentry.SentryBaggageHeader, "Metadata should contain Sentry baggage header") return nil, nil }, - options: sentrygrpc.ClientOptions{}, streamDesc: &grpc.StreamDesc{ ClientStreams: true, }, assertions: func(t *testing.T, transport *sentry.MockTransport) {}, }, - "Custom ReportOn behavior": { - streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { - return nil, errors.New("test stream error") - }, - options: sentrygrpc.ClientOptions{ - ReportOn: func(err error) bool { - return err.Error() == "specific error" - }, - }, - expectedErr: errors.New("test stream error"), - streamDesc: &grpc.StreamDesc{}, - assertions: func(t *testing.T, transport *sentry.MockTransport) { - assert.Empty(t, transport.Events(), "No events should be captured due to custom ReportOn") - }, - }, } for name, test := range tests { @@ -229,17 +101,10 @@ func TestStreamClientInterceptor(t *testing.T) { Transport: transport, }) - interceptor := sentrygrpc.StreamClientInterceptor(test.options) + interceptor := sentrygrpc.StreamClientInterceptor() // Execute the interceptor - clientStream, err := interceptor(context.Background(), test.streamDesc, nil, "/test.Service/TestMethod", test.streamer) - - if test.expectedErr != nil { - assert.Equal(t, test.expectedErr, err, "Expected error mismatch") - } else { - assert.NoError(t, err, "Expected no error") - } - + clientStream, _ := interceptor(context.Background(), test.streamDesc, nil, "/test.Service/TestMethod", test.streamer) sentry.Flush(2 * time.Second) assert.Nil(t, clientStream, "ClientStream should be nil in this test scenario") From 0ae4e61062a4c1a455fe3958d38f8ea276b209c9 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis Date: Tue, 6 May 2025 12:51:45 +0200 Subject: [PATCH 13/39] remove error capture from server --- grpc/server.go | 61 ---------------------------- grpc/server_test.go | 98 --------------------------------------------- 2 files changed, 159 deletions(-) diff --git a/grpc/server.go b/grpc/server.go index 73f8ace8f..c023a6fd6 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -7,7 +7,6 @@ import ( "time" "github.com/getsentry/sentry-go" - "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -28,18 +27,9 @@ type ServerOptions struct { // Timeout sets the maximum duration for Sentry event delivery. Timeout time.Duration - - // ReportOn defines the conditions under which errors are reported to Sentry. - ReportOn func(error) bool } func (o *ServerOptions) SetDefaults() { - if o.ReportOn == nil { - o.ReportOn = func(err error) bool { - return true - } - } - if o.Timeout == 0 { o.Timeout = sentry.DefaultFlushTimeout } @@ -59,45 +49,6 @@ func recoverWithSentry(ctx context.Context, hub *sentry.Hub, o ServerOptions) { } } -func reportErrorToSentry(hub *sentry.Hub, err error, methodName string, req any, md map[string]string) { - hub.WithScope(func(scope *sentry.Scope) { - scope.SetExtras(map[string]any{ - "grpc.method": methodName, - "grpc.error": err.Error(), - }) - - if req != nil { - scope.SetExtra("request", req) - } - - if len(md) > 0 { - scope.SetExtra("metadata", md) - } - - defer hub.CaptureException(err) - - statusErr, ok := status.FromError(err) - if !ok { - return - } - - for _, detail := range statusErr.Details() { - debugInfo, ok := detail.(*errdetails.DebugInfo) - if !ok { - continue - } - hub.AddBreadcrumb(&sentry.Breadcrumb{ - Type: "debug", - Category: "grpc.server", - Message: debugInfo.Detail, - Data: map[string]any{"stackTrace": strings.Join(debugInfo.StackEntries, "\n")}, - Level: sentry.LevelError, - Timestamp: time.Now(), - }, nil) - } - }) -} - func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { opts.SetDefaults() @@ -145,12 +96,6 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { defer recoverWithSentry(ctx, hub, opts) resp, err := handler(ctx, req) - if err != nil && opts.ReportOn(err) { - reportErrorToSentry(hub, err, info.FullMethod, req, data) - - transaction.Sampled = sentry.SampledTrue - } - statusCode := status.Code(err) transaction.Status = toSpanStatus(statusCode) transaction.SetData("http.response.status_code", statusCode.String()) @@ -207,12 +152,6 @@ func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { defer recoverWithSentry(ctx, hub, opts) err := handler(srv, stream) - if err != nil && opts.ReportOn(err) { - reportErrorToSentry(hub, err, info.FullMethod, nil, data) - - transaction.Sampled = sentry.SampledTrue - } - statusCode := status.Code(err) transaction.Status = toSpanStatus(statusCode) transaction.SetData("grpc.status", statusCode.String()) diff --git a/grpc/server_test.go b/grpc/server_test.go index 12df9a93d..01c1d4ebe 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -2,9 +2,7 @@ package sentrygrpc_test import ( "context" - "errors" "fmt" - "google.golang.org/genproto/googleapis/rpc/errdetails" "testing" "time" @@ -17,15 +15,6 @@ import ( "google.golang.org/grpc/status" ) -func errorWithDebugInfo() error { - st := status.New(codes.Internal, "debug info error") - stWithDetails, _ := st.WithDetails(&errdetails.DebugInfo{ - Detail: "debugging something broke", - StackEntries: []string{"main.go:10", "server.go:15"}, - }) - return stWithDetails.Err() -} - func TestServerOptions_SetDefaults(t *testing.T) { tests := map[string]struct { options sentrygrpc.ServerOptions @@ -34,24 +23,9 @@ func TestServerOptions_SetDefaults(t *testing.T) { "Defaults are set when fields are empty": { options: sentrygrpc.ServerOptions{}, assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { - assert.NotNil(t, options.ReportOn, "ReportOn should be set to default function") assert.Equal(t, sentry.DefaultFlushTimeout, options.Timeout, "Timeout should be set to default value") }, }, - "Custom ReportOn is preserved": { - options: sentrygrpc.ServerOptions{ - ReportOn: func(err error) bool { - return err.Error() == "specific error" - }, - }, - assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { - assert.NotNil(t, options.ReportOn, "ReportOn should not be nil") - err := errors.New("random error") - assert.False(t, options.ReportOn(err), "ReportOn should return false for random error") - err = errors.New("specific error") - assert.True(t, options.ReportOn(err), "ReportOn should return true for specific error") - }, - }, "Custom Timeout is preserved": { options: sentrygrpc.ServerOptions{ Timeout: 5 * time.Second, @@ -76,7 +50,6 @@ func TestUnaryServerInterceptor(t *testing.T) { options sentrygrpc.ServerOptions handler grpc.UnaryHandler ctx context.Context - expectedErr string wantException string wantTransaction *sentry.Event expectedMetadata string @@ -90,48 +63,6 @@ func TestUnaryServerInterceptor(t *testing.T) { panic("test panic") }, }, - "Report error with transaction": { - options: sentrygrpc.ServerOptions{ - ReportOn: func(err error) bool { - return true - }, - }, - ctx: context.Background(), - handler: func(ctx context.Context, req any) (any, error) { - return nil, status.Error(codes.Internal, "handler error") - }, - expectedErr: "rpc error: code = Internal desc = handler error", - wantException: "rpc error: code = Internal desc = handler error", - assertTransaction: true, - }, - "Do not report error when ReportOn returns false": { - options: sentrygrpc.ServerOptions{ - ReportOn: func(err error) bool { - return false - }, - }, - ctx: context.Background(), - handler: func(ctx context.Context, req any) (any, error) { - return nil, status.Error(codes.Internal, "handler error not reported") - }, - expectedErr: "rpc error: code = Internal desc = handler error not reported", - assertTransaction: true, - }, - "Report error with DebugInfo breadcrumb": { - options: sentrygrpc.ServerOptions{ - ReportOn: func(err error) bool { - return true - }, - }, - ctx: context.Background(), - handler: func(ctx context.Context, req any) (any, error) { - return nil, errorWithDebugInfo() - }, - expectedErr: "rpc error: code = Internal desc = debug info error", - wantException: "rpc error: code = Internal desc = debug info error", - assertTransaction: true, - expectedMetadata: "", // optional - }, } for name, test := range tests { @@ -171,12 +102,6 @@ func TestUnaryServerInterceptor(t *testing.T) { FullMethod: "TestService.Method", }, test.handler) - if test.expectedErr != "" { - assert.EqualError(t, err, test.expectedErr) - } else { - assert.NoError(t, err) - } - if test.wantException != "" { close(eventsCh) var gotEvent *sentry.Event @@ -223,7 +148,6 @@ func TestStreamServerInterceptor(t *testing.T) { tests := map[string]struct { options sentrygrpc.ServerOptions handler grpc.StreamHandler - expectedErr string expectedMetadata bool expectedEvent bool }{ @@ -232,23 +156,9 @@ func TestStreamServerInterceptor(t *testing.T) { handler: func(srv any, stream grpc.ServerStream) error { return nil }, - expectedErr: "", expectedMetadata: false, expectedEvent: false, }, - "Handler returns an error": { - options: sentrygrpc.ServerOptions{ - ReportOn: func(err error) bool { - return true - }, - }, - handler: func(srv any, stream grpc.ServerStream) error { - return status.Error(codes.Internal, "stream error") - }, - expectedErr: "rpc error: code = Internal desc = stream error", - expectedMetadata: false, - expectedEvent: true, - }, "Repanic is enabled": { options: sentrygrpc.ServerOptions{ Repanic: true, @@ -256,7 +166,6 @@ func TestStreamServerInterceptor(t *testing.T) { handler: func(srv any, stream grpc.ServerStream) error { panic("test panic") }, - expectedErr: "", expectedMetadata: false, expectedEvent: true, }, @@ -269,7 +178,6 @@ func TestStreamServerInterceptor(t *testing.T) { } return nil }, - expectedErr: "", expectedMetadata: true, expectedEvent: false, }, @@ -311,12 +219,6 @@ func TestStreamServerInterceptor(t *testing.T) { err = interceptor(nil, stream, &grpc.StreamServerInfo{FullMethod: "TestService.StreamMethod"}, test.handler) }() - if test.expectedErr != "" { - assert.EqualError(t, err, test.expectedErr) - } else { - assert.NoError(t, err) - } - if test.expectedMetadata { md, ok := metadata.FromIncomingContext(stream.Context()) assert.True(t, ok, "Expected metadata to be propagated in context") From 9e512130f6227609045bbbec59960d3213e72701 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis Date: Tue, 6 May 2025 12:55:17 +0200 Subject: [PATCH 14/39] update client examples --- _examples/grpc/client/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/_examples/grpc/client/main.go b/_examples/grpc/client/main.go index 144ad59d2..fb598cba4 100644 --- a/_examples/grpc/client/main.go +++ b/_examples/grpc/client/main.go @@ -32,8 +32,8 @@ func main() { conn, err := grpc.NewClient( grpcServerAddress, grpc.WithTransportCredentials(insecure.NewCredentials()), // Use TLS in production - grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor(sentrygrpc.ClientOptions{})), - grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor(sentrygrpc.ClientOptions{})), + grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor()), + grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor()), ) if err != nil { log.Fatalf("Failed to connect to gRPC server: %s", err) From 6b3eefb40ed3857a2a377d41a86fc62973cf5b7d Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis Date: Tue, 6 May 2025 12:56:15 +0200 Subject: [PATCH 15/39] update README --- grpc/README.MD | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/grpc/README.MD b/grpc/README.MD index a4598b1da..80d13a82c 100644 --- a/grpc/README.MD +++ b/grpc/README.MD @@ -96,8 +96,8 @@ func main() { conn, err := grpc.Dial( "localhost:50051", grpc.WithInsecure(), - grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor(sentrygrpc.ClientOptions{})), - grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor(sentrygrpc.ClientOptions{})), + grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor()), + grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor()), ) if err != nil { sentry.CaptureException(err) From 8404fc45f97cc698e6cbfbecec6f741c9bff44a7 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:57:33 +0200 Subject: [PATCH 16/39] update go and sdk version --- grpc/go.mod | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/grpc/go.mod b/grpc/go.mod index 65ac87a9f..a7e023870 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -1,13 +1,12 @@ module github.com/getsentry/sentry-go/grpc -go 1.21 +go 1.24 replace github.com/getsentry/sentry-go => ../ require ( - github.com/getsentry/sentry-go v0.32.0 + github.com/getsentry/sentry-go v0.43.0 github.com/stretchr/testify v1.10.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 google.golang.org/grpc v1.67.3 ) @@ -18,6 +17,7 @@ require ( golang.org/x/net v0.33.0 // indirect golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect google.golang.org/protobuf v1.36.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) From 1d90a1e2bf6c012a62377d264fafd977c6e7f3f8 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:01:05 +0200 Subject: [PATCH 17/39] fix rpc span attributes --- grpc/client.go | 121 ++++++++++++++++++++++++++++++++++++++++---- grpc/server.go | 113 ++++++++++++++++++++++++++++++----------- grpc/server_test.go | 110 +++++++++++++++++++++++++++------------- tracing.go | 2 +- 4 files changed, 273 insertions(+), 73 deletions(-) diff --git a/grpc/client.go b/grpc/client.go index 610506b32..4062b0ef5 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -5,13 +5,31 @@ package sentrygrpc import ( "context" + "io" + "sync" "github.com/getsentry/sentry-go" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" ) -const defaultClientOperationName = "grpc.client" +const defaultClientOperationName = "rpc.client" + +func hubFromClientContext(ctx context.Context) context.Context { + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = sentry.CurrentHub().Clone() + ctx = sentry.SetHubOnContext(ctx, hub) + } + + if client := hub.Client(); client != nil { + client.SetSDKIdentifier(sdkIdentifier) + } + + return ctx +} func createOrUpdateMetadata(ctx context.Context, span *sentry.Span) context.Context { md, ok := metadata.FromOutgoingContext(ctx) @@ -33,18 +51,30 @@ func createOrUpdateMetadata(ctx context.Context, span *sentry.Span) context.Cont func UnaryClientInterceptor() grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, - req, reply interface{}, + req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, callOpts ...grpc.CallOption) error { - span := sentry.StartSpan(ctx, defaultClientOperationName, sentry.WithDescription(method)) - span.SetData("grpc.request.method", method) + ctx = hubFromClientContext(ctx) + span := sentry.StartSpan( + ctx, + defaultClientOperationName, + sentry.WithTransactionName(method), + sentry.WithDescription(method), + ) + service, _ := splitGRPCMethod(method) + if service != "" { + span.SetData("rpc.service", service) + } ctx = span.Context() ctx = createOrUpdateMetadata(ctx, span) defer span.Finish() - return invoker(ctx, method, req, reply, cc, callOpts...) + err := invoker(ctx, method, req, reply, cc, callOpts...) + span.Status = toSpanStatus(status.Code(err)) + span.SetData("rpc.grpc.status_code", int(status.Code(err))) + return err } } @@ -55,13 +85,86 @@ func StreamClientInterceptor() grpc.StreamClientInterceptor { method string, streamer grpc.Streamer, callOpts ...grpc.CallOption) (grpc.ClientStream, error) { - span := sentry.StartSpan(ctx, defaultClientOperationName, sentry.WithDescription(method)) - span.SetData("grpc.request.method", method) + ctx = hubFromClientContext(ctx) + span := sentry.StartSpan( + ctx, + defaultClientOperationName, + sentry.WithTransactionName(method), + sentry.WithDescription(method), + ) + service, _ := splitGRPCMethod(method) + if service != "" { + span.SetData("rpc.service", service) + } ctx = span.Context() ctx = createOrUpdateMetadata(ctx, span) - defer span.Finish() - return streamer(ctx, desc, cc, method, callOpts...) + stream, err := streamer(ctx, desc, cc, method, callOpts...) + if err != nil { + span.Status = toSpanStatus(status.Code(err)) + span.SetData("rpc.grpc.status_code", int(status.Code(err))) + span.Finish() + return nil, err + } + if stream == nil { + return nil, nil + } + + return &sentryClientStream{ClientStream: stream, span: span}, nil + } +} + +type sentryClientStream struct { + grpc.ClientStream + span *sentry.Span + finishOnce sync.Once +} + +func (s *sentryClientStream) Header() (metadata.MD, error) { + md, err := s.ClientStream.Header() + if err != nil { + s.finish(err) + } + return md, err +} + +func (s *sentryClientStream) CloseSend() error { + err := s.ClientStream.CloseSend() + if err != nil { + s.finish(err) + } + return err +} + +func (s *sentryClientStream) SendMsg(m any) error { + err := s.ClientStream.SendMsg(m) + if err != nil { + s.finish(err) + } + return err +} + +func (s *sentryClientStream) RecvMsg(m any) error { + err := s.ClientStream.RecvMsg(m) + if err != nil { + if err == io.EOF { + s.finish(nil) + } else { + s.finish(err) + } } + return err +} + +func (s *sentryClientStream) finish(err error) { + s.finishOnce.Do(func() { + s.span.Status = toSpanStatus(status.Code(err)) + if err == nil { + s.span.SetData("rpc.grpc.status_code", int(codes.OK)) + } else { + s.span.SetData("rpc.grpc.status_code", int(status.Code(err))) + } + s.span.Finish() + }) } diff --git a/grpc/server.go b/grpc/server.go index c023a6fd6..fafccd19f 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -2,7 +2,6 @@ package sentrygrpc import ( "context" - "fmt" "strings" "time" @@ -15,7 +14,8 @@ import ( const ( sdkIdentifier = "sentry.go.grpc" - defaultServerOperationName = "grpc.server" + defaultServerOperationName = "rpc.server" + internalServerErrorMessage = "internal server error" ) type ServerOptions struct { @@ -35,10 +35,14 @@ func (o *ServerOptions) SetDefaults() { } } -func recoverWithSentry(ctx context.Context, hub *sentry.Hub, o ServerOptions) { +func recoverWithSentry(ctx context.Context, hub *sentry.Hub, o ServerOptions, onRecover func()) { if r := recover(); r != nil { eventID := hub.RecoverWithContext(ctx, r) + if onRecover != nil { + onRecover() + } + if eventID != nil && o.WaitForDelivery { hub.Flush(o.Timeout) } @@ -52,7 +56,7 @@ func recoverWithSentry(ctx context.Context, hub *sentry.Hub, o ServerOptions) { func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { opts.SetDefaults() - return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { hub := sentry.GetHubFromContext(ctx) if hub == nil { hub = sentry.CurrentHub().Clone() @@ -64,41 +68,44 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { md, ok := metadata.FromIncomingContext(ctx) var sentryTraceHeader, sentryBaggageHeader string - data := make(map[string]string) if ok { sentryTraceHeader = getFirstHeader(md, sentry.SentryTraceHeader) sentryBaggageHeader = getFirstHeader(md, sentry.SentryBaggageHeader) - - for k, v := range md { - data[k] = strings.Join(v, ",") - } } + setScopeMetadata(hub, info.FullMethod, md) + options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), sentry.WithOpName(defaultServerOperationName), sentry.WithDescription(info.FullMethod), - sentry.WithTransactionSource(sentry.SourceURL), + sentry.WithTransactionSource(sentry.SourceCustom), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), } + service, _ := splitGRPCMethod(info.FullMethod) transaction := sentry.StartTransaction( sentry.SetHubOnContext(ctx, hub), - fmt.Sprintf("%s %s", "UnaryServerInterceptor", info.FullMethod), + info.FullMethod, options..., ) - - transaction.SetData("http.request.method", info.FullMethod) + if service != "" { + transaction.SetData("rpc.service", service) + } ctx = transaction.Context() defer transaction.Finish() - defer recoverWithSentry(ctx, hub, opts) + defer recoverWithSentry(ctx, hub, opts, func() { + err = status.Error(codes.Internal, internalServerErrorMessage) + transaction.Status = sentry.SpanStatusInternalError + transaction.SetData("rpc.grpc.status_code", int(codes.Internal)) + }) - resp, err := handler(ctx, req) + resp, err = handler(ctx, req) statusCode := status.Code(err) transaction.Status = toSpanStatus(statusCode) - transaction.SetData("http.response.status_code", statusCode.String()) + transaction.SetData("rpc.grpc.status_code", int(statusCode)) return resp, err } @@ -107,7 +114,7 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { // StreamServerInterceptor provides Sentry integration for streaming gRPC calls. func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { opts.SetDefaults() - return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) (err error) { ctx := ss.Context() hub := sentry.GetHubFromContext(ctx) if hub == nil { @@ -120,41 +127,45 @@ func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { md, ok := metadata.FromIncomingContext(ctx) var sentryTraceHeader, sentryBaggageHeader string - data := make(map[string]string) if ok { sentryTraceHeader = getFirstHeader(md, sentry.SentryTraceHeader) sentryBaggageHeader = getFirstHeader(md, sentry.SentryBaggageHeader) - - for k, v := range md { - data[k] = strings.Join(v, ",") - } } + setScopeMetadata(hub, info.FullMethod, md) + options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), + sentry.WithOpName(defaultServerOperationName), sentry.WithDescription(info.FullMethod), - sentry.WithTransactionSource(sentry.SourceURL), + sentry.WithTransactionSource(sentry.SourceCustom), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), } + service, _ := splitGRPCMethod(info.FullMethod) transaction := sentry.StartTransaction( sentry.SetHubOnContext(ctx, hub), - fmt.Sprintf("%s %s", "StreamServerInterceptor", info.FullMethod), + info.FullMethod, options..., ) - - transaction.SetData("grpc.method", info.FullMethod) + if service != "" { + transaction.SetData("rpc.service", service) + } ctx = transaction.Context() defer transaction.Finish() stream := wrapServerStream(ss, ctx) - defer recoverWithSentry(ctx, hub, opts) + defer recoverWithSentry(ctx, hub, opts, func() { + err = status.Error(codes.Internal, internalServerErrorMessage) + transaction.Status = sentry.SpanStatusInternalError + transaction.SetData("rpc.grpc.status_code", int(codes.Internal)) + }) - err := handler(srv, stream) + err = handler(srv, stream) statusCode := status.Code(err) transaction.Status = toSpanStatus(statusCode) - transaction.SetData("grpc.status", statusCode.String()) + transaction.SetData("rpc.grpc.status_code", int(statusCode)) return err } @@ -167,6 +178,50 @@ func getFirstHeader(md metadata.MD, key string) string { return "" } +func setScopeMetadata(hub *sentry.Hub, method string, md metadata.MD) { + hub.ConfigureScope(func(scope *sentry.Scope) { + scope.SetContext("grpc", sentry.Context{ + "method": method, + "metadata": metadataToContext(md), + }) + }) +} + +func metadataToContext(md metadata.MD) map[string]any { + if len(md) == 0 { + return nil + } + + ctx := make(map[string]any, len(md)) + for key, values := range md { + if len(values) == 1 { + ctx[key] = values[0] + continue + } + + joined := make([]string, len(values)) + copy(joined, values) + ctx[key] = joined + } + + return ctx +} + +func splitGRPCMethod(fullMethod string) (service string, method string) { + trimmed := strings.TrimPrefix(fullMethod, "/") + if trimmed == "" { + return "", "" + } + + parts := strings.SplitN(trimmed, "/", 2) + service = parts[0] + if len(parts) > 1 { + method = parts[1] + } + + return service, method +} + // wrapServerStream wraps a grpc.ServerStream, allowing you to inject a custom context. func wrapServerStream(ss grpc.ServerStream, ctx context.Context) grpc.ServerStream { return &wrappedServerStream{ServerStream: ss, ctx: ctx} diff --git a/grpc/server_test.go b/grpc/server_test.go index 01c1d4ebe..32f2b0b0d 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -2,7 +2,6 @@ package sentrygrpc_test import ( "context" - "fmt" "testing" "time" @@ -50,15 +49,24 @@ func TestUnaryServerInterceptor(t *testing.T) { options sentrygrpc.ServerOptions handler grpc.UnaryHandler ctx context.Context - wantException string - wantTransaction *sentry.Event expectedMetadata string + expectedCode codes.Code assertTransaction bool }{ + "Handle panic and return internal error": { + options: sentrygrpc.ServerOptions{}, + ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("md", "some")), + expectedMetadata: "some", + expectedCode: codes.Internal, + handler: func(ctx context.Context, req any) (any, error) { + panic("test panic") + }, + }, "Handle panic and re-panic": { options: sentrygrpc.ServerOptions{Repanic: true}, ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("md", "some")), expectedMetadata: "some", + expectedCode: codes.Internal, handler: func(ctx context.Context, req any) (any, error) { panic("test panic") }, @@ -76,7 +84,6 @@ func TestUnaryServerInterceptor(t *testing.T) { return event }, BeforeSendTransaction: func(tx *sentry.Event, hint *sentry.EventHint) *sentry.Event { - fmt.Println("Transaction: ", tx.Transaction) transactionsCh <- tx return tx }, @@ -91,7 +98,6 @@ func TestUnaryServerInterceptor(t *testing.T) { defer func() { if r := recover(); r != nil { - // Assert the panic message for tests with repanic enabled if test.options.Repanic { assert.Equal(t, "test panic", r, "Expected panic to propagate with message 'test panic'") } @@ -102,30 +108,35 @@ func TestUnaryServerInterceptor(t *testing.T) { FullMethod: "TestService.Method", }, test.handler) - if test.wantException != "" { - close(eventsCh) - var gotEvent *sentry.Event - for e := range eventsCh { - gotEvent = e - } + if !test.options.Repanic { + assert.Error(t, err) + assert.Equal(t, test.expectedCode, status.Code(err)) + } + + if test.expectedMetadata != "" { + sentry.Flush(2 * time.Second) + gotEvent := <-eventsCh assert.NotNil(t, gotEvent, "Expected an event") - assert.Len(t, gotEvent.Exception, 1, "Expected one exception in the event") - assert.Equal(t, test.wantException, gotEvent.Exception[0].Value, "Exception values should match") - if test.expectedMetadata != "" { - assert.Equal(t, gotEvent.Extra["md"], test.expectedMetadata) - } + grpcContext, ok := gotEvent.Contexts["grpc"] + assert.True(t, ok, "Expected gRPC context on the event") + metadataContext, ok := grpcContext["metadata"].(map[string]interface{}) + assert.True(t, ok, "Expected metadata to be attached to the gRPC context") + assert.Equal(t, test.expectedMetadata, metadataContext["md"]) } if test.assertTransaction { - close(transactionsCh) - var gotTransaction *sentry.Event - for tx := range transactionsCh { - fmt.Println("Transaction: ", tx.Transaction) - gotTransaction = tx - } + sentry.Flush(2 * time.Second) + gotTransaction := <-transactionsCh assert.NotNil(t, gotTransaction, "Expected a transaction") - assert.Equal(t, fmt.Sprintf("UnaryServerInterceptor %s", "TestService.Method"), gotTransaction.Transaction, "Transaction names should match") + assert.Equal(t, "TestService.Method", gotTransaction.Transaction, "Transaction names should match") + assert.Equal(t, "grpc.server", gotTransaction.Contexts["trace"]["op"]) + assert.Equal(t, "TestService.Method", gotTransaction.Contexts["trace"]["description"]) + assert.Equal(t, sentry.SourceCustom, gotTransaction.TransactionInfo.Source) + assert.Equal(t, "TestService.Method", gotTransaction.Contexts["grpc"]["method"]) + assert.Equal(t, sentry.SpanStatusOK, gotTransaction.Contexts["trace"]["status"]) + assert.Equal(t, "TestService.Method", gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.service"]) + assert.Equal(t, codes.OK, gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) } sentry.Flush(2 * time.Second) @@ -146,10 +157,12 @@ func (w *wrappedServerStream) Context() context.Context { func TestStreamServerInterceptor(t *testing.T) { tests := map[string]struct { - options sentrygrpc.ServerOptions - handler grpc.StreamHandler - expectedMetadata bool - expectedEvent bool + options sentrygrpc.ServerOptions + handler grpc.StreamHandler + expectedMetadata bool + expectedEvent bool + expectedCode codes.Code + assertTransaction bool }{ "Default behavior, no error": { options: sentrygrpc.ServerOptions{}, @@ -168,6 +181,16 @@ func TestStreamServerInterceptor(t *testing.T) { }, expectedMetadata: false, expectedEvent: true, + expectedCode: codes.Internal, + }, + "Recovered panic returns internal error": { + options: sentrygrpc.ServerOptions{}, + handler: func(srv any, stream grpc.ServerStream) error { + panic("test panic") + }, + expectedMetadata: false, + expectedEvent: true, + expectedCode: codes.Internal, }, "Metadata is propagated": { options: sentrygrpc.ServerOptions{}, @@ -178,8 +201,9 @@ func TestStreamServerInterceptor(t *testing.T) { } return nil }, - expectedMetadata: true, - expectedEvent: false, + expectedMetadata: true, + expectedEvent: false, + assertTransaction: true, }, } @@ -198,6 +222,8 @@ func TestStreamServerInterceptor(t *testing.T) { transactionsCh <- tx return tx }, + EnableTracing: true, + TracesSampleRate: 1.0, }) if err != nil { t.Fatal(err) @@ -226,16 +252,32 @@ func TestStreamServerInterceptor(t *testing.T) { } if test.expectedEvent { - close(eventsCh) - var gotEvent *sentry.Event - for e := range eventsCh { - gotEvent = e - } + sentry.Flush(2 * time.Second) + gotEvent := <-eventsCh assert.NotNil(t, gotEvent, "Expected an event to be captured") } else { assert.Empty(t, eventsCh, "Expected no event to be captured") } + if test.expectedCode != codes.OK && !test.options.Repanic { + assert.Error(t, err) + assert.Equal(t, test.expectedCode, status.Code(err)) + } + + if test.assertTransaction { + sentry.Flush(2 * time.Second) + gotTransaction := <-transactionsCh + assert.NotNil(t, gotTransaction, "Expected a transaction") + traceContext, ok := gotTransaction.Contexts["trace"] + assert.True(t, ok, "Expected trace context on the transaction") + assert.Equal(t, "grpc.server", traceContext["op"]) + assert.Equal(t, "TestService.StreamMethod", gotTransaction.Transaction) + assert.Equal(t, codes.OK, gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) + assert.Equal(t, "TestService.StreamMethod", gotTransaction.Contexts["trace"]["description"]) + assert.Equal(t, "TestService.StreamMethod", gotTransaction.Contexts["grpc"]["method"]) + assert.Equal(t, "TestService", gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.service"]) + } + if test.options.Repanic { assert.NotNil(t, recovered, "Expected panic to be re-raised") assert.Equal(t, "test panic", recovered, "Panic value should match") diff --git a/tracing.go b/tracing.go index 76999a195..c50544d21 100644 --- a/tracing.go +++ b/tracing.go @@ -31,7 +31,7 @@ const ( SpanOriginStdLib = "auto.http.stdlib" SpanOriginIris = "auto.http.iris" SpanOriginNegroni = "auto.http.negroni" - SpanOriginGrpc = "auto.http.grpc" + SpanOriginGrpc = "auto.rpc.grpc" ) // A Span is the building block of a Sentry transaction. Spans build up a tree From 09330468d2d3c429d51ff6dc86320b0e3c51f7ca Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:16:43 +0200 Subject: [PATCH 18/39] mod tidy --- grpc/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grpc/go.mod b/grpc/go.mod index a7e023870..e410b5e73 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -1,6 +1,6 @@ module github.com/getsentry/sentry-go/grpc -go 1.24 +go 1.24.0 replace github.com/getsentry/sentry-go => ../ From 7912c13806fecc8b327b5566307a7650782d4abc Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:23:12 +0200 Subject: [PATCH 19/39] bump google.golang.org/grpc to latest the dependency needs to get bumped to the latest version to resolve https://github.com/advisories/GHSA-p77j-4mvh-x3m3 --- grpc/go.mod | 12 ++++++------ grpc/go.sum | 13 +++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/grpc/go.mod b/grpc/go.mod index e410b5e73..d5b41267b 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -7,17 +7,17 @@ replace github.com/getsentry/sentry-go => ../ require ( github.com/getsentry/sentry-go v0.43.0 github.com/stretchr/testify v1.10.0 - google.golang.org/grpc v1.67.3 + google.golang.org/grpc v1.79.3 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect - google.golang.org/protobuf v1.36.1 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/grpc/go.sum b/grpc/go.sum index ebaece8c9..dbdb86d02 100644 --- a/grpc/go.sum +++ b/grpc/go.sum @@ -5,6 +5,7 @@ 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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -23,16 +24,28 @@ 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/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A= google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8= google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 8007d7fb209b92123403670fee8fc85481c5a2a6 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:15:29 +0200 Subject: [PATCH 20/39] fix tests --- grpc/server_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/grpc/server_test.go b/grpc/server_test.go index 32f2b0b0d..fbd344a8f 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -130,7 +130,7 @@ func TestUnaryServerInterceptor(t *testing.T) { gotTransaction := <-transactionsCh assert.NotNil(t, gotTransaction, "Expected a transaction") assert.Equal(t, "TestService.Method", gotTransaction.Transaction, "Transaction names should match") - assert.Equal(t, "grpc.server", gotTransaction.Contexts["trace"]["op"]) + assert.Equal(t, "rpc.server", gotTransaction.Contexts["trace"]["op"]) assert.Equal(t, "TestService.Method", gotTransaction.Contexts["trace"]["description"]) assert.Equal(t, sentry.SourceCustom, gotTransaction.TransactionInfo.Source) assert.Equal(t, "TestService.Method", gotTransaction.Contexts["grpc"]["method"]) @@ -270,12 +270,12 @@ func TestStreamServerInterceptor(t *testing.T) { assert.NotNil(t, gotTransaction, "Expected a transaction") traceContext, ok := gotTransaction.Contexts["trace"] assert.True(t, ok, "Expected trace context on the transaction") - assert.Equal(t, "grpc.server", traceContext["op"]) + assert.Equal(t, "rpc.server", traceContext["op"]) assert.Equal(t, "TestService.StreamMethod", gotTransaction.Transaction) - assert.Equal(t, codes.OK, gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) + assert.Equal(t, int(codes.OK), gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) assert.Equal(t, "TestService.StreamMethod", gotTransaction.Contexts["trace"]["description"]) assert.Equal(t, "TestService.StreamMethod", gotTransaction.Contexts["grpc"]["method"]) - assert.Equal(t, "TestService", gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.service"]) + assert.Equal(t, "TestService.StreamMethod", gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.service"]) } if test.options.Repanic { From 9cc7d0d86f0226d56a790e6a57ba0531ff50fe07 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:20:43 +0200 Subject: [PATCH 21/39] revert logrus README change and change timeout --- logrus/README.md | 2 +- sentry.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/logrus/README.md b/logrus/README.md index f9ad4e6ba..939f6a65d 100644 --- a/logrus/README.md +++ b/logrus/README.md @@ -148,4 +148,4 @@ This ensures that logs from specific contexts or threads use the appropriate Sen ## Notes -- Always call Flush to ensure all events are sent to Sentry before program termination +- Always call `Flush` or `FlushWithContext` to ensure all events are sent to Sentry before program termination diff --git a/sentry.go b/sentry.go index 86b6d069a..dbfafa2de 100644 --- a/sentry.go +++ b/sentry.go @@ -13,7 +13,7 @@ const SDKVersion = "0.44.1" const apiVersion = "7" // DefaultFlushTimeout is the default timeout used for flushing events. -const DefaultFlushTimeout = 2 * time.Second +const DefaultFlushTimeout = 5 * time.Second // Init initializes the SDK with options. The returned error is non-nil if // options is invalid, for instance if a malformed DSN is provided. From ec00d57b402684a283382c22f39b6c0ac9254563 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:21:03 +0200 Subject: [PATCH 22/39] go mod tidy --- grpc/go.sum | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/grpc/go.sum b/grpc/go.sum index dbdb86d02..b9937d237 100644 --- a/grpc/go.sum +++ b/grpc/go.sum @@ -1,11 +1,20 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -20,30 +29,32 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= 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/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8= -google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= -google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= -google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 1bc068289ba23fc3e169589f5bcc650d7ab1ae1c Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:33:35 +0200 Subject: [PATCH 23/39] update README and example --- _examples/grpc/server/main.go | 5 ++--- grpc/README.MD | 40 ++++++++--------------------------- 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/_examples/grpc/server/main.go b/_examples/grpc/server/main.go index defe1b09a..eb164a15e 100644 --- a/_examples/grpc/server/main.go +++ b/_examples/grpc/server/main.go @@ -3,13 +3,13 @@ package main import ( "context" "fmt" - "grpcdemo/cmd/server/examplepb" "log" "net" "time" "github.com/getsentry/sentry-go" sentrygrpc "github.com/getsentry/sentry-go/grpc" + "github.com/sentry-go/_examples/grpc/server/examplepb" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) @@ -71,8 +71,7 @@ func main() { // Create a new gRPC server with Sentry interceptors server := grpc.NewServer( grpc.UnaryInterceptor(sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{ - Repanic: true, - CaptureRequestBody: true, + Repanic: true, })), grpc.StreamInterceptor(sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{ Repanic: true, diff --git a/grpc/README.MD b/grpc/README.MD index 80d13a82c..7f6d44fd0 100644 --- a/grpc/README.MD +++ b/grpc/README.MD @@ -42,14 +42,12 @@ func main() { // Create gRPC server with Sentry interceptors server := grpc.NewServer( - sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{ - Repanic: true, - WaitForDelivery: true, - }), - sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{ - Repanic: true, - WaitForDelivery: true, - }), + grpc.UnaryInterceptor(sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{ + Repanic: true, + })), + grpc.StreamInterceptor(sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{ + Repanic: true, + })), ) // Register reflection for debugging @@ -79,6 +77,7 @@ import ( "fmt" "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "github.com/getsentry/sentry-go" sentrygrpc "github.com/getsentry/sentry-go/grpc" @@ -93,9 +92,9 @@ func main() { } // Create gRPC client with Sentry interceptors - conn, err := grpc.Dial( + conn, err := grpc.NewClient( "localhost:50051", - grpc.WithInsecure(), + grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor()), grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor()), ) @@ -133,27 +132,6 @@ type ServerOptions struct { // Timeout sets the maximum duration for Sentry event delivery. Timeout time.Duration - - // ReportOn defines the conditions under which errors are reported to Sentry. - ReportOn func(error) bool - - // CaptureRequestBody determines whether to capture and send request bodies to Sentry. - CaptureRequestBody bool - - // OperationName overrides the default operation name (grpc.server). - OperationName string -} -``` - -### Client Options - -```go -type ClientOptions struct { - // ReportOn defines the conditions under which errors are reported to Sentry. - ReportOn func(error) bool - - // OperationName overrides the default operation name (grpc.client). - OperationName string } ``` From dbdad597fe1e9cdf86c0de620977310d931cdb89 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:46:52 +0200 Subject: [PATCH 24/39] add span origin & handle empty stream case --- grpc/client.go | 5 +++++ grpc/server.go | 3 +++ 2 files changed, 8 insertions(+) diff --git a/grpc/client.go b/grpc/client.go index 4062b0ef5..d37b5ce8b 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -61,6 +61,7 @@ func UnaryClientInterceptor() grpc.UnaryClientInterceptor { defaultClientOperationName, sentry.WithTransactionName(method), sentry.WithDescription(method), + sentry.WithSpanOrigin(sentry.SpanOriginGrpc), ) service, _ := splitGRPCMethod(method) if service != "" { @@ -91,6 +92,7 @@ func StreamClientInterceptor() grpc.StreamClientInterceptor { defaultClientOperationName, sentry.WithTransactionName(method), sentry.WithDescription(method), + sentry.WithSpanOrigin(sentry.SpanOriginGrpc), ) service, _ := splitGRPCMethod(method) if service != "" { @@ -108,6 +110,9 @@ func StreamClientInterceptor() grpc.StreamClientInterceptor { return nil, err } if stream == nil { + span.Status = toSpanStatus(codes.OK) + span.SetData("rpc.grpc.status_code", int(codes.OK)) + span.Finish() return nil, nil } diff --git a/grpc/server.go b/grpc/server.go index fafccd19f..8b4517578 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +// Part of this code is derived from [github.com/johnbellone/grpc-middleware-sentry], licensed under the Apache 2.0 License. + package sentrygrpc import ( From 7e5b061fad0f27c4f98cef3e09e8620408095599 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:09:59 +0200 Subject: [PATCH 25/39] fix span attributes --- grpc/client.go | 38 ++++++----- grpc/client_test.go | 160 +++++++++++++++++++++++++++++++++++++++++++- grpc/go.mod | 1 + grpc/server.go | 45 ++++++++----- grpc/server_test.go | 61 +++++++++++------ 5 files changed, 248 insertions(+), 57 deletions(-) diff --git a/grpc/client.go b/grpc/client.go index d37b5ce8b..85a4b8999 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -5,6 +5,7 @@ package sentrygrpc import ( "context" + "errors" "io" "sync" @@ -56,17 +57,21 @@ func UnaryClientInterceptor() grpc.UnaryClientInterceptor { invoker grpc.UnaryInvoker, callOpts ...grpc.CallOption) error { ctx = hubFromClientContext(ctx) + name, service, rpcMethod := parseGRPCMethod(method) span := sentry.StartSpan( ctx, defaultClientOperationName, - sentry.WithTransactionName(method), - sentry.WithDescription(method), + sentry.WithTransactionName(name), + sentry.WithDescription(name), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), ) - service, _ := splitGRPCMethod(method) if service != "" { span.SetData("rpc.service", service) } + if rpcMethod != "" { + span.SetData("rpc.method", rpcMethod) + } + span.SetData("rpc.system", "grpc") ctx = span.Context() ctx = createOrUpdateMetadata(ctx, span) @@ -87,17 +92,21 @@ func StreamClientInterceptor() grpc.StreamClientInterceptor { streamer grpc.Streamer, callOpts ...grpc.CallOption) (grpc.ClientStream, error) { ctx = hubFromClientContext(ctx) + name, service, rpcMethod := parseGRPCMethod(method) span := sentry.StartSpan( ctx, defaultClientOperationName, - sentry.WithTransactionName(method), - sentry.WithDescription(method), + sentry.WithTransactionName(name), + sentry.WithDescription(name), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), ) - service, _ := splitGRPCMethod(method) if service != "" { span.SetData("rpc.service", service) } + if rpcMethod != "" { + span.SetData("rpc.method", rpcMethod) + } + span.SetData("rpc.system", "grpc") ctx = span.Context() ctx = createOrUpdateMetadata(ctx, span) @@ -110,10 +119,11 @@ func StreamClientInterceptor() grpc.StreamClientInterceptor { return nil, err } if stream == nil { - span.Status = toSpanStatus(codes.OK) - span.SetData("rpc.grpc.status_code", int(codes.OK)) + nilErr := status.Error(codes.Internal, "streamer returned nil stream without error") + span.Status = toSpanStatus(codes.Internal) + span.SetData("rpc.grpc.status_code", int(codes.Internal)) span.Finish() - return nil, nil + return nil, nilErr } return &sentryClientStream{ClientStream: stream, span: span}, nil @@ -152,12 +162,10 @@ func (s *sentryClientStream) SendMsg(m any) error { func (s *sentryClientStream) RecvMsg(m any) error { err := s.ClientStream.RecvMsg(m) - if err != nil { - if err == io.EOF { - s.finish(nil) - } else { - s.finish(err) - } + if errors.Is(err, io.EOF) { + s.finish(nil) + } else { + s.finish(err) } return err } diff --git a/grpc/client_test.go b/grpc/client_test.go index 048e5269f..10f243ce9 100644 --- a/grpc/client_test.go +++ b/grpc/client_test.go @@ -2,16 +2,54 @@ package sentrygrpc_test import ( "context" + "io" "testing" - "time" "github.com/getsentry/sentry-go" + "github.com/getsentry/sentry-go/internal/testutils" sentrygrpc "github.com/getsentry/sentry-go/grpc" "github.com/stretchr/testify/assert" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" ) +// mockClientStream implements grpc.ClientStream for testing. +type mockClientStream struct { + headerFn func() (metadata.MD, error) + closeSendFn func() error + sendMsgFn func(msg any) error + recvMsgFn func(msg any) error +} + +func (m *mockClientStream) Header() (metadata.MD, error) { + if m.headerFn != nil { + return m.headerFn() + } + return metadata.MD{}, nil +} +func (m *mockClientStream) Trailer() metadata.MD { return metadata.MD{} } +func (m *mockClientStream) CloseSend() error { + if m.closeSendFn != nil { + return m.closeSendFn() + } + return nil +} +func (m *mockClientStream) Context() context.Context { return context.Background() } +func (m *mockClientStream) SendMsg(msg any) error { + if m.sendMsgFn != nil { + return m.sendMsgFn(msg) + } + return nil +} +func (m *mockClientStream) RecvMsg(msg any) error { + if m.recvMsgFn != nil { + return m.recvMsgFn(msg) + } + return nil +} + func TestUnaryClientInterceptor(t *testing.T) { tests := map[string]struct { ctx context.Context @@ -53,7 +91,7 @@ func TestUnaryClientInterceptor(t *testing.T) { // Execute the interceptor interceptor(test.ctx, "/test.Service/TestMethod", struct{}{}, struct{}{}, nil, test.invoker) - sentry.Flush(2 * time.Second) + sentry.Flush(testutils.FlushTimeout()) // Pass the transport to the assertions to verify captured events. test.assertions(t, transport) @@ -105,7 +143,7 @@ func TestStreamClientInterceptor(t *testing.T) { // Execute the interceptor clientStream, _ := interceptor(context.Background(), test.streamDesc, nil, "/test.Service/TestMethod", test.streamer) - sentry.Flush(2 * time.Second) + sentry.Flush(testutils.FlushTimeout()) assert.Nil(t, clientStream, "ClientStream should be nil in this test scenario") // Pass the transport to the assertions to verify captured events. @@ -113,3 +151,119 @@ func TestStreamClientInterceptor(t *testing.T) { }) } } + +func TestSentryClientStream(t *testing.T) { + newInterceptorWithStream := func(t *testing.T, inner grpc.ClientStream) (grpc.ClientStream, *sentry.MockTransport) { + t.Helper() + transport := &sentry.MockTransport{} + sentry.Init(sentry.ClientOptions{ + Transport: transport, + EnableTracing: true, + TracesSampleRate: 1.0, + }) + interceptor := sentrygrpc.StreamClientInterceptor() + streamer := func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + return inner, nil + } + stream, err := interceptor(context.Background(), &grpc.StreamDesc{}, nil, "/test.Service/Method", streamer) + assert.NoError(t, err) + return stream, transport + } + + t.Run("RecvMsg EOF finishes span with OK", func(t *testing.T) { + mock := &mockClientStream{ + recvMsgFn: func(msg any) error { return io.EOF }, + } + stream, transport := newInterceptorWithStream(t, mock) + + err := stream.RecvMsg(nil) + assert.Equal(t, io.EOF, err) + + sentry.Flush(testutils.FlushTimeout()) + events := transport.Events() + assert.Len(t, events, 1) + assert.Equal(t, int(codes.OK), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) + }) + + t.Run("RecvMsg error finishes span with error status", func(t *testing.T) { + rpcErr := status.Error(codes.Unavailable, "down") + mock := &mockClientStream{ + recvMsgFn: func(msg any) error { return rpcErr }, + } + stream, transport := newInterceptorWithStream(t, mock) + + err := stream.RecvMsg(nil) + assert.Equal(t, rpcErr, err) + + sentry.Flush(testutils.FlushTimeout()) + events := transport.Events() + assert.Len(t, events, 1) + assert.Equal(t, int(codes.Unavailable), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) + }) + + t.Run("CloseSend error finishes span", func(t *testing.T) { + rpcErr := status.Error(codes.Internal, "internal") + mock := &mockClientStream{ + closeSendFn: func() error { return rpcErr }, + } + stream, transport := newInterceptorWithStream(t, mock) + + err := stream.CloseSend() + assert.Equal(t, rpcErr, err) + + sentry.Flush(testutils.FlushTimeout()) + events := transport.Events() + assert.Len(t, events, 1) + assert.Equal(t, int(codes.Internal), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) + }) + + t.Run("SendMsg error finishes span", func(t *testing.T) { + rpcErr := status.Error(codes.DeadlineExceeded, "timeout") + mock := &mockClientStream{ + sendMsgFn: func(msg any) error { return rpcErr }, + } + stream, transport := newInterceptorWithStream(t, mock) + + err := stream.SendMsg(nil) + assert.Equal(t, rpcErr, err) + + sentry.Flush(testutils.FlushTimeout()) + events := transport.Events() + assert.Len(t, events, 1) + assert.Equal(t, int(codes.DeadlineExceeded), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) + }) + + t.Run("Header error finishes span", func(t *testing.T) { + rpcErr := status.Error(codes.NotFound, "not found") + mock := &mockClientStream{ + headerFn: func() (metadata.MD, error) { return nil, rpcErr }, + } + stream, transport := newInterceptorWithStream(t, mock) + + _, err := stream.Header() + assert.Equal(t, rpcErr, err) + + sentry.Flush(testutils.FlushTimeout()) + events := transport.Events() + assert.Len(t, events, 1) + assert.Equal(t, int(codes.NotFound), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) + }) + + t.Run("finish is idempotent", func(t *testing.T) { + rpcErr := status.Error(codes.Canceled, "canceled") + mock := &mockClientStream{ + recvMsgFn: func(msg any) error { return rpcErr }, + closeSendFn: func() error { return rpcErr }, + } + stream, transport := newInterceptorWithStream(t, mock) + + // Trigger finish via two different paths. + stream.RecvMsg(nil) + stream.CloseSend() + + sentry.Flush(testutils.FlushTimeout()) + // Only one transaction should be recorded. + events := transport.Events() + assert.Len(t, events, 1) + }) +} diff --git a/grpc/go.mod b/grpc/go.mod index d5b41267b..3adc7f588 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -12,6 +12,7 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/net v0.48.0 // indirect diff --git a/grpc/server.go b/grpc/server.go index 8b4517578..932c31cae 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -78,23 +78,27 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { setScopeMetadata(hub, info.FullMethod, md) + name, service, method := parseGRPCMethod(info.FullMethod) options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), sentry.WithOpName(defaultServerOperationName), - sentry.WithDescription(info.FullMethod), + sentry.WithDescription(name), sentry.WithTransactionSource(sentry.SourceCustom), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), } - service, _ := splitGRPCMethod(info.FullMethod) transaction := sentry.StartTransaction( sentry.SetHubOnContext(ctx, hub), - info.FullMethod, + name, options..., ) if service != "" { transaction.SetData("rpc.service", service) } + if method != "" { + transaction.SetData("rpc.method", method) + } + transaction.SetData("rpc.system", "grpc") ctx = transaction.Context() defer transaction.Finish() @@ -137,23 +141,27 @@ func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { setScopeMetadata(hub, info.FullMethod, md) + name, service, method := parseGRPCMethod(info.FullMethod) options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), sentry.WithOpName(defaultServerOperationName), - sentry.WithDescription(info.FullMethod), + sentry.WithDescription(name), sentry.WithTransactionSource(sentry.SourceCustom), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), } - service, _ := splitGRPCMethod(info.FullMethod) transaction := sentry.StartTransaction( sentry.SetHubOnContext(ctx, hub), - info.FullMethod, + name, options..., ) if service != "" { transaction.SetData("rpc.service", service) } + if method != "" { + transaction.SetData("rpc.method", method) + } + transaction.SetData("rpc.system", "grpc") ctx = transaction.Context() defer transaction.Finish() @@ -210,19 +218,22 @@ func metadataToContext(md metadata.MD) map[string]any { return ctx } -func splitGRPCMethod(fullMethod string) (service string, method string) { - trimmed := strings.TrimPrefix(fullMethod, "/") - if trimmed == "" { - return "", "" +// parseGRPCMethod parses a gRPC full method name and returns the span name, service, and method components. +// +// It expects the format "/service/method" and parsing is compatible with: +// https://github.com/grpc/grpc-go/blob/v1.79.3/internal/grpcutil/method.go#L28 +// +// Returns the original string as name and empty service/method if the format is invalid. +func parseGRPCMethod(fullMethod string) (name, service, method string) { + if !strings.HasPrefix(fullMethod, "/") { + return fullMethod, "", "" } - - parts := strings.SplitN(trimmed, "/", 2) - service = parts[0] - if len(parts) > 1 { - method = parts[1] + name = fullMethod[1:] + pos := strings.LastIndex(name, "/") + if pos < 0 { + return name, "", "" } - - return service, method + return name, name[:pos], name[pos+1:] } // wrapServerStream wraps a grpc.ServerStream, allowing you to inject a custom context. diff --git a/grpc/server_test.go b/grpc/server_test.go index fbd344a8f..a0907a2e0 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -3,9 +3,9 @@ package sentrygrpc_test import ( "context" "testing" - "time" "github.com/getsentry/sentry-go" + "github.com/getsentry/sentry-go/internal/testutils" sentrygrpc "github.com/getsentry/sentry-go/grpc" "github.com/stretchr/testify/assert" "google.golang.org/grpc" @@ -27,10 +27,10 @@ func TestServerOptions_SetDefaults(t *testing.T) { }, "Custom Timeout is preserved": { options: sentrygrpc.ServerOptions{ - Timeout: 5 * time.Second, + Timeout: testutils.FlushTimeout(), }, assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { - assert.Equal(t, 5*time.Second, options.Timeout, "Timeout should be set to custom value") + assert.Equal(t, testutils.FlushTimeout(), options.Timeout, "Timeout should be set to custom value") }, }, } @@ -51,6 +51,7 @@ func TestUnaryServerInterceptor(t *testing.T) { ctx context.Context expectedMetadata string expectedCode codes.Code + expectError bool assertTransaction bool }{ "Handle panic and return internal error": { @@ -58,6 +59,7 @@ func TestUnaryServerInterceptor(t *testing.T) { ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("md", "some")), expectedMetadata: "some", expectedCode: codes.Internal, + expectError: true, handler: func(ctx context.Context, req any) (any, error) { panic("test panic") }, @@ -67,10 +69,19 @@ func TestUnaryServerInterceptor(t *testing.T) { ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("md", "some")), expectedMetadata: "some", expectedCode: codes.Internal, + expectError: true, handler: func(ctx context.Context, req any) (any, error) { panic("test panic") }, }, + "Successful handler produces transaction": { + options: sentrygrpc.ServerOptions{}, + ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("md", "some")), + assertTransaction: true, + handler: func(ctx context.Context, req any) (any, error) { + return struct{}{}, nil + }, + }, } for name, test := range tests { @@ -105,16 +116,16 @@ func TestUnaryServerInterceptor(t *testing.T) { }() _, err = interceptor(test.ctx, nil, &grpc.UnaryServerInfo{ - FullMethod: "TestService.Method", + FullMethod: "/test.TestService/Method", }, test.handler) - if !test.options.Repanic { + if test.expectError && !test.options.Repanic { assert.Error(t, err) assert.Equal(t, test.expectedCode, status.Code(err)) } if test.expectedMetadata != "" { - sentry.Flush(2 * time.Second) + sentry.Flush(testutils.FlushTimeout()) gotEvent := <-eventsCh assert.NotNil(t, gotEvent, "Expected an event") @@ -126,20 +137,23 @@ func TestUnaryServerInterceptor(t *testing.T) { } if test.assertTransaction { - sentry.Flush(2 * time.Second) + sentry.Flush(testutils.FlushTimeout()) gotTransaction := <-transactionsCh assert.NotNil(t, gotTransaction, "Expected a transaction") - assert.Equal(t, "TestService.Method", gotTransaction.Transaction, "Transaction names should match") + assert.Equal(t, "test.TestService/Method", gotTransaction.Transaction, "Transaction names should match") assert.Equal(t, "rpc.server", gotTransaction.Contexts["trace"]["op"]) - assert.Equal(t, "TestService.Method", gotTransaction.Contexts["trace"]["description"]) + assert.Equal(t, "test.TestService/Method", gotTransaction.Contexts["trace"]["description"]) assert.Equal(t, sentry.SourceCustom, gotTransaction.TransactionInfo.Source) - assert.Equal(t, "TestService.Method", gotTransaction.Contexts["grpc"]["method"]) + assert.Equal(t, "/test.TestService/Method", gotTransaction.Contexts["grpc"]["method"]) assert.Equal(t, sentry.SpanStatusOK, gotTransaction.Contexts["trace"]["status"]) - assert.Equal(t, "TestService.Method", gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.service"]) - assert.Equal(t, codes.OK, gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) + data := gotTransaction.Contexts["trace"]["data"].(map[string]interface{}) + assert.Equal(t, "test.TestService", data["rpc.service"]) + assert.Equal(t, "Method", data["rpc.method"]) + assert.Equal(t, "grpc", data["rpc.system"]) + assert.Equal(t, int(codes.OK), data["rpc.grpc.status_code"]) } - sentry.Flush(2 * time.Second) + sentry.Flush(testutils.FlushTimeout()) }) } } @@ -242,7 +256,7 @@ func TestStreamServerInterceptor(t *testing.T) { defer func() { recovered = recover() }() - err = interceptor(nil, stream, &grpc.StreamServerInfo{FullMethod: "TestService.StreamMethod"}, test.handler) + err = interceptor(nil, stream, &grpc.StreamServerInfo{FullMethod: "/test.TestService/StreamMethod"}, test.handler) }() if test.expectedMetadata { @@ -252,7 +266,7 @@ func TestStreamServerInterceptor(t *testing.T) { } if test.expectedEvent { - sentry.Flush(2 * time.Second) + sentry.Flush(testutils.FlushTimeout()) gotEvent := <-eventsCh assert.NotNil(t, gotEvent, "Expected an event to be captured") } else { @@ -265,17 +279,20 @@ func TestStreamServerInterceptor(t *testing.T) { } if test.assertTransaction { - sentry.Flush(2 * time.Second) + sentry.Flush(testutils.FlushTimeout()) gotTransaction := <-transactionsCh assert.NotNil(t, gotTransaction, "Expected a transaction") traceContext, ok := gotTransaction.Contexts["trace"] assert.True(t, ok, "Expected trace context on the transaction") assert.Equal(t, "rpc.server", traceContext["op"]) - assert.Equal(t, "TestService.StreamMethod", gotTransaction.Transaction) - assert.Equal(t, int(codes.OK), gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) - assert.Equal(t, "TestService.StreamMethod", gotTransaction.Contexts["trace"]["description"]) - assert.Equal(t, "TestService.StreamMethod", gotTransaction.Contexts["grpc"]["method"]) - assert.Equal(t, "TestService.StreamMethod", gotTransaction.Contexts["trace"]["data"].(map[string]interface{})["rpc.service"]) + assert.Equal(t, "test.TestService/StreamMethod", gotTransaction.Transaction) + assert.Equal(t, "test.TestService/StreamMethod", gotTransaction.Contexts["trace"]["description"]) + assert.Equal(t, "/test.TestService/StreamMethod", gotTransaction.Contexts["grpc"]["method"]) + data := gotTransaction.Contexts["trace"]["data"].(map[string]interface{}) + assert.Equal(t, "test.TestService", data["rpc.service"]) + assert.Equal(t, "StreamMethod", data["rpc.method"]) + assert.Equal(t, "grpc", data["rpc.system"]) + assert.Equal(t, int(codes.OK), data["rpc.grpc.status_code"]) } if test.options.Repanic { @@ -283,7 +300,7 @@ func TestStreamServerInterceptor(t *testing.T) { assert.Equal(t, "test panic", recovered, "Panic value should match") } - sentry.Flush(2 * time.Second) + sentry.Flush(testutils.FlushTimeout()) }) } } From b0de8aea2a2cf067421227ee35d270fee1ec8df7 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:24:54 +0200 Subject: [PATCH 26/39] simplify tests --- grpc/client_test.go | 297 ++++++++++++------------------- grpc/go.mod | 2 +- grpc/server_test.go | 425 ++++++++++++++++++++------------------------ 3 files changed, 313 insertions(+), 411 deletions(-) diff --git a/grpc/client_test.go b/grpc/client_test.go index 10f243ce9..2272c83c5 100644 --- a/grpc/client_test.go +++ b/grpc/client_test.go @@ -6,9 +6,10 @@ import ( "testing" "github.com/getsentry/sentry-go" - "github.com/getsentry/sentry-go/internal/testutils" sentrygrpc "github.com/getsentry/sentry-go/grpc" + "github.com/getsentry/sentry-go/internal/testutils" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -29,7 +30,7 @@ func (m *mockClientStream) Header() (metadata.MD, error) { } return metadata.MD{}, nil } -func (m *mockClientStream) Trailer() metadata.MD { return metadata.MD{} } +func (m *mockClientStream) Trailer() metadata.MD { return metadata.MD{} } func (m *mockClientStream) CloseSend() error { if m.closeSendFn != nil { return m.closeSendFn() @@ -50,220 +51,156 @@ func (m *mockClientStream) RecvMsg(msg any) error { return nil } +func initMockTransport(t *testing.T) *sentry.MockTransport { + t.Helper() + transport := &sentry.MockTransport{} + require.NoError(t, sentry.Init(sentry.ClientOptions{ + Transport: transport, + EnableTracing: true, + TracesSampleRate: 1.0, + })) + return transport +} + +func spanStatusCode(t *testing.T, transport *sentry.MockTransport) int { + t.Helper() + events := transport.Events() + require.Len(t, events, 1) + return events[0].Contexts["trace"]["data"].(map[string]any)["rpc.grpc.status_code"].(int) +} + func TestUnaryClientInterceptor(t *testing.T) { tests := map[string]struct { - ctx context.Context - invoker grpc.UnaryInvoker - assertions func(t *testing.T, transport *sentry.MockTransport) + ctx context.Context + invoker grpc.UnaryInvoker + wantCode codes.Code }{ - "Default behavior, no error": { - ctx: context.Background(), - invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { - return nil - }, - assertions: func(t *testing.T, transport *sentry.MockTransport) { - assert.Empty(t, transport.Events(), "No events should be captured") - }, - }, - "Metadata propagation": { + "records span and propagates trace headers": { ctx: metadata.NewOutgoingContext(context.Background(), metadata.Pairs("existing", "value")), - invoker: func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + invoker: func(ctx context.Context, _ string, _, _ any, _ *grpc.ClientConn, _ ...grpc.CallOption) error { md, ok := metadata.FromOutgoingContext(ctx) - assert.True(t, ok, "Metadata should be present in the outgoing context") - assert.Contains(t, md, sentry.SentryTraceHeader, "Metadata should contain Sentry trace header") - assert.Contains(t, md, sentry.SentryBaggageHeader, "Metadata should contain Sentry baggage header") - assert.Contains(t, md, "existing", "Metadata should contain key") + require.True(t, ok) + assert.Contains(t, md, sentry.SentryTraceHeader) + assert.Contains(t, md, sentry.SentryBaggageHeader) + assert.Contains(t, md, "existing") return nil }, - assertions: func(t *testing.T, transport *sentry.MockTransport) {}, + wantCode: codes.OK, + }, + "records span with error status on handler error": { + ctx: context.Background(), + invoker: func(_ context.Context, _ string, _, _ any, _ *grpc.ClientConn, _ ...grpc.CallOption) error { + return status.Error(codes.NotFound, "not found") + }, + wantCode: codes.NotFound, }, } - for name, test := range tests { + for name, tc := range tests { t.Run(name, func(t *testing.T) { - transport := &sentry.MockTransport{} - sentry.Init(sentry.ClientOptions{ - Transport: transport, - }) - + transport := initMockTransport(t) interceptor := sentrygrpc.UnaryClientInterceptor() - // Execute the interceptor - interceptor(test.ctx, "/test.Service/TestMethod", struct{}{}, struct{}{}, nil, test.invoker) - + interceptor(tc.ctx, "/test.TestService/Method", struct{}{}, struct{}{}, nil, tc.invoker) sentry.Flush(testutils.FlushTimeout()) - // Pass the transport to the assertions to verify captured events. - test.assertions(t, transport) + assert.Equal(t, int(tc.wantCode), spanStatusCode(t, transport)) }) } } func TestStreamClientInterceptor(t *testing.T) { tests := map[string]struct { - streamer grpc.Streamer - assertions func(t *testing.T, transport *sentry.MockTransport) - streamDesc *grpc.StreamDesc + streamer grpc.Streamer + streamOp func(stream grpc.ClientStream) + wantCode codes.Code }{ - "Default behavior, no error": { - streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { + "records span and propagates trace headers": { + streamer: func(ctx context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok) + assert.Contains(t, md, sentry.SentryTraceHeader) + assert.Contains(t, md, sentry.SentryBaggageHeader) + return &mockClientStream{recvMsgFn: func(_ any) error { return io.EOF }}, nil + }, + streamOp: func(stream grpc.ClientStream) { stream.RecvMsg(nil) }, + wantCode: codes.OK, + }, + "streamer error records span with error status": { + streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, status.Error(codes.Unavailable, "unavailable") + }, + wantCode: codes.Unavailable, + }, + "nil stream from streamer records Internal error": { + streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { return nil, nil }, - streamDesc: &grpc.StreamDesc{ - ClientStreams: true, + wantCode: codes.Internal, + }, + "RecvMsg EOF finishes span with OK": { + streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + return &mockClientStream{recvMsgFn: func(_ any) error { return io.EOF }}, nil }, - assertions: func(t *testing.T, transport *sentry.MockTransport) { - assert.Empty(t, transport.Events(), "No events should be captured") + streamOp: func(stream grpc.ClientStream) { stream.RecvMsg(nil) }, + wantCode: codes.OK, + }, + "RecvMsg error records error status": { + streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + return &mockClientStream{recvMsgFn: func(_ any) error { return status.Error(codes.Unavailable, "down") }}, nil }, + streamOp: func(stream grpc.ClientStream) { stream.RecvMsg(nil) }, + wantCode: codes.Unavailable, }, - "Metadata propagation": { - streamer: func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { - md, ok := metadata.FromOutgoingContext(ctx) - assert.True(t, ok, "Metadata should be present in the outgoing context") - assert.Contains(t, md, sentry.SentryTraceHeader, "Metadata should contain Sentry trace header") - assert.Contains(t, md, sentry.SentryBaggageHeader, "Metadata should contain Sentry baggage header") - return nil, nil + "CloseSend error records error status": { + streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + return &mockClientStream{closeSendFn: func() error { return status.Error(codes.Internal, "internal") }}, nil + }, + streamOp: func(stream grpc.ClientStream) { stream.CloseSend() }, + wantCode: codes.Internal, + }, + "SendMsg error records error status": { + streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + return &mockClientStream{sendMsgFn: func(_ any) error { return status.Error(codes.DeadlineExceeded, "timeout") }}, nil + }, + streamOp: func(stream grpc.ClientStream) { stream.SendMsg(nil) }, + wantCode: codes.DeadlineExceeded, + }, + "Header error records error status": { + streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + return &mockClientStream{headerFn: func() (metadata.MD, error) { return nil, status.Error(codes.NotFound, "not found") }}, nil + }, + streamOp: func(stream grpc.ClientStream) { stream.Header() }, + wantCode: codes.NotFound, + }, + "finish is idempotent across multiple error paths": { + streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + rpcErr := status.Error(codes.Canceled, "canceled") + return &mockClientStream{ + recvMsgFn: func(_ any) error { return rpcErr }, + closeSendFn: func() error { return rpcErr }, + }, nil }, - streamDesc: &grpc.StreamDesc{ - ClientStreams: true, + streamOp: func(stream grpc.ClientStream) { + stream.RecvMsg(nil) + stream.CloseSend() }, - assertions: func(t *testing.T, transport *sentry.MockTransport) {}, + wantCode: codes.Canceled, }, } - for name, test := range tests { + for name, tc := range tests { t.Run(name, func(t *testing.T) { - // Reinitialize the transport for each test to ensure isolation. - transport := &sentry.MockTransport{} - sentry.Init(sentry.ClientOptions{ - Transport: transport, - }) - + transport := initMockTransport(t) interceptor := sentrygrpc.StreamClientInterceptor() - // Execute the interceptor - clientStream, _ := interceptor(context.Background(), test.streamDesc, nil, "/test.Service/TestMethod", test.streamer) - sentry.Flush(testutils.FlushTimeout()) - - assert.Nil(t, clientStream, "ClientStream should be nil in this test scenario") - // Pass the transport to the assertions to verify captured events. - test.assertions(t, transport) - }) - } -} + stream, _ := interceptor(context.Background(), &grpc.StreamDesc{}, nil, "/test.TestService/Method", tc.streamer) + if tc.streamOp != nil && stream != nil { + tc.streamOp(stream) + } -func TestSentryClientStream(t *testing.T) { - newInterceptorWithStream := func(t *testing.T, inner grpc.ClientStream) (grpc.ClientStream, *sentry.MockTransport) { - t.Helper() - transport := &sentry.MockTransport{} - sentry.Init(sentry.ClientOptions{ - Transport: transport, - EnableTracing: true, - TracesSampleRate: 1.0, + sentry.Flush(testutils.FlushTimeout()) + assert.Equal(t, int(tc.wantCode), spanStatusCode(t, transport)) }) - interceptor := sentrygrpc.StreamClientInterceptor() - streamer := func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { - return inner, nil - } - stream, err := interceptor(context.Background(), &grpc.StreamDesc{}, nil, "/test.Service/Method", streamer) - assert.NoError(t, err) - return stream, transport } - - t.Run("RecvMsg EOF finishes span with OK", func(t *testing.T) { - mock := &mockClientStream{ - recvMsgFn: func(msg any) error { return io.EOF }, - } - stream, transport := newInterceptorWithStream(t, mock) - - err := stream.RecvMsg(nil) - assert.Equal(t, io.EOF, err) - - sentry.Flush(testutils.FlushTimeout()) - events := transport.Events() - assert.Len(t, events, 1) - assert.Equal(t, int(codes.OK), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) - }) - - t.Run("RecvMsg error finishes span with error status", func(t *testing.T) { - rpcErr := status.Error(codes.Unavailable, "down") - mock := &mockClientStream{ - recvMsgFn: func(msg any) error { return rpcErr }, - } - stream, transport := newInterceptorWithStream(t, mock) - - err := stream.RecvMsg(nil) - assert.Equal(t, rpcErr, err) - - sentry.Flush(testutils.FlushTimeout()) - events := transport.Events() - assert.Len(t, events, 1) - assert.Equal(t, int(codes.Unavailable), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) - }) - - t.Run("CloseSend error finishes span", func(t *testing.T) { - rpcErr := status.Error(codes.Internal, "internal") - mock := &mockClientStream{ - closeSendFn: func() error { return rpcErr }, - } - stream, transport := newInterceptorWithStream(t, mock) - - err := stream.CloseSend() - assert.Equal(t, rpcErr, err) - - sentry.Flush(testutils.FlushTimeout()) - events := transport.Events() - assert.Len(t, events, 1) - assert.Equal(t, int(codes.Internal), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) - }) - - t.Run("SendMsg error finishes span", func(t *testing.T) { - rpcErr := status.Error(codes.DeadlineExceeded, "timeout") - mock := &mockClientStream{ - sendMsgFn: func(msg any) error { return rpcErr }, - } - stream, transport := newInterceptorWithStream(t, mock) - - err := stream.SendMsg(nil) - assert.Equal(t, rpcErr, err) - - sentry.Flush(testutils.FlushTimeout()) - events := transport.Events() - assert.Len(t, events, 1) - assert.Equal(t, int(codes.DeadlineExceeded), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) - }) - - t.Run("Header error finishes span", func(t *testing.T) { - rpcErr := status.Error(codes.NotFound, "not found") - mock := &mockClientStream{ - headerFn: func() (metadata.MD, error) { return nil, rpcErr }, - } - stream, transport := newInterceptorWithStream(t, mock) - - _, err := stream.Header() - assert.Equal(t, rpcErr, err) - - sentry.Flush(testutils.FlushTimeout()) - events := transport.Events() - assert.Len(t, events, 1) - assert.Equal(t, int(codes.NotFound), events[0].Contexts["trace"]["data"].(map[string]interface{})["rpc.grpc.status_code"]) - }) - - t.Run("finish is idempotent", func(t *testing.T) { - rpcErr := status.Error(codes.Canceled, "canceled") - mock := &mockClientStream{ - recvMsgFn: func(msg any) error { return rpcErr }, - closeSendFn: func() error { return rpcErr }, - } - stream, transport := newInterceptorWithStream(t, mock) - - // Trigger finish via two different paths. - stream.RecvMsg(nil) - stream.CloseSend() - - sentry.Flush(testutils.FlushTimeout()) - // Only one transaction should be recorded. - events := transport.Events() - assert.Len(t, events, 1) - }) } diff --git a/grpc/go.mod b/grpc/go.mod index 3adc7f588..e89ceed28 100644 --- a/grpc/go.mod +++ b/grpc/go.mod @@ -6,13 +6,13 @@ replace github.com/getsentry/sentry-go => ../ require ( github.com/getsentry/sentry-go v0.43.0 + github.com/google/go-cmp v0.7.0 github.com/stretchr/testify v1.10.0 google.golang.org/grpc v1.79.3 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect golang.org/x/net v0.48.0 // indirect diff --git a/grpc/server_test.go b/grpc/server_test.go index a0907a2e0..c0f571fd1 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -5,302 +5,267 @@ import ( "testing" "github.com/getsentry/sentry-go" - "github.com/getsentry/sentry-go/internal/testutils" sentrygrpc "github.com/getsentry/sentry-go/grpc" + "github.com/getsentry/sentry-go/internal/testutils" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" ) +// stubServerStream provides a minimal grpc.ServerStream for testing. +type stubServerStream struct { + grpc.ServerStream + ctx context.Context +} + +func (s *stubServerStream) Context() context.Context { return s.ctx } + +// txSummary is a comparable snapshot of the span/transaction fields we assert. +type txSummary struct { + Name string + Op string + Status sentry.SpanStatus + Data map[string]any + GRPC map[string]any +} + +func summarizeTx(tx *sentry.Event) txSummary { + s := txSummary{ + Name: tx.Transaction, + Op: tx.Contexts["trace"]["op"].(string), + Status: tx.Contexts["trace"]["status"].(sentry.SpanStatus), + Data: tx.Contexts["trace"]["data"].(map[string]any), + } + if g, ok := tx.Contexts["grpc"]; ok { + s.GRPC = map[string]any(g) + } + return s +} + func TestServerOptions_SetDefaults(t *testing.T) { tests := map[string]struct { - options sentrygrpc.ServerOptions - assertions func(t *testing.T, options sentrygrpc.ServerOptions) + input sentrygrpc.ServerOptions + want sentrygrpc.ServerOptions }{ - "Defaults are set when fields are empty": { - options: sentrygrpc.ServerOptions{}, - assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { - assert.Equal(t, sentry.DefaultFlushTimeout, options.Timeout, "Timeout should be set to default value") - }, + "zero value gets default timeout": { + input: sentrygrpc.ServerOptions{}, + want: sentrygrpc.ServerOptions{Timeout: sentry.DefaultFlushTimeout}, }, - "Custom Timeout is preserved": { - options: sentrygrpc.ServerOptions{ - Timeout: testutils.FlushTimeout(), - }, - assertions: func(t *testing.T, options sentrygrpc.ServerOptions) { - assert.Equal(t, testutils.FlushTimeout(), options.Timeout, "Timeout should be set to custom value") - }, + "non-zero timeout is preserved": { + input: sentrygrpc.ServerOptions{Timeout: testutils.FlushTimeout()}, + want: sentrygrpc.ServerOptions{Timeout: testutils.FlushTimeout()}, }, } - for name, test := range tests { + for name, tc := range tests { t.Run(name, func(t *testing.T) { - test.options.SetDefaults() - - test.assertions(t, test.options) + tc.input.SetDefaults() + assert.Equal(t, tc.want, tc.input) }) } } func TestUnaryServerInterceptor(t *testing.T) { + txCh := make(chan *sentry.Event, 1) + require.NoError(t, sentry.Init(sentry.ClientOptions{ + BeforeSendTransaction: func(tx *sentry.Event, _ *sentry.EventHint) *sentry.Event { + txCh <- tx + return tx + }, + EnableTracing: true, + TracesSampleRate: 1.0, + })) + + interceptor := sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{}) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")) + + _, err := interceptor(ctx, nil, &grpc.UnaryServerInfo{ + FullMethod: "/test.TestService/Method", + }, func(ctx context.Context, _ any) (any, error) { + return struct{}{}, nil + }) + + require.NoError(t, err) + sentry.Flush(testutils.FlushTimeout()) + + if diff := cmp.Diff(txSummary{ + Name: "test.TestService/Method", + Op: "rpc.server", + Status: sentry.SpanStatusOK, + Data: map[string]any{ + "rpc.system": "grpc", + "rpc.service": "test.TestService", + "rpc.method": "Method", + "rpc.grpc.status_code": int(codes.OK), + }, + GRPC: map[string]any{ + "method": "/test.TestService/Method", + "metadata": map[string]any{"key": "value"}, + }, + }, summarizeTx(<-txCh)); diff != "" { + t.Errorf("transaction mismatch (-want +got):\n%s", diff) + } +} + +func TestUnaryServerInterceptor_Panic(t *testing.T) { tests := map[string]struct { - options sentrygrpc.ServerOptions - handler grpc.UnaryHandler - ctx context.Context - expectedMetadata string - expectedCode codes.Code - expectError bool - assertTransaction bool + options sentrygrpc.ServerOptions + wantRepanic bool }{ - "Handle panic and return internal error": { - options: sentrygrpc.ServerOptions{}, - ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("md", "some")), - expectedMetadata: "some", - expectedCode: codes.Internal, - expectError: true, - handler: func(ctx context.Context, req any) (any, error) { - panic("test panic") - }, - }, - "Handle panic and re-panic": { - options: sentrygrpc.ServerOptions{Repanic: true}, - ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("md", "some")), - expectedMetadata: "some", - expectedCode: codes.Internal, - expectError: true, - handler: func(ctx context.Context, req any) (any, error) { - panic("test panic") - }, + "panic is recovered and returns Internal error": { + options: sentrygrpc.ServerOptions{}, }, - "Successful handler produces transaction": { - options: sentrygrpc.ServerOptions{}, - ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("md", "some")), - assertTransaction: true, - handler: func(ctx context.Context, req any) (any, error) { - return struct{}{}, nil - }, + "panic is re-panicked when Repanic is set": { + options: sentrygrpc.ServerOptions{Repanic: true}, + wantRepanic: true, }, } - for name, test := range tests { + for name, tc := range tests { t.Run(name, func(t *testing.T) { eventsCh := make(chan *sentry.Event, 1) - transactionsCh := make(chan *sentry.Event, 1) - - err := sentry.Init(sentry.ClientOptions{ - 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 + require.NoError(t, sentry.Init(sentry.ClientOptions{ + BeforeSend: func(e *sentry.Event, _ *sentry.EventHint) *sentry.Event { + eventsCh <- e + return e }, EnableTracing: true, TracesSampleRate: 1.0, - }) - if err != nil { - t.Fatal(err) - } + })) - interceptor := sentrygrpc.UnaryServerInterceptor(test.options) + interceptor := sentrygrpc.UnaryServerInterceptor(tc.options) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")) - defer func() { - if r := recover(); r != nil { - if test.options.Repanic { - assert.Equal(t, "test panic", r, "Expected panic to propagate with message 'test panic'") - } - } + var ( + err error + recovered any + ) + func() { + defer func() { recovered = recover() }() + _, err = interceptor(ctx, nil, &grpc.UnaryServerInfo{ + FullMethod: "/test.TestService/Method", + }, func(context.Context, any) (any, error) { + panic("test panic") + }) }() - _, err = interceptor(test.ctx, nil, &grpc.UnaryServerInfo{ - FullMethod: "/test.TestService/Method", - }, test.handler) - - if test.expectError && !test.options.Repanic { - assert.Error(t, err) - assert.Equal(t, test.expectedCode, status.Code(err)) - } - - if test.expectedMetadata != "" { - sentry.Flush(testutils.FlushTimeout()) - gotEvent := <-eventsCh - - assert.NotNil(t, gotEvent, "Expected an event") - grpcContext, ok := gotEvent.Contexts["grpc"] - assert.True(t, ok, "Expected gRPC context on the event") - metadataContext, ok := grpcContext["metadata"].(map[string]interface{}) - assert.True(t, ok, "Expected metadata to be attached to the gRPC context") - assert.Equal(t, test.expectedMetadata, metadataContext["md"]) - } + sentry.Flush(testutils.FlushTimeout()) + require.NotNil(t, <-eventsCh) - if test.assertTransaction { - sentry.Flush(testutils.FlushTimeout()) - gotTransaction := <-transactionsCh - assert.NotNil(t, gotTransaction, "Expected a transaction") - assert.Equal(t, "test.TestService/Method", gotTransaction.Transaction, "Transaction names should match") - assert.Equal(t, "rpc.server", gotTransaction.Contexts["trace"]["op"]) - assert.Equal(t, "test.TestService/Method", gotTransaction.Contexts["trace"]["description"]) - assert.Equal(t, sentry.SourceCustom, gotTransaction.TransactionInfo.Source) - assert.Equal(t, "/test.TestService/Method", gotTransaction.Contexts["grpc"]["method"]) - assert.Equal(t, sentry.SpanStatusOK, gotTransaction.Contexts["trace"]["status"]) - data := gotTransaction.Contexts["trace"]["data"].(map[string]interface{}) - assert.Equal(t, "test.TestService", data["rpc.service"]) - assert.Equal(t, "Method", data["rpc.method"]) - assert.Equal(t, "grpc", data["rpc.system"]) - assert.Equal(t, int(codes.OK), data["rpc.grpc.status_code"]) + if tc.wantRepanic { + assert.Equal(t, "test panic", recovered) + } else { + assert.Nil(t, recovered) + assert.Equal(t, codes.Internal, status.Code(err)) } - - sentry.Flush(testutils.FlushTimeout()) }) } } -// wrappedServerStream is a wrapper around grpc.ServerStream that overrides the Context method. -type wrappedServerStream struct { - grpc.ServerStream - ctx context.Context -} +func TestStreamServerInterceptor(t *testing.T) { + txCh := make(chan *sentry.Event, 1) + require.NoError(t, sentry.Init(sentry.ClientOptions{ + BeforeSendTransaction: func(tx *sentry.Event, _ *sentry.EventHint) *sentry.Event { + txCh <- tx + return tx + }, + EnableTracing: true, + TracesSampleRate: 1.0, + })) -// Context returns the custom context for the stream. -func (w *wrappedServerStream) Context() context.Context { - return w.ctx + interceptor := sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{}) + ss := &stubServerStream{ + ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")), + } + + err := interceptor(nil, ss, &grpc.StreamServerInfo{ + FullMethod: "/test.TestService/StreamMethod", + }, func(_ any, stream grpc.ServerStream) error { + md, ok := metadata.FromIncomingContext(stream.Context()) + require.True(t, ok) + require.Contains(t, md, "key") + return nil + }) + + require.NoError(t, err) + sentry.Flush(testutils.FlushTimeout()) + + if diff := cmp.Diff(txSummary{ + Name: "test.TestService/StreamMethod", + Op: "rpc.server", + Status: sentry.SpanStatusOK, + Data: map[string]any{ + "rpc.system": "grpc", + "rpc.service": "test.TestService", + "rpc.method": "StreamMethod", + "rpc.grpc.status_code": int(codes.OK), + }, + GRPC: map[string]any{ + "method": "/test.TestService/StreamMethod", + "metadata": map[string]any{"key": "value"}, + }, + }, summarizeTx(<-txCh)); diff != "" { + t.Errorf("transaction mismatch (-want +got):\n%s", diff) + } } -func TestStreamServerInterceptor(t *testing.T) { +func TestStreamServerInterceptor_Panic(t *testing.T) { tests := map[string]struct { - options sentrygrpc.ServerOptions - handler grpc.StreamHandler - expectedMetadata bool - expectedEvent bool - expectedCode codes.Code - assertTransaction bool + options sentrygrpc.ServerOptions + wantRepanic bool }{ - "Default behavior, no error": { - options: sentrygrpc.ServerOptions{}, - handler: func(srv any, stream grpc.ServerStream) error { - return nil - }, - expectedMetadata: false, - expectedEvent: false, - }, - "Repanic is enabled": { - options: sentrygrpc.ServerOptions{ - Repanic: true, - }, - handler: func(srv any, stream grpc.ServerStream) error { - panic("test panic") - }, - expectedMetadata: false, - expectedEvent: true, - expectedCode: codes.Internal, - }, - "Recovered panic returns internal error": { + "panic is recovered and returns Internal error": { options: sentrygrpc.ServerOptions{}, - handler: func(srv any, stream grpc.ServerStream) error { - panic("test panic") - }, - expectedMetadata: false, - expectedEvent: true, - expectedCode: codes.Internal, }, - "Metadata is propagated": { - options: sentrygrpc.ServerOptions{}, - handler: func(srv any, stream grpc.ServerStream) error { - md, ok := metadata.FromIncomingContext(stream.Context()) - if !ok || len(md) == 0 { - return status.Error(codes.InvalidArgument, "metadata missing") - } - return nil - }, - expectedMetadata: true, - expectedEvent: false, - assertTransaction: true, + "panic is re-panicked when Repanic is set": { + options: sentrygrpc.ServerOptions{Repanic: true}, + wantRepanic: true, }, } - for name, test := range tests { + for name, tc := range tests { t.Run(name, func(t *testing.T) { - eventsCh := make(chan *sentry.Event, 1) - transactionsCh := make(chan *sentry.Event, 1) - - err := sentry.Init(sentry.ClientOptions{ - 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 + require.NoError(t, sentry.Init(sentry.ClientOptions{ + BeforeSend: func(e *sentry.Event, _ *sentry.EventHint) *sentry.Event { + eventsCh <- e + return e }, EnableTracing: true, TracesSampleRate: 1.0, - }) - if err != nil { - t.Fatal(err) - } + })) - interceptor := sentrygrpc.StreamServerInterceptor(test.options) - - // Simulate a server stream - stream := &wrappedServerStream{ - ServerStream: nil, - ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")), + interceptor := sentrygrpc.StreamServerInterceptor(tc.options) + ss := &stubServerStream{ + ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")), } - var recovered interface{} + var ( + err error + recovered any + ) func() { - defer func() { - recovered = recover() - }() - err = interceptor(nil, stream, &grpc.StreamServerInfo{FullMethod: "/test.TestService/StreamMethod"}, test.handler) + defer func() { recovered = recover() }() + err = interceptor(nil, ss, &grpc.StreamServerInfo{ + FullMethod: "/test.TestService/StreamMethod", + }, func(_ any, _ grpc.ServerStream) error { + panic("test panic") + }) }() - if test.expectedMetadata { - md, ok := metadata.FromIncomingContext(stream.Context()) - assert.True(t, ok, "Expected metadata to be propagated in context") - assert.Contains(t, md, "key", "Expected metadata to include 'key'") - } + sentry.Flush(testutils.FlushTimeout()) + require.NotNil(t, <-eventsCh) - if test.expectedEvent { - sentry.Flush(testutils.FlushTimeout()) - gotEvent := <-eventsCh - assert.NotNil(t, gotEvent, "Expected an event to be captured") + if tc.wantRepanic { + assert.Equal(t, "test panic", recovered) } else { - assert.Empty(t, eventsCh, "Expected no event to be captured") - } - - if test.expectedCode != codes.OK && !test.options.Repanic { - assert.Error(t, err) - assert.Equal(t, test.expectedCode, status.Code(err)) - } - - if test.assertTransaction { - sentry.Flush(testutils.FlushTimeout()) - gotTransaction := <-transactionsCh - assert.NotNil(t, gotTransaction, "Expected a transaction") - traceContext, ok := gotTransaction.Contexts["trace"] - assert.True(t, ok, "Expected trace context on the transaction") - assert.Equal(t, "rpc.server", traceContext["op"]) - assert.Equal(t, "test.TestService/StreamMethod", gotTransaction.Transaction) - assert.Equal(t, "test.TestService/StreamMethod", gotTransaction.Contexts["trace"]["description"]) - assert.Equal(t, "/test.TestService/StreamMethod", gotTransaction.Contexts["grpc"]["method"]) - data := gotTransaction.Contexts["trace"]["data"].(map[string]interface{}) - assert.Equal(t, "test.TestService", data["rpc.service"]) - assert.Equal(t, "StreamMethod", data["rpc.method"]) - assert.Equal(t, "grpc", data["rpc.system"]) - assert.Equal(t, int(codes.OK), data["rpc.grpc.status_code"]) + assert.Nil(t, recovered) + assert.Equal(t, codes.Internal, status.Code(err)) } - - if test.options.Repanic { - assert.NotNil(t, recovered, "Expected panic to be re-raised") - assert.Equal(t, "test panic", recovered, "Panic value should match") - } - - sentry.Flush(testutils.FlushTimeout()) }) } } From d9ccd343dd596b9aad0a1e8dfc20dccbfc093789 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:51:31 +0200 Subject: [PATCH 27/39] misc fixes --- grpc/client.go | 10 ++++++---- grpc/server.go | 10 ++++------ grpc/server_test.go | 34 ++++++++++------------------------ 3 files changed, 20 insertions(+), 34 deletions(-) diff --git a/grpc/client.go b/grpc/client.go index 85a4b8999..0488c1c97 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -162,10 +162,12 @@ func (s *sentryClientStream) SendMsg(m any) error { func (s *sentryClientStream) RecvMsg(m any) error { err := s.ClientStream.RecvMsg(m) - if errors.Is(err, io.EOF) { - s.finish(nil) - } else { - s.finish(err) + if err != nil { + if errors.Is(err, io.EOF) { + s.finish(nil) + } else { + s.finish(err) + } } return err } diff --git a/grpc/server.go b/grpc/server.go index 932c31cae..c2e54a237 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -76,14 +76,13 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { sentryBaggageHeader = getFirstHeader(md, sentry.SentryBaggageHeader) } - setScopeMetadata(hub, info.FullMethod, md) - name, service, method := parseGRPCMethod(info.FullMethod) + setScopeMetadata(hub, name, md) options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), sentry.WithOpName(defaultServerOperationName), sentry.WithDescription(name), - sentry.WithTransactionSource(sentry.SourceCustom), + sentry.WithTransactionSource(sentry.SourceRoute), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), } @@ -139,14 +138,13 @@ func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { sentryBaggageHeader = getFirstHeader(md, sentry.SentryBaggageHeader) } - setScopeMetadata(hub, info.FullMethod, md) - name, service, method := parseGRPCMethod(info.FullMethod) + setScopeMetadata(hub, name, md) options := []sentry.SpanOption{ sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), sentry.WithOpName(defaultServerOperationName), sentry.WithDescription(name), - sentry.WithTransactionSource(sentry.SourceCustom), + sentry.WithTransactionSource(sentry.SourceRoute), sentry.WithSpanOrigin(sentry.SpanOriginGrpc), } diff --git a/grpc/server_test.go b/grpc/server_test.go index c0f571fd1..36cfa54de 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -70,16 +70,7 @@ func TestServerOptions_SetDefaults(t *testing.T) { } func TestUnaryServerInterceptor(t *testing.T) { - txCh := make(chan *sentry.Event, 1) - require.NoError(t, sentry.Init(sentry.ClientOptions{ - BeforeSendTransaction: func(tx *sentry.Event, _ *sentry.EventHint) *sentry.Event { - txCh <- tx - return tx - }, - EnableTracing: true, - TracesSampleRate: 1.0, - })) - + transport := initMockTransport(t) interceptor := sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{}) ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")) @@ -92,6 +83,8 @@ func TestUnaryServerInterceptor(t *testing.T) { require.NoError(t, err) sentry.Flush(testutils.FlushTimeout()) + events := transport.Events() + require.Len(t, events, 1) if diff := cmp.Diff(txSummary{ Name: "test.TestService/Method", Op: "rpc.server", @@ -103,10 +96,10 @@ func TestUnaryServerInterceptor(t *testing.T) { "rpc.grpc.status_code": int(codes.OK), }, GRPC: map[string]any{ - "method": "/test.TestService/Method", + "method": "test.TestService/Method", "metadata": map[string]any{"key": "value"}, }, - }, summarizeTx(<-txCh)); diff != "" { + }, summarizeTx(events[0])); diff != "" { t.Errorf("transaction mismatch (-want +got):\n%s", diff) } } @@ -167,16 +160,7 @@ func TestUnaryServerInterceptor_Panic(t *testing.T) { } func TestStreamServerInterceptor(t *testing.T) { - txCh := make(chan *sentry.Event, 1) - require.NoError(t, sentry.Init(sentry.ClientOptions{ - BeforeSendTransaction: func(tx *sentry.Event, _ *sentry.EventHint) *sentry.Event { - txCh <- tx - return tx - }, - EnableTracing: true, - TracesSampleRate: 1.0, - })) - + transport := initMockTransport(t) interceptor := sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{}) ss := &stubServerStream{ ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")), @@ -194,6 +178,8 @@ func TestStreamServerInterceptor(t *testing.T) { require.NoError(t, err) sentry.Flush(testutils.FlushTimeout()) + events := transport.Events() + require.Len(t, events, 1) if diff := cmp.Diff(txSummary{ Name: "test.TestService/StreamMethod", Op: "rpc.server", @@ -205,10 +191,10 @@ func TestStreamServerInterceptor(t *testing.T) { "rpc.grpc.status_code": int(codes.OK), }, GRPC: map[string]any{ - "method": "/test.TestService/StreamMethod", + "method": "test.TestService/StreamMethod", "metadata": map[string]any{"key": "value"}, }, - }, summarizeTx(<-txCh)); diff != "" { + }, summarizeTx(events[0])); diff != "" { t.Errorf("transaction mismatch (-want +got):\n%s", diff) } } From 8844c9974af62b575f96f6d09de88f3406cc42b5 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:13:40 +0200 Subject: [PATCH 28/39] fix trace continuation --- grpc/client.go | 4 ++-- grpc/client_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/grpc/client.go b/grpc/client.go index 0488c1c97..3d971ff2a 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -36,8 +36,8 @@ func createOrUpdateMetadata(ctx context.Context, span *sentry.Span) context.Cont md, ok := metadata.FromOutgoingContext(ctx) if ok { md = md.Copy() - md.Append(sentry.SentryTraceHeader, span.ToSentryTrace()) - md.Append(sentry.SentryBaggageHeader, span.ToBaggage()) + md.Set(sentry.SentryTraceHeader, span.ToSentryTrace()) + md.Set(sentry.SentryBaggageHeader, span.ToBaggage()) return metadata.NewOutgoingContext(ctx, md) } diff --git a/grpc/client_test.go b/grpc/client_test.go index 2272c83c5..3c78bca99 100644 --- a/grpc/client_test.go +++ b/grpc/client_test.go @@ -109,6 +109,34 @@ func TestUnaryClientInterceptor(t *testing.T) { } } +func TestUnaryClientInterceptor_ReplacesExistingTraceHeaders(t *testing.T) { + transport := initMockTransport(t) + interceptor := sentrygrpc.UnaryClientInterceptor() + + oldTrace := "0123456789abcdef0123456789abcdef-0123456789abcdef-1" + oldBaggage := "sentry-trace_id=0123456789abcdef0123456789abcdef" + ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs( + sentry.SentryTraceHeader, oldTrace, + sentry.SentryBaggageHeader, oldBaggage, + "existing", "value", + )) + + err := interceptor(ctx, "/test.TestService/Method", struct{}{}, struct{}{}, nil, func(ctx context.Context, _ string, _, _ any, _ *grpc.ClientConn, _ ...grpc.CallOption) error { + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok) + assert.Equal(t, []string{"value"}, md.Get("existing")) + assert.Len(t, md.Get(sentry.SentryTraceHeader), 1) + assert.Len(t, md.Get(sentry.SentryBaggageHeader), 1) + assert.NotEqual(t, oldTrace, md.Get(sentry.SentryTraceHeader)[0]) + assert.NotEqual(t, oldBaggage, md.Get(sentry.SentryBaggageHeader)[0]) + return nil + }) + + require.NoError(t, err) + sentry.Flush(testutils.FlushTimeout()) + assert.Equal(t, int(codes.OK), spanStatusCode(t, transport)) +} + func TestStreamClientInterceptor(t *testing.T) { tests := map[string]struct { streamer grpc.Streamer From 6e55ff480b1698d73daa39b00c35d2871fee59c3 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:13:52 +0200 Subject: [PATCH 29/39] filter grpc sensitive metadata --- grpc/server.go | 8 ++++++++ grpc/server_test.go | 27 +++++++++++++++++++++++++++ interfaces.go | 35 +---------------------------------- util.go | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 34 deletions(-) diff --git a/grpc/server.go b/grpc/server.go index c2e54a237..06cc1d74e 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -203,6 +203,10 @@ func metadataToContext(md metadata.MD) map[string]any { ctx := make(map[string]any, len(md)) for key, values := range md { + if sentry.IsSensitiveHeader(key) { + continue + } + if len(values) == 1 { ctx[key] = values[0] continue @@ -213,6 +217,10 @@ func metadataToContext(md metadata.MD) map[string]any { ctx[key] = joined } + if len(ctx) == 0 { + return nil + } + return ctx } diff --git a/grpc/server_test.go b/grpc/server_test.go index 36cfa54de..7ab307c5f 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -104,6 +104,33 @@ func TestUnaryServerInterceptor(t *testing.T) { } } +func TestUnaryServerInterceptor_ScrubsSensitiveMetadata(t *testing.T) { + transport := initMockTransport(t) + interceptor := sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{}) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs( + "authorization", "Bearer secret-token", + "x-api-key", "top-secret", + "cookie", "session=secret", + "key", "value", + )) + + _, err := interceptor(ctx, nil, &grpc.UnaryServerInfo{ + FullMethod: "/test.TestService/Method", + }, func(ctx context.Context, _ any) (any, error) { + return struct{}{}, nil + }) + + require.NoError(t, err) + sentry.Flush(testutils.FlushTimeout()) + + events := transport.Events() + require.Len(t, events, 1) + grpcContext := map[string]any(events[0].Contexts["grpc"]) + metadataContext, ok := grpcContext["metadata"].(map[string]any) + require.True(t, ok) + assert.Equal(t, map[string]any{"key": "value"}, metadataContext) +} + func TestUnaryServerInterceptor_Panic(t *testing.T) { tests := map[string]struct { options sentrygrpc.ServerOptions diff --git a/interfaces.go b/interfaces.go index 07fb3300f..7916b1fe7 100644 --- a/interfaces.go +++ b/interfaces.go @@ -247,39 +247,6 @@ type Request struct { Env map[string]string `json:"env,omitempty"` } -var sensitiveHeaders = map[string]struct{}{ - "_csrf": {}, - "_csrf_token": {}, - "_session": {}, - "_xsrf": {}, - "Api-Key": {}, - "Apikey": {}, - "Auth": {}, - "Authorization": {}, - "Cookie": {}, - "Credentials": {}, - "Csrf": {}, - "Csrf-Token": {}, - "Csrftoken": {}, - "Ip-Address": {}, - "Passwd": {}, - "Password": {}, - "Private-Key": {}, - "Privatekey": {}, - "Proxy-Authorization": {}, - "Remote-Addr": {}, - "Secret": {}, - "Session": {}, - "Sessionid": {}, - "Token": {}, - "User-Session": {}, - "X-Api-Key": {}, - "X-Csrftoken": {}, - "X-Forwarded-For": {}, - "X-Real-Ip": {}, - "XSRF-TOKEN": {}, -} - // NewRequest returns a new Sentry Request from the given http.Request. // // NewRequest avoids operations that depend on network access. In particular, it @@ -312,7 +279,7 @@ func NewRequest(r *http.Request) *Request { } } else { for k, v := range r.Header { - if _, ok := sensitiveHeaders[k]; !ok { + if !IsSensitiveHeader(k) { headers[k] = strings.Join(v, ",") } } diff --git a/util.go b/util.go index f81ef1d38..11be8e644 100644 --- a/util.go +++ b/util.go @@ -111,6 +111,45 @@ func Pointer[T any](v T) *T { return &v } +var sensitiveHeaders = map[string]struct{}{ + "_csrf": {}, + "_csrf_token": {}, + "_session": {}, + "_xsrf": {}, + "api-key": {}, + "apikey": {}, + "auth": {}, + "authorization": {}, + "cookie": {}, + "credentials": {}, + "csrf": {}, + "csrf-token": {}, + "csrftoken": {}, + "ip-address": {}, + "passwd": {}, + "password": {}, + "private-key": {}, + "privatekey": {}, + "proxy-authorization": {}, + "remote-addr": {}, + "secret": {}, + "session": {}, + "sessionid": {}, + "token": {}, + "user-session": {}, + "x-api-key": {}, + "x-csrftoken": {}, + "x-forwarded-for": {}, + "x-real-ip": {}, + "xsrf-token": {}, +} + +// IsSensitiveHeader reports whether a header or metadata key should be treated as sensitive. +func IsSensitiveHeader(key string) bool { + _, ok := sensitiveHeaders[strings.ToLower(key)] + return ok +} + // eventIdentifier returns a human-readable identifier for the event to be used in log messages. // Format: " []". func eventIdentifier(event *Event) string { From 81528b47f530d47ab90669ec10442e1a4f2a9463 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 31 Mar 2026 13:36:06 +0000 Subject: [PATCH 30/39] fix(grpc): Use 2s default timeout consistent with other integrations Changed gRPC server interceptor default timeout from 5s to 2s to match the default timeout used by all other HTTP integrations (echo, gin, fiber, fasthttp, http, iris, negroni). Co-Authored-By: Claude Sonnet 4.5 --- grpc/server.go | 2 +- grpc/server_test.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/grpc/server.go b/grpc/server.go index 06cc1d74e..874402620 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -34,7 +34,7 @@ type ServerOptions struct { func (o *ServerOptions) SetDefaults() { if o.Timeout == 0 { - o.Timeout = sentry.DefaultFlushTimeout + o.Timeout = 2 * time.Second } } diff --git a/grpc/server_test.go b/grpc/server_test.go index 7ab307c5f..503019c7e 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -3,6 +3,7 @@ package sentrygrpc_test import ( "context" "testing" + "time" "github.com/getsentry/sentry-go" sentrygrpc "github.com/getsentry/sentry-go/grpc" @@ -53,7 +54,7 @@ func TestServerOptions_SetDefaults(t *testing.T) { }{ "zero value gets default timeout": { input: sentrygrpc.ServerOptions{}, - want: sentrygrpc.ServerOptions{Timeout: sentry.DefaultFlushTimeout}, + want: sentrygrpc.ServerOptions{Timeout: 2 * time.Second}, }, "non-zero timeout is preserved": { input: sentrygrpc.ServerOptions{Timeout: testutils.FlushTimeout()}, From c17453fca4cdce291007dd85254fc68e76ff11cb Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:52:51 +0200 Subject: [PATCH 31/39] add default flush timeout --- echo/sentryecho.go | 2 +- fasthttp/sentryfasthttp.go | 2 +- fiber/sentryfiber.go | 2 +- gin/sentrygin.go | 2 +- grpc/server.go | 2 +- grpc/server_test.go | 3 +-- http/sentryhttp.go | 2 +- internal/telemetry/scheduler_test.go | 3 ++- iris/sentryiris.go | 2 +- negroni/sentrynegroni.go | 2 +- sentry.go | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/echo/sentryecho.go b/echo/sentryecho.go index b892577c1..cacfcfb20 100644 --- a/echo/sentryecho.go +++ b/echo/sentryecho.go @@ -46,7 +46,7 @@ type Options struct { // It can be used with Use() methods. func New(options Options) echo.MiddlewareFunc { if options.Timeout == 0 { - options.Timeout = 2 * time.Second + options.Timeout = sentry.DefaultFlushTimeout } return (&handler{ diff --git a/fasthttp/sentryfasthttp.go b/fasthttp/sentryfasthttp.go index fea14ddd6..a5949e448 100644 --- a/fasthttp/sentryfasthttp.go +++ b/fasthttp/sentryfasthttp.go @@ -47,7 +47,7 @@ type Options struct { // that satisfy fasthttp.RequestHandler interface. func New(options Options) *Handler { if options.Timeout == 0 { - options.Timeout = 2 * time.Second + options.Timeout = sentry.DefaultFlushTimeout } return &Handler{ diff --git a/fiber/sentryfiber.go b/fiber/sentryfiber.go index 1e44f9fd5..2e625d3a4 100644 --- a/fiber/sentryfiber.go +++ b/fiber/sentryfiber.go @@ -48,7 +48,7 @@ type Options struct { // New returns a handler struct which satisfies Fiber's middleware interface func New(options Options) fiber.Handler { if options.Timeout == 0 { - options.Timeout = 2 * time.Second + options.Timeout = sentry.DefaultFlushTimeout } return (&handler{ diff --git a/gin/sentrygin.go b/gin/sentrygin.go index 1b27086a2..bfc551c66 100644 --- a/gin/sentrygin.go +++ b/gin/sentrygin.go @@ -46,7 +46,7 @@ type Options struct { // It can be used with Use() methods. func New(options Options) gin.HandlerFunc { if options.Timeout == 0 { - options.Timeout = 2 * time.Second + options.Timeout = sentry.DefaultFlushTimeout } return (&handler{ diff --git a/grpc/server.go b/grpc/server.go index 874402620..06cc1d74e 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -34,7 +34,7 @@ type ServerOptions struct { func (o *ServerOptions) SetDefaults() { if o.Timeout == 0 { - o.Timeout = 2 * time.Second + o.Timeout = sentry.DefaultFlushTimeout } } diff --git a/grpc/server_test.go b/grpc/server_test.go index 503019c7e..7ab307c5f 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -3,7 +3,6 @@ package sentrygrpc_test import ( "context" "testing" - "time" "github.com/getsentry/sentry-go" sentrygrpc "github.com/getsentry/sentry-go/grpc" @@ -54,7 +53,7 @@ func TestServerOptions_SetDefaults(t *testing.T) { }{ "zero value gets default timeout": { input: sentrygrpc.ServerOptions{}, - want: sentrygrpc.ServerOptions{Timeout: 2 * time.Second}, + want: sentrygrpc.ServerOptions{Timeout: sentry.DefaultFlushTimeout}, }, "non-zero timeout is preserved": { input: sentrygrpc.ServerOptions{Timeout: testutils.FlushTimeout()}, diff --git a/http/sentryhttp.go b/http/sentryhttp.go index c63f8547b..4a80e6fd7 100644 --- a/http/sentryhttp.go +++ b/http/sentryhttp.go @@ -54,7 +54,7 @@ type Options struct { // existing HTTP handlers. func New(options Options) *Handler { if options.Timeout == 0 { - options.Timeout = 2 * time.Second + options.Timeout = sentry.DefaultFlushTimeout } return &Handler{ diff --git a/internal/telemetry/scheduler_test.go b/internal/telemetry/scheduler_test.go index d84d2761a..337882039 100644 --- a/internal/telemetry/scheduler_test.go +++ b/internal/telemetry/scheduler_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" "github.com/getsentry/sentry-go/internal/testutils" @@ -293,7 +294,7 @@ func TestTelemetrySchedulerContextCancellation(t *testing.T) { select { case <-done: - case <-time.After(2 * time.Second): + case <-time.After(sentry.DefaultFlushTimeout): t.Error("Scheduler stop took too long") } } diff --git a/iris/sentryiris.go b/iris/sentryiris.go index 242adbdf9..e3e1589a2 100644 --- a/iris/sentryiris.go +++ b/iris/sentryiris.go @@ -44,7 +44,7 @@ type Options struct { // It can be used with Use() method. func New(options Options) iris.Handler { if options.Timeout == 0 { - options.Timeout = 2 * time.Second + options.Timeout = sentry.DefaultFlushTimeout } return (&handler{ diff --git a/negroni/sentrynegroni.go b/negroni/sentrynegroni.go index c59ac5c00..cdee10f88 100644 --- a/negroni/sentrynegroni.go +++ b/negroni/sentrynegroni.go @@ -36,7 +36,7 @@ type Options struct { // It can be used with New(), Use() or With() methods. func New(options Options) negroni.Handler { if options.Timeout == 0 { - options.Timeout = 2 * time.Second + options.Timeout = sentry.DefaultFlushTimeout } return &handler{ diff --git a/sentry.go b/sentry.go index dbfafa2de..86b6d069a 100644 --- a/sentry.go +++ b/sentry.go @@ -13,7 +13,7 @@ const SDKVersion = "0.44.1" const apiVersion = "7" // DefaultFlushTimeout is the default timeout used for flushing events. -const DefaultFlushTimeout = 5 * time.Second +const DefaultFlushTimeout = 2 * time.Second // Init initializes the SDK with options. The returned error is non-nil if // options is invalid, for instance if a malformed DSN is provided. From aeae8b206a0464dd6e7b0ce0b25d8dda08e77930 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:55:18 +0200 Subject: [PATCH 32/39] fix import cycle --- internal/telemetry/scheduler_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/telemetry/scheduler_test.go b/internal/telemetry/scheduler_test.go index 337882039..d84d2761a 100644 --- a/internal/telemetry/scheduler_test.go +++ b/internal/telemetry/scheduler_test.go @@ -4,7 +4,6 @@ import ( "testing" "time" - "github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go/internal/protocol" "github.com/getsentry/sentry-go/internal/ratelimit" "github.com/getsentry/sentry-go/internal/testutils" @@ -294,7 +293,7 @@ func TestTelemetrySchedulerContextCancellation(t *testing.T) { select { case <-done: - case <-time.After(sentry.DefaultFlushTimeout): + case <-time.After(2 * time.Second): t.Error("Scheduler stop took too long") } } From 718d221b70b1ceb240be80a17fcbe90c06a5dc53 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:35:48 +0200 Subject: [PATCH 33/39] fix examples --- _examples/grpc/client/main.go | 7 +++++-- _examples/grpc/server/main.go | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/_examples/grpc/client/main.go b/_examples/grpc/client/main.go index fb598cba4..36bb4c5de 100644 --- a/_examples/grpc/client/main.go +++ b/_examples/grpc/client/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "io" "log" "time" @@ -107,8 +108,10 @@ func streamExample(client examplepb.ExampleServiceClient) { for { res, err := stream.Recv() if err != nil { - fmt.Printf("Stream Recv Error: %v\n", err) - sentry.CaptureException(err) + if err != io.EOF { + fmt.Printf("Stream Recv Error: %v\n", err) + sentry.CaptureException(err) + } break } fmt.Printf("Stream Response: %s\n", res.Message) diff --git a/_examples/grpc/server/main.go b/_examples/grpc/server/main.go index eb164a15e..09f52fb18 100644 --- a/_examples/grpc/server/main.go +++ b/_examples/grpc/server/main.go @@ -8,8 +8,8 @@ import ( "time" "github.com/getsentry/sentry-go" + "github.com/getsentry/sentry-go/_examples/grpc/server/examplepb" sentrygrpc "github.com/getsentry/sentry-go/grpc" - "github.com/sentry-go/_examples/grpc/server/examplepb" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) From 48d27959d7631fc3a7fb57236e68e0d740650389 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:36:17 +0200 Subject: [PATCH 34/39] remove SetDefaults test --- grpc/server_test.go | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/grpc/server_test.go b/grpc/server_test.go index 7ab307c5f..5cfd059dc 100644 --- a/grpc/server_test.go +++ b/grpc/server_test.go @@ -46,29 +46,6 @@ func summarizeTx(tx *sentry.Event) txSummary { return s } -func TestServerOptions_SetDefaults(t *testing.T) { - tests := map[string]struct { - input sentrygrpc.ServerOptions - want sentrygrpc.ServerOptions - }{ - "zero value gets default timeout": { - input: sentrygrpc.ServerOptions{}, - want: sentrygrpc.ServerOptions{Timeout: sentry.DefaultFlushTimeout}, - }, - "non-zero timeout is preserved": { - input: sentrygrpc.ServerOptions{Timeout: testutils.FlushTimeout()}, - want: sentrygrpc.ServerOptions{Timeout: testutils.FlushTimeout()}, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - tc.input.SetDefaults() - assert.Equal(t, tc.want, tc.input) - }) - } -} - func TestUnaryServerInterceptor(t *testing.T) { transport := initMockTransport(t) interceptor := sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{}) From b7de787f5400f4f9ab118a070a9423a2f3efa4b6 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:48:15 +0200 Subject: [PATCH 35/39] extend client tests --- grpc/client_test.go | 73 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/grpc/client_test.go b/grpc/client_test.go index 3c78bca99..3c21bc6d4 100644 --- a/grpc/client_test.go +++ b/grpc/client_test.go @@ -3,7 +3,9 @@ package sentrygrpc_test import ( "context" "io" + "strings" "testing" + "time" "github.com/getsentry/sentry-go" sentrygrpc "github.com/getsentry/sentry-go/grpc" @@ -20,6 +22,7 @@ import ( type mockClientStream struct { headerFn func() (metadata.MD, error) closeSendFn func() error + contextFn func() context.Context sendMsgFn func(msg any) error recvMsgFn func(msg any) error } @@ -37,7 +40,12 @@ func (m *mockClientStream) CloseSend() error { } return nil } -func (m *mockClientStream) Context() context.Context { return context.Background() } +func (m *mockClientStream) Context() context.Context { + if m.contextFn != nil { + return m.contextFn() + } + return context.Background() +} func (m *mockClientStream) SendMsg(msg any) error { if m.sendMsgFn != nil { return m.sendMsgFn(msg) @@ -137,13 +145,37 @@ func TestUnaryClientInterceptor_ReplacesExistingTraceHeaders(t *testing.T) { assert.Equal(t, int(codes.OK), spanStatusCode(t, transport)) } +func TestUnaryClientInterceptor_PreservesExistingBaggageMembers(t *testing.T) { + transport := initMockTransport(t) + interceptor := sentrygrpc.UnaryClientInterceptor() + + ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs( + sentry.SentryBaggageHeader, "othervendor=bla", + )) + + err := interceptor(ctx, "/test.TestService/Method", struct{}{}, struct{}{}, nil, func(ctx context.Context, _ string, _, _ any, _ *grpc.ClientConn, _ ...grpc.CallOption) error { + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok) + baggageHeader := strings.Join(md.Get(sentry.SentryBaggageHeader), ",") + assert.Contains(t, baggageHeader, "othervendor=bla") + assert.Contains(t, baggageHeader, "sentry-trace_id") + return nil + }) + + require.NoError(t, err) + sentry.Flush(testutils.FlushTimeout()) + assert.Equal(t, int(codes.OK), spanStatusCode(t, transport)) +} + func TestStreamClientInterceptor(t *testing.T) { tests := map[string]struct { + ctx context.Context streamer grpc.Streamer streamOp func(stream grpc.ClientStream) wantCode codes.Code }{ "records span and propagates trace headers": { + ctx: context.Background(), streamer: func(ctx context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { md, ok := metadata.FromOutgoingContext(ctx) require.True(t, ok) @@ -155,18 +187,21 @@ func TestStreamClientInterceptor(t *testing.T) { wantCode: codes.OK, }, "streamer error records span with error status": { + ctx: context.Background(), streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { return nil, status.Error(codes.Unavailable, "unavailable") }, wantCode: codes.Unavailable, }, "nil stream from streamer records Internal error": { + ctx: context.Background(), streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { return nil, nil }, wantCode: codes.Internal, }, "RecvMsg EOF finishes span with OK": { + ctx: context.Background(), streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { return &mockClientStream{recvMsgFn: func(_ any) error { return io.EOF }}, nil }, @@ -174,6 +209,7 @@ func TestStreamClientInterceptor(t *testing.T) { wantCode: codes.OK, }, "RecvMsg error records error status": { + ctx: context.Background(), streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { return &mockClientStream{recvMsgFn: func(_ any) error { return status.Error(codes.Unavailable, "down") }}, nil }, @@ -181,6 +217,7 @@ func TestStreamClientInterceptor(t *testing.T) { wantCode: codes.Unavailable, }, "CloseSend error records error status": { + ctx: context.Background(), streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { return &mockClientStream{closeSendFn: func() error { return status.Error(codes.Internal, "internal") }}, nil }, @@ -188,6 +225,7 @@ func TestStreamClientInterceptor(t *testing.T) { wantCode: codes.Internal, }, "SendMsg error records error status": { + ctx: context.Background(), streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { return &mockClientStream{sendMsgFn: func(_ any) error { return status.Error(codes.DeadlineExceeded, "timeout") }}, nil }, @@ -195,6 +233,7 @@ func TestStreamClientInterceptor(t *testing.T) { wantCode: codes.DeadlineExceeded, }, "Header error records error status": { + ctx: context.Background(), streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { return &mockClientStream{headerFn: func() (metadata.MD, error) { return nil, status.Error(codes.NotFound, "not found") }}, nil }, @@ -202,6 +241,7 @@ func TestStreamClientInterceptor(t *testing.T) { wantCode: codes.NotFound, }, "finish is idempotent across multiple error paths": { + ctx: context.Background(), streamer: func(_ context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { rpcErr := status.Error(codes.Canceled, "canceled") return &mockClientStream{ @@ -222,7 +262,7 @@ func TestStreamClientInterceptor(t *testing.T) { transport := initMockTransport(t) interceptor := sentrygrpc.StreamClientInterceptor() - stream, _ := interceptor(context.Background(), &grpc.StreamDesc{}, nil, "/test.TestService/Method", tc.streamer) + stream, _ := interceptor(tc.ctx, &grpc.StreamDesc{}, nil, "/test.TestService/Method", tc.streamer) if tc.streamOp != nil && stream != nil { tc.streamOp(stream) } @@ -232,3 +272,32 @@ func TestStreamClientInterceptor(t *testing.T) { }) } } + +func TestStreamClientInterceptor_FinishesOnContextCancellation(t *testing.T) { + transport := initMockTransport(t) + interceptor := sentrygrpc.StreamClientInterceptor() + + ctx, cancel := context.WithCancel(context.Background()) + stream, err := interceptor(ctx, &grpc.StreamDesc{}, nil, "/test.TestService/Method", func(ctx context.Context, _ *grpc.StreamDesc, _ *grpc.ClientConn, _ string, _ ...grpc.CallOption) (grpc.ClientStream, error) { + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok) + assert.Contains(t, md, sentry.SentryTraceHeader) + assert.Contains(t, md, sentry.SentryBaggageHeader) + return &mockClientStream{}, nil + }) + + require.NoError(t, err) + require.NotNil(t, stream) + + cancel() + + require.Eventually(t, func() bool { + sentry.Flush(testutils.FlushTimeout()) + return len(transport.Events()) > 0 + }, testutils.FlushTimeout(), 10*time.Millisecond) + + events := transport.Events() + lastEvent := events[len(events)-1] + statusCode := lastEvent.Contexts["trace"]["data"].(map[string]any)["rpc.grpc.status_code"].(int) + assert.Equal(t, int(codes.Canceled), statusCode) +} From 43d773bf1857e00fd55376c51e07e01d0c83cd11 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:13:32 +0200 Subject: [PATCH 36/39] add MergeBaggage helper --- baggage.go | 34 ++++++++++++++++++ baggage_test.go | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 baggage.go create mode 100644 baggage_test.go diff --git a/baggage.go b/baggage.go new file mode 100644 index 000000000..4fbc87109 --- /dev/null +++ b/baggage.go @@ -0,0 +1,34 @@ +package sentry + +import ( + "fmt" + + "github.com/getsentry/sentry-go/internal/otel/baggage" +) + +// MergeBaggage merges an existing baggage header with a Sentry-generated one. +// +// Existing third-party members are preserved. If both baggage strings contain +// the same member key, the Sentry-generated member wins. +func MergeBaggage(existingHeader, sentryHeader string) (string, error) { + // TODO: we are reparsing the headers here, because we currently don't + // expose a method to get only DSC or its baggage members. + sentryBaggage, err := baggage.Parse(sentryHeader) + if err != nil { + return "", fmt.Errorf("cannot parse sentryHeader: %w", err) + } + + finalBaggage, err := baggage.Parse(existingHeader) + if err != nil { + return "", fmt.Errorf("cannot parse existingHeader: %w", err) + } + + for _, member := range sentryBaggage.Members() { + finalBaggage, err = finalBaggage.SetMember(member) + if err != nil { + return "", fmt.Errorf("cannot merge baggage: %w", err) + } + } + + return finalBaggage.String(), nil +} diff --git a/baggage_test.go b/baggage_test.go new file mode 100644 index 000000000..a629c7890 --- /dev/null +++ b/baggage_test.go @@ -0,0 +1,93 @@ +package sentry + +import ( + "strings" + "testing" +) + +func TestMergeBaggage(t *testing.T) { + t.Run("both empty", func(t *testing.T) { + got, err := MergeBaggage("", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("expected empty baggage, got %q", got) + } + }) + + t.Run("empty existing returns sentry baggage", func(t *testing.T) { + got, err := MergeBaggage("", "sentry-trace_id=123,sentry-sampled=true") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertBaggageStringsEqual(t, got, "sentry-trace_id=123,sentry-sampled=true") + }) + + t.Run("empty sentry returns existing baggage", func(t *testing.T) { + got, err := MergeBaggage("othervendor=bla", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertBaggageStringsEqual(t, got, "othervendor=bla") + }) + + t.Run("preserves third party members", func(t *testing.T) { + got, err := MergeBaggage("othervendor=bla", "sentry-trace_id=123,sentry-sampled=true") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertBaggageStringsEqual(t, got, "othervendor=bla,sentry-trace_id=123,sentry-sampled=true") + }) + + t.Run("sentry members override existing members", func(t *testing.T) { + got, err := MergeBaggage( + "othervendor=bla,sentry-trace_id=old,sentry-sampled=false", + "sentry-trace_id=new,sentry-sampled=true", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertBaggageStringsEqual(t, got, "othervendor=bla,sentry-trace_id=new,sentry-sampled=true") + }) + + t.Run("invalid existing returns empty and error", func(t *testing.T) { + got, err := MergeBaggage("not-valid", "sentry-trace_id=123,sentry-sampled=true") + if err == nil { + t.Fatal("expected error") + } + if got != "" { + t.Fatalf("expected empty baggage, got %q", got) + } + if !strings.Contains(err.Error(), "cannot parse existingHeader") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("invalid sentry returns empty and error", func(t *testing.T) { + got, err := MergeBaggage("othervendor=bla", "sentry-trace_id=123,invalid member,sentry-sampled=true") + if err == nil { + t.Fatal("expected error") + } + if got != "" { + t.Fatalf("expected empty baggage, got %q", got) + } + if !strings.Contains(err.Error(), "cannot parse sentryHeader") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("invalid existing with empty sentry still errors", func(t *testing.T) { + got, err := MergeBaggage("not-valid", "") + if err == nil { + t.Fatal("expected error") + } + if got != "" { + t.Fatalf("expected empty baggage, got %q", got) + } + }) +} From e19e1ad00ac01ed1cc16b233e8f2a15038aebaed Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:13:39 +0200 Subject: [PATCH 37/39] ref client & server --- grpc/client.go | 125 +++++++++++++++++------------------- grpc/server.go | 171 ++++++++++++++++++++++--------------------------- 2 files changed, 134 insertions(+), 162 deletions(-) diff --git a/grpc/client.go b/grpc/client.go index 3d971ff2a..ee0efd80d 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -7,6 +7,7 @@ import ( "context" "errors" "io" + "strings" "sync" "github.com/getsentry/sentry-go" @@ -33,20 +34,46 @@ func hubFromClientContext(ctx context.Context) context.Context { } func createOrUpdateMetadata(ctx context.Context, span *sentry.Span) context.Context { - md, ok := metadata.FromOutgoingContext(ctx) - if ok { - md = md.Copy() - md.Set(sentry.SentryTraceHeader, span.ToSentryTrace()) - md.Set(sentry.SentryBaggageHeader, span.ToBaggage()) - return metadata.NewOutgoingContext(ctx, md) + md, _ := metadata.FromOutgoingContext(ctx) + md = md.Copy() + md.Set(sentry.SentryTraceHeader, span.ToSentryTrace()) + + existingBaggage := strings.Join(md.Get(sentry.SentryBaggageHeader), ",") + mergedBaggage, err := sentry.MergeBaggage(existingBaggage, span.ToBaggage()) + if err == nil { + md.Set(sentry.SentryBaggageHeader, mergedBaggage) } - md = metadata.Pairs( - sentry.SentryTraceHeader, span.ToSentryTrace(), - sentry.SentryBaggageHeader, span.ToBaggage(), + return metadata.NewOutgoingContext(ctx, md) +} + +func finishSpan(span *sentry.Span, err error) { + code := grpcStatusCode(err) + span.Status = toSpanStatus(code) + span.SetData("rpc.grpc.status_code", int(code)) + span.Finish() +} + +func startClientSpan(ctx context.Context, method string) (context.Context, *sentry.Span) { + ctx = hubFromClientContext(ctx) + name, service, rpcMethod := parseGRPCMethod(method) + span := sentry.StartSpan( + ctx, + defaultClientOperationName, + sentry.WithTransactionName(name), + sentry.WithDescription(name), + sentry.WithSpanOrigin(sentry.SpanOriginGrpc), ) + if service != "" { + span.SetData("rpc.service", service) + } + if rpcMethod != "" { + span.SetData("rpc.method", rpcMethod) + } + span.SetData("rpc.system", "grpc") - return metadata.NewOutgoingContext(ctx, md) + ctx = createOrUpdateMetadata(span.Context(), span) + return ctx, span } func UnaryClientInterceptor() grpc.UnaryClientInterceptor { @@ -55,31 +82,13 @@ func UnaryClientInterceptor() grpc.UnaryClientInterceptor { req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, - callOpts ...grpc.CallOption) error { - ctx = hubFromClientContext(ctx) - name, service, rpcMethod := parseGRPCMethod(method) - span := sentry.StartSpan( - ctx, - defaultClientOperationName, - sentry.WithTransactionName(name), - sentry.WithDescription(name), - sentry.WithSpanOrigin(sentry.SpanOriginGrpc), - ) - if service != "" { - span.SetData("rpc.service", service) - } - if rpcMethod != "" { - span.SetData("rpc.method", rpcMethod) - } - span.SetData("rpc.system", "grpc") - ctx = span.Context() + callOpts ...grpc.CallOption) (err error) { + ctx, span := startClientSpan(ctx, method) + defer func() { + finishSpan(span, err) + }() - ctx = createOrUpdateMetadata(ctx, span) - defer span.Finish() - - err := invoker(ctx, method, req, reply, cc, callOpts...) - span.Status = toSpanStatus(status.Code(err)) - span.SetData("rpc.grpc.status_code", int(status.Code(err))) + err = invoker(ctx, method, req, reply, cc, callOpts...) return err } } @@ -91,49 +100,32 @@ func StreamClientInterceptor() grpc.StreamClientInterceptor { method string, streamer grpc.Streamer, callOpts ...grpc.CallOption) (grpc.ClientStream, error) { - ctx = hubFromClientContext(ctx) - name, service, rpcMethod := parseGRPCMethod(method) - span := sentry.StartSpan( - ctx, - defaultClientOperationName, - sentry.WithTransactionName(name), - sentry.WithDescription(name), - sentry.WithSpanOrigin(sentry.SpanOriginGrpc), - ) - if service != "" { - span.SetData("rpc.service", service) - } - if rpcMethod != "" { - span.SetData("rpc.method", rpcMethod) - } - span.SetData("rpc.system", "grpc") - ctx = span.Context() - - ctx = createOrUpdateMetadata(ctx, span) + ctx, span := startClientSpan(ctx, method) stream, err := streamer(ctx, desc, cc, method, callOpts...) if err != nil { - span.Status = toSpanStatus(status.Code(err)) - span.SetData("rpc.grpc.status_code", int(status.Code(err))) - span.Finish() + finishSpan(span, err) return nil, err } if stream == nil { nilErr := status.Error(codes.Internal, "streamer returned nil stream without error") - span.Status = toSpanStatus(codes.Internal) - span.SetData("rpc.grpc.status_code", int(codes.Internal)) - span.Finish() + finishSpan(span, nilErr) return nil, nilErr } - return &sentryClientStream{ClientStream: stream, span: span}, nil + wrappedStream := &sentryClientStream{ClientStream: stream, span: span} + wrappedStream.stopMonitor = context.AfterFunc(ctx, func() { + wrappedStream.finish(ctx.Err()) + }) + return wrappedStream, nil } } type sentryClientStream struct { grpc.ClientStream - span *sentry.Span - finishOnce sync.Once + span *sentry.Span + stopMonitor func() bool + finishOnce sync.Once } func (s *sentryClientStream) Header() (metadata.MD, error) { @@ -174,12 +166,9 @@ func (s *sentryClientStream) RecvMsg(m any) error { func (s *sentryClientStream) finish(err error) { s.finishOnce.Do(func() { - s.span.Status = toSpanStatus(status.Code(err)) - if err == nil { - s.span.SetData("rpc.grpc.status_code", int(codes.OK)) - } else { - s.span.SetData("rpc.grpc.status_code", int(status.Code(err))) + if s.stopMonitor != nil { + s.stopMonitor() } - s.span.Finish() + finishSpan(s.span, err) }) } diff --git a/grpc/server.go b/grpc/server.go index 06cc1d74e..d1bbd8491 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -32,7 +32,7 @@ type ServerOptions struct { Timeout time.Duration } -func (o *ServerOptions) SetDefaults() { +func (o *ServerOptions) setDefaults() { if o.Timeout == 0 { o.Timeout = sentry.DefaultFlushTimeout } @@ -56,62 +56,83 @@ func recoverWithSentry(ctx context.Context, hub *sentry.Hub, o ServerOptions, on } } -func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { - opts.SetDefaults() +func hubFromServerContext(ctx context.Context) *sentry.Hub { + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = sentry.CurrentHub().Clone() + } - return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { - hub := sentry.GetHubFromContext(ctx) - if hub == nil { - hub = sentry.CurrentHub().Clone() - } + if client := hub.Client(); client != nil { + client.SetSDKIdentifier(sdkIdentifier) + } - if client := hub.Client(); client != nil { - client.SetSDKIdentifier(sdkIdentifier) - } + return hub +} - md, ok := metadata.FromIncomingContext(ctx) - var sentryTraceHeader, sentryBaggageHeader string - if ok { - sentryTraceHeader = getFirstHeader(md, sentry.SentryTraceHeader) - sentryBaggageHeader = getFirstHeader(md, sentry.SentryBaggageHeader) - } +func traceHeadersFromContext(ctx context.Context) (metadata.MD, string, string) { + md, _ := metadata.FromIncomingContext(ctx) + return md, getFirstHeader(md, sentry.SentryTraceHeader), getFirstHeader(md, sentry.SentryBaggageHeader) +} - name, service, method := parseGRPCMethod(info.FullMethod) - setScopeMetadata(hub, name, md) - options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), - sentry.WithOpName(defaultServerOperationName), - sentry.WithDescription(name), - sentry.WithTransactionSource(sentry.SourceRoute), - sentry.WithSpanOrigin(sentry.SpanOriginGrpc), - } +func startServerTransaction(ctx context.Context, fullMethod string) (context.Context, *sentry.Hub, *sentry.Span) { + hub := hubFromServerContext(ctx) + md, sentryTraceHeader, sentryBaggageHeader := traceHeadersFromContext(ctx) + name, service, method := parseGRPCMethod(fullMethod) + + setScopeMetadata(hub, name, md) + + transaction := sentry.StartTransaction( + sentry.SetHubOnContext(ctx, hub), + name, + sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), + sentry.WithOpName(defaultServerOperationName), + sentry.WithDescription(name), + sentry.WithTransactionSource(sentry.SourceRoute), + sentry.WithSpanOrigin(sentry.SpanOriginGrpc), + ) + if service != "" { + transaction.SetData("rpc.service", service) + } + if method != "" { + transaction.SetData("rpc.method", method) + } + transaction.SetData("rpc.system", "grpc") - transaction := sentry.StartTransaction( - sentry.SetHubOnContext(ctx, hub), - name, - options..., - ) - if service != "" { - transaction.SetData("rpc.service", service) - } - if method != "" { - transaction.SetData("rpc.method", method) - } - transaction.SetData("rpc.system", "grpc") + return transaction.Context(), hub, transaction +} - ctx = transaction.Context() +func setRPCStatus(span *sentry.Span, err error) { + code := grpcStatusCode(err) + span.Status = toSpanStatus(code) + span.SetData("rpc.grpc.status_code", int(code)) +} + +func grpcStatusCode(err error) codes.Code { + if err == nil { + return codes.OK + } + + if s, ok := status.FromError(err); ok { + return s.Code() + } + + return status.FromContextError(err).Code() +} + +func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { + opts.setDefaults() + + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) { + ctx, hub, transaction := startServerTransaction(ctx, info.FullMethod) defer transaction.Finish() defer recoverWithSentry(ctx, hub, opts, func() { err = status.Error(codes.Internal, internalServerErrorMessage) - transaction.Status = sentry.SpanStatusInternalError - transaction.SetData("rpc.grpc.status_code", int(codes.Internal)) + setRPCStatus(transaction, err) }) resp, err = handler(ctx, req) - statusCode := status.Code(err) - transaction.Status = toSpanStatus(statusCode) - transaction.SetData("rpc.grpc.status_code", int(statusCode)) + setRPCStatus(transaction, err) return resp, err } @@ -119,62 +140,20 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { // StreamServerInterceptor provides Sentry integration for streaming gRPC calls. func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { - opts.SetDefaults() + opts.setDefaults() return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) (err error) { - ctx := ss.Context() - hub := sentry.GetHubFromContext(ctx) - if hub == nil { - hub = sentry.CurrentHub().Clone() - } - - if client := hub.Client(); client != nil { - client.SetSDKIdentifier(sdkIdentifier) - } - - md, ok := metadata.FromIncomingContext(ctx) - var sentryTraceHeader, sentryBaggageHeader string - if ok { - sentryTraceHeader = getFirstHeader(md, sentry.SentryTraceHeader) - sentryBaggageHeader = getFirstHeader(md, sentry.SentryBaggageHeader) - } - - name, service, method := parseGRPCMethod(info.FullMethod) - setScopeMetadata(hub, name, md) - options := []sentry.SpanOption{ - sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader), - sentry.WithOpName(defaultServerOperationName), - sentry.WithDescription(name), - sentry.WithTransactionSource(sentry.SourceRoute), - sentry.WithSpanOrigin(sentry.SpanOriginGrpc), - } - - transaction := sentry.StartTransaction( - sentry.SetHubOnContext(ctx, hub), - name, - options..., - ) - if service != "" { - transaction.SetData("rpc.service", service) - } - if method != "" { - transaction.SetData("rpc.method", method) - } - transaction.SetData("rpc.system", "grpc") - ctx = transaction.Context() + ctx, hub, transaction := startServerTransaction(ss.Context(), info.FullMethod) defer transaction.Finish() stream := wrapServerStream(ss, ctx) defer recoverWithSentry(ctx, hub, opts, func() { err = status.Error(codes.Internal, internalServerErrorMessage) - transaction.Status = sentry.SpanStatusInternalError - transaction.SetData("rpc.grpc.status_code", int(codes.Internal)) + setRPCStatus(transaction, err) }) err = handler(srv, stream) - statusCode := status.Code(err) - transaction.Status = toSpanStatus(statusCode) - transaction.SetData("rpc.grpc.status_code", int(statusCode)) + setRPCStatus(transaction, err) return err } @@ -207,14 +186,18 @@ func metadataToContext(md metadata.MD) map[string]any { continue } + if len(values) == 0 { + continue + } + if len(values) == 1 { ctx[key] = values[0] continue } - joined := make([]string, len(values)) - copy(joined, values) - ctx[key] = joined + copied := make([]string, len(values)) + copy(copied, values) + ctx[key] = copied } if len(ctx) == 0 { @@ -235,7 +218,7 @@ func parseGRPCMethod(fullMethod string) (name, service, method string) { return fullMethod, "", "" } name = fullMethod[1:] - pos := strings.LastIndex(name, "/") + pos := strings.Index(name, "/") if pos < 0 { return name, "", "" } From 5e7b9c9ee4d5c03712c08bdecc882d96fe396b90 Mon Sep 17 00:00:00 2001 From: Giannis Gkiortzis <58184179+giortzisg@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:07:07 +0200 Subject: [PATCH 38/39] fix: MergeBaggage keeps sentry baggage when incoming is malformed --- baggage.go | 12 ++++++++++-- baggage_test.go | 20 +++++--------------- grpc/client_test.go | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/baggage.go b/baggage.go index 4fbc87109..fe3d19957 100644 --- a/baggage.go +++ b/baggage.go @@ -3,13 +3,15 @@ package sentry import ( "fmt" + "github.com/getsentry/sentry-go/internal/debuglog" "github.com/getsentry/sentry-go/internal/otel/baggage" ) // MergeBaggage merges an existing baggage header with a Sentry-generated one. // // Existing third-party members are preserved. If both baggage strings contain -// the same member key, the Sentry-generated member wins. +// the same member key, the Sentry-generated member wins. The helper is best-effort +// and only keeps the sentry baggage in case the existing one is malformed. func MergeBaggage(existingHeader, sentryHeader string) (string, error) { // TODO: we are reparsing the headers here, because we currently don't // expose a method to get only DSC or its baggage members. @@ -20,7 +22,13 @@ func MergeBaggage(existingHeader, sentryHeader string) (string, error) { finalBaggage, err := baggage.Parse(existingHeader) if err != nil { - return "", fmt.Errorf("cannot parse existingHeader: %w", err) + if sentryBaggage.Len() == 0 { + return "", fmt.Errorf("cannot parse existingHeader: %w", err) + } + // in case that the incoming header is malformed we should only + // care about merging sentry related baggage information for distributed tracing. + debuglog.Printf("malformed incoming header: %v", err) + return sentryBaggage.String(), nil } for _, member := range sentryBaggage.Members() { diff --git a/baggage_test.go b/baggage_test.go index a629c7890..96a068a1e 100644 --- a/baggage_test.go +++ b/baggage_test.go @@ -1,9 +1,6 @@ package sentry -import ( - "strings" - "testing" -) +import "testing" func TestMergeBaggage(t *testing.T) { t.Run("both empty", func(t *testing.T) { @@ -55,17 +52,13 @@ func TestMergeBaggage(t *testing.T) { assertBaggageStringsEqual(t, got, "othervendor=bla,sentry-trace_id=new,sentry-sampled=true") }) - t.Run("invalid existing returns empty and error", func(t *testing.T) { + t.Run("invalid existing returns sentry baggage", func(t *testing.T) { got, err := MergeBaggage("not-valid", "sentry-trace_id=123,sentry-sampled=true") - if err == nil { - t.Fatal("expected error") - } - if got != "" { - t.Fatalf("expected empty baggage, got %q", got) - } - if !strings.Contains(err.Error(), "cannot parse existingHeader") { + if err != nil { t.Fatalf("unexpected error: %v", err) } + + assertBaggageStringsEqual(t, got, "sentry-trace_id=123,sentry-sampled=true") }) t.Run("invalid sentry returns empty and error", func(t *testing.T) { @@ -76,9 +69,6 @@ func TestMergeBaggage(t *testing.T) { if got != "" { t.Fatalf("expected empty baggage, got %q", got) } - if !strings.Contains(err.Error(), "cannot parse sentryHeader") { - t.Fatalf("unexpected error: %v", err) - } }) t.Run("invalid existing with empty sentry still errors", func(t *testing.T) { diff --git a/grpc/client_test.go b/grpc/client_test.go index 3c21bc6d4..24c2fa36a 100644 --- a/grpc/client_test.go +++ b/grpc/client_test.go @@ -167,6 +167,28 @@ func TestUnaryClientInterceptor_PreservesExistingBaggageMembers(t *testing.T) { assert.Equal(t, int(codes.OK), spanStatusCode(t, transport)) } +func TestUnaryClientInterceptor_PropagatesSentryBaggageWhenExistingBaggageIsMalformed(t *testing.T) { + transport := initMockTransport(t) + interceptor := sentrygrpc.UnaryClientInterceptor() + + ctx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs( + sentry.SentryBaggageHeader, "not-valid", + )) + + err := interceptor(ctx, "/test.TestService/Method", struct{}{}, struct{}{}, nil, func(ctx context.Context, _ string, _, _ any, _ *grpc.ClientConn, _ ...grpc.CallOption) error { + md, ok := metadata.FromOutgoingContext(ctx) + require.True(t, ok) + baggageHeader := strings.Join(md.Get(sentry.SentryBaggageHeader), ",") + assert.NotContains(t, baggageHeader, "not-valid") + assert.Contains(t, baggageHeader, "sentry-trace_id") + return nil + }) + + require.NoError(t, err) + sentry.Flush(testutils.FlushTimeout()) + assert.Equal(t, int(codes.OK), spanStatusCode(t, transport)) +} + func TestStreamClientInterceptor(t *testing.T) { tests := map[string]struct { ctx context.Context From 970151ee5c1b54a55beca60a556f88aa11e9e923 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Apr 2026 12:20:59 +0000 Subject: [PATCH 39/39] fix(grpc): Flush events when WaitForDelivery is true The WaitForDelivery option was only triggering a flush during panic recovery, but not after normal request handling. This meant events captured inside gRPC handlers (e.g., hub.CaptureException) would not be delivered before the response was sent, potentially leading to lost events if the process shut down shortly after. This change adds hub.Flush() calls after both unary and streaming handlers complete when WaitForDelivery is true, ensuring all events are delivered before the response is sent. Co-Authored-By: Claude Sonnet 4.5 --- grpc/server.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/grpc/server.go b/grpc/server.go index d1bbd8491..4a8d5583f 100644 --- a/grpc/server.go +++ b/grpc/server.go @@ -134,6 +134,10 @@ func UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor { resp, err = handler(ctx, req) setRPCStatus(transaction, err) + if opts.WaitForDelivery { + hub.Flush(opts.Timeout) + } + return resp, err } } @@ -155,6 +159,10 @@ func StreamServerInterceptor(opts ServerOptions) grpc.StreamServerInterceptor { err = handler(srv, stream) setRPCStatus(transaction, err) + if opts.WaitForDelivery { + hub.Flush(opts.Timeout) + } + return err } }