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..fb598cba4
--- /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()),
+ 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 {
+ 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/README.MD b/grpc/README.MD
new file mode 100644
index 000000000..80d13a82c
--- /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()),
+ 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
+
+ // 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..610506b32
--- /dev/null
+++ b/grpc/client.go
@@ -0,0 +1,67 @@
+// 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"
+
+ "github.com/getsentry/sentry-go"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/metadata"
+)
+
+const defaultClientOperationName = "grpc.client"
+
+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())
+ return metadata.NewOutgoingContext(ctx, md)
+ }
+
+ md = metadata.Pairs(
+ sentry.SentryTraceHeader, span.ToSentryTrace(),
+ sentry.SentryBaggageHeader, span.ToBaggage(),
+ )
+
+ return metadata.NewOutgoingContext(ctx, md)
+}
+
+func UnaryClientInterceptor() grpc.UnaryClientInterceptor {
+ return func(ctx context.Context,
+ method string,
+ req, reply interface{},
+ 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 = span.Context()
+
+ ctx = createOrUpdateMetadata(ctx, span)
+ defer span.Finish()
+
+ return invoker(ctx, method, req, reply, cc, callOpts...)
+ }
+}
+
+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) {
+ span := sentry.StartSpan(ctx, defaultClientOperationName, sentry.WithDescription(method))
+ span.SetData("grpc.request.method", method)
+ ctx = span.Context()
+
+ ctx = createOrUpdateMetadata(ctx, span)
+ defer span.Finish()
+
+ return streamer(ctx, desc, cc, method, callOpts...)
+ }
+}
diff --git a/grpc/client_test.go b/grpc/client_test.go
new file mode 100644
index 000000000..048e5269f
--- /dev/null
+++ b/grpc/client_test.go
@@ -0,0 +1,115 @@
+package sentrygrpc_test
+
+import (
+ "context"
+ "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"
+)
+
+func TestUnaryClientInterceptor(t *testing.T) {
+ tests := map[string]struct {
+ 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
+ },
+ assertions: func(t *testing.T, transport *sentry.MockTransport) {
+ assert.Empty(t, transport.Events(), "No events should be captured")
+ },
+ },
+ "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
+ },
+ assertions: func(t *testing.T, transport *sentry.MockTransport) {},
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ transport := &sentry.MockTransport{}
+ sentry.Init(sentry.ClientOptions{
+ Transport: transport,
+ })
+
+ interceptor := sentrygrpc.UnaryClientInterceptor()
+
+ // Execute the interceptor
+ interceptor(test.ctx, "/test.Service/TestMethod", struct{}{}, struct{}{}, nil, test.invoker)
+
+ 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
+ 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
+ },
+ streamDesc: &grpc.StreamDesc{
+ ClientStreams: true,
+ },
+ assertions: func(t *testing.T, transport *sentry.MockTransport) {
+ assert.Empty(t, transport.Events(), "No events should be captured")
+ },
+ },
+ "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
+ },
+ streamDesc: &grpc.StreamDesc{
+ ClientStreams: true,
+ },
+ assertions: func(t *testing.T, transport *sentry.MockTransport) {},
+ },
+ }
+
+ for name, test := 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,
+ })
+
+ interceptor := sentrygrpc.StreamClientInterceptor()
+
+ // Execute the interceptor
+ 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")
+ // Pass the transport to the assertions to verify captured events.
+ test.assertions(t, transport)
+ })
+ }
+}
diff --git a/grpc/go.mod b/grpc/go.mod
new file mode 100644
index 000000000..65ac87a9f
--- /dev/null
+++ b/grpc/go.mod
@@ -0,0 +1,23 @@
+module github.com/getsentry/sentry-go/grpc
+
+go 1.21
+
+replace github.com/getsentry/sentry-go => ../
+
+require (
+ github.com/getsentry/sentry-go v0.32.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
+)
+
+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/protobuf v1.36.1 // 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..ebaece8c9
--- /dev/null
+++ b/grpc/go.sum
@@ -0,0 +1,40 @@
+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/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.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/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.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.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..c023a6fd6
--- /dev/null
+++ b/grpc/server.go
@@ -0,0 +1,211 @@
+package sentrygrpc
+
+import (
+ "context"
+ "fmt"
+ "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 = "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
+}
+
+func (o *ServerOptions) SetDefaults() {
+ if o.Timeout == 0 {
+ o.Timeout = sentry.DefaultFlushTimeout
+ }
+}
+
+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 UnaryServerInterceptor(opts ServerOptions) grpc.UnaryServerInterceptor {
+ opts.SetDefaults()
+
+ 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()
+ }
+
+ 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(defaultServerOperationName),
+ sentry.WithDescription(info.FullMethod),
+ sentry.WithTransactionSource(sentry.SourceURL),
+ sentry.WithSpanOrigin(sentry.SpanOriginGrpc),
+ }
+
+ 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()
+
+ defer recoverWithSentry(ctx, hub, opts)
+
+ resp, err := handler(ctx, req)
+ 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 any, 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.WithDescription(info.FullMethod),
+ sentry.WithTransactionSource(sentry.SourceURL),
+ sentry.WithSpanOrigin(sentry.SpanOriginGrpc),
+ }
+
+ 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)
+ 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 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..01c1d4ebe
--- /dev/null
+++ b/grpc/server_test.go
@@ -0,0 +1,247 @@
+package sentrygrpc_test
+
+import (
+ "context"
+ "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"
+)
+
+func TestServerOptions_SetDefaults(t *testing.T) {
+ 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.Equal(t, sentry.DefaultFlushTimeout, options.Timeout, "Timeout should be set to default value")
+ },
+ },
+ "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")
+ },
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ test.options.SetDefaults()
+
+ test.assertions(t, test.options)
+ })
+ }
+}
+
+func TestUnaryServerInterceptor(t *testing.T) {
+ tests := map[string]struct {
+ options sentrygrpc.ServerOptions
+ handler grpc.UnaryHandler
+ ctx context.Context
+ wantException string
+ wantTransaction *sentry.Event
+ expectedMetadata string
+ assertTransaction bool
+ }{
+ "Handle panic and re-panic": {
+ 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")
+ },
+ },
+ }
+
+ 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(test.ctx, nil, &grpc.UnaryServerInfo{
+ FullMethod: "TestService.Method",
+ }, test.handler)
+
+ 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.expectedMetadata != "" {
+ assert.Equal(t, gotEvent.Extra["md"], test.expectedMetadata)
+ }
+ }
+
+ 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) {
+ tests := map[string]struct {
+ options sentrygrpc.ServerOptions
+ handler grpc.StreamHandler
+ expectedMetadata bool
+ expectedEvent 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,
+ },
+ "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,
+ },
+ }
+
+ 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 {
+ transactionsCh <- tx
+ return tx
+ },
+ })
+ 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")),
+ }
+
+ var recovered interface{}
+ func() {
+ defer func() {
+ recovered = recover()
+ }()
+ err = interceptor(nil, stream, &grpc.StreamServerInfo{FullMethod: "TestService.StreamMethod"}, test.handler)
+ }()
+
+ 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")
+ }
+
+ sentry.Flush(2 * time.Second)
+ })
+ }
+}
diff --git a/logrus/README.md b/logrus/README.md
index cbb16a573..0f204fd1e 100644
--- a/logrus/README.md
+++ b/logrus/README.md
@@ -103,4 +103,3 @@ 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
-
diff --git a/sentry.go b/sentry.go
index d59cdab88..58e2d5ccc 100644
--- a/sentry.go
+++ b/sentry.go
@@ -12,6 +12,9 @@ const SDKVersion = "0.32.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 836ba9024..76999a195 100644
--- a/tracing.go
+++ b/tracing.go
@@ -31,6 +31,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