Skip to content

feat: Add grpc integration support#938

Open
ribice wants to merge 45 commits intomasterfrom
grpc-interceptor
Open

feat: Add grpc integration support#938
ribice wants to merge 45 commits intomasterfrom
grpc-interceptor

Conversation

@ribice
Copy link
Copy Markdown
Contributor

@ribice ribice commented Dec 27, 2024

Closes #240

Sample error logged to Sentry:

CleanShot 2024-12-27 at 09 52 53

Possible improvements / todos:

  • Create docs in sentry and sentry-docs
  • The options is a simple struct (as in other integrations). Could implement Functional Options Pattern for these.
  • More tests and examples?

@ribice ribice requested a review from cleptric December 27, 2024 08:55
@ribice ribice changed the title grpc interceptors Add grpc interceptors Dec 27, 2024
@codecov
Copy link
Copy Markdown

codecov bot commented Dec 27, 2024

Codecov Report

Attention: Patch coverage is 95.85799% with 7 lines in your changes missing coverage. Please review.

Project coverage is 84.32%. Comparing base (6b014ea) to head (6b3eefb).
Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
grpc/server.go 94.30% 5 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #938      +/-   ##
==========================================
+ Coverage   84.00%   84.32%   +0.31%     
==========================================
  Files          50       52       +2     
  Lines        5171     5340     +169     
==========================================
+ Hits         4344     4503     +159     
- Misses        673      680       +7     
- Partials      154      157       +3     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

grpc/server.go Outdated
Comment on lines +35 to +36
// CaptureRequestBody determines whether to capture and send request bodies to Sentry.
CaptureRequestBody bool
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be handled by the base SDK.

grpc/server.go Outdated
Comment on lines +38 to +39
// OperationName overrides the default operation name (grpc.server).
OperationName string
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not expose this to users.

grpc/server.go Outdated

