Repository: getsentry/sentry-go

Issue:
Flush to receive go context type
<!--

Note that the Sentry team has finite resources and priorities that are not
always visible on GitHub. If your issue doesn't align with our priorities it's
unlikely it will be worked on. As time permits and depending on the request,
we'll follow up and discuss the proposed feature in more detail.

-->

## Summary
Currently `Flush` function receives a timeout, so when it is called someone needs to provide a timeout value. By using a context of the app and propagating to `Flush` the operation will timeout whenever the context expires, so no explicit timeout is needed.

## Motivation
This solution will remove some corner-case in the blocking operations of an application. If a long timeout is specified the application might wait for long. If the application already performs some finalisation and has an overall shutdown timeout, we would need to calculate the duration left to timeout, in order to provide to `Flush`.

## Additional Context
-


Hints from issue comments:
@greywolve: This is definitely more in the spirit of Go, but we also need to respect the [unified API](https://develop.sentry.dev/sdk/unified-api/) for consistency across the various language SDKs.

Perhaps we could add a more Go specific `Flush` in addition to the common API?

Solution patch:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index c1e879e..9ce5de3 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -23,7 +23,7 @@ jobs:
           go-version: "1.24"
       - uses: actions/checkout@v4
       - name: golangci-lint
-        uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # pin@v7.0
+        uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # pin@v8.0
         with:
           version: v2.1.1
           args: --timeout=10m
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c19aaef..00907ef 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,7 +15,7 @@ jobs:
     steps:
       - name: Get auth token
         id: token
-        uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0
+        uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
         with:
           app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
           private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6b566a9..94564ae 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -50,7 +50,7 @@ jobs:
       - name: Test
         run: make test-coverage
       - name: Upload coverage to Codecov
-        uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # pin@v5.4.2
+        uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # pin@v5.4.3
         with:
           directory: .coverage
           token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/_examples/flush-with-context/main.go b/_examples/flush-with-context/main.go
new file mode 100644
index 0000000..3f6d3c7
--- /dev/null
+++ b/_examples/flush-with-context/main.go
@@ -0,0 +1,36 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/getsentry/sentry-go"
+)
+
+func main() {
+	_ = sentry.Init(sentry.ClientOptions{
+		Dsn:   "https://hello@example.com/1337",
+		Debug: true,
+	})
+
+	sentry.CaptureMessage("Event #1")
+	sentry.CaptureMessage("Event #2")
+	sentry.CaptureMessage("Event #3")
+
+	go func() {
+		sentry.CaptureMessage("Event #4")
+		sentry.CaptureMessage("Event #5")
+	}()
+
+	fmt.Println("=> Flushing transport buffer")
+
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
+	defer cancel()
+
+	if sentry.FlushWithContext(ctx) {
+		fmt.Println("=> All queued events delivered!")
+	} else {
+		fmt.Println("=> Flush timeout reached")
+	}
+}
diff --git a/attribute/bulider.go b/attribute/builder.go
similarity index 100%
rename from attribute/bulider.go
rename to attribute/builder.go
diff --git a/client.go b/client.go
index 5b6fc1b..ea29096 100644
--- a/client.go
+++ b/client.go
@@ -516,6 +516,25 @@ func (client *Client) Flush(timeout time.Duration) bool {
 	return client.Transport.Flush(timeout)
 }
 
+// FlushWithContext waits until the underlying Transport sends any buffered events
+// to the Sentry server, blocking for at most the duration specified by the context.
+// It returns false if the context is canceled before the events are sent. In such a case,
+// some events may not be delivered.
+//
+// FlushWithContext should be called before terminating the program to ensure no
+// events are unintentionally dropped.
+//
+// Avoid calling FlushWithContext indiscriminately after each call to CaptureEvent,
+// CaptureException, or CaptureMessage. To send events synchronously over the network,
+// configure the SDK to use HTTPSyncTransport during initialization with Init.
+
+func (client *Client) FlushWithContext(ctx context.Context) bool {
+	if client.batchLogger != nil {
+		client.batchLogger.Flush()
+	}
+	return client.Transport.FlushWithContext(ctx)
+}
+
 // Close clean up underlying Transport resources.
 //
 // Close should be called after Flush and before terminating the program
