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