options := []sentry.SpanOption{
sentry.ContinueTrace(hub, sentryTraceHeader, sentryBaggageHeader),
sentry.WithOpName(opts.OperationName),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's hard code this grpc.server.

examplepb.RegisterExampleServiceServer(server, &ExampleServiceServer{})

// Start the server
listener, err := net.Listen("tcp", grpcPort)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:
Detected a network listener listening on 0.0.0.0 or an empty string. This could unexpectedly expose the server publicly as it binds to all available interfaces. Instead, specify another IP address that is not 0.0.0.0 nor the empty string.

To resolve this comment:

✨ Commit Assistant Fix Suggestion
  1. Update the value of grpcPort so it is not just a port or set to 0.0.0.0.
    For example, if grpcPort is ":50051", change it to "127.0.0.1:50051" or another appropriate interface (like your private network IP address).
  2. If you need the server to be accessible only from the local machine, use "127.0.0.1:<port>" as the address when calling net.Listen.
  3. If you do need remote access, restrict the IP address as much as possible to only the needed network interface, rather than using 0.0.0.0 or a blank string.
  4. Example: listener, err := net.Listen("tcp", "127.0.0.1:50051")

When a server binds to 0.0.0.0 or just the port (like ":50051"), it listens on all interfaces, which could make your service accessible from unwanted sources. Use a specific IP to limit access.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by avoid-bind-to-all-interfaces.

You can view more details about this finding in the Semgrep AppSec Platform.

@stephanie-anderson stephanie-anderson added Feature Issue type and removed Type: Feature labels Apr 25, 2025
grpc/client.go Outdated
Comment on lines +20 to +21
// OperationName overrides the default operation name (grpc.client).
OperationName string
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove this here as well.

grpc/server.go Outdated
Comment on lines +32 to +33
// ReportOn defines the conditions under which errors are reported to Sentry.
ReportOn func(error) bool
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove this

grpc/server.go Outdated
Comment on lines +62 to +99
func reportErrorToSentry(hub *sentry.Hub, err error, methodName string, req any, md map[string]string) {
hub.WithScope(func(scope *sentry.Scope) {
scope.SetExtras(map[string]any{
"grpc.method": methodName,
"grpc.error": err.Error(),
})

if req != nil {
scope.SetExtra("request", req)
}

if len(md) > 0 {
scope.SetExtra("metadata", md)
}

defer hub.CaptureException(err)

statusErr, ok := status.FromError(err)
if !ok {
return
}

for _, detail := range statusErr.Details() {
debugInfo, ok := detail.(*errdetails.DebugInfo)
if !ok {
continue
}
hub.AddBreadcrumb(&sentry.Breadcrumb{
Type: "debug",
Category: "grpc.server",
Message: debugInfo.Detail,
Data: map[string]any{"stackTrace": strings.Join(debugInfo.StackEntries, "\n")},
Level: sentry.LevelError,
Timestamp: time.Now(),
}, nil)
}
})
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reported errors here add zero value. Let's remove this.

@giortzisg giortzisg marked this pull request as ready for review March 31, 2026 13:22
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Default timeout inconsistent with all other integrations
    • Changed gRPC server interceptor default timeout from 5s to 2s to match all other HTTP integrations in the project.

You can send follow-ups to this agent here.

cursoragent and others added 3 commits March 31, 2026 13:36
Changed gRPC server interceptor default timeout from 5s to 2s to match
the default timeout used by all other HTTP integrations (echo, gin, fiber,
fasthttp, http, iris, negroni).

Co-Authored-By: Claude Sonnet 4.5 <noreply@example.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: WaitForDelivery option ineffective for non-panic requests
    • Added hub.Flush() calls after both unary and streaming handlers complete when WaitForDelivery is true, ensuring events captured during normal request handling are delivered before the response is sent.
Preview (970151ee5c)
diff --git a/.craft.yml b/.craft.yml
--- a/.craft.yml
+++ b/.craft.yml
@@ -25,6 +25,9 @@
     tagPrefix: gin/v
     tagOnly: true
   - name: github
+    tagPrefix: grpc/v
+    tagOnly: true
+  - name: github
     tagPrefix: iris/v
     tagOnly: true
   - name: github

diff --git a/_examples/grpc/client/main.go b/_examples/grpc/client/main.go
new file mode 100644
--- /dev/null
+++ b/_examples/grpc/client/main.go
@@ -1,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
--- /dev/null
+++ b/_examples/grpc/server/example.proto
@@ -1,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
--- /dev/null
+++ b/_examples/grpc/server/examplepb/example.pb.go
@@ -1,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
--- /dev/null
+++ b/_examples/grpc/server/examplepb/example_grpc.pb.go
@@ -1,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
--- /dev/null
+++ b/_examples/grpc/server/main.go
@@ -1,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
--- /dev/null
+++ b/baggage.go
@@ -1,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
--- /dev/null
+++ b/baggage_test.go
@@ -1,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
--- a/echo/sentryecho.go
+++ b/echo/sentryecho.go
@@ -46,7 +46,7 @@
 // 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
--- a/fasthttp/sentryfasthttp.go
+++ b/fasthttp/sentryfasthttp.go
@@ -47,7 +47,7 @@
 // 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
--- a/fiber/sentryfiber.go
+++ b/fiber/sentryfiber.go
@@ -48,7 +48,7 @@
 // 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
 	}
... diff truncated: showing 800 of 2301 lines

You can send follow-ups to this agent here.

cursoragent and others added 2 commits April 1, 2026 12:20
The WaitForDelivery option was only triggering a flush during panic
recovery, but not after normal request handling. This meant events
captured inside gRPC handlers (e.g., hub.CaptureException) would not
be delivered before the response was sent, potentially leading to
lost events if the process shut down shortly after.

This change adds hub.Flush() calls after both unary and streaming
handlers complete when WaitForDelivery is true, ensuring all events
are delivered before the response is sent.

Co-Authored-By: Claude Sonnet 4.5 <noreply@example.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature Issue type

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gRPC interceptors

6 participants