diff --git a/hub.go b/hub.go
index 1b36c2f..8aea273 100644
--- a/hub.go
+++ b/hub.go
@@ -365,6 +365,28 @@ func (hub *Hub) Flush(timeout time.Duration) bool {
 	return client.Flush(timeout)
 }
 
+// FlushWithContext waits until the underlying Transport sends any buffered events
+// to the Sentry server, blocking for at most the duration specified by the context.
+// It returns false if the context is canceled before the events are sent. In such a case,
+// some events may not be delivered.
+//
+// FlushWithContext should be called before terminating the program to ensure no
+// events are unintentionally dropped.
+//
+// Avoid calling FlushWithContext indiscriminately after each call to CaptureEvent,
+// CaptureException, or CaptureMessage. To send events synchronously over the network,
+// configure the SDK to use HTTPSyncTransport during initialization with Init.
+
+func (hub *Hub) FlushWithContext(ctx context.Context) bool {
+	client := hub.Client()
+
+	if client == nil {
+		return false
+	}
+
+	return client.FlushWithContext(ctx)
+}
+
 // GetTraceparent returns the current Sentry traceparent string, to be used as a HTTP header value
 // or HTML meta tag value.
 // This function is context aware, as in it either returns the traceparent based
diff --git a/interfaces.go b/interfaces.go
index 28bcde4..2884bbb 100644
--- a/interfaces.go
+++ b/interfaces.go
@@ -214,11 +214,36 @@ type Request struct {
 }
 
 var sensitiveHeaders = map[string]struct{}{
+	"_csrf":               {},
+	"_csrf_token":         {},
+	"_session":            {},
+	"_xsrf":               {},
+	"Api-Key":             {},
+	"Apikey":              {},
+	"Auth":                {},
 	"Authorization":       {},
-	"Proxy-Authorization": {},
 	"Cookie":              {},
+	"Credentials":         {},
+	"Csrf":                {},
+	"Csrf-Token":          {},
+	"Csrftoken":           {},
+	"Ip-Address":          {},
+	"Passwd":              {},
+	"Password":            {},
+	"Private-Key":         {},
+	"Privatekey":          {},
+	"Proxy-Authorization": {},
+	"Remote-Addr":         {},
+	"Secret":              {},
+	"Session":             {},
+	"Sessionid":           {},
+	"Token":               {},
+	"User-Session":        {},
+	"X-Api-Key":           {},
+	"X-Csrftoken":         {},
 	"X-Forwarded-For":     {},
 	"X-Real-Ip":           {},
+	"XSRF-TOKEN":          {},
 }
 
 // NewRequest returns a new Sentry Request from the given http.Request.
