diff --git a/.craft.yml b/.craft.yml
index 71d1631a2..64e218ad2 100644
--- a/.craft.yml
+++ b/.craft.yml
@@ -24,6 +24,9 @@ targets:
- name: github
tagPrefix: gin/v
tagOnly: true
+ - name: github
+ tagPrefix: grpc/v
+ tagOnly: true
- name: github
tagPrefix: iris/v
tagOnly: true
diff --git a/_examples/grpc/client/main.go b/_examples/grpc/client/main.go
new file mode 100644
index 000000000..36bb4c5de
--- /dev/null
+++ b/_examples/grpc/client/main.go
@@ -0,0 +1,119 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "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()),
+ grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor()),
+ )
+ 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 {
+ 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/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..09f52fb18
--- /dev/null
+++ b/_examples/grpc/server/main.go
@@ -0,0 +1,94 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net"
+ "time"
+
+ "github.com/getsentry/sentry-go"
+ "github.com/getsentry/sentry-go/_examples/grpc/server/examplepb"
+ 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,
+ })),
+ 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/baggage.go b/baggage.go
new file mode 100644
index 000000000..fe3d19957
--- /dev/null
+++ b/baggage.go
@@ -0,0 +1,42 @@
+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 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.
+ sentryBaggage, err := baggage.Parse(sentryHeader)
+ if err != nil {
+ return "", fmt.Errorf("cannot parse sentryHeader: %w", err)
+ }
+
+ finalBaggage, err := baggage.Parse(existingHeader)
+ if err != nil {
+ 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() {
+ 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..96a068a1e
--- /dev/null
+++ b/baggage_test.go
@@ -0,0 +1,83 @@
+package sentry
+
+import "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 sentry baggage", func(t *testing.T) {
+ got, err := MergeBaggage("not-valid", "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("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)
+ }
+ })
+
+ 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)
+ }
+ })
+}
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/README.MD b/grpc/README.MD
new file mode 100644
index 000000000..7f6d44fd0
--- /dev/null
+++ b/grpc/README.MD
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+# 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(
+ grpc.UnaryInterceptor(sentrygrpc.UnaryServerInterceptor(sentrygrpc.ServerOptions{
+ Repanic: true,
+ })),
+ grpc.StreamInterceptor(sentrygrpc.StreamServerInterceptor(sentrygrpc.ServerOptions{
+ Repanic: 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"
+ "google.golang.org/grpc/credentials/insecure"
+
+ "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.NewClient(
+ "localhost:50051",
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ grpc.WithUnaryInterceptor(sentrygrpc.UnaryClientInterceptor()),
+ grpc.WithStreamInterceptor(sentrygrpc.StreamClientInterceptor()),
+ )
+ 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
+}
+```
+
+## 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..ee0efd80d
--- /dev/null
+++ b/grpc/client.go
@@ -0,0 +1,174 @@
+// 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 (
+ "context"
+ "errors"
+ "io"
+ "strings"
+ "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 = "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, _ := 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)
+ }
+
+ 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")
+
+ ctx = createOrUpdateMetadata(span.Context(), span)
+ return ctx, span
+}
+
+func UnaryClientInterceptor() grpc.UnaryClientInterceptor {
+ return func(ctx context.Context,
+ method string,
+ req, reply any,
+ cc *grpc.ClientConn,
+ invoker grpc.UnaryInvoker,
+ callOpts ...grpc.CallOption) (err error) {
+ ctx, span := startClientSpan(ctx, method)
+ defer func() {
+ finishSpan(span, err)
+ }()
+
+ err = invoker(ctx, method, req, reply, cc, callOpts...)
+ return err
+ }
+}
+
+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) {
+ ctx, span := startClientSpan(ctx, method)
+
+ stream, err := streamer(ctx, desc, cc, method, callOpts...)
+ if err != nil {
+ finishSpan(span, err)
+ return nil, err
+ }
+ if stream == nil {
+ nilErr := status.Error(codes.Internal, "streamer returned nil stream without error")
+ finishSpan(span, nilErr)
+ return nil, nilErr
+ }
+
+ 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
+ stopMonitor func() bool
+ 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 errors.Is(err, io.EOF) {
+ s.finish(nil)
+ } else {
+ s.finish(err)
+ }
+ }
+ return err
+}
+
+func (s *sentryClientStream) finish(err error) {
+ s.finishOnce.Do(func() {
+ if s.stopMonitor != nil {
+ s.stopMonitor()
+ }
+ finishSpan(s.span, err)
+ })
+}
diff --git a/grpc/client_test.go b/grpc/client_test.go
new file mode 100644
index 000000000..24c2fa36a
--- /dev/null
+++ b/grpc/client_test.go
@@ -0,0 +1,325 @@
+package sentrygrpc_test
+
+import (
+ "context"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/getsentry/sentry-go"
+ 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"
+ "google.golang.org/grpc/status"
+)
+
+// mockClientStream implements grpc.ClientStream for testing.
+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
+}
+
+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 {
+ 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)
+ }
+ return nil
+}
+func (m *mockClientStream) RecvMsg(msg any) error {
+ if m.recvMsgFn != nil {
+ return m.recvMsgFn(msg)
+ }
+ 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
+ wantCode codes.Code
+ }{
+ "records span and propagates trace headers": {
+ ctx: metadata.NewOutgoingContext(context.Background(), metadata.Pairs("existing", "value")),
+ invoker: func(ctx context.Context, _ string, _, _ any, _ *grpc.ClientConn, _ ...grpc.CallOption) error {
+ md, ok := metadata.FromOutgoingContext(ctx)
+ require.True(t, ok)
+ assert.Contains(t, md, sentry.SentryTraceHeader)
+ assert.Contains(t, md, sentry.SentryBaggageHeader)
+ assert.Contains(t, md, "existing")
+ return nil
+ },
+ 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, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ transport := initMockTransport(t)
+ interceptor := sentrygrpc.UnaryClientInterceptor()
+
+ interceptor(tc.ctx, "/test.TestService/Method", struct{}{}, struct{}{}, nil, tc.invoker)
+ sentry.Flush(testutils.FlushTimeout())
+
+ assert.Equal(t, int(tc.wantCode), spanStatusCode(t, transport))
+ })
+ }
+}
+
+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 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 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
+ 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)
+ 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": {
+ 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
+ },
+ streamOp: func(stream grpc.ClientStream) { stream.RecvMsg(nil) },
+ 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
+ },
+ streamOp: func(stream grpc.ClientStream) { stream.RecvMsg(nil) },
+ 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
+ },
+ streamOp: func(stream grpc.ClientStream) { stream.CloseSend() },
+ 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
+ },
+ streamOp: func(stream grpc.ClientStream) { stream.SendMsg(nil) },
+ 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
+ },
+ streamOp: func(stream grpc.ClientStream) { stream.Header() },
+ 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{
+ recvMsgFn: func(_ any) error { return rpcErr },
+ closeSendFn: func() error { return rpcErr },
+ }, nil
+ },
+ streamOp: func(stream grpc.ClientStream) {
+ stream.RecvMsg(nil)
+ stream.CloseSend()
+ },
+ wantCode: codes.Canceled,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ transport := initMockTransport(t)
+ interceptor := sentrygrpc.StreamClientInterceptor()
+
+ stream, _ := interceptor(tc.ctx, &grpc.StreamDesc{}, nil, "/test.TestService/Method", tc.streamer)
+ if tc.streamOp != nil && stream != nil {
+ tc.streamOp(stream)
+ }
+
+ sentry.Flush(testutils.FlushTimeout())
+ assert.Equal(t, int(tc.wantCode), spanStatusCode(t, transport))
+ })
+ }
+}
+
+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)
+}
diff --git a/grpc/go.mod b/grpc/go.mod
new file mode 100644
index 000000000..e89ceed28
--- /dev/null
+++ b/grpc/go.mod
@@ -0,0 +1,24 @@
+module github.com/getsentry/sentry-go/grpc
+
+go 1.24.0
+
+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/kr/text v0.2.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // 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
new file mode 100644
index 000000000..b9937d237
--- /dev/null
+++ b/grpc/go.sum
@@ -0,0 +1,64 @@
+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/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=
+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/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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
+golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+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.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
+google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+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=
+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..4a8d5583f
--- /dev/null
+++ b/grpc/server.go
@@ -0,0 +1,277 @@
+// 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 (
+ "context"
+ "strings"
+ "time"
+
+ "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 (
+ sdkIdentifier = "sentry.go.grpc"
+ defaultServerOperationName = "rpc.server"
+ internalServerErrorMessage = "internal server error"
+)
+
+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
+}
+
+func (o *ServerOptions) setDefaults() {
+ if o.Timeout == 0 {
+ o.Timeout = sentry.DefaultFlushTimeout
+ }
+}
+
+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)
+ }
+
+ if o.Repanic {
+ panic(r)
+ }
+ }
+}
+
+func hubFromServerContext(ctx context.Context) *sentry.Hub {
+ hub := sentry.GetHubFromContext(ctx)
+ if hub == nil {
+ hub = sentry.CurrentHub().Clone()
+ }
+
+ if client := hub.Client(); client != nil {
+ client.SetSDKIdentifier(sdkIdentifier)
+ }
+
+ return hub
+}
+
+func traceHeadersFromContext(ctx context.Context) (metadata.MD, string, string) {
+ md, _ := metadata.FromIncomingContext(ctx)
+ return md, getFirstHeader(md, sentry.SentryTraceHeader), getFirstHeader(md, sentry.SentryBaggageHeader)
+}
+
+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")
+
+ return transaction.Context(), hub, transaction
+}
+
+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)
+ setRPCStatus(transaction, err)
+ })
+
+ resp, err = handler(ctx, req)
+ setRPCStatus(transaction, err)
+
+ if opts.WaitForDelivery {
+ hub.Flush(opts.Timeout)
+ }
+
+ return resp, err
+ }
+}
+
+// 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) (err error) {
+ 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)
+ setRPCStatus(transaction, err)
+ })
+
+ err = handler(srv, stream)
+ setRPCStatus(transaction, err)
+
+ if opts.WaitForDelivery {
+ hub.Flush(opts.Timeout)
+ }
+
+ return err
+ }
+}
+
+func getFirstHeader(md metadata.MD, key string) string {
+ if values := md.Get(key); len(values) > 0 {
+ return values[0]
+ }
+ 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 sentry.IsSensitiveHeader(key) {
+ continue
+ }
+
+ if len(values) == 0 {
+ continue
+ }
+
+ if len(values) == 1 {
+ ctx[key] = values[0]
+ continue
+ }
+
+ copied := make([]string, len(values))
+ copy(copied, values)
+ ctx[key] = copied
+ }
+
+ if len(ctx) == 0 {
+ return nil
+ }
+
+ return ctx
+}
+
+// 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, "", ""
+ }
+ name = fullMethod[1:]
+ pos := strings.Index(name, "/")
+ if pos < 0 {
+ return name, "", ""
+ }
+ return name, name[:pos], name[pos+1:]
+}
+
+// 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 spanStatus, ok := codeToSpanStatus[code]; ok {
+ return spanStatus
+ }
+ return sentry.SpanStatusUndefined
+}
diff --git a/grpc/server_test.go b/grpc/server_test.go
new file mode 100644
index 000000000..5cfd059dc
--- /dev/null
+++ b/grpc/server_test.go
@@ -0,0 +1,261 @@
+package sentrygrpc_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/getsentry/sentry-go"
+ 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 TestUnaryServerInterceptor(t *testing.T) {
+ transport := initMockTransport(t)
+ 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())
+
+ events := transport.Events()
+ require.Len(t, events, 1)
+ 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(events[0])); diff != "" {
+ t.Errorf("transaction mismatch (-want +got):\n%s", diff)
+ }
+}
+
+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
+ wantRepanic bool
+ }{
+ "panic is recovered and returns Internal error": {
+ options: sentrygrpc.ServerOptions{},
+ },
+ "panic is re-panicked when Repanic is set": {
+ options: sentrygrpc.ServerOptions{Repanic: true},
+ wantRepanic: true,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ eventsCh := make(chan *sentry.Event, 1)
+ 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,
+ }))
+
+ interceptor := sentrygrpc.UnaryServerInterceptor(tc.options)
+ ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value"))
+
+ 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")
+ })
+ }()
+
+ sentry.Flush(testutils.FlushTimeout())
+ require.NotNil(t, <-eventsCh)
+
+ if tc.wantRepanic {
+ assert.Equal(t, "test panic", recovered)
+ } else {
+ assert.Nil(t, recovered)
+ assert.Equal(t, codes.Internal, status.Code(err))
+ }
+ })
+ }
+}
+
+func TestStreamServerInterceptor(t *testing.T) {
+ transport := initMockTransport(t)
+ 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())
+
+ events := transport.Events()
+ require.Len(t, events, 1)
+ 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(events[0])); diff != "" {
+ t.Errorf("transaction mismatch (-want +got):\n%s", diff)
+ }
+}
+
+func TestStreamServerInterceptor_Panic(t *testing.T) {
+ tests := map[string]struct {
+ options sentrygrpc.ServerOptions
+ wantRepanic bool
+ }{
+ "panic is recovered and returns Internal error": {
+ options: sentrygrpc.ServerOptions{},
+ },
+ "panic is re-panicked when Repanic is set": {
+ options: sentrygrpc.ServerOptions{Repanic: true},
+ wantRepanic: true,
+ },
+ }
+
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ eventsCh := make(chan *sentry.Event, 1)
+ 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,
+ }))
+
+ interceptor := sentrygrpc.StreamServerInterceptor(tc.options)
+ ss := &stubServerStream{
+ ctx: metadata.NewIncomingContext(context.Background(), metadata.Pairs("key", "value")),
+ }
+
+ var (
+ err error
+ recovered any
+ )
+ func() {
+ defer func() { recovered = recover() }()
+ err = interceptor(nil, ss, &grpc.StreamServerInfo{
+ FullMethod: "/test.TestService/StreamMethod",
+ }, func(_ any, _ grpc.ServerStream) error {
+ panic("test panic")
+ })
+ }()
+
+ sentry.Flush(testutils.FlushTimeout())
+ require.NotNil(t, <-eventsCh)
+
+ if tc.wantRepanic {
+ assert.Equal(t, "test panic", recovered)
+ } else {
+ assert.Nil(t, recovered)
+ assert.Equal(t, codes.Internal, status.Code(err))
+ }
+ })
+ }
+}
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/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/logrus/README.md b/logrus/README.md
index 5e2b82fc2..939f6a65d 100644
--- a/logrus/README.md
+++ b/logrus/README.md
@@ -149,4 +149,3 @@ This ensures that logs from specific contexts or threads use the appropriate Sen
## Notes
- Always call `Flush` or `FlushWithContext` to ensure all events are sent to Sentry before program termination
-
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 994eae800..86b6d069a 100644
--- a/sentry.go
+++ b/sentry.go
@@ -12,6 +12,9 @@ const SDKVersion = "0.44.1"
// 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 7704364a7..b63cf0ad0 100644
--- a/tracing.go
+++ b/tracing.go
@@ -36,6 +36,7 @@ const (
SpanOriginStdLib = "auto.http.stdlib"
SpanOriginIris = "auto.http.iris"
SpanOriginNegroni = "auto.http.negroni"
+ SpanOriginGrpc = "auto.rpc.grpc"
)
// A Span is the building block of a Sentry transaction. Spans build up a tree