Skip to content

feat: Add grpc integration support#938

Merged
giortzisg merged 56 commits intomasterfrom
grpc-interceptor
Apr 20, 2026
Merged

feat: Add grpc integration support#938
giortzisg merged 56 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.

Comment thread 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.

Comment thread 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.

Comment thread 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
Comment thread 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.

Comment thread 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

Comment thread 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.

Copy link
Copy Markdown
Member

@sl0thentr0py sl0thentr0py left a comment

Choose a reason for hiding this comment

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

one comment about baggage

Comment thread baggage.go
// 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) {
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.

i would put sentry stuff first since baggage has a size limit so while we respect third party, ours should take precedence

Comment thread grpc/server.go
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: Data race on stopMonitor field in stream wrapper
    • Initialized stopMonitor to a no-op function before context.AfterFunc to prevent concurrent read/write access when the context is already done.
Preview (16fd93e883)
diff --git a/.craft.yml b/.craft.yml
--- a/.craft.yml
+++ b/.craft.yml
@@ -28,6 +28,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
@@ -47,7 +47,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 2262 lines

You can send follow-ups to the cloud agent here.

Comment thread grpc/client.go
cursoragent and others added 2 commits April 17, 2026 08:58
Initialize stopMonitor to a no-op function before calling context.AfterFunc
to prevent a data race where the AfterFunc callback reads stopMonitor
concurrently with the assignment if the context is already done.

Co-Authored-By: Claude Sonnet 4.5 <claude-sonnet-4.5@cursor.sh>
Comment thread grpc/client.go
Comment thread baggage.go
Comment thread grpc/server.go Outdated
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: Non-deterministic baggage member ordering breaks truncation guarantee
    • Modified Baggage.String() to sort keys deterministically with sentry- prefixed members first, ensuring W3C Baggage truncation guarantees are met.
Preview (4f11aac865)
diff --git a/.craft.yml b/.craft.yml
--- a/.craft.yml
+++ b/.craft.yml
@@ -28,6 +28,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,50 @@
+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)
+	}
+
+	existingBaggage, 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
+	}
+
+	// Start with sentry baggage as the base to ensure sentry members
+	// appear first in the output. This is critical for W3C Baggage spec
+	// compliance: if size limits are exceeded, truncation happens from
+	// the end, so sentry's distributed tracing members must take precedence.
+	finalBaggage := sentryBaggage
+	for _, member := range existingBaggage.Members() {
+		// Only add third-party members if they don't conflict with sentry keys
+		if sentryBaggage.Member(member.Key()).Key() == "" {
+			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,154 @@
+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)
+		}
+	})
+
+	t.Run("sentry members appear before third party members", func(t *testing.T) {
+		got, err := MergeBaggage("thirdparty=value", "sentry-trace_id=123")
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+
+		// Parse and verify member order - sentry members should come first
+		parsed, err := parseBaggageString(got)
+		if err != nil {
+			t.Fatalf("failed to parse result: %v", err)
+		}
+
+		// The first member should be a sentry- member
+		if len(parsed) < 2 {
+			t.Fatalf("expected at least 2 members, got %d", len(parsed))
+		}
+		if parsed[0].key != "sentry-trace_id" {
+			t.Errorf("expected first member to be sentry-trace_id, got %s", parsed[0].key)
+		}
+	})
+}
+
+type baggageMember struct {
+	key   string
+	value string
+}
+
+func parseBaggageString(s string) ([]baggageMember, error) {
+	if s == "" {
... diff truncated: showing 800 of 2541 lines

You can send follow-ups to the cloud agent here.

Comment thread baggage.go
Comment thread grpc/client.go
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: Duplicated span status logic across client and server
    • Refactored finishSpan to call setRPCStatus internally, eliminating the duplication of span status-setting logic.
Preview (9fd99bf004)
diff --git a/.craft.yml b/.craft.yml
--- a/.craft.yml
+++ b/.craft.yml
@@ -28,6 +28,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,52 @@
+package sentry
+
+import (
+	"fmt"
+	"strings"
+
+	"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)
+	}
+
+	existingBaggage, 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
+	}
+
+	sentryKeys := make(map[string]struct{}, sentryBaggage.Len())
+	for _, member := range sentryBaggage.Members() {
+		sentryKeys[member.Key()] = struct{}{}
+	}
+
+	parts := make([]string, 0, sentryBaggage.Len()+existingBaggage.Len())
+	if s := sentryBaggage.String(); s != "" {
+		parts = append(parts, s)
+	}
+	for _, member := range existingBaggage.Members() {
+		if _, collides := sentryKeys[member.Key()]; collides {
+			continue
+		}
+		parts = append(parts, member.String())
+	}
+
+	return strings.Join(parts, ","), 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
@@ -47,7 +47,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 truncated: showing 800 of 2273 lines

You can send follow-ups to the cloud agent here.

Comment thread grpc/client.go
cursoragent and others added 2 commits April 20, 2026 08:57
Refactored finishSpan to call setRPCStatus internally instead of
duplicating the same three lines (grpcStatusCode, toSpanStatus,
SetData). This avoids maintaining identical status-setting logic
in two places and ensures consistency if the logic needs to change.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.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 flushes every response unlike other integrations
    • Moved hub.Flush() from unconditional defer to inside recoverWithSentry, gated by eventID != nil check, aligning with other integrations to only flush on panic recovery.
Preview (43747179dc)
diff --git a/.craft.yml b/.craft.yml
--- a/.craft.yml
+++ b/.craft.yml
@@ -28,6 +28,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,52 @@
+package sentry
+
+import (
+	"fmt"
+	"strings"
+
+	"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)
+	}
+
+	existingBaggage, 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
+	}
+
+	sentryKeys := make(map[string]struct{}, sentryBaggage.Len())
+	for _, member := range sentryBaggage.Members() {
+		sentryKeys[member.Key()] = struct{}{}
+	}
+
+	parts := make([]string, 0, sentryBaggage.Len()+existingBaggage.Len())
+	if s := sentryBaggage.String(); s != "" {
+		parts = append(parts, s)
+	}
+	for _, member := range existingBaggage.Members() {
+		if _, collides := sentryKeys[member.Key()]; collides {
+			continue
+		}
+		parts = append(parts, member.String())
+	}
+
+	return strings.Join(parts, ","), 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
@@ -47,7 +47,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 truncated: showing 800 of 2391 lines

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 404bfe1. Configure here.

Comment thread grpc/server.go Outdated
@giortzisg giortzisg merged commit 1fee895 into master Apr 20, 2026
16 checks passed
@giortzisg giortzisg deleted the grpc-interceptor branch April 20, 2026 11:39
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

7 participants