@@ -613,7 +638,7 @@ type EventHint struct {
 type Log struct {
 	Timestamp  time.Time            `json:"timestamp,omitempty"`
 	TraceID    TraceID              `json:"trace_id,omitempty"`
-	Level      Level                `json:"level"`
+	Level      LogLevel             `json:"level"`
 	Severity   int                  `json:"severity_number,omitempty"`
 	Body       string               `json:"body,omitempty"`
 	Attributes map[string]Attribute `json:"attributes,omitempty"`
diff --git a/log.go b/log.go
index 5b49531..08be1b4 100644
--- a/log.go
+++ b/log.go
@@ -13,12 +13,12 @@ import (
 type LogLevel string
 
 const (
-	LogLevelTrace Level = "trace"
-	LogLevelDebug Level = "debug"
-	LogLevelInfo  Level = "info"
-	LogLevelWarn  Level = "warn"
-	LogLevelError Level = "error"
-	LogLevelFatal Level = "fatal"
+	LogLevelTrace LogLevel = "trace"
+	LogLevelDebug LogLevel = "debug"
+	LogLevelInfo  LogLevel = "info"
+	LogLevelWarn  LogLevel = "warn"
+	LogLevelError LogLevel = "error"
+	LogLevelFatal LogLevel = "fatal"
 )
 
 const (
@@ -63,11 +63,11 @@ func NewLogger(ctx context.Context) Logger {
 func (l *sentryLogger) Write(p []byte) (int, error) {
 	// Avoid sending double newlines to Sentry
 	msg := strings.TrimRight(string(p), "\n")
-	l.log(context.Background(), LevelInfo, LogSeverityInfo, msg)
+	l.log(context.Background(), LogLevelInfo, LogSeverityInfo, msg)
 	return len(p), nil
 }
 
-func (l *sentryLogger) log(ctx context.Context, level Level, severity int, message string, args ...interface{}) {
+func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, message string, args ...interface{}) {
 	if message == "" {
 		return
 	}
@@ -120,6 +120,21 @@ func (l *sentryLogger) log(ctx context.Context, level Level, severity int, messa
 	} else if serverAddr, err := os.Hostname(); err == nil {
 		attrs["sentry.server.address"] = Attribute{Value: serverAddr, Type: "string"}
 	}
+	scope := hub.Scope()
+	if scope != nil {
+		user := scope.user
+		if !user.IsEmpty() {
+			if user.ID != "" {
+				attrs["user.id"] = Attribute{Value: user.ID, Type: "string"}
+			}
+			if user.Name != "" {
+				attrs["user.name"] = Attribute{Value: user.Name, Type: "string"}
+			}
+			if user.Email != "" {
+				attrs["user.email"] = Attribute{Value: user.Email, Type: "string"}
+			}
+		}
+	}
 	if spanID.String() != "0000000000000000" {
 		attrs["sentry.trace.parent_span_id"] = Attribute{Value: spanID.String(), Type: "string"}
 	}
@@ -129,7 +144,6 @@ func (l *sentryLogger) log(ctx context.Context, level Level, severity int, messa
 	if sdkVersion := l.client.sdkVersion; sdkVersion != "" {
 		attrs["sentry.sdk.version"] = Attribute{Value: sdkVersion, Type: "string"}
 	}
-	attrs["sentry.origin"] = Attribute{Value: "auto.logger.log", Type: "string"}
 
 	log := &Log{
 		Timestamp:  time.Now(),
diff --git a/logrus/README.md b/logrus/README.md
index cbb16a5..1797a39 100644
--- a/logrus/README.md
+++ b/logrus/README.md
@@ -102,5 +102,5 @@ This ensures that logs from specific contexts or threads use the appropriate Sen
 
 ## Notes
 
-- Always call Flush to ensure all events are sent to Sentry before program termination
+- Always call `Flush` or `FlushWithContext` to ensure all events are sent to Sentry before program termination
 
diff --git a/logrus/logrusentry.go b/logrus/logrusentry.go
index 1ac9da4..1837110 100644
--- a/logrus/logrusentry.go
+++ b/logrus/logrusentry.go
@@ -2,6 +2,7 @@
 package sentrylogrus
 
 import (
+	"context"
 	"errors"
 	"net/http"
 	"time"
@@ -222,3 +223,11 @@ func (h *Hook) entryToEvent(l *logrus.Entry) *sentry.Event {
 func (h *Hook) Flush(timeout time.Duration) bool {
 	return h.hubProvider().Client().Flush(timeout)
 }
+
+// FlushWithContext waits for the underlying Sentry transport to send any buffered
+// events, blocking until the context's deadline is reached or the context is canceled.
+// It returns false if the context is canceled or its deadline expires before the events
+// are sent, meaning some events may not have been sent.
+func (h *Hook) FlushWithContext(ctx context.Context) bool {
+	return h.hubProvider().Client().FlushWithContext(ctx)
+}
diff --git a/mocks.go b/mocks.go
index e6345ab..92bd84d 100644
--- a/mocks.go
+++ b/mocks.go
@@ -1,6 +1,7 @@
 package sentry
 
 import (
+	"context"
 	"sync"
 	"time"
 )
@@ -39,6 +40,7 @@ func (t *MockTransport) SendEvent(event *Event) {
 func (t *MockTransport) Flush(_ time.Duration) bool {
 	return true
 }
+func (t *MockTransport) FlushWithContext(_ context.Context) bool { return true }
 func (t *MockTransport) Events() []*Event {
 	t.mu.Lock()
 	defer t.mu.Unlock()
diff --git a/sentry.go b/sentry.go
index 0ce4154..3041c54 100644
--- a/sentry.go
+++ b/sentry.go
@@ -125,6 +125,23 @@ func Flush(timeout time.Duration) bool {
 	return hub.Flush(timeout)
 }
 
+// FlushWithContext waits until the underlying Transport sends any buffered events
+// to the Sentry server, blocking for at most the duration specified by the context.
+// It returns false if the context is canceled before the events are sent. In such a case,
+// some events may not be delivered.
+//
+// FlushWithContext should be called before terminating the program to ensure no
+// events are unintentionally dropped.
+//
+// Avoid calling FlushWithContext indiscriminately after each call to CaptureEvent,
+// CaptureException, or CaptureMessage. To send events synchronously over the network,
+// configure the SDK to use HTTPSyncTransport during initialization with Init.
+
+func FlushWithContext(ctx context.Context) bool {
+	hub := CurrentHub()
+	return hub.FlushWithContext(ctx)
+}
+
 // LastEventID returns an ID of last captured event.
 func LastEventID() EventID {
 	hub := CurrentHub()
diff --git a/slog/README.MD b/slog/README.MD
index 494fda8..a6a44c0 100644
--- a/slog/README.MD
+++ b/slog/README.MD
@@ -90,4 +90,4 @@ handler := slogSentry.Option{
 
 ## Notes
 
-- Always call Flush to ensure all events are sent to Sentry before program termination
+- Always call `Flush` or `FlushWithContext` to ensure all events are sent to Sentry before program termination
diff --git a/tracing.go b/tracing.go
index b292f98..993c207 100644
--- a/tracing.go
+++ b/tracing.go
@@ -942,16 +942,17 @@ func ContinueFromHeaders(trace, baggage string) SpanOption {
 	return func(s *Span) {
 		if trace != "" {
 			s.updateFromSentryTrace([]byte(trace))
-		}
-		if baggage != "" {
-			s.updateFromBaggage([]byte(baggage))
-		}
 
-		// In case a sentry-trace header is present but there are no sentry-related
-		// values in the baggage, create an empty, frozen DynamicSamplingContext.
-		if trace != "" && !s.dynamicSamplingContext.HasEntries() {
-			s.dynamicSamplingContext = DynamicSamplingContext{
-				Frozen: true,
+			if baggage != "" {
+				s.updateFromBaggage([]byte(baggage))
+			}
+
+			// In case a sentry-trace header is present but there are no sentry-related
+			// values in the baggage, create an empty, frozen DynamicSamplingContext.
+			if !s.dynamicSamplingContext.HasEntries() {
+				s.dynamicSamplingContext = DynamicSamplingContext{
+					Frozen: true,
+				}
 			}
 		}
 	}
diff --git a/transport.go b/transport.go
index 28f7713..e2ec87a 100644
--- a/transport.go
+++ b/transport.go
@@ -35,6 +35,7 @@ const maxDrainResponseBytes = 16 << 10
 // Transport is used by the Client to deliver events to remote server.
 type Transport interface {
 	Flush(timeout time.Duration) bool
+	FlushWithContext(ctx context.Context) bool
 	Configure(options ClientOptions)
 	SendEvent(event *Event)
 	Close()
@@ -439,8 +440,19 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event)
 // have the SDK send events over the network synchronously, configure it to use
 // the HTTPSyncTransport in the call to Init.
 func (t *HTTPTransport) Flush(timeout time.Duration) bool {
-	toolate := time.After(timeout)
+	timeoutCh := make(chan struct{})
+	time.AfterFunc(timeout, func() {
+		close(timeoutCh)
+	})
+	return t.flushInternal(timeoutCh)
+}
+
+// FlushWithContext works like Flush, but it accepts a context.Context instead of a timeout.
+func (t *HTTPTransport) FlushWithContext(ctx context.Context) bool {
+	return t.flushInternal(ctx.Done())
+}
 
+func (t *HTTPTransport) flushInternal(timeout <-chan struct{}) bool {
 	// Wait until processing the current batch has started or the timeout.
 	//
 	// We must wait until the worker has seen the current batch, because it is
@@ -448,6 +460,7 @@ func (t *HTTPTransport) Flush(timeout time.Duration) bool {
 	// possible execution flow in which b.done is never closed, and the only way
 	// out of Flush would be waiting for the timeout, which is undesired.
 	var b batch
+
 	for {
 		select {
 		case b = <-t.buffer:
@@ -457,7 +470,7 @@ func (t *HTTPTransport) Flush(timeout time.Duration) bool {
 			default:
 				t.buffer <- b
 			}
-		case <-toolate:
+		case <-timeout:
 			goto fail
 		}
 	}
@@ -478,12 +491,12 @@ started:
 	case <-b.done:
 		DebugLogger.Println("Buffer flushed successfully.")
 		return true
-	case <-toolate:
+	case <-timeout:
 		goto fail
 	}
 
 fail:
-	DebugLogger.Println("Buffer flushing reached the timeout.")
+	DebugLogger.Println("Buffer flushing was canceled or timed out.")
 	return false
 }
 
@@ -697,6 +710,11 @@ func (t *HTTPSyncTransport) Flush(_ time.Duration) bool {
 	return true
 }
 
+// FlushWithContext is a no-op for HTTPSyncTransport. It always returns true immediately.
+func (t *HTTPSyncTransport) FlushWithContext(_ context.Context) bool {
+	return true
+}
+
 func (t *HTTPSyncTransport) disabled(c ratelimit.Category) bool {
 	t.mu.Lock()
 	defer t.mu.Unlock()
@@ -729,4 +747,8 @@ func (noopTransport) Flush(time.Duration) bool {
 	return true
 }
 
+func (noopTransport) FlushWithContext(context.Context) bool {
+	return true
+}
+
 func (noopTransport) Close() {}
diff --git a/zerolog/README.md b/zerolog/README.md
index 18e4b0d..e27fcbf 100644
--- a/zerolog/README.md
+++ b/zerolog/README.md
@@ -85,4 +85,4 @@ The `sentryzerolog.Options` struct allows you to configure the following:
 
 ## Notes
 
-- Always call Flush to ensure all events are sent to Sentry before program termination
\ No newline at end of file
+- Always call `Flush` or `FlushWithContext` to ensure all events are sent to Sentry before program termination
\ No newline at end of file


Test patch:
diff --git a/hub_test.go b/hub_test.go
index 7aade55..1840621 100644
--- a/hub_test.go
+++ b/hub_test.go
@@ -7,6 +7,7 @@ import (
 	"strings"
 	"sync"
 	"testing"
+	"time"
 
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
@@ -493,3 +494,62 @@ func TestConcurrentHubClone(t *testing.T) {
 		t.Errorf("Events mismatch (-want +got):\n%s", diff)
 	}
 }
+
+func TestHub_Flush(t *testing.T) {
+	hub, client, _ := setupHubTest()
+	transport := &MockTransport{}
+	client.Transport = transport
+
+	wantEvent := Event{Message: "something"}
+	hub.CaptureEvent(&wantEvent)
+	hub.Flush(20 * time.Millisecond)
+
+	gotEvents := transport.Events()
+	if len(gotEvents) != 1 {
+		t.Fatalf("expected 1 event, got %d", len(gotEvents))
+	}
+	if gotEvents[0].Message != wantEvent.Message {
+		t.Fatalf("expected message to be %v, got %v", wantEvent.Message, gotEvents[0].Message)
+	}
+}
+
+func TestHub_Flush_NoClient(t *testing.T) {
+	hub := NewHub(nil, nil)
+	flushed := hub.Flush(20 * time.Millisecond)
+
+	if flushed != false {
+		t.Fatalf("expected flush to be false, got %v", flushed)
+	}
+}
+
+func TestHub_FlushWithCtx_NoClient(t *testing.T) {
+	hub := NewHub(nil, nil)
+	cancelCtx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	flushed := hub.FlushWithContext(cancelCtx)
+
+	if flushed != false {
+		t.Fatalf("expected flush to be false, got %v", flushed)
+	}
+}
+
+func TestHub_FlushWithContext(t *testing.T) {
+	hub, client, _ := setupHubTest()
+	transport := &MockTransport{}
+	client.Transport = transport
+
+	wantEvent := Event{Message: "something"}
+	hub.CaptureEvent(&wantEvent)
+
+	cancelCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+	hub.FlushWithContext(cancelCtx)
+	defer cancel()
+
+	gotEvents := transport.Events()
+	if len(gotEvents) != 1 {
+		t.Fatalf("expected 1 event, got %d", len(gotEvents))
+	}
+	if gotEvents[0].Message != wantEvent.Message {
+		t.Fatalf("expected message to be %v, got %v", wantEvent.Message, gotEvents[0].Message)
+	}
+}
diff --git a/log_test.go b/log_test.go
index 58852a0..62cb3d3 100644
--- a/log_test.go
+++ b/log_test.go
@@ -46,7 +46,6 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		"sentry.server.address":       {Value: "test-server", Type: "string"},
 		"sentry.sdk.name":             {Value: "sentry.go", Type: "string"},
 		"sentry.sdk.version":          {Value: "0.10.0", Type: "string"},
-		"sentry.origin":               {Value: "auto.logger.log", Type: "string"},
 		"sentry.message.template":     {Value: "param matching: %v and %v", Type: "string"},
 		"sentry.message.parameters.0": {Value: "param1", Type: "string"},
 		"sentry.message.parameters.1": {Value: "param2", Type: "string"},
@@ -207,7 +206,6 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		"sentry.server.address": {Value: "test-server", Type: "string"},
 		"sentry.sdk.name":       {Value: "sentry.go", Type: "string"},
 		"sentry.sdk.version":    {Value: "0.10.0", Type: "string"},
-		"sentry.origin":         {Value: "auto.logger.log", Type: "string"},
 	}
 
 	tests := []struct {
@@ -381,7 +379,6 @@ func Test_sentryLogger_Write(t *testing.T) {
 		"sentry.server.address": {Value: "test-server", Type: "string"},
 		"sentry.sdk.name":       {Value: "sentry.go", Type: "string"},
 		"sentry.sdk.version":    {Value: "0.10.0", Type: "string"},
-		"sentry.origin":         {Value: "auto.logger.log", Type: "string"},
 	}
 	wantLogs := []Log{
 		{
@@ -455,6 +452,21 @@ func Test_batchLogger_Flush(t *testing.T) {
 	}
 }
 
+func Test_batchLogger_FlushWithContext(t *testing.T) {
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(context.Background())
+	l.Info(ctx, "context done log")
+
+	cancelCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+	FlushWithContext(cancelCtx)
+	defer cancel()
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event, got %d", len(events))
+	}
+}
+
 func Test_sentryLogger_BeforeSendLog(t *testing.T) {
 	ctx := context.Background()
 	mockTransport := &MockTransport{}
@@ -592,3 +604,65 @@ func TestSentryLogger_DebugLogging(t *testing.T) {
 		})
 	}
 }
+
+func Test_sentryLogger_UserAttributes(t *testing.T) {
+	ctx := context.Background()
+	mockTransport := &MockTransport{}
+	mockClient, _ := NewClient(ClientOptions{
+		Dsn:           testDsn,
+		Transport:     mockTransport,
+		Release:       "v1.2.3",
+		Environment:   "testing",
+		ServerName:    "test-server",
+		EnableLogs:    true,
+		EnableTracing: true,
+	})
+	mockClient.sdkIdentifier = "sentry.go"
+	mockClient.sdkVersion = "0.10.0"
+	hub := CurrentHub()
+	hub.BindClient(mockClient)
+	hub.Scope().propagationContext.TraceID = TraceIDFromHex(LogTraceID)
+
+	hub.Scope().SetUser(User{
+		ID:    "user123",
+		Name:  "Test User",
+		Email: "test@example.com",
+	})
+
+	ctx = SetHubOnContext(ctx, hub)
+
+	l := NewLogger(ctx)
+	l.Info(ctx, "test message with PII")
+	Flush(20 * time.Millisecond)
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event, got %d", len(events))
+	}
+
+	logs := events[0].Logs
+	if len(logs) != 1 {
+		t.Fatalf("expected 1 log, got %d", len(logs))
+	}
+
+	log := logs[0]
+	attrs := log.Attributes
+
+	if val, ok := attrs["user.id"]; !ok {
+		t.Error("missing user.id attribute")
+	} else if val.Value != "user123" {
+		t.Errorf("unexpected user.id: got %v, want %v", val.Value, "user123")
+	}
+
+	if val, ok := attrs["user.name"]; !ok {
+		t.Error("missing user.name attribute")
+	} else if val.Value != "Test User" {
+		t.Errorf("unexpected user.name: got %v, want %v", val.Value, "Test User")
+	}
+
+	if val, ok := attrs["user.email"]; !ok {
+		t.Error("missing user.email attribute")
+	} else if val.Value != "test@example.com" {
+		t.Errorf("unexpected user.email: got %v, want %v", val.Value, "test@example.com")
+	}
+}
diff --git a/tracing_test.go b/tracing_test.go
index ee37633..9a698e0 100644
--- a/tracing_test.go
+++ b/tracing_test.go
@@ -508,13 +508,14 @@ func TestContinueSpanFromRequest(t *testing.T) {
 
 func TestContinueTransactionFromHeaders(t *testing.T) {
 	tests := []struct {
+		name       string
 		traceStr   string
 		baggageStr string
 		// Using a pointer to Span so we don't implicitly copy Span.mu mutex
 		wantSpan *Span
 	}{
 		{
-			// No sentry-trace or baggage => nothing to do, unfrozen DSC
+			name:       "No sentry-trace or baggage => nothing to do, unfrozen DSC",
 			traceStr:   "",
 			baggageStr: "",
 			wantSpan: &Span{
@@ -526,20 +527,19 @@ func TestContinueTransactionFromHeaders(t *testing.T) {
 			},
 		},
 		{
-			// Third-party baggage => nothing to do, unfrozen DSC
+			name:       "baggage => nothing to do, unfrozen DSC",
 			traceStr:   "",
 			baggageStr: "other-vendor-key1=value1;value2, other-vendor-key2=value3",
 			wantSpan: &Span{
 				Sampled: 0,
 				dynamicSamplingContext: DynamicSamplingContext{
 					Frozen:  false,
-					Entries: map[string]string{},
+					Entries: nil,
 				},
 			},
 		},
 		{
-			// sentry-trace and no baggage => we should create a new DSC and freeze it
-			// immediately.
+			name:       "sentry-trace and no baggage => we should create a new DSC and freeze it",
 			traceStr:   "bc6d53f15eb88f4320054569b8c553d4-b72fa28504b07285-1",
 			baggageStr: "",
 			wantSpan: &Span{
@@ -552,7 +552,7 @@ func TestContinueTransactionFromHeaders(t *testing.T) {
 			},
 		},
 		{
-			// sentry-trace and baggage with Sentry values => we freeze immediately.
+			name:       "sentry-trace and baggage with Sentry values => we freeze immediately.",
 			traceStr:   "bc6d53f15eb88f4320054569b8c553d4-b72fa28504b07285-1",
 			baggageStr: "sentry-trace_id=d49d9bf66f13450b81f65bc51cf49c03,sentry-public_key=public,sentry-sample_rate=1",
 			wantSpan: &Span{
@@ -569,14 +569,32 @@ func TestContinueTransactionFromHeaders(t *testing.T) {
 				},
 			},
 		},
+		{
+			name:       "no sentry-trace and baggage with Sentry values => unfrozen DSC",
+			baggageStr: "sentry-trace_id=d49d9bf66f13450b81f65bc51cf49c03,sentry-public_key=public,sentry-sample_rate=1",
+			wantSpan: &Span{
+				Sampled: 0,
+				dynamicSamplingContext: DynamicSamplingContext{
+					Frozen:  false,
+					Entries: nil,
+				},
+			},
+		},
 	}
 
 	for _, tt := range tests {
-		s := &Span{}
-		spanOption := ContinueFromHeaders(tt.traceStr, tt.baggageStr)
-		spanOption(s)
-
-		assertEqual(t, s, tt.wantSpan)
+		t.Run(tt.name, func(t *testing.T) {
+			s := &Span{}
+			spanOption := ContinueFromHeaders(tt.traceStr, tt.baggageStr)
+			spanOption(s)
+
+			if diff := cmp.Diff(tt.wantSpan, s, cmp.Options{
+				cmp.AllowUnexported(Span{}),
+				cmpopts.IgnoreFields(Span{}, "mu", "finishOnce"),
+			}); diff != "" {
+				t.Fatalf("Expected no difference on spans, got: %s", diff)
+			}
+		})
 	}
 }
 
diff --git a/transport_test.go b/transport_test.go
index 5204dc6..cf29596 100644
--- a/transport_test.go
+++ b/transport_test.go
@@ -491,6 +491,98 @@ func TestHTTPTransport(t *testing.T) {
 	})
 }
 
+func TestHTTPTransport_FlushWithContext(t *testing.T) {
+	server := newTestHTTPServer(t)
+	defer server.Close()
+
+	transport := NewHTTPTransport()
+	transport.Configure(ClientOptions{
+		Dsn:        fmt.Sprintf("https://test@%s/1", server.Listener.Addr()),
+		HTTPClient: server.Client(),
+	})
+
+	// Helpers
+
+	transportSendTestEvent := func(t *testing.T) (id string) {
+		t.Helper()
+
+		e := NewEvent()
+		id = uuid()
+		e.EventID = EventID(id)
+
+		transport.SendEvent(e)
+
+		t.Logf("[CLIENT] {%.4s} event sent", e.EventID)
+		return id
+	}
+
+	transportMustFlushWithCtx := func(t *testing.T, id string) {
+		t.Helper()
+		cancelCtx, cancel := context.WithCancel(context.Background())
+		defer cancel()
+		ok := transport.FlushWithContext(cancelCtx)
+		if !ok {
+			t.Fatalf("[CLIENT] {%.4s} Flush() timed out", id)
+		}
+	}
+
+	serverEventCountMustBe := func(t *testing.T, n uint64) {
+		t.Helper()
+
+		count := server.EventCount()
+		if count != n {
+			t.Fatalf("[SERVER] event count = %d, want %d", count, n)
+		}
+	}
+
+	testSendSingleEvent := func(t *testing.T) {
+		// Sending a single event should increase the server event count by
+		// exactly one.
+
+		initialCount := server.EventCount()
+		id := transportSendTestEvent(t)
+
+		// Server is blocked waiting for us, right now count must not have
+		// changed yet.
+		serverEventCountMustBe(t, initialCount)
+
+		// After unblocking the server, Flush must guarantee that the server
+		// event count increased by one.
+		server.Unblock()
+		transportMustFlushWithCtx(t, id)
+		serverEventCountMustBe(t, initialCount+1)
+	}
+
+	// Actual tests
+	t.Run("SendSingleEvent", testSendSingleEvent)
+
+	t.Run("FlushMultipleTimes", func(t *testing.T) {
+		// Flushing multiple times should not increase the server event count.
+
+		initialCount := server.EventCount()
+		for i := 0; i < 10; i++ {
+			transportMustFlushWithCtx(t, fmt.Sprintf("loop%d", i))
+		}
+		serverEventCountMustBe(t, initialCount)
+	})
+
+	t.Run("ConcurrentSendAndFlush", func(t *testing.T) {
+		// It should be safe to send events and flush concurrently.
+
+		var wg sync.WaitGroup
+		wg.Add(2)
+		go func() {
+			defer wg.Done()
+			testSendSingleEvent(t)
+		}()
+		go func() {
+			defer wg.Done()
+			transportMustFlushWithCtx(t, "from goroutine")
+		}()
+		wg.Wait()
+	})
+}
+
 // httptraceRoundTripper implements http.RoundTripper by wrapping
 // http.DefaultTransport and keeps track of whether TCP connections have been
 // reused for every request.
@@ -707,3 +799,26 @@ func TestHTTPSyncTransportClose(_ *testing.T) {
 	tr := noopTransport{}
 	tr.Close()
 }
+
+func TestHTTPSyncTransport_Flush(t *testing.T) {
+	// Flush does not do anything for HTTPSyncTransport, added for coverage.
+	transport := HTTPSyncTransport{}
+	transport.Flush(testutils.FlushTimeout())
+
+	tr := noopTransport{}
+	ret := tr.Flush(testutils.FlushTimeout())
+	if ret != true {
+		t.Fatalf("expected Flush to be true, got: %v", ret)
+	}
+}
+
+func TestHTTPSyncTransport_FlushWithContext(_ *testing.T) {
+	// FlushWithContext does not do anything for HTTPSyncTransport, added for coverage.
+	transport := HTTPSyncTransport{}
+	cancelCtx, cancel := context.WithCancel(context.Background())
+	defer cancel()
+	transport.FlushWithContext(cancelCtx)
+
+	tr := noopTransport{}
+	tr.FlushWithContext(cancelCtx)
+}