Skip to content

Conversation

@renovate
Copy link
Contributor

@renovate renovate bot commented Oct 14, 2025

This PR contains the following updates:

Package Change Age Confidence
github.com/getsentry/sentry-go v0.34.0 -> v0.39.0 age confidence

Release Notes

getsentry/sentry-go (github.com/getsentry/sentry-go)

v0.39.0: 0.39.0

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.39.0.

Features
  • Drop events from the telemetry buffer when rate-limited or transport is full, allowing the buffer queue to empty itself under load (#​1138).
Bug Fixes
  • Fix scheduler's hasWork() method to check if buffers are ready to flush. The previous implementation was causing CPU spikes (#​1143).

v0.38.0: 0.38.0

Compare Source

Breaking Changes
Features
  • Introduce a new async envelope transport and telemetry buffer to prioritize and batch events (#​1094, #​1093, #​1107).

    • Advantages:
      • Prioritized, per-category buffers (errors, transactions, logs, check-ins) reduce starvation and improve resilience under load
      • Batching for high-volume logs (up to 100 items or 5s) cuts network overhead
      • Bounded memory with eviction policies
      • Improved flush behavior with context-aware flushing
  • Add ClientOptions.DisableTelemetryBuffer to opt out and fall back to the legacy transport layer (HTTPTransport / HTTPSyncTransport).

    err := sentry.Init(sentry.ClientOptions{
      Dsn: "__DSN__",
      DisableTelemetryBuffer: true, // fallback to legacy transport
    })
Notes
  • If a custom Transport is provided, the SDK automatically disables the telemetry buffer and uses the legacy transport for compatibility.

v0.37.0: 0.37.0

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.37.0.

Breaking Changes
  • Behavioral change for the TraceIgnoreStatusCodes option. The option now defaults to ignoring 404 status codes (#​1122).
Features
  • Add sentry.origin attribute to structured logs to identify log origin for slog and logrus integrations (auto.log.slog, auto.log.logrus) (#​1121).
Bug Fixes
  • Fix slog event handler to use the initial context, ensuring events use the correct hub/span when the emission context lacks one (#​1133).
  • Improve exception chain processing by checking pointer values when tracking visited errors, avoiding instability for certain wrapped errors (#​1132).
Misc
  • Bump golang.org/x/net to v0.38.0 (#​1126).

v0.36.2: 0.36.2

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.36.2.

Bug Fixes
  • Fix context propagation for logs to ensure logger instances correctly inherit span and hub information from their creation context (#​1118)
    • Logs now properly propagate trace context from the logger's original context, even when emitted in a different context
    • The logger will first check the emission context, then fall back to its creation context, and finally to the current hub

v0.36.1: 0.36.1

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.36.1.

Bug Fixes
  • Prevent panic when converting error chains containing non-comparable error types by using a safe fallback for visited detection in exception conversion (#​1113)

v0.36.0: 0.36.0

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.36.0.

Breaking Changes
  • Behavioral change for the MaxBreadcrumbs client option. Removed the hard limit of 100 breadcrumbs, allowing users to set a larger limit and also changed the default limit from 30 to 100 (#​1106))

  • The changes to error handling (#​1075) will affect issue grouping. It is expected that any wrapped and complex errors will be grouped under a new issue group.

Features
  • Add support for improved issue grouping with enhanced error chain handling (#​1075)

    The SDK now provides better handling of complex error scenarios, particularly when dealing with multiple related errors or error chains. This feature automatically detects and properly structures errors created with Go's errors.Join() function and other multi-error patterns.

    // Multiple errors are now properly grouped and displayed in Sentry
    err1 := errors.New("err1")
    err2 := errors.New("err2") 
    combinedErr := errors.Join(err1, err2)
    
    // When captured, these will be shown as related exceptions in Sentry
    sentry.CaptureException(combinedErr)
  • Add TraceIgnoreStatusCodes option to allow filtering of HTTP transactions based on status codes (#​1089)

    • Configure which HTTP status codes should not be traced by providing single codes or ranges
    • Example: TraceIgnoreStatusCodes: [][]int{{404}, {500, 599}} ignores 404 and server errors 500-599
Bug Fixes
  • Fix logs being incorrectly filtered by BeforeSend callback (#​1109)
    • Logs now bypass the processEvent method and are sent directly to the transport
    • This ensures logs are only filtered by BeforeSendLog, not by the error/message BeforeSend callback
Misc
  • Add support for Go 1.25 and drop support for Go 1.22 (#​1103)

v0.35.3: 0.35.3

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.3.

Bug Fixes
  • Add missing rate limit categories (#​1082)

v0.35.2: 0.35.2

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.2.

Bug Fixes
  • Fix OpenTelemetry spans being created as transactions instead of child spans (#​1073)
Misc
  • Add MockTransport to test clients for improved testing (#​1071)

v0.35.1: 0.35.1

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.1.

Bug Fixes
  • Fix race conditions when accessing the scope during logging operations (#​1050)
  • Fix nil pointer dereference with malformed URLs when tracing is enabled in fasthttp and fiber integrations (#​1055)
Misc
  • Bump github.com/gofiber/fiber/v2 from 2.52.5 to 2.52.9 in /fiber (#​1067)

v0.35.0: 0.35.0

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.0.

Breaking Changes

The logging API now supports a fluent interface for structured logging with attributes:

// usage before
logger := sentry.NewLogger(ctx)
// attributes weren't being set permanently
logger.SetAttributes(
    attribute.String("version", "1.0.0"),
)
logger.Infof(ctx, "Message with parameters %d and %d", 1, 2)

// new behavior
ctx := context.Background()
logger := sentry.NewLogger(ctx)

// Set permanent attributes on the logger
logger.SetAttributes(
    attribute.String("version", "1.0.0"),
)

// Chain attributes on individual log entries
logger.Info().
    String("key.string", "value").
    Int("key.int", 42).
    Bool("key.bool", true).
    Emitf("Message with parameters %d and %d", 1, 2)
Bug Fixes
  • Correctly serialize FailureIssueThreshold and RecoveryThreshold onto check-in payloads (#​1060)

v0.34.1: 0.34.1

Compare Source

The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.1.

Bug Fixes
  • Allow flush to be used multiple times without issues, particularly for the batch logger (#​1051)
  • Fix race condition in Scope.GetSpan() method by adding proper mutex locking (#​1044)
  • Guard transport on Close() to prevent panic when called multiple times (#​1044)

Configuration

📅 Schedule: Branch creation - Tuesday through Thursday ( * * * * 2-4 ) (UTC), Automerge - At any time (no schedule defined).

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate bot force-pushed the renovate/github-com-getsentry-sentry-go-0-x branch 2 times, most recently from 1f7b611 to 338d06c Compare October 19, 2025 02:18
@github-actions
Copy link

[puLL-Merge] - getsentry/sentry-go@otel/v0.34.0..otel/v0.35.3

Diff
diff --git a/.cursor/rules/changelog.mdc b/.cursor/rules/changelog.mdc
new file mode 100644
index 000000000..a22fefe88
--- /dev/null
+++ .cursor/rules/changelog.mdc
@@ -0,0 +1,168 @@
+---
+globs: CHANGELOG.md
+alwaysApply: false
+---
+# Changelog Creation Guidelines
+
+When creating or updating changelogs for the Sentry Go SDK, follow these rules:
+
+## Gathering Changes
+
+Before creating a changelog entry, collect all changes since the last release tag:
+
+### Find the Latest Release Tag
+```bash
+git tag --sort=-version:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -1
+```
+
+### Get Commits Since Last Release
+```bash
+# Get commit hashes and messages since last tag
+git log --oneline $(git describe --tags --abbrev=0)..HEAD
+
+# Get detailed commit information
+git log --pretty=format:"%h %s (%an)" $(git describe --tags --abbrev=0)..HEAD
+```
+
+### Analyze Changes
+For each commit since the last release:
+1. Check if it's a merge commit from a PR: `git show --stat <commit_hash>`
+2. For PR commits, fetch the PR details to understand the full context
+3. Categorize changes as Breaking Changes, Features, Deprecations, Bug Fixes, or Misc
+4. Identify any commits that should be excluded (internal refactoring, test-only changes, etc.)
+
+### Example Workflow
+```bash
+# Check current branch and recent commits
+git log --oneline --since="2024-01-01" | head -10
+
+# Get PR information for specific commits
+git show <commit_hash> --stat
+
+# Look at file changes to understand scope
+git diff --name-only <last_tag>..HEAD
+```
+
+Always base changelog entries on the complete set of commits since the last release tag to ensure no changes are missed.
+
+## Version Structure
+
+Use semantic versioning (e.g., `0.34.0`) with this format:
+
+```markdown
+## [VERSION]
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v[VERSION].
+```
+
+## Section Order
+
+Include sections in this exact order (only include sections that have content):
+
+1. **Breaking Changes** - Changes requiring code modifications
+2. **Deprecations** - Features marked for future removal
+3. **Features** - New functionality and enhancements  
+4. **Bug Fixes** - Fixes for existing functionality
+5. **Misc** - Other changes
+
+## Formatting Rules
+
+### Pull Request Links
+- Always use format: `([#NUMBER](https://github.com/getsentry/sentry-go/pull/NUMBER))`
+- For issues: `([#NUMBER](https://github.com/getsentry/sentry-go/issues/NUMBER))`
+
+### Code Examples
+- Use Go syntax highlighting: ````go
+- For breaking changes, show both before and after examples
+- Include relevant context, not just the changed line
+
+### Descriptions
+- Start with action verbs (Add, Fix, Remove, Update, etc.)
+- Be specific about what changed
+- Include component/module names when relevant
+- Keep concise but informative
+
+## Section Guidelines
+
+### Breaking Changes
+- Always provide migration examples with **Before:** and **After:** code blocks
+- Explain rationale for the change
+- Include timeline for removal if deprecating
+
+### Features
+- Focus on user-facing functionality
+- Include code examples for complex features
+- Link to documentation when relevant
+
+### Bug Fixes
+- Clearly describe what was fixed
+- Include component names (e.g., "Fix race condition in `Scope.GetSpan()` method")
+- Reference the specific issue if applicable
+
+### Deprecations
+- Include migration guidance
+- Specify removal timeline
+- Provide alternative solutions
+
+## Content Guidelines
+
+### Include:
+- All user-facing changes
+- Breaking changes with migration guidance
+- New features and enhancements
+- Important bug fixes
+- Performance improvements
+- Security fixes
+- Deprecation notices
+
+### Exclude:
+- Internal refactoring (unless affects performance)
+- Test-only changes
+- Documentation-only changes (unless significant)
+- Build system changes
+- CI/CD changes
+
+## Example Structure
+
+```markdown
+## 0.34.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.1.
+
+### Features
+
+- Allow flush to be used multiple times without issues, particularly for the batch logger ([#1051](https://github.com/getsentry/sentry-go/pull/1051))
+
+### Bug Fixes
+
+- Fix race condition in `Scope.GetSpan()` method by adding proper mutex locking ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+- Guard transport on `Close()` to prevent panic when called multiple times ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+```
+
+## Quality Checklist
+
+Before publishing:
+- [ ] Version number follows semantic versioning
+- [ ] All sections in correct order
+- [ ] All PR/issue links working
+- [ ] Code examples tested and accurate
+- [ ] Breaking changes include migration guidance
+- [ ] Descriptions clear and specific
+- [ ] Grammar and spelling correct
+- [ ] No internal-only changes included
+
+## Notes Section
+
+For significant releases, add a notes section:
+
+```markdown
+_NOTE:_
+Additional context, warnings, or important information about this release.
+```
+
+Use for:
+- Go version compatibility changes
+- Important upgrade considerations
+- Significant behavioral changes
+- Performance characteristics
+- Known limitations
diff --git .github/ISSUE_TEMPLATE/config.yml .github/ISSUE_TEMPLATE/config.yml
index 191febb53..31f71b14f 100644
--- .github/ISSUE_TEMPLATE/config.yml
+++ .github/ISSUE_TEMPLATE/config.yml
@@ -3,9 +3,3 @@ contact_links:
   - name: Support Request
     url: https://sentry.io/support
     about: Use our dedicated support channel for paid accounts.
-  - name: Ask a question about self-hosting/on-premise
-    url: https://forum.sentry.io
-    about: Please use the community forums for questions about self-hosting.
-  - name: Report a security vulnerability
-    url: https://sentry.io/security/#vulnerability-disclosure
-    about: Please see our guide for responsible disclosure.
diff --git .github/workflows/test.yml .github/workflows/test.yml
index 94564ae2f..fd1c57a81 100644
--- .github/workflows/test.yml
+++ .github/workflows/test.yml
@@ -20,6 +20,11 @@ jobs:
     env:
       GO111MODULE: "on"
       GOFLAGS: "-mod=readonly"
+      # The race detector adds considerable runtime overhead. To save time on
+      # pull requests, only run this step for a single job in the matrix. For
+      # all other workflow triggers (e.g., pushes to a release branch) run
+      # this step for the whole matrix.
+      RUN_RACE_TESTS: ${{ github.event_name != 'pull_request' || (matrix.go == '1.24' && matrix.os == 'ubuntu') }}
     steps:
       - uses: actions/setup-go@v5
         with:
@@ -40,27 +45,20 @@ jobs:
           key: ${{ runner.os }}-go-${{ matrix.go }}-${{ hashFiles('**/go.sum') }}
           restore-keys: |
             ${{ runner.os }}-go-${{ matrix.go }}-
+      - name: Tidy for min version
+        run: make mod-tidy
+        if: ${{ matrix.go == '1.22' }}
       - name: Build
         run: make build
       - name: Vet
         run: make vet
-      - name: Check go.mod Tidiness
-        run: make mod-tidy
-        if: ${{ matrix.go == '1.21' }}
-      - name: Test
-        run: make test-coverage
+      - name: Test${{ env.RUN_RACE_TESTS == 'true' && ' (with race detection)' || '' }}
+        run: ${{ env.RUN_RACE_TESTS == 'true' && 'make test-race-coverage' || 'make test-coverage' }}
       - name: Upload coverage to Codecov
         uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # [email protected]
         with:
           directory: .coverage
           token: ${{ secrets.CODECOV_TOKEN }}
-      - name: Test (with race detection)
-        run: make test-race
-        # The race detector adds considerable runtime overhead. To save time on
-        # pull requests, only run this step for a single job in the matrix. For
-        # all other workflow triggers (e.g., pushes to a release branch) run
-        # this step for the whole matrix.
-        if: ${{ github.event_name != 'pull_request' || (matrix.go == '1.23' && matrix.os == 'ubuntu') }}
     timeout-minutes: 15
     strategy:
       matrix:
diff --git CHANGELOG.md CHANGELOG.md
index b9bb13306..1533acc9e 100644
--- CHANGELOG.md
+++ CHANGELOG.md
@@ -1,5 +1,88 @@
 # Changelog
 
+## 0.35.3
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.3.
+
+### Bug Fixes
+
+- Add missing rate limit categories ([#1082](https://github.com/getsentry/sentry-go/pull/1082))
+
+## 0.35.2
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.2.
+
+### Bug Fixes
+
+- Fix OpenTelemetry spans being created as transactions instead of child spans ([#1073](https://github.com/getsentry/sentry-go/pull/1073))
+
+### Misc
+
+- Add `MockTransport` to test clients for improved testing ([#1071](https://github.com/getsentry/sentry-go/pull/1071))
+
+## 0.35.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.1.
+
+### Bug Fixes
+
+- Fix race conditions when accessing the scope during logging operations ([#1050](https://github.com/getsentry/sentry-go/pull/1050))
+- Fix nil pointer dereference with malformed URLs when tracing is enabled in `fasthttp` and `fiber` integrations ([#1055](https://github.com/getsentry/sentry-go/pull/1055))
+
+### Misc
+
+- Bump `github.com/gofiber/fiber/v2` from 2.52.5 to 2.52.9 in `/fiber` ([#1067](https://github.com/getsentry/sentry-go/pull/1067))
+
+## 0.35.0
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.0.
+
+### Breaking Changes
+
+- Changes to the logging API ([#1046](https://github.com/getsentry/sentry-go/pull/1046))
+
+The logging API now supports a fluent interface for structured logging with attributes:
+
+```go
+// usage before
+logger := sentry.NewLogger(ctx)
+// attributes weren't being set permanently
+logger.SetAttributes(
+    attribute.String("version", "1.0.0"),
+)
+logger.Infof(ctx, "Message with parameters %d and %d", 1, 2)
+
+// new behavior
+ctx := context.Background()
+logger := sentry.NewLogger(ctx)
+
+// Set permanent attributes on the logger
+logger.SetAttributes(
+    attribute.String("version", "1.0.0"),
+)
+
+// Chain attributes on individual log entries
+logger.Info().
+    String("key.string", "value").
+    Int("key.int", 42).
+    Bool("key.bool", true).
+    Emitf("Message with parameters %d and %d", 1, 2)
+```
+
+### Bug Fixes
+
+- Correctly serialize `FailureIssueThreshold` and `RecoveryThreshold` onto check-in payloads ([#1060](https://github.com/getsentry/sentry-go/pull/1060))
+
+## 0.34.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.1.
+
+### Bug Fixes
+
+- Allow flush to be used multiple times without issues, particularly for the batch logger ([#1051](https://github.com/getsentry/sentry-go/pull/1051))
+- Fix race condition in `Scope.GetSpan()` method by adding proper mutex locking ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+- Guard transport on `Close()` to prevent panic when called multiple times ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+
 ## 0.34.0
 
 The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.0.
diff --git Makefile Makefile
index 9cc0959fd..9ef0e9cd9 100644
--- Makefile
+++ Makefile
@@ -54,12 +54,23 @@ test-coverage: $(COVERAGE_REPORT_DIR) clean-report-dir  ## Test with coverage en
 	    $(GO) tool cover -html=$(COVERAGE_PROFILE) -o coverage.html); \
 	done;
 .PHONY: test-coverage clean-report-dir
-
+test-race-coverage: $(COVERAGE_REPORT_DIR) clean-report-dir  ## Run tests with race detection and coverage
+	set -e ; \
+	for dir in $(ALL_GO_MOD_DIRS); do \
+	  echo ">>> Running tests with race detection and coverage for module: $${dir}"; \
+	  DIR_ABS=$$(python -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' $${dir}) ; \
+	  REPORT_NAME=$$(basename $${DIR_ABS}); \
+	  (cd "$${dir}" && \
+	    $(GO) test -count=1 -timeout $(TIMEOUT)s -race -coverpkg=./... -covermode=$(COVERAGE_MODE) -coverprofile="$(COVERAGE_PROFILE)" ./... && \
+		cp $(COVERAGE_PROFILE) "$(COVERAGE_REPORT_DIR_ABS)/$${REPORT_NAME}_$(COVERAGE_PROFILE)" && \
+	    $(GO) tool cover -html=$(COVERAGE_PROFILE) -o coverage.html); \
+	done;
+.PHONY: test-race-coverage
 mod-tidy: ## Check go.mod tidiness
 	set -e ; \
 	for dir in $(ALL_GO_MOD_DIRS); do \
 		echo ">>> Running 'go mod tidy' for module: $${dir}"; \
-		(cd "$${dir}" && go mod tidy -go=1.21 -compat=1.21); \
+		(cd "$${dir}" && go mod tidy -go=1.22 -compat=1.22); \
 	done; \
 	git diff --exit-code;
 .PHONY: mod-tidy
diff --git _examples/logs/main.go _examples/logs/main.go
index c9785f2f6..b808ada4c 100644
--- _examples/logs/main.go
+++ _examples/logs/main.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"context"
+	"net/http"
 	"time"
 
 	"github.com/getsentry/sentry-go"
@@ -11,7 +12,7 @@ import (
 func main() {
 	err := sentry.Init(sentry.ClientOptions{
 		Dsn:        "",
-		EnableLogs: true,
+		EnableLogs: true, // you need to have EnableLogs set to true
 	})
 	if err != nil {
 		panic(err)
@@ -19,19 +20,42 @@ func main() {
 	defer sentry.Flush(2 * time.Second)
 
 	ctx := context.Background()
+	loggerWithAttrs := sentry.NewLogger(ctx)
+	// Attaching permanent attributes on the logger.
+	loggerWithAttrs.SetAttributes(
+		attribute.String("version", "1.0.0"),
+	)
+
+	// It's also possible to attach attributes on the [LogEntry] itself.
+	loggerWithAttrs.Info().
+		String("key.string", "value").
+		Int("key.int", 42).
+		Bool("key.bool", true).
+		// don't forget to call Emit to send the logs to Sentry
+		Emitf("Message with parameters %d and %d", 1, 2)
+
+	// The [LogEntry] can also be precompiled, if you don't want to set the same attributes multiple times
+	logEntry := loggerWithAttrs.Info().Int("int", 1)
+	// And then call Emit multiple times
+	logEntry.Emit("once")
+	logEntry.Emit("twice")
+
+	// You can also create different loggers with different precompiled attributes
 	logger := sentry.NewLogger(ctx)
+	logger.Info().
+		Emit("doesn't contain version") // this log does not contain the version attribute
+}
 
-	// You can use the logger like [fmt.Print]
-	logger.Info(ctx, "Expecting ", 2, " params")
-	// or like [fmt.Printf]
-	logger.Infof(ctx, "format: %v", "value")
-
-	// Additionally, you can also set attributes on the log like this
-	logger.SetAttributes(
-		attribute.Int("key.int", 42),
-		attribute.Bool("key.boolean", true),
-		attribute.Float64("key.float", 42.4),
-		attribute.String("key.string", "string"),
-	)
-	logger.Warnf(ctx, "I have params %v and attributes", "example param")
+type MyHandler struct {
+	logger sentry.Logger
+}
+
+// ServeHTTP example of a handler
+// To correlate logs with transactions, [context.Context] needs to be passed to the [LogEntry] with the [WithCtx] func.
+// Assuming you are using a Sentry tracing integration.
+func (h MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	// By using [WithCtx] the log entry will be associated with the transaction from the request
+	h.logger.Info().WithCtx(ctx).Emit("log inside handler")
+	w.WriteHeader(http.StatusOK)
 }
diff --git batch_logger.go batch_logger.go
index 8f25c008f..d4ebe2fd1 100644
--- batch_logger.go
+++ batch_logger.go
@@ -12,17 +12,20 @@ const (
 )
 
 type BatchLogger struct {
-	client    *Client
-	logCh     chan Log
-	cancel    context.CancelFunc
-	wg        sync.WaitGroup
-	startOnce sync.Once
+	client       *Client
+	logCh        chan Log
+	flushCh      chan chan struct{}
+	cancel       context.CancelFunc
+	wg           sync.WaitGroup
+	startOnce    sync.Once
+	shutdownOnce sync.Once
 }
 
 func NewBatchLogger(client *Client) *BatchLogger {
 	return &BatchLogger{
-		client: client,
-		logCh:  make(chan Log, batchSize),
+		client:  client,
+		logCh:   make(chan Log, batchSize),
+		flushCh: make(chan chan struct{}),
 	}
 }
 
@@ -35,17 +38,32 @@ func (l *BatchLogger) Start() {
 	})
 }
 
-func (l *BatchLogger) Flush() {
-	if l.cancel != nil {
-		l.cancel()
-		l.wg.Wait()
+func (l *BatchLogger) Flush(timeout <-chan struct{}) {
+	done := make(chan struct{})
+	select {
+	case l.flushCh <- done:
+		select {
+		case <-done:
+		case <-timeout:
+		}
+	case <-timeout:
 	}
 }
 
+func (l *BatchLogger) Shutdown() {
+	l.shutdownOnce.Do(func() {
+		if l.cancel != nil {
+			l.cancel()
+			l.wg.Wait()
+		}
+	})
+}
+
 func (l *BatchLogger) run(ctx context.Context) {
 	defer l.wg.Done()
 	var logs []Log
 	timer := time.NewTimer(batchTimeout)
+	defer timer.Stop()
 
 	for {
 		select {
@@ -65,8 +83,27 @@ func (l *BatchLogger) run(ctx context.Context) {
 				logs = nil
 			}
 			timer.Reset(batchTimeout)
+		case done := <-l.flushCh:
+		flushDrain:
+			for {
+				select {
+				case log := <-l.logCh:
+					logs = append(logs, log)
+				default:
+					break flushDrain
+				}
+			}
+
+			if len(logs) > 0 {
+				l.processEvent(logs)
+				logs = nil
+			}
+			if !timer.Stop() {
+				<-timer.C
+			}
+			timer.Reset(batchTimeout)
+			close(done)
 		case <-ctx.Done():
-			// Drain remaining logs from channel
 		drain:
 			for {
 				select {
diff --git client.go client.go
index ea29096b6..3a1b40f82 100644
--- client.go
+++ client.go
@@ -511,7 +511,9 @@ func (client *Client) RecoverWithContext(
 // call to Init.
 func (client *Client) Flush(timeout time.Duration) bool {
 	if client.batchLogger != nil {
-		client.batchLogger.Flush()
+		ctx, cancel := context.WithTimeout(context.Background(), timeout)
+		defer cancel()
+		return client.FlushWithContext(ctx)
 	}
 	return client.Transport.Flush(timeout)
 }
@@ -530,7 +532,7 @@ func (client *Client) Flush(timeout time.Duration) bool {
 
 func (client *Client) FlushWithContext(ctx context.Context) bool {
 	if client.batchLogger != nil {
-		client.batchLogger.Flush()
+		client.batchLogger.Flush(ctx.Done())
 	}
 	return client.Transport.FlushWithContext(ctx)
 }
diff --git client_test.go client_test.go
index 7c09586de..dc6a6db1d 100644
--- client_test.go
+++ client_test.go
@@ -5,6 +5,8 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"net"
+	"net/http"
 	"sync"
 	"sync/atomic"
 	"testing"
@@ -871,8 +873,18 @@ func TestSDKIdentifier(t *testing.T) {
 }
 
 func TestClientSetsUpTransport(t *testing.T) {
-	client, _ := NewClient(ClientOptions{Dsn: testDsn})
-	require.IsType(t, &HTTPTransport{}, client.Transport)
+	client, _ := NewClient(ClientOptions{
+		Dsn: testDsn,
+		HTTPClient: &http.Client{
+			Transport: &http.Transport{
+				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+					return nil, fmt.Errorf("mock transport - no real connections")
+				},
+			},
+		},
+		Transport: &MockTransport{},
+	})
+	require.IsType(t, &MockTransport{}, client.Transport)
 
 	client, _ = NewClient(ClientOptions{})
 	require.IsType(t, &noopTransport{}, client.Transport)
diff --git echo/go.mod echo/go.mod
index 8cecf3df8..5701da09b 100644
--- echo/go.mod
+++ echo/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/echo
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.35.3
 	github.com/google/go-cmp v0.5.9
 	github.com/labstack/echo/v4 v4.10.0
 )
diff --git fasthttp/go.mod fasthttp/go.mod
index 2b7ddf5b6..ba34f51e1 100644
--- fasthttp/go.mod
+++ fasthttp/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/fasthttp
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.35.3
 	github.com/google/go-cmp v0.5.9
 	github.com/valyala/fasthttp v1.52.0
 )
diff --git fasthttp/go.sum fasthttp/go.sum
index 1bba7c1c6..574570aaa 100644
--- fasthttp/go.sum
+++ fasthttp/go.sum
@@ -14,8 +14,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
diff --git fasthttp/sentryfasthttp.go fasthttp/sentryfasthttp.go
index 86b444ddc..9a078d958 100644
--- fasthttp/sentryfasthttp.go
+++ fasthttp/sentryfasthttp.go
@@ -152,9 +152,11 @@ func convert(ctx *fasthttp.RequestCtx) *http.Request {
 	r.Method = string(ctx.Method())
 
 	uri := ctx.URI()
-	url, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path()))
-	if err == nil {
-		r.URL = url
+	r.URL = &url.URL{Path: string(uri.Path())}
+	r.URL.RawQuery = string(uri.QueryString())
+
+	if parsedURL, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path())); err == nil {
+		r.URL = parsedURL
 		r.URL.RawQuery = string(uri.QueryString())
 	}
 
diff --git fasthttp/sentryfasthttp_test.go fasthttp/sentryfasthttp_test.go
index 5600c42d3..c59b438f9 100644
--- fasthttp/sentryfasthttp_test.go
+++ fasthttp/sentryfasthttp_test.go
@@ -571,3 +571,35 @@ func TestSetHubOnContext(t *testing.T) {
 		t.Fatalf("expected hub to be %v, but got %v", hub, retrievedHub)
 	}
 }
+
+// TestMalformedURLNoPanic verifies that malformed URLs don't cause panics
+// when tracing is enabled
+func TestMalformedURLNoPanic(t *testing.T) {
+	err := sentry.Init(sentry.ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	sentryHandler := sentryfasthttp.New(sentryfasthttp.Options{})
+
+	handler := sentryHandler.Handle(func(ctx *fasthttp.RequestCtx) {
+		ctx.SetStatusCode(fasthttp.StatusOK)
+		ctx.SetBodyString("OK")
+	})
+
+	ctx := &fasthttp.RequestCtx{}
+	ctx.Request.SetRequestURI("http://localhost/%zz")
+	ctx.Request.Header.SetMethod("GET")
+	ctx.Request.SetHost("localhost")
+	ctx.Request.Header.Set("User-Agent", "fasthttp")
+
+	handler(ctx)
+
+	// Should complete successfully without panic
+	if ctx.Response.StatusCode() != fasthttp.StatusOK {
+		t.Errorf("Expected 200, got %d", ctx.Response.StatusCode())
+	}
+}
diff --git fiber/go.mod fiber/go.mod
index f6e6f7199..b5bfef48f 100644
--- fiber/go.mod
+++ fiber/go.mod
@@ -1,12 +1,12 @@
 module github.com/getsentry/sentry-go/fiber
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
-	github.com/gofiber/fiber/v2 v2.52.5
+	github.com/getsentry/sentry-go v0.35.3
+	github.com/gofiber/fiber/v2 v2.52.9
 	github.com/google/go-cmp v0.5.9
 )
 
diff --git fiber/go.sum fiber/go.sum
index aa0d31210..523708b5d 100644
--- fiber/go.sum
+++ fiber/go.sum
@@ -4,8 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
 github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
-github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
-github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
+github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
+github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -28,8 +28,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.57.0 h1:Xw8SjWGEP/+wAAgyy5XTvgrWlOD1+TxbbvNADYCm1Tg=
diff --git fiber/sentryfiber.go fiber/sentryfiber.go
index f8ce16dca..c26df18b3 100644
--- fiber/sentryfiber.go
+++ fiber/sentryfiber.go
@@ -152,9 +152,11 @@ func convert(ctx *fiber.Ctx) *http.Request {
 	r.Method = utils.CopyString(ctx.Method())
 
 	uri := ctx.Request().URI()
-	url, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path()))
-	if err == nil {
-		r.URL = url
+	r.URL = &url.URL{Path: string(uri.Path())}
+	r.URL.RawQuery = string(uri.QueryString())
+
+	if parsedURL, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path())); err == nil {
+		r.URL = parsedURL
 		r.URL.RawQuery = string(uri.QueryString())
 	}
 
diff --git fiber/sentryfiber_test.go fiber/sentryfiber_test.go
index 842215e57..bf41fe6b2 100644
--- fiber/sentryfiber_test.go
+++ fiber/sentryfiber_test.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"reflect"
 	"strings"
 	"testing"
@@ -594,3 +595,40 @@ func TestSetHubOnContext(t *testing.T) {
 		t.Fatalf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode)
 	}
 }
+
+// TestMalformedURLNoPanic verifies that malformed URLs don't cause panics
+// when tracing is enabled,
+func TestMalformedURLNoPanic(t *testing.T) {
+	err := sentry.Init(sentry.ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	app := fiber.New()
+	app.Use(sentryfiber.New(sentryfiber.Options{Timeout: 3 * time.Second, WaitForDelivery: true}))
+
+	app.Get("/*", func(c *fiber.Ctx) error {
+		return c.SendString("OK")
+	})
+
+	req := &http.Request{
+		Method: "GET",
+		URL:    &url.URL{Scheme: "http", Host: "localhost", Path: "/%zz"},
+		Header: make(http.Header),
+		Host:   "localhost",
+	}
+	req.Header.Set("User-Agent", "fiber")
+
+	resp, err := app.Test(req)
+	if err != nil {
+		t.Fatalf("Request failed: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("Expected 200, got %d", resp.StatusCode)
+	}
+}
diff --git gin/go.mod gin/go.mod
index 20b6df784..d15f636e4 100644
--- gin/go.mod
+++ gin/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/gin
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.35.3
 	github.com/gin-gonic/gin v1.9.1
 	github.com/google/go-cmp v0.5.9
 )
diff --git go.mod go.mod
index 8693fe4b8..92d93747d 100644
--- go.mod
+++ go.mod
@@ -1,6 +1,6 @@
 module github.com/getsentry/sentry-go
 
-go 1.21
+go 1.22
 
 require (
 	github.com/go-errors/errors v1.4.2
diff --git interfaces.go interfaces.go
index 2884bbb14..2cec1cca9 100644
--- interfaces.go
+++ interfaces.go
@@ -13,6 +13,7 @@ import (
 	"time"
 
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/ratelimit"
 )
 
 const eventType = "event"
@@ -101,6 +102,7 @@ func (b *Breadcrumb) MarshalJSON() ([]byte, error) {
 	return json.Marshal((*breadcrumb)(b))
 }
 
+// Logger provides a chaining API for structured logging to Sentry.
 type Logger interface {
 	// Write implements the io.Writer interface. Currently, the [sentry.Hub] is
 	// context aware, in order to get the correct trace correlation. Using this
@@ -108,51 +110,47 @@ type Logger interface {
 	// Write it is recommended to create a NewLogger so that the associated context
 	// is passed correctly.
 	Write(p []byte) (n int, err error)
-	// Trace emits a [LogLevelTrace] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Trace(ctx context.Context, v ...interface{})
-	// Debug emits a [LogLevelDebug] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Debug(ctx context.Context, v ...interface{})
-	// Info emits a [LogLevelInfo] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Info(ctx context.Context, v ...interface{})
-	// Warn emits a [LogLevelWarn] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Warn(ctx context.Context, v ...interface{})
-	// Error emits a [LogLevelError] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Error(ctx context.Context, v ...interface{})
-	// Fatal emits a [LogLevelFatal] log to Sentry followed by a call to [os.Exit](1).
-	// Arguments are handled in the manner of [fmt.Print].
-	Fatal(ctx context.Context, v ...interface{})
-	// Panic emits a [LogLevelFatal] log to Sentry followed by a call to panic().
-	// Arguments are handled in the manner of [fmt.Print].
-	Panic(ctx context.Context, v ...interface{})
-
-	// Tracef emits a [LogLevelTrace] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Tracef(ctx context.Context, format string, v ...interface{})
-	// Debugf emits a [LogLevelDebug] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Debugf(ctx context.Context, format string, v ...interface{})
-	// Infof emits a [LogLevelInfo] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Infof(ctx context.Context, format string, v ...interface{})
-	// Warnf emits a [LogLevelWarn] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Warnf(ctx context.Context, format string, v ...interface{})
-	// Errorf emits a [LogLevelError] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Errorf(ctx context.Context, format string, v ...interface{})
-	// Fatalf emits a [LogLevelFatal] log to Sentry followed by a call to [os.Exit](1).
-	// Arguments are handled in the manner of [fmt.Printf].
-	Fatalf(ctx context.Context, format string, v ...interface{})
-	// Panicf emits a [LogLevelFatal] log to Sentry followed by a call to panic().
-	// Arguments are handled in the manner of [fmt.Printf].
-	Panicf(ctx context.Context, format string, v ...interface{})
-	// SetAttributes allows attaching parameters to the log message using the attribute API.
+
+	// SetAttributes allows attaching parameters to the logger using the attribute API.
+	// These attributes will be included in all subsequent log entries.
 	SetAttributes(...attribute.Builder)
+
+	// Trace defines the [sentry.LogLevel] for the log entry.
+	Trace() LogEntry
+	// Debug defines the [sentry.LogLevel] for the log entry.
+	Debug() LogEntry
+	// Info defines the [sentry.LogLevel] for the log entry.
+	Info() LogEntry
+	// Warn defines the [sentry.LogLevel] for the log entry.
+	Warn() LogEntry
+	// Error defines the [sentry.LogLevel] for the log entry.
+	Error() LogEntry
+	// Fatal defines the [sentry.LogLevel] for the log entry.
+	Fatal() LogEntry
+	// Panic defines the [sentry.LogLevel] for the log entry.
+	Panic() LogEntry
+	// GetCtx returns the [context.Context] set on the logger.
+	GetCtx() context.Context
+}
+
+// LogEntry defines the interface for a log entry that supports chaining attributes.
+type LogEntry interface {
+	// WithCtx creates a new LogEntry with the specified context without overwriting the previous one.
+	WithCtx(ctx context.Context) LogEntry
+	// String adds a string attribute to the LogEntry.
+	String(key, value string) LogEntry
+	// Int adds an int attribute to the LogEntry.
+	Int(key string, value int) LogEntry
+	// Int64 adds an int64 attribute to the LogEntry.
+	Int64(key string, value int64) LogEntry
+	// Float64 adds a float64 attribute to the LogEntry.
+	Float64(key string, value float64) LogEntry
+	// Bool adds a bool attribute to the LogEntry.
+	Bool(key string, value bool) LogEntry
+	// Emit emits the LogEntry with the provided arguments.
+	Emit(args ...interface{})
+	// Emitf emits the LogEntry using a format string and arguments.
+	Emitf(format string, args ...interface{})
 }
 
 // Attachment allows associating files with your events to aid in investigation.
@@ -595,16 +593,33 @@ func (e *Event) checkInMarshalJSON() ([]byte, error) {
 
 	if e.MonitorConfig != nil {
 		checkIn.MonitorConfig = &MonitorConfig{
-			Schedule:      e.MonitorConfig.Schedule,
-			CheckInMargin: e.MonitorConfig.CheckInMargin,
-			MaxRuntime:    e.MonitorConfig.MaxRuntime,
-			Timezone:      e.MonitorConfig.Timezone,
+			Schedule:              e.MonitorConfig.Schedule,
+			CheckInMargin:         e.MonitorConfig.CheckInMargin,
+			MaxRuntime:            e.MonitorConfig.MaxRuntime,
+			Timezone:              e.MonitorConfig.Timezone,
+			FailureIssueThreshold: e.MonitorConfig.FailureIssueThreshold,
+			RecoveryThreshold:     e.MonitorConfig.RecoveryThreshold,
 		}
 	}
 
 	return json.Marshal(checkIn)
 }
 
+func (e *Event) toCategory() ratelimit.Category {
+	switch e.Type {
+	case "":
+		return ratelimit.CategoryError
+	case transactionType:
+		return ratelimit.CategoryTransaction
+	case logEvent.Type:
+		return ratelimit.CategoryLog
+	case checkInType:
+		return ratelimit.CategoryMonitor
+	default:
+		return ratelimit.CategoryUnknown
+	}
+}
+
 // NewEvent creates a new Event.
 func NewEvent() *Event {
 	return &Event{
@@ -644,7 +659,17 @@ type Log struct {
 	Attributes map[string]Attribute `json:"attributes,omitempty"`
 }
 
+type AttrType string
+
+const (
+	AttributeInvalid AttrType = ""
+	AttributeBool    AttrType = "boolean"
+	AttributeInt     AttrType = "integer"
+	AttributeFloat   AttrType = "double"
+	AttributeString  AttrType = "string"
+)
+
 type Attribute struct {
-	Value any    `json:"value"`
-	Type  string `json:"type"`
+	Value any      `json:"value"`
+	Type  AttrType `json:"type"`
 }
diff --git interfaces_test.go interfaces_test.go
index c7f3195dc..c9eeb2a49 100644
--- interfaces_test.go
+++ interfaces_test.go
@@ -12,6 +12,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/getsentry/sentry-go/internal/ratelimit"
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -520,3 +521,26 @@ func TestStructSnapshots(t *testing.T) {
 		})
 	}
 }
+
+func TestEvent_ToCategory(t *testing.T) {
+	cases := []struct {
+		name      string
+		eventType string
+		want      ratelimit.Category
+	}{
+		{"error", "", ratelimit.CategoryError},
+		{"transaction", transactionType, ratelimit.CategoryTransaction},
+		{"log", logEvent.Type, ratelimit.CategoryLog},
+		{"checkin", checkInType, ratelimit.CategoryMonitor},
+		{"unknown", "foobar", ratelimit.CategoryUnknown},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := &Event{Type: tc.eventType}
+			got := e.toCategory()
+			if got != tc.want {
+				t.Errorf("Type %q: got %v, want %v", tc.eventType, got, tc.want)
+			}
+		})
+	}
+}
diff --git internal/ratelimit/category.go internal/ratelimit/category.go
index 2db76d2bf..96d9e21b9 100644
--- internal/ratelimit/category.go
+++ internal/ratelimit/category.go
@@ -14,12 +14,14 @@ import (
 // and, therefore, rate limited.
 type Category string
 
-// Known rate limit categories. As a special case, the CategoryAll applies to
-// all known payload types.
+// Known rate limit categories that are specified in rate limit headers.
 const (
-	CategoryAll         Category = ""
+	CategoryUnknown     Category = "unknown" // Unknown category should not get rate limited
+	CategoryAll         Category = ""        // Special category for empty categories (applies to all)
 	CategoryError       Category = "error"
 	CategoryTransaction Category = "transaction"
+	CategoryLog         Category = "log_item"
+	CategoryMonitor     Category = "monitor"
 )
 
 // knownCategories is the set of currently known categories. Other categories
@@ -28,18 +30,30 @@ var knownCategories = map[Category]struct{}{
 	CategoryAll:         {},
 	CategoryError:       {},
 	CategoryTransaction: {},
+	CategoryLog:         {},
+	CategoryMonitor:     {},
 }
 
 // String returns the category formatted for debugging.
 func (c Category) String() string {
-	if c == "" {
+	switch c {
+	case CategoryAll:
 		return "CategoryAll"
+	case CategoryError:
+		return "CategoryError"
+	case CategoryTransaction:
+		return "CategoryTransaction"
+	case CategoryLog:
+		return "CategoryLog"
+	case CategoryMonitor:
+		return "CategoryMonitor"
+	default:
+		// For unknown categories, use the original formatting logic
+		caser := cases.Title(language.English)
+		rv := "Category"
+		for _, w := range strings.Fields(string(c)) {
+			rv += caser.String(w)
+		}
+		return rv
 	}
-
-	caser := cases.Title(language.English)
-	rv := "Category"
-	for _, w := range strings.Fields(string(c)) {
-		rv += caser.String(w)
-	}
-	return rv
 }
diff --git internal/ratelimit/category_test.go internal/ratelimit/category_test.go
index 48af16d20..e0ec06b29 100644
--- internal/ratelimit/category_test.go
+++ internal/ratelimit/category_test.go
@@ -1,23 +1,61 @@
 package ratelimit
 
-import "testing"
+import (
+	"testing"
+)
 
-func TestCategoryString(t *testing.T) {
+func TestCategory_String(t *testing.T) {
 	tests := []struct {
-		Category Category
-		want     string
+		category Category
+		expected string
 	}{
 		{CategoryAll, "CategoryAll"},
 		{CategoryError, "CategoryError"},
 		{CategoryTransaction, "CategoryTransaction"},
-		{Category("unknown"), "CategoryUnknown"},
-		{Category("two words"), "CategoryTwoWords"},
+		{CategoryMonitor, "CategoryMonitor"},
+		{CategoryLog, "CategoryLog"},
+		{Category("custom type"), "CategoryCustomType"},
+		{Category("multi word type"), "CategoryMultiWordType"},
 	}
+
 	for _, tt := range tests {
-		t.Run(tt.want, func(t *testing.T) {
-			got := tt.Category.String()
-			if got != tt.want {
-				t.Errorf("got %q, want %q", got, tt.want)
+		t.Run(string(tt.category), func(t *testing.T) {
+			result := tt.category.String()
+			if result != tt.expected {
+				t.Errorf("Category(%q).String() = %q, want %q", tt.category, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestKnownCategories(t *testing.T) {
+	expectedCategories := []Category{
+		CategoryAll,
+		CategoryError,
+		CategoryTransaction,
+		CategoryMonitor,
+		CategoryLog,
+	}
+
+	for _, category := range expectedCategories {
+		t.Run(string(category), func(t *testing.T) {
+			if _, exists := knownCategories[category]; !exists {
+				t.Errorf("Category %q should be in knownCategories map", category)
+			}
+		})
+	}
+
+	// Test that unknown categories are not in the map
+	unknownCategories := []Category{
+		Category("unknown"),
+		Category("custom"),
+		Category("random"),
+	}
+
+	for _, category := range unknownCategories {
+		t.Run("unknown_"+string(category), func(t *testing.T) {
+			if _, exists := knownCategories[category]; exists {
+				t.Errorf("Unknown category %q should not be in knownCategories map", category)
 			}
 		})
 	}
diff --git internal/ratelimit/rate_limits_test.go internal/ratelimit/rate_limits_test.go
index 78cd64d2e..e81e89f0f 100644
--- internal/ratelimit/rate_limits_test.go
+++ internal/ratelimit/rate_limits_test.go
@@ -59,7 +59,9 @@ func TestParseXSentryRateLimits(t *testing.T) {
 		{
 			// ignore unknown categories
 			"8:error;default;unknown",
-			Map{CategoryError: Deadline(now.Add(8 * time.Second))},
+			Map{
+				CategoryError: Deadline(now.Add(8 * time.Second)),
+			},
 		},
 		{
 			"30:error:scope1, 20:error:scope2, 40:error",
diff --git iris/go.mod iris/go.mod
index 10bb024cc..2e52f04f5 100644
--- iris/go.mod
+++ iris/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/iris
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.35.3
 	github.com/google/go-cmp v0.5.9
 	github.com/kataras/iris/v12 v12.2.0
 )
diff --git log.go log.go
index 08be1b4e0..609ffc8b0 100644
--- log.go
+++ log.go
@@ -3,8 +3,10 @@ package sentry
 import (
 	"context"
 	"fmt"
+	"maps"
 	"os"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/getsentry/sentry-go/attribute"
@@ -30,17 +32,28 @@ const (
 	LogSeverityFatal   int = 21
 )
 
-var mapTypesToStr = map[attribute.Type]string{
-	attribute.INVALID: "",
-	attribute.BOOL:    "boolean",
-	attribute.INT64:   "integer",
-	attribute.FLOAT64: "double",
-	attribute.STRING:  "string",
+var mapTypesToStr = map[attribute.Type]AttrType{
+	attribute.INVALID: AttributeInvalid,
+	attribute.BOOL:    AttributeBool,
+	attribute.INT64:   AttributeInt,
+	attribute.FLOAT64: AttributeFloat,
+	attribute.STRING:  AttributeString,
 }
 
 type sentryLogger struct {
+	ctx        context.Context
 	client     *Client
 	attributes map[string]Attribute
+	mu         sync.RWMutex
+}
+
+type logEntry struct {
+	logger      *sentryLogger
+	ctx         context.Context
+	level       LogLevel
+	severity    int
+	attributes  map[string]Attribute
+	shouldPanic bool
 }
 
 // NewLogger returns a Logger that emits logs to Sentry. If logging is turned off, all logs get discarded.
@@ -53,7 +66,12 @@ func NewLogger(ctx context.Context) Logger {
 
 	client := hub.Client()
 	if client != nil && client.batchLogger != nil {
-		return &sentryLogger{client, make(map[string]Attribute)}
+		return &sentryLogger{
+			ctx:        ctx,
+			client:     client,
+			attributes: make(map[string]Attribute),
+			mu:         sync.RWMutex{},
+		}
 	}
 
 	DebugLogger.Println("fallback to noopLogger: enableLogs disabled")
@@ -63,11 +81,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(), LogLevelInfo, LogSeverityInfo, msg)
+	l.Info().Emit(msg)
 	return len(p), nil
 }
 
-func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, message string, args ...interface{}) {
+func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, message string, entryAttrs map[string]Attribute, args ...interface{}) {
 	if message == "" {
 		return
 	}
@@ -78,71 +96,77 @@ func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, me
 
 	var traceID TraceID
 	var spanID SpanID
+	var span *Span
+	var user User
 
-	span := hub.Scope().span
-	if span != nil {
-		traceID = span.TraceID
-		spanID = span.SpanID
-	} else {
-		traceID = hub.Scope().propagationContext.TraceID
+	scope := hub.Scope()
+	if scope != nil {
+		scope.mu.Lock()
+		span = scope.span
+		if span != nil {
+			traceID = span.TraceID
+			spanID = span.SpanID
+		} else {
+			traceID = scope.propagationContext.TraceID
+		}
+		user = scope.user
+		scope.mu.Unlock()
 	}
 
 	attrs := map[string]Attribute{}
 	if len(args) > 0 {
 		attrs["sentry.message.template"] = Attribute{
-			Value: message, Type: "string",
+			Value: message, Type: AttributeString,
 		}
 		for i, p := range args {
 			attrs[fmt.Sprintf("sentry.message.parameters.%d", i)] = Attribute{
-				Value: fmt.Sprint(p), Type: "string",
+				Value: fmt.Sprintf("%+v", p), Type: AttributeString,
 			}
 		}
 	}
 
-	// If `log` was called with SetAttributes, pass the attributes to attrs
-	if len(l.attributes) > 0 {
-		for k, v := range l.attributes {
-			attrs[k] = v
-		}
-		// flush attributes from logger after send
-		clear(l.attributes)
+	l.mu.RLock()
+	for k, v := range l.attributes {
+		attrs[k] = v
+	}
+	l.mu.RUnlock()
+
+	for k, v := range entryAttrs {
+		attrs[k] = v
 	}
 
 	// Set default attributes
 	if release := l.client.options.Release; release != "" {
-		attrs["sentry.release"] = Attribute{Value: release, Type: "string"}
+		attrs["sentry.release"] = Attribute{Value: release, Type: AttributeString}
 	}
 	if environment := l.client.options.Environment; environment != "" {
-		attrs["sentry.environment"] = Attribute{Value: environment, Type: "string"}
+		attrs["sentry.environment"] = Attribute{Value: environment, Type: AttributeString}
 	}
 	if serverName := l.client.options.ServerName; serverName != "" {
-		attrs["sentry.server.address"] = Attribute{Value: serverName, Type: "string"}
+		attrs["sentry.server.address"] = Attribute{Value: serverName, Type: AttributeString}
 	} else if serverAddr, err := os.Hostname(); err == nil {
-		attrs["sentry.server.address"] = Attribute{Value: serverAddr, Type: "string"}
+		attrs["sentry.server.address"] = Attribute{Value: serverAddr, Type: AttributeString}
 	}
-	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 !user.IsEmpty() {
+		if user.ID != "" {
+			attrs["user.id"] = Attribute{Value: user.ID, Type: AttributeString}
+		}
+		if user.Name != "" {
+			attrs["user.name"] = Attribute{Value: user.Name, Type: AttributeString}
+		}
+		if user.Email != "" {
+			attrs["user.email"] = Attribute{Value: user.Email, Type: AttributeString}
 		}
 	}
-	if spanID.String() != "0000000000000000" {
-		attrs["sentry.trace.parent_span_id"] = Attribute{Value: spanID.String(), Type: "string"}
+	if span != nil {
+		attrs["sentry.trace.parent_span_id"] = Attribute{Value: spanID.String(), Type: AttributeString}
 	}
 	if sdkIdentifier := l.client.sdkIdentifier; sdkIdentifier != "" {
-		attrs["sentry.sdk.name"] = Attribute{Value: sdkIdentifier, Type: "string"}
+		attrs["sentry.sdk.name"] = Attribute{Value: sdkIdentifier, Type: AttributeString}
 	}
 	if sdkVersion := l.client.sdkVersion; sdkVersion != "" {
-		attrs["sentry.sdk.version"] = Attribute{Value: sdkVersion, Type: "string"}
+		attrs["sentry.sdk.version"] = Attribute{Value: sdkVersion, Type: AttributeString}
 	}
 
 	log := &Log{
@@ -168,6 +192,9 @@ func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, me
 }
 
 func (l *sentryLogger) SetAttributes(attrs ...attribute.Builder) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+
 	for _, v := range attrs {
 		t, ok := mapTypesToStr[v.Value.Type()]
 		if !ok || t == "" {
@@ -182,49 +209,136 @@ func (l *sentryLogger) SetAttributes(attrs ...attribute.Builder) {
 	}
 }
 
-func (l *sentryLogger) Trace(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelTrace, LogSeverityTrace, fmt.Sprint(v...))
+func (l *sentryLogger) Trace() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelTrace,
+		severity:   LogSeverityTrace,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Debug(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelDebug, LogSeverityDebug, fmt.Sprint(v...))
+
+func (l *sentryLogger) Debug() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelDebug,
+		severity:   LogSeverityDebug,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Info(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelInfo, LogSeverityInfo, fmt.Sprint(v...))
+
+func (l *sentryLogger) Info() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelInfo,
+		severity:   LogSeverityInfo,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Warn(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelWarn, LogSeverityWarning, fmt.Sprint(v...))
+
+func (l *sentryLogger) Warn() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelWarn,
+		severity:   LogSeverityWarning,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Error(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelError, LogSeverityError, fmt.Sprint(v...))
+
+func (l *sentryLogger) Error() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelError,
+		severity:   LogSeverityError,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Fatal(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, fmt.Sprint(v...))
-	os.Exit(1)
+
+func (l *sentryLogger) Fatal() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelFatal,
+		severity:   LogSeverityFatal,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Panic(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, fmt.Sprint(v...))
-	panic(fmt.Sprint(v...))
+
+func (l *sentryLogger) Panic() LogEntry {
+	return &logEntry{
+		logger:      l,
+		ctx:         l.ctx,
+		level:       LogLevelFatal,
+		severity:    LogSeverityFatal,
+		attributes:  make(map[string]Attribute),
+		shouldPanic: true, // this should panic instead of exit
+	}
 }
-func (l *sentryLogger) Tracef(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelTrace, LogSeverityTrace, format, v...)
+
+func (l *sentryLogger) GetCtx() context.Context {
+	return l.ctx
 }
-func (l *sentryLogger) Debugf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelDebug, LogSeverityDebug, format, v...)
+
+func (e *logEntry) WithCtx(ctx context.Context) LogEntry {
+	return &logEntry{
+		logger:      e.logger,
+		ctx:         ctx,
+		level:       e.level,
+		severity:    e.severity,
+		attributes:  maps.Clone(e.attributes),
+		shouldPanic: e.shouldPanic,
+	}
 }
-func (l *sentryLogger) Infof(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelInfo, LogSeverityInfo, format, v...)
+
+func (e *logEntry) String(key, value string) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeString}
+	return e
 }
-func (l *sentryLogger) Warnf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelWarn, LogSeverityWarning, format, v...)
+
+func (e *logEntry) Int(key string, value int) LogEntry {
+	e.attributes[key] = Attribute{Value: int64(value), Type: AttributeInt}
+	return e
 }
-func (l *sentryLogger) Errorf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelError, LogSeverityError, format, v...)
+
+func (e *logEntry) Int64(key string, value int64) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeInt}
+	return e
 }
-func (l *sentryLogger) Fatalf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, format, v...)
-	os.Exit(1)
+
+func (e *logEntry) Float64(key string, value float64) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeFloat}
+	return e
+}
+
+func (e *logEntry) Bool(key string, value bool) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeBool}
+	return e
+}
+
+func (e *logEntry) Emit(args ...interface{}) {
+	e.logger.log(e.ctx, e.level, e.severity, fmt.Sprint(args...), e.attributes)
+
+	if e.level == LogLevelFatal {
+		if e.shouldPanic {
+			panic(fmt.Sprint(args...))
+		}
+		os.Exit(1)
+	}
 }
-func (l *sentryLogger) Panicf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, format, v...)
-	panic(fmt.Sprint(v...))
+
+func (e *logEntry) Emitf(format string, args ...interface{}) {
+	e.logger.log(e.ctx, e.level, e.severity, format, e.attributes, args...)
+
+	if e.level == LogLevelFatal {
+		if e.shouldPanic {
+			formattedMessage := fmt.Sprintf(format, args...)
+			panic(formattedMessage)
+		}
+		os.Exit(1)
+	}
 }
diff --git log_fallback.go log_fallback.go
index b9eb7061f..5dc058ec0 100644
--- log_fallback.go
+++ log_fallback.go
@@ -11,55 +11,94 @@ import (
 // Fallback, no-op logger if logging is disabled.
 type noopLogger struct{}
 
-func (*noopLogger) Trace(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelTrace)
+// noopLogEntry implements LogEntry for the no-op logger.
+type noopLogEntry struct {
+	level       LogLevel
+	shouldPanic bool
 }
-func (*noopLogger) Debug(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelDebug)
+
+func (n *noopLogEntry) WithCtx(_ context.Context) LogEntry {
+	return n
+}
+
+func (n *noopLogEntry) String(_, _ string) LogEntry {
+	return n
 }
-func (*noopLogger) Info(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+
+func (n *noopLogEntry) Int(_ string, _ int) LogEntry {
+	return n
 }
-func (*noopLogger) Warn(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelWarn)
+
+func (n *noopLogEntry) Int64(_ string, _ int64) LogEntry {
+	return n
 }
-func (*noopLogger) Error(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelError)
+
+func (n *noopLogEntry) Float64(_ string, _ float64) LogEntry {
+	return n
 }
-func (*noopLogger) Fatal(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	os.Exit(1)
+
+func (n *noopLogEntry) Bool(_ string, _ bool) LogEntry {
+	return n
+}
+
+func (n *noopLogEntry) Attributes(_ ...attribute.Builder) LogEntry {
+	return n
+}
+
+func (n *noopLogEntry) Emit(args ...interface{}) {
+	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", n.level)
+	if n.level == LogLevelFatal {
+		if n.shouldPanic {
+			panic(args)
+		}
+		os.Exit(1)
+	}
 }
-func (*noopLogger) Panic(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	panic(fmt.Sprintf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal))
+
+func (n *noopLogEntry) Emitf(message string, args ...interface{}) {
+	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", n.level)
+	if n.level == LogLevelFatal {
+		if n.shouldPanic {
+			panic(fmt.Sprintf(message, args...))
+		}
+		os.Exit(1)
+	}
 }
-func (*noopLogger) Tracef(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelTrace)
+
+func (n *noopLogger) GetCtx() context.Context { return context.Background() }
+
+func (*noopLogger) Trace() LogEntry {
+	return &noopLogEntry{level: LogLevelTrace}
 }
-func (*noopLogger) Debugf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelDebug)
+
+func (*noopLogger) Debug() LogEntry {
+	return &noopLogEntry{level: LogLevelDebug}
 }
-func (*noopLogger) Infof(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+
+func (*noopLogger) Info() LogEntry {
+	return &noopLogEntry{level: LogLevelInfo}
 }
-func (*noopLogger) Warnf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelWarn)
+
+func (*noopLogger) Warn() LogEntry {
+	return &noopLogEntry{level: LogLevelWarn}
 }
-func (*noopLogger) Errorf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelError)
+
+func (*noopLogger) Error() LogEntry {
+	return &noopLogEntry{level: LogLevelError}
 }
-func (*noopLogger) Fatalf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	os.Exit(1)
+
+func (*noopLogger) Fatal() LogEntry {
+	return &noopLogEntry{level: LogLevelFatal}
 }
-func (*noopLogger) Panicf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	panic(fmt.Sprintf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal))
+
+func (*noopLogger) Panic() LogEntry {
+	return &noopLogEntry{level: LogLevelFatal, shouldPanic: true}
 }
+
 func (*noopLogger) SetAttributes(...attribute.Builder) {
 	DebugLogger.Printf("No attributes attached. Turn on logging via EnableLogs")
 }
+
 func (*noopLogger) Write(_ []byte) (n int, err error) {
-	return 0, fmt.Errorf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+	return 0, fmt.Errorf("log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
 }
diff --git a/log_race_test.go b/log_race_test.go
new file mode 100644
index 000000000..3f41c2e7e
--- /dev/null
+++ log_race_test.go
@@ -0,0 +1,381 @@
+package sentry
+
+import (
+	"context"
+	"fmt"
+	"runtime"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/testutils"
+)
+
+const (
+	loggingGoroutines = 50
+	loggingIterations = 100
+)
+
+type CtxKey int
+
+func TestLoggingRaceConditions(t *testing.T) {
+	testCases := []struct {
+		name    string
+		timeout time.Duration
+		testFn  func(*testing.T)
+	}{
+		{
+			name:    "ConcurrentLoggerSetAttributes",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLoggerSetAttributes,
+		},
+		{
+			name:    "ConcurrentLogEmission",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLogEmission,
+		},
+		{
+			name:    "ConcurrentLogEntryOperations",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLogEntryOperations,
+		},
+		{
+			name:    "ConcurrentLoggerCreationAndUsage",
+			timeout: testutils.FlushTimeout(),
+			testFn:  testConcurrentLoggerCreationAndUsage,
+		},
+		{
+			name:    "ConcurrentLogWithSpanOperations",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLogWithSpanOperations,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			timeout := time.After(tc.timeout)
+			done := make(chan bool)
+
+			go func() {
+				defer func() {
+					if r := recover(); r != nil {
+						t.Errorf("Test %s panicked: %v", tc.name, r)
+					}
+					done <- true
+				}()
+				tc.testFn(t)
+			}()
+
+			select {
+			case <-timeout:
+				t.Fatalf("Test %s didn't finish in time (timeout: %v) - likely deadlock", tc.name, tc.timeout)
+			case <-done:
+				t.Logf("Test %s completed successfully", tc.name)
+			}
+		})
+	}
+}
+
+func testConcurrentLoggerSetAttributes(t *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	logger := NewLogger(ctx)
+	if _, ok := logger.(*noopLogger); ok {
+		t.Skip("Logging is disabled, skipping test")
+	}
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines/2; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations; j++ {
+				attrs := []attribute.Builder{
+					attribute.String(fmt.Sprintf("attr-string-%d", id), fmt.Sprintf("value-%d-%d", id, j)),
+					attribute.Int64(fmt.Sprintf("attr-int-%d", id), int64(id*j)),
+					attribute.Float64(fmt.Sprintf("attr-float-%d", id), float64(id)+float64(j)*0.1),
+					attribute.Bool(fmt.Sprintf("attr-bool-%d", id), j%2 == 0),
+				}
+				logger.SetAttributes(attrs...)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	for i := 0; i < loggingGoroutines/2; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations; j++ {
+				logger.Info().
+					String("worker_id", fmt.Sprintf("%d", id)).
+					Int("iteration", j).
+					Emit("Concurrent log message from worker", id)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLogEmission(_ *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			logger := NewLogger(ctx)
+			if _, ok := logger.(*noopLogger); ok {
+				return
+			}
+
+			for j := 0; j < loggingIterations/5; j++ {
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Trace().
+						String("operation", "trace").
+						Int("worker", id).
+						Emit("Trace message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Debug().
+						String("operation", "debug").
+						Int("worker", id).
+						Emit("Debug message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Info().
+						String("operation", "info").
+						Int("worker", id).
+						Emit("Info message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Warn().
+						String("operation", "warn").
+						Int("worker", id).
+						Emit("Warning message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Error().
+						String("operation", "error").
+						Int("worker", id).
+						Emit("Error message from worker %d", id)
+				}()
+
+				localWg.Wait()
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLogEntryOperations(t *testing.T) {
+	t.Skip("A single instance of a log entry should not be used concurrently")
+
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	logger := NewLogger(ctx)
+	if _, ok := logger.(*noopLogger); ok {
+		t.Skip("Logging is disabled, skipping test")
+	}
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations/10; j++ {
+				entry := logger.Info()
+
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					entry.String("worker_id", fmt.Sprintf("worker-%d", id))
+					entry.Int("iteration", j)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					entry.Float64("progress", float64(j)/float64(loggingIterations/10))
+					entry.Bool("is_test", true)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					newCtx := context.WithValue(ctx, CtxKey(2), fmt.Sprintf("test_value_%d", id))
+					_ = entry.WithCtx(newCtx)
+				}()
+
+				localWg.Wait()
+				entry.Emit("Concurrent entry operations test %d-%d", id, j)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLoggerCreationAndUsage(_ *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations/20; j++ {
+				ctx := context.WithValue(context.Background(), CtxKey(1), id)
+				ctx = SetHubOnContext(ctx, hub)
+
+				logger := NewLogger(ctx)
+				if _, ok := logger.(*noopLogger); ok {
+					continue
+				}
+
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.SetAttributes(
+						attribute.String("creation_worker", fmt.Sprintf("%d", id)),
+						attribute.Int64("creation_iteration", int64(j)),
+					)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Info().
+						String("immediate_usage", "true").
+						Emit("Logger created and used immediately by worker %d", id)
+				}()
+
+				localWg.Wait()
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLogWithSpanOperations(_ *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:              testDsn,
+		EnableLogs:       true,
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+		Transport:        &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations/20; j++ {
+				transaction := StartTransaction(ctx, fmt.Sprintf("log-transaction-%d", id))
+				span := transaction.StartChild(f,mt.Sprintf("log-span-%d", id))
+
+				spanCtx := span.Context()
+				logger := NewLogger(spanCtx)
+				if _, ok := logger.(*noopLogger); ok {
+					span.Finish()
+					transaction.Finish()
+					continue
+				}
+
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					span.SetTag("worker_id", fmt.Sprintf("%d", id))
+					span.SetData("iteration", j)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.SetAttributes(
+						attribute.String("span_operation", span.Op),
+						attribute.String("trace_id", span.TraceID.String()),
+					)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Info().
+						String("span_context", "active").
+						String("span_id", span.SpanID.String()).
+						Emit("Log within span from worker %d", id)
+				}()
+
+				localWg.Wait()
+				span.Finish()
+				transaction.Finish()
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
diff --git log_test.go log_test.go
index 62cb3d3b1..0fe9cc19c 100644
--- log_test.go
+++ log_test.go
@@ -3,14 +3,16 @@ package sentry
 import (
 	"bytes"
 	"context"
-	"log"
+	"io"
 	"strings"
 	"testing"
 	"time"
 
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/testutils"
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/stretchr/testify/assert"
 )
 
 const (
@@ -65,7 +67,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Trace level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Tracef(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Trace().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -85,7 +87,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Debug level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Debugf(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Debug().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -105,7 +107,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Info level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Infof(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Info().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -125,7 +127,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Warn level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Warnf(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Warn().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -145,7 +147,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Error level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Errorf(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Error().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -177,7 +179,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 			// invalid attribute should be dropped
 			l.SetAttributes(attribute.Builder{Key: "key.invalid", Value: attribute.Value{}})
 			tt.logFunc(ctx, l)
-			Flush(20 * time.Millisecond)
+			Flush(testutils.FlushTimeout())
 
 			opts := cmp.Options{
 				cmpopts.IgnoreFields(Log{}, "Timestamp"),
@@ -217,7 +219,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Trace level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Trace(ctx, msg)
+				l.Trace().WithCtx(ctx).Emit(msg)
 			},
 			args: "trace",
 			wantEvents: []Event{
@@ -237,7 +239,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Debug level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Debug(ctx, msg)
+				l.Debug().WithCtx(ctx).Emit(msg)
 			},
 			args: "debug",
 			wantEvents: []Event{
@@ -257,7 +259,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Info level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Info(ctx, msg)
+				l.Info().WithCtx(ctx).Emit(msg)
 			},
 			args: "info",
 			wantEvents: []Event{
@@ -277,7 +279,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Warn level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Warn(ctx, msg)
+				l.Warn().WithCtx(ctx).Emit(msg)
 			},
 			args: "warn",
 			wantEvents: []Event{
@@ -297,7 +299,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Error level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Error(ctx, msg)
+				l.Error().WithCtx(ctx).Emit(msg)
 			},
 			args: "error",
 			wantEvents: []Event{
@@ -321,7 +323,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 			ctx, mockTransport := setupMockTransport()
 			l := NewLogger(ctx)
 			tt.logFunc(ctx, l, tt.args)
-			Flush(20 * time.Millisecond)
+			Flush(testutils.FlushTimeout())
 
 			opts := cmp.Options{
 				cmpopts.IgnoreFields(Log{}, "Timestamp"),
@@ -354,7 +356,7 @@ func Test_sentryLogger_Panic(t *testing.T) {
 		}()
 		ctx, _ := setupMockTransport()
 		l := NewLogger(ctx)
-		l.Panic(context.Background(), "panic message") // This should panic
+		l.Panic().Emit("panic message") // This should panic
 	})
 
 	t.Run("logger.Panicf", func(t *testing.T) {
@@ -367,7 +369,7 @@ func Test_sentryLogger_Panic(t *testing.T) {
 		}()
 		ctx, _ := setupMockTransport()
 		l := NewLogger(ctx)
-		l.Panicf(context.Background(), "panic message") // This should panic
+		l.Panic().Emitf("panic message") // This should panic
 	})
 }
 
@@ -400,7 +402,7 @@ func Test_sentryLogger_Write(t *testing.T) {
 	if n != len(msg) {
 		t.Errorf("Write returned wrong byte count: got %d, want %d", n, len(msg))
 	}
-	Flush(20 * time.Millisecond)
+	Flush(testutils.FlushTimeout())
 
 	gotEvents := mockTransport.Events()
 	if len(gotEvents) != 1 {
@@ -422,11 +424,11 @@ func Test_sentryLogger_FlushAttributesAfterSend(t *testing.T) {
 	ctx, mockTransport := setupMockTransport()
 	l := NewLogger(ctx)
 	l.SetAttributes(attribute.Int("int", 42))
-	l.Info(ctx, msg)
+	l.Info().WithCtx(ctx).Emit(msg)
 
 	l.SetAttributes(attribute.String("string", "some str"))
-	l.Warn(ctx, msg)
-	Flush(20 * time.Millisecond)
+	l.Warn().WithCtx(ctx).Emit(msg)
+	Flush(testutils.FlushTimeout())
 
 	gotEvents := mockTransport.Events()
 	if len(gotEvents) != 1 {
@@ -434,17 +436,43 @@ func Test_sentryLogger_FlushAttributesAfterSend(t *testing.T) {
 	}
 	event := gotEvents[0]
 	assertEqual(t, event.Logs[0].Attributes["int"].Value, int64(42))
-	if _, ok := event.Logs[1].Attributes["int"]; ok {
-		t.Fatalf("expected key to not exist")
+	if _, ok := event.Logs[1].Attributes["int"]; !ok {
+		t.Fatalf("expected key to exist")
 	}
 	assertEqual(t, event.Logs[1].Attributes["string"].Value, "some str")
 }
 
+func TestSentryLogger_LogEntryAttributes(t *testing.T) {
+	msg := []byte("something")
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+	l.Info().WithCtx(ctx).
+		String("key.string", "some str").
+		Int("key.int", 42).
+		Int64("key.int64", 17).
+		Float64("key.float", 42.2).
+		Bool("key.bool", true).
+		Emit(msg)
+
+	Flush(20 * time.Millisecond)
+
+	gotEvents := mockTransport.Events()
+	if len(gotEvents) != 1 {
+		t.Fatalf("expected 1 event, got %d", len(gotEvents))
+	}
+	event := gotEvents[0]
+	assertEqual(t, event.Logs[0].Attributes["key.int"].Value, int64(42))
+	assertEqual(t, event.Logs[0].Attributes["key.int64"].Value, int64(17))
+	assertEqual(t, event.Logs[0].Attributes["key.float"].Value, 42.2)
+	assertEqual(t, event.Logs[0].Attributes["key.bool"].Value, true)
+	assertEqual(t, event.Logs[0].Attributes["key.string"].Value, "some str")
+}
+
 func Test_batchLogger_Flush(t *testing.T) {
 	ctx, mockTransport := setupMockTransport()
 	l := NewLogger(context.Background())
-	l.Info(ctx, "context done log")
-	Flush(20 * time.Millisecond)
+	l.Info().WithCtx(ctx).Emit("context done log")
+	Flush(testutils.FlushTimeout())
 
 	events := mockTransport.Events()
 	if len(events) != 1 {
@@ -455,9 +483,9 @@ 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")
+	l.Info().WithCtx(ctx).Emit("context done log")
 
-	cancelCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+	cancelCtx, cancel := context.WithTimeout(context.Background(), testutils.FlushTimeout())
 	FlushWithContext(cancelCtx)
 	defer cancel()
 
@@ -467,6 +495,88 @@ func Test_batchLogger_FlushWithContext(t *testing.T) {
 	}
 }
 
+func Test_batchLogger_FlushMultipleTimes(t *testing.T) {
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+
+	for i := 0; i < 5; i++ {
+		l.Info().WithCtx(ctx).Emit("test")
+	}
+
+	Flush(testutils.FlushTimeout())
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Logf("Got %d events instead of 1", len(events))
+		for i, event := range events {
+			t.Logf("Event %d: %d logs", i, len(event.Logs))
+		}
+		t.Fatalf("expected 1 event after first flush, got %d", len(events))
+	}
+	if len(events[0].Logs) != 5 {
+		t.Fatalf("expected 5 logs in first batch, got %d", len(events[0].Logs))
+	}
+
+	mockTransport.events = nil
+
+	for i := 0; i < 3; i++ {
+		l.Info().WithCtx(ctx).Emit("test")
+	}
+
+	Flush(testutils.FlushTimeout())
+	events = mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event after second flush, got %d", len(events))
+	}
+	if len(events[0].Logs) != 3 {
+		t.Fatalf("expected 3 logs in second batch, got %d", len(events[0].Logs))
+	}
+
+	mockTransport.events = nil
+
+	Flush(testutils.FlushTimeout())
+	events = mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected 0 events after third flush with no logs, got %d", len(events))
+	}
+}
+
+func Test_batchLogger_Shutdown(t *testing.T) {
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+	for i := 0; i < 3; i++ {
+		l.Info().WithCtx(ctx).Emit("test")
+	}
+
+	hub := GetHubFromContext(ctx)
+	hub.Client().batchLogger.Shutdown()
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event after shutdown, got %d", len(events))
+	}
+	if len(events[0].Logs) != 3 {
+		t.Fatalf("expected 3 logs in shutdown batch, got %d", len(events[0].Logs))
+	}
+
+	mockTransport.events = nil
+
+	// Test that shutdown can be called multiple times safely
+	hub.Client().batchLogger.Shutdown()
+	hub.Client().batchLogger.Shutdown()
+
+	events = mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected 0 events after multiple shutdowns, got %d", len(events))
+	}
+
+	Flush(testutils.FlushTimeout())
+	events = mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected 0 events after flush on shutdown logger, got %d", len(events))
+	}
+}
+
 func Test_sentryLogger_BeforeSendLog(t *testing.T) {
 	ctx := context.Background()
 	mockTransport := &MockTransport{}
@@ -491,8 +601,8 @@ func Test_sentryLogger_BeforeSendLog(t *testing.T) {
 	ctx = SetHubOnContext(ctx, hub)
 
 	l := NewLogger(ctx)
-	l.Info(ctx, "context done log")
-	Flush(20 * time.Millisecond)
+	l.Info().WithCtx(ctx).Emit("context done log")
+	Flush(testutils.FlushTimeout())
 
 	events := mockTransport.Events()
 	if len(events) != 0 {
@@ -504,7 +614,7 @@ func Test_Logger_ExceedBatchSize(t *testing.T) {
 	ctx, mockTransport := setupMockTransport()
 	l := NewLogger(context.Background())
 	for i := 0; i < 100; i++ {
-		l.Info(ctx, "test")
+		l.Info().WithCtx(ctx).Emit("test")
 	}
 
 	// sleep to wait for events to propagate
@@ -526,9 +636,9 @@ func Test_sentryLogger_TracePropagationWithTransaction(t *testing.T) {
 	expectedSpanID := txn.SpanID
 
 	logger := NewLogger(txn.Context())
-	logger.Info(txn.Context(), "message with tracing")
+	logger.Info().WithCtx(txn.Context()).Emit("message with tracing")
 
-	Flush(20 * time.Millisecond)
+	Flush(testutils.FlushTimeout())
 
 	events := mockTransport.Events()
 	if len(events) != 1 {
@@ -552,54 +662,48 @@ func Test_sentryLogger_TracePropagationWithTransaction(t *testing.T) {
 }
 
 func TestSentryLogger_DebugLogging(t *testing.T) {
-	var buf bytes.Buffer
-	debugLogger := log.New(&buf, "", 0)
-	originalLogger := DebugLogger
-	DebugLogger = debugLogger
-	defer func() {
-		DebugLogger = originalLogger
-	}()
-
 	tests := []struct {
-		name          string
-		debugEnabled  bool
-		message       string
-		expectedDebug string
+		name       string
+		enableLogs bool
+		message    string
 	}{
 		{
-			name:          "Debug enabled",
-			debugEnabled:  true,
-			message:       "test message",
-			expectedDebug: "test message\n",
+			name:       "Debug enabled",
+			enableLogs: true,
+			message:    "test message",
 		},
 		{
-			name:          "Debug disabled",
-			debugEnabled:  false,
-			message:       "test message",
-			expectedDebug: "",
+			name:       "Debug disabled",
+			enableLogs: false,
+			message:    "test message",
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			buf.Reset()
+			var buf bytes.Buffer
+
 			ctx := context.Background()
 			mockClient, _ := NewClient(ClientOptions{
 				Transport:  &MockTransport{},
-				EnableLogs: true,
-				Debug:      tt.debugEnabled,
+				EnableLogs: tt.enableLogs,
+				Debug:      true,
 			})
 			hub := CurrentHub()
 			hub.BindClient(mockClient)
 
+			// set the debug logger output after NewClient, so that it doesn't change.
+			DebugLogger.SetOutput(&buf)
+			defer DebugLogger.SetOutput(io.Discard)
+
 			logger := NewLogger(ctx)
-			logger.Info(ctx, tt.message)
+			logger.Info().WithCtx(ctx).Emit(tt.message)
 
 			got := buf.String()
-			if !tt.debugEnabled {
-				assertEqual(t, len(got), 0)
-			} else if strings.Contains(got, tt.expectedDebug) {
-				t.Errorf("Debug output = %q, want %q", got, tt.expectedDebug)
+			if tt.enableLogs {
+				assertEqual(t, strings.Contains(got, "test message"), true)
+			} else {
+				assertEqual(t, strings.Contains(got, "test message"), false)
 			}
 		})
 	}
@@ -632,7 +736,7 @@ func Test_sentryLogger_UserAttributes(t *testing.T) {
 	ctx = SetHubOnContext(ctx, hub)
 
 	l := NewLogger(ctx)
-	l.Info(ctx, "test message with PII")
+	l.Info().WithCtx(ctx).Emit("test message with PII")
 	Flush(20 * time.Millisecond)
 
 	events := mockTransport.Events()
@@ -666,3 +770,21 @@ func Test_sentryLogger_UserAttributes(t *testing.T) {
 		t.Errorf("unexpected user.email: got %v, want %v", val.Value, "[email protected]")
 	}
 }
+
+func TestLogEntryWithCtx_ShouldCopy(t *testing.T) {
+	ctx, _ := setupMockTransport()
+	l := NewLogger(ctx)
+
+	// using WithCtx should return a new log entry with the new ctx
+	newCtx := context.Background()
+	lentry := l.Info().String("key", "value").(*logEntry)
+	newlentry := lentry.WithCtx(newCtx).(*logEntry)
+	lentry.String("key2", "value")
+
+	assert.Equal(t, lentry.ctx, ctx)
+	assert.Equal(t, newlentry.ctx, newCtx)
+	assert.Contains(t, lentry.attributes, "key")
+	assert.Contains(t, lentry.attributes, "key2")
+	assert.Contains(t, newlentry.attributes, "key")
+	assert.NotContains(t, newlentry.attributes, "key2")
+}
diff --git logrus/go.mod logrus/go.mod
index fd5ab5f27..0616487db 100644
--- logrus/go.mod
+++ logrus/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/logrus
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.35.3
 	github.com/google/go-cmp v0.6.0
 	github.com/pkg/errors v0.9.1
 	github.com/sirupsen/logrus v1.9.3
diff --git logrus/logrusentry.go logrus/logrusentry.go
index 3dd08bff9..424580dd9 100644
--- logrus/logrusentry.go
+++ logrus/logrusentry.go
@@ -43,6 +43,8 @@ const (
 	// These fields are simply omitted, as they are duplicated by the Sentry SDK.
 	FieldGoVersion = "go_version"
 	FieldMaxProcs  = "go_maxprocs"
+
+	LogrusOrigin = "auto.logger.logrus"
 )
 
 var levelMap = map[logrus.Level]sentry.Level{
@@ -289,70 +291,67 @@ func (h *logHook) key(key string) string {
 	return key
 }
 
+func logrusFieldToLogEntry(logEntry sentry.LogEntry, key string, value interface{}) sentry.LogEntry {
+	switch val := value.(type) {
+	case int8:
+		return logEntry.Int64(key, int64(val))
+	case int16:
+		return logEntry.Int64(key, int64(val))
+	case int32:
+		return logEntry.Int64(key, int64(val))
+	case int64:
+		return logEntry.Int64(key, val)
+	case int:
+		return logEntry.Int64(key, int64(val))
+	case uint, uint8, uint16, uint32, uint64:
+		uval := reflect.ValueOf(val).Convert(reflect.TypeOf(uint64(0))).Uint()
+		if uval <= math.MaxInt64 {
+			return logEntry.Int64(key, int64(uval))
+		} else {
+			// For values larger than int64 can handle, we use string
+			return logEntry.String(key, strconv.FormatUint(uval, 10))
+		}
+	case string:
+		return logEntry.String(key, val)
+	case float32:
+		return logEntry.Float64(key, float64(val))
+	case float64:
+		return logEntry.Float64(key, val)
+	case bool:
+		return logEntry.Bool(key, val)
+	case time.Time:
+		return logEntry.String(key, val.Format(time.RFC3339))
+	case time.Duration:
+		return logEntry.String(key, val.String())
+	default:
+		// Fallback to string conversion for unknown types
+		return logEntry.String(key, fmt.Sprint(value))
+	}
+}
+
 func (h *logHook) Fire(entry *logrus.Entry) error {
 	ctx := context.Background()
 	if entry.Context != nil {
 		ctx = entry.Context
 	}
 
-	for k, v := range entry.Data {
-		// Skip specific fields that might be handled separately
-		if k == h.key(FieldRequest) || k == h.key(FieldUser) ||
-			k == h.key(FieldFingerprint) || k == FieldGoVersion ||
-			k == FieldMaxProcs || k == logrus.ErrorKey {
-			continue
-		}
-
-		switch val := v.(type) {
-		case int8:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int16:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int32:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int64:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int:
-			h.logger.SetAttributes(attribute.Int(k, val))
-		case uint, uint8, uint16, uint32, uint64:
-			uval := reflect.ValueOf(val).Convert(reflect.TypeOf(uint64(0))).Uint()
-			if uval <= math.MaxInt64 {
-				h.logger.SetAttributes(attribute.Int64(k, int64(uval)))
-			} else {
-				// For values larger than int64 can handle, we are using string.
-				h.logger.SetAttributes(attribute.String(k, strconv.FormatUint(uval, 10)))
-			}
-		case string:
-			h.logger.SetAttributes(attribute.String(k, val))
-		case float32:
-			h.logger.SetAttributes(attribute.Float64(k, float64(val)))
-		case float64:
-			h.logger.SetAttributes(attribute.Float64(k, val))
-		case bool:
-			h.logger.SetAttributes(attribute.Bool(k, val))
-		default:
-			// can't drop argument, fallback to string conversion
-			h.logger.SetAttributes(attribute.String(k, fmt.Sprint(v)))
-		}
-	}
-
-	h.logger.SetAttributes(attribute.String("sentry.origin", "auto.logger.logrus"))
-
+	// Create the base log entry for the appropriate level
+	var logEntry sentry.LogEntry
 	switch entry.Level {
 	case logrus.TraceLevel:
-		h.logger.Trace(ctx, entry.Message)
+		logEntry = h.logger.Trace().WithCtx(ctx)
 	case logrus.DebugLevel:
-		h.logger.Debug(ctx, entry.Message)
+		logEntry = h.logger.Debug().WithCtx(ctx)
 	case logrus.InfoLevel:
-		h.logger.Info(ctx, entry.Message)
+		logEntry = h.logger.Info().WithCtx(ctx)
 	case logrus.WarnLevel:
-		h.logger.Warn(ctx, entry.Message)
+		logEntry = h.logger.Warn().WithCtx(ctx)
 	case logrus.ErrorLevel:
-		h.logger.Error(ctx, entry.Message)
+		logEntry = h.logger.Error().WithCtx(ctx)
 	case logrus.FatalLevel:
-		h.logger.Fatal(ctx, entry.Message)
+		logEntry = h.logger.Fatal().WithCtx(ctx)
 	case logrus.PanicLevel:
-		h.logger.Panic(ctx, entry.Message)
+		logEntry = h.logger.Panic().WithCtx(ctx)
 	default:
 		sentry.DebugLogger.Printf("Invalid logrus logging level: %v. Dropping log.", entry.Level)
 		if h.fallback != nil {
@@ -360,6 +359,21 @@ func (h *logHook) Fire(entry *logrus.Entry) error {
 		}
 		return errors.New("invalid log level")
 	}
+
+	// Add all the fields as attributes to this specific log entry
+	for k, v := range entry.Data {
+		// Skip specific fields that might be handled separately
+		if k == h.key(FieldRequest) || k == h.key(FieldUser) ||
+			k == h.key(FieldFingerprint) || k == FieldGoVersion ||
+			k == FieldMaxProcs || k == logrus.ErrorKey {
+			continue
+		}
+
+		logEntry = logrusFieldToLogEntry(logEntry, k, v)
+	}
+
+	// Emit the log entry with the message
+	logEntry.Emit(entry.Message)
 	return nil
 }
 
@@ -395,8 +409,11 @@ func NewLogHook(levels []logrus.Level, opts sentry.ClientOptions) (Hook, error)
 func NewLogHookFromClient(levels []logrus.Level, client *sentry.Client) Hook {
 	defaultHub := sentry.NewHub(client, sentry.NewScope())
 	ctx := sentry.SetHubOnContext(context.Background(), defaultHub)
+	logger := sentry.NewLogger(ctx)
+	logger.SetAttributes(attribute.String("sentry.origin", LogrusOrigin))
+
 	return &logHook{
-		logger: sentry.NewLogger(ctx),
+		logger: logger,
 		levels: levels,
 		hubProvider: func() *sentry.Hub {
 			// Default to using the same hub if no specific provider is set
diff --git logrus/logrusentry_test.go logrus/logrusentry_test.go
index 7faa7506d..f26122d56 100644
--- logrus/logrusentry_test.go
+++ logrus/logrusentry_test.go
@@ -657,8 +657,6 @@ func TestLogHookFire(t *testing.T) {
 				Context: context.Background(),
 			}
 
-			// Since we're using a real logger, which is hard to verify,
-			// we're just checking that Fire doesn't error
 			err := logHook.Fire(entry)
 			assert.NoError(t, err)
 		})
diff --git marshal_test.go marshal_test.go
index ecf1d8134..ed290d111 100644
--- marshal_test.go
+++ marshal_test.go
@@ -180,6 +180,28 @@ func TestCheckInEventMarshalJSON(t *testing.T) {
 				Timezone:      "America/Los_Angeles",
 			},
 		},
+		{
+			Release:     "1.0.0",
+			Environment: "dev",
+			Type:        checkInType,
+			CheckIn: &CheckIn{
+				ID:          "c2f0ce1334c74564bf6631f6161173f5",
+				MonitorSlug: "my-monitor",
+				Status:      "ok",
+				Duration:    time.Second * 10,
+			},
+			MonitorConfig: &MonitorConfig{
+				Schedule: &crontabSchedule{
+					Type:  "crontab",
+					Value: "* * * * *",
+				},
+				CheckInMargin:         2,
+				MaxRuntime:            1,
+				Timezone:              "UTC",
+				FailureIssueThreshold: 5,
+				RecoveryThreshold:     1,
+			},
+		},
 	}
 
 	var buf bytes.Buffer
diff --git negroni/go.mod negroni/go.mod
index 7df60a45d..ded69659f 100644
--- negroni/go.mod
+++ negroni/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/negroni
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.35.3
 	github.com/google/go-cmp v0.5.9
 	github.com/urfave/negroni/v3 v3.1.1
 )
diff --git negroni/go.sum negroni/go.sum
index 17be47367..35d0b3a32 100644
--- negroni/go.sum
+++ negroni/go.sum
@@ -10,8 +10,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw=
 github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
diff --git otel/go.mod otel/go.mod
index 1b9d6ce68..72f9dde20 100644
--- otel/go.mod
+++ otel/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/otel
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.35.3
 	github.com/google/go-cmp v0.5.9
 	github.com/stretchr/testify v1.8.4
 	go.opentelemetry.io/otel v1.11.0
diff --git otel/propagator_test.go otel/propagator_test.go
index d4d537941..5c0d4b682 100644
--- otel/propagator_test.go
+++ otel/propagator_test.go
@@ -41,9 +41,9 @@ func createTransactionAndMaybeSpan(transactionContext transactionTestContext, wi
 		// we "swap" span IDs from the transaction and the child span.
 		transaction.SpanID = span.SpanID
 		span.SpanID = SpanIDFromHex(transactionContext.spanID)
-		sentrySpanMap.Set(trace.SpanID(span.SpanID), span)
+		sentrySpanMap.Set(trace.SpanID(span.SpanID), span, trace.SpanID{})
 	}
-	sentrySpanMap.Set(trace.SpanID(transaction.SpanID), transaction)
+	sentrySpanMap.Set(trace.SpanID(transaction.SpanID), transaction, trace.SpanID{})
 
 	otelContext := trace.SpanContextConfig{
 		TraceID:    otelTraceIDFromHex(transactionContext.traceID),
diff --git otel/span_map.go otel/span_map.go
index 5340ff30a..52ac7e539 100644
--- otel/span_map.go
+++ otel/span_map.go
@@ -7,37 +7,111 @@ import (
 	otelTrace "go.opentelemetry.io/otel/trace"
 )
 
+type spanInfo struct {
+	span     *sentry.Span
+	finished bool
+	children map[otelTrace.SpanID]struct{}
+	parentID otelTrace.SpanID
+}
+
 // SentrySpanMap is a mapping between OpenTelemetry spans and Sentry spans.
 // It helps Sentry span processor and propagator to keep track of unfinished
 // Sentry spans and to establish parent-child links between spans.
 type SentrySpanMap struct {
-	spanMap map[otelTrace.SpanID]*sentry.Span
+	spanMap map[otelTrace.SpanID]*spanInfo
 	mu      sync.RWMutex
 }
 
 func (ssm *SentrySpanMap) Get(otelSpandID otelTrace.SpanID) (*sentry.Span, bool) {
 	ssm.mu.RLock()
 	defer ssm.mu.RUnlock()
-	span, ok := ssm.spanMap[otelSpandID]
-	return span, ok
+	info, ok := ssm.spanMap[otelSpandID]
+	if !ok {
+		return nil, false
+	}
+	return info.span, true
 }
 
-func (ssm *SentrySpanMap) Set(otelSpandID otelTrace.SpanID, sentrySpan *sentry.Span) {
+func (ssm *SentrySpanMap) Set(otelSpandID otelTrace.SpanID, sentrySpan *sentry.Span, parentID otelTrace.SpanID) {
 	ssm.mu.Lock()
 	defer ssm.mu.Unlock()
-	ssm.spanMap[otelSpandID] = sentrySpan
+
+	info := &spanInfo{
+		span:     sentrySpan,
+		finished: false,
+		children: make(map[otelTrace.SpanID]struct{}),
+		parentID: parentID,
+	}
+	ssm.spanMap[otelSpandID] = info
+
+	if parentID != (otelTrace.SpanID{}) {
+		if parentInfo, ok := ssm.spanMap[parentID]; ok {
+			parentInfo.children[otelSpandID] = struct{}{}
+		}
+	}
 }
 
-func (ssm *SentrySpanMap) Delete(otelSpandID otelTrace.SpanID) {
+func (ssm *SentrySpanMap) MarkFinished(otelSpandID otelTrace.SpanID) {
 	ssm.mu.Lock()
 	defer ssm.mu.Unlock()
-	delete(ssm.spanMap, otelSpandID)
+
+	info, ok := ssm.spanMap[otelSpandID]
+	if !ok {
+		return
+	}
+
+	info.finished = true
+	ssm.tryCleanupSpan(otelSpandID)
+}
+
+// tryCleanupSpan deletes a parent and all children only if the whole subtree is marked finished.
+// Must be called with lock held.
+func (ssm *SentrySpanMap) tryCleanupSpan(spanID otelTrace.SpanID) {
+	info, ok := ssm.spanMap[spanID]
+	if !ok || !info.finished {
+		return
+	}
+
+	if !info.span.IsTransaction() {
+		parentID := info.parentID
+		if parentID != (otelTrace.SpanID{}) {
+			if parentInfo, parentExists := ssm.spanMap[parentID]; parentExists && !parentInfo.finished {
+				return
+			}
+		}
+	}
+
+	// We need to have a lookup first to see if every child is marked as finished to actually cleanup everything.
+	// There probably is a better way to do this
+	for childID := range info.children {
+		if childInfo, exists := ssm.spanMap[childID]; exists && !childInfo.finished {
+			return
+		}
+	}
+
+	parentID := info.parentID
+	if parentID != (otelTrace.SpanID{}) {
+		if parentInfo, ok := ssm.spanMap[parentID]; ok {
+			delete(parentInfo.children, spanID)
+		}
+	}
+
+	for childID := range info.children {
+		if childInfo, exists := ssm.spanMap[childID]; exists && childInfo.finished {
+			ssm.tryCleanupSpan(childID)
+		}
+	}
+
+	delete(ssm.spanMap, spanID)
+	if parentID != (otelTrace.SpanID{}) {
+		ssm.tryCleanupSpan(parentID)
+	}
 }
 
 func (ssm *SentrySpanMap) Clear() {
 	ssm.mu.Lock()
 	defer ssm.mu.Unlock()
-	ssm.spanMap = make(map[otelTrace.SpanID]*sentry.Span)
+	ssm.spanMap = make(map[otelTrace.SpanID]*spanInfo)
 }
 
 func (ssm *SentrySpanMap) Len() int {
@@ -46,4 +120,4 @@ func (ssm *SentrySpanMap) Len() int {
 	return len(ssm.spanMap)
 }
 
-var sentrySpanMap = SentrySpanMap{spanMap: make(map[otelTrace.SpanID]*sentry.Span)}
+var sentrySpanMap = SentrySpanMap{spanMap: make(map[otelTrace.SpanID]*spanInfo)}
diff --git otel/span_processor.go otel/span_processor.go
index 263d77280..ba2f67a16 100644
--- otel/span_processor.go
+++ otel/span_processor.go
@@ -21,7 +21,7 @@ func NewSentrySpanProcessor() otelSdkTrace.SpanProcessor {
 		return sentrySpanProcessorInstance
 	}
 	sentry.AddGlobalEventProcessor(linkTraceContextToErrorEvent)
-	sentrySpanProcessorInstance := &sentrySpanProcessor{}
+	sentrySpanProcessorInstance = &sentrySpanProcessor{}
 	return sentrySpanProcessorInstance
 }
 
@@ -42,7 +42,7 @@ func (ssp *sentrySpanProcessor) OnStart(parent context.Context, s otelSdkTrace.R
 		span.SpanID = sentry.SpanID(otelSpanID)
 		span.StartTime = s.StartTime()
 
-		sentrySpanMap.Set(otelSpanID, span)
+		sentrySpanMap.Set(otelSpanID, span, otelParentSpanID)
 	} else {
 		traceParentContext := getTraceParentContext(parent)
 		transaction := sentry.StartTransaction(
@@ -58,7 +58,7 @@ func (ssp *sentrySpanProcessor) OnStart(parent context.Context, s otelSdkTrace.R
 			transaction.SetDynamicSamplingContext(dynamicSamplingContext)
 		}
 
-		sentrySpanMap.Set(otelSpanID, transaction)
+		sentrySpanMap.Set(otelSpanID, transaction, otelParentSpanID)
 	}
 }
 
@@ -71,7 +71,7 @@ func (ssp *sentrySpanProcessor) OnEnd(s otelSdkTrace.ReadOnlySpan) {
 	}
 
 	if utils.IsSentryRequestSpan(sentrySpan.Context(), s) {
-		sentrySpanMap.Delete(otelSpanId)
+		sentrySpanMap.MarkFinished(otelSpanId)
 		return
 	}
 
@@ -84,7 +84,7 @@ func (ssp *sentrySpanProcessor) OnEnd(s otelSdkTrace.ReadOnlySpan) {
 	sentrySpan.EndTime = s.EndTime()
 	sentrySpan.Finish()
 
-	sentrySpanMap.Delete(otelSpanId)
+	sentrySpanMap.MarkFinished(otelSpanId)
 }
 
 // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/sdk.md#shutdown-1
diff --git otel/span_processor_test.go otel/span_processor_test.go
index 9d6013f00..23d4a31df 100644
--- otel/span_processor_test.go
+++ otel/span_processor_test.go
@@ -75,7 +75,7 @@ func TestSpanProcessorShutdown(t *testing.T) {
 
 	assertEqual(t, sentrySpanMap.Len(), 1)
 
-	spanProcessor.Shutdown(ctx)
+	_ = spanProcessor.Shutdown(ctx)
 
 	// The span map should be empty
 	assertEqual(t, sentrySpanMap.Len(), 0)
@@ -399,3 +399,59 @@ func TestParseSpanAttributesHttpServer(t *testing.T) {
 	assertEqual(t, sentrySpan.Op, "http.server")
 	assertEqual(t, sentrySpan.Source, sentry.TransactionSource(""))
 }
+
+func TestSpanBecomesChildOfFinishedSpan(t *testing.T) {
+	_, _, tracer := setupSpanProcessorTest()
+	ctx, otelRootSpan := tracer.Start(
+		emptyContextWithSentry(),
+		"rootSpan",
+	)
+	sentryTransaction, _ := sentrySpanMap.Get(otelRootSpan.SpanContext().SpanID())
+
+	ctx, childSpan1 := tracer.Start(
+		ctx,
+		"span name 1",
+	)
+	sentrySpan1, _ := sentrySpanMap.Get(childSpan1.SpanContext().SpanID())
+	childSpan1.End()
+
+	_, childSpan2 := tracer.Start(
+		ctx,
+		"span name 2",
+	)
+	sentrySpan2, _ := sentrySpanMap.Get(childSpan2.SpanContext().SpanID())
+	childSpan2.End()
+
+	otelRootSpan.End()
+
+	assertEqual(t, sentryTransaction.IsTransaction(), true)
+	assertEqual(t, sentrySpan1.IsTransaction(), false)
+	assertEqual(t, sentrySpan1.ParentSpanID, sentryTransaction.SpanID)
+	assertEqual(t, sentrySpan2.IsTransaction(), false)
+	assertEqual(t, sentrySpan2.ParentSpanID, sentrySpan1.SpanID)
+}
+
+func TestSpanWithFinishedParentShouldBeDeleted(t *testing.T) {
+	_, _, tracer := setupSpanProcessorTest()
+
+	ctx, parent := tracer.Start(context.Background(), "parent")
+	parentSpanID := parent.SpanContext().SpanID()
+	_, child := tracer.Start(ctx, "child")
+	childSpanID := child.SpanContext().SpanID()
+
+	_, parentExists := sentrySpanMap.Get(parentSpanID)
+	_, childExists := sentrySpanMap.Get(childSpanID)
+	assertEqual(t, parentExists, true)
+	assertEqual(t, childExists, true)
+
+	parent.End()
+	_, parentExists = sentrySpanMap.Get(parentSpanID)
+	assertEqual(t, parentExists, true)
+	_, childExists = sentrySpanMap.Get(childSpanID)
+	assertEqual(t, childExists, true)
+
+	child.End()
+	_, childExists = sentrySpanMap.Get(childSpanID)
+	assertEqual(t, childExists, false)
+	assertEqual(t, sentrySpanMap.Len(), 0)
+}
diff --git scope.go scope.go
index 3c06279c2..22f7f93d8 100644
--- scope.go
+++ scope.go
@@ -304,6 +304,9 @@ func (scope *Scope) SetPropagationContext(propagationContext PropagationContext)
 
 // GetSpan returns the span from the current scope.
 func (scope *Scope) GetSpan() *Span {
+	scope.mu.RLock()
+	defer scope.mu.RUnlock()
+
 	return scope.span
 }
 
diff --git sentry.go sentry.go
index 423849d54..55309ed54 100644
--- sentry.go
+++ sentry.go
@@ -6,7 +6,7 @@ import (
 )
 
 // The version of the SDK.
-const SDKVersion = "0.34.0"
+const SDKVersion = "0.35.3"
 
 // apiVersion is the minimum version of the Sentry API compatible with the
 // sentry-go SDK.
diff --git slog/converter.go slog/converter.go
index f23923c71..389c32fe8 100644
--- slog/converter.go
+++ slog/converter.go
@@ -10,7 +10,6 @@ import (
 	"time"
 
 	"github.com/getsentry/sentry-go"
-	"github.com/getsentry/sentry-go/attribute"
 )
 
 var (
@@ -134,45 +133,44 @@ func handleFingerprint(v slog.Value, event *sentry.Event) {
 	}
 }
 
-func attrToSentryLog(group string, a slog.Attr) []attribute.Builder {
+func slogAttrToLogEntry(logEntry sentry.LogEntry, group string, a slog.Attr) sentry.LogEntry {
 	key := group + a.Key
 	switch a.Value.Kind() {
 	case slog.KindAny:
-		return []attribute.Builder{attribute.String(key, fmt.Sprintf("%+v", a.Value.Any()))}
+		return logEntry.String(key, fmt.Sprintf("%+v", a.Value.Any()))
 	case slog.KindBool:
-		return []attribute.Builder{attribute.Bool(key, a.Value.Bool())}
+		return logEntry.Bool(key, a.Value.Bool())
 	case slog.KindDuration:
-		return []attribute.Builder{attribute.String(key, a.Value.Duration().String())}
+		return logEntry.String(key, a.Value.Duration().String())
 	case slog.KindFloat64:
-		return []attribute.Builder{attribute.Float64(key, a.Value.Float64())}
+		return logEntry.Float64(key, a.Value.Float64())
 	case slog.KindInt64:
-		return []attribute.Builder{attribute.Int64(key, a.Value.Int64())}
+		return logEntry.Int64(key, a.Value.Int64())
 	case slog.KindString:
-		return []attribute.Builder{attribute.String(key, a.Value.String())}
+		return logEntry.String(key, a.Value.String())
 	case slog.KindTime:
-		return []attribute.Builder{attribute.String(key, a.Value.Time().Format(time.RFC3339))}
+		return logEntry.String(key, a.Value.Time().Format(time.RFC3339))
 	case slog.KindUint64:
 		val := a.Value.Uint64()
 		if val <= math.MaxInt64 {
-			return []attribute.Builder{attribute.Int64(key, int64(val))}
+			return logEntry.Int64(key, int64(val))
 		} else {
-			return []attribute.Builder{attribute.String(key, strconv.FormatUint(val, 10))}
+			return logEntry.String(key, strconv.FormatUint(val, 10))
 		}
 	case slog.KindLogValuer:
-		return []attribute.Builder{attribute.String(key, a.Value.LogValuer().LogValue().String())}
+		return logEntry.String(key, a.Value.LogValuer().LogValue().String())
 	case slog.KindGroup:
 		// Handle nested group attributes
-		var attrs []attribute.Builder
 		groupPrefix := key
 		if groupPrefix != "" {
 			groupPrefix += "."
 		}
 		for _, subAttr := range a.Value.Group() {
-			attrs = append(attrs, attrToSentryLog(groupPrefix, subAttr)...)
+			logEntry = slogAttrToLogEntry(logEntry, groupPrefix, subAttr)
 		}
-		return attrs
+		return logEntry
 	}
 
 	sentry.DebugLogger.Printf("Invalid type: dropping attribute with key: %v and value: %v", a.Key, a.Value)
-	return []attribute.Builder{}
+	return logEntry
 }
diff --git slog/go.mod slog/go.mod
index 5eeee4e99..be250c225 100644
--- slog/go.mod
+++ slog/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/slog
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.35.3
 	github.com/stretchr/testify v1.9.0
 )
 
diff --git slog/sentryslog.go slog/sentryslog.go
index 2ebfc4efd..5cfb89570 100644
--- slog/sentryslog.go
+++ slog/sentryslog.go
@@ -44,7 +44,9 @@ var (
 	}
 )
 
+// LevelFatal is a custom [slog.Level] that maps to [sentry.LevelFatal]
 const LevelFatal = slog.Level(12)
+const SlogOrigin = "auto.logger.slog"
 
 type Option struct {
 	// Deprecated: Use EventLevel instead. Level is kept for backwards compatibility and defaults to EventLevel.
@@ -104,6 +106,8 @@ func (o Option) NewSentryHandler(ctx context.Context) slog.Handler {
 	}
 
 	logger := sentry.NewLogger(ctx)
+	logger.SetAttributes(attribute.String("sentry.origin", SlogOrigin))
+
 	eventHandler := &eventHandler{
 		option: o,
 		attrs:  []slog.Attr{},
@@ -189,10 +193,14 @@ func (h *eventHandler) Handle(ctx context.Context, record slog.Record) error {
 }
 
 func (h *eventHandler) WithAttrs(attrs []slog.Attr) *eventHandler {
+	// Create a copy of the groups slice to avoid sharing state
+	groupsCopy := make([]string, len(h.groups))
+	copy(groupsCopy, h.groups)
+
 	return &eventHandler{
 		option: h.option,
 		attrs:  appendAttrsToGroup(h.groups, h.attrs, attrs...),
-		groups: h.groups,
+		groups: groupsCopy,
 	}
 }
 
@@ -201,10 +209,15 @@ func (h *eventHandler) WithGroup(name string) *eventHandler {
 		return h
 	}
 
+	// Create a copy of the groups slice to avoid modifying the original
+	newGroups := make([]string, len(h.groups), len(h.groups)+1)
+	copy(newGroups, h.groups)
+	newGroups = append(newGroups, name)
+
 	return &eventHandler{
 		option: h.option,
 		attrs:  h.attrs,
-		groups: append(h.groups, name),
+		groups: newGroups,
 	}
 }
 
@@ -233,42 +246,63 @@ func (h *logHandler) Handle(ctx context.Context, record slog.Record) error {
 	attrs = replaceAttrs(h.option.ReplaceAttr, []string{}, attrs...)
 	attrs = removeEmptyAttrs(attrs)
 
-	var sentryAttributes []attribute.Builder
-	for _, attr := range attrs {
-		sentryAttributes = append(sentryAttributes, attrToSentryLog("", attr)...)
-	}
-	h.logger.SetAttributes(sentryAttributes...)
-	h.logger.SetAttributes(attribute.String("sentry.origin", "auto.logger.slog"))
-
 	// Use level ranges instead of exact matches to support custom levels
 	switch {
 	case record.Level < slog.LevelDebug:
 		// Levels below Debug (e.g., Trace)
-		h.logger.Trace(ctx, record.Message)
+		logEntry := h.logger.Trace().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < slog.LevelInfo:
 		// Debug level range: -4 to -1
-		h.logger.Debug(ctx, record.Message)
+		logEntry := h.logger.Debug().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < slog.LevelWarn:
 		// Info level range: 0 to 3
-		h.logger.Info(ctx, record.Message)
+		logEntry := h.logger.Info().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < slog.LevelError:
 		// Warn level range: 4 to 7
-		h.logger.Warn(ctx, record.Message)
+		logEntry := h.logger.Warn().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < LevelFatal: // custom Fatal level, keep +4 increments
-		h.logger.Error(ctx, record.Message)
+		logEntry := h.logger.Error().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	default:
 		// Fatal level range: 12 and above
-		h.logger.Fatal(ctx, record.Message)
+		logEntry := h.logger.Fatal().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	}
 
 	return nil
 }
 
 func (h *logHandler) WithAttrs(attrs []slog.Attr) *logHandler {
+	// Create a copy of the groups slice to avoid sharing state
+	groupsCopy := make([]string, len(h.groups))
+	copy(groupsCopy, h.groups)
+
 	return &logHandler{
 		option: h.option,
 		attrs:  appendAttrsToGroup(h.groups, h.attrs, attrs...),
-		groups: h.groups,
+		groups: groupsCopy,
 		logger: h.logger,
 	}
 }
@@ -278,10 +312,15 @@ func (h *logHandler) WithGroup(name string) *logHandler {
 		return h
 	}
 
+	// Create a copy of the groups slice to avoid modifying the original
+	newGroups := make([]string, len(h.groups), len(h.groups)+1)
+	copy(newGroups, h.groups)
+	newGroups = append(newGroups, name)
+
 	return &logHandler{
 		option: h.option,
 		attrs:  h.attrs,
-		groups: append(h.groups, name),
+		groups: newGroups,
 		logger: h.logger,
 	}
 }
diff --git a/testdata/json/checkin/003.json b/testdata/json/checkin/003.json
new file mode 100644
index 000000000..cbce42cf7
--- /dev/null
+++ testdata/json/checkin/003.json
@@ -0,0 +1,19 @@
+{
+  "check_in_id": "c2f0ce1334c74564bf6631f6161173f5",
+  "monitor_slug": "my-monitor",
+  "status": "ok",
+  "duration": 10,
+  "release": "1.0.0",
+  "environment": "dev",
+  "monitor_config": {
+    "schedule": {
+      "type": "crontab",
+      "value": "* * * * *"
+    },
+    "checkin_margin": 2,
+    "max_runtime": 1,
+    "timezone": "UTC",
+    "failure_issue_threshold": 5,
+    "recovery_threshold": 1
+  }
+}
diff --git transport.go transport.go
index e2ec87abf..aae5072d6 100644
--- transport.go
+++ transport.go
@@ -262,17 +262,6 @@ func getRequestFromEvent(ctx context.Context, event *Event, dsn *Dsn) (r *http.R
 	)
 }
 
-func categoryFor(eventType string) ratelimit.Category {
-	switch eventType {
-	case "":
-		return ratelimit.CategoryError
-	case transactionType:
-		return ratelimit.CategoryTransaction
-	default:
-		return ratelimit.Category(eventType)
-	}
-}
-
 // ================================
 // HTTPTransport
 // ================================
@@ -303,7 +292,8 @@ type HTTPTransport struct {
 	// current in-flight items and starts a new batch for subsequent events.
 	buffer chan batch
 
-	start sync.Once
+	startOnce sync.Once
+	closeOnce sync.Once
 
 	// Size of the transport buffer. Defaults to 30.
 	BufferSize int
@@ -364,7 +354,7 @@ func (t *HTTPTransport) Configure(options ClientOptions) {
 		}
 	}
 
-	t.start.Do(func() {
+	t.startOnce.Do(func() {
 		go t.worker()
 	})
 }
@@ -380,7 +370,7 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event)
 		return
 	}
 
-	category := categoryFor(event.Type)
+	category := event.toCategory()
 
 	if t.disabled(category) {
 		return
@@ -440,11 +430,9 @@ 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 {
-	timeoutCh := make(chan struct{})
-	time.AfterFunc(timeout, func() {
-		close(timeoutCh)
-	})
-	return t.flushInternal(timeoutCh)
+	ctx, cancel := context.WithTimeout(context.Background(), timeout)
+	defer cancel()
+	return t.FlushWithContext(ctx)
 }
 
 // FlushWithContext works like Flush, but it accepts a context.Context instead of a timeout.
@@ -506,7 +494,9 @@ fail:
 // Close should be called after Flush and before terminating the program
 // otherwise some events may be lost.
 func (t *HTTPTransport) Close() {
-	close(t.done)
+	t.closeOnce.Do(func() {
+		close(t.done)
+	})
 }
 
 func (t *HTTPTransport) worker() {
@@ -652,7 +642,7 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve
 		return
 	}
 
-	if t.disabled(categoryFor(event.Type)) {
+	if t.disabled(event.toCategory()) {
 		return
 	}
 
diff --git transport_test.go transport_test.go
index cf29596f1..f4a066ad2 100644
--- transport_test.go
+++ transport_test.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"net"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptrace"
@@ -490,6 +491,28 @@ func TestHTTPTransport(t *testing.T) {
 		wg.Wait()
 	})
 }
+func TestHTTPTransport_CloseMultipleTimes(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(),
+	})
+
+	// Closing multiple times should not panic.
+	for i := 0; i < 10; i++ {
+		transport.Close()
+	}
+
+	// Verify the done channel is closed
+	select {
+	case <-transport.done:
+		// Expected - channel should be closed
+	case <-time.After(time.Second):
+		t.Fatal("transport.done not closed")
+	}
+}
 
 func TestHTTPTransport_FlushWithContext(t *testing.T) {
 	server := newTestHTTPServer(t)
@@ -767,19 +790,31 @@ func TestHTTPTransportDoesntLeakGoroutines(t *testing.T) {
 
 	transport := NewHTTPTransport()
 	transport.Configure(ClientOptions{
-		Dsn:        "https://test@foobar/1",
-		HTTPClient: http.DefaultClient,
+		Dsn: "https://test@foobar/1",
+		HTTPClient: &http.Client{
+			Transport: &http.Transport{
+				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+					return nil, fmt.Errorf("mock transport - no real connections")
+				},
+			},
+		},
 	})
 
-	transport.Flush(0)
+	transport.Flush(testutils.FlushTimeout())
 	transport.Close()
 }
 
 func TestHTTPTransportClose(t *testing.T) {
 	transport := NewHTTPTransport()
 	transport.Configure(ClientOptions{
-		Dsn:        "https://test@foobar/1",
-		HTTPClient: http.DefaultClient,
+		Dsn: "https://test@foobar/1",
+		HTTPClient: &http.Client{
+			Transport: &http.Transport{
+				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+					return nil, fmt.Errorf("mock transport - no real connections")
+				},
+			},
+		},
 	})
 
 	transport.Close()
diff --git zerolog/go.mod zerolog/go.mod
index 69e9b398f..037782cde 100644
--- zerolog/go.mod
+++ zerolog/go.mod
@@ -1,12 +1,12 @@
 module github.com/getsentry/sentry-go/zerolog
 
-go 1.21
+go 1.22
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
 	github.com/buger/jsonparser v1.1.1
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.35.3
 	github.com/rs/zerolog v1.33.0
 	github.com/stretchr/testify v1.9.0
 )

Description

This PR encompasses significant updates to the Sentry Go SDK, including major logging API changes, bug fixes, dependency updates, and infrastructure improvements. The most notable change is a breaking change to the logging API that introduces a fluent interface for structured logging. The PR also includes multiple bug fixes, CI/CD improvements, and comprehensive test additions.

Changes

Changes

.cursor/rules/changelog.mdc

  • Adds comprehensive changelog creation guidelines
  • Defines rules for gathering changes, version structure, formatting, and content guidelines
  • Provides examples and quality checklists for creating changelog entries

.github/ISSUE_TEMPLATE/config.yml

  • Removes support request links for self-hosting and security vulnerability reporting
  • Simplifies issue template configuration

.github/workflows/test.yml

  • Optimizes test execution by running race detection tests conditionally
  • Changes minimum Go version from 1.21 to 1.22
  • Reorganizes test steps and adds conditional race testing logic

CHANGELOG.md

  • Adds changelog entries for versions 0.35.3, 0.35.2, 0.35.1, 0.35.0, and 0.34.1
  • Documents the breaking changes to the logging API in version 0.35.0
  • Lists various bug fixes and improvements across versions

Makefile

  • Adds test-race-coverage target combining race detection with coverage
  • Updates Go compatibility from 1.21 to 1.22

_examples/logs/main.go

  • Updates example to demonstrate the new fluent logging API
  • Shows both permanent logger attributes and per-entry attributes
  • Demonstrates context correlation with HTTP handlers

batch_logger.go

  • Adds flushCh channel for coordinated flushing
  • Implements Shutdown() method with proper cleanup
  • Improves Flush() to accept timeout channels
  • Adds synchronization primitives to prevent multiple shutdowns

client.go

  • Updates flush methods to properly integrate with batch logger

interfaces.go

  • Breaking Change: Complete redesign of the Logger interface to support fluent API
  • Adds LogEntry interface for chaining attributes
  • Updates Attribute struct with strongly typed AttrType
  • Adds new rate limit categories and toCategory() method for events

log.go and log_fallback.go

  • Breaking Change: Complete rewrite of logging implementation
  • Implements fluent interface with method chaining
  • Adds thread-safe attribute management
  • Implements logEntry struct with attribute chaining capabilities
  • Updates no-op logger to match new interface

internal/ratelimit/category.go

  • Adds new rate limit categories: CategoryLog, CategoryMonitor, CategoryUnknown
  • Improves category string formatting

Multiple integration modules (fasthttp/, fiber/, gin/, etc.)

  • Updates Go version requirement to 1.22
  • Updates dependency versions
  • Adds tests for malformed URL handling in fasthttp and fiber integrations

otel/span_processor.go and otel/span_map.go

  • Fixes OpenTelemetry spans being created as transactions instead of child spans
  • Implements proper parent-child span relationship tracking
  • Adds span cleanup logic with hierarchical deletion

slog/ and logrus/ integrations

  • Updates to use new fluent logging API
  • Adds origin tracking for better debugging
  • Improves attribute handling and conversion

Test files

  • Adds comprehensive race condition tests (log_race_test.go)
  • Updates existing tests to use new logging API
  • Adds tests for batch logger shutdown and multiple flush scenarios
sequenceDiagram
    participant Client
    participant Logger
    participant LogEntry
    participant BatchLogger
    participant Transport

    Client->>Logger: NewLogger(ctx)
    Client->>Logger: SetAttributes(attrs...)
    Client->>Logger: Info()
    Logger->>LogEntry: Create LogEntry with level
    Client->>LogEntry: String("key", "value")
    Client->>LogEntry: Int("num", 42)
    Client->>LogEntry: WithCtx(newCtx)
    LogEntry->>LogEntry: Return new LogEntry
    Client->>LogEntry: Emit("message")
    LogEntry->>Logger: log(ctx, level, severity, message, attrs)
    Logger->>BatchLogger: Send log to channel
    BatchLogger->>BatchLogger: Batch logs together
    BatchLogger->>Transport: Send batched event
    Client->>Client: Flush(timeout)
    Client->>BatchLogger: Flush with timeout
    BatchLogger->>Transport: Flush remaining logs
Loading

Security Hotspots

  1. Rate Limiting Changes - The addition of new rate limit categories (CategoryLog, CategoryMonitor) could potentially affect how requests are rate-limited, though this appears to be an improvement rather than a vulnerability.

  2. Context Handling in Logging - The new WithCtx() method creates copies of log entries with different contexts, but the implementation appears to properly isolate context data.

Privacy Hotspots

  1. Enhanced Attribute Logging - The new fluent logging interface makes it easier to attach attributes to log entries, which could lead to accidental logging of sensitive data if developers aren't careful about what they log.

  2. User Data in Logs - The logging system automatically extracts user information (ID, name, email) from the scope and adds it to log attributes, which could be a privacy concern if not properly managed.

@renovate renovate bot force-pushed the renovate/github-com-getsentry-sentry-go-0-x branch 2 times, most recently from 56eceb8 to bc610d4 Compare October 21, 2025 15:52
@renovate renovate bot changed the title fix(deps): update module github.com/getsentry/sentry-go to v0.35.3 fix(deps): update module github.com/getsentry/sentry-go to v0.36.0 Oct 21, 2025
@renovate renovate bot force-pushed the renovate/github-com-getsentry-sentry-go-0-x branch 2 times, most recently from 99da404 to 59d7246 Compare October 24, 2025 23:42
@github-actions
Copy link

[puLL-Merge] - getsentry/sentry-go@otel/v0.34.0..otel/v0.36.0

Diff
diff --git a/.cursor/rules/changelog.mdc b/.cursor/rules/changelog.mdc
new file mode 100644
index 000000000..a22fefe88
--- /dev/null
+++ .cursor/rules/changelog.mdc
@@ -0,0 +1,168 @@
+---
+globs: CHANGELOG.md
+alwaysApply: false
+---
+# Changelog Creation Guidelines
+
+When creating or updating changelogs for the Sentry Go SDK, follow these rules:
+
+## Gathering Changes
+
+Before creating a changelog entry, collect all changes since the last release tag:
+
+### Find the Latest Release Tag
+```bash
+git tag --sort=-version:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -1
+```
+
+### Get Commits Since Last Release
+```bash
+# Get commit hashes and messages since last tag
+git log --oneline $(git describe --tags --abbrev=0)..HEAD
+
+# Get detailed commit information
+git log --pretty=format:"%h %s (%an)" $(git describe --tags --abbrev=0)..HEAD
+```
+
+### Analyze Changes
+For each commit since the last release:
+1. Check if it's a merge commit from a PR: `git show --stat <commit_hash>`
+2. For PR commits, fetch the PR details to understand the full context
+3. Categorize changes as Breaking Changes, Features, Deprecations, Bug Fixes, or Misc
+4. Identify any commits that should be excluded (internal refactoring, test-only changes, etc.)
+
+### Example Workflow
+```bash
+# Check current branch and recent commits
+git log --oneline --since="2024-01-01" | head -10
+
+# Get PR information for specific commits
+git show <commit_hash> --stat
+
+# Look at file changes to understand scope
+git diff --name-only <last_tag>..HEAD
+```
+
+Always base changelog entries on the complete set of commits since the last release tag to ensure no changes are missed.
+
+## Version Structure
+
+Use semantic versioning (e.g., `0.34.0`) with this format:
+
+```markdown
+## [VERSION]
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v[VERSION].
+```
+
+## Section Order
+
+Include sections in this exact order (only include sections that have content):
+
+1. **Breaking Changes** - Changes requiring code modifications
+2. **Deprecations** - Features marked for future removal
+3. **Features** - New functionality and enhancements  
+4. **Bug Fixes** - Fixes for existing functionality
+5. **Misc** - Other changes
+
+## Formatting Rules
+
+### Pull Request Links
+- Always use format: `([#NUMBER](https://github.com/getsentry/sentry-go/pull/NUMBER))`
+- For issues: `([#NUMBER](https://github.com/getsentry/sentry-go/issues/NUMBER))`
+
+### Code Examples
+- Use Go syntax highlighting: ````go
+- For breaking changes, show both before and after examples
+- Include relevant context, not just the changed line
+
+### Descriptions
+- Start with action verbs (Add, Fix, Remove, Update, etc.)
+- Be specific about what changed
+- Include component/module names when relevant
+- Keep concise but informative
+
+## Section Guidelines
+
+### Breaking Changes
+- Always provide migration examples with **Before:** and **After:** code blocks
+- Explain rationale for the change
+- Include timeline for removal if deprecating
+
+### Features
+- Focus on user-facing functionality
+- Include code examples for complex features
+- Link to documentation when relevant
+
+### Bug Fixes
+- Clearly describe what was fixed
+- Include component names (e.g., "Fix race condition in `Scope.GetSpan()` method")
+- Reference the specific issue if applicable
+
+### Deprecations
+- Include migration guidance
+- Specify removal timeline
+- Provide alternative solutions
+
+## Content Guidelines
+
+### Include:
+- All user-facing changes
+- Breaking changes with migration guidance
+- New features and enhancements
+- Important bug fixes
+- Performance improvements
+- Security fixes
+- Deprecation notices
+
+### Exclude:
+- Internal refactoring (unless affects performance)
+- Test-only changes
+- Documentation-only changes (unless significant)
+- Build system changes
+- CI/CD changes
+
+## Example Structure
+
+```markdown
+## 0.34.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.1.
+
+### Features
+
+- Allow flush to be used multiple times without issues, particularly for the batch logger ([#1051](https://github.com/getsentry/sentry-go/pull/1051))
+
+### Bug Fixes
+
+- Fix race condition in `Scope.GetSpan()` method by adding proper mutex locking ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+- Guard transport on `Close()` to prevent panic when called multiple times ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+```
+
+## Quality Checklist
+
+Before publishing:
+- [ ] Version number follows semantic versioning
+- [ ] All sections in correct order
+- [ ] All PR/issue links working
+- [ ] Code examples tested and accurate
+- [ ] Breaking changes include migration guidance
+- [ ] Descriptions clear and specific
+- [ ] Grammar and spelling correct
+- [ ] No internal-only changes included
+
+## Notes Section
+
+For significant releases, add a notes section:
+
+```markdown
+_NOTE:_
+Additional context, warnings, or important information about this release.
+```
+
+Use for:
+- Go version compatibility changes
+- Important upgrade considerations
+- Significant behavioral changes
+- Performance characteristics
+- Known limitations
diff --git .github/ISSUE_TEMPLATE/config.yml .github/ISSUE_TEMPLATE/config.yml
index 191febb53..31f71b14f 100644
--- .github/ISSUE_TEMPLATE/config.yml
+++ .github/ISSUE_TEMPLATE/config.yml
@@ -3,9 +3,3 @@ contact_links:
   - name: Support Request
     url: https://sentry.io/support
     about: Use our dedicated support channel for paid accounts.
-  - name: Ask a question about self-hosting/on-premise
-    url: https://forum.sentry.io
-    about: Please use the community forums for questions about self-hosting.
-  - name: Report a security vulnerability
-    url: https://sentry.io/security/#vulnerability-disclosure
-    about: Please see our guide for responsible disclosure.
diff --git .github/pull_request_template.md .github/pull_request_template.md
index 3d8a87332..a4e54d4b2 100644
--- .github/pull_request_template.md
+++ .github/pull_request_template.md
@@ -1,11 +1,13 @@
-<!--
-
-Hey, thanks for your contribution!
-
-The Sentry team has finite resources and priorities that are not always visible on GitHub.
-Please help us save time when reviewing your PR by following this two-step guide:
-
-1. Is your PR a simple typo fix? __Click that green "Create pull request" button__!
-2. For more complex PRs, please read https://github.com/getsentry/sentry-go/blob/master/CONTRIBUTING.md
+### Description
+<!-- What changed and why? -->
 
+#### Issues
+<!--
+* resolves: #1234
+* resolves: LIN-1234
 -->
+
+#### Reminders
+- Add GH Issue ID _&_ Linear ID
+- PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`)
+- For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-go/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr)
diff --git .github/workflows/codeql.yml .github/workflows/codeql.yml
index 446043f48..a79201583 100644
--- .github/workflows/codeql.yml
+++ .github/workflows/codeql.yml
@@ -40,7 +40,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v4
+      uses: actions/checkout@v5
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
diff --git .github/workflows/lint.yml .github/workflows/lint.yml
index 9ce5de3fd..ab6ef61de 100644
--- .github/workflows/lint.yml
+++ .github/workflows/lint.yml
@@ -18,10 +18,10 @@ jobs:
     name: Lint
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/setup-go@v5
+      - uses: actions/setup-go@v6
         with:
           go-version: "1.24"
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
       - name: golangci-lint
         uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # [email protected]
         with:
diff --git .github/workflows/release.yml .github/workflows/release.yml
index 00907efb8..17df7fe53 100644
--- .github/workflows/release.yml
+++ .github/workflows/release.yml
@@ -15,11 +15,11 @@ jobs:
     steps:
       - name: Get auth token
         id: token
-        uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
+        uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
         with:
           app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
           private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
         with:
           token: ${{ steps.token.outputs.token }}
           fetch-depth: 0
diff --git .github/workflows/test.yml .github/workflows/test.yml
index 94564ae2f..b7490f788 100644
--- .github/workflows/test.yml
+++ .github/workflows/test.yml
@@ -20,11 +20,16 @@ jobs:
     env:
       GO111MODULE: "on"
       GOFLAGS: "-mod=readonly"
+      # The race detector adds considerable runtime overhead. To save time on
+      # pull requests, only run this step for a single job in the matrix. For
+      # all other workflow triggers (e.g., pushes to a release branch) run
+      # this step for the whole matrix.
+      RUN_RACE_TESTS: ${{ github.event_name != 'pull_request' || (matrix.go == '1.24' && matrix.os == 'ubuntu') }}
     steps:
-      - uses: actions/setup-go@v5
+      - uses: actions/setup-go@v6
         with:
           go-version: ${{ matrix.go }}
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
       - uses: actions/cache@v4
         with:
           # In order:
@@ -40,30 +45,23 @@ jobs:
           key: ${{ runner.os }}-go-${{ matrix.go }}-${{ hashFiles('**/go.sum') }}
           restore-keys: |
             ${{ runner.os }}-go-${{ matrix.go }}-
+      - name: Tidy for min version
+        run: make mod-tidy
+        if: ${{ matrix.go == '1.23' }}
       - name: Build
         run: make build
       - name: Vet
         run: make vet
-      - name: Check go.mod Tidiness
-        run: make mod-tidy
-        if: ${{ matrix.go == '1.21' }}
-      - name: Test
-        run: make test-coverage
+      - name: Test${{ env.RUN_RACE_TESTS == 'true' && ' (with race detection)' || '' }}
+        run: ${{ env.RUN_RACE_TESTS == 'true' && 'make test-race-coverage' || 'make test-coverage' }}
       - name: Upload coverage to Codecov
-        uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # [email protected]
+        uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # [email protected]
         with:
           directory: .coverage
           token: ${{ secrets.CODECOV_TOKEN }}
-      - name: Test (with race detection)
-        run: make test-race
-        # The race detector adds considerable runtime overhead. To save time on
-        # pull requests, only run this step for a single job in the matrix. For
-        # all other workflow triggers (e.g., pushes to a release branch) run
-        # this step for the whole matrix.
-        if: ${{ github.event_name != 'pull_request' || (matrix.go == '1.23' && matrix.os == 'ubuntu') }}
     timeout-minutes: 15
     strategy:
       matrix:
-        go: ["1.24", "1.23", "1.22"]
+        go: ["1.25", "1.24", "1.23"]
         os: [ubuntu, windows, macos]
       fail-fast: false
diff --git CHANGELOG.md CHANGELOG.md
index b9bb13306..aa0a974ab 100644
--- CHANGELOG.md
+++ CHANGELOG.md
@@ -1,5 +1,128 @@
 # Changelog
 
+## 0.36.0
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.36.0.
+
+### Breaking Changes
+
+- Behavioral change for the `MaxBreadcrumbs` client option. Removed the hard limit of 100 breadcrumbs, allowing users to set a larger limit and also changed the default limit from 30 to 100 ([#1106](https://github.com/getsentry/sentry-go/pull/1106)))
+
+- The changes to error handling ([#1075](https://github.com/getsentry/sentry-go/pull/1075)) will affect issue grouping. It is expected that any wrapped and complex errors will be grouped under a new issue group.
+
+### Features
+
+- Add support for improved issue grouping with enhanced error chain handling ([#1075](https://github.com/getsentry/sentry-go/pull/1075))
+
+  The SDK now provides better handling of complex error scenarios, particularly when dealing with multiple related errors or error chains. This feature automatically detects and properly structures errors created with Go's `errors.Join()` function and other multi-error patterns.
+
+  ```go
+  // Multiple errors are now properly grouped and displayed in Sentry
+  err1 := errors.New("err1")
+  err2 := errors.New("err2") 
+  combinedErr := errors.Join(err1, err2)
+  
+  // When captured, these will be shown as related exceptions in Sentry
+  sentry.CaptureException(combinedErr)
+  ```
+
+- Add `TraceIgnoreStatusCodes` option to allow filtering of HTTP transactions based on status codes ([#1089](https://github.com/getsentry/sentry-go/pull/1089))
+  - Configure which HTTP status codes should not be traced by providing single codes or ranges
+  - Example: `TraceIgnoreStatusCodes: [][]int{{404}, {500, 599}}` ignores 404 and server errors 500-599
+
+### Bug Fixes
+
+- Fix logs being incorrectly filtered by `BeforeSend` callback ([#1109](https://github.com/getsentry/sentry-go/pull/1109))
+  - Logs now bypass the `processEvent` method and are sent directly to the transport
+  - This ensures logs are only filtered by `BeforeSendLog`, not by the error/message `BeforeSend` callback
+
+### Misc
+
+- Add support for Go 1.25 and drop support for Go 1.22 ([#1103](https://github.com/getsentry/sentry-go/pull/1103))
+
+## 0.35.3
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.3.
+
+### Bug Fixes
+
+- Add missing rate limit categories ([#1082](https://github.com/getsentry/sentry-go/pull/1082))
+
+## 0.35.2
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.2.
+
+### Bug Fixes
+
+- Fix OpenTelemetry spans being created as transactions instead of child spans ([#1073](https://github.com/getsentry/sentry-go/pull/1073))
+
+### Misc
+
+- Add `MockTransport` to test clients for improved testing ([#1071](https://github.com/getsentry/sentry-go/pull/1071))
+
+## 0.35.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.1.
+
+### Bug Fixes
+
+- Fix race conditions when accessing the scope during logging operations ([#1050](https://github.com/getsentry/sentry-go/pull/1050))
+- Fix nil pointer dereference with malformed URLs when tracing is enabled in `fasthttp` and `fiber` integrations ([#1055](https://github.com/getsentry/sentry-go/pull/1055))
+
+### Misc
+
+- Bump `github.com/gofiber/fiber/v2` from 2.52.5 to 2.52.9 in `/fiber` ([#1067](https://github.com/getsentry/sentry-go/pull/1067))
+
+## 0.35.0
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.0.
+
+### Breaking Changes
+
+- Changes to the logging API ([#1046](https://github.com/getsentry/sentry-go/pull/1046))
+
+The logging API now supports a fluent interface for structured logging with attributes:
+
+```go
+// usage before
+logger := sentry.NewLogger(ctx)
+// attributes weren't being set permanently
+logger.SetAttributes(
+    attribute.String("version", "1.0.0"),
+)
+logger.Infof(ctx, "Message with parameters %d and %d", 1, 2)
+
+// new behavior
+ctx := context.Background()
+logger := sentry.NewLogger(ctx)
+
+// Set permanent attributes on the logger
+logger.SetAttributes(
+    attribute.String("version", "1.0.0"),
+)
+
+// Chain attributes on individual log entries
+logger.Info().
+    String("key.string", "value").
+    Int("key.int", 42).
+    Bool("key.bool", true).
+    Emitf("Message with parameters %d and %d", 1, 2)
+```
+
+### Bug Fixes
+
+- Correctly serialize `FailureIssueThreshold` and `RecoveryThreshold` onto check-in payloads ([#1060](https://github.com/getsentry/sentry-go/pull/1060))
+
+## 0.34.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.1.
+
+### Bug Fixes
+
+- Allow flush to be used multiple times without issues, particularly for the batch logger ([#1051](https://github.com/getsentry/sentry-go/pull/1051))
+- Fix race condition in `Scope.GetSpan()` method by adding proper mutex locking ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+- Guard transport on `Close()` to prevent panic when called multiple times ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+
 ## 0.34.0
 
 The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.0.
diff --git Makefile Makefile
index 9cc0959fd..26d993e9c 100644
--- Makefile
+++ Makefile
@@ -54,12 +54,23 @@ test-coverage: $(COVERAGE_REPORT_DIR) clean-report-dir  ## Test with coverage en
 	    $(GO) tool cover -html=$(COVERAGE_PROFILE) -o coverage.html); \
 	done;
 .PHONY: test-coverage clean-report-dir
-
+test-race-coverage: $(COVERAGE_REPORT_DIR) clean-report-dir  ## Run tests with race detection and coverage
+	set -e ; \
+	for dir in $(ALL_GO_MOD_DIRS); do \
+	  echo ">>> Running tests with race detection and coverage for module: $${dir}"; \
+	  DIR_ABS=$$(python -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' $${dir}) ; \
+	  REPORT_NAME=$$(basename $${DIR_ABS}); \
+	  (cd "$${dir}" && \
+	    $(GO) test -count=1 -timeout $(TIMEOUT)s -race -coverpkg=./... -covermode=$(COVERAGE_MODE) -coverprofile="$(COVERAGE_PROFILE)" ./... && \
+		cp $(COVERAGE_PROFILE) "$(COVERAGE_REPORT_DIR_ABS)/$${REPORT_NAME}_$(COVERAGE_PROFILE)" && \
+	    $(GO) tool cover -html=$(COVERAGE_PROFILE) -o coverage.html); \
+	done;
+.PHONY: test-race-coverage
 mod-tidy: ## Check go.mod tidiness
 	set -e ; \
 	for dir in $(ALL_GO_MOD_DIRS); do \
 		echo ">>> Running 'go mod tidy' for module: $${dir}"; \
-		(cd "$${dir}" && go mod tidy -go=1.21 -compat=1.21); \
+		(cd "$${dir}" && go mod tidy -go=1.23 -compat=1.23); \
 	done; \
 	git diff --exit-code;
 .PHONY: mod-tidy
diff --git _examples/logs/main.go _examples/logs/main.go
index c9785f2f6..b808ada4c 100644
--- _examples/logs/main.go
+++ _examples/logs/main.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"context"
+	"net/http"
 	"time"
 
 	"github.com/getsentry/sentry-go"
@@ -11,7 +12,7 @@ import (
 func main() {
 	err := sentry.Init(sentry.ClientOptions{
 		Dsn:        "",
-		EnableLogs: true,
+		EnableLogs: true, // you need to have EnableLogs set to true
 	})
 	if err != nil {
 		panic(err)
@@ -19,19 +20,42 @@ func main() {
 	defer sentry.Flush(2 * time.Second)
 
 	ctx := context.Background()
+	loggerWithAttrs := sentry.NewLogger(ctx)
+	// Attaching permanent attributes on the logger.
+	loggerWithAttrs.SetAttributes(
+		attribute.String("version", "1.0.0"),
+	)
+
+	// It's also possible to attach attributes on the [LogEntry] itself.
+	loggerWithAttrs.Info().
+		String("key.string", "value").
+		Int("key.int", 42).
+		Bool("key.bool", true).
+		// don't forget to call Emit to send the logs to Sentry
+		Emitf("Message with parameters %d and %d", 1, 2)
+
+	// The [LogEntry] can also be precompiled, if you don't want to set the same attributes multiple times
+	logEntry := loggerWithAttrs.Info().Int("int", 1)
+	// And then call Emit multiple times
+	logEntry.Emit("once")
+	logEntry.Emit("twice")
+
+	// You can also create different loggers with different precompiled attributes
 	logger := sentry.NewLogger(ctx)
+	logger.Info().
+		Emit("doesn't contain version") // this log does not contain the version attribute
+}
 
-	// You can use the logger like [fmt.Print]
-	logger.Info(ctx, "Expecting ", 2, " params")
-	// or like [fmt.Printf]
-	logger.Infof(ctx, "format: %v", "value")
-
-	// Additionally, you can also set attributes on the log like this
-	logger.SetAttributes(
-		attribute.Int("key.int", 42),
-		attribute.Bool("key.boolean", true),
-		attribute.Float64("key.float", 42.4),
-		attribute.String("key.string", "string"),
-	)
-	logger.Warnf(ctx, "I have params %v and attributes", "example param")
+type MyHandler struct {
+	logger sentry.Logger
+}
+
+// ServeHTTP example of a handler
+// To correlate logs with transactions, [context.Context] needs to be passed to the [LogEntry] with the [WithCtx] func.
+// Assuming you are using a Sentry tracing integration.
+func (h MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	// By using [WithCtx] the log entry will be associated with the transaction from the request
+	h.logger.Info().WithCtx(ctx).Emit("log inside handler")
+	w.WriteHeader(http.StatusOK)
 }
diff --git a/_examples/trace-ignore-status-codes/main.go b/_examples/trace-ignore-status-codes/main.go
new file mode 100644
index 000000000..4597b09b0
--- /dev/null
+++ _examples/trace-ignore-status-codes/main.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"net/http"
+	"time"
+
+	"github.com/getsentry/sentry-go"
+	sentryhttp "github.com/getsentry/sentry-go/http"
+)
+
+func main() {
+	// Initialize Sentry with TraceIgnoreStatusCodes configuration
+	err := sentry.Init(sentry.ClientOptions{
+		Dsn:              "", // Replace with your DSN
+		Debug:            true,
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+		// Configure which HTTP status codes should not be traced
+		// Each element can be a single code {code} or a range {min, max}
+		TraceIgnoreStatusCodes: [][]int{{404}, {500, 599}}, // Ignore 404 and server errors 500-599
+	})
+	if err != nil {
+		log.Fatalf("sentry.Init: %s", err)
+	}
+
+	defer sentry.Flush(2 * time.Second)
+
+	// Create a Sentry-instrumented HTTP handler
+	sentryHandler := sentryhttp.New(sentryhttp.Options{})
+
+	http.HandleFunc("/", sentryHandler.HandleFunc(homeHandler))
+	http.HandleFunc("/users/", sentryHandler.HandleFunc(usersHandler))
+	http.HandleFunc("/forbidden", sentryHandler.HandleFunc(forbiddenHandler))
+	http.HandleFunc("/error", sentryHandler.HandleFunc(errorHandler))
+
+	fmt.Println("Server starting on :8080")
+	fmt.Println("Try these endpoints:")
+	fmt.Println("  GET /             - Returns 200 OK (will be traced)")
+	fmt.Println("  GET /users/123     - Returns 200 OK (will be traced)")
+	fmt.Println("  GET /nonexistent  - Returns 404 Not Found (will NOT be traced - matches {404})")
+	fmt.Println("  GET /forbidden    - Returns 403 Forbidden (will be traced)")
+	fmt.Println("  GET /error        - Returns 500 Internal Server Error (will NOT be traced - in range {500, 599})")
+
+	log.Fatal(http.ListenAndServe(":8080", nil))
+}
+
+func homeHandler(w http.ResponseWriter, r *http.Request) {
+	if r.URL.Path != "/" {
+		// This will return 404 and won't be traced due to our configuration (matches {404})
+		http.NotFound(w, r)
+		return
+	}
+
+	if span := sentry.SpanFromContext(r.Context()); span != nil {
+		span.SetTag("endpoint", "home")
+		span.SetData("custom_data", "This is the home page")
+	}
+
+	w.WriteHeader(http.StatusOK)
+	fmt.Fprintf(w, "Welcome to the home page! This 200 response will be traced.\n")
+}
+
+func usersHandler(w http.ResponseWriter, r *http.Request) {
+	if span := sentry.SpanFromContext(r.Context()); span != nil {
+		span.SetTag("endpoint", "users")
+		span.SetData("user_id", r.URL.Path[7:])
+	}
+
+	w.WriteHeader(http.StatusOK)
+	fmt.Fprintf(w, "User profile page. This 200 response will be traced.\n")
+}
+
+func forbiddenHandler(w http.ResponseWriter, r *http.Request) {
+	if span := sentry.SpanFromContext(r.Context()); span != nil {
+		span.SetTag("endpoint", "forbidden")
+		span.SetData("reason", "Access denied")
+	}
+
+	w.WriteHeader(http.StatusForbidden)
+	fmt.Fprintf(w, "Access forbidden. This 403 response will be traced.\n")
+}
+
+func errorHandler(w http.ResponseWriter, r *http.Request) {
+	if span := sentry.SpanFromContext(r.Context()); span != nil {
+		span.SetTag("endpoint", "error")
+		span.SetData("error_type", "simulated_server_error")
+	}
+
+	w.WriteHeader(http.StatusInternalServerError)
+	fmt.Fprintf(w, "Internal server error. This 500 response will NOT be traced (in range 500-599).\n")
+}
diff --git batch_logger.go batch_logger.go
index 8f25c008f..39487ae77 100644
--- batch_logger.go
+++ batch_logger.go
@@ -12,17 +12,20 @@ const (
 )
 
 type BatchLogger struct {
-	client    *Client
-	logCh     chan Log
-	cancel    context.CancelFunc
-	wg        sync.WaitGroup
-	startOnce sync.Once
+	client       *Client
+	logCh        chan Log
+	flushCh      chan chan struct{}
+	cancel       context.CancelFunc
+	wg           sync.WaitGroup
+	startOnce    sync.Once
+	shutdownOnce sync.Once
 }
 
 func NewBatchLogger(client *Client) *BatchLogger {
 	return &BatchLogger{
-		client: client,
-		logCh:  make(chan Log, batchSize),
+		client:  client,
+		logCh:   make(chan Log, batchSize),
+		flushCh: make(chan chan struct{}),
 	}
 }
 
@@ -35,17 +38,32 @@ func (l *BatchLogger) Start() {
 	})
 }
 
-func (l *BatchLogger) Flush() {
-	if l.cancel != nil {
-		l.cancel()
-		l.wg.Wait()
+func (l *BatchLogger) Flush(timeout <-chan struct{}) {
+	done := make(chan struct{})
+	select {
+	case l.flushCh <- done:
+		select {
+		case <-done:
+		case <-timeout:
+		}
+	case <-timeout:
 	}
 }
 
+func (l *BatchLogger) Shutdown() {
+	l.shutdownOnce.Do(func() {
+		if l.cancel != nil {
+			l.cancel()
+			l.wg.Wait()
+		}
+	})
+}
+
 func (l *BatchLogger) run(ctx context.Context) {
 	defer l.wg.Done()
 	var logs []Log
 	timer := time.NewTimer(batchTimeout)
+	defer timer.Stop()
 
 	for {
 		select {
@@ -65,8 +83,27 @@ func (l *BatchLogger) run(ctx context.Context) {
 				logs = nil
 			}
 			timer.Reset(batchTimeout)
+		case done := <-l.flushCh:
+		flushDrain:
+			for {
+				select {
+				case log := <-l.logCh:
+					logs = append(logs, log)
+				default:
+					break flushDrain
+				}
+			}
+
+			if len(logs) > 0 {
+				l.processEvent(logs)
+				logs = nil
+			}
+			if !timer.Stop() {
+				<-timer.C
+			}
+			timer.Reset(batchTimeout)
+			close(done)
 		case <-ctx.Done():
-			// Drain remaining logs from channel
 		drain:
 			for {
 				select {
@@ -88,7 +125,8 @@ func (l *BatchLogger) run(ctx context.Context) {
 func (l *BatchLogger) processEvent(logs []Log) {
 	event := NewEvent()
 	event.Timestamp = time.Now()
+	event.EventID = EventID(uuid())
 	event.Type = logEvent.Type
 	event.Logs = logs
-	l.client.CaptureEvent(event, nil, nil)
+	l.client.Transport.SendEvent(event)
 }
diff --git client.go client.go
index ea29096b6..346230223 100644
--- client.go
+++ client.go
@@ -5,7 +5,6 @@ import (
 	"crypto/x509"
 	"fmt"
 	"io"
-	"log"
 	"math/rand"
 	"net/http"
 	"os"
@@ -15,24 +14,31 @@ import (
 	"time"
 
 	"github.com/getsentry/sentry-go/internal/debug"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // The identifier of the SDK.
 const sdkIdentifier = "sentry.go"
 
-// maxErrorDepth is the maximum number of errors reported in a chain of errors.
-// This protects the SDK from an arbitrarily long chain of wrapped errors.
-//
-// An additional consideration is that arguably reporting a long chain of errors
-// is of little use when debugging production errors with Sentry. The Sentry UI
-// is not optimized for long chains either. The top-level error together with a
-// stack trace is often the most useful information.
-const maxErrorDepth = 10
+const (
+	// maxErrorDepth is the maximum number of errors reported in a chain of errors.
+	// This protects the SDK from an arbitrarily long chain of wrapped errors.
+	//
+	// An additional consideration is that arguably reporting a long chain of errors
+	// is of little use when debugging production errors with Sentry. The Sentry UI
+	// is not optimized for long chains either. The top-level error together with a
+	// stack trace is often the most useful information.
+	maxErrorDepth = 100
+
+	// defaultMaxSpans limits the default number of recorded spans per transaction. The limit is
+	// meant to bound memory usage and prevent too large transaction events that
+	// would be rejected by Sentry.
+	defaultMaxSpans = 1000
 
-// defaultMaxSpans limits the default number of recorded spans per transaction. The limit is
-// meant to bound memory usage and prevent too large transaction events that
-// would be rejected by Sentry.
-const defaultMaxSpans = 1000
+	// defaultMaxBreadcrumbs is the default maximum number of breadcrumbs added to
+	// an event. Can be overwritten with the MaxBreadcrumbs option.
+	defaultMaxBreadcrumbs = 100
+)
 
 // hostname is the host name reported by the kernel. It is precomputed once to
 // avoid syscalls when capturing events.
@@ -78,8 +84,8 @@ type usageError struct {
 }
 
 // DebugLogger is an instance of log.Logger that is used to provide debug information about running Sentry Client
-// can be enabled by either using DebugLogger.SetOutput directly or with Debug client option.
-var DebugLogger = log.New(io.Discard, "[Sentry] ", log.LstdFlags)
+// can be enabled by either using debuglog.SetOutput directly or with Debug client option.
+var DebugLogger = debuglog.GetLogger()
 
 // EventProcessor is a function that processes an event.
 // Event processors are used to change an event before it is sent to Sentry.
@@ -228,6 +234,21 @@ type ClientOptions struct {
 	Tags map[string]string
 	// EnableLogs controls when logs should be emitted.
 	EnableLogs bool
+	// TraceIgnoreStatusCodes is a list of HTTP status codes that should not be traced.
+	// Each element can be either:
+	// - A single-element slice [code] for a specific status code
+	// - A two-element slice [min, max] for a range of status codes (inclusive)
+	// When an HTTP request results in a status code that matches any of these codes or ranges,
+	// the transaction will not be sent to Sentry.
+	//
+	// Examples:
+	//   [][]int{{404}}                           // ignore only status code 404
+	//   [][]int{{400, 405}}                     // ignore status codes 400-405
+	//   [][]int{{404}, {500}}                   // ignore status codes 404 and 500
+	//   [][]int{{404}, {400, 405}, {500, 599}}  // ignore 404, range 400-405, and range 500-599
+	//
+	// By default, this is empty and all status codes are traced.
+	TraceIgnoreStatusCodes [][]int
 }
 
 // Client is the underlying processor that is used by the main API and Hub
@@ -281,7 +302,7 @@ func NewClient(options ClientOptions) (*Client, error) {
 		if debugWriter == nil {
 			debugWriter = os.Stderr
 		}
-		DebugLogger.SetOutput(debugWriter)
+		debuglog.SetOutput(debugWriter)
 	}
 
 	if options.Dsn == "" {
@@ -386,12 +407,12 @@ func (client *Client) setupIntegrations() {
 
 	for _, integration := range integrations {
 		if client.integrationAlreadyInstalled(integration.Name()) {
-			DebugLogger.Printf("Integration %s is already installed\n", integration.Name())
+			debuglog.Printf("Integration %s is already installed\n", integration.Name())
 			continue
 		}
 		client.integrations = append(client.integrations, integration)
 		integration.SetupOnce(client)
-		DebugLogger.Printf("Integration installed: %s\n", integration.Name())
+		debuglog.Printf("Integration installed: %s\n", integration.Name())
 	}
 
 	sort.Slice(client.integrations, func(i, j int) bool {
@@ -511,7 +532,9 @@ func (client *Client) RecoverWithContext(
 // call to Init.
 func (client *Client) Flush(timeout time.Duration) bool {
 	if client.batchLogger != nil {
-		client.batchLogger.Flush()
+		ctx, cancel := context.WithTimeout(context.Background(), timeout)
+		defer cancel()
+		return client.FlushWithContext(ctx)
 	}
 	return client.Transport.Flush(timeout)
 }
@@ -530,7 +553,7 @@ func (client *Client) Flush(timeout time.Duration) bool {
 
 func (client *Client) FlushWithContext(ctx context.Context) bool {
 	if client.batchLogger != nil {
-		client.batchLogger.Flush()
+		client.batchLogger.Flush(ctx.Done())
 	}
 	return client.Transport.FlushWithContext(ctx)
 }
@@ -630,7 +653,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
 	// options.TracesSampler when they are started. Other events
 	// (errors, messages) are sampled here. Does not apply to check-ins.
 	if event.Type != transactionType && event.Type != checkInType && !sample(client.options.SampleRate) {
-		DebugLogger.Println("Event dropped due to SampleRate hit.")
+		debuglog.Println("Event dropped due to SampleRate hit.")
 		return nil
 	}
 
@@ -646,7 +669,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
 	case transactionType:
 		if client.options.BeforeSendTransaction != nil {
 			if event = client.options.BeforeSendTransaction(event, hint); event == nil {
-				DebugLogger.Println("Transaction dropped due to BeforeSendTransaction callback.")
+				debuglog.Println("Transaction dropped due to BeforeSendTransaction callback.")
 				return nil
 			}
 		}
@@ -654,7 +677,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
 	default:
 		if client.options.BeforeSend != nil {
 			if event = client.options.BeforeSend(event, hint); event == nil {
-				DebugLogger.Println("Event dropped due to BeforeSend callback.")
+				debuglog.Println("Event dropped due to BeforeSend callback.")
 				return nil
 			}
 		}
@@ -721,7 +744,7 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod
 		id := event.EventID
 		event = processor(event, hint)
 		if event == nil {
-			DebugLogger.Printf("Event dropped by one of the Client EventProcessors: %s\n", id)
+			debuglog.Printf("Event dropped by one of the Client EventProcessors: %s\n", id)
 			return nil
 		}
 	}
@@ -730,7 +753,7 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod
 		id := event.EventID
 		event = processor(event, hint)
 		if event == nil {
-			DebugLogger.Printf("Event dropped by one of the Global EventProcessors: %s\n", id)
+			debuglog.Printf("Event dropped by one of the Global EventProcessors: %s\n", id)
 			return nil
 		}
 	}
diff --git client_test.go client_test.go
index 7c09586de..de9d43418 100644
--- client_test.go
+++ client_test.go
@@ -5,6 +5,8 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"net"
+	"net/http"
 	"sync"
 	"sync/atomic"
 	"testing"
@@ -162,12 +164,15 @@ func TestCaptureException(t *testing.T) {
 			err:  pkgErrors.WithStack(&customErr{}),
 			want: []Exception{
 				{
-					Type:  "*sentry.customErr",
-					Value: "wat",
+					Type:       "*sentry.customErr",
+					Value:      "wat",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             MechanismTypeChained,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						Source:           MechanismTypeUnwrap,
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -175,10 +180,11 @@ func TestCaptureException(t *testing.T) {
 					Value:      "wat",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      1,
-						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						Type:             MechanismTypeGeneric,
+						ExceptionID:      0,
+						ParentID:         nil,
+						Source:           "",
+						IsExceptionGroup: false,
 					},
 				},
 			},
@@ -199,12 +205,15 @@ func TestCaptureException(t *testing.T) {
 			err:  &customErrWithCause{cause: &customErr{}},
 			want: []Exception{
 				{
-					Type:  "*sentry.customErr",
-					Value: "wat",
+					Type:       "*sentry.customErr",
+					Value:      "wat",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             MechanismTypeChained,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						Source:           "cause",
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -212,10 +221,11 @@ func TestCaptureException(t *testing.T) {
 					Value:      "err",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      1,
-						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						Type:             MechanismTypeGeneric,
+						ExceptionID:      0,
+						ParentID:         nil,
+						Source:           "",
+						IsExceptionGroup: false,
 					},
 				},
 			},
@@ -225,12 +235,15 @@ func TestCaptureException(t *testing.T) {
 			err:  wrappedError{original: errors.New("original")},
 			want: []Exception{
 				{
-					Type:  "*errors.errorString",
-					Value: "original",
+					Type:       "*errors.errorString",
+					Value:      "original",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             MechanismTypeChained,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						Source:           MechanismTypeUnwrap,
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -238,10 +251,11 @@ func TestCaptureException(t *testing.T) {
 					Value:      "wrapped: original",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      1,
-						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						Type:             MechanismTypeGeneric,
+						ExceptionID:      0,
+						ParentID:         nil,
+						Source:           "",
+						IsExceptionGroup: false,
 					},
 				},
 			},
@@ -697,6 +711,132 @@ func TestIgnoreTransactions(t *testing.T) {
 	}
 }
 
+func TestTraceIgnoreStatusCode_EmptyCode(t *testing.T) {
+	transport := &MockTransport{}
+	ctx := NewTestContext(ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+		Transport:        transport,
+	})
+
+	transaction := StartTransaction(ctx, "test")
+	// Transaction has no http.response.status_code
+	transaction.Finish()
+
+	dropped := transport.lastEvent == nil
+	assertEqual(t, dropped, false, "expected transaction to not be dropped")
+}
+
+func TestTraceIgnoreStatusCodes(t *testing.T) {
+	tests := map[string]struct {
+		ignoreStatusCodes [][]int
+		statusCode        interface{}
+		expectDrop        bool
+	}{
+		"No ignored codes": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{},
+			expectDrop:        false,
+		},
+		"Status code not in ignore ranges": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        false,
+		},
+		"404 in ignore range": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        true,
+		},
+		"403 in ignore range": {
+			statusCode:        403,
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        true,
+		},
+		"200 not ignored": {
+			statusCode:        200,
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        false,
+		},
+		"wrong code not ignored": {
+			statusCode:        "something",
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        false,
+		},
+		"Single status code as single-element slice": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{{404}},
+			expectDrop:        true,
+		},
+		"Single status code not in single-element slice": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{404}},
+			expectDrop:        false,
+		},
+		"Multiple single codes": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{404}, {500}},
+			expectDrop:        true,
+		},
+		"Multiple ranges - code in first range": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{{400, 405}, {500, 599}},
+			expectDrop:        true,
+		},
+		"Multiple ranges - code in second range": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{400, 405}, {500, 599}},
+			expectDrop:        true,
+		},
+		"Multiple ranges - code not in any range": {
+			statusCode:        200,
+			ignoreStatusCodes: [][]int{{400, 405}, {500, 599}},
+			expectDrop:        false,
+		},
+		"Mixed single codes and ranges": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{{404}, {500, 599}},
+			expectDrop:        true,
+		},
+		"Mixed single codes and ranges - code in range": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{404}, {500, 599}},
+			expectDrop:        true,
+		},
+		"Mixed single codes and ranges - code not matched": {
+			statusCode:        200,
+			ignoreStatusCodes: [][]int{{404}, {500, 599}},
+			expectDrop:        false,
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			transport := &MockTransport{}
+			ctx := NewTestContext(ClientOptions{
+				EnableTracing:          true,
+				TracesSampleRate:       1.0,
+				Transport:              transport,
+				TraceIgnoreStatusCodes: tt.ignoreStatusCodes,
+			})
+
+			transaction := StartTransaction(ctx, "test")
+			// Simulate HTTP response data like the integrations do
+			transaction.SetData("http.response.status_code", tt.statusCode)
+			transaction.Finish()
+
+			dropped := transport.lastEvent == nil
+			if tt.expectDrop != dropped {
+				if tt.expectDrop {
+					t.Errorf("expected transaction with status code %d to be dropped", tt.statusCode)
+				} else {
+					t.Errorf("expected transaction with status code %d not to be dropped", tt.statusCode)
+				}
+			}
+		})
+	}
+}
+
 func TestSampleRate(t *testing.T) {
 	tests := []struct {
 		SampleRate float64
@@ -871,8 +1011,18 @@ func TestSDKIdentifier(t *testing.T) {
 }
 
 func TestClientSetsUpTransport(t *testing.T) {
-	client, _ := NewClient(ClientOptions{Dsn: testDsn})
-	require.IsType(t, &HTTPTransport{}, client.Transport)
+	client, _ := NewClient(ClientOptions{
+		Dsn: testDsn,
+		HTTPClient: &http.Client{
+			Transport: &http.Transport{
+				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+					return nil, fmt.Errorf("mock transport - no real connections")
+				},
+			},
+		},
+		Transport: &MockTransport{},
+	})
+	require.IsType(t, &MockTransport{}, client.Transport)
 
 	client, _ = NewClient(ClientOptions{})
 	require.IsType(t, &noopTransport{}, client.Transport)
diff --git echo/go.mod echo/go.mod
index 8cecf3df8..2a835b05e 100644
--- echo/go.mod
+++ echo/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/echo
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.0
 	github.com/google/go-cmp v0.5.9
 	github.com/labstack/echo/v4 v4.10.0
 )
diff --git a/exception.go b/exception.go
new file mode 100644
index 000000000..46e8ba868
--- /dev/null
+++ exception.go
@@ -0,0 +1,103 @@
+package sentry
+
+import (
+	"fmt"
+	"reflect"
+	"slices"
+)
+
+const (
+	MechanismTypeGeneric string = "generic"
+	MechanismTypeChained string = "chained"
+	MechanismTypeUnwrap  string = "unwrap"
+	MechanismSourceCause string = "cause"
+)
+
+func convertErrorToExceptions(err error, maxErrorDepth int) []Exception {
+	var exceptions []Exception
+	visited := make(map[error]bool)
+	convertErrorDFS(err, &exceptions, nil, "", visited, maxErrorDepth, 0)
+
+	// mechanism type is used for debugging purposes, but since we can't really distinguish the origin of who invoked
+	// captureException, we set it to nil if the error is not chained.
+	if len(exceptions) == 1 {
+		exceptions[0].Mechanism = nil
+	}
+
+	slices.Reverse(exceptions)
+
+	// Add a trace of the current stack to the top level(outermost) error in a chain if
+	// it doesn't have a stack trace yet.
+	// We only add to the most recent error to avoid duplication and because the
+	// current stack is most likely unrelated to errors deeper in the chain.
+	if len(exceptions) > 0 && exceptions[len(exceptions)-1].Stacktrace == nil {
+		exceptions[len(exceptions)-1].Stacktrace = NewStacktrace()
+	}
+
+	return exceptions
+}
+
+func convertErrorDFS(err error, exceptions *[]Exception, parentID *int, source string, visited map[error]bool, maxErrorDepth int, currentDepth int) {
+	if err == nil {
+		return
+	}
+
+	if visited[err] {
+		return
+	}
+	visited[err] = true
+
+	_, isExceptionGroup := err.(interface{ Unwrap() []error })
+
+	exception := Exception{
+		Value:      err.Error(),
+		Type:       reflect.TypeOf(err).String(),
+		Stacktrace: ExtractStacktrace(err),
+	}
+
+	currentID := len(*exceptions)
+
+	var mechanismType string
+
+	if parentID == nil {
+		mechanismType = MechanismTypeGeneric
+		source = ""
+	} else {
+		mechanismType = MechanismTypeChained
+	}
+
+	exception.Mechanism = &Mechanism{
+		Type:             mechanismType,
+		ExceptionID:      currentID,
+		ParentID:         parentID,
+		Source:           source,
+		IsExceptionGroup: isExceptionGroup,
+	}
+
+	*exceptions = append(*exceptions, exception)
+
+	if maxErrorDepth >= 0 && currentDepth >= maxErrorDepth {
+		return
+	}
+
+	switch v := err.(type) {
+	case interface{ Unwrap() []error }:
+		unwrapped := v.Unwrap()
+		for i := range unwrapped {
+			if unwrapped[i] != nil {
+				childSource := fmt.Sprintf("errors[%d]", i)
+				convertErrorDFS(unwrapped[i], exceptions, &currentID, childSource, visited, maxErrorDepth, currentDepth+1)
+			}
+		}
+	case interface{ Unwrap() error }:
+		unwrapped := v.Unwrap()
+		if unwrapped != nil {
+			convertErrorDFS(unwrapped, exceptions, &currentID, MechanismTypeUnwrap, visited, maxErrorDepth, currentDepth+1)
+		}
+	case interface{ Cause() error }:
+		cause := v.Cause()
+		if cause != nil {
+			convertErrorDFS(cause, exceptions, &currentID, MechanismSourceCause, visited, maxErrorDepth, currentDepth+1)
+		}
+	}
+}
diff --git a/exception_test.go b/exception_test.go
new file mode 100644
index 000000000..e8970f9ff
--- /dev/null
+++ exception_test.go
@@ -0,0 +1,379 @@
+package sentry
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestConvertErrorToExceptions(t *testing.T) {
+	tests := []struct {
+		name     string
+		err      error
+		expected []Exception
+	}{
+		{
+			name:     "nil error",
+			err:      nil,
+			expected: nil,
+		},
+		{
+			name: "single error",
+			err:  errors.New("single error"),
+			expected: []Exception{
+				{
+					Value:      "single error",
+					Type:       "*errors.errorString",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+				},
+			},
+		},
+		{
+			name: "errors.Join with multiple errors",
+			err:  errors.Join(errors.New("error A"), errors.New("error B")),
+			expected: []Exception{
+				{
+					Value:      "error B",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      2,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value:      "error A\nerror B",
+					Type:       "*errors.joinError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: true,
+					},
+				},
+			},
+		},
+		{
+			name: "nested wrapped error with errors.Join",
+			err:  fmt.Errorf("wrapper: %w", errors.Join(errors.New("error A"), errors.New("error B"))),
+			expected: []Exception{
+				{
+					Value:      "error B",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      3,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      2,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A\nerror B",
+					Type:  "*errors.joinError",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: true,
+					},
+				},
+				{
+					Value:      "wrapper: error A\nerror B",
+					Type:       "*fmt.wrapError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := convertErrorToExceptions(tt.err, -1)
+
+			if tt.expected == nil {
+				if result != nil {
+					t.Errorf("expected nil result, got %+v", result)
+				}
+				return
+			}
+
+			if diff := cmp.Diff(tt.expected, result); diff != "" {
+				t.Errorf("Exception mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+// AggregateError represents multiple errors occurring together
+// This simulates JavaScript's AggregateError for testing purposes.
+type AggregateError struct {
+	Message string
+	Errors  []error
+}
+
+func (e *AggregateError) Error() string {
+	if e.Message != "" {
+		return e.Message
+	}
+	return "Multiple errors occurred"
+}
+
+func (e *AggregateError) Unwrap() []error {
+	return e.Errors
+}
+
+func TestExceptionGroupsWithAggregateError(t *testing.T) {
+	tests := []struct {
+		name     string
+		err      error
+		expected []Exception
+	}{
+		{
+			name: "AggregateError with custom message",
+			err: &AggregateError{
+				Message: "Request failed due to multiple errors",
+				Errors: []error{
+					errors.New("network timeout"),
+					errors.New("authentication failed"),
+					errors.New("rate limit exceeded"),
+				},
+			},
+			expected: []Exception{
+				{
+					Value:      "rate limit exceeded",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[2]",
+						ExceptionID:      3,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "authentication failed",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      2,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "network timeout",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value:      "Request failed due to multiple errors",
+					Type:       "*sentry.AggregateError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: true,
+					},
+				},
+			},
+		},
+		{
+			name: "Nested AggregateError with wrapper",
+			err: fmt.Errorf("operation failed: %w", &AggregateError{
+				Message: "Multiple validation errors",
+				Errors: []error{
+					errors.New("field 'email' is required"),
+					errors.New("field 'password' is too short"),
+				},
+			}),
+			expected: []Exception{
+				{
+					Value:      "field 'password' is too short",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      3,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "field 'email' is required",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      2,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "Multiple validation errors",
+					Type:  "*sentry.AggregateError",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: true,
+					},
+				},
+				{
+					Value:      "operation failed: Multiple validation errors",
+					Type:       "*fmt.wrapError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			event := &Event{}
+			event.SetException(tt.err, 10) // Use high max depth
+
+			if diff := cmp.Diff(tt.expected, event.Exception); diff != "" {
+				t.Errorf("Exception mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+type CircularError struct {
+	Message string
+	Next    error
+}
+
+func (e *CircularError) Error() string {
+	return e.Message
+}
+
+func (e *CircularError) Unwrap() error {
+	return e.Next
+}
+
+func TestCircularReferenceProtection(t *testing.T) {
+	tests := []struct {
+		name        string
+		setupError  func() error
+		description string
+		maxDepth    int
+	}{
+		{
+			name: "self-reference",
+			setupError: func() error {
+				err := &CircularError{Message: "self-referencing error"}
+				err.Next = err
+				return err
+			},
+			description: "Error that directly references itself",
+			maxDepth:    1,
+		},
+		{
+			name: "chain-loop",
+			setupError: func() error {
+				err1 := &CircularError{Message: "error A"}
+				err2 := &CircularError{Message: "error B"}
+				err1.Next = err2
+				err2.Next = err1 // Creates A -> B -> A cycle
+				return err1
+			},
+			description: "Two errors that reference each other in a cycle",
+			maxDepth:    2,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.setupError()
+
+			start := time.Now()
+			exceptions := convertErrorToExceptions(err, -1)
+			duration := time.Since(start)
+
+			if duration > 100*time.Millisecond {
+				t.Errorf("convertErrorToExceptions took too long: %v, possible infinite recursion", duration)
+			}
+
+			if len(exceptions) == 0 {
+				t.Error("Expected at least one exception, got none")
+				return
+			}
+
+			if len(exceptions) != tt.maxDepth {
+				t.Errorf("Expected exactly %d exceptions (before cycle detection), got %d", tt.maxDepth, len(exceptions))
+			}
+
+			for i, exception := range exceptions {
+				if exception.Value == "" {
+					t.Errorf("Exception %d has empty value", i)
+				}
+				if exception.Type == "" {
+					t.Errorf("Exception %d has empty type", i)
+				}
+			}
+
+			t.Logf("✓ Successfully handled %s: got %d exceptions in %v", tt.description, len(exceptions), duration)
+		})
+	}
+}
diff --git fasthttp/go.mod fasthttp/go.mod
index 2b7ddf5b6..04278cfc9 100644
--- fasthttp/go.mod
+++ fasthttp/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/fasthttp
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.0
 	github.com/google/go-cmp v0.5.9
 	github.com/valyala/fasthttp v1.52.0
 )
diff --git fasthttp/go.sum fasthttp/go.sum
index 1bba7c1c6..574570aaa 100644
--- fasthttp/go.sum
+++ fasthttp/go.sum
@@ -14,8 +14,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
diff --git fasthttp/sentryfasthttp.go fasthttp/sentryfasthttp.go
index 86b444ddc..fea14ddd6 100644
--- fasthttp/sentryfasthttp.go
+++ fasthttp/sentryfasthttp.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/getsentry/sentry-go"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 	"github.com/valyala/fasthttp"
 )
 
@@ -143,7 +144,7 @@ func GetSpanFromContext(ctx *fasthttp.RequestCtx) *sentry.Span {
 func convert(ctx *fasthttp.RequestCtx) *http.Request {
 	defer func() {
 		if err := recover(); err != nil {
-			sentry.DebugLogger.Printf("%v", err)
+			debuglog.Printf("%v", err)
 		}
 	}()
 
@@ -152,9 +153,11 @@ func convert(ctx *fasthttp.RequestCtx) *http.Request {
 	r.Method = string(ctx.Method())
 
 	uri := ctx.URI()
-	url, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path()))
-	if err == nil {
-		r.URL = url
+	r.URL = &url.URL{Path: string(uri.Path())}
+	r.URL.RawQuery = string(uri.QueryString())
+
+	if parsedURL, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path())); err == nil {
+		r.URL = parsedURL
 		r.URL.RawQuery = string(uri.QueryString())
 	}
 
diff --git fasthttp/sentryfasthttp_test.go fasthttp/sentryfasthttp_test.go
index 5600c42d3..c59b438f9 100644
--- fasthttp/sentryfasthttp_test.go
+++ fasthttp/sentryfasthttp_test.go
@@ -571,3 +571,35 @@ func TestSetHubOnContext(t *testing.T) {
 		t.Fatalf("expected hub to be %v, but got %v", hub, retrievedHub)
 	}
 }
+
+// TestMalformedURLNoPanic verifies that malformed URLs don't cause panics
+// when tracing is enabled
+func TestMalformedURLNoPanic(t *testing.T) {
+	err := sentry.Init(sentry.ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	sentryHandler := sentryfasthttp.New(sentryfasthttp.Options{})
+
+	handler := sentryHandler.Handle(func(ctx *fasthttp.RequestCtx) {
+		ctx.SetStatusCode(fasthttp.StatusOK)
+		ctx.SetBodyString("OK")
+	})
+
+	ctx := &fasthttp.RequestCtx{}
+	ctx.Request.SetRequestURI("http://localhost/%zz")
+	ctx.Request.Header.SetMethod("GET")
+	ctx.Request.SetHost("localhost")
+	ctx.Request.Header.Set("User-Agent", "fasthttp")
+
+	handler(ctx)
+
+	// Should complete successfully without panic
+	if ctx.Response.StatusCode() != fasthttp.StatusOK {
+		t.Errorf("Expected 200, got %d", ctx.Response.StatusCode())
+	}
+}
diff --git fiber/go.mod fiber/go.mod
index f6e6f7199..3b895ff58 100644
--- fiber/go.mod
+++ fiber/go.mod
@@ -1,12 +1,12 @@
 module github.com/getsentry/sentry-go/fiber
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
-	github.com/gofiber/fiber/v2 v2.52.5
+	github.com/getsentry/sentry-go v0.36.0
+	github.com/gofiber/fiber/v2 v2.52.9
 	github.com/google/go-cmp v0.5.9
 )
 
diff --git fiber/go.sum fiber/go.sum
index aa0d31210..523708b5d 100644
--- fiber/go.sum
+++ fiber/go.sum
@@ -4,8 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
 github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
-github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
-github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
+github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
+github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -28,8 +28,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.57.0 h1:Xw8SjWGEP/+wAAgyy5XTvgrWlOD1+TxbbvNADYCm1Tg=
diff --git fiber/sentryfiber.go fiber/sentryfiber.go
index f8ce16dca..1e44f9fd5 100644
--- fiber/sentryfiber.go
+++ fiber/sentryfiber.go
@@ -13,6 +13,7 @@ import (
 	"github.com/gofiber/fiber/v2/utils"
 
 	"github.com/getsentry/sentry-go"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 const (
@@ -143,7 +144,7 @@ func GetSpanFromContext(ctx *fiber.Ctx) *sentry.Span {
 func convert(ctx *fiber.Ctx) *http.Request {
 	defer func() {
 		if err := recover(); err != nil {
-			sentry.DebugLogger.Printf("%v", err)
+			debuglog.Printf("%v", err)
 		}
 	}()
 
@@ -152,9 +153,11 @@ func convert(ctx *fiber.Ctx) *http.Request {
 	r.Method = utils.CopyString(ctx.Method())
 
 	uri := ctx.Request().URI()
-	url, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path()))
-	if err == nil {
-		r.URL = url
+	r.URL = &url.URL{Path: string(uri.Path())}
+	r.URL.RawQuery = string(uri.QueryString())
+
+	if parsedURL, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path())); err == nil {
+		r.URL = parsedURL
 		r.URL.RawQuery = string(uri.QueryString())
 	}
 
diff --git fiber/sentryfiber_test.go fiber/sentryfiber_test.go
index 842215e57..bf41fe6b2 100644
--- fiber/sentryfiber_test.go
+++ fiber/sentryfiber_test.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"reflect"
 	"strings"
 	"testing"
@@ -594,3 +595,40 @@ func TestSetHubOnContext(t *testing.T) {
 		t.Fatalf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode)
 	}
 }
+
+// TestMalformedURLNoPanic verifies that malformed URLs don't cause panics
+// when tracing is enabled,
+func TestMalformedURLNoPanic(t *testing.T) {
+	err := sentry.Init(sentry.ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	app := fiber.New()
+	app.Use(sentryfiber.New(sentryfiber.Options{Timeout: 3 * time.Second, WaitForDelivery: true}))
+
+	app.Get("/*", func(c *fiber.Ctx) error {
+		return c.SendString("OK")
+	})
+
+	req := &http.Request{
+		Method: "GET",
+		URL:    &url.URL{Scheme: "http", Host: "localhost", Path: "/%zz"},
+		Header: make(http.Header),
+		Host:   "localhost",
+	}
+	req.Header.Set("User-Agent", "fiber")
+
+	resp, err := app.Test(req)
+	if err != nil {
+		t.Fatalf("Request failed: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("Expected 200, got %d", resp.StatusCode)
+	}
+}
diff --git gin/go.mod gin/go.mod
index 20b6df784..7f50b6cc8 100644
--- gin/go.mod
+++ gin/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/gin
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.0
 	github.com/gin-gonic/gin v1.9.1
 	github.com/google/go-cmp v0.5.9
 )
diff --git go.mod go.mod
index 8693fe4b8..ae180f2b8 100644
--- go.mod
+++ go.mod
@@ -1,6 +1,6 @@
 module github.com/getsentry/sentry-go
 
-go 1.21
+go 1.23
 
 require (
 	github.com/go-errors/errors v1.4.2
diff --git hub.go hub.go
index 8aea27377..30d8c1a72 100644
--- hub.go
+++ hub.go
@@ -5,6 +5,8 @@ import (
 	"fmt"
 	"sync"
 	"time"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 type contextKey int
@@ -18,14 +20,6 @@ const (
 	RequestContextKey = contextKey(2)
 )
 
-// defaultMaxBreadcrumbs is the default maximum number of breadcrumbs added to
-// an event. Can be overwritten with the maxBreadcrumbs option.
-const defaultMaxBreadcrumbs = 30
-
-// maxBreadcrumbs is the absolute maximum number of breadcrumbs added to an
-// event. The maxBreadcrumbs option cannot be set higher than this value.
-const maxBreadcrumbs = 100
-
 // currentHub is the initial Hub with no Client bound and an empty Scope.
 var currentHub = NewHub(nil, NewScope())
 
@@ -289,7 +283,7 @@ func (hub *Hub) AddBreadcrumb(breadcrumb *Breadcrumb, hint *,BreadcrumbHint) {
 
 	// If there's no client, just store it on the scope straight away
 	if client == nil {
-		hub.Scope().AddBreadcrumb(breadcrumb, maxBreadcrumbs)
+		hub.Scope().AddBreadcrumb(breadcrumb, defaultMaxBreadcrumbs)
 		return
 	}
 
@@ -299,8 +293,6 @@ func (hub *Hub) AddBreadcrumb(breadcrumb *Breadcrumb, hint *BreadcrumbHint) {
 		return
 	case limit == 0:
 		limit = defaultMaxBreadcrumbs
-	case limit > maxBreadcrumbs:
-		limit = maxBreadcrumbs
 	}
 
 	if client.options.BeforeBreadcrumb != nil {
@@ -308,7 +300,7 @@ func (hub *Hub) AddBreadcrumb(breadcrumb *Breadcrumb, hint *BreadcrumbHint) {
 			hint = &BreadcrumbHint{}
 		}
 		if breadcrumb = client.options.BeforeBreadcrumb(breadcrumb, hint); breadcrumb == nil {
-			DebugLogger.Println("breadcrumb dropped due to BeforeBreadcrumb callback.")
+			debuglog.Println("breadcrumb dropped due to BeforeBreadcrumb callback.")
 			return
 		}
 	}
diff --git hub_test.go hub_test.go
index 184062179..ee98051ea 100644
--- hub_test.go
+++ hub_test.go
@@ -247,19 +247,6 @@ func TestAddBreadcrumbSkipAllBreadcrumbsIfMaxBreadcrumbsIsLessThanZero(t *testin
 	assertEqual(t, len(scope.breadcrumbs), 0)
 }
 
-func TestAddBreadcrumbShouldNeverExceedMaxBreadcrumbsConst(t *testing.T) {
-	hub, client, scope := setupHubTest()
-	client.options.MaxBreadcrumbs = 1000
-
-	breadcrumb := &Breadcrumb{Message: "Breadcrumb"}
-
-	for i := 0; i < 111; i++ {
-		hub.AddBreadcrumb(breadcrumb, nil)
-	}
-
-	assertEqual(t, len(scope.breadcrumbs), 100)
-}
-
 func TestAddBreadcrumbShouldWorkWithoutClient(t *testing.T) {
 	scope := NewScope()
 	hub := NewHub(nil, scope)
diff --git integrations.go integrations.go
index 70acf9147..60cc73d57 100644
--- integrations.go
+++ integrations.go
@@ -8,6 +8,8 @@ import (
 	"runtime/debug"
 	"strings"
 	"sync"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // ================================
@@ -32,7 +34,7 @@ func (mi *modulesIntegration) processor(event *Event, _ *EventHint) *Event {
 		mi.once.Do(func() {
 			info, ok := debug.ReadBuildInfo()
 			if !ok {
-				DebugLogger.Print("The Modules integration is not available in binaries built without module support.")
+				debuglog.Print("The Modules integration is not available in binaries built without module support.")
 				return
 			}
 			mi.modules = extractModules(info)
@@ -141,7 +143,7 @@ func (iei *ignoreErrorsIntegration) processor(event *Event, _ *EventHint) *Event
 	for _, suspect := range suspects {
 		for _, pattern := range iei.ignoreErrors {
 			if pattern.Match([]byte(suspect)) || strings.Contains(suspect, pattern.String()) {
-				DebugLogger.Printf("Event dropped due to being matched by `IgnoreErrors` option."+
+				debuglog.Printf("Event dropped due to being matched by `IgnoreErrors` option."+
 					"| Value matched: %s | Filter used: %s", suspect, pattern)
 				return nil
 			}
@@ -203,7 +205,7 @@ func (iei *ignoreTransactionsIntegration) processor(event *Event, _ *EventHint)
 
 	for _, pattern := range iei.ignoreTransactions {
 		if pattern.Match([]byte(suspect)) || strings.Contains(suspect, pattern.String()) {
-			DebugLogger.Printf("Transaction dropped due to being matched by `IgnoreTransactions` option."+
+			debuglog.Printf("Transaction dropped due to being matched by `IgnoreTransactions` option."+
 				"| Value matched: %s | Filter used: %s", suspect, pattern)
 			return nil
 		}
diff --git interfaces.go interfaces.go
index 2884bbb14..33d569977 100644
--- interfaces.go
+++ interfaces.go
@@ -3,16 +3,14 @@ package sentry
 import (
 	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"net"
 	"net/http"
-	"reflect"
-	"slices"
 	"strings"
 	"time"
 
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/ratelimit"
 )
 
 const eventType = "event"
@@ -101,6 +99,7 @@ func (b *Breadcrumb) MarshalJSON() ([]byte, error) {
 	return json.Marshal((*breadcrumb)(b))
 }
 
+// Logger provides a chaining API for structured logging to Sentry.
 type Logger interface {
 	// Write implements the io.Writer interface. Currently, the [sentry.Hub] is
 	// context aware, in order to get the correct trace correlation. Using this
@@ -108,51 +107,47 @@ type Logger interface {
 	// Write it is recommended to create a NewLogger so that the associated context
 	// is passed correctly.
 	Write(p []byte) (n int, err error)
-	// Trace emits a [LogLevelTrace] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Trace(ctx context.Context, v ...interface{})
-	// Debug emits a [LogLevelDebug] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Debug(ctx context.Context, v ...interface{})
-	// Info emits a [LogLevelInfo] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Info(ctx context.Context, v ...interface{})
-	// Warn emits a [LogLevelWarn] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Warn(ctx context.Context, v ...interface{})
-	// Error emits a [LogLevelError] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Error(ctx context.Context, v ...interface{})
-	// Fatal emits a [LogLevelFatal] log to Sentry followed by a call to [os.Exit](1).
-	// Arguments are handled in the manner of [fmt.Print].
-	Fatal(ctx context.Context, v ...interface{})
-	// Panic emits a [LogLevelFatal] log to Sentry followed by a call to panic().
-	// Arguments are handled in the manner of [fmt.Print].
-	Panic(ctx context.Context, v ...interface{})
-
-	// Tracef emits a [LogLevelTrace] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Tracef(ctx context.Context, format string, v ...interface{})
-	// Debugf emits a [LogLevelDebug] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Debugf(ctx context.Context, format string, v ...interface{})
-	// Infof emits a [LogLevelInfo] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Infof(ctx context.Context, format string, v ...interface{})
-	// Warnf emits a [LogLevelWarn] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Warnf(ctx context.Context, format string, v ...interface{})
-	// Errorf emits a [LogLevelError] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Errorf(ctx context.Context, format string, v ...interface{})
-	// Fatalf emits a [LogLevelFatal] log to Sentry followed by a call to [os.Exit](1).
-	// Arguments are handled in the manner of [fmt.Printf].
-	Fatalf(ctx context.Context, format string, v ...interface{})
-	// Panicf emits a [LogLevelFatal] log to Sentry followed by a call to panic().
-	// Arguments are handled in the manner of [fmt.Printf].
-	Panicf(ctx context.Context, format string, v ...interface{})
-	// SetAttributes allows attaching parameters to the log message using the attribute API.
+
+	// SetAttributes allows attaching parameters to the logger using the attribute API.
+	// These attributes will be included in all subsequent log entries.
 	SetAttributes(...attribute.Builder)
+
+	// Trace defines the [sentry.LogLevel] for the log entry.
+	Trace() LogEntry
+	// Debug defines the [sentry.LogLevel] for the log entry.
+	Debug() LogEntry
+	// Info defines the [sentry.LogLevel] for the log entry.
+	Info() LogEntry
+	// Warn defines the [sentry.LogLevel] for the log entry.
+	Warn() LogEntry
+	// Error defines the [sentry.LogLevel] for the log entry.
+	Error() LogEntry
+	// Fatal defines the [sentry.LogLevel] for the log entry.
+	Fatal() LogEntry
+	// Panic defines the [sentry.LogLevel] for the log entry.
+	Panic() LogEntry
+	// GetCtx returns the [context.Context] set on the logger.
+	GetCtx() context.Context
+}
+
+// LogEntry defines the interface for a log entry that supports chaining attributes.
+type LogEntry interface {
+	// WithCtx creates a new LogEntry with the specified context without overwriting the previous one.
+	WithCtx(ctx context.Context) LogEntry
+	// String adds a string attribute to the LogEntry.
+	String(key, value string) LogEntry
+	// Int adds an int attribute to the LogEntry.
+	Int(key string, value int) LogEntry
+	// Int64 adds an int64 attribute to the LogEntry.
+	Int64(key string, value int64) LogEntry
+	// Float64 adds a float64 attribute to the LogEntry.
+	Float64(key string, value float64) LogEntry
+	// Bool adds a bool attribute to the LogEntry.
+	Bool(key string, value bool) LogEntry
+	// Emit emits the LogEntry with the provided arguments.
+	Emit(args ...interface{})
+	// Emitf emits the LogEntry using a format string and arguments.
+	Emitf(format string, args ...interface{})
 }
 
 // Attachment allows associating files with your events to aid in investigation.
@@ -298,7 +293,7 @@ func NewRequest(r *http.Request) *Request {
 
 // Mechanism is the mechanism by which an exception was generated and handled.
 type Mechanism struct {
-	Type             string         `json:"type,omitempty"`
+	Type             string         `json:"type"`
 	Description      string         `json:"description,omitempty"`
 	HelpLink         string         `json:"help_link,omitempty"`
 	Source           string         `json:"source,omitempty"`
@@ -427,64 +422,12 @@ func (e *Event) SetException(exception error, maxErrorDepth int) {
 		return
 	}
 
-	err := exception
-
-	for i := 0; err != nil && (i < maxErrorDepth || maxErrorDepth == -1); i++ {
-		// Add the current error to the exception slice with its details
-		e.Exception = append(e.Exception, Exception{
-			Value:      err.Error(),
-			Type:       reflect.TypeOf(err).String(),
-			Stacktrace: ExtractStacktrace(err),
-		})
-
-		// Attempt to unwrap the error using the standard library's Unwrap method.
-		// If errors.Unwrap returns nil, it means either there is no error to unwrap,
-		// or the error does not implement the Unwrap method.
-		unwrappedErr := errors.Unwrap(err)
-
-		if unwrappedErr != nil {
-			// The error was successfully unwrapped using the standard library's Unwrap method.
-			err = unwrappedErr
-			continue
-		}
-
-		cause, ok := err.(interface{ Cause() error })
-		if !ok {
-			// We cannot unwrap the error further.
-			break
-		}
-
-		// The error implements the Cause method, indicating it may have been wrapped
-		// using the github.com/pkg/errors package.
-		err = cause.Cause()
-	}
-
-	// Add a trace of the current stack to the most recent error in a chain if
-	// it doesn't have a stack trace yet.
-	// We only add to the most recent error to avoid duplication and because the
-	// current stack is most likely unrelated to errors deeper in the chain.
-	if e.Exception[0].Stacktrace == nil {
-		e.Exception[0].Stacktrace = NewStacktrace()
-	}
-
-	if len(e.Exception) <= 1 {
+	exceptions := convertErrorToExceptions(exception, maxErrorDepth)
+	if len(exceptions) == 0 {
 		return
 	}
 
-	// event.Exception should be sorted such that the most recent error is last.
-	slices.Reverse(e.Exception)
-
-	for i := range e.Exception {
-		e.Exception[i].Mechanism = &Mechanism{
-			IsExceptionGroup: true,
-			ExceptionID:      i,
-			Type:             "generic",
-		}
-		if i == 0 {
-			continue
-		}
-		e.Exception[i].Mechanism.ParentID = Pointer(i - 1)
-	}
+	e.Exception = exceptions
 }
 
 // TODO: Event.Contexts map[string]interface{} => map[string]EventContext,
@@ -595,16 +538,33 @@ func (e *Event) checkInMarshalJSON() ([]byte, error) {
 
 	if e.MonitorConfig != nil {
 		checkIn.MonitorConfig = &MonitorConfig{
-			Schedule:      e.MonitorConfig.Schedule,
-			CheckInMargin: e.MonitorConfig.CheckInMargin,
-			MaxRuntime:    e.MonitorConfig.MaxRuntime,
-			Timezone:      e.MonitorConfig.Timezone,
+			Schedule:              e.MonitorConfig.Schedule,
+			CheckInMargin:         e.MonitorConfig.CheckInMargin,
+			MaxRuntime:            e.MonitorConfig.MaxRuntime,
+			Timezone:              e.MonitorConfig.Timezone,
+			FailureIssueThreshold: e.MonitorConfig.FailureIssueThreshold,
+			RecoveryThreshold:     e.MonitorConfig.RecoveryThreshold,
 		}
 	}
 
 	return json.Marshal(checkIn)
 }
 
+func (e *Event) toCategory() ratelimit.Category {
+	switch e.Type {
+	case "":
+		return ratelimit.CategoryError
+	case transactionType:
+		return ratelimit.CategoryTransaction
+	case logEvent.Type:
+		return ratelimit.CategoryLog
+	case checkInType:
+		return ratelimit.CategoryMonitor
+	default:
+		return ratelimit.CategoryUnknown
+	}
+}
+
 // NewEvent creates a new Event.
 func NewEvent() *Event {
 	return &Event{
@@ -644,7 +604,17 @@ type Log struct {
 	Attributes map[string]Attribute `json:"attributes,omitempty"`
 }
 
+type AttrType string
+
+const (
+	AttributeInvalid AttrType = ""
+	AttributeBool    AttrType = "boolean"
+	AttributeInt     AttrType = "integer"
+	AttributeFloat   AttrType = "double"
+	AttributeString  AttrType = "string"
+)
+
 type Attribute struct {
-	Value any    `json:"value"`
-	Type  string `json:"type"`
+	Value any      `json:"value"`
+	Type  AttrType `json:"type"`
 }
diff --git interfaces_test.go interfaces_test.go
index c7f3195dc..a9e31e54e 100644
--- interfaces_test.go
+++ interfaces_test.go
@@ -12,6 +12,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/getsentry/sentry-go/internal/ratelimit"
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -247,6 +248,7 @@ func TestSetException(t *testing.T) {
 					Value:      "simple error",
 					Type:       "*errors.errorString",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism:  nil,
 				},
 			},
 		},
@@ -255,22 +257,26 @@ func TestSetException(t *testing.T) {
 			maxErrorDepth: 3,
 			expected: []Exception{
 				{
-					Value: "base error",
-					Type:  "*errors.errorString",
+					Value:      "base error",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
+						ExceptionID:      2,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
 					},
 				},
 				{
 					Value: "level 1: base error",
 					Type:  "*fmt.wrapError",
 					Mechanism: &Mechanism{
-						Type:             "generic",
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
 						ExceptionID:      1,
 						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -279,9 +285,10 @@ func TestSetException(t *testing.T) {
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
 						Type:             "generic",
-						ExceptionID:      2,
-						ParentID:         Pointer(1),
-						IsExceptionGroup: true,
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
 					},
 				},
 			},
@@ -296,6 +303,7 @@ func TestSetException(t *testing.T) {
 					Value:      "custom error message",
 					Type:       "*sentry.customError",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism:  nil,
 				},
 			},
 		},
@@ -307,22 +315,26 @@ func TestSetException(t *testing.T) {
 			maxErrorDepth: 3,
 			expected: []Exception{
 				{
-					Value: "the cause",
-					Type:  "*errors.errorString",
+					Value:      "the cause",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             "chained",
+						Source:           MechanismSourceCause,
+						ExceptionID:      2,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
 					},
 				},
 				{
 					Value: "error with cause",
 					Type:  "*sentry.withCause",
 					Mechanism: &Mechanism{
-						Type:             "generic",
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
 						ExceptionID:      1,
 						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -331,11 +343,116 @@ func TestSetException(t *testing.T) {
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
 						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
+					},
+				},
+			},
+		},
+		"errors.Join with multiple errors": {
+			exception:     errors.Join(errors.New("error 1"), errors.New("error 2"), errors.New("error 3")),
+			maxErrorDepth: 5,
+			expected: []Exception{
+				{
+					Value:      "error 3",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[2]",
+						ExceptionID:      3,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error 2",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      2,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error 1",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value:      "error 1\nerror 2\nerror 3",
+					Type:       "*errors.joinError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: true,
+					},
+				},
+			},
+		},
+		"Nested errors.Join with fmt.Errorf": {
+			exception:     fmt.Errorf("wrapper: %w", errors.Join(errors.New("error A"), errors.New("error B"))),
+			maxErrorDepth: 5,
+			expected: []Exception{
+				{
+					Value:      "error B",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      3,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
 						ExceptionID:      2,
 						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A\nerror B",
+					Type:  "*errors.joinError",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
 						IsExceptionGroup: true,
 					},
 				},
+				{
+					Value:      "wrapper: error A\nerror B",
+					Type:       "*fmt.wrapError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
+					},
+				},
 			},
 		},
 	}
@@ -520,3 +637,26 @@ func TestStructSnapshots(t *testing.T) {
 		})
 	}
 }
+
+func TestEvent_ToCategory(t *testing.T) {
+	cases := []struct {
+		name      string
+		eventType string
+		want      ratelimit.Category
+	}{
+		{"error", "", ratelimit.CategoryError},
+		{"transaction", transactionType, ratelimit.CategoryTransaction},
+		{"log", logEvent.Type, ratelimit.CategoryLog},
+		{"checkin", checkInType, ratelimit.CategoryMonitor},
+		{"unknown", "foobar", ratelimit.CategoryUnknown},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := &Event{Type: tc.eventType}
+			got := e.toCategory()
+			if got != tc.want {
+				t.Errorf("Type %q: got %v, want %v", tc.eventType, got, tc.want)
+			}
+		})
+	}
+}
diff --git a/internal/debuglog/log.go b/internal/debuglog/log.go
new file mode 100644
index 000000000..37fa4d0f3
--- /dev/null
+++ internal/debuglog/log.go
@@ -0,0 +1,35 @@
+package debuglog
+
+import (
+	"io"
+	"log"
+)
+
+// logger is the global debug logger instance.
+var logger = log.New(io.Discard, "[Sentry] ", log.LstdFlags)
+
+// SetOutput changes the output destination of the logger.
+func SetOutput(w io.Writer) {
+	logger.SetOutput(w)
+}
+
+// GetLogger returns the current logger instance.
+// This function is thread-safe and can be called concurrently.
+func GetLogger() *log.Logger {
+	return logger
+}
+
+// Printf calls Printf on the underlying logger.
+func Printf(format string, args ...interface{}) {
+	logger.Printf(format, args...)
+}
+
+// Println calls Println on the underlying logger.
+func Println(args ...interface{}) {
+	logger.Println(args...)
+}
+
+// Print calls Print on the underlying logger.
+func Print(args ...interface{}) {
+	logger.Print(args...)
+}
diff --git a/internal/debuglog/log_test.go b/internal/debuglog/log_test.go
new file mode 100644
index 000000000..c1ccb551f
--- /dev/null
+++ internal/debuglog/log_test.go
@@ -0,0 +1,120 @@
+package debuglog
+
+import (
+	"bytes"
+	"io"
+	"strings"
+	"sync"
+	"testing"
+)
+
+func TestGetLogger(t *testing.T) {
+	logger := GetLogger()
+	if logger == nil {
+		t.Error("GetLogger returned nil")
+	}
+}
+
+func TestSetOutput(t *testing.T) {
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Printf("test %s %d", "message", 42)
+
+	output := buf.String()
+	if !strings.Contains(output, "test message 42") {
+		t.Errorf("Printf output incorrect: got %q", output)
+	}
+}
+
+func TestPrintf(t *testing.T) {
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Printf("test %s %d", "message", 42)
+
+	output := buf.String()
+	if !strings.Contains(output, "test message 42") {
+		t.Errorf("Printf output incorrect: got %q", output)
+	}
+}
+
+func TestPrintln(t *testing.T) {
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Println("test", "message")
+
+	output := buf.String()
+	if !strings.Contains(output, "test message") {
+		t.Errorf("Println output incorrect: got %q", output)
+	}
+}
+
+func TestPrint(t *testing.T) {
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Print("test", "message")
+
+	output := buf.String()
+	if !strings.Contains(output, "testmessage") {
+		t.Errorf("Print output incorrect: got %q", output)
+	}
+}
+
+func TestConcurrentAccess(_ *testing.T) {
+	var wg sync.WaitGroup
+	iterations := 1000
+
+	for i := 0; i < iterations; i++ {
+		wg.Add(1)
+		go func(n int) {
+			defer wg.Done()
+			Printf("concurrent message %d", n)
+		}(i)
+	}
+
+	for i := 0; i < iterations; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			_ = GetLogger()
+		}()
+	}
+
+	for i := 0; i < 10; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			SetOutput(io.Discard)
+		}()
+	}
+
+	wg.Wait()
+}
+
+func TestInitialization(t *testing.T) {
+	// The logger should be initialized on package load
+	logger := GetLogger()
+	if logger == nil {
+		t.Error("Logger was not initialized")
+	}
+
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Printf("test")
+	Println("test")
+	Print("test")
+
+	output := buf.String()
+	if !strings.Contains(output, "test") {
+		t.Errorf("Expected output to contain 'test', got %q", output)
+	}
+}
diff --git internal/ratelimit/category.go internal/ratelimit/category.go
index 2db76d2bf..96d9e21b9 100644
--- internal/ratelimit/category.go
+++ internal/ratelimit/category.go
@@ -14,12 +14,14 @@ import (
 // and, therefore, rate limited.
 type Category string
 
-// Known rate limit categories. As a special case, the CategoryAll applies to
-// all known payload types.
+// Known rate limit categories that are specified in rate limit headers.
 const (
-	CategoryAll         Category = ""
+	CategoryUnknown     Category = "unknown" // Unknown category should not get rate limited
+	CategoryAll         Category = ""        // Special category for empty categories (applies to all)
 	CategoryError       Category = "error"
 	CategoryTransaction Category = "transaction"
+	CategoryLog         Category = "log_item"
+	CategoryMonitor     Category = "monitor"
 )
 
 // knownCategories is the set of currently known categories. Other categories
@@ -28,18 +30,30 @@ var knownCategories = map[Category]struct{}{
 	CategoryAll:         {},
 	CategoryError:       {},
 	CategoryTransaction: {},
+	CategoryLog:         {},
+	CategoryMonitor:     {},
 }
 
 // String returns the category formatted for debugging.
 func (c Category) String() string {
-	if c == "" {
+	switch c {
+	case CategoryAll:
 		return "CategoryAll"
+	case CategoryError:
+		return "CategoryError"
+	case CategoryTransaction:
+		return "CategoryTransaction"
+	case CategoryLog:
+		return "CategoryLog"
+	case CategoryMonitor:
+		return "CategoryMonitor"
+	default:
+		// For unknown categories, use the original formatting logic
+		caser := cases.Title(language.English)
+		rv := "Category"
+		for _, w := range strings.Fields(string(c)) {
+			rv += caser.String(w)
+		}
+		return rv
 	}
-
-	caser := cases.Title(language.English)
-	rv := "Category"
-	for _, w := range strings.Fields(string(c)) {
-		rv += caser.String(w)
-	}
-	return rv
 }
diff --git internal/ratelimit/category_test.go internal/ratelimit/category_test.go
index 48af16d20..e0ec06b29 100644
--- internal/ratelimit/category_test.go
+++ internal/ratelimit/category_test.go
@@ -1,23 +1,61 @@
 package ratelimit
 
-import "testing"
+import (
+	"testing"
+)
 
-func TestCategoryString(t *testing.T) {
+func TestCategory_String(t *testing.T) {
 	tests := []struct {
-		Category Category
-		want     string
+		category Category
+		expected string
 	}{
 		{CategoryAll, "CategoryAll"},
 		{CategoryError, "CategoryError"},
 		{CategoryTransaction, "CategoryTransaction"},
-		{Category("unknown"), "CategoryUnknown"},
-		{Category("two words"), "CategoryTwoWords"},
+		{CategoryMonitor, "CategoryMonitor"},
+		{CategoryLog, "CategoryLog"},
+		{Category("custom type"), "CategoryCustomType"},
+		{Category("multi word type"), "CategoryMultiWordType"},
 	}
+
 	for _, tt := range tests {
-		t.Run(tt.want, func(t *testing.T) {
-			got := tt.Category.String()
-			if got != tt.want {
-				t.Errorf("got %q, want %q", got, tt.want)
+		t.Run(string(tt.category), func(t *testing.T) {
+			result := tt.category.String()
+			if result != tt.expected {
+				t.Errorf("Category(%q).String() = %q, want %q", tt.category, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestKnownCategories(t *testing.T) {
+	expectedCategories := []Category{
+		CategoryAll,
+		CategoryError,
+		CategoryTransaction,
+		CategoryMonitor,
+		CategoryLog,
+	}
+
+	for _, category := range expectedCategories {
+		t.Run(string(category), func(t *testing.T) {
+			if _, exists := knownCategories[category]; !exists {
+				t.Errorf("Category %q should be in knownCategories map", category)
+			}
+		})
+	}
+
+	// Test that unknown categories are not in the map
+	unknownCategories := []Category{
+		Category("unknown"),
+		Category("custom"),
+		Category("random"),
+	}
+
+	for _, category := range unknownCategories {
+		t.Run("unknown_"+string(category), func(t *testing.T) {
+			if _, exists := knownCategories[category]; exists {
+				t.Errorf("Unknown category %q should not be in knownCategories map", category)
 			}
 		})
 	}
diff --git internal/ratelimit/rate_limits_test.go internal/ratelimit/rate_limits_test.go
index 78cd64d2e..e81e89f0f 100644
--- internal/ratelimit/rate_limits_test.go
+++ internal/ratelimit/rate_limits_test.go
@@ -59,7 +59,9 @@ func TestParseXSentryRateLimits(t *testing.T) {
 		{
 			// ignore unknown categories
 			"8:error;default;unknown",
-			Map{CategoryError: Deadline(now.Add(8 * time.Second))},
+			Map{
+				CategoryError: Deadline(now.Add(8 * time.Second)),
+			},
 		},
 		{
 			"30:error:scope1, 20:error:scope2, 40:error",
diff --git iris/go.mod iris/go.mod
index 10bb024cc..170dcdfa7 100644
--- iris/go.mod
+++ iris/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/iris
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.0
 	github.com/google/go-cmp v0.5.9
 	github.com/kataras/iris/v12 v12.2.0
 )
diff --git log.go log.go
index 08be1b4e0..c26933612 100644
--- log.go
+++ log.go
@@ -3,11 +3,14 @@ package sentry
 import (
 	"context"
 	"fmt"
+	"maps"
 	"os"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/getsentry/sentry-go/attribute"
+	debuglog "github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 type LogLevel string
@@ -30,17 +33,28 @@ const (
 	LogSeverityFatal   int = 21
 )
 
-var mapTypesToStr = map[attribute.Type]string{
-	attribute.INVALID: "",
-	attribute.BOOL:    "boolean",
-	attribute.INT64:   "integer",
-	attribute.FLOAT64: "double",
-	attribute.STRING:  "string",
+var mapTypesToStr = map[attribute.Type]AttrType{
+	attribute.INVALID: AttributeInvalid,
+	attribute.BOOL:    AttributeBool,
+	attribute.INT64:   AttributeInt,
+	attribute.FLOAT64: AttributeFloat,
+	attribute.STRING:  AttributeString,
 }
 
 type sentryLogger struct {
+	ctx        context.Context
 	client     *Client
 	attributes map[string]Attribute
+	mu         sync.RWMutex
+}
+
+type logEntry struct {
+	logger      *sentryLogger
+	ctx         context.Context
+	level       LogLevel
+	severity    int
+	attributes  map[string]Attribute
+	shouldPanic bool
 }
 
 // NewLogger returns a Logger that emits logs to Sentry. If logging is turned off, all logs get discarded.
@@ -53,21 +67,26 @@ func NewLogger(ctx context.Context) Logger {
 
 	client := hub.Client()
 	if client != nil && client.batchLogger != nil {
-		return &sentryLogger{client, make(map[string]Attribute)}
+		return &sentryLogger{
+			ctx:        ctx,
+			client:     client,
+			attributes: make(map[string]Attribute),
+			mu:         sync.RWMutex{},
+		}
 	}
 
-	DebugLogger.Println("fallback to noopLogger: enableLogs disabled")
+	debuglog.Println("fallback to noopLogger: enableLogs disabled")
 	return &noopLogger{} // fallback: does nothing
 }
 
 func (l *sentryLogger) Write(p []byte) (int, error) {
 	// Avoid sending double newlines to Sentry
 	msg := strings.TrimRight(string(p), "\n")
-	l.log(context.Background(), LogLevelInfo, LogSeverityInfo, msg)
+	l.Info().Emit(msg)
 	return len(p), nil
 }
 
-func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, message string, args ...interface{}) {
+func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, message string, entryAttrs map[string]Attribute, args ...interface{}) {
 	if message == "" {
 		return
 	}
@@ -78,71 +97,77 @@ func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, me
 
 	var traceID TraceID
 	var spanID SpanID
+	var span *Span
+	var user User
 
-	span := hub.Scope().span
-	if span != nil {
-		traceID = span.TraceID
-		spanID = span.SpanID
-	} else {
-		traceID = hub.Scope().propagationContext.TraceID
+	scope := hub.Scope()
+	if scope != nil {
+		scope.mu.Lock()
+		span = scope.span
+		if span != nil {
+			traceID = span.TraceID
+			spanID = span.SpanID
+		} else {
+			traceID = scope.propagationContext.TraceID
+		}
+		user = scope.user
+		scope.mu.Unlock()
 	}
 
 	attrs := map[string]Attribute{}
 	if len(args) > 0 {
 		attrs["sentry.message.template"] = Attribute{
-			Value: message, Type: "string",
+			Value: message, Type: AttributeString,
 		}
 		for i, p := range args {
 			attrs[fmt.Sprintf("sentry.message.parameters.%d", i)] = Attribute{
-				Value: fmt.Sprint(p), Type: "string",
+				Value: fmt.Sprintf("%+v", p), Type: AttributeString,
 			}
 		}
 	}
 
-	// If `log` was called with SetAttributes, pass the attributes to attrs
-	if len(l.attributes) > 0 {
-		for k, v := range l.attributes {
-			attrs[k] = v
-		}
-		// flush attributes from logger after send
-		clear(l.attributes)
+	l.mu.RLock()
+	for k, v := range l.attributes {
+		attrs[k] = v
+	}
+	l.mu.RUnlock()
+
+	for k, v := range entryAttrs {
+		attrs[k] = v
 	}
 
 	// Set default attributes
 	if release := l.client.options.Release; release != "" {
-		attrs["sentry.release"] = Attribute{Value: release, Type: "string"}
+		attrs["sentry.release"] = Attribute{Value: release, Type: AttributeString}
 	}
 	if environment := l.client.options.Environment; environment != "" {
-		attrs["sentry.environment"] = Attribute{Value: environment, Type: "string"}
+		attrs["sentry.environment"] = Attribute{Value: environment, Type: AttributeString}
 	}
 	if serverName := l.client.options.ServerName; serverName != "" {
-		attrs["sentry.server.address"] = Attribute{Value: serverName, Type: "string"}
+		attrs["sentry.server.address"] = Attribute{Value: serverName, Type: AttributeString}
 	} else if serverAddr, err := os.Hostname(); err == nil {
-		attrs["sentry.server.address"] = Attribute{Value: serverAddr, Type: "string"}
+		attrs["sentry.server.address"] = Attribute{Value: serverAddr, Type: AttributeString}
 	}
-	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 !user.IsEmpty() {
+		if user.ID != "" {
+			attrs["user.id"] = Attribute{Value: user.ID, Type: AttributeString}
+		}
+		if user.Name != "" {
+			attrs["user.name"] = Attribute{Value: user.Name, Type: AttributeString}
+		}
+		if user.Email != "" {
+			attrs["user.email"] = Attribute{Value: user.Email, Type: AttributeString}
 		}
 	}
-	if spanID.String() != "0000000000000000" {
-		attrs["sentry.trace.parent_span_id"] = Attribute{Value: spanID.String(), Type: "string"}
+	if span != nil {
+		attrs["sentry.trace.parent_span_id"] = Attribute{Value: spanID.String(), Type: AttributeString}
 	}
 	if sdkIdentifier := l.client.sdkIdentifier; sdkIdentifier != "" {
-		attrs["sentry.sdk.name"] = Attribute{Value: sdkIdentifier, Type: "string"}
+		attrs["sentry.sdk.name"] = Attribute{Value: sdkIdentifier, Type: AttributeString}
 	}
 	if sdkVersion := l.client.sdkVersion; sdkVersion != "" {
-		attrs["sentry.sdk.version"] = Attribute{Value: sdkVersion, Type: "string"}
+		attrs["sentry.sdk.version"] = Attribute{Value: sdkVersion, Type: AttributeString}
 	}
 
 	log := &Log{
@@ -163,15 +188,18 @@ func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, me
 	}
 
 	if l.client.options.Debug {
-		DebugLogger.Printf(message, args...)
+		debuglog.Printf(message, args...)
 	}
 }
 
 func (l *sentryLogger) SetAttributes(attrs ...attribute.Builder) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+
 	for _, v := range attrs {
 		t, ok := mapTypesToStr[v.Value.Type()]
 		if !ok || t == "" {
-			DebugLogger.Printf("invalid attribute type set: %v", t)
+			debuglog.Printf("invalid attribute type set: %v", t)
 			continue
 		}
 
@@ -182,49 +210,136 @@ func (l *sentryLogger) SetAttributes(attrs ...attribute.Builder) {
 	}
 }
 
-func (l *sentryLogger) Trace(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelTrace, LogSeverityTrace, fmt.Sprint(v...))
+func (l *sentryLogger) Trace() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelTrace,
+		severity:   LogSeverityTrace,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Debug(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelDebug, LogSeverityDebug, fmt.Sprint(v...))
+
+func (l *sentryLogger) Debug() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelDebug,
+		severity:   LogSeverityDebug,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Info(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelInfo, LogSeverityInfo, fmt.Sprint(v...))
+
+func (l *sentryLogger) Info() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelInfo,
+		severity:   LogSeverityInfo,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Warn(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelWarn, LogSeverityWarning, fmt.Sprint(v...))
+
+func (l *sentryLogger) Warn() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelWarn,
+		severity:   LogSeverityWarning,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Error(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelError, LogSeverityError, fmt.Sprint(v...))
+
+func (l *sentryLogger) Error() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelError,
+		severity:   LogSeverityError,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Fatal(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, fmt.Sprint(v...))
-	os.Exit(1)
+
+func (l *sentryLogger) Fatal() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelFatal,
+		severity:   LogSeverityFatal,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Panic(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, fmt.Sprint(v...))
-	panic(fmt.Sprint(v...))
+
+func (l *sentryLogger) Panic() LogEntry {
+	return &logEntry{
+		logger:      l,
+		ctx:         l.ctx,
+		level:       LogLevelFatal,
+		severity:    LogSeverityFatal,
+		attributes:  make(map[string]Attribute),
+		shouldPanic: true, // this should panic instead of exit
+	}
 }
-func (l *sentryLogger) Tracef(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelTrace, LogSeverityTrace, format, v...)
+
+func (l *sentryLogger) GetCtx() context.Context {
+	return l.ctx
 }
-func (l *sentryLogger) Debugf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelDebug, LogSeverityDebug, format, v...)
+
+func (e *logEntry) WithCtx(ctx context.Context) LogEntry {
+	return &logEntry{
+		logger:      e.logger,
+		ctx:         ctx,
+		level:       e.level,
+		severity:    e.severity,
+		attributes:  maps.Clone(e.attributes),
+		shouldPanic: e.shouldPanic,
+	}
 }
-func (l *sentryLogger) Infof(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelInfo, LogSeverityInfo, format, v...)
+
+func (e *logEntry) String(key, value string) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeString}
+	return e
 }
-func (l *sentryLogger) Warnf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelWarn, LogSeverityWarning, format, v...)
+
+func (e *logEntry) Int(key string, value int) LogEntry {
+	e.attributes[key] = Attribute{Value: int64(value), Type: AttributeInt}
+	return e
 }
-func (l *sentryLogger) Errorf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelError, LogSeverityError, format, v...)
+
+func (e *logEntry) Int64(key string, value int64) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeInt}
+	return e
 }
-func (l *sentryLogger) Fatalf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, format, v...)
-	os.Exit(1)
+
+func (e *logEntry) Float64(key string, value float64) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeFloat}
+	return e
+}
+
+func (e *logEntry) Bool(key string, value bool) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeBool}
+	return e
+}
+
+func (e *logEntry) Emit(args ...interface{}) {
+	e.logger.log(e.ctx, e.level, e.severity, fmt.Sprint(args...), e.attributes)
+
+	if e.level == LogLevelFatal {
+		if e.shouldPanic {
+			panic(fmt.Sprint(args...))
+		}
+		os.Exit(1)
+	}
 }
-func (l *sentryLogger) Panicf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, format, v...)
-	panic(fmt.Sprint(v...))
+
+func (e *logEntry) Emitf(format string, args ...interface{}) {
+	e.logger.log(e.ctx, e.level, e.severity, format, e.attributes, args...)
+
+	if e.level == LogLevelFatal {
+		if e.shouldPanic {
+			formattedMessage := fmt.Sprintf(format, args...)
+			panic(formattedMessage)
+		}
+		os.Exit(1)
+	}
 }
diff --git log_fallback.go log_fallback.go
index b9eb7061f..6e9331955 100644
--- log_fallback.go
+++ log_fallback.go
@@ -6,60 +6,100 @@ import (
 	"os"
 
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // Fallback, no-op logger if logging is disabled.
 type noopLogger struct{}
 
-func (*noopLogger) Trace(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelTrace)
+// noopLogEntry implements LogEntry for the no-op logger.
+type noopLogEntry struct {
+	level       LogLevel
+	shouldPanic bool
 }
-func (*noopLogger) Debug(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelDebug)
+
+func (n *noopLogEntry) WithCtx(_ context.Context) LogEntry {
+	return n
+}
+
+func (n *noopLogEntry) String(_, _ string) LogEntry {
+	return n
 }
-func (*noopLogger) Info(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+
+func (n *noopLogEntry) Int(_ string, _ int) LogEntry {
+	return n
 }
-func (*noopLogger) Warn(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelWarn)
+
+func (n *noopLogEntry) Int64(_ string, _ int64) LogEntry {
+	return n
 }
-func (*noopLogger) Error(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelError)
+
+func (n *noopLogEntry) Float64(_ string, _ float64) LogEntry {
+	return n
 }
-func (*noopLogger) Fatal(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	os.Exit(1)
+
+func (n *noopLogEntry) Bool(_ string, _ bool) LogEntry {
+	return n
+}
+
+func (n *noopLogEntry) Attributes(_ ...attribute.Builder) LogEntry {
+	return n
+}
+
+func (n *noopLogEntry) Emit(args ...interface{}) {
+	debuglog.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", n.level)
+	if n.level == LogLevelFatal {
+		if n.shouldPanic {
+			panic(args)
+		}
+		os.Exit(1)
+	}
 }
-func (*noopLogger) Panic(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	panic(fmt.Sprintf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal))
+
+func (n *noopLogEntry) Emitf(message string, args ...interface{}) {
+	debuglog.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", n.level)
+	if n.level == LogLevelFatal {
+		if n.shouldPanic {
+			panic(fmt.Sprintf(message, args...))
+		}
+		os.Exit(1)
+	}
 }
-func (*noopLogger) Tracef(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelTrace)
+
+func (n *noopLogger) GetCtx() context.Context { return context.Background() }
+
+func (*noopLogger) Trace() LogEntry {
+	return &noopLogEntry{level: LogLevelTrace}
 }
-func (*noopLogger) Debugf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelDebug)
+
+func (*noopLogger) Debug() LogEntry {
+	return &noopLogEntry{level: LogLevelDebug}
 }
-func (*noopLogger) Infof(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+
+func (*noopLogger) Info() LogEntry {
+	return &noopLogEntry{level: LogLevelInfo}
 }
-func (*noopLogger) Warnf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelWarn)
+
+func (*noopLogger) Warn() LogEntry {
+	return &noopLogEntry{level: LogLevelWarn}
 }
-func (*noopLogger) Errorf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelError)
+
+func (*noopLogger) Error() LogEntry {
+	return &noopLogEntry{level: LogLevelError}
 }
-func (*noopLogger) Fatalf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	os.Exit(1)
+
+func (*noopLogger) Fatal() LogEntry {
+	return &noopLogEntry{level: LogLevelFatal}
 }
-func (*noopLogger) Panicf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	panic(fmt.Sprintf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal))
+
+func (*noopLogger) Panic() LogEntry {
+	return &noopLogEntry{level: LogLevelFatal, shouldPanic: true}
 }
+
 func (*noopLogger) SetAttributes(...attribute.Builder) {
-	DebugLogger.Printf("No attributes attached. Turn on logging via EnableLogs")
+	debuglog.Printf("No attributes attached. Turn on logging via EnableLogs")
 }
+
 func (*noopLogger) Write(_ []byte) (n int, err error) {
-	return 0, fmt.Errorf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+	return 0, fmt.Errorf("log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
 }
diff --git a/log_race_test.go b/log_race_test.go
new file mode 100644
index 000000000..3f41c2e7e
--- /dev/null
+++ log_race_test.go
@@ -0,0 +1,381 @@
+package sentry
+
+import (
+	"context"
+	"fmt"
+	"runtime"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/testutils"
+)
+
+const (
+	loggingGoroutines = 50
+	loggingIterations = 100
+)
+
+type CtxKey int
+
+func TestLoggingRaceConditions(t *testing.T) {
+	testCases := []struct {
+		name    string
+		timeout time.Duration
+		testFn  func(*testing.T)
+	}{
+		{
+			name:    "ConcurrentLoggerSetAttributes",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLoggerSetAttributes,
+		},
+		{
+			name:    "ConcurrentLogEmission",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLogEmission,
+		},
+		{
+			name:    "ConcurrentLogEntryOperations",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLogEntryOperations,
+		},
+		{
+			name:    "ConcurrentLoggerCreationAndUsage",
+			timeout: testutils.FlushTimeout(),
+			testFn:  testConcurrentLoggerCreationAndUsage,
+		},
+		{
+			name:    "ConcurrentLogWithSpanOperations",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLogWithSpanOperations,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			timeout := time.After(tc.timeout)
+			done := make(chan bool)
+
+			go func() {
+				defer func() {
+					if r := recover(); r != nil {
+						t.Errorf("Test %s panicked: %v", tc.name, r)
+					}
+					done <- true
+				}()
+				tc.testFn(t)
+			}()
+
+			select {
+			case <-timeout:
+				t.Fatalf("Test %s didn't finish in time (timeout: %v) - likely deadlock", tc.name, tc.timeout)
+			case <-done:
+				t.Logf("Test %s completed successfully", tc.name)
+			}
+		})
+	}
+}
+
+func testConcurrentLoggerSetAttributes(t *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	logger := NewLogger(ctx)
+	if _, ok := logger.(*noopLogger); ok {
+		t.Skip("Logging is disabled, skipping test")
+	}
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines/2; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations; j++ {
+				attrs := []attribute.Builder{
+					attribute.String(fmt.Sprintf("attr-string-%d", id), fmt.Sprintf("value-%d-%d", id, j)),
+					attribute.Int64(fmt.Sprintf("attr-int-%d", id), int64(id*j)),
+					attribute.Float64(fmt.Sprintf("attr-float-%d", id), float64(id)+float64(j)*0.1),
+					attribute.Bool(fmt.Sprintf("attr-bool-%d", id), j%2 == 0),
+				}
+				logger.SetAttributes(attrs...)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	for i := 0; i < loggingGoroutines/2; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations; j++ {
+				logger.Info().
+					String("worker_id", fmt.Sprintf("%d", id)).
+					Int("iteration", j).
+					Emit("Concurrent log message from worker", id)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLogEmission(_ *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			logger := NewLogger(ctx)
+			if _, ok := logger.(*noopLogger); ok {
+				return
+			}
+
+			for j := 0; j < loggingIterations/5; j++ {
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Trace().
+						String("operation", "trace").
+						Int("worker", id).
+						Emit("Trace message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Debug().
+						String("operation", "debug").
+						Int("worker", id).
+						Emit("Debug message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Info().
+						String("operation", "info").
+						Int("worker", id).
+						Emit("Info message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Warn().
+						String("operation", "warn").
+						Int("worker", id).
+						Emit("Warning message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Error().
+						String("operation", "error").
+						Int("worker", id).
+						Emit("Error message from worker %d", id)
+				}()
+
+				localWg.Wait()
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLogEntryOperations(t *testing.T) {
+	t.Skip("A single instance of a log entry should not be used concurrently")
+
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	logger := NewLogger(ctx)
+	if _, ok := logger.(*noopLogger); ok {
+		t.Skip("Logging is disabled, skipping test")
+	}
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations/10; j++ {
+				entry := logger.Info()
+
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					entry.String("worker_id", fmt.Sprintf("worker-%d", id))
+					entry.Int("iteration", j)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					entry.Float64("progress", float64(j)/float64(loggingIterations/10))
+					entry.Bool("is_test", true)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					newCtx := context.WithValue(ctx, CtxKey(2), fmt.Sprintf("test_value_%d", id))
+					_ = entry.WithCtx(newCtx)
+				}()
+
+				localWg.Wait()
+				entry.Emit("Concurrent entry operations test %d-%d", id, j)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLoggerCreationAndUsage(_ *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations/20; j++ {
+				ctx := context.WithValue(context.Background(), CtxKey(1), id)
+				ctx = SetHubOnContext(ctx, hub)
+
+				logger := NewLogger(ctx)
+				if _, ok := logger.(*noopLogger); ok {
+					continue
+				}
+
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.SetAttributes(
+						attribute.String("creation_worker", fmt.Sprintf("%d", id)),
+						attribute.Int64("creation_iteration", int64(j)),
+					)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Info().
+						String("immediate_usage", "true").
+						Emit("Logger created and used immediately by worker %d", id)
+				}()
+
+				localWg.Wait()
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLogWithSpanOperations(_ *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:              testDsn,
+		EnableLogs:       true,
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+		Transport:        &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations/20; j++ {
+				transaction := StartTransaction(ctx, fmt.Sprintf("log-transaction-%d", id))
+				span := transaction.StartChild(fmt.Sprintf("log-span-%d", id))
+
+				spanCtx := span.Context()
+				logger := NewLogger(spanCtx)
+				if _, ok := logger.(*noopLogger); ok {
+					span.Finish()
+					transaction.Finish()
+					continue
+				}
+
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					span.SetTag("worker_id", fmt.Sprintf("%d", id))
+					span.SetData("iteration", j)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.SetAttributes(
+						attribute.String("span_operation", span.Op),
+						attribute.String("trace_id", span.TraceID.String()),
+					)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Info().
+						String("span_context", "active").
+						String("span_id", span.SpanID.String()).
+						Emit("Log within span from worker %d", id)
+				}()
+
+				localWg.Wait()
+				span.Finish()
+				transaction.Finish()
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
diff --git log_test.go log_test.go
index 62cb3d3b1..e0357abc9 100644
--- log_test.go
+++ log_test.go
@@ -3,14 +3,17 @@ package sentry
 import (
 	"bytes"
 	"context"
-	"log"
+	"io"
 	"strings"
 	"testing"
 	"time"
 
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/debuglog"
+	"github.com/getsentry/sentry-go/internal/testutils"
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/stretchr/testify/assert"
 )
 
 const (
@@ -65,7 +68,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Trace level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Tracef(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Trace().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -85,7 +88,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Debug level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Debugf(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Debug().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -105,7 +108,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Info level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Infof(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Info().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -125,7 +128,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Warn level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Warnf(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Warn().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -145,7 +148,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Error level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Errorf(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Error().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -177,7 +180,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 			// invalid attribute should be dropped
 			l.SetAttributes(attribute.Builder{Key: "key.invalid", Value: attribute.Value{}})
 			tt.logFunc(ctx, l)
-			Flush(20 * time.Millisecond)
+			Flush(testutils.FlushTimeout())
 
 			opts := cmp.Options{
 				cmpopts.IgnoreFields(Log{}, "Timestamp"),
@@ -217,7 +220,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Trace level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Trace(ctx, msg)
+				l.Trace().WithCtx(ctx).Emit(msg)
 			},
 			args: "trace",
 			wantEvents: []Event{
@@ -237,7 +240,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Debug level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Debug(ctx, msg)
+				l.Debug().WithCtx(ctx).Emit(msg)
 			},
 			args: "debug",
 			wantEvents: []Event{
@@ -257,7 +260,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Info level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Info(ctx, msg)
+				l.Info().WithCtx(ctx).Emit(msg)
 			},
 			args: "info",
 			wantEvents: []Event{
@@ -277,7 +280,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Warn level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Warn(ctx, msg)
+				l.Warn().WithCtx(ctx).Emit(msg)
 			},
 			args: "warn",
 			wantEvents: []Event{
@@ -297,7 +300,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Error level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Error(ctx, msg)
+				l.Error().WithCtx(ctx).Emit(msg)
 			},
 			args: "error",
 			wantEvents: []Event{
@@ -321,7 +324,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 			ctx, mockTransport := setupMockTransport()
 			l := NewLogger(ctx)
 			tt.logFunc(ctx, l, tt.args)
-			Flush(20 * time.Millisecond)
+			Flush(testutils.FlushTimeout())
 
 			opts := cmp.Options{
 				cmpopts.IgnoreFields(Log{}, "Timestamp"),
@@ -354,7 +357,7 @@ func Test_sentryLogger_Panic(t *testing.T) {
 		}()
 		ctx, _ := setupMockTransport()
 		l := NewLogger(ctx)
-		l.Panic(context.Background(), "panic message") // This should panic
+		l.Panic().Emit("panic message") // This should panic
 	})
 
 	t.Run("logger.Panicf", func(t *testing.T) {
@@ -367,7 +370,7 @@ func Test_sentryLogger_Panic(t *testing.T) {
 		}()
 		ctx, _ := setupMockTransport()
 		l := NewLogger(ctx)
-		l.Panicf(context.Background(), "panic message") // This should panic
+		l.Panic().Emitf("panic message") // This should panic
 	})
 }
 
@@ -400,7 +403,7 @@ func Test_sentryLogger_Write(t *testing.T) {
 	if n != len(msg) {
 		t.Errorf("Write returned wrong byte count: got %d, want %d", n, len(msg))
 	}
-	Flush(20 * time.Millisecond)
+	Flush(testutils.FlushTimeout())
 
 	gotEvents := mockTransport.Events()
 	if len(gotEvents) != 1 {
@@ -422,11 +425,11 @@ func Test_sentryLogger_FlushAttributesAfterSend(t *testing.T) {
 	ctx, mockTransport := setupMockTransport()
 	l := NewLogger(ctx)
 	l.SetAttributes(attribute.Int("int", 42))
-	l.Info(ctx, msg)
+	l.Info().WithCtx(ctx).Emit(msg)
 
 	l.SetAttributes(attribute.String("string", "some str"))
-	l.Warn(ctx, msg)
-	Flush(20 * time.Millisecond)
+	l.Warn().WithCtx(ctx).Emit(msg)
+	Flush(testutils.FlushTimeout())
 
 	gotEvents := mockTransport.Events()
 	if len(gotEvents) != 1 {
@@ -434,17 +437,43 @@ func Test_sentryLogger_FlushAttributesAfterSend(t *testing.T) {
 	}
 	event := gotEvents[0]
 	assertEqual(t, event.Logs[0].Attributes["int"].Value, int64(42))
-	if _, ok := event.Logs[1].Attributes["int"]; ok {
-		t.Fatalf("expected key to not exist")
+	if _, ok := event.Logs[1].Attributes["int"]; !ok {
+		t.Fatalf("expected key to exist")
 	}
 	assertEqual(t, event.Logs[1].Attributes["string"].Value, "some str")
 }
 
+func TestSentryLogger_LogEntryAttributes(t *testing.T) {
+	msg := []byte("something")
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+	l.Info().WithCtx(ctx).
+		String("key.string", "some str").
+		Int("key.int", 42).
+		Int64("key.int64", 17).
+		Float64("key.float", 42.2).
+		Bool("key.bool", true).
+		Emit(msg)
+
+	Flush(20 * time.Millisecond)
+
+	gotEvents := mockTransport.Events()
+	if len(gotEvents) != 1 {
+		t.Fatalf("expected 1 event, got %d", len(gotEvents))
+	}
+	event := gotEvents[0]
+	assertEqual(t, event.Logs[0].Attributes["key.int"].Value, int64(42))
+	assertEqual(t, event.Logs[0].Attributes["key.int64"].Value, int64(17))
+	assertEqual(t, event.Logs[0].Attributes["key.float"].Value, 42.2)
+	assertEqual(t, event.Logs[0].Attributes["key.bool"].Value, true)
+	assertEqual(t, event.Logs[0].Attributes["key.string"].Value, "some str")
+}
+
 func Test_batchLogger_Flush(t *testing.T) {
 	ctx, mockTransport := setupMockTransport()
 	l := NewLogger(context.Background())
-	l.Info(ctx, "context done log")
-	Flush(20 * time.Millisecond)
+	l.Info().WithCtx(ctx).Emit("context done log")
+	Flush(testutils.FlushTimeout())
 
 	events := mockTransport.Events()
 	if len(events) != 1 {
@@ -455,9 +484,9 @@ 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")
+	l.Info().WithCtx(ctx).Emit("context done log")
 
-	cancelCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+	cancelCtx, cancel := context.WithTimeout(context.Background(), testutils.FlushTimeout())
 	FlushWithContext(cancelCtx)
 	defer cancel()
 
@@ -467,6 +496,88 @@ func Test_batchLogger_FlushWithContext(t *testing.T) {
 	}
 }
 
+func Test_batchLogger_FlushMultipleTimes(t *testing.T) {
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+
+	for i := 0; i < 5; i++ {
+		l.Info().WithCtx(ctx).Emit("test")
+	}
+
+	Flush(testutils.FlushTimeout())
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Logf("Got %d events instead of 1", len(events))
+		for i, event := range events {
+			t.Logf("Event %d: %d logs", i, len(event.Logs))
+		}
+		t.Fatalf("expected 1 event after first flush, got %d", len(events))
+	}
+	if len(events[0].Logs) != 5 {
+		t.Fatalf("expected 5 logs in first batch, got %d", len(events[0].Logs))
+	}
+
+	mockTransport.events = nil
+
+	for i := 0; i < 3; i++ {
+		l.Info().WithCtx(ctx).Emit("test")
+	}
+
+	Flush(testutils.FlushTimeout())
+	events = mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event after second flush, got %d", len(events))
+	}
+	if len(events[0].Logs) != 3 {
+		t.Fatalf("expected 3 logs in second batch, got %d", len(events[0].Logs))
+	}
+
+	mockTransport.events = nil
+
+	Flush(testutils.FlushTimeout())
+	events = mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected 0 events after third flush with no logs, got %d", len(events))
+	}
+}
+
+func Test_batchLogger_Shutdown(t *testing.T) {
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+	for i := 0; i < 3; i++ {
+		l.Info().WithCtx(ctx).Emit("test")
+	}
+
+	hub := GetHubFromContext(ctx)
+	hub.Client().batchLogger.Shutdown()
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event after shutdown, got %d", len(events))
+	}
+	if len(events[0].Logs) != 3 {
+		t.Fatalf("expected 3 logs in shutdown batch, got %d", len(events[0].Logs))
+	}
+
+	mockTransport.events = nil
+
+	// Test that shutdown can be called multiple times safely
+	hub.Client().batchLogger.Shutdown()
+	hub.Client().batchLogger.Shutdown()
+
+	events = mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected 0 events after multiple shutdowns, got %d", len(events))
+	}
+
+	Flush(testutils.FlushTimeout())
+	events = mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected 0 events after flush on shutdown logger, got %d", len(events))
+	}
+}
+
 func Test_sentryLogger_BeforeSendLog(t *testing.T) {
 	ctx := context.Background()
 	mockTransport := &MockTransport{}
@@ -491,8 +602,8 @@ func Test_sentryLogger_BeforeSendLog(t *testing.T) {
 	ctx = SetHubOnContext(ctx, hub)
 
 	l := NewLogger(ctx)
-	l.Info(ctx, "context done log")
-	Flu,sh(20 * time.Millisecond)
+	l.Info().WithCtx(ctx).Emit("context done log")
+	Flush(testutils.FlushTimeout())
 
 	events := mockTransport.Events()
 	if len(events) != 0 {
@@ -504,7 +615,7 @@ func Test_Logger_ExceedBatchSize(t *testing.T) {
 	ctx, mockTransport := setupMockTransport()
 	l := NewLogger(context.Background())
 	for i := 0; i < 100; i++ {
-		l.Info(ctx, "test")
+		l.Info().WithCtx(ctx).Emit("test")
 	}
 
 	// sleep to wait for events to propagate
@@ -526,9 +637,9 @@ func Test_sentryLogger_TracePropagationWithTransaction(t *testing.T) {
 	expectedSpanID := txn.SpanID
 
 	logger := NewLogger(txn.Context())
-	logger.Info(txn.Context(), "message with tracing")
+	logger.Info().WithCtx(txn.Context()).Emit("message with tracing")
 
-	Flush(20 * time.Millisecond)
+	Flush(testutils.FlushTimeout())
 
 	events := mockTransport.Events()
 	if len(events) != 1 {
@@ -552,54 +663,48 @@ func Test_sentryLogger_TracePropagationWithTransaction(t *testing.T) {
 }
 
 func TestSentryLogger_DebugLogging(t *testing.T) {
-	var buf bytes.Buffer
-	debugLogger := log.New(&buf, "", 0)
-	originalLogger := DebugLogger
-	DebugLogger = debugLogger
-	defer func() {
-		DebugLogger = originalLogger
-	}()
-
 	tests := []struct {
-		name          string
-		debugEnabled  bool
-		message       string
-		expectedDebug string
+		name       string
+		enableLogs bool
+		message    string
 	}{
 		{
-			name:          "Debug enabled",
-			debugEnabled:  true,
-			message:       "test message",
-			expectedDebug: "test message\n",
+			name:       "Debug enabled",
+			enableLogs: true,
+			message:    "test message",
 		},
 		{
-			name:          "Debug disabled",
-			debugEnabled:  false,
-			message:       "test message",
-			expectedDebug: "",
+			name:       "Debug disabled",
+			enableLogs: false,
+			message:    "test message",
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			buf.Reset()
+			var buf bytes.Buffer
+
 			ctx := context.Background()
 			mockClient, _ := NewClient(ClientOptions{
 				Transport:  &MockTransport{},
-				EnableLogs: true,
-				Debug:      tt.debugEnabled,
+				EnableLogs: tt.enableLogs,
+				Debug:      true,
 			})
 			hub := CurrentHub()
 			hub.BindClient(mockClient)
 
+			// set the debug logger output after NewClient, so that it doesn't change.
+			debuglog.SetOutput(&buf)
+			defer debuglog.SetOutput(io.Discard)
+
 			logger := NewLogger(ctx)
-			logger.Info(ctx, tt.message)
+			logger.Info().WithCtx(ctx).Emit(tt.message)
 
 			got := buf.String()
-			if !tt.debugEnabled {
-				assertEqual(t, len(got), 0)
-			} else if strings.Contains(got, tt.expectedDebug) {
-				t.Errorf("Debug output = %q, want %q", got, tt.expectedDebug)
+			if tt.enableLogs {
+				assertEqual(t, strings.Contains(got, "test message"), true)
+			} else {
+				assertEqual(t, strings.Contains(got, "test message"), false)
 			}
 		})
 	}
@@ -632,7 +737,7 @@ func Test_sentryLogger_UserAttributes(t *testing.T) {
 	ctx = SetHubOnContext(ctx, hub)
 
 	l := NewLogger(ctx)
-	l.Info(ctx, "test message with PII")
+	l.Info().WithCtx(ctx).Emit("test message with PII")
 	Flush(20 * time.Millisecond)
 
 	events := mockTransport.Events()
@@ -666,3 +771,21 @@ func Test_sentryLogger_UserAttributes(t *testing.T) {
 		t.Errorf("unexpected user.email: got %v, want %v", val.Value, "[email protected]")
 	}
 }
+
+func TestLogEntryWithCtx_ShouldCopy(t *testing.T) {
+	ctx, _ := setupMockTransport()
+	l := NewLogger(ctx)
+
+	// using WithCtx should return a new log entry with the new ctx
+	newCtx := context.Background()
+	lentry := l.Info().String("key", "value").(*logEntry)
+	newlentry := lentry.WithCtx(newCtx).(*logEntry)
+	lentry.String("key2", "value")
+
+	assert.Equal(t, lentry.ctx, ctx)
+	assert.Equal(t, newlentry.ctx, newCtx)
+	assert.Contains(t, lentry.attributes, "key")
+	assert.Contains(t, lentry.attributes, "key2")
+	assert.Contains(t, newlentry.attributes, "key")
+	assert.NotContains(t, newlentry.attributes, "key2")
+}
diff --git logrus/go.mod logrus/go.mod
index fd5ab5f27..441e96155 100644
--- logrus/go.mod
+++ logrus/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/logrus
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.0
 	github.com/google/go-cmp v0.6.0
 	github.com/pkg/errors v0.9.1
 	github.com/sirupsen/logrus v1.9.3
diff --git logrus/logrusentry.go logrus/logrusentry.go
index 3dd08bff9..995df3a45 100644
--- logrus/logrusentry.go
+++ logrus/logrusentry.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/getsentry/sentry-go"
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 	"github.com/sirupsen/logrus"
 )
 
@@ -21,6 +22,8 @@ const (
 	sdkIdentifier = "sentry.go.logrus"
 	// the name of the logger.
 	name = "logrus"
+
+	maxErrorDepth = 100
 )
 
 // These default log field keys are used to pass specific metadata in a way that
@@ -43,6 +46,8 @@ const (
 	// These fields are simply omitted, as they are duplicated by the Sentry SDK.
 	FieldGoVersion = "go_version"
 	FieldMaxProcs  = "go_maxprocs"
+
+	LogrusOrigin = "auto.logger.logrus"
 )
 
 var levelMap = map[logrus.Level]sentry.Level{
@@ -180,7 +185,14 @@ func (h *eventHook) entryToEvent(l *logrus.Entry) *sentry.Event {
 
 	if err, ok := s.Extra[logrus.ErrorKey].(error); ok {
 		delete(s.Extra, logrus.ErrorKey)
-		s.SetException(err, -1)
+
+		errorDepth := maxErrorDepth
+		if hub := h.hubProvider(); hub != nil {
+			if client := hub.Client(); client != nil {
+				errorDepth = client.Options().MaxErrorDepth
+			}
+		}
+		s.SetException(err, errorDepth)
 	}
 
 	key = h.key(FieldUser)
@@ -289,77 +301,89 @@ func (h *logHook) key(key string) string {
 	return key
 }
 
+func logrusFieldToLogEntry(logEntry sentry.LogEntry, key string, value interface{}) sentry.LogEntry {
+	switch val := value.(type) {
+	case int8:
+		return logEntry.Int64(key, int64(val))
+	case int16:
+		return logEntry.Int64(key, int64(val))
+	case int32:
+		return logEntry.Int64(key, int64(val))
+	case int64:
+		return logEntry.Int64(key, val)
+	case int:
+		return logEntry.Int64(key, int64(val))
+	case uint, uint8, uint16, uint32, uint64:
+		uval := reflect.ValueOf(val).Convert(reflect.TypeOf(uint64(0))).Uint()
+		if uval <= math.MaxInt64 {
+			return logEntry.Int64(key, int64(uval))
+		} else {
+			// For values larger than int64 can handle, we use string
+			return logEntry.String(key, strconv.FormatUint(uval, 10))
+		}
+	case string:
+		return logEntry.String(key, val)
+	case float32:
+		return logEntry.Float64(key, float64(val))
+	case float64:
+		return logEntry.Float64(key, val)
+	case bool:
+		return logEntry.Bool(key, val)
+	case time.Time:
+		return logEntry.String(key, val.Format(time.RFC3339))
+	case time.Duration:
+		return logEntry.String(key, val.String())
+	default:
+		// Fallback to string conversion for unknown types
+		return logEntry.String(key, fmt.Sprint(value))
+	}
+}
+
 func (h *logHook) Fire(entry *logrus.Entry) error {
 	ctx := context.Background()
 	if entry.Context != nil {
 		ctx = entry.Context
 	}
 
-	for k, v := range entry.Data {
-		// Skip specific fields that might be handled separately
-		if k == h.key(FieldRequest) || k == h.key(FieldUser) ||
-			k == h.key(FieldFingerprint) || k == FieldGoVersion ||
-			k == FieldMaxProcs || k == logrus.ErrorKey {
-			continue
-		}
-
-		switch val := v.(type) {
-		case int8:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int16:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int32:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int64:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int:
-			h.logger.SetAttributes(attribute.Int(k, val))
-		case uint, uint8, uint16, uint32, uint64:
-			uval := reflect.ValueOf(val).Convert(reflect.TypeOf(uint64(0))).Uint()
-			if uval <= math.MaxInt64 {
-				h.logger.SetAttributes(attribute.Int64(k, int64(uval)))
-			} else {
-				// For values larger than int64 can handle, we are using string.
-				h.logger.SetAttributes(attribute.String(k, strconv.FormatUint(uval, 10)))
-			}
-		case string:
-			h.logger.SetAttributes(attribute.String(k, val))
-		case float32:
-			h.logger.SetAttributes(attribute.Float64(k, float64(val)))
-		case float64:
-			h.logger.SetAttributes(attribute.Float64(k, val))
-		case bool:
-			h.logger.SetAttributes(attribute.Bool(k, val))
-		default:
-			// can't drop argument, fallback to string conversion
-			h.logger.SetAttributes(attribute.String(k, fmt.Sprint(v)))
-		}
-	}
-
-	h.logger.SetAttributes(attribute.String("sentry.origin", "auto.logger.logrus"))
-
+	// Create the base log entry for the appropriate level
+	var logEntry sentry.LogEntry
 	switch entry.Level {
 	case logrus.TraceLevel:
-		h.logger.Trace(ctx, entry.Message)
+		logEntry = h.logger.Trace().WithCtx(ctx)
 	case logrus.DebugLevel:
-		h.logger.Debug(ctx, entry.Message)
+		logEntry = h.logger.Debug().WithCtx(ctx)
 	case logrus.InfoLevel:
-		h.logger.Info(ctx, entry.Message)
+		logEntry = h.logger.Info().WithCtx(ctx)
 	case logrus.WarnLevel:
-		h.logger.Warn(ctx, entry.Message)
+		logEntry = h.logger.Warn().WithCtx(ctx)
 	case logrus.ErrorLevel:
-		h.logger.Error(ctx, entry.Message)
+		logEntry = h.logger.Error().WithCtx(ctx)
 	case logrus.FatalLevel:
-		h.logger.Fatal(ctx, entry.Message)
+		logEntry = h.logger.Fatal().WithCtx(ctx)
 	case logrus.PanicLevel:
-		h.logger.Panic(ctx, entry.Message)
+		logEntry = h.logger.Panic().WithCtx(ctx)
 	default:
-		sentry.DebugLogger.Printf("Invalid logrus logging level: %v. Dropping log.", entry.Level)
+		debuglog.Printf("Invalid logrus logging level: %v. Dropping log.", entry.Level)
 		if h.fallback != nil {
 			return h.fallback(entry)
 		}
 		return errors.New("invalid log level")
 	}
+
+	// Add all the fields as attributes to this specific log entry
+	for k, v := range entry.Data {
+		// Skip specific fields that might be handled separately
+		if k == h.key(FieldRequest) || k == h.key(FieldUser) ||
+			k == h.key(FieldFingerprint) || k == FieldGoVersion ||
+			k == FieldMaxProcs || k == logrus.ErrorKey {
+			continue
+		}
+
+		logEntry = logrusFieldToLogEntry(logEntry, k, v)
+	}
+
+	// Emit the log entry with the message
+	logEntry.Emit(entry.Message)
 	return nil
 }
 
@@ -395,8 +419,11 @@ func NewLogHook(levels []logrus.Level, opts sentry.ClientOptions) (Hook, error)
 func NewLogHookFromClient(levels []logrus.Level, client *sentry.Client) Hook {
 	defaultHub := sentry.NewHub(client, sentry.NewScope())
 	ctx := sentry.SetHubOnContext(context.Background(), defaultHub)
+	logger := sentry.NewLogger(ctx)
+	logger.SetAttributes(attribute.String("sentry.origin", LogrusOrigin))
+
 	return &logHook{
-		logger: sentry.NewLogger(ctx),
+		logger: logger,
 		levels: levels,
 		hubProvider: func() *sentry.Hub {
 			// Default to using the same hub if no specific provider is set
diff --git logrus/logrusentry_test.go logrus/logrusentry_test.go
index 7faa7506d..d1822658b 100644
--- logrus/logrusentry_test.go
+++ logrus/logrusentry_test.go
@@ -373,12 +373,15 @@ func TestEventHook_entryToEvent(t *testing.T) {
 				Extra: map[string]any{},
 				Exception: []sentry.Exception{
 					{
-						Type:  "*errors.errorString",
-						Value: "failure",
+						Type:       "*errors.errorString",
+						Value:      "failure",
+						Stacktrace: nil,
 						Mechanism: &sentry.Mechanism{
-							ExceptionID:      0,
-							IsExceptionGroup: true,
-							Type:             "generic",
+							ExceptionID:      1,
+							IsExceptionGroup: false,
+							ParentID:         sentry.Pointer(0),
+							Type:             sentry.MechanismTypeChained,
+							Source:           sentry.MechanismTypeUnwrap,
 						},
 					},
 					{
@@ -388,10 +391,11 @@ func TestEventHook_entryToEvent(t *testing.T) {
 							Frames: []sentry.Frame{},
 						},
 						Mechanism: &sentry.Mechanism{
-							ExceptionID:      1,
-							IsExceptionGroup: true,
-							ParentID:         sentry.Pointer(0),
-							Type:             "generic",
+							ExceptionID:      0,
+							IsExceptionGroup: false,
+							ParentID:         nil,
+							Type:             sentry.MechanismTypeGeneric,
+							Source:           "",
 						},
 					},
 				},
@@ -657,8 +661,6 @@ func TestLogHookFire(t *testing.T) {
 				Context: context.Background(),
 			}
 
-			// Since we're using a real logger, which is hard to verify,
-			// we're just checking that Fire doesn't error
 			err := logHook.Fire(entry)
 			assert.NoError(t, err)
 		})
diff --git marshal_test.go marshal_test.go
index ecf1d8134..ed290d111 100644
--- marshal_test.go
+++ marshal_test.go
@@ -180,6 +180,28 @@ func TestCheckInEventMarshalJSON(t *testing.T) {
 				Timezone:      "America/Los_Angeles",
 			},
 		},
+		{
+			Release:     "1.0.0",
+			Environment: "dev",
+			Type:        checkInType,
+			CheckIn: &CheckIn{
+				ID:          "c2f0ce1334c74564bf6631f6161173f5",
+				MonitorSlug: "my-monitor",
+				Status:      "ok",
+				Duration:    time.Second * 10,
+			},
+			MonitorConfig: &MonitorConfig{
+				Schedule: &crontabSchedule{
+					Type:  "crontab",
+					Value: "* * * * *",
+				},
+				CheckInMargin:         2,
+				MaxRuntime:            1,
+				Timezone:              "UTC",
+				FailureIssueThreshold: 5,
+				RecoveryThreshold:     1,
+			},
+		},
 	}
 
 	var buf bytes.Buffer
diff --git negroni/go.mod negroni/go.mod
index 7df60a45d..96cb517b5 100644
--- negroni/go.mod
+++ negroni/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/negroni
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.0
 	github.com/google/go-cmp v0.5.9
 	github.com/urfave/negroni/v3 v3.1.1
 )
diff --git negroni/go.sum negroni/go.sum
index 17be47367..35d0b3a32 100644
--- negroni/go.sum
+++ negroni/go.sum
@@ -10,8 +10,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw=
 github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
diff --git otel/go.mod otel/go.mod
index 1b9d6ce68..c825b0a33 100644
--- otel/go.mod
+++ otel/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/otel
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.0
 	github.com/google/go-cmp v0.5.9
 	github.com/stretchr/testify v1.8.4
 	go.opentelemetry.io/otel v1.11.0
diff --git otel/propagator_test.go otel/propagator_test.go
index d4d537941..5c0d4b682 100644
--- otel/propagator_test.go
+++ otel/propagator_test.go
@@ -41,9 +41,9 @@ func createTransactionAndMaybeSpan(transactionContext transactionTestContext, wi
 		// we "swap" span IDs from the transaction and the child span.
 		transaction.SpanID = span.SpanID
 		span.SpanID = SpanIDFromHex(transactionContext.spanID)
-		sentrySpanMap.Set(trace.SpanID(span.SpanID), span)
+		sentrySpanMap.Set(trace.SpanID(span.SpanID), span, trace.SpanID{})
 	}
-	sentrySpanMap.Set(trace.SpanID(transaction.SpanID), transaction)
+	sentrySpanMap.Set(trace.SpanID(transaction.SpanID), transaction, trace.SpanID{})
 
 	otelContext := trace.SpanContextConfig{
 		TraceID:    otelTraceIDFromHex(transactionContext.traceID),
diff --git otel/span_map.go otel/span_map.go
index 5340ff30a..52ac7e539 100644
--- otel/span_map.go
+++ otel/span_map.go
@@ -7,37 +7,111 @@ import (
 	otelTrace "go.opentelemetry.io/otel/trace"
 )
 
+type spanInfo struct {
+	span     *sentry.Span
+	finished bool
+	children map[otelTrace.SpanID]struct{}
+	parentID otelTrace.SpanID
+}
+
 // SentrySpanMap is a mapping between OpenTelemetry spans and Sentry spans.
 // It helps Sentry span processor and propagator to keep track of unfinished
 // Sentry spans and to establish parent-child links between spans.
 type SentrySpanMap struct {
-	spanMap map[otelTrace.SpanID]*sentry.Span
+	spanMap map[otelTrace.SpanID]*spanInfo
 	mu      sync.RWMutex
 }
 
 func (ssm *SentrySpanMap) Get(otelSpandID otelTrace.SpanID) (*sentry.Span, bool) {
 	ssm.mu.RLock()
 	defer ssm.mu.RUnlock()
-	span, ok := ssm.spanMap[otelSpandID]
-	return span, ok
+	info, ok := ssm.spanMap[otelSpandID]
+	if !ok {
+		return nil, false
+	}
+	return info.span, true
 }
 
-func (ssm *SentrySpanMap) Set(otelSpandID otelTrace.SpanID, sentrySpan *sentry.Span) {
+func (ssm *SentrySpanMap) Set(otelSpandID otelTrace.SpanID, sentrySpan *sentry.Span, parentID otelTrace.SpanID) {
 	ssm.mu.Lock()
 	defer ssm.mu.Unlock()
-	ssm.spanMap[otelSpandID] = sentrySpan
+
+	info := &spanInfo{
+		span:     sentrySpan,
+		finished: false,
+		children: make(map[otelTrace.SpanID]struct{}),
+		parentID: parentID,
+	}
+	ssm.spanMap[otelSpandID] = info
+
+	if parentID != (otelTrace.SpanID{}) {
+		if parentInfo, ok := ssm.spanMap[parentID]; ok {
+			parentInfo.children[otelSpandID] = struct{}{}
+		}
+	}
 }
 
-func (ssm *SentrySpanMap) Delete(otelSpandID otelTrace.SpanID) {
+func (ssm *SentrySpanMap) MarkFinished(otelSpandID otelTrace.SpanID) {
 	ssm.mu.Lock()
 	defer ssm.mu.Unlock()
-	delete(ssm.spanMap, otelSpandID)
+
+	info, ok := ssm.spanMap[otelSpandID]
+	if !ok {
+		return
+	}
+
+	info.finished = true
+	ssm.tryCleanupSpan(otelSpandID)
+}
+
+// tryCleanupSpan deletes a parent and all children only if the whole subtree is marked finished.
+// Must be called with lock held.
+func (ssm *SentrySpanMap) tryCleanupSpan(spanID otelTrace.SpanID) {
+	info, ok := ssm.spanMap[spanID]
+	if !ok || !info.finished {
+		return
+	}
+
+	if !info.span.IsTransaction() {
+		parentID := info.parentID
+		if parentID != (otelTrace.SpanID{}) {
+			if parentInfo, parentExists := ssm.spanMap[parentID]; parentExists && !parentInfo.finished {
+				return
+			}
+		}
+	}
+
+	// We need to have a lookup first to see if every child is marked as finished to actually cleanup everything.
+	// There probably is a better way to do this
+	for childID := range info.children {
+		if childInfo, exists := ssm.spanMap[childID]; exists && !childInfo.finished {
+			return
+		}
+	}
+
+	parentID := info.parentID
+	if parentID != (otelTrace.SpanID{}) {
+		if parentInfo, ok := ssm.spanMap[parentID]; ok {
+			delete(parentInfo.children, spanID)
+		}
+	}
+
+	for childID := range info.children {
+		if childInfo, exists := ssm.spanMap[childID]; exists && childInfo.finished {
+			ssm.tryCleanupSpan(childID)
+		}
+	}
+
+	delete(ssm.spanMap, spanID)
+	if parentID != (otelTrace.SpanID{}) {
+		ssm.tryCleanupSpan(parentID)
+	}
 }
 
 func (ssm *SentrySpanMap) Clear() {
 	ssm.mu.Lock()
 	defer ssm.mu.Unlock()
-	ssm.spanMap = make(map[otelTrace.SpanID]*sentry.Span)
+	ssm.spanMap = make(map[otelTrace.SpanID]*spanInfo)
 }
 
 func (ssm *SentrySpanMap) Len() int {
@@ -46,4 +120,4 @@ func (ssm *SentrySpanMap) Len() int {
 	return len(ssm.spanMap)
 }
 
-var sentrySpanMap = SentrySpanMap{spanMap: make(map[otelTrace.SpanID]*sentry.Span)}
+var sentrySpanMap = SentrySpanMap{spanMap: make(map[otelTrace.SpanID]*spanInfo)}
diff --git otel/span_processor.go otel/span_processor.go
index 263d77280..ba2f67a16 100644
--- otel/span_processor.go
+++ otel/span_processor.go
@@ -21,7 +21,7 @@ func NewSentrySpanProcessor() otelSdkTrace.SpanProcessor {
 		return sentrySpanProcessorInstance
 	}
 	sentry.AddGlobalEventProcessor(linkTraceContextToErrorEvent)
-	sentrySpanProcessorInstance := &sentrySpanProcessor{}
+	sentrySpanProcessorInstance = &sentrySpanProcessor{}
 	return sentrySpanProcessorInstance
 }
 
@@ -42,7 +42,7 @@ func (ssp *sentrySpanProcessor) OnStart(parent context.Context, s otelSdkTrace.R
 		span.SpanID = sentry.SpanID(otelSpanID)
 		span.StartTime = s.StartTime()
 
-		sentrySpanMap.Set(otelSpanID, span)
+		sentrySpanMap.Set(otelSpanID, span, otelParentSpanID)
 	} else {
 		traceParentContext := getTraceParentContext(parent)
 		transaction := sentry.StartTransaction(
@@ -58,7 +58,7 @@ func (ssp *sentrySpanProcessor) OnStart(parent context.Context, s otelSdkTrace.R
 			transaction.SetDynamicSamplingContext(dynamicSamplingContext)
 		}
 
-		sentrySpanMap.Set(otelSpanID, transaction)
+		sentrySpanMap.Set(otelSpanID, transaction, otelParentSpanID)
 	}
 }
 
@@ -71,7 +71,7 @@ func (ssp *sentrySpanProcessor) OnEnd(s otelSdkTrace.ReadOnlySpan) {
 	}
 
 	if utils.IsSentryRequestSpan(sentrySpan.Context(), s) {
-		sentrySpanMap.Delete(otelSpanId)
+		sentrySpanMap.MarkFinished(otelSpanId)
 		return
 	}
 
@@ -84,7 +84,7 @@ func (ssp *sentrySpanProcessor) OnEnd(s otelSdkTrace.ReadOnlySpan) {
 	sentrySpan.EndTime = s.EndTime()
 	sentrySpan.Finish()
 
-	sentrySpanMap.Delete(otelSpanId)
+	sentrySpanMap.MarkFinished(otelSpanId)
 }
 
 // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/sdk.md#shutdown-1
diff --git otel/span_processor_test.go otel/span_processor_test.go
index 9d6013f00..23d4a31df 100644
--- otel/span_processor_test.go
+++ otel/span_processor_test.go
@@ -75,7 +75,7 @@ func TestSpanProcessorShutdown(t *testing.T) {
 
 	assertEqual(t, sentrySpanMap.Len(), 1)
 
-	spanProcessor.Shutdown(ctx)
+	_ = spanProcessor.Shutdown(ctx)
 
 	// The span map should be empty
 	assertEqual(t, sentrySpanMap.Len(), 0)
@@ -399,3 +399,59 @@ func TestParseSpanAttributesHttpServer(t *testing.T) {
 	assertEqual(t, sentrySpan.Op, "http.server")
 	assertEqual(t, sentrySpan.Source, sentry.TransactionSource(""))
 }
+
+func TestSpanBecomesChildOfFinishedSpan(t *testing.T) {
+	_, _, tracer := setupSpanProcessorTest()
+	ctx, otelRootSpan := tracer.Start(
+		emptyContextWithSentry(),
+		"rootSpan",
+	)
+	sentryTransaction, _ := sentrySpanMap.Get(otelRootSpan.SpanContext().SpanID())
+
+	ctx, childSpan1 := tracer.Start(
+		ctx,
+		"span name 1",
+	)
+	sentrySpan1, _ := sentrySpanMap.Get(childSpan1.SpanContext().SpanID())
+	childSpan1.End()
+
+	_, childSpan2 := tracer.Start(
+		ctx,
+		"span name 2",
+	)
+	sentrySpan2, _ := sentrySpanMap.Get(childSpan2.SpanContext().SpanID())
+	childSpan2.End()
+
+	otelRootSpan.End()
+
+	assertEqual(t, sentryTransaction.IsTransaction(), true)
+	assertEqual(t, sentrySpan1.IsTransaction(), false)
+	assertEqual(t, sentrySpan1.ParentSpanID, sentryTransaction.SpanID)
+	assertEqual(t, sentrySpan2.IsTransaction(), false)
+	assertEqual(t, sentrySpan2.ParentSpanID, sentrySpan1.SpanID)
+}
+
+func TestSpanWithFinishedParentShouldBeDeleted(t *testing.T) {
+	_, _, tracer := setupSpanProcessorTest()
+
+	ctx, parent := tracer.Start(context.Background(), "parent")
+	parentSpanID := parent.SpanContext().SpanID()
+	_, child := tracer.Start(ctx, "child")
+	childSpanID := child.SpanContext().SpanID()
+
+	_, parentExists := sentrySpanMap.Get(parentSpanID)
+	_, childExists := sentrySpanMap.Get(childSpanID)
+	assertEqual(t, parentExists, true)
+	assertEqual(t, childExists, true)
+
+	parent.End()
+	_, parentExists = sentrySpanMap.Get(parentSpanID)
+	assertEqual(t, parentExists, true)
+	_, childExists = sentrySpanMap.Get(childSpanID)
+	assertEqual(t, childExists, true)
+
+	child.End()
+	_, childExists = sentrySpanMap.Get(childSpanID)
+	assertEqual(t, childExists, false)
+	assertEqual(t, sentrySpanMap.Len(), 0)
+}
diff --git scope.go scope.go
index 3c06279c2..a0bf3f633 100644
--- scope.go
+++ scope.go
@@ -6,6 +6,8 @@ import (
 	"net/http"
 	"sync"
 	"time"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // Scope holds contextual data for the current scope.
@@ -304,6 +306,9 @@ func (scope *Scope) SetPropagationContext(propagationContext PropagationContext)
 
 // GetSpan returns the span from the current scope.
 func (scope *Scope) GetSpan() *Span {
+	scope.mu.RLock()
+	defer scope.mu.RUnlock()
+
 	return scope.span
 }
 
@@ -469,7 +474,7 @@ func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client *Client)
 		id := event.EventID
 		event = processor(event, hint)
 		if event == nil {
-			DebugLogger.Printf("Event dropped by one of the Scope EventProcessors: %s\n", id)
+			debuglog.Printf("Event dropped by one of the Scope EventProcessors: %s\n", id)
 			return nil
 		}
 	}
diff --git scope_test.go scope_test.go
index d11cd8ee4..c23163fcf 100644
--- scope_test.go
+++ scope_test.go
@@ -330,15 +330,15 @@ func TestScopeSetLevelOverrides(t *testing.T) {
 
 func TestAddBreadcrumbAddsBreadcrumb(t *testing.T) {
 	scope := NewScope()
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test"}, defaultMaxBreadcrumbs)
 	assertEqual(t, []*Breadcrumb{{Timestamp: testNow, Message: "test"}}, scope.breadcrumbs)
 }
 
 func TestAddBreadcrumbAppendsBreadcrumb(t *testing.T) {
 	scope := NewScope()
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test1"}, maxBreadcrumbs)
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test2"}, maxBreadcrumbs)
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test3"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test1"}, defaultMaxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test2"}, defaultMaxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test3"}, defaultMaxBreadcrumbs)
 
 	assertEqual(t, []*Breadcrumb{
 		{Timestamp: testNow, Message: "test1"},
@@ -350,7 +350,7 @@ func TestAddBreadcrumbAppendsBreadcrumb(t *testing.T) {
 func TestAddBreadcrumbDefaultLimit(t *testing.T) {
 	scope := NewScope()
 	for i := 0; i < 101; i++ {
-		scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test"}, maxBreadcrumbs)
+		scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test"}, defaultMaxBreadcrumbs)
 	}
 
 	if len(scope.breadcrumbs) != 100 {
@@ -361,7 +361,7 @@ func TestAddBreadcrumbDefaultLimit(t *testing.T) {
 func TestAddBreadcrumbAddsTimestamp(t *testing.T) {
 	scope := NewScope()
 	before := time.Now()
-	scope.AddBreadcrumb(&Breadcrumb{Message: "test"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Message: "test"}, defaultMaxBreadcrumbs)
 	after := time.Now()
 	ts := scope.breadcrumbs[0].Timestamp
 
@@ -412,7 +412,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	clone.SetExtra("foo", "bar")
 	clone.SetLevel(LevelDebug)
 	clone.SetFingerprint([]string{"foo"})
-	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, maxBreadcrumbs)
+	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, defaultMaxBreadcrumbs)
 	clone.AddAttachment(&Attachment{Filename: "foo.txt", Payload: []byte("foo")})
 	clone.SetUser(User{ID: "foo"})
 	r1 := httptest.NewRequest("GET", "/foo", nil)
@@ -427,7 +427,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	scope.SetExtra("foo", "baz")
 	scope.SetLevel(LevelFatal)
 	scope.SetFingerprint([]string{"bar"})
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, defaultMaxBreadcrumbs)
 	scope.AddAttachment(&Attachment{Filename: "bar.txt", Payload: []byte("bar")})
 	scope.SetUser(User{ID: "bar"})
 	r2 := httptest.NewRequest("GET", "/bar", nil)
@@ -469,7 +469,7 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 	scope.SetExtra("foo", "baz")
 	scope.SetLevel(LevelFatal)
 	scope.SetFingerprint([]string{"bar"})
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, defaultMaxBreadcrumbs)
 	scope.AddAttachment(&Attachment{Filename: "bar.txt", Payload: []byte("bar")})
 	scope.SetUser(User{ID: "bar"})
 	r1 := httptest.NewRequest("GET", "/bar", nil)
@@ -488,7 +488,7 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 	clone.SetExtra("foo", "bar")
 	clone.SetLevel(LevelDebug)
 	clone.SetFingerprint([]string{"foo"})
-	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, maxBreadcrumbs)
+	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, defaultMaxBreadcrumbs)
 	clone.AddAttachment(&Attachment{Filename: "foo.txt", Payload: []byte("foo")})
 	clone.SetUser(User{ID: "foo"})
 	r2 := httptest.NewRequest("GET", "/foo", nil)
@@ -560,7 +560,7 @@ func TestClearAndReconfigure(t *testing.T) {
 	scope.SetExtra("foo", "bar")
 	scope.SetLevel(LevelDebug)
 	scope.SetFingerprint([]string{"foo"})
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, defaultMaxBreadcrumbs)
 	scope.AddAttachment(&Attachment{Filename: "foo.txt", Payload: []byte("foo")})
 	scope.SetUser(User{ID: "foo"})
 	r := httptest.NewRequest("GET", "/foo", nil)
diff --git sentry.go sentry.go
index 423849d54..17ba28fca 100644
--- sentry.go
+++ sentry.go
@@ -6,7 +6,7 @@ import (
 )
 
 // The version of the SDK.
-const SDKVersion = "0.34.0"
+const SDKVersion = "0.36.0"
 
 // apiVersion is the minimum version of the Sentry API compatible with the
 // sentry-go SDK.
diff --git slog/converter.go slog/converter.go
index f23923c71..b5340c26f 100644
--- slog/converter.go
+++ slog/converter.go
@@ -10,9 +10,11 @@ import (
 	"time"
 
 	"github.com/getsentry/sentry-go"
-	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
+const maxErrorDepth = 100
+
 var (
 	sourceKey = "source"
 	errorKeys = map[string]struct{}{
@@ -24,7 +26,7 @@ var (
 
 type Converter func(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, hub *sentry.Hub) *sentry.Event
 
-func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, _ *sentry.Hub) *sentry.Event {
+func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, hub *sentry.Hub) *sentry.Event {
 	// aggregate all attributes
 	attrs := appendRecordAttrsToAttrs(loggerAttr, groups, record)
 
@@ -42,7 +44,14 @@ func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.A
 	event.Level = LogLevels[record.Level]
 	event.Message = record.Message
 	event.Logger = name
-	event.SetException(err, 10)
+
+	errorDepth := maxErrorDepth
+	if hub != nil {
+		if client := hub.Client(); client != nil {
+			errorDepth = client.Options().MaxErrorDepth
+		}
+	}
+	event.SetException(err, errorDepth)
 
 	for i := range attrs {
 		attrToSentryEvent(attrs[i], event)
@@ -134,45 +143,44 @@ func handleFingerprint(v slog.Value, event *sentry.Event) {
 	}
 }
 
-func attrToSentryLog(group string, a slog.Attr) []attribute.Builder {
+func slogAttrToLogEntry(logEntry sentry.LogEntry, group string, a slog.Attr) sentry.LogEntry {
 	key := group + a.Key
 	switch a.Value.Kind() {
 	case slog.KindAny:
-		return []attribute.Builder{attribute.String(key, fmt.Sprintf("%+v", a.Value.Any()))}
+		return logEntry.String(key, fmt.Sprintf("%+v", a.Value.Any()))
 	case slog.KindBool:
-		return []attribute.Builder{attribute.Bool(key, a.Value.Bool())}
+		return logEntry.Bool(key, a.Value.Bool())
 	case slog.KindDuration:
-		return []attribute.Builder{attribute.String(key, a.Value.Duration().String())}
+		return logEntry.String(key, a.Value.Duration().String())
 	case slog.KindFloat64:
-		return []attribute.Builder{attribute.Float64(key, a.Value.Float64())}
+		return logEntry.Float64(key, a.Value.Float64())
 	case slog.KindInt64:
-		return []attribute.Builder{attribute.Int64(key, a.Value.Int64())}
+		return logEntry.Int64(key, a.Value.Int64())
 	case slog.KindString:
-		return []attribute.Builder{attribute.String(key, a.Value.String())}
+		return logEntry.String(key, a.Value.String())
 	case slog.KindTime:
-		return []attribute.Builder{attribute.String(key, a.Value.Time().Format(time.RFC3339))}
+		return logEntry.String(key, a.Value.Time().Format(time.RFC3339))
 	case slog.KindUint64:
 		val := a.Value.Uint64()
 		if val <= math.MaxInt64 {
-			return []attribute.Builder{attribute.Int64(key, int64(val))}
+			return logEntry.Int64(key, int64(val))
 		} else {
-			return []attribute.Builder{attribute.String(key, strconv.FormatUint(val, 10))}
+			return logEntry.String(key, strconv.FormatUint(val, 10))
 		}
 	case slog.KindLogValuer:
-		return []attribute.Builder{attribute.String(key, a.Value.LogValuer().LogValue().String())}
+		return logEntry.String(key, a.Value.LogValuer().LogValue().String())
 	case slog.KindGroup:
 		// Handle nested group attributes
-		var attrs []attribute.Builder
 		groupPrefix := key
 		if groupPrefix != "" {
 			groupPrefix += "."
 		}
 		for _, subAttr := range a.Value.Group() {
-			attrs = append(attrs, attrToSentryLog(groupPrefix, subAttr)...)
+			logEntry = slogAttrToLogEntry(logEntry, groupPrefix, subAttr)
 		}
-		return attrs
+		return logEntry
 	}
 
-	sentry.DebugLogger.Printf("Invalid type: dropping attribute with key: %v and value: %v", a.Key, a.Value)
-	return []attribute.Builder{}
+	debuglog.Printf("Invalid type: dropping attribute with key: %v and value: %v", a.Key, a.Value)
+	return logEntry
 }
diff --git slog/go.mod slog/go.mod
index 5eeee4e99..2df7f0dbd 100644
--- slog/go.mod
+++ slog/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/slog
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.0
 	github.com/stretchr/testify v1.9.0
 )
 
diff --git slog/sentryslog.go slog/sentryslog.go
index 2ebfc4efd..5cfb89570 100644
--- slog/sentryslog.go
+++ slog/sentryslog.go
@@ -44,7 +44,9 @@ var (
 	}
 )
 
+// LevelFatal is a custom [slog.Level] that maps to [sentry.LevelFatal]
 const LevelFatal = slog.Level(12)
+const SlogOrigin = "auto.logger.slog"
 
 type Option struct {
 	// Deprecated: Use EventLevel instead. Level is kept for backwards compatibility and defaults to EventLevel.
@@ -104,6 +106,8 @@ func (o Option) NewSentryHandler(ctx context.Context) slog.Handler {
 	}
 
 	logger := sentry.NewLogger(ctx)
+	logger.SetAttributes(attribute.String("sentry.origin", SlogOrigin))
+
 	eventHandler := &eventHandler{
 		option: o,
 		attrs:  []slog.Attr{},
@@ -189,10 +193,14 @@ func (h *eventHandler) Handle(ctx context.Context, record slog.Record) error {
 }
 
 func (h *eventHandler) WithAttrs(attrs []slog.Attr) *eventHandler {
+	// Create a copy of the groups slice to avoid sharing state
+	groupsCopy := make([]string, len(h.groups))
+	copy(groupsCopy, h.groups)
+
 	return &eventHandler{
 		option: h.option,
 		attrs:  appendAttrsToGroup(h.groups, h.attrs, attrs...),
-		groups: h.groups,
+		groups: groupsCopy,
 	}
 }
 
@@ -201,10 +209,15 @@ func (h *eventHandler) WithGroup(name string) *eventHandler {
 		return h
 	}
 
+	// Create a copy of the groups slice to avoid modifying the original
+	newGroups := make([]string, len(h.groups), len(h.groups)+1)
+	copy(newGroups, h.groups)
+	newGroups = append(newGroups, name)
+
 	return &eventHandler{
 		option: h.option,
 		attrs:  h.attrs,
-		groups: append(h.groups, name),
+		groups: newGroups,
 	}
 }
 
@@ -233,42 +246,63 @@ func (h *logHandler) Handle(ctx context.Context, record slog.Record) error {
 	attrs = replaceAttrs(h.option.ReplaceAttr, []string{}, attrs...)
 	attrs = removeEmptyAttrs(attrs)
 
-	var sentryAttributes []attribute.Builder
-	for _, attr := range attrs {
-		sentryAttributes = append(sentryAttributes, attrToSentryLog("", attr)...)
-	}
-	h.logger.SetAttributes(sentryAttributes...)
-	h.logger.SetAttributes(attribute.String("sentry.origin", "auto.logger.slog"))
-
 	// Use level ranges instead of exact matches to support custom levels
 	switch {
 	case record.Level < slog.LevelDebug:
 		// Levels below Debug (e.g., Trace)
-		h.logger.Trace(ctx, record.Message)
+		logEntry := h.logger.Trace().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < slog.LevelInfo:
 		// Debug level range: -4 to -1
-		h.logger.Debug(ctx, record.Message)
+		logEntry := h.logger.Debug().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < slog.LevelWarn:
 		// Info level range: 0 to 3
-		h.logger.Info(ctx, record.Message)
+		logEntry := h.logger.Info().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < slog.LevelError:
 		// Warn level range: 4 to 7
-		h.logger.Warn(ctx, record.Message)
+		logEntry := h.logger.Warn().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < LevelFatal: // custom Fatal level, keep +4 increments
-		h.logger.Error(ctx, record.Message)
+		logEntry := h.logger.Error().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	default:
 		// Fatal level range: 12 and above
-		h.logger.Fatal(ctx, record.Message)
+		logEntry := h.logger.Fatal().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	}
 
 	return nil
 }
 
 func (h *logHandler) WithAttrs(attrs []slog.Attr) *logHandler {
+	// Create a copy of the groups slice to avoid sharing state
+	groupsCopy := make([]string, len(h.groups))
+	copy(groupsCopy, h.groups)
+
 	return &logHandler{
 		option: h.option,
 		attrs:  appendAttrsToGroup(h.groups, h.attrs, attrs...),
-		groups: h.groups,
+		groups: groupsCopy,
 		logger: h.logger,
 	}
 }
@@ -278,10 +312,15 @@ func (h *logHandler) WithGroup(name string) *logHandler {
 		return h
 	}
 
+	// Create a copy of the groups slice to avoid modifying the original
+	newGroups := make([]string, len(h.groups), len(h.groups)+1)
+	copy(newGroups, h.groups)
+	newGroups = append(newGroups, name)
+
 	return &logHandler{
 		option: h.option,
 		attrs:  h.attrs,
-		groups: append(h.groups, name),
+		groups: newGroups,
 		logger: h.logger,
 	}
 }
diff --git span_recorder.go span_recorder.go
index a2a7d19ce..ba0410150 100644
--- span_recorder.go
+++ span_recorder.go
@@ -2,6 +2,8 @@ package sentry
 
 import (
 	"sync"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // A spanRecorder stores a span tree that makes up a transaction. Safe for
@@ -24,7 +26,7 @@ func (r *spanRecorder) record(s *Span) {
 	if len(r.spans) >= maxSpans {
 		r.overflowOnce.Do(func() {
 			root := r.spans[0]
-			DebugLogger.Printf("Too many spans: dropping spans from transaction with TraceID=%s SpanID=%s limit=%d",
+			debuglog.Printf("Too many spans: dropping spans from transaction with TraceID=%s SpanID=%s limit=%d",
 				root.TraceID, root.SpanID, maxSpans)
 		})
 		// TODO(tracing): mark the transaction event in some way to
diff --git span_recorder_test.go span_recorder_test.go
index 65f432dc3..f8d8706ff 100644
--- span_recorder_test.go
+++ span_recorder_test.go
@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"io"
 	"testing"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 func Test_spanRecorder_record(t *testing.T) {
@@ -32,8 +34,8 @@ func Test_spanRecorder_record(t *testing.T) {
 	} {
 		t.Run(tt.name, func(t *testing.T) {
 			logBuffer := bytes.Buffer{}
-			DebugLogger.SetOutput(&logBuffer)
-			defer DebugLogger.SetOutput(io.Discard)
+			debuglog.SetOutput(&logBuffer)
+			defer debuglog.SetOutput(io.Discard)
 			spanRecorder := spanRecorder{}
 
 			currentHub.BindClient(&Client{
@@ -54,7 +56,7 @@ func Test_spanRecorder_record(t *testing.T) {
 			} else {
 				assertEqual(t, len(spanRecorder.spans), tt.toRecordSpans, "expected no overflow")
 			}
-			// check if DebugLogger was called for overflow messages
+			// check if debuglog was called for overflow messages
 			if bytes.Contains(logBuffer.Bytes(), []byte("Too many spans")) && !tt.expectOverflow {
 				t.Error("unexpected overflow log")
 			}
diff --git a/testdata/json/checkin/003.json b/testdata/json/checkin/003.json
new file mode 100644
index 000000000..cbce42cf7
--- /dev/null
+++ testdata/json/checkin/003.json
@@ -0,0 +1,19 @@
+{
+  "check_in_id": "c2f0ce1334c74564bf6631f6161173f5",
+  "monitor_slug": "my-monitor",
+  "status": "ok",
+  "duration": 10,
+  "release": "1.0.0",
+  "environment": "dev",
+  "monitor_config": {
+    "schedule": {
+      "type": "crontab",
+      "value": "* * * * *"
+    },
+    "checkin_margin": 2,
+    "max_runtime": 1,
+    "timezone": "UTC",
+    "failure_issue_threshold": 5,
+    "recovery_threshold": 1
+  }
+}
diff --git tracing.go tracing.go
index 993c207f9..1ab03d3bb 100644
--- tracing.go
+++ tracing.go
@@ -12,6 +12,8 @@ import (
 	"strings"
 	"sync"
 	"time"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 const (
@@ -347,6 +349,58 @@ func (s *Span) SetDynamicSamplingContext(dsc DynamicSamplingContext) {
 	}
 }
 
+// shouldIgnoreStatusCode checks if the transaction should be ignored based on HTTP status code.
+func (s *Span) shouldIgnoreStatusCode() bool {
+	if !s.IsTransaction() {
+		return false
+	}
+
+	ignoreStatusCodes := s.clientOptions().TraceIgnoreStatusCodes
+	if len(ignoreStatusCodes) == 0 {
+		return false
+	}
+
+	s.mu.Lock()
+	statusCodeData, exists := s.Data["http.response.status_code"]
+	s.mu.Unlock()
+
+	if !exists {
+		return false
+	}
+
+	statusCode, ok := statusCodeData.(int)
+	if !ok {
+		return false
+	}
+
+	for _, ignoredRange := range ignoreStatusCodes {
+		switch len(ignoredRange) {
+		case 1:
+			// Single status code
+			if statusCode == ignoredRange[0] {
+				s.mu.Lock()
+				s.Sampled = SampledFalse
+				s.mu.Unlock()
+				debuglog.Printf("dropping transaction with status code %v: found in TraceIgnoreStatusCodes", statusCode)
+				return true
+			}
+		case 2:
+			// Range of status codes [min, max]
+			if ignoredRange[0] <= statusCode && statusCode <= ignoredRange[1] {
+				s.mu.Lock()
+				s.Sampled = SampledFalse
+				s.mu.Unlock()
+				debuglog.Printf("dropping transaction with status code %v: found in TraceIgnoreStatusCodes range [%d, %d]", statusCode, ignoredRange[0], ignoredRange[1])
+				return true
+			}
+		default:
+			debuglog.Printf("incorrect TraceIgnoreStatusCodes format: %v", ignoredRange)
+		}
+	}
+
+	return false
+}
+
 // doFinish runs the actual Span.Finish() logic.
 func (s *Span) doFinish() {
 	if s.EndTime.IsZero() {
@@ -360,6 +414,10 @@ func (s *Span) doFinish() {
 		}
 	}
 
+	if s.shouldIgnoreStatusCode() {
+		return
+	}
+
 	if !s.Sampled.Bool() {
 		return
 	}
@@ -449,14 +507,14 @@ func (s *Span) sample() Sampled {
 	// https://develop.sentry.dev/sdk/performance/#sampling
 	// #1 tracing is not enabled.
 	if !clientOptions.EnableTracing {
-		DebugLogger.Printf("Dropping transaction: EnableTracing is set to %t", clientOptions.EnableTracing)
+		debuglog.Printf("Dropping transaction: EnableTracing is set to %t", clientOptions.EnableTracing)
 		s.sampleRate = 0.0
 		return SampledFalse
 	}
 
 	// #2 explicit sampling decision via StartSpan/StartTransaction options.
 	if s.explicitSampled != SampledUndefined {
-		DebugLogger.Printf("Using explicit sampling decision from StartSpan/StartTransaction: %v", s.explicitSampled)
+		debuglog.Printf("Using explicit sampling decision from StartSpan/StartTransaction: %v", s.explicitSampled)
 		switch s.explicitSampled {
 		case SampledTrue:
 			s.sampleRate = 1.0
@@ -489,25 +547,25 @@ func (s *Span) sample() Sampled {
 			s.dynamicSamplingContext.Entries["sample_rate"] = strconv.FormatFloat(tracesSamplerSampleRate, 'f', -1, 64)
 		}
 		if tracesSamplerSampleRate < 0.0 || tracesSamplerSampleRate > 1.0 {
-			DebugLogger.Printf("Dropping transaction: Returned TracesSampler rate is out of range [0.0, 1.0]: %f", tracesSamplerSampleRate)
+			debuglog.Printf("Dropping transaction: Returned TracesSampler rate is out of range [0.0, 1.0]: %f", tracesSamplerSampleRate)
 			return SampledFalse
 		}
 		if tracesSamplerSampleRate == 0.0 {
-			DebugLogger.Printf("Dropping transaction: Returned TracesSampler rate is: %f", tracesSamplerSampleRate)
+			debuglog.Printf("Dropping transaction: Returned TracesSampler rate is: %f", tracesSamplerSampleRate)
 			return SampledFalse
 		}
 
 		if rng.Float64() < tracesSamplerSampleRate {
 			return SampledTrue
 		}
-		DebugLogger.Printf("Dropping transaction: TracesSampler returned rate: %f", tracesSamplerSampleRate)
+		debuglog.Printf("Dropping transaction: TracesSampler returned rate: %f", tracesSamplerSampleRate)
 
 		return SampledFalse
 	}
 
 	// #4 inherit parent decision.
 	if s.Sampled != SampledUndefined {
-		DebugLogger.Printf("Using sampling decision from parent: %v", s.Sampled)
+		debuglog.Printf("Using sampling decision from parent: %v", s.Sampled)
 		switch s.Sampled {
 		case SampledTrue:
 			s.sampleRate = 1.0
@@ -525,11 +583,11 @@ func (s *Span) sample() Sampled {
 		s.dynamicSamplingContext.Entries["sample_rate"] = strconv.FormatFloat(sampleRate, 'f', -1, 64)
 	}
 	if sampleRate < 0.0 || sampleRate > 1.0 {
-		DebugLogger.Printf("Dropping transaction: TracesSampleRate out of range [0.0, 1.0]: %f", sampleRate)
+		debuglog.Printf("Dropping transaction: TracesSampleRate out of range [0.0, 1.0]: %f", sampleRate)
 		return SampledFalse
 	}
 	if sampleRate == 0.0 {
-		DebugLogger.Printf("Dropping transaction: TracesSampleRate rate is: %f", sampleRate)
+		debuglog.Printf("Dropping transaction: TracesSampleRate rate is: %f", sampleRate)
 		return SampledFalse
 	}
 
@@ -552,7 +610,7 @@ func (s *Span) toEvent() *Event {
 	finished := make([]*Span, 0, len(children))
 	for _, child := range children {
 		if child.EndTime.IsZero() {
-			DebugLogger.Printf("Dropped unfinished span: Op=%q TraceID=%s SpanID=%s", child.Op, child.TraceID, child.SpanID)
+			debuglog.Printf("Dropped unfinished span: Op=%q TraceID=%s SpanID=%s", child.Op, child.TraceID, child.SpanID)
 			continue
 		}
 		finished = append(finished, child)
diff --git transport.go transport.go
index e2ec87abf..d2867418c 100644
--- transport.go
+++ transport.go
@@ -13,6 +13,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/getsentry/sentry-go/internal/debuglog"
 	"github.com/getsentry/sentry-go/internal/ratelimit"
 )
 
@@ -87,14 +88,14 @@ func getRequestBodyFromEvent(event *Event) []byte {
 	}
 	body, err = json.Marshal(event)
 	if err == nil {
-		DebugLogger.Println(msg)
+		debuglog.Println(msg)
 		return body
 	}
 
 	// This should _only_ happen when Event.Exception[0].Stacktrace.Frames[0].Vars is unserializable
 	// Which won't ever happen, as we don't use it now (although it's the part of public interface accepted by Sentry)
 	// Juuust in case something, somehow goes utterly wrong.
-	DebugLogger.Println("Event couldn't be marshaled, even with stripped contextual data. Skipping delivery. " +
+	debuglog.Println("Event couldn't be marshaled, even with stripped contextual data. Skipping delivery. " +
 		"Please notify the SDK owners with possibly broken payload.")
 	return nil
 }
@@ -262,17 +263,6 @@ func getRequestFromEvent(ctx context.Context, event *Event, dsn *Dsn) (r *http.R
 	)
 }
 
-func categoryFor(eventType string) ratelimit.Category {
-	switch eventType {
-	case "":
-		return ratelimit.CategoryError
-	case transactionType:
-		return ratelimit.CategoryTransaction
-	default:
-		return ratelimit.Category(eventType)
-	}
-}
-
 // ================================
 // HTTPTransport
 // ================================
@@ -303,7 +293,8 @@ type HTTPTransport struct {
 	// current in-flight items and starts a new batch for subsequent events.
 	buffer chan batch
 
-	start sync.Once
+	startOnce sync.Once
+	closeOnce sync.Once
 
 	// Size of the transport buffer. Defaults to 30.
 	BufferSize int
@@ -331,7 +322,7 @@ func NewHTTPTransport() *HTTPTransport {
 func (t *HTTPTransport) Configure(options ClientOptions) {
 	dsn, err := NewDsn(options.Dsn)
 	if err != nil {
-		DebugLogger.Printf("%v\n", err)
+		debuglog.Printf("%v\n", err)
 		return
 	}
 	t.dsn = dsn
@@ -364,7 +355,7 @@ func (t *HTTPTransport) Configure(options ClientOptions) {
 		}
 	}
 
-	t.start.Do(func() {
+	t.startOnce.Do(func() {
 		go t.worker()
 	})
 }
@@ -380,7 +371,7 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event)
 		return
 	}
 
-	category := categoryFor(event.Type)
+	category := event.toCategory()
 
 	if t.disabled(category) {
 		return
@@ -413,9 +404,9 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event)
 		if event.Type == transactionType {
 			eventType = "transaction"
 		} else {
-			eventType = fmt.Sprintf("%s event", event.Level)
+			eventType = fmt.Sprintf("%s event", event.Type)
 		}
-		DebugLogger.Printf(
+		debuglog.Printf(
 			"Sending %s [%s] to %s project: %s",
 			eventType,
 			event.EventID,
@@ -423,7 +414,7 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event)
 			t.dsn.projectID,
 		)
 	default:
-		DebugLogger.Println("Event dropped due to transport buffer being full.")
+		debuglog.Println("Event dropped due to transport buffer being full.")
 	}
 
 	t.buffer <- b
@@ -440,11 +431,9 @@ 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 {
-	timeoutCh := make(chan struct{})
-	time.AfterFunc(timeout, func() {
-		close(timeoutCh)
-	})
-	return t.flushInternal(timeoutCh)
+	ctx, cancel := context.WithTimeout(context.Background(), timeout)
+	defer cancel()
+	return t.FlushWithContext(ctx)
 }
 
 // FlushWithContext works like Flush, but it accepts a context.Context instead of a timeout.
@@ -489,14 +478,14 @@ started:
 	// Wait until the current batch is done or the timeout.
 	select {
 	case <-b.done:
-		DebugLogger.Println("Buffer flushed successfully.")
+		debuglog.Println("Buffer flushed successfully.")
 		return true
 	case <-timeout:
 		goto fail
 	}
 
 fail:
-	DebugLogger.Println("Buffer flushing was canceled or timed out.")
+	debuglog.Println("Buffer flushing was canceled or timed out.")
 	return false
 }
 
@@ -506,7 +495,9 @@ fail:
 // Close should be called after Flush and before terminating the program
 // otherwise some events may be lost.
 func (t *HTTPTransport) Close() {
-	close(t.done)
+	t.closeOnce.Do(func() {
+		close(t.done)
+	})
 }
 
 func (t *HTTPTransport) worker() {
@@ -534,15 +525,15 @@ func (t *HTTPTransport) worker() {
 
 				response, err := t.client.Do(item.request)
 				if err != nil {
-					DebugLogger.Printf("There was an issue with sending an event: %v", err)
+					debuglog.Printf("There was an issue with sending an event: %v", err)
 					continue
 				}
 				if response.StatusCode >= 400 && response.StatusCode <= 599 {
 					b, err := io.ReadAll(response.Body)
 					if err != nil {
-						DebugLogger.Printf("Error while reading response code: %v", err)
+						debuglog.Printf("Error while reading response code: %v", err)
 					}
-					DebugLogger.Printf("Sending %s failed with the following error: %s", eventType, string(b))
+					debuglog.Printf("Sending %s failed with the following error: %s", eventType, string(b))
 				}
 
 				t.mu.Lock()
@@ -569,7 +560,7 @@ func (t *HTTPTransport) disabled(c ratelimit.Category) bool {
 	defer t.mu.RUnlock()
 	disabled := t.limits.IsRateLimited(c)
 	if disabled {
-		DebugLogger.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
+		debuglog.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
 	}
 	return disabled
 }
@@ -615,7 +606,7 @@ func NewHTTPSyncTransport() *HTTPSyncTransport {
 func (t *HTTPSyncTransport) Configure(options ClientOptions) {
 	dsn, err := NewDsn(options.Dsn)
 	if err != nil {
-		DebugLogger.Printf("%v\n", err)
+		debuglog.Printf("%v\n", err)
 		return
 	}
 	t.dsn = dsn
@@ -652,7 +643,7 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve
 		return
 	}
 
-	if t.disabled(categoryFor(event.Type)) {
+	if t.disabled(event.toCategory()) {
 		return
 	}
 
@@ -670,7 +661,7 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve
 	default:
 		eventIdentifier = fmt.Sprintf("%s event", event.Level)
 	}
-	DebugLogger.Printf(
+	debuglog.Printf(
 		"Sending %s [%s] to %s project: %s",
 		eventIdentifier,
 		event.EventID,
@@ -680,15 +671,15 @@ func (t *HTTPSyncTranspo,rt) SendEventWithContext(ctx context.Context, event *Eve
 
 	response, err := t.client.Do(request)
 	if err != nil {
-		DebugLogger.Printf("There was an issue with sending an event: %v", err)
+		debuglog.Printf("There was an issue with sending an event: %v", err)
 		return
 	}
 	if response.StatusCode >= 400 && response.StatusCode <= 599 {
 		b, err := io.ReadAll(response.Body)
 		if err != nil {
-			DebugLogger.Printf("Error while reading response code: %v", err)
+			debuglog.Printf("Error while reading response code: %v", err)
 		}
-		DebugLogger.Printf("Sending %s failed with the following error: %s", eventIdentifier, string(b))
+		debuglog.Printf("Sending %s failed with the following error: %s", eventIdentifier, string(b))
 	}
 
 	t.mu.Lock()
@@ -720,7 +711,7 @@ func (t *HTTPSyncTransport) disabled(c ratelimit.Category) bool {
 	defer t.mu.Unlock()
 	disabled := t.limits.IsRateLimited(c)
 	if disabled {
-		DebugLogger.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
+		debuglog.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
 	}
 	return disabled
 }
@@ -736,11 +727,11 @@ type noopTransport struct{}
 var _ Transport = noopTransport{}
 
 func (noopTransport) Configure(ClientOptions) {
-	DebugLogger.Println("Sentry client initialized with an empty DSN. Using noopTransport. No events will be delivered.")
+	debuglog.Println("Sentry client initialized with an empty DSN. Using noopTransport. No events will be delivered.")
 }
 
 func (noopTransport) SendEvent(*Event) {
-	DebugLogger.Println("Event dropped due to noopTransport usage.")
+	debuglog.Println("Event dropped due to noopTransport usage.")
 }
 
 func (noopTransport) Flush(time.Duration) bool {
diff --git transport_test.go transport_test.go
index cf29596f1..f4a066ad2 100644
--- transport_test.go
+++ transport_test.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"net"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptrace"
@@ -490,6 +491,28 @@ func TestHTTPTransport(t *testing.T) {
 		wg.Wait()
 	})
 }
+func TestHTTPTransport_CloseMultipleTimes(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(),
+	})
+
+	// Closing multiple times should not panic.
+	for i := 0; i < 10; i++ {
+		transport.Close()
+	}
+
+	// Verify the done channel is closed
+	select {
+	case <-transport.done:
+		// Expected - channel should be closed
+	case <-time.After(time.Second):
+		t.Fatal("transport.done not closed")
+	}
+}
 
 func TestHTTPTransport_FlushWithContext(t *testing.T) {
 	server := newTestHTTPServer(t)
@@ -767,19 +790,31 @@ func TestHTTPTransportDoesntLeakGoroutines(t *testing.T) {
 
 	transport := NewHTTPTransport()
 	transport.Configure(ClientOptions{
-		Dsn:        "https://test@foobar/1",
-		HTTPClient: http.DefaultClient,
+		Dsn: "https://test@foobar/1",
+		HTTPClient: &http.Client{
+			Transport: &http.Transport{
+				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+					return nil, fmt.Errorf("mock transport - no real connections")
+				},
+			},
+		},
 	})
 
-	transport.Flush(0)
+	transport.Flush(testutils.FlushTimeout())
 	transport.Close()
 }
 
 func TestHTTPTransportClose(t *testing.T) {
 	transport := NewHTTPTransport()
 	transport.Configure(ClientOptions{
-		Dsn:        "https://test@foobar/1",
-		HTTPClient: http.DefaultClient,
+		Dsn: "https://test@foobar/1",
+		HTTPClient: &http.Client{
+			Transport: &http.Transport{
+				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+					return nil, fmt.Errorf("mock transport - no real connections")
+				},
+			},
+		},
 	})
 
 	transport.Close()
diff --git util.go util.go
index 1dfb091fb..3a6a33c8d 100644
--- util.go
+++ util.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/getsentry/sentry-go/internal/debuglog"
 	exec "golang.org/x/sys/execabs"
 )
 
@@ -62,7 +63,7 @@ func defaultRelease() (release string) {
 	}
 	for _, e := range envs {
 		if release = os.Getenv(e); release != "" {
-			DebugLogger.Printf("Using release from environment variable %s: %s", e, release)
+			debuglog.Printf("Using release from environment variable %s: %s", e, release)
 			return release
 		}
 	}
@@ -89,23 +90,23 @@ func defaultRelease() (release string) {
 			if err, ok := err.(*exec.ExitError); ok && len(err.Stderr) > 0 {
 				fmt.Fprintf(&s, ": %s", err.Stderr)
 			}
-			DebugLogger.Print(s.String())
+			debuglog.Print(s.String())
 		} else {
 			release = strings.TrimSpace(string(b))
-			DebugLogger.Printf("Using release from Git: %s", release)
+			debuglog.Printf("Using release from Git: %s", release)
 			return release
 		}
 	}
 
-	DebugLogger.Print("Some Sentry features will not be available. See https://docs.sentry.io/product/releases/.")
-	DebugLogger.Print("To stop seeing this message, pass a Release to sentry.Init or set the SENTRY_RELEASE environment variable.")
+	debuglog.Print("Some Sentry features will not be available. See https://docs.sentry.io/product/releases/.")
+	debuglog.Print("To stop seeing this message, pass a Release to sentry.Init or set the SENTRY_RELEASE environment variable.")
 	return ""
 }
 
 func revisionFromBuildInfo(info *debug.BuildInfo) string {
 	for _, setting := range info.Settings {
 		if setting.Key == "vcs.revision" && setting.Value != "" {
-			DebugLogger.Printf("Using release from debug info: %s", setting.Value)
+			debuglog.Printf("Using release from debug info: %s", setting.Value)
 			return setting.Value
 		}
 	}
diff --git zerolog/go.mod zerolog/go.mod
index 69e9b398f..2df4460c3 100644
--- zerolog/go.mod
+++ zerolog/go.mod
@@ -1,12 +1,12 @@
 module github.com/getsentry/sentry-go/zerolog
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
 	github.com/buger/jsonparser v1.1.1
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.0
 	github.com/rs/zerolog v1.33.0
 	github.com/stretchr/testify v1.9.0
 )

Description

This is a comprehensive update to the Sentry Go SDK (v0.36.0) that includes several breaking changes, new features, bug fixes, and organizational improvements. The PR introduces enhanced error chain handling, HTTP status code filtering for tracing, updated logging APIs, and better concurrency safety.

Possible Issues

  • Breaking changes in the logging API will require code migration for existing users
  • Behavioral changes in error handling could cause new issue groupings in Sentry
  • Increased default breadcrumb limit from 30 to 100 could impact memory usage for applications with many breadcrumbs
  • The fluentlogging API introduces more complex state management that could lead to confusion if not properly documented

Security Hotspots

No significant security hotspots were identified in this code change. The changes primarily focus on functionality improvements and API enhancements without introducing obvious security vulnerabilities.

Privacy Hotspots

  • User attribute handling in logs: The logging changes include automatic user attribute collection (user ID, name, email) when present in the scope, which could increase data collection
  • Enhanced error chain collection: More detailed error information is now captured and sent to Sentry, potentially including sensitive data in nested error messages
Changes

Changes

.cursor/rules/changelog.mdc

  • Added: New comprehensive changelog creation guidelines with detailed formatting rules and examples

.github/ directory updates

  • Updated: GitHub Actions workflow versions (checkout@v5, setup-go@v6, etc.)
  • Simplified: Issue template configuration and pull request template
  • Enhanced: Test workflow with conditional race detection based on event type

Version and dependency updates

  • Updated: Go version requirement from 1.21 to 1.23
  • Updated: SDK version to 0.36.0 across all modules
  • Dropped: Support for Go 1.22, added support for Go 1.25

Core functionality changes

client.go

  • Changed: Default max breadcrumbs from 30 to 100 with hard limit removal
  • Added: TraceIgnoreStatusCodes configuration option
  • Enhanced: Batch logger shutdown and flush handling with proper synchronization

exception.go (new file)

  • Added: Comprehensive error chain handling with DFS traversal
  • Added: Support for errors.Join() and complex error hierarchies
  • Added: Circular reference protection and improved mechanism types

log.go and related files

  • Breaking change: Logging API now uses fluent interface pattern
  • Added: LogEntry interface for chaining attributes and context
  • Enhanced: Concurrency safety with proper mutex usage
  • Added: Better attribute type handling and context correlation

tracing.go

  • Added: HTTP status code filtering via shouldIgnoreStatusCode() method
  • Enhanced: Debug logging for sampling decisions

transport.go

  • Added: Enhanced rate limiting categories for logs and monitors
  • Fixed: Race conditions in transport close operations
  • Improved: Error categorization logic

OpenTelemetry integration (otel/)

  • Fixed: Span lifecycle management with proper parent-child cleanup
  • Enhanced: Span mapping with hierarchical tracking
  • Improved: Memory management for finished spans

Integration updates

  • Enhanced: All HTTP integrations (fasthttp, fiber, gin, etc.) with malformed URL protection
  • Updated: All go.mod files to Go 1.23 and new SDK version
  • Fixed: Race conditions in scope access across integrations

Testing improvements

  • Added: Comprehensive race condition tests for logging
  • Enhanced: Mock transport functionality
  • Added: Circular reference protection tests
  • Improved: Test timeout handling and flaky test mitigation
sequenceDiagram
    participant App as Application
    participant Logger as Logger/LogEntry
    participant BatchLogger as BatchLogger
    participant Transport as Transport
    participant Sentry as Sentry Server

    App->>Logger: Info().String("key", "value").Emit("message")
    Logger->>Logger: Build log entry with attributes
    Logger->>BatchLogger: Send log to batch channel
    
    alt Batch full or timeout
        BatchLogger->>BatchLogger: Process batch
        BatchLogger->>Transport: SendEvent(batched logs)
        Transport->>Sentry: HTTP POST /api/projects/.../store/
        Sentry-->>Transport: Response with rate limits
        Transport-->>BatchLogger: Update rate limits
    end
    
    alt Flush requested
        App->>BatchLogger: Flush(timeout)
        BatchLogger->>BatchLogger: Drain remaining logs
        BatchLogger->>Transport: SendEvent(remaining logs)
        Transport->>Sentry: HTTP POST
        BatchLogger-->>App: Flush complete
    end
Loading

@renovate renovate bot force-pushed the renovate/github-com-getsentry-sentry-go-0-x branch from 59d7246 to b9fc649 Compare October 28, 2025 16:42
@renovate renovate bot changed the title fix(deps): update module github.com/getsentry/sentry-go to v0.36.0 fix(deps): update module github.com/getsentry/sentry-go to v0.36.1 Oct 28, 2025
@github-actions
Copy link

[puLL-Merge] - getsentry/sentry-go@otel/v0.34.0..otel/v0.36.1

Diff
diff --git a/.cursor/rules/changelog.mdc b/.cursor/rules/changelog.mdc
new file mode 100644
index 000000000..a22fefe88
--- /dev/null
+++ .cursor/rules/changelog.mdc
@@ -0,0 +1,168 @@
+---
+globs: CHANGELOG.md
+alwaysApply: false
+---
+# Changelog Creation Guidelines
+
+When creating or updating changelogs for the Sentry Go SDK, follow these rules:
+
+## Gathering Changes
+
+Before creating a changelog entry, collect all changes since the last release tag:
+
+### Find the Latest Release Tag
+```bash
+git tag --sort=-version:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -1
+```
+
+### Get Commits Since Last Release
+```bash
+# Get commit hashes and messages since last tag
+git log --oneline $(git describe --tags --abbrev=0)..HEAD
+
+# Get detailed commit information
+git log --pretty=format:"%h %s (%an)" $(git describe --tags --abbrev=0)..HEAD
+```
+
+### Analyze Changes
+For each commit since the last release:
+1. Check if it's a merge commit from a PR: `git show --stat <commit_hash>`
+2. For PR commits, fetch the PR details to understand the full context
+3. Categorize changes as Breaking Changes, Features, Deprecations, Bug Fixes, or Misc
+4. Identify any commits that should be excluded (internal refactoring, test-only changes, etc.)
+
+### Example Workflow
+```bash
+# Check current branch and recent commits
+git log --oneline --since="2024-01-01" | head -10
+
+# Get PR information for specific commits
+git show <commit_hash> --stat
+
+# Look at file changes to understand scope
+git diff --name-only <last_tag>..HEAD
+```
+
+Always base changelog entries on the complete set of commits since the last release tag to ensure no changes are missed.
+
+## Version Structure
+
+Use semantic versioning (e.g., `0.34.0`) with this format:
+
+```markdown
+## [VERSION]
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v[VERSION].
+```
+
+## Section Order
+
+Include sections in this exact order (only include sections that have content):
+
+1. **Breaking Changes** - Changes requiring code modifications
+2. **Deprecations** - Features marked for future removal
+3. **Features** - New functionality and enhancements  
+4. **Bug Fixes** - Fixes for existing functionality
+5. **Misc** - Other changes
+
+## Formatting Rules
+
+### Pull Request Links
+- Always use format: `([#NUMBER](https://github.com/getsentry/sentry-go/pull/NUMBER))`
+- For issues: `([#NUMBER](https://github.com/getsentry/sentry-go/issues/NUMBER))`
+
+### Code Examples
+- Use Go syntax highlighting: ````go
+- For breaking changes, show both before and after examples
+- Include relevant context, not just the changed line
+
+### Descriptions
+- Start with action verbs (Add, Fix, Remove, Update, etc.)
+- Be specific about what changed
+- Include component/module names when relevant
+- Keep concise but informative
+
+## Section Guidelines
+
+### Breaking Changes
+- Always provide migration examples with **Before:** and **After:** code blocks
+- Explain rationale for the change
+- Include timeline for removal if deprecating
+
+### Features
+- Focus on user-facing functionality
+- Include code examples for complex features
+- Link to documentation when relevant
+
+### Bug Fixes
+- Clearly describe what was fixed
+- Include component names (e.g., "Fix race condition in `Scope.GetSpan()` method")
+- Reference the specific issue if applicable
+
+### Deprecations
+- Include migration guidance
+- Specify removal timeline
+- Provide alternative solutions
+
+## Content Guidelines
+
+### Include:
+- All user-facing changes
+- Breaking changes with migration guidance
+- New features and enhancements
+- Important bug fixes
+- Performance improvements
+- Security fixes
+- Deprecation notices
+
+### Exclude:
+- Internal refactoring (unless affects performance)
+- Test-only changes
+- Documentation-only changes (unless significant)
+- Build system changes
+- CI/CD changes
+
+## Example Structure
+
+```markdown
+## 0.34.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.1.
+
+### Features
+
+- Allow flush to be used multiple times without issues, particularly for the batch logger ([#1051](https://github.com/getsentry/sentry-go/pull/1051))
+
+### Bug Fixes
+
+- Fix race condition in `Scope.GetSpan()` method by adding proper mutex locking ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+- Guard transport on `Close()` to prevent panic when called multiple times ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+```
+
+## Quality Checklist
+
+Before publishing:
+- [ ] Version number follows semantic versioning
+- [ ] All sections in correct order
+- [ ] All PR/issue links working
+- [ ] Code examples tested and accurate
+- [ ] Breaking changes include migration guidance
+- [ ] Descriptions clear and specific
+- [ ] Grammar and spelling correct
+- [ ] No internal-only changes included
+
+## Notes Section
+
+For significant releases, add a notes section:
+
+```markdown
+_NOTE:_
+Additional context, warnings, or important information about this release.
+```
+
+Use for:
+- Go version compatibility changes
+- Important upgrade considerations
+- Significant behavioral changes
+- Performance characteristics
+- Known limitations
diff --git .github/ISSUE_TEMPLATE/config.yml .github/ISSUE_TEMPLATE/config.yml
index 191febb53..31f71b14f 100644
--- .github/ISSUE_TEMPLATE/config.yml
+++ .github/ISSUE_TEMPLATE/config.yml
@@ -3,9 +3,3 @@ contact_links:
   - name: Support Request
     url: https://sentry.io/support
     about: Use our dedicated support channel for paid accounts.
-  - name: Ask a question about self-hosting/on-premise
-    url: https://forum.sentry.io
-    about: Please use the community forums for questions about self-hosting.
-  - name: Report a security vulnerability
-    url: https://sentry.io/security/#vulnerability-disclosure
-    about: Please see our guide for responsible disclosure.
diff --git .github/pull_request_template.md .github/pull_request_template.md
index 3d8a87332..a4e54d4b2 100644
--- .github/pull_request_template.md
+++ .github/pull_request_template.md
@@ -1,11 +1,13 @@
-<!--
-
-Hey, thanks for your contribution!
-
-The Sentry team has finite resources and priorities that are not always visible on GitHub.
-Please help us save time when reviewing your PR by following this two-step guide:
-
-1. Is your PR a simple typo fix? __Click that green "Create pull request" button__!
-2. For more complex PRs, please read https://github.com/getsentry/sentry-go/blob/master/CONTRIBUTING.md
+### Description
+<!-- What changed and why? -->
 
+#### Issues
+<!--
+* resolves: #1234
+* resolves: LIN-1234
 -->
+
+#### Reminders
+- Add GH Issue ID _&_ Linear ID
+- PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`)
+- For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-go/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr)
diff --git .github/workflows/codeql.yml .github/workflows/codeql.yml
index 446043f48..a79201583 100644
--- .github/workflows/codeql.yml
+++ .github/workflows/codeql.yml
@@ -40,7 +40,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v4
+      uses: actions/checkout@v5
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
diff --git .github/workflows/lint.yml .github/workflows/lint.yml
index 9ce5de3fd..ab6ef61de 100644
--- .github/workflows/lint.yml
+++ .github/workflows/lint.yml
@@ -18,10 +18,10 @@ jobs:
     name: Lint
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/setup-go@v5
+      - uses: actions/setup-go@v6
         with:
           go-version: "1.24"
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
       - name: golangci-lint
         uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # [email protected]
         with:
diff --git .github/workflows/release.yml .github/workflows/release.yml
index 00907efb8..17df7fe53 100644
--- .github/workflows/release.yml
+++ .github/workflows/release.yml
@@ -15,11 +15,11 @@ jobs:
     steps:
       - name: Get auth token
         id: token
-        uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
+        uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
         with:
           app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }}
           private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }}
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
         with:
           token: ${{ steps.token.outputs.token }}
           fetch-depth: 0
diff --git .github/workflows/test.yml .github/workflows/test.yml
index 94564ae2f..b7490f788 100644
--- .github/workflows/test.yml
+++ .github/workflows/test.yml
@@ -20,11 +20,16 @@ jobs:
     env:
       GO111MODULE: "on"
       GOFLAGS: "-mod=readonly"
+      # The race detector adds considerable runtime overhead. To save time on
+      # pull requests, only run this step for a single job in the matrix. For
+      # all other workflow triggers (e.g., pushes to a release branch) run
+      # this step for the whole matrix.
+      RUN_RACE_TESTS: ${{ github.event_name != 'pull_request' || (matrix.go == '1.24' && matrix.os == 'ubuntu') }}
     steps:
-      - uses: actions/setup-go@v5
+      - uses: actions/setup-go@v6
         with:
           go-version: ${{ matrix.go }}
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v5
       - uses: actions/cache@v4
         with:
           # In order:
@@ -40,30 +45,23 @@ jobs:
           key: ${{ runner.os }}-go-${{ matrix.go }}-${{ hashFiles('**/go.sum') }}
           restore-keys: |
             ${{ runner.os }}-go-${{ matrix.go }}-
+      - name: Tidy for min version
+        run: make mod-tidy
+        if: ${{ matrix.go == '1.23' }}
       - name: Build
         run: make build
       - name: Vet
         run: make vet
-      - name: Check go.mod Tidiness
-        run: make mod-tidy
-        if: ${{ matrix.go == '1.21' }}
-      - name: Test
-        run: make test-coverage
+      - name: Test${{ env.RUN_RACE_TESTS == 'true' && ' (with race detection)' || '' }}
+        run: ${{ env.RUN_RACE_TESTS == 'true' && 'make test-race-coverage' || 'make test-coverage' }}
       - name: Upload coverage to Codecov
-        uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # [email protected]
+        uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # [email protected]
         with:
           directory: .coverage
           token: ${{ secrets.CODECOV_TOKEN }}
-      - name: Test (with race detection)
-        run: make test-race
-        # The race detector adds considerable runtime overhead. To save time on
-        # pull requests, only run this step for a single job in the matrix. For
-        # all other workflow triggers (e.g., pushes to a release branch) run
-        # this step for the whole matrix.
-        if: ${{ github.event_name != 'pull_request' || (matrix.go == '1.23' && matrix.os == 'ubuntu') }}
     timeout-minutes: 15
     strategy:
       matrix:
-        go: ["1.24", "1.23", "1.22"]
+        go: ["1.25", "1.24", "1.23"]
         os: [ubuntu, windows, macos]
       fail-fast: false
diff --git CHANGELOG.md CHANGELOG.md
index b9bb13306..ff55bf6ea 100644
--- CHANGELOG.md
+++ CHANGELOG.md
@@ -1,5 +1,136 @@
 # Changelog
 
+## 0.36.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.36.1.
+
+### Bug Fixes
+
+- Prevent panic when converting error chains containing non-comparable error types by using a safe fallback for visited detection in exception conversion ([#1113](https://github.com/getsentry/sentry-go/pull/1113))
+
+## 0.36.0
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.36.0.
+
+### Breaking Changes
+
+- Behavioral change for the `MaxBreadcrumbs` client option. Removed the hard limit of 100 breadcrumbs, allowing users to set a larger limit and also changed the default limit from 30 to 100 ([#1106](https://github.com/getsentry/sentry-go/pull/1106)))
+
+- The changes to error handling ([#1075](https://github.com/getsentry/sentry-go/pull/1075)) will affect issue grouping. It is expected that any wrapped and complex errors will be grouped under a new issue group.
+
+### Features
+
+- Add support for improved issue grouping with enhanced error chain handling ([#1075](https://github.com/getsentry/sentry-go/pull/1075))
+
+  The SDK now provides better handling of complex error scenarios, particularly when dealing with multiple related errors or error chains. This feature automatically detects and properly structures errors created with Go's `errors.Join()` function and other multi-error patterns.
+
+  ```go
+  // Multiple errors are now properly grouped and displayed in Sentry
+  err1 := errors.New("err1")
+  err2 := errors.New("err2") 
+  combinedErr := errors.Join(err1, err2)
+  
+  // When captured, these will be shown as related exceptions in Sentry
+  sentry.CaptureException(combinedErr)
+  ```
+
+- Add `TraceIgnoreStatusCodes` option to allow filtering of HTTP transactions based on status codes ([#1089](https://github.com/getsentry/sentry-go/pull/1089))
+  - Configure which HTTP status codes should not be traced by providing single codes or ranges
+  - Example: `TraceIgnoreStatusCodes: [][]int{{404}, {500, 599}}` ignores 404 and server errors 500-599
+
+### Bug Fixes
+
+- Fix logs being incorrectly filtered by `BeforeSend` callback ([#1109](https://github.com/getsentry/sentry-go/pull/1109))
+  - Logs now bypass the `processEvent` method and are sent directly to the transport
+  - This ensures logs are only filtered by `BeforeSendLog`, not by the error/message `BeforeSend` callback
+
+### Misc
+
+- Add support for Go 1.25 and drop support for Go 1.22 ([#1103](https://github.com/getsentry/sentry-go/pull/1103))
+
+## 0.35.3
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.3.
+
+### Bug Fixes
+
+- Add missing rate limit categories ([#1082](https://github.com/getsentry/sentry-go/pull/1082))
+
+## 0.35.2
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.2.
+
+### Bug Fixes
+
+- Fix OpenTelemetry spans being created as transactions instead of child spans ([#1073](https://github.com/getsentry/sentry-go/pull/1073))
+
+### Misc
+
+- Add `MockTransport` to test clients for improved testing ([#1071](https://github.com/getsentry/sentry-go/pull/1071))
+
+## 0.35.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.1.
+
+### Bug Fixes
+
+- Fix race conditions when accessing the scope during logging operations ([#1050](https://github.com/getsentry/sentry-go/pull/1050))
+- Fix nil pointer dereference with malformed URLs when tracing is enabled in `fasthttp` and `fiber` integrations ([#1055](https://github.com/getsentry/sentry-go/pull/1055))
+
+### Misc
+
+- Bump `github.com/gofiber/fiber/v2` from 2.52.5 to 2.52.9 in `/fiber` ([#1067](https://github.com/getsentry/sentry-go/pull/1067))
+
+## 0.35.0
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.35.0.
+
+### Breaking Changes
+
+- Changes to the logging API ([#1046](https://github.com/getsentry/sentry-go/pull/1046))
+
+The logging API now supports a fluent interface for structured logging with attributes:
+
+```go
+// usage before
+logger := sentry.NewLogger(ctx)
+// attributes weren't being set permanently
+logger.SetAttributes(
+    attribute.String("version", "1.0.0"),
+)
+logger.Infof(ctx, "Message with parameters %d and %d", 1, 2)
+
+// new behavior
+ctx := context.Background()
+logger := sentry.NewLogger(ctx)
+
+// Set permanent attributes on the logger
+logger.SetAttributes(
+    attribute.String("version", "1.0.0"),
+)
+
+// Chain attributes on individual log entries
+logger.Info().
+    String("key.string", "value").
+    Int("key.int", 42).
+    Bool("key.bool", true).
+    Emitf("Message with parameters %d and %d", 1, 2)
+```
+
+### Bug Fixes
+
+- Correctly serialize `FailureIssueThreshold` and `RecoveryThreshold` onto check-in payloads ([#1060](https://github.com/getsentry/sentry-go/pull/1060))
+
+## 0.34.1
+
+The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.1.
+
+### Bug Fixes
+
+- Allow flush to be used multiple times without issues, particularly for the batch logger ([#1051](https://github.com/getsentry/sentry-go/pull/1051))
+- Fix race condition in `Scope.GetSpan()` method by adding proper mutex locking ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+- Guard transport on `Close()` to prevent panic when called multiple times ([#1044](https://github.com/getsentry/sentry-go/pull/1044))
+
 ## 0.34.0
 
 The Sentry SDK team is happy to announce the immediate availability of Sentry Go SDK v0.34.0.
diff --git Makefile Makefile
index 9cc0959fd..26d993e9c 100644
--- Makefile
+++ Makefile
@@ -54,12 +54,23 @@ test-coverage: $(COVERAGE_REPORT_DIR) clean-report-dir  ## Test with coverage en
 	    $(GO) tool cover -html=$(COVERAGE_PROFILE) -o coverage.html); \
 	done;
 .PHONY: test-coverage clean-report-dir
-
+test-race-coverage: $(COVERAGE_REPORT_DIR) clean-report-dir  ## Run tests with race detection and coverage
+	set -e ; \
+	for dir in $(ALL_GO_MOD_DIRS); do \
+	  echo ">>> Running tests with race detection and coverage for module: $${dir}"; \
+	  DIR_ABS=$$(python -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' $${dir}) ; \
+	  REPORT_NAME=$$(basename $${DIR_ABS}); \
+	  (cd "$${dir}" && \
+	    $(GO) test -count=1 -timeout $(TIMEOUT)s -race -coverpkg=./... -covermode=$(COVERAGE_MODE) -coverprofile="$(COVERAGE_PROFILE)" ./... && \
+		cp $(COVERAGE_PROFILE) "$(COVERAGE_REPORT_DIR_ABS)/$${REPORT_NAME}_$(COVERAGE_PROFILE)" && \
+	    $(GO) tool cover -html=$(COVERAGE_PROFILE) -o coverage.html); \
+	done;
+.PHONY: test-race-coverage
 mod-tidy: ## Check go.mod tidiness
 	set -e ; \
 	for dir in $(ALL_GO_MOD_DIRS); do \
 		echo ">>> Running 'go mod tidy' for module: $${dir}"; \
-		(cd "$${dir}" && go mod tidy -go=1.21 -compat=1.21); \
+		(cd "$${dir}" && go mod tidy -go=1.23 -compat=1.23); \
 	done; \
 	git diff --exit-code;
 .PHONY: mod-tidy
diff --git _examples/logs/main.go _examples/logs/main.go
index c9785f2f6..b808ada4c 100644
--- _examples/logs/main.go
+++ _examples/logs/main.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"context"
+	"net/http"
 	"time"
 
 	"github.com/getsentry/sentry-go"
@@ -11,7 +12,7 @@ import (
 func main() {
 	err := sentry.Init(sentry.ClientOptions{
 		Dsn:        "",
-		EnableLogs: true,
+		EnableLogs: true, // you need to have EnableLogs set to true
 	})
 	if err != nil {
 		panic(err)
@@ -19,19 +20,42 @@ func main() {
 	defer sentry.Flush(2 * time.Second)
 
 	ctx := context.Background()
+	loggerWithAttrs := sentry.NewLogger(ctx)
+	// Attaching permanent attributes on the logger.
+	loggerWithAttrs.SetAttributes(
+		attribute.String("version", "1.0.0"),
+	)
+
+	// It's also possible to attach attributes on the [LogEntry] itself.
+	loggerWithAttrs.Info().
+		String("key.string", "value").
+		Int("key.int", 42).
+		Bool("key.bool", true).
+		// don't forget to call Emit to send the logs to Sentry
+		Emitf("Message with parameters %d and %d", 1, 2)
+
+	// The [LogEntry] can also be precompiled, if you don't want to set the same attributes multiple times
+	logEntry := loggerWithAttrs.Info().Int("int", 1)
+	// And then call Emit multiple times
+	logEntry.Emit("once")
+	logEntry.Emit("twice")
+
+	// You can also create different loggers with different precompiled attributes
 	logger := sentry.NewLogger(ctx)
+	logger.Info().
+		Emit("doesn't contain version") // this log does not contain the version attribute
+}
 
-	// You can use the logger like [fmt.Print]
-	logger.Info(ctx, "Expecting ", 2, " params")
-	// or like [fmt.Printf]
-	logger.Infof(ctx, "format: %v", "value")
-
-	// Additionally, you can also set attributes on the log like this
-	logger.SetAttributes(
-		attribute.Int("key.int", 42),
-		attribute.Bool("key.boolean", true),
-		attribute.Float64("key.float", 42.4),
-		attribute.String("key.string", "string"),
-	)
-	logger.Warnf(ctx, "I have params %v and attributes", "example param")
+type MyHandler struct {
+	logger sentry.Logger
+}
+
+// ServeHTTP example of a handler
+// To correlate logs with transactions, [context.Context] needs to be passed to the [LogEntry] with the [WithCtx] func.
+// Assuming you are using a Sentry tracing integration.
+func (h MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	// By using [WithCtx] the log entry will be associated with the transaction from the request
+	h.logger.Info().WithCtx(ctx).Emit("log inside handler")
+	w.WriteHeader(http.StatusOK)
 }
diff --git a/_examples/trace-ignore-status-codes/main.go b/_examples/trace-ignore-status-codes/main.go
new file mode 100644
index 000000000..4597b09b0
--- /dev/null
+++ _examples/trace-ignore-status-codes/main.go
@@ -0,0 +1,93 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"net/http"
+	"time"
+
+	"github.com/getsentry/sentry-go"
+	sentryhttp "github.com/getsentry/sentry-go/http"
+)
+
+func main() {
+	// Initialize Sentry with TraceIgnoreStatusCodes configuration
+	err := sentry.Init(sentry.ClientOptions{
+		Dsn:              "", // Replace with your DSN
+		Debug:            true,
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+		// Configure which HTTP status codes should not be traced
+		// Each element can be a single code {code} or a range {min, max}
+		TraceIgnoreStatusCodes: [][]int{{404}, {500, 599}}, // Ignore 404 and server errors 500-599
+	})
+	if err != nil {
+		log.Fatalf("sentry.Init: %s", err)
+	}
+
+	defer sentry.Flush(2 * time.Second)
+
+	// Create a Sentry-instrumented HTTP handler
+	sentryHandler := sentryhttp.New(sentryhttp.Options{})
+
+	http.HandleFunc("/", sentryHandler.HandleFunc(homeHandler))
+	http.HandleFunc("/users/", sentryHandler.HandleFunc(usersHandler))
+	http.HandleFunc("/forbidden", sentryHandler.HandleFunc(forbiddenHandler))
+	http.HandleFunc("/error", sentryHandler.HandleFunc(errorHandler))
+
+	fmt.Println("Server starting on :8080")
+	fmt.Println("Try these endpoints:")
+	fmt.Println("  GET /             - Returns 200 OK (will be traced)")
+	fmt.Println("  GET /users/123     - Returns 200 OK (will be traced)")
+	fmt.Println("  GET /nonexistent  - Returns 404 Not Found (will NOT be traced - matches {404})")
+	fmt.Println("  GET /forbidden    - Returns 403 Forbidden (will be traced)")
+	fmt.Println("  GET /error        - Returns 500 Internal Server Error (will NOT be traced - in range {500, 599})")
+
+	log.Fatal(http.ListenAndServe(":8080", nil))
+}
+
+func homeHandler(w http.ResponseWriter, r *http.Request) {
+	if r.URL.Path != "/" {
+		// This will return 404 and won't be traced due to our configuration (matches {404})
+		http.NotFound(w, r)
+		return
+	}
+
+	if span := sentry.SpanFromContext(r.Context()); span != nil {
+		span.SetTag("endpoint", "home")
+		span.SetData("custom_data", "This is the home page")
+	}
+
+	w.WriteHeader(http.StatusOK)
+	fmt.Fprintf(w, "Welcome to the home page! This 200 response will be traced.\n")
+}
+
+func usersHandler(w http.ResponseWriter, r *http.Request) {
+	if span := sentry.SpanFromContext(r.Context()); span != nil {
+		span.SetTag("endpoint", "users")
+		span.SetData("user_id", r.URL.Path[7:])
+	}
+
+	w.WriteHeader(http.StatusOK)
+	fmt.Fprintf(w, "User profile page. This 200 response will be traced.\n")
+}
+
+func forbiddenHandler(w http.ResponseWriter, r *http.Request) {
+	if span := sentry.SpanFromContext(r.Context()); span != nil {
+		span.SetTag("endpoint", "forbidden")
+		span.SetData("reason", "Access denied")
+	}
+
+	w.WriteHeader(http.StatusForbidden)
+	fmt.Fprintf(w, "Access forbidden. This 403 response will be traced.\n")
+}
+
+func errorHandler(w http.ResponseWriter, r *http.Request) {
+	if span := sentry.SpanFromContext(r.Context()); span != nil {
+		span.SetTag("endpoint", "error")
+		span.SetData("error_type", "simulated_server_error")
+	}
+
+	w.WriteHeader(http.StatusInternalServerError)
+	fmt.Fprintf(w, "Internal server error. This 500 response will NOT be traced (in range 500-599).\n")
+}
diff --git batch_logger.go batch_logger.go
index 8f25c008f..39487ae77 100644
--- batch_logger.go
+++ batch_logger.go
@@ -12,17 +12,20 @@ const (
 )
 
 type BatchLogger struct {
-	client    *Client
-	logCh     chan Log
-	cancel    context.CancelFunc
-	wg        sync.WaitGroup
-	startOnce sync.Once
+	client       *Client
+	logCh        chan Log
+	flushCh      chan chan struct{}
+	cancel       context.CancelFunc
+	wg           sync.WaitGroup
+	startOnce    sync.Once
+	shutdownOnce sync.Once
 }
 
 func NewBatchLogger(client *Client) *BatchLogger {
 	return &BatchLogger{
-		client: client,
-		logCh:  make(chan Log, batchSize),
+		client:  client,
+		logCh:   make(chan Log, batchSize),
+		flushCh: make(chan chan struct{}),
 	}
 }
 
@@ -35,17 +38,32 @@ func (l *BatchLogger) Start() {
 	})
 }
 
-func (l *BatchLogger) Flush() {
-	if l.cancel != nil {
-		l.cancel()
-		l.wg.Wait()
+func (l *BatchLogger) Flush(timeout <-chan struct{}) {
+	done := make(chan struct{})
+	select {
+	case l.flushCh <- done:
+		select {
+		case <-done:
+		case <-timeout:
+		}
+	case <-timeout:
 	}
 }
 
+func (l *BatchLogger) Shutdown() {
+	l.shutdownOnce.Do(func() {
+		if l.cancel != nil {
+			l.cancel()
+			l.wg.Wait()
+		}
+	})
+}
+
 func (l *BatchLogger) run(ctx context.Context) {
 	defer l.wg.Done()
 	var logs []Log
 	timer := time.NewTimer(batchTimeout)
+	defer timer.Stop()
 
 	for {
 		select {
@@ -65,8 +83,27 @@ func (l *BatchLogger) run(ctx context.Context) {
 				logs = nil
 			}
 			timer.Reset(batchTimeout)
+		case done := <-l.flushCh:
+		flushDrain:
+			for {
+				select {
+				case log := <-l.logCh:
+					logs = append(logs, log)
+				default:
+					break flushDrain
+				}
+			}
+
+			if len(logs) > 0 {
+				l.processEvent(logs)
+				logs = nil
+			}
+			if !timer.Stop() {
+				<-timer.C
+			}
+			timer.Reset(batchTimeout)
+			close(done)
 		case <-ctx.Done():
-			// Drain remaining logs from channel
 		drain:
 			for {
 				select {
@@ -88,7 +125,8 @@ func (l *BatchLogger) run(ctx context.Context) {
 func (l *BatchLogger) processEvent(logs []Log) {
 	event := NewEvent()
 	event.Timestamp = time.Now()
+	event.EventID = EventID(uuid())
 	event.Type = logEvent.Type
 	event.Logs = logs
-	l.client.CaptureEvent(event, nil, nil)
+	l.client.Transport.SendEvent(event)
 }
diff --git client.go client.go
index ea29096b6..346230223 100644
--- client.go
+++ client.go
@@ -5,7 +5,6 @@ import (
 	"crypto/x509"
 	"fmt"
 	"io"
-	"log"
 	"math/rand"
 	"net/http"
 	"os"
@@ -15,24 +14,31 @@ import (
 	"time"
 
 	"github.com/getsentry/sentry-go/internal/debug"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // The identifier of the SDK.
 const sdkIdentifier = "sentry.go"
 
-// maxErrorDepth is the maximum number of errors reported in a chain of errors.
-// This protects the SDK from an arbitrarily long chain of wrapped errors.
-//
-// An additional consideration is that arguably reporting a long chain of errors
-// is of little use when debugging production errors with Sentry. The Sentry UI
-// is not optimized for long chains either. The top-level error together with a
-// stack trace is often the most useful information.
-const maxErrorDepth = 10
+const (
+	// maxErrorDepth is the maximum number of errors reported in a chain of errors.
+	// This protects the SDK from an arbitrarily long chain of wrapped errors.
+	//
+	// An additional consideration is that arguably reporting a long chain of errors
+	// is of little use when debugging production errors with Sentry. The Sentry UI
+	// is not optimized for long chains either. The top-level error together with a
+	// stack trace is often the most useful information.
+	maxErrorDepth = 100
+
+	// defaultMaxSpans limits the default number of recorded spans per transaction. The limit is
+	// meant to bound memory usage and prevent too large transaction events that
+	// would be rejected by Sentry.
+	defaultMaxSpans = 1000
 
-// defaultMaxSpans limits the default number of recorded spans per transaction. The limit is
-// meant to bound memory usage and prevent too large transaction events that
-// would be rejected by Sentry.
-const defaultMaxSpans = 1000
+	// defaultMaxBreadcrumbs is the default maximum number of breadcrumbs added to
+	// an event. Can be overwritten with the MaxBreadcrumbs option.
+	defaultMaxBreadcrumbs = 100
+)
 
 // hostname is the host name reported by the kernel. It is precomputed once to
 // avoid syscalls when capturing events.
@@ -78,8 +84,8 @@ type usageError struct {
 }
 
 // DebugLogger is an instance of log.Logger that is used to provide debug information about running Sentry Client
-// can be enabled by either using DebugLogger.SetOutput directly or with Debug client option.
-var DebugLogger = log.New(io.Discard, "[Sentry] ", log.LstdFlags)
+// can be enabled by either using debuglog.SetOutput directly or with Debug client option.
+var DebugLogger = debuglog.GetLogger()
 
 // EventProcessor is a function that processes an event.
 // Event processors are used to change an event before it is sent to Sentry.
@@ -228,6 +234,21 @@ type ClientOptions struct {
 	Tags map[string]string
 	// EnableLogs controls when logs should be emitted.
 	EnableLogs bool
+	// TraceIgnoreStatusCodes is a list of HTTP status codes that should not be traced.
+	// Each element can be either:
+	// - A single-element slice [code] for a specific status code
+	// - A two-element slice [min, max] for a range of status codes (inclusive)
+	// When an HTTP request results in a status code that matches any of these codes or ranges,
+	// the transaction will not be sent to Sentry.
+	//
+	// Examples:
+	//   [][]int{{404}}                           // ignore only status code 404
+	//   [][]int{{400, 405}}                     // ignore status codes 400-405
+	//   [][]int{{404}, {500}}                   // ignore status codes 404 and 500
+	//   [][]int{{404}, {400, 405}, {500, 599}}  // ignore 404, range 400-405, and range 500-599
+	//
+	// By default, this is empty and all status codes are traced.
+	TraceIgnoreStatusCodes [][]int
 }
 
 // Client is the underlying processor that is used by the main API and Hub
@@ -281,7 +302,7 @@ func NewClient(options ClientOptions) (*Client, error) {
 		if debugWriter == nil {
 			debugWriter = os.Stderr
 		}
-		DebugLogger.SetOutput(debugWriter)
+		debuglog.SetOutput(debugWriter)
 	}
 
 	if options.Dsn == "" {
@@ -386,12 +407,12 @@ func (client *Client) setupIntegrations() {
 
 	for _, integration := range integrations {
 		if client.integrationAlreadyInstalled(integration.Name()) {
-			DebugLogger.Printf("Integration %s is already installed\n", integration.Name())
+			debuglog.Printf("Integration %s is already installed\n", integration.Name())
 			continue
 		}
 		client.integrations = append(client.integrations, integration)
 		integration.SetupOnce(client)
-		DebugLogger.Printf("Integration installed: %s\n", integration.Name())
+		debuglog.Printf("Integration installed: %s\n", integration.Name())
 	}
 
 	sort.Slice(client.integrations, func(i, j int) bool {
@@ -511,7 +532,9 @@ func (client *Client) RecoverWithContext(
 // call to Init.
 func (client *Client) Flush(timeout time.Duration) bool {
 	if client.batchLogger != nil {
-		client.batchLogger.Flush()
+		ctx, cancel := context.WithTimeout(context.Background(), timeout)
+		defer cancel()
+		return client.FlushWithContext(ctx)
 	}
 	return client.Transport.Flush(timeout)
 }
@@ -530,7 +553,7 @@ func (client *Client) Flush(timeout time.Duration) bool {
 
 func (client *Client) FlushWithContext(ctx context.Context) bool {
 	if client.batchLogger != nil {
-		client.batchLogger.Flush()
+		client.batchLogger.Flush(ctx.Done())
 	}
 	return client.Transport.FlushWithContext(ctx)
 }
@@ -630,7 +653,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
 	// options.TracesSampler when they are started. Other events
 	// (errors, messages) are sampled here. Does not apply to check-ins.
 	if event.Type != transactionType && event.Type != checkInType && !sample(client.options.SampleRate) {
-		DebugLogger.Println("Event dropped due to SampleRate hit.")
+		debuglog.Println("Event dropped due to SampleRate hit.")
 		return nil
 	}
 
@@ -646,7 +669,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
 	case transactionType:
 		if client.options.BeforeSendTransaction != nil {
 			if event = client.options.BeforeSendTransaction(event, hint); event == nil {
-				DebugLogger.Println("Transaction dropped due to BeforeSendTransaction callback.")
+				debuglog.Println("Transaction dropped due to BeforeSendTransaction callback.")
 				return nil
 			}
 		}
@@ -654,7 +677,7 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
 	default:
 		if client.options.BeforeSend != nil {
 			if event = client.options.BeforeSend(event, hint); event == nil {
-				DebugLogger.Println("Event dropped due to BeforeSend callback.")
+				debuglog.Println("Event dropped due to BeforeSend callback.")
 				return nil
 			}
 		}
@@ -721,7 +744,7 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod
 		id := event.EventID
 		event = processor(event, hint)
 		if event == nil {
-			DebugLogger.Printf("Event dropped by one of the Client EventProcessors: %s\n", id)
+			debuglog.Printf("Event dropped by one of the Client EventProcessors: %s\n", id)
 			return nil
 		}
 	}
@@ -730,7 +753,7 @@ func (client *Client) prepareEvent(event *Event, hint *EventHint, scope EventMod
 		id := event.EventID
 		event = processor(event, hint)
 		if event == nil {
-			DebugLogger.Printf("Event dropped by one of the Global EventProcessors: %s\n", id)
+			debuglog.Printf("Event dropped by one of the Global EventProcessors: %s\n", id)
 			return nil
 		}
 	}
diff --git client_test.go client_test.go
index 7c09586de..de9d43418 100644
--- client_test.go
+++ client_test.go
@@ -5,6 +5,8 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"net"
+	"net/http"
 	"sync"
 	"sync/atomic"
 	"testing"
@@ -162,12 +164,15 @@ func TestCaptureException(t *testing.T) {
 			err:  pkgErrors.WithStack(&customErr{}),
 			want: []Exception{
 				{
-					Type:  "*sentry.customErr",
-					Value: "wat",
+					Type:       "*sentry.customErr",
+					Value:      "wat",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             MechanismTypeChained,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						Source:           MechanismTypeUnwrap,
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -175,10 +180,11 @@ func TestCaptureException(t *testing.T) {
 					Value:      "wat",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      1,
-						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						Type:             MechanismTypeGeneric,
+						ExceptionID:      0,
+						ParentID:         nil,
+						Source:           "",
+						IsExceptionGroup: false,
 					},
 				},
 			},
@@ -199,12 +205,15 @@ func TestCaptureException(t *testing.T) {
 			err:  &customErrWithCause{cause: &customErr{}},
 			want: []Exception{
 				{
-					Type:  "*sentry.customErr",
-					Value: "wat",
+					Type:       "*sentry.customErr",
+					Value:      "wat",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             MechanismTypeChained,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						Source:           "cause",
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -212,10 +221,11 @@ func TestCaptureException(t *testing.T) {
 					Value:      "err",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      1,
-						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						Type:             MechanismTypeGeneric,
+						ExceptionID:      0,
+						ParentID:         nil,
+						Source:           "",
+						IsExceptionGroup: false,
 					},
 				},
 			},
@@ -225,12 +235,15 @@ func TestCaptureException(t *testing.T) {
 			err:  wrappedError{original: errors.New("original")},
 			want: []Exception{
 				{
-					Type:  "*errors.errorString",
-					Value: "original",
+					Type:       "*errors.errorString",
+					Value:      "original",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             MechanismTypeChained,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						Source:           MechanismTypeUnwrap,
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -238,10 +251,11 @@ func TestCaptureException(t *testing.T) {
 					Value:      "wrapped: original",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      1,
-						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						Type:             MechanismTypeGeneric,
+						ExceptionID:      0,
+						ParentID:         nil,
+						Source:           "",
+						IsExceptionGroup: false,
 					},
 				},
 			},
@@ -697,6 +711,132 @@ func TestIgnoreTransactions(t *testing.T) {
 	}
 }
 
+func TestTraceIgnoreStatusCode_EmptyCode(t *testing.T) {
+	transport := &MockTransport{}
+	ctx := NewTestContext(ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+		Transport:        transport,
+	})
+
+	transaction := StartTransaction(ctx, "test")
+	// Transaction has no http.response.status_code
+	transaction.Finish()
+
+	dropped := transport.lastEvent == nil
+	assertEqual(t, dropped, false, "expected transaction to not be dropped")
+}
+
+func TestTraceIgnoreStatusCodes(t *testing.T) {
+	tests := map[string]struct {
+		ignoreStatusCodes [][]int
+		statusCode        interface{}
+		expectDrop        bool
+	}{
+		"No ignored codes": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{},
+			expectDrop:        false,
+		},
+		"Status code not in ignore ranges": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        false,
+		},
+		"404 in ignore range": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        true,
+		},
+		"403 in ignore range": {
+			statusCode:        403,
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        true,
+		},
+		"200 not ignored": {
+			statusCode:        200,
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        false,
+		},
+		"wrong code not ignored": {
+			statusCode:        "something",
+			ignoreStatusCodes: [][]int{{400, 405}},
+			expectDrop:        false,
+		},
+		"Single status code as single-element slice": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{{404}},
+			expectDrop:        true,
+		},
+		"Single status code not in single-element slice": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{404}},
+			expectDrop:        false,
+		},
+		"Multiple single codes": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{404}, {500}},
+			expectDrop:        true,
+		},
+		"Multiple ranges - code in first range": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{{400, 405}, {500, 599}},
+			expectDrop:        true,
+		},
+		"Multiple ranges - code in second range": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{400, 405}, {500, 599}},
+			expectDrop:        true,
+		},
+		"Multiple ranges - code not in any range": {
+			statusCode:        200,
+			ignoreStatusCodes: [][]int{{400, 405}, {500, 599}},
+			expectDrop:        false,
+		},
+		"Mixed single codes and ranges": {
+			statusCode:        404,
+			ignoreStatusCodes: [][]int{{404}, {500, 599}},
+			expectDrop:        true,
+		},
+		"Mixed single codes and ranges - code in range": {
+			statusCode:        500,
+			ignoreStatusCodes: [][]int{{404}, {500, 599}},
+			expectDrop:        true,
+		},
+		"Mixed single codes and ranges - code not matched": {
+			statusCode:        200,
+			ignoreStatusCodes: [][]int{{404}, {500, 599}},
+			expectDrop:        false,
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			transport := &MockTransport{}
+			ctx := NewTestContext(ClientOptions{
+				EnableTracing:          true,
+				TracesSampleRate:       1.0,
+				Transport:              transport,
+				TraceIgnoreStatusCodes: tt.ignoreStatusCodes,
+			})
+
+			transaction := StartTransaction(ctx, "test")
+			// Simulate HTTP response data like the integrations do
+			transaction.SetData("http.response.status_code", tt.statusCode)
+			transaction.Finish()
+
+			dropped := transport.lastEvent == nil
+			if tt.expectDrop != dropped {
+				if tt.expectDrop {
+					t.Errorf("expected transaction with status code %d to be dropped", tt.statusCode)
+				} else {
+					t.Errorf("expected transaction with status code %d not to be dropped", tt.statusCode)
+				}
+			}
+		})
+	}
+}
+
 func TestSampleRate(t *testing.T) {
 	tests := []struct {
 		SampleRate float64
@@ -871,8 +1011,18 @@ func TestSDKIdentifier(t *testing.T) {
 }
 
 func TestClientSetsUpTransport(t *testing.T) {
-	client, _ := NewClient(ClientOptions{Dsn: testDsn})
-	require.IsType(t, &HTTPTransport{}, client.Transport)
+	client, _ := NewClient(ClientOptions{
+		Dsn: testDsn,
+		HTTPClient: &http.Client{
+			Transport: &http.Transport{
+				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+					return nil, fmt.Errorf("mock transport - no real connections")
+				},
+			},
+		},
+		Transport: &MockTransport{},
+	})
+	require.IsType(t, &MockTransport{}, client.Transport)
 
 	client, _ = NewClient(ClientOptions{})
 	require.IsType(t, &noopTransport{}, client.Transport)
diff --git echo/go.mod echo/go.mod
index 8cecf3df8..b320df062 100644
--- echo/go.mod
+++ echo/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/echo
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.1
 	github.com/google/go-cmp v0.5.9
 	github.com/labstack/echo/v4 v4.10.0
 )
diff --git a/exception.go b/exception.go
new file mode 100644
index 000000000..9505c9902
--- /dev/null
+++ exception.go
@@ -0,0 +1,132 @@
+package sentry
+
+import (
+	"fmt"
+	"reflect"
+	"slices"
+)
+
+const (
+	MechanismTypeGeneric string = "generic"
+	MechanismTypeChained string = "chained"
+	MechanismTypeUnwrap  string = "unwrap"
+	MechanismSourceCause string = "cause"
+)
+
+type visited struct {
+	comparable map[error]struct{}
+	msgs       map[string]struct{}
+}
+
+func (v *visited) seenError(err error) bool {
+	t := reflect.TypeOf(err)
+	if t == nil {
+		return false
+	}
+
+	if t.Comparable() {
+		if _, ok := v.comparable[err]; ok {
+			return true
+		}
+		v.comparable[err] = struct{}{}
+		return false
+	}
+
+	key := t.String() + err.Error()
+	if _, ok := v.msgs[key]; ok {
+		return true
+	}
+	v.msgs[key] = struct{}{}
+	return false
+}
+
+func convertErrorToExceptions(err error, maxErrorDepth int) []Exception {
+	var exceptions []Exception
+	vis := &visited{
+		make(map[error]struct{}),
+		make(map[string]struct{}),
+	}
+	convertErrorDFS(err, &exceptions, nil, "", vis, maxErrorDepth, 0)
+
+	// mechanism type is used for debugging purposes, but since we can't really distinguish the origin of who invoked
+	// captureException, we set it to nil if the error is not chained.
+	if len(exceptions) == 1 {
+		exceptions[0].Mechanism = nil
+	}
+
+	slices.Reverse(exceptions)
+
+	// Add a trace of the current stack to the top level(outermost) error in a chain if
+	// it doesn't have a stack trace yet.
+	// We only add to the most recent error to avoid duplication and because the
+	// current stack is most likely unrelated to errors deeper in the chain.
+	if len(exceptions) > 0 && exceptions[len(exceptions)-1].Stacktrace == nil {
+		exceptions[len(exceptions)-1].Stacktrace = NewStacktrace()
+	}
+
+	return exceptions
+}
+
+func convertErrorDFS(err error, exceptions *[]Exception, parentID *int, source string, visited *visited, maxErrorDepth int, currentDepth int) {
+	if err == nil {
+		return
+	}
+
+	if visited.seenError(err) {
+		return
+	}
+
+	_, isExceptionGroup := err.(interface{ Unwrap() []error })
+
+	exception := Exception{
+		Value:      err.Error(),
+		Type:       reflect.TypeOf(err).String(),
+		Stacktrace: ExtractStacktrace(err),
+	}
+
+	currentID := len(*exceptions)
+
+	var mechanismType string
+
+	if parentID == nil {
+		mechanismType = MechanismTypeGeneric
+		source = ""
+	} else {
+		mechanismType = MechanismTypeChained
+	}
+
+	exception.Mechanism = &Mechanism{
+		Type:             mechanismType,
+		ExceptionID:      currentID,
+		ParentID:         parentID,
+		Source:           source,
+		IsExceptionGroup: isExceptionGroup,
+	}
+
+	*exceptions = append(*exceptions, exception)
+
+	if maxErrorDepth >= 0 && currentDepth >= maxErrorDepth {
+		return
+	}
+
+	switch v := err.(type) {
+	case interface{ Unwrap() []error }:
+		unwrapped := v.Unwrap()
+		for i := range unwrapped {
+			if unwrapped[i] != nil {
+				childSource := fmt.Sprintf("errors[%d]", i)
+				convertErrorDFS(unwrapped[i], exceptions, &currentID, childSource, visited, maxErrorDepth, currentDepth+1)
+			}
+		}
+	case interface{ Unwrap() error }:
+		unwrapped := v.Unwrap()
+		if unwrapped != nil {
+			convertErrorDFS(unwrapped, exceptions, &currentID, MechanismTypeUnwrap, visited, maxErrorDepth, currentDepth+1)
+		}
+	case interface{ Cause() error }:
+		cause := v.Cause()
+		if cause != nil {
+			convertErrorDFS(cause, exceptions, &currentID, MechanismSourceCause, visited, maxErrorDepth, currentDepth+1)
+		}
+	}
+}
diff --git a/exception_test.go b/exception_test.go
new file mode 100644
index 000000000..a8a5743a3
--- /dev/null
+++ exception_test.go
@@ -0,0 +1,397 @@
+package sentry
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestConvertErrorToExceptions(t *testing.T) {
+	tests := []struct {
+		name     string
+		err      error
+		expected []Exception
+	}{
+		{
+			name:     "nil error",
+			err:      nil,
+			expected: nil,
+		},
+		{
+			name: "single error",
+			err:  errors.New("single error"),
+			expected: []Exception{
+				{
+					Value:      "single error",
+					Type:       "*errors.errorString",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+				},
+			},
+		},
+		{
+			name: "errors.Join with multiple errors",
+			err:  errors.Join(errors.New("error A"), errors.New("error B")),
+			expected: []Exception{
+				{
+					Value:      "error B",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      2,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value:      "error A\nerror B",
+					Type:       "*errors.joinError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: true,
+					},
+				},
+			},
+		},
+		{
+			name: "nested wrapped error with errors.Join",
+			err:  fmt.Errorf("wrapper: %w", errors.Join(errors.New("error A"), errors.New("error B"))),
+			expected: []Exception{
+				{
+					Value:      "error B",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      3,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      2,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A\nerror B",
+					Type:  "*errors.joinError",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: true,
+					},
+				},
+				{
+					Value:      "wrapper: error A\nerror B",
+					Type:       "*fmt.wrapError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := convertErrorToExceptions(tt.err, -1)
+
+			if tt.expected == nil {
+				if result != nil {
+					t.Errorf("expected nil result, got %+v", result)
+				}
+				return
+			}
+
+			if diff := cmp.Diff(tt.expected, result); diff != "" {
+				t.Errorf("Exception mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+// AggregateError represents multiple errors occurring together
+// This simulates JavaScript's AggregateError for testing purposes.
+type AggregateError struct {
+	Message string
+	Errors  []error
+}
+
+func (e *AggregateError) Error() string {
+	if e.Message != "" {
+		return e.Message
+	}
+	return "Multiple errors occurred"
+}
+
+func (e *AggregateError) Unwrap() []error {
+	return e.Errors
+}
+
+func TestExceptionGroupsWithAggregateError(t *testing.T) {
+	tests := []struct {
+		name     string
+		err      error
+		expected []Exception
+	}{
+		{
+			name: "AggregateError with custom message",
+			err: &AggregateError{
+				Message: "Request failed due to multiple errors",
+				Errors: []error{
+					errors.New("network timeout"),
+					errors.New("authentication failed"),
+					errors.New("rate limit exceeded"),
+				},
+			},
+			expected: []Exception{
+				{
+					Value:      "rate limit exceeded",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[2]",
+						ExceptionID:      3,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "authentication failed",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      2,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "network timeout",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value:      "Request failed due to multiple errors",
+					Type:       "*sentry.AggregateError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: true,
+					},
+				},
+			},
+		},
+		{
+			name: "Nested AggregateError with wrapper",
+			err: fmt.Errorf("operation failed: %w", &AggregateError{
+				Message: "Multiple validation errors",
+				Errors: []error{
+					errors.New("field 'email' is required"),
+					errors.New("field 'password' is too short"),
+				},
+			}),
+			expected: []Exception{
+				{
+					Value:      "field 'password' is too short",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      3,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "field 'email' is required",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      2,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "Multiple validation errors",
+					Type:  "*sentry.AggregateError",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: true,
+					},
+				},
+				{
+					Value:      "operation failed: Multiple validation errors",
+					Type:       "*fmt.wrapError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			event := &Event{}
+			event.SetException(tt.err, 10) // Use high max depth
+
+			if diff := cmp.Diff(tt.expected, event.Exception); diff != "" {
+				t.Errorf("Exception mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+type CircularError struct {
+	Message string
+	Next    error
+}
+
+func (e *CircularError) Error() string {
+	return e.Message
+}
+
+func (e *CircularError) Unwrap() error {
+	return e.Next
+}
+
+func TestCircularReferenceProtection(t *testing.T) {
+	tests := []struct {
+		name        string
+		setupError  func() error
+		description string
+		maxDepth    int
+	}{
+		{
+			name: "self-reference",
+			setupError: func() error {
+				err := &CircularError{Message: "self-referencing error"}
+				err.Next = err
+				return err
+			},
+			description: "Error that directly references itself",
+			maxDepth:    1,
+		},
+		{
+			name: "chain-loop",
+			setupError: func() error {
+				err1 := &CircularError{Message: "error A"}
+				err2 := &CircularError{Message: "error B"}
+				err1.Next = err2
+				err2.Next = err1 // Creates A -> B -> A cycle
+				return err1
+			},
+			description: "Two errors that reference each other in a cycle",
+			maxDepth:    2,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.setupError()
+
+			start := time.Now()
+			exceptions := convertErrorToExceptions(err, -1)
+			duration := time.Since(start)
+
+			if duration > 100*time.Millisecond {
+				t.Errorf("convertErrorToExceptions took too long: %v, possible infinite recursion", duration)
+			}
+
+			if len(exceptions) == 0 {
+				t.Error("Expected at least one exception, got none")
+				return
+			}
+
+			if len(exceptions) != tt.maxDepth {
+				t.Errorf("Expected exactly %d exceptions (before cycle detection), got %d", tt.maxDepth, len(exceptions))
+			}
+
+			for i, exception := range exceptions {
+				if exception.Value == "" {
+					t.Errorf("Exception %d has empty value", i)
+				}
+				if exception.Type == "" {
+					t.Errorf("Exception %d has empty type", i)
+				}
+			}
+
+			t.Logf("✓ Successfully handled %s: got %d exceptions in %v", tt.description, len(exceptions), duration)
+		})
+	}
+}
+
+// unhashableSliceError is a non-comparable error type.
+type unhashableSliceError []string
+
+func (e unhashableSliceError) Error() string {
+	return "unhashable slice error"
+}
+
+func TestConvertErrorToExceptions_UnhashableError_NoPanic(t *testing.T) {
+	defer func() {
+		if r := recover(); r != nil {
+			t.Fatalf("convertErrorToExceptions panicked for unhashable error: %v", r)
+		}
+	}()
+
+	err := unhashableSliceError{"a", "b"}
+	_ = convertErrorToExceptions(err, -1)
+}
diff --git fasthttp/go.mod fasthttp/go.mod
index 2b7ddf5b6..78668f473 100644
--- fasthttp/go.mod
+++ fasthttp/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/fasthttp
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.1
 	github.com/google/go-cmp v0.5.9
 	github.com/valyala/fasthttp v1.52.0
 )
diff --git fasthttp/go.sum fasthttp/go.sum
index 1bba7c1c6..574570aaa 100644
--- fasthttp/go.sum
+++ fasthttp/go.sum
@@ -14,8 +14,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
diff --git fasthttp/sentryfasthttp.go fasthttp/sentryfasthttp.go
index 86b444ddc..fea14ddd6 100644
--- fasthttp/sentryfasthttp.go
+++ fasthttp/sentryfasthttp.go
@@ -10,6 +10,7 @@ import (
 	"time"
 
 	"github.com/getsentry/sentry-go"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 	"github.com/valyala/fasthttp"
 )
 
@@ -143,7 +144,7 @@ func GetSpanFromContext(ctx *fasthttp.RequestCtx) *sentry.Span {
 func convert(ctx *fasthttp.RequestCtx) *http.Request {
 	defer func() {
 		if err := recover(); err != nil {
-			sentry.DebugLogger.Printf("%v", err)
+			debuglog.Printf("%v", err)
 		}
 	}()
 
@@ -152,9 +153,11 @@ func convert(ctx *fasthttp.RequestCtx) *http.Request {
 	r.Method = string(ctx.Method())
 
 	uri := ctx.URI()
-	url, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path()))
-	if err == nil {
-		r.URL = url
+	r.URL = &url.URL{Path: string(uri.Path())}
+	r.URL.RawQuery = string(uri.QueryString())
+
+	if parsedURL, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path())); err == nil {
+		r.URL = parsedURL
 		r.URL.RawQuery = string(uri.QueryString())
 	}
 
diff --git fasthttp/sentryfasthttp_test.go fasthttp/sentryfasthttp_test.go
index 5600c42d3..c59b438f9 100644
--- fasthttp/sentryfasthttp_test.go
+++ fasthttp/sentryfasthttp_test.go
@@ -571,3 +571,35 @@ func TestSetHubOnContext(t *testing.T) {
 		t.Fatalf("expected hub to be %v, but got %v", hub, retrievedHub)
 	}
 }
+
+// TestMalformedURLNoPanic verifies that malformed URLs don't cause panics
+// when tracing is enabled
+func TestMalformedURLNoPanic(t *testing.T) {
+	err := sentry.Init(sentry.ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	sentryHandler := sentryfasthttp.New(sentryfasthttp.Options{})
+
+	handler := sentryHandler.Handle(func(ctx *fasthttp.RequestCtx) {
+		ctx.SetStatusCode(fasthttp.StatusOK)
+		ctx.SetBodyString("OK")
+	})
+
+	ctx := &fasthttp.RequestCtx{}
+	ctx.Request.SetRequestURI("http://localhost/%zz")
+	ctx.Request.Header.SetMethod("GET")
+	ctx.Request.SetHost("localhost")
+	ctx.Request.Header.Set("User-Agent", "fasthttp")
+
+	handler(ctx)
+
+	// Should complete successfully without panic
+	if ctx.Response.StatusCode() != fasthttp.StatusOK {
+		t.Errorf("Expected 200, got %d", ctx.Response.StatusCode())
+	}
+}
diff --git fiber/go.mod fiber/go.mod
index f6e6f7199..ca8a771ad 100644
--- fiber/go.mod
+++ fiber/go.mod
@@ -1,12 +1,12 @@
 module github.com/getsentry/sentry-go/fiber
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
-	github.com/gofiber/fiber/v2 v2.52.5
+	github.com/getsentry/sentry-go v0.36.1
+	github.com/gofiber/fiber/v2 v2.52.9
 	github.com/google/go-cmp v0.5.9
 )
 
diff --git fiber/go.sum fiber/go.sum
index aa0d31210..523708b5d 100644
--- fiber/go.sum
+++ fiber/go.sum
@@ -4,8 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
 github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
-github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo=
-github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
+github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
+github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -28,8 +28,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasthttp v1.57.0 h1:Xw8SjWGEP/+wAAgyy5XTvgrWlOD1+TxbbvNADYCm1Tg=
diff --git fiber/sentryfiber.go fiber/sentryfiber.go
index f8ce16dca..1e44f9fd5 100644
--- fiber/sentryfiber.go
+++ fiber/sentryfiber.go
@@ -13,6 +13,7 @@ import (
 	"github.com/gofiber/fiber/v2/utils"
 
 	"github.com/getsentry/sentry-go"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 const (
@@ -143,7 +144,7 @@ func GetSpanFromContext(ctx *fiber.Ctx) *sentry.Span {
 func convert(ctx *fiber.Ctx) *http.Request {
 	defer func() {
 		if err := recover(); err != nil {
-			sentry.DebugLogger.Printf("%v", err)
+			debuglog.Printf("%v", err)
 		}
 	}()
 
@@ -152,9 +153,11 @@ func convert(ctx *fiber.Ctx) *http.Request {
 	r.Method = utils.CopyString(ctx.Method())
 
 	uri := ctx.Request().URI()
-	url, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path()))
-	if err == nil {
-		r.URL = url
+	r.URL = &url.URL{Path: string(uri.Path())}
+	r.URL.RawQuery = string(uri.QueryString())
+
+	if parsedURL, err := url.Parse(fmt.Sprintf("%s://%s%s", uri.Scheme(), uri.Host(), uri.Path())); err == nil {
+		r.URL = parsedURL
 		r.URL.RawQuery = string(uri.QueryString())
 	}
 
diff --git fiber/sentryfiber_test.go fiber/sentryfiber_test.go
index 842215e57..bf41fe6b2 100644
--- fiber/sentryfiber_test.go
+++ fiber/sentryfiber_test.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"net/url"
 	"reflect"
 	"strings"
 	"testing"
@@ -594,3 +595,40 @@ func TestSetHubOnContext(t *testing.T) {
 		t.Fatalf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode)
 	}
 }
+
+// TestMalformedURLNoPanic verifies that malformed URLs don't cause panics
+// when tracing is enabled,
+func TestMalformedURLNoPanic(t *testing.T) {
+	err := sentry.Init(sentry.ClientOptions{
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	app := fiber.New()
+	app.Use(sentryfiber.New(sentryfiber.Options{Timeout: 3 * time.Second, WaitForDelivery: true}))
+
+	app.Get("/*", func(c *fiber.Ctx) error {
+		return c.SendString("OK")
+	})
+
+	req := &http.Request{
+		Method: "GET",
+		URL:    &url.URL{Scheme: "http", Host: "localhost", Path: "/%zz"},
+		Header: make(http.Header),
+		Host:   "localhost",
+	}
+	req.Header.Set("User-Agent", "fiber")
+
+	resp, err := app.Test(req)
+	if err != nil {
+		t.Fatalf("Request failed: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("Expected 200, got %d", resp.StatusCode)
+	}
+}
diff --git gin/go.mod gin/go.mod
index 20b6df784..25810dc41 100644
--- gin/go.mod
+++ gin/go.mod
@@ -1,11 +1,11 @@,
 module github.com/getsentry/sentry-go/gin
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.1
 	github.com/gin-gonic/gin v1.9.1
 	github.com/google/go-cmp v0.5.9
 )
diff --git go.mod go.mod
index 8693fe4b8..ae180f2b8 100644
--- go.mod
+++ go.mod
@@ -1,6 +1,6 @@
 module github.com/getsentry/sentry-go
 
-go 1.21
+go 1.23
 
 require (
 	github.com/go-errors/errors v1.4.2
diff --git hub.go hub.go
index 8aea27377..30d8c1a72 100644
--- hub.go
+++ hub.go
@@ -5,6 +5,8 @@ import (
 	"fmt"
 	"sync"
 	"time"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 type contextKey int
@@ -18,14 +20,6 @@ const (
 	RequestContextKey = contextKey(2)
 )
 
-// defaultMaxBreadcrumbs is the default maximum number of breadcrumbs added to
-// an event. Can be overwritten with the maxBreadcrumbs option.
-const defaultMaxBreadcrumbs = 30
-
-// maxBreadcrumbs is the absolute maximum number of breadcrumbs added to an
-// event. The maxBreadcrumbs option cannot be set higher than this value.
-const maxBreadcrumbs = 100
-
 // currentHub is the initial Hub with no Client bound and an empty Scope.
 var currentHub = NewHub(nil, NewScope())
 
@@ -289,7 +283,7 @@ func (hub *Hub) AddBreadcrumb(breadcrumb *Breadcrumb, hint *BreadcrumbHint) {
 
 	// If there's no client, just store it on the scope straight away
 	if client == nil {
-		hub.Scope().AddBreadcrumb(breadcrumb, maxBreadcrumbs)
+		hub.Scope().AddBreadcrumb(breadcrumb, defaultMaxBreadcrumbs)
 		return
 	}
 
@@ -299,8 +293,6 @@ func (hub *Hub) AddBreadcrumb(breadcrumb *Breadcrumb, hint *BreadcrumbHint) {
 		return
 	case limit == 0:
 		limit = defaultMaxBreadcrumbs
-	case limit > maxBreadcrumbs:
-		limit = maxBreadcrumbs
 	}
 
 	if client.options.BeforeBreadcrumb != nil {
@@ -308,7 +300,7 @@ func (hub *Hub) AddBreadcrumb(breadcrumb *Breadcrumb, hint *BreadcrumbHint) {
 			hint = &BreadcrumbHint{}
 		}
 		if breadcrumb = client.options.BeforeBreadcrumb(breadcrumb, hint); breadcrumb == nil {
-			DebugLogger.Println("breadcrumb dropped due to BeforeBreadcrumb callback.")
+			debuglog.Println("breadcrumb dropped due to BeforeBreadcrumb callback.")
 			return
 		}
 	}
diff --git hub_test.go hub_test.go
index 184062179..ee98051ea 100644
--- hub_test.go
+++ hub_test.go
@@ -247,19 +247,6 @@ func TestAddBreadcrumbSkipAllBreadcrumbsIfMaxBreadcrumbsIsLessThanZero(t *testin
 	assertEqual(t, len(scope.breadcrumbs), 0)
 }
 
-func TestAddBreadcrumbShouldNeverExceedMaxBreadcrumbsConst(t *testing.T) {
-	hub, client, scope := setupHubTest()
-	client.options.MaxBreadcrumbs = 1000
-
-	breadcrumb := &Breadcrumb{Message: "Breadcrumb"}
-
-	for i := 0; i < 111; i++ {
-		hub.AddBreadcrumb(breadcrumb, nil)
-	}
-
-	assertEqual(t, len(scope.breadcrumbs), 100)
-}
-
 func TestAddBreadcrumbShouldWorkWithoutClient(t *testing.T) {
 	scope := NewScope()
 	hub := NewHub(nil, scope)
diff --git integrations.go integrations.go
index 70acf9147..60cc73d57 100644
--- integrations.go
+++ integrations.go
@@ -8,6 +8,8 @@ import (
 	"runtime/debug"
 	"strings"
 	"sync"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // ================================
@@ -32,7 +34,7 @@ func (mi *modulesIntegration) processor(event *Event, _ *EventHint) *Event {
 		mi.once.Do(func() {
 			info, ok := debug.ReadBuildInfo()
 			if !ok {
-				DebugLogger.Print("The Modules integration is not available in binaries built without module support.")
+				debuglog.Print("The Modules integration is not available in binaries built without module support.")
 				return
 			}
 			mi.modules = extractModules(info)
@@ -141,7 +143,7 @@ func (iei *ignoreErrorsIntegration) processor(event *Event, _ *EventHint) *Event
 	for _, suspect := range suspects {
 		for _, pattern := range iei.ignoreErrors {
 			if pattern.Match([]byte(suspect)) || strings.Contains(suspect, pattern.String()) {
-				DebugLogger.Printf("Event dropped due to being matched by `IgnoreErrors` option."+
+				debuglog.Printf("Event dropped due to being matched by `IgnoreErrors` option."+
 					"| Value matched: %s | Filter used: %s", suspect, pattern)
 				return nil
 			}
@@ -203,7 +205,7 @@ func (iei *ignoreTransactionsIntegration) processor(event *Event, _ *EventHint)
 
 	for _, pattern := range iei.ignoreTransactions {
 		if pattern.Match([]byte(suspect)) || strings.Contains(suspect, pattern.String()) {
-			DebugLogger.Printf("Transaction dropped due to being matched by `IgnoreTransactions` option."+
+			debuglog.Printf("Transaction dropped due to being matched by `IgnoreTransactions` option."+
 				"| Value matched: %s | Filter used: %s", suspect, pattern)
 			return nil
 		}
diff --git interfaces.go interfaces.go
index 2884bbb14..33d569977 100644
--- interfaces.go
+++ interfaces.go
@@ -3,16 +3,14 @@ package sentry
 import (
 	"context"
 	"encoding/json"
-	"errors"
 	"fmt"
 	"net"
 	"net/http"
-	"reflect"
-	"slices"
 	"strings"
 	"time"
 
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/ratelimit"
 )
 
 const eventType = "event"
@@ -101,6 +99,7 @@ func (b *Breadcrumb) MarshalJSON() ([]byte, error) {
 	return json.Marshal((*breadcrumb)(b))
 }
 
+// Logger provides a chaining API for structured logging to Sentry.
 type Logger interface {
 	// Write implements the io.Writer interface. Currently, the [sentry.Hub] is
 	// context aware, in order to get the correct trace correlation. Using this
@@ -108,51 +107,47 @@ type Logger interface {
 	// Write it is recommended to create a NewLogger so that the associated context
 	// is passed correctly.
 	Write(p []byte) (n int, err error)
-	// Trace emits a [LogLevelTrace] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Trace(ctx context.Context, v ...interface{})
-	// Debug emits a [LogLevelDebug] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Debug(ctx context.Context, v ...interface{})
-	// Info emits a [LogLevelInfo] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Info(ctx context.Context, v ...interface{})
-	// Warn emits a [LogLevelWarn] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Warn(ctx context.Context, v ...interface{})
-	// Error emits a [LogLevelError] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Print].
-	Error(ctx context.Context, v ...interface{})
-	// Fatal emits a [LogLevelFatal] log to Sentry followed by a call to [os.Exit](1).
-	// Arguments are handled in the manner of [fmt.Print].
-	Fatal(ctx context.Context, v ...interface{})
-	// Panic emits a [LogLevelFatal] log to Sentry followed by a call to panic().
-	// Arguments are handled in the manner of [fmt.Print].
-	Panic(ctx context.Context, v ...interface{})
-
-	// Tracef emits a [LogLevelTrace] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Tracef(ctx context.Context, format string, v ...interface{})
-	// Debugf emits a [LogLevelDebug] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Debugf(ctx context.Context, format string, v ...interface{})
-	// Infof emits a [LogLevelInfo] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Infof(ctx context.Context, format string, v ...interface{})
-	// Warnf emits a [LogLevelWarn] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Warnf(ctx context.Context, format string, v ...interface{})
-	// Errorf emits a [LogLevelError] log to Sentry.
-	// Arguments are handled in the manner of [fmt.Printf].
-	Errorf(ctx context.Context, format string, v ...interface{})
-	// Fatalf emits a [LogLevelFatal] log to Sentry followed by a call to [os.Exit](1).
-	// Arguments are handled in the manner of [fmt.Printf].
-	Fatalf(ctx context.Context, format string, v ...interface{})
-	// Panicf emits a [LogLevelFatal] log to Sentry followed by a call to panic().
-	// Arguments are handled in the manner of [fmt.Printf].
-	Panicf(ctx context.Context, format string, v ...interface{})
-	// SetAttributes allows attaching parameters to the log message using the attribute API.
+
+	// SetAttributes allows attaching parameters to the logger using the attribute API.
+	// These attributes will be included in all subsequent log entries.
 	SetAttributes(...attribute.Builder)
+
+	// Trace defines the [sentry.LogLevel] for the log entry.
+	Trace() LogEntry
+	// Debug defines the [sentry.LogLevel] for the log entry.
+	Debug() LogEntry
+	// Info defines the [sentry.LogLevel] for the log entry.
+	Info() LogEntry
+	// Warn defines the [sentry.LogLevel] for the log entry.
+	Warn() LogEntry
+	// Error defines the [sentry.LogLevel] for the log entry.
+	Error() LogEntry
+	// Fatal defines the [sentry.LogLevel] for the log entry.
+	Fatal() LogEntry
+	// Panic defines the [sentry.LogLevel] for the log entry.
+	Panic() LogEntry
+	// GetCtx returns the [context.Context] set on the logger.
+	GetCtx() context.Context
+}
+
+// LogEntry defines the interface for a log entry that supports chaining attributes.
+type LogEntry interface {
+	// WithCtx creates a new LogEntry with the specified context without overwriting the previous one.
+	WithCtx(ctx context.Context) LogEntry
+	// String adds a string attribute to the LogEntry.
+	String(key, value string) LogEntry
+	// Int adds an int attribute to the LogEntry.
+	Int(key string, value int) LogEntry
+	// Int64 adds an int64 attribute to the LogEntry.
+	Int64(key string, value int64) LogEntry
+	// Float64 adds a float64 attribute to the LogEntry.
+	Float64(key string, value float64) LogEntry
+	// Bool adds a bool attribute to the LogEntry.
+	Bool(key string, value bool) LogEntry
+	// Emit emits the LogEntry with the provided arguments.
+	Emit(args ...interface{})
+	// Emitf emits the LogEntry using a format string and arguments.
+	Emitf(format string, args ...interface{})
 }
 
 // Attachment allows associating files with your events to aid in investigation.
@@ -298,7 +293,7 @@ func NewRequest(r *http.Request) *Request {
 
 // Mechanism is the mechanism by which an exception was generated and handled.
 type Mechanism struct {
-	Type             string         `json:"type,omitempty"`
+	Type             string         `json:"type"`
 	Description      string         `json:"description,omitempty"`
 	HelpLink         string         `json:"help_link,omitempty"`
 	Source           string         `json:"source,omitempty"`
@@ -427,64 +422,12 @@ func (e *Event) SetException(exception error, maxErrorDepth int) {
 		return
 	}
 
-	err := exception
-
-	for i := 0; err != nil && (i < maxErrorDepth || maxErrorDepth == -1); i++ {
-		// Add the current error to the exception slice with its details
-		e.Exception = append(e.Exception, Exception{
-			Value:      err.Error(),
-			Type:       reflect.TypeOf(err).String(),
-			Stacktrace: ExtractStacktrace(err),
-		})
-
-		// Attempt to unwrap the error using the standard library's Unwrap method.
-		// If errors.Unwrap returns nil, it means either there is no error to unwrap,
-		// or the error does not implement the Unwrap method.
-		unwrappedErr := errors.Unwrap(err)
-
-		if unwrappedErr != nil {
-			// The error was successfully unwrapped using the standard library's Unwrap method.
-			err = unwrappedErr
-			continue
-		}
-
-		cause, ok := err.(interface{ Cause() error })
-		if !ok {
-			// We cannot unwrap the error further.
-			break
-		}
-
-		// The error implements the Cause method, indicating it may have been wrapped
-		// using the github.com/pkg/errors package.
-		err = cause.Cause()
-	}
-
-	// Add a trace of the current stack to the most recent error in a chain if
-	// it doesn't have a stack trace yet.
-	// We only add to the most recent error to avoid duplication and because the
-	// current stack is most likely unrelated to errors deeper in the chain.
-	if e.Exception[0].Stacktrace == nil {
-		e.Exception[0].Stacktrace = NewStacktrace()
-	}
-
-	if len(e.Exception) <= 1 {
+	exceptions := convertErrorToExceptions(exception, maxErrorDepth)
+	if len(exceptions) == 0 {
 		return
 	}
 
-	// event.Exception should be sorted such that the most recent error is last.
-	slices.Reverse(e.Exception)
-
-	for i := range e.Exception {
-		e.Exception[i].Mechanism = &Mechanism{
-			IsExceptionGroup: true,
-			ExceptionID:      i,
-			Type:             "generic",
-		}
-		if i == 0 {
-			continue
-		}
-		e.Exception[i].Mechanism.ParentID = Pointer(i - 1)
-	}
+	e.Exception = exceptions
 }
 
 // TODO: Event.Contexts map[string]interface{} => map[string]EventContext,
@@ -595,16 +538,33 @@ func (e *Event) checkInMarshalJSON() ([]byte, error) {
 
 	if e.MonitorConfig != nil {
 		checkIn.MonitorConfig = &MonitorConfig{
-			Schedule:      e.MonitorConfig.Schedule,
-			CheckInMargin: e.MonitorConfig.CheckInMargin,
-			MaxRuntime:    e.MonitorConfig.MaxRuntime,
-			Timezone:      e.MonitorConfig.Timezone,
+			Schedule:              e.MonitorConfig.Schedule,
+			CheckInMargin:         e.MonitorConfig.CheckInMargin,
+			MaxRuntime:            e.MonitorConfig.MaxRuntime,
+			Timezone:              e.MonitorConfig.Timezone,
+			FailureIssueThreshold: e.MonitorConfig.FailureIssueThreshold,
+			RecoveryThreshold:     e.MonitorConfig.RecoveryThreshold,
 		}
 	}
 
 	return json.Marshal(checkIn)
 }
 
+func (e *Event) toCategory() ratelimit.Category {
+	switch e.Type {
+	case "":
+		return ratelimit.CategoryError
+	case transactionType:
+		return ratelimit.CategoryTransaction
+	case logEvent.Type:
+		return ratelimit.CategoryLog
+	case checkInType:
+		return ratelimit.CategoryMonitor
+	default:
+		return ratelimit.CategoryUnknown
+	}
+}
+
 // NewEvent creates a new Event.
 func NewEvent() *Event {
 	return &Event{
@@ -644,7 +604,17 @@ type Log struct {
 	Attributes map[string]Attribute `json:"attributes,omitempty"`
 }
 
+type AttrType string
+
+const (
+	AttributeInvalid AttrType = ""
+	AttributeBool    AttrType = "boolean"
+	AttributeInt     AttrType = "integer"
+	AttributeFloat   AttrType = "double"
+	AttributeString  AttrType = "string"
+)
+
 type Attribute struct {
-	Value any    `json:"value"`
-	Type  string `json:"type"`
+	Value any      `json:"value"`
+	Type  AttrType `json:"type"`
 }
diff --git interfaces_test.go interfaces_test.go
index c7f3195dc..a9e31e54e 100644
--- interfaces_test.go
+++ interfaces_test.go
@@ -12,6 +12,7 @@ import (
 	"testing"
 	"time"
 
+	"github.com/getsentry/sentry-go/internal/ratelimit"
 	"github.com/google/go-cmp/cmp"
 )
 
@@ -247,6 +248,7 @@ func TestSetException(t *testing.T) {
 					Value:      "simple error",
 					Type:       "*errors.errorString",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism:  nil,
 				},
 			},
 		},
@@ -255,22 +257,26 @@ func TestSetException(t *testing.T) {
 			maxErrorDepth: 3,
 			expected: []Exception{
 				{
-					Value: "base error",
-					Type:  "*errors.errorString",
+					Value:      "base error",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
+						ExceptionID:      2,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
 					},
 				},
 				{
 					Value: "level 1: base error",
 					Type:  "*fmt.wrapError",
 					Mechanism: &Mechanism{
-						Type:             "generic",
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
 						ExceptionID:      1,
 						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -279,9 +285,10 @@ func TestSetException(t *testing.T) {
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
 						Type:             "generic",
-						ExceptionID:      2,
-						ParentID:         Pointer(1),
-						IsExceptionGroup: true,
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
 					},
 				},
 			},
@@ -296,6 +303,7 @@ func TestSetException(t *testing.T) {
 					Value:      "custom error message",
 					Type:       "*sentry.customError",
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism:  nil,
 				},
 			},
 		},
@@ -307,22 +315,26 @@ func TestSetException(t *testing.T) {
 			maxErrorDepth: 3,
 			expected: []Exception{
 				{
-					Value: "the cause",
-					Type:  "*errors.errorString",
+					Value:      "the cause",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
 					Mechanism: &Mechanism{
-						Type:             "generic",
-						ExceptionID:      0,
-						IsExceptionGroup: true,
+						Type:             "chained",
+						Source:           MechanismSourceCause,
+						ExceptionID:      2,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
 					},
 				},
 				{
 					Value: "error with cause",
 					Type:  "*sentry.withCause",
 					Mechanism: &Mechanism{
-						Type:             "generic",
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
 						ExceptionID:      1,
 						ParentID:         Pointer(0),
-						IsExceptionGroup: true,
+						IsExceptionGroup: false,
 					},
 				},
 				{
@@ -331,11 +343,116 @@ func TestSetException(t *testing.T) {
 					Stacktrace: &Stacktrace{Frames: []Frame{}},
 					Mechanism: &Mechanism{
 						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
+					},
+				},
+			},
+		},
+		"errors.Join with multiple errors": {
+			exception:     errors.Join(errors.New("error 1"), errors.New("error 2"), errors.New("error 3")),
+			maxErrorDepth: 5,
+			expected: []Exception{
+				{
+					Value:      "error 3",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[2]",
+						ExceptionID:      3,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error 2",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      2,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error 1",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value:      "error 1\nerror 2\nerror 3",
+					Type:       "*errors.joinError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: true,
+					},
+				},
+			},
+		},
+		"Nested errors.Join with fmt.Errorf": {
+			exception:     fmt.Errorf("wrapper: %w", errors.Join(errors.New("error A"), errors.New("error B"))),
+			maxErrorDepth: 5,
+			expected: []Exception{
+				{
+					Value:      "error B",
+					Type:       "*errors.errorString",
+					Stacktrace: nil,
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[1]",
+						ExceptionID:      3,
+						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A",
+					Type:  "*errors.errorString",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           "errors[0]",
 						ExceptionID:      2,
 						ParentID:         Pointer(1),
+						IsExceptionGroup: false,
+					},
+				},
+				{
+					Value: "error A\nerror B",
+					Type:  "*errors.joinError",
+					Mechanism: &Mechanism{
+						Type:             "chained",
+						Source:           MechanismTypeUnwrap,
+						ExceptionID:      1,
+						ParentID:         Pointer(0),
 						IsExceptionGroup: true,
 					},
 				},
+				{
+					Value:      "wrapper: error A\nerror B",
+					Type:       "*fmt.wrapError",
+					Stacktrace: &Stacktrace{Frames: []Frame{}},
+					Mechanism: &Mechanism{
+						Type:             "generic",
+						Source:           "",
+						ExceptionID:      0,
+						ParentID:         nil,
+						IsExceptionGroup: false,
+					},
+				},
 			},
 		},
 	}
@@ -520,3 +637,26 @@ func TestStructSnapshots(t *testing.T) {
 		})
 	}
 }
+
+func TestEvent_ToCategory(t *testing.T) {
+	cases := []struct {
+		name      string
+		eventType string
+		want      ratelimit.Category
+	}{
+		{"error", "", ratelimit.CategoryError},
+		{"transaction", transactionType, ratelimit.CategoryTransaction},
+		{"log", logEvent.Type, ratelimit.CategoryLog},
+		{"checkin", checkInType, ratelimit.CategoryMonitor},
+		{"unknown", "foobar", ratelimit.CategoryUnknown},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			e := &Event{Type: tc.eventType}
+			got := e.toCategory()
+			if got != tc.want {
+				t.Errorf("Type %q: got %v, want %v", tc.eventType, got, tc.want)
+			}
+		})
+	}
+}
diff --git a/internal/debuglog/log.go b/internal/debuglog/log.go
new file mode 100644
index 000000000..37fa4d0f3
--- /dev/null
+++ internal/debuglog/log.go
@@ -0,0 +1,35 @@
+package debuglog
+
+import (
+	"io"
+	"log"
+)
+
+// logger is the global debug logger instance.
+var logger = log.New(io.Discard, "[Sentry] ", log.LstdFlags)
+
+// SetOutput changes the output destination of the logger.
+func SetOutput(w io.Writer) {
+	logger.SetOutput(w)
+}
+
+// GetLogger returns the current logger instance.
+// This function is thread-safe and can be called concurrently.
+func GetLogger() *log.Logger {
+	return logger
+}
+
+// Printf calls Printf on the underlying logger.
+func Printf(format string, args ...interface{}) {
+	logger.Printf(format, args...)
+}
+
+// Println calls Println on the underlying logger.
+func Println(args ...interface{}) {
+	logger.Println(args...)
+}
+
+// Print calls Print on the underlying logger.
+func Print(args ...interface{}) {
+	logger.Print(args...)
+}
diff --git a/internal/debuglog/log_test.go b/internal/debuglog/log_test.go
new file mode 100644
index 000000000..c1ccb551f
--- /dev/null
+++ internal/debuglog/log_test.go
@@ -0,0 +1,120 @@
+package debuglog
+
+import (
+	"bytes"
+	"io"
+	"strings"
+	"sync"
+	"testing"
+)
+
+func TestGetLogger(t *testing.T) {
+	logger := GetLogger()
+	if logger == nil {
+		t.Error("GetLogger returned nil")
+	}
+}
+
+func TestSetOutput(t *testing.T) {
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Printf("test %s %d", "message", 42)
+
+	output := buf.String()
+	if !strings.Contains(output, "test message 42") {
+		t.Errorf("Printf output incorrect: got %q", output)
+	}
+}
+
+func TestPrintf(t *testing.T) {
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Printf("test %s %d", "message", 42)
+
+	output := buf.String()
+	if !strings.Contains(output, "test message 42") {
+		t.Errorf("Printf output incorrect: got %q", output)
+	}
+}
+
+func TestPrintln(t *testing.T) {
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Println("test", "message")
+
+	output := buf.String()
+	if !strings.Contains(output, "test message") {
+		t.Errorf("Println output incorrect: got %q", output)
+	}
+}
+
+func TestPrint(t *testing.T) {
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Print("test", "message")
+
+	output := buf.String()
+	if !strings.Contains(output, "testmessage") {
+		t.Errorf("Print output incorrect: got %q", output)
+	}
+}
+
+func TestConcurrentAccess(_ *testing.T) {
+	var wg sync.WaitGroup
+	iterations := 1000
+
+	for i := 0; i < iterations; i++ {
+		wg.Add(1)
+		go func(n int) {
+			defer wg.Done()
+			Printf("concurrent message %d", n)
+		}(i)
+	}
+
+	for i := 0; i < iterations; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			_ = GetLogger()
+		}()
+	}
+
+	for i := 0; i < 10; i++ {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			SetOutput(io.Discard)
+		}()
+	}
+
+	wg.Wait()
+}
+
+func TestInitialization(t *testing.T) {
+	// The logger should be initialized on package load
+	logger := GetLogger()
+	if logger == nil {
+		t.Error("Logger was not initialized")
+	}
+
+	var buf bytes.Buffer
+	SetOutput(&buf)
+	defer SetOutput(io.Discard)
+
+	Printf("test")
+	Println("test")
+	Print("test")
+
+	output := buf.String()
+	if !strings.Contains(output, "test") {
+		t.Errorf("Expected output to contain 'test', got %q", output)
+	}
+}
diff --git internal/ratelimit/category.go internal/ratelimit/category.go
index 2db76d2bf..96d9e21b9 100644
--- internal/ratelimit/category.go
+++ internal/ratelimit/category.go
@@ -14,12 +14,14 @@ import (
 // and, therefore, rate limited.
 type Category string
 
-// Known rate limit categories. As a special case, the CategoryAll applies to
-// all known payload types.
+// Known rate limit categories that are specified in rate limit headers.
 const (
-	CategoryAll         Category = ""
+	CategoryUnknown     Category = "unknown" // Unknown category should not get rate limited
+	CategoryAll         Category = ""        // Special category for empty categories (applies to all)
 	CategoryError       Category = "error"
 	CategoryTransaction Category = "transaction"
+	CategoryLog         Category = "log_item"
+	CategoryMonitor     Category = "monitor"
 )
 
 // knownCategories is the set of currently known categories. Other categories
@@ -28,18 +30,30 @@ var knownCategories = map[Category]struct{}{
 	CategoryAll:         {},
 	CategoryError:       {},
 	CategoryTransaction: {},
+	CategoryLog:         {},
+	CategoryMonitor:     {},
 }
 
 // String returns the category formatted for debugging.
 func (c Category) String() string {
-	if c == "" {
+	switch c {
+	case CategoryAll:
 		return "CategoryAll"
+	case CategoryError:
+		return "CategoryError"
+	case CategoryTransaction:
+		return "CategoryTransaction"
+	case CategoryLog:
+		return "CategoryLog"
+	case CategoryMonitor:
+		return "CategoryMonitor"
+	default:
+		// For unknown categories, use the original formatting logic
+		caser := cases.Title(language.English)
+		rv := "Category"
+		for _, w := range strings.Fields(string(c)) {
+			rv += caser.String(w)
+		}
+		return rv
 	}
-
-	caser := cases.Title(language.English)
-	rv := "Category"
-	for _, w := range strings.Fields(string(c)) {
-		rv += caser.String(w)
-	}
-	return rv
 }
diff --git internal/ratelimit/category_test.go internal/ratelimit/category_test.go
index 48af16d20..e0ec06b29 100644
--- internal/ratelimit/category_test.go
+++ internal/ratelimit/category_test.go
@@ -1,23 +1,61 @@
 package ratelimit
 
-import "testing"
+import (
+	"testing"
+)
 
-func TestCategoryString(t *testing.T) {
+func TestCategory_String(t *testing.T) {
 	tests := []struct {
-		Category Category
-		want     string
+		category Category
+		expected string
 	}{
 		{CategoryAll, "CategoryAll"},
 		{CategoryError, "CategoryError"},
 		{CategoryTransaction, "CategoryTransaction"},
-		{Category("unknown"), "CategoryUnknown"},
-		{Category("two words"), "CategoryTwoWords"},
+		{CategoryMonitor, "CategoryMonitor"},
+		{CategoryLog, "CategoryLog"},
+		{Category("custom type"), "CategoryCustomType"},
+		{Category("multi word type"), "CategoryMultiWordType"},
 	}
+
 	for _, tt := range tests {
-		t.Run(tt.want, func(t *testing.T) {
-			got := tt.Category.String()
-			if got != tt.want {
-				t.Errorf("got %q, want %q", got, tt.want)
+		t.Run(string(tt.category), func(t *testing.T) {
+			result := tt.category.String()
+			if result != tt.expected {
+				t.Errorf("Category(%q).String() = %q, want %q", tt.category, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestKnownCategories(t *testing.T) {
+	expectedCategories := []Category{
+		CategoryAll,
+		CategoryError,
+		CategoryTransaction,
+		CategoryMonitor,
+		CategoryLog,
+	}
+
+	for _, category := range expectedCategories {
+		t.Run(string(category), func(t *testing.T) {
+			if _, exists := knownCategories[category]; !exists {
+				t.Errorf("Category %q should be in knownCategories map", category)
+			}
+		})
+	}
+
+	// Test that unknown categories are not in the map
+	unknownCategories := []Category{
+		Category("unknown"),
+		Category("custom"),
+		Category("random"),
+	}
+
+	for _, category := range unknownCategories {
+		t.Run("unknown_"+string(category), func(t *testing.T) {
+			if _, exists := knownCategories[category]; exists {
+				t.Errorf("Unknown category %q should not be in knownCategories map", category)
 			}
 		})
 	}
diff --git internal/ratelimit/rate_limits_test.go internal/ratelimit/rate_limits_test.go
index 78cd64d2e..e81e89f0f 100644
--- internal/ratelimit/rate_limits_test.go
+++ internal/ratelimit/rate_limits_test.go
@@ -59,7 +59,9 @@ func TestParseXSentryRateLimits(t *testing.T) {
 		{
 			// ignore unknown categories
 			"8:error;default;unknown",
-			Map{CategoryError: Deadline(now.Add(8 * time.Second))},
+			Map{
+				CategoryError: Deadline(now.Add(8 * time.Second)),
+			},
 		},
 		{
 			"30:error:scope1, 20:error:scope2, 40:error",
diff --git iris/go.mod iris/go.mod
index 10bb024cc..f9d64470d 100644
--- iris/go.mod
+++ iris/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/iris
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.1
 	github.com/google/go-cmp v0.5.9
 	github.com/kataras/iris/v12 v12.2.0
 )
diff --git log.go log.go
index 08be1b4e0..c26933612 100644
--- log.go
+++ log.go
@@ -3,11 +3,14 @@ package sentry
 import (
 	"context"
 	"fmt"
+	"maps"
 	"os"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/getsentry/sentry-go/attribute"
+	debuglog "github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 type LogLevel string
@@ -30,17 +33,28 @@ const (
 	LogSeverityFatal   int = 21
 )
 
-var mapTypesToStr = map[attribute.Type]string{
-	attribute.INVALID: "",
-	attribute.BOOL:    "boolean",
-	attribute.INT64:   "integer",
-	attribute.FLOAT64: "double",
-	attribute.STRING:  "string",
+var mapTypesToStr = map[attribute.Type]AttrType{
+	attribute.INVALID: AttributeInvalid,
+	attribute.BOOL:    AttributeBool,
+	attribute.INT64:   AttributeInt,
+	attribute.FLOAT64: AttributeFloat,
+	attribute.STRING:  AttributeString,
 }
 
 type sentryLogger struct {
+	ctx        context.Context
 	client     *Client
 	attributes map[string]Attribute
+	mu         sync.RWMutex
+}
+
+type logEntry struct {
+	logger      *sentryLogger
+	ctx         context.Context
+	level       LogLevel
+	severity    int
+	attributes  map[string]Attribute
+	shouldPanic bool
 }
 
 // NewLogger returns a Logger that emits logs to Sentry. If logging is turned off, all logs get discarded.
@@ -53,21 +67,26 @@ func NewLogger(ctx context.Context) Logger {
 
 	client := hub.Client()
 	if client != nil && client.batchLogger != nil {
-		return &sentryLogger{client, make(map[string]Attribute)}
+		return &sentryLogger{
+			ctx:        ctx,
+			client:     client,
+			attributes: make(map[string]Attribute),
+			mu:         sync.RWMutex{},
+		}
 	}
 
-	DebugLogger.Println("fallback to noopLogger: enableLogs disabled")
+	debuglog.Println("fallback to noopLogger: enableLogs disabled")
 	return &noopLogger{} // fallback: does nothing
 }
 
 func (l *sentryLogger) Write(p []byte) (int, error) {
 	// Avoid sending double newlines to Sentry
 	msg := strings.TrimRight(string(p), "\n")
-	l.log(context.Background(), LogLevelInfo, LogSeverityInfo, msg)
+	l.Info().Emit(msg)
 	return len(p), nil
 }
 
-func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, message string, args ...interface{}) {
+func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, message string, entryAttrs map[string]Attribute, args ...interface{}) {
 	if message == "" {
 		return
 	}
@@ -78,71 +97,77 @@ func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, me
 
 	var traceID TraceID
 	var spanID SpanID
+	var span *Span
+	var user User
 
-	span := hub.Scope().span
-	if span != nil {
-		traceID = span.TraceID
-		spanID = span.SpanID
-	} else {
-		traceID = hub.Scope().propagationContext.TraceID
+	scope := hub.Scope()
+	if scope != nil {
+		scope.mu.Lock()
+		span = scope.span
+		if span != nil {
+			traceID = span.TraceID
+			spanID = span.SpanID
+		} else {
+			traceID = scope.propagationContext.TraceID
+		}
+		user = scope.user
+		scope.mu.Unlock()
 	}
 
 	attrs := map[string]Attribute{}
 	if len(args) > 0 {
 		attrs["sentry.message.template"] = Attribute{
-			Value: message, Type: "string",
+			Value: message, Type: AttributeString,
 		}
 		for i, p := range args {
 			attrs[fmt.Sprintf("sentry.message.parameters.%d", i)] = Attribute{
-				Value: fmt.Sprint(p), Type: "string",
+				Value: fmt.Sprintf("%+v", p), Type: AttributeString,
 			}
 		}
 	}
 
-	// If `log` was called with SetAttributes, pass the attributes to attrs
-	if len(l.attributes) > 0 {
-		for k, v := range l.attributes {
-			attrs[k] = v
-		}
-		// flush attributes from logger after send
-		clear(l.attributes)
+	l.mu.RLock()
+	for k, v := range l.attributes {
+		attrs[k] = v
+	}
+	l.mu.RUnlock()
+
+	for k, v := range entryAttrs {
+		attrs[k] = v
 	}
 
 	// Set default attributes
 	if release := l.client.options.Release; release != "" {
-		attrs["sentry.release"] = Attribute{Value: release, Type: "string"}
+		attrs["sentry.release"] = Attribute{Value: release, Type: AttributeString}
 	}
 	if environment := l.client.options.Environment; environment != "" {
-		attrs["sentry.environment"] = Attribute{Value: environment, Type: "string"}
+		attrs["sentry.environment"] = Attribute{Value: environment, Type: AttributeString}
 	}
 	if serverName := l.client.options.ServerName; serverName != "" {
-		attrs["sentry.server.address"] = Attribute{Value: serverName, Type: "string"}
+		attrs["sentry.server.address"] = Attribute{Value: serverName, Type: AttributeString}
 	} else if serverAddr, err := os.Hostname(); err == nil {
-		attrs["sentry.server.address"] = Attribute{Value: serverAddr, Type: "string"}
+		attrs["sentry.server.address"] = Attribute{Value: serverAddr, Type: AttributeString}
 	}
-	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 !user.IsEmpty() {
+		if user.ID != "" {
+			attrs["user.id"] = Attribute{Value: user.ID, Type: AttributeString}
+		}
+		if user.Name != "" {
+			attrs["user.name"] = Attribute{Value: user.Name, Type: AttributeString}
+		}
+		if user.Email != "" {
+			attrs["user.email"] = Attribute{Value: user.Email, Type: AttributeString}
 		}
 	}
-	if spanID.String() != "0000000000000000" {
-		attrs["sentry.trace.parent_span_id"] = Attribute{Value: spanID.String(), Type: "string"}
+	if span != nil {
+		attrs["sentry.trace.parent_span_id"] = Attribute{Value: spanID.String(), Type: AttributeString}
 	}
 	if sdkIdentifier := l.client.sdkIdentifier; sdkIdentifier != "" {
-		attrs["sentry.sdk.name"] = Attribute{Value: sdkIdentifier, Type: "string"}
+		attrs["sentry.sdk.name"] = Attribute{Value: sdkIdentifier, Type: AttributeString}
 	}
 	if sdkVersion := l.client.sdkVersion; sdkVersion != "" {
-		attrs["sentry.sdk.version"] = Attribute{Value: sdkVersion, Type: "string"}
+		attrs["sentry.sdk.version"] = Attribute{Value: sdkVersion, Type: AttributeString}
 	}
 
 	log := &Log{
@@ -163,15 +188,18 @@ func (l *sentryLogger) log(ctx context.Context, level LogLevel, severity int, me
 	}
 
 	if l.client.options.Debug {
-		DebugLogger.Printf(message, args...)
+		debuglog.Printf(message, args...)
 	}
 }
 
 func (l *sentryLogger) SetAttributes(attrs ...attribute.Builder) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+
 	for _, v := range attrs {
 		t, ok := mapTypesToStr[v.Value.Type()]
 		if !ok || t == "" {
-			DebugLogger.Printf("invalid attribute type set: %v", t)
+			debuglog.Printf("invalid attribute type set: %v", t)
 			continue
 		}
 
@@ -182,49 +210,136 @@ func (l *sentryLogger) SetAttributes(attrs ...attribute.Builder) {
 	}
 }
 
-func (l *sentryLogger) Trace(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelTrace, LogSeverityTrace, fmt.Sprint(v...))
+func (l *sentryLogger) Trace() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelTrace,
+		severity:   LogSeverityTrace,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Debug(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelDebug, LogSeverityDebug, fmt.Sprint(v...))
+
+func (l *sentryLogger) Debug() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelDebug,
+		severity:   LogSeverityDebug,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Info(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelInfo, LogSeverityInfo, fmt.Sprint(v...))
+
+func (l *sentryLogger) Info() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelInfo,
+		severity:   LogSeverityInfo,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Warn(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelWarn, LogSeverityWarning, fmt.Sprint(v...))
+
+func (l *sentryLogger) Warn() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelWarn,
+		severity:   LogSeverityWarning,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Error(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelError, LogSeverityError, fmt.Sprint(v...))
+
+func (l *sentryLogger) Error() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelError,
+		severity:   LogSeverityError,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Fatal(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, fmt.Sprint(v...))
-	os.Exit(1)
+
+func (l *sentryLogger) Fatal() LogEntry {
+	return &logEntry{
+		logger:     l,
+		ctx:        l.ctx,
+		level:      LogLevelFatal,
+		severity:   LogSeverityFatal,
+		attributes: make(map[string]Attribute),
+	}
 }
-func (l *sentryLogger) Panic(ctx context.Context, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, fmt.Sprint(v...))
-	panic(fmt.Sprint(v...))
+
+func (l *sentryLogger) Panic() LogEntry {
+	return &logEntry{
+		logger:      l,
+		ctx:         l.ctx,
+		level:       LogLevelFatal,
+		severity:    LogSeverityFatal,
+		attributes:  make(map[string]Attribute),
+		shouldPanic: true, // this should panic instead of exit
+	}
 }
-func (l *sentryLogger) Tracef(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelTrace, LogSeverityTrace, format, v...)
+
+func (l *sentryLogger) GetCtx() context.Context {
+	return l.ctx
 }
-func (l *sentryLogger) Debugf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelDebug, LogSeverityDebug, format, v...)
+
+func (e *logEntry) WithCtx(ctx context.Context) LogEntry {
+	return &logEntry{
+		logger:      e.logger,
+		ctx:         ctx,
+		level:       e.level,
+		severity:    e.severity,
+		attributes:  maps.Clone(e.attributes),
+		shouldPanic: e.shouldPanic,
+	}
 }
-func (l *sentryLogger) Infof(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelInfo, LogSeverityInfo, format, v...)
+
+func (e *logEntry) String(key, value string) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeString}
+	return e
 }
-func (l *sentryLogger) Warnf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelWarn, LogSeverityWarning, format, v...)
+
+func (e *logEntry) Int(key string, value int) LogEntry {
+	e.attributes[key] = Attribute{Value: int64(value), Type: AttributeInt}
+	return e
 }
-func (l *sentryLogger) Errorf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelError, LogSeverityError, format, v...)
+
+func (e *logEntry) Int64(key string, value int64) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeInt}
+	return e
 }
-func (l *sentryLogger) Fatalf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, format, v...)
-	os.Exit(1)
+
+func (e *logEntry) Float64(key string, value float64) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeFloat}
+	return e
+}
+
+func (e *logEntry) Bool(key string, value bool) LogEntry {
+	e.attributes[key] = Attribute{Value: value, Type: AttributeBool}
+	return e
+}
+
+func (e *logEntry) Emit(args ...interface{}) {
+	e.logger.log(e.ctx, e.level, e.severity, fmt.Sprint(args...), e.attributes)
+
+	if e.level == LogLevelFatal {
+		if e.shouldPanic {
+			panic(fmt.Sprint(args...))
+		}
+		os.Exit(1)
+	}
 }
-func (l *sentryLogger) Panicf(ctx context.Context, format string, v ...interface{}) {
-	l.log(ctx, LogLevelFatal, LogSeverityFatal, format, v...)
-	panic(fmt.Sprint(v...))
+
+func (e *logEntry) Emitf(format string, args ...interface{}) {
+	e.logger.log(e.ctx, e.level, e.severity, format, e.attributes, args...)
+
+	if e.level == LogLevelFatal {
+		if e.shouldPanic {
+			formattedMessage := fmt.Sprintf(format, args...)
+			panic(formattedMessage)
+		}
+		os.Exit(1)
+	}
 }
diff --git log_fallback.go log_fallback.go
index b9eb7061f..6e9331955 100644
--- log_fallback.go
+++ log_fallback.go
@@ -6,60 +6,100 @@ import (
 	"os"
 
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // Fallback, no-op logger if logging is disabled.
 type noopLogger struct{}
 
-func (*noopLogger) Trace(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelTrace)
+// noopLogEntry implements LogEntry for the no-op logger.
+type noopLogEntry struct {
+	level       LogLevel
+	shouldPanic bool
 }
-func (*noopLogger) Debug(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelDebug)
+
+func (n *noopLogEntry) WithCtx(_ context.Context) LogEntry {
+	return n
+}
+
+func (n *noopLogEntry) String(_, _ string) LogEntry {
+	return n
 }
-func (*noopLogger) Info(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+
+func (n *noopLogEntry) Int(_ string, _ int) LogEntry {
+	return n
 }
-func (*noopLogger) Warn(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelWarn)
+
+func (n *noopLogEntry) Int64(_ string, _ int64) LogEntry {
+	return n
 }
-func (*noopLogger) Error(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelError)
+
+func (n *noopLogEntry) Float64(_ string, _ float64) LogEntry {
+	return n
 }
-func (*noopLogger) Fatal(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	os.Exit(1)
+
+func (n *noopLogEntry) Bool(_ string, _ bool) LogEntry {
+	return n
+}
+
+func (n *noopLogEntry) Attributes(_ ...attribute.Builder) LogEntry {
+	return n
+}
+
+func (n *noopLogEntry) Emit(args ...interface{}) {
+	debuglog.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", n.level)
+	if n.level == LogLevelFatal {
+		if n.shouldPanic {
+			panic(args)
+		}
+		os.Exit(1)
+	}
 }
-func (*noopLogger) Panic(_ context.Context, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	panic(fmt.Sprintf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal))
+
+func (n *noopLogEntry) Emitf(message string, args ...interface{}) {
+	debuglog.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", n.level)
+	if n.level == LogLevelFatal {
+		if n.shouldPanic {
+			panic(fmt.Sprintf(message, args...))
+		}
+		os.Exit(1)
+	}
 }
-func (*noopLogger) Tracef(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelTrace)
+
+func (n *noopLogger) GetCtx() context.Context { return context.Background() }
+
+func (*noopLogger) Trace() LogEntry {
+	return &noopLogEntry{level: LogLevelTrace}
 }
-func (*noopLogger) Debugf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelDebug)
+
+func (*noopLogger) Debug() LogEntry {
+	return &noopLogEntry{level: LogLevelDebug}
 }
-func (*noopLogger) Infof(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+
+func (*noopLogger) Info() LogEntry {
+	return &noopLogEntry{level: LogLevelInfo}
 }
-func (*noopLogger) Warnf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelWarn)
+
+func (*noopLogger) Warn() LogEntry {
+	return &noopLogEntry{level: LogLevelWarn}
 }
-func (*noopLogger) Errorf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelError)
+
+func (*noopLogger) Error() LogEntry {
+	return &noopLogEntry{level: LogLevelError}
 }
-func (*noopLogger) Fatalf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	os.Exit(1)
+
+func (*noopLogger) Fatal() LogEntry {
+	return &noopLogEntry{level: LogLevelFatal}
 }
-func (*noopLogger) Panicf(_ context.Context, _ string, _ ...interface{}) {
-	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal)
-	panic(fmt.Sprintf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelFatal))
+
+func (*noopLogger) Panic() LogEntry {
+	return &noopLogEntry{level: LogLevelFatal, shouldPanic: true}
 }
+
 func (*noopLogger) SetAttributes(...attribute.Builder) {
-	DebugLogger.Printf("No attributes attached. Turn on logging via EnableLogs")
+	debuglog.Printf("No attributes attached. Turn on logging via EnableLogs")
 }
+
 func (*noopLogger) Write(_ []byte) (n int, err error) {
-	return 0, fmt.Errorf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+	return 0, fmt.Errorf("log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
 }
diff --git a/log_race_test.go b/log_race_test.go
new file mode 100644
index 000000000..3f41c2e7e
--- /dev/null
+++ log_race_test.go
@@ -0,0 +1,381 @@
+package sentry
+
+import (
+	"context"
+	"fmt"
+	"runtime"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/testutils"
+)
+
+const (
+	loggingGoroutines = 50
+	loggingIterations = 100
+)
+
+type CtxKey int
+
+func TestLoggingRaceConditions(t *testing.T) {
+	testCases := []struct {
+		name    string
+		timeout time.Duration
+		testFn  func(*testing.T)
+	}{
+		{
+			name:    "ConcurrentLoggerSetAttributes",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLoggerSetAttributes,
+		},
+		{
+			name:    "ConcurrentLogEmission",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLogEmission,
+		},
+		{
+			name:    "ConcurrentLogEntryOperations",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLogEntryOperations,
+		},
+		{
+			name:    "ConcurrentLoggerCreationAndUsage",
+			timeout: testutils.FlushTimeout(),
+			testFn:  testConcurrentLoggerCreationAndUsage,
+		},
+		{
+			name:    "ConcurrentLogWithSpanOperations",
+			timeout: 5 * time.Second,
+			testFn:  testConcurrentLogWithSpanOperations,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			timeout := time.After(tc.timeout)
+			done := make(chan bool)
+
+			go func() {
+				defer func() {
+					if r := recover(); r != nil {
+						t.Errorf("Test %s panicked: %v", tc.name, r)
+					}
+					done <- true
+				}()
+				tc.testFn(t)
+			}()
+
+			select {
+			case <-timeout:
+				t.Fatalf("Test %s didn't finish in time (timeout: %v) - likely deadlock", tc.name, tc.timeout)
+			case <-done:
+				t.Logf("Test %s completed successfully", tc.name)
+			}
+		})
+	}
+}
+
+func testConcurrentLoggerSetAttributes(t *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	logger := NewLogger(ctx)
+	if _, ok := logger.(*noopLogger); ok {
+		t.Skip("Logging is disabled, skipping test")
+	}
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines/2; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations; j++ {
+				attrs := []attribute.Builder{
+					attribute.String(fmt.Sprintf("attr-string-%d", id), fmt.Sprintf("value-%d-%d", id, j)),
+					attribute.Int64(fmt.Sprintf("attr-int-%d", id), int64(id*j)),
+					attribute.Float64(fmt.Sprintf("attr-float-%d", id), float64(id)+float64(j)*0.1),
+					attribute.Bool(fmt.Sprintf("attr-bool-%d", id), j%2 == 0),
+				}
+				logger.SetAttributes(attrs...)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	for i := 0; i < loggingGoroutines/2; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations; j++ {
+				logger.Info().
+					String("worker_id", fmt.Sprintf("%d", id)).
+					Int("iteration", j).
+					Emit("Concurrent log message from worker", id)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLogEmission(_ *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			logger := NewLogger(ctx)
+			if _, ok := logger.(*noopLogger); ok {
+				return
+			}
+
+			for j := 0; j < loggingIterations/5; j++ {
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Trace().
+						String("operation", "trace").
+						Int("worker", id).
+						Emit("Trace message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Debug().
+						String("operation", "debug").
+						Int("worker", id).
+						Emit("Debug message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Info().
+						String("operation", "info").
+						Int("worker", id).
+						Emit("Info message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Warn().
+						String("operation", "warn").
+						Int("worker", id).
+						Emit("Warning message from worker %d", id)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Error().
+						String("operation", "error").
+						Int("worker", id).
+						Emit("Error message from worker %d", id)
+				}()
+
+				localWg.Wait()
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLogEntryOperations(t *testing.T) {
+	t.Skip("A single instance of a log entry should not be used concurrently")
+
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	logger := NewLogger(ctx)
+	if _, ok := logger.(*noopLogger); ok {
+		t.Skip("Logging is disabled, skipping test")
+	}
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations/10; j++ {
+				entry := logger.Info()
+
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					entry.String("worker_id", fmt.Sprintf("worker-%d", id))
+					entry.Int("iteration", j)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					entry.Float64("progress", float64(j)/float64(loggingIterations/10))
+					entry.Bool("is_test", true)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					newCtx := context.WithValue(ctx, CtxKey(2), fmt.Sprintf("test_value_%d", id))
+					_ = entry.WithCtx(newCtx)
+				}()
+
+				localWg.Wait()
+				entry.Emit("Concurrent entry operations test %d-%d", id, j)
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLoggerCreationAndUsage(_ *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:        testDsn,
+		EnableLogs: true,
+		Transport:  &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations/20; j++ {
+				ctx := context.WithValue(context.Background(), CtxKey(1), id)
+				ctx = SetHubOnContext(ctx, hub)
+
+				logger := NewLogger(ctx)
+				if _, ok := logger.(*noopLogger); ok {
+					continue
+				}
+
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.SetAttributes(
+						attribute.String("creation_worker", fmt.Sprintf("%d", id)),
+						attribute.Int64("creation_iteration", int64(j)),
+					)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Info().
+						String("immediate_usage", "true").
+						Emit("Logger created and used immediately by worker %d", id)
+				}()
+
+				localWg.Wait()
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
+
+func testConcurrentLogWithSpanOperations(_ *testing.T) {
+	client, _ := NewClient(ClientOptions{
+		Dsn:              testDsn,
+		EnableLogs:       true,
+		EnableTracing:    true,
+		TracesSampleRate: 1.0,
+		Transport:        &MockTransport{},
+	})
+	hub := NewHub(client, NewScope())
+	ctx := SetHubOnContext(context.Background(), hub)
+
+	var wg sync.WaitGroup
+
+	for i := 0; i < loggingGoroutines; i++ {
+		wg.Add(1)
+		go func(id int) {
+			defer wg.Done()
+			for j := 0; j < loggingIterations/20; j++ {
+				transaction := StartTransaction(ctx, fmt.Sprintf("log-transaction-%d", id))
+				span := transaction.StartChild(fmt.Sprintf("log-span-%d", id))
+
+				spanCtx := span.Context()
+				logger := NewLogger(spanCtx)
+				if _, ok := logger.(*noopLogger); ok {
+					span.Finish()
+					transaction.Finish()
+					continue
+				}
+
+				var localWg sync.WaitGroup
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					span.SetTag("worker_id", fmt.Sprintf("%d", id))
+					span.SetData("iteration", j)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.SetAttributes(
+						attribute.String("span_operation", span.Op),
+						attribute.String("trace_id", span.TraceID.String()),
+					)
+				}()
+
+				localWg.Add(1)
+				go func() {
+					defer localWg.Done()
+					logger.Info().
+						String("span_context", "active").
+						String("span_id", span.SpanID.String()).
+						Emit("Log within span from worker %d", id)
+				}()
+
+				localWg.Wait()
+				span.Finish()
+				transaction.Finish()
+				runtime.Gosched()
+			}
+		}(i)
+	}
+
+	wg.Wait()
+}
diff --git log_test.go log_test.go
index 62cb3d3b1..e0357abc9 100644
--- log_test.go
+++ log_test.go
@@ -3,14 +3,17 @@ package sentry
 import (
 	"bytes"
 	"context"
-	"log"
+	"io"
 	"strings"
 	"testing"
 	"time"
 
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/debuglog"
+	"github.com/getsentry/sentry-go/internal/testutils"
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
+	"github.com/stretchr/testify/assert"
 )
 
 const (
@@ -65,7 +68,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Trace level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Tracef(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Trace().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -85,7 +88,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Debug level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Debugf(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Debug().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -105,7 +108,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Info level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Infof(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Info().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -125,7 +128,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Warn level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Warnf(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Warn().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -145,7 +148,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 		{
 			name: "Error level",
 			logFunc: func(ctx context.Context, l Logger) {
-				l.Errorf(ctx, "param matching: %v and %v", "param1", "param2")
+				l.Error().WithCtx(ctx).Emitf("param matching: %v and %v", "param1", "param2")
 			},
 			message: "param matching: %v and %v",
 			wantEvents: []Event{
@@ -177,7 +180,7 @@ func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
 			// invalid attribute should be dropped
 			l.SetAttributes(attribute.Builder{Key: "key.invalid", Value: attribute.Value{}})
 			tt.logFunc(ctx, l)
-			Flush(20 * time.Millisecond)
+			Flush(testutils.FlushTimeout())
 
 			opts := cmp.Options{
 				cmpopts.IgnoreFields(Log{}, "Timestamp"),
@@ -217,7 +220,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Trace level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Trace(ctx, msg)
+				l.Trace().WithCtx(ctx).Emit(msg)
 			},
 			args: "trace",
 			wantEvents: []Event{
@@ -237,7 +240,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Debug level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Debug(ctx, msg)
+				l.Debug().WithCtx(ctx).Emit(msg)
 			},
 			args: "debug",
 			wantEvents: []Event{
@@ -257,7 +260,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Info level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Info(ctx, msg)
+				l.Info().WithCtx(ctx).Emit(msg)
 			},
 			args: "info",
 			wantEvents: []Event{
@@ -277,7 +280,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Warn level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Warn(ctx, msg)
+				l.Warn().WithCtx(ctx).Emit(msg)
 			},
 			args: "warn",
 			wantEvents: []Event{
@@ -297,7 +300,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 		{
 			name: "Error level",
 			logFunc: func(ctx context.Context, l Logger, msg any) {
-				l.Error(ctx, msg)
+				l.Error().WithCtx(ctx).Emit(msg)
 			},
 			args: "error",
 			wantEvents: []Event{
@@ -321,7 +324,7 @@ func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
 			ctx, mockTransport := setupMockTransport()
 			l := NewLogger(ctx)
 			tt.logFunc(ctx, l, tt.args)
-			Flush(20 * time.Millisecond)
+			Flush(testutils.FlushTimeout())
 
 			opts := cmp.Options{
 				cmpopts.IgnoreFields(Log{}, "Timestamp"),
@@ -354,7 +357,7 @@ func Test_sentryLogger_Panic(t *testing.T) {
 		}()
 		ctx, _ := setupMockTransport()
 		l := NewLogger(ctx)
-		l.Panic(context.Background(), "panic message") // This should panic
+		l.Panic().Emit("panic message") // This should panic
 	})
 
 	t.Run("logger.Panicf", func(t *testing.T) {
@@ -367,7 +370,7 @@ func Test_sentryLogger_Panic(t *testing.T) {
 		}()
 		ctx, _ := setupMockTransport()
 		l := NewLogger(ctx)
-		l.Panicf(context.Background(), "panic message") // This should panic
+		l.Panic().Emitf("panic message") // This should panic
 	})
 }
 
@@ -400,7 +403,7 @@ func Test_sentryLogger_Write(t *testing.T) {
 	if n != len(msg) {
 		t.Errorf("Write returned wrong byte count: got %d, want %d", n, len(msg))
 	}
-	Flush(20 * time.Millisecond)
+	Flush(testutils.FlushTimeout())
 
 	gotEvents := mockTransport.Events()
 	if len(gotEvents) != 1 {
@@ -422,11 +425,11 @@ func Test_sentryLogger_FlushAttributesAfterSend(t *testing.T) {
 	ctx, mockTransport := setupMockTransport()
 	l := NewLogger(ctx)
 	l.SetAttributes(attribute.Int("int", 42))
-	l.Info(ctx, msg)
+	l.Info().WithCtx(ctx).Emit(msg)
 
 	l.SetAttributes(attribute.String("string", "some str"))
-	l.Warn(ctx, msg)
-	Flush(20 * time.Millisecond)
+	l.Warn().WithCtx(ctx).Emit(msg)
+	Flush(testutils.FlushTimeout())
 
 	gotEvents := mockTransport.Events()
 	if len(gotEvents) != 1 {
@@ -434,17 +437,43 @@ func Test_sentryLogger_FlushAttributesAfterSend(t *testing.T) {
 	}
 	event := gotEvents[0]
 	assertEqual(t, event.Logs[0].Attributes["int"].Value, int64(42))
-	if _, ok := event.Logs[1].Attributes["int"]; ok {
-		t.Fatalf("expected key to not exist")
+	if _, ok := event.Logs[1].Attributes["int"]; !ok {
+		t.Fatalf("expected key to exist")
 	}
 	assertEqual(t, event.Logs[1].Attributes["string"].Value, "some str")
 }
 
+func TestSentryLogger_LogEntryAttributes(t *testing.T) {
+	msg := []byte("something")
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+	l.Info().WithCtx(ctx).
+		String("key.string", "some str").
+		Int("key.int", 42).
+		Int64("key.int64", 17).
+		Float64("key.float", 42.2).
+		Bool("key.bool", true).
+		Emit(msg)
+
+	Flush(20 * time.Millisecond)
+
+	gotEvents := mockTransport.Events()
+	if len(gotEvents) != 1 {
+		t.Fatalf("expected 1 event, got %d", len(gotEvents))
+	}
+	event := gotEvents[0]
+	assertEqual(t, event.Logs[0].Attributes["key.int"].Value, int64(42))
+	assertEqual(t, event.Logs[0].Attributes["key.int64"].Value, int64(17))
+	assertEqual(t, event.Logs[0].Attributes["key.float"].Value, 42.2)
+	assertEqual(t, event.Logs[0].Attributes["key.bool"].Value, true)
+	assertEqual(t, event.Logs[0].Attributes["key.string"].Value, "some str")
+}
+
 func Test_batchLogger_Flush(t *testing.T) {
 	ctx, mockTransport := setupMockTransport()
 	l := NewLogger(context.Background())
-	l.Info(ctx, "context done log")
-	Flush(20 * time.Millisecond)
+	l.Info().WithCtx(ctx).Emit("context done log")
+	Flush(testutils.FlushTimeout())
 
 	events := mockTransport.Events()
 	if len(events) != 1 {
@@ -455,9 +484,9 @@ 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")
+	l.Info().WithCtx(ctx).Emit("context done log")
 
-	cancelCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+	cancelCtx, cancel := context.WithTimeout(context.Background(), testutils.FlushTimeout())
 	FlushWithContext(cancelCtx)
 	defer cancel()
 
@@ -467,6 +496,88 @@ func Test_batchLogger_FlushWithContext(t *testing.T) {
 	}
 }
 
+func Test_batchLogger_FlushMultipleTimes(t *testing.T) {
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+
+	for i := 0; i < 5; i++ {
+		l.Info().WithCtx(ctx).Emit("test")
+	}
+
+	Flush(testutils.FlushTimeout())
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Logf("Got %d events instead of 1", len(events))
+		for i, event := range events {
+			t.Logf("Event %d: %d logs", i, len(event.Logs))
+		}
+		t.Fatalf("expected 1 event after first flush, got %d", len(events))
+	}
+	if len(events[0].Logs) != 5 {
+		t.Fatalf("expected 5 logs in first batch, got %d", len(events[0].Logs))
+	}
+
+	mockTransport.events = nil
+
+	for i := 0; i < 3; i++ {
+		l.Info().WithCtx(ctx).Emit("test")
+	}
+
+	Flush(testutils.FlushTimeout())
+	events = mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event after second flush, got %d", len(events))
+	}
+	if len(events[0].Logs) != 3 {
+		t.Fatalf("expected 3 logs in second batch, got %d", len(events[0].Logs))
+	}
+
+	mockTransport.events = nil
+
+	Flush(testutils.FlushTimeout())
+	events = mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected 0 events after third flush with no lo,gs, got %d", len(events))
+	}
+}
+
+func Test_batchLogger_Shutdown(t *testing.T) {
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+	for i := 0; i < 3; i++ {
+		l.Info().WithCtx(ctx).Emit("test")
+	}
+
+	hub := GetHubFromContext(ctx)
+	hub.Client().batchLogger.Shutdown()
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event after shutdown, got %d", len(events))
+	}
+	if len(events[0].Logs) != 3 {
+		t.Fatalf("expected 3 logs in shutdown batch, got %d", len(events[0].Logs))
+	}
+
+	mockTransport.events = nil
+
+	// Test that shutdown can be called multiple times safely
+	hub.Client().batchLogger.Shutdown()
+	hub.Client().batchLogger.Shutdown()
+
+	events = mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected 0 events after multiple shutdowns, got %d", len(events))
+	}
+
+	Flush(testutils.FlushTimeout())
+	events = mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected 0 events after flush on shutdown logger, got %d", len(events))
+	}
+}
+
 func Test_sentryLogger_BeforeSendLog(t *testing.T) {
 	ctx := context.Background()
 	mockTransport := &MockTransport{}
@@ -491,8 +602,8 @@ func Test_sentryLogger_BeforeSendLog(t *testing.T) {
 	ctx = SetHubOnContext(ctx, hub)
 
 	l := NewLogger(ctx)
-	l.Info(ctx, "context done log")
-	Flush(20 * time.Millisecond)
+	l.Info().WithCtx(ctx).Emit("context done log")
+	Flush(testutils.FlushTimeout())
 
 	events := mockTransport.Events()
 	if len(events) != 0 {
@@ -504,7 +615,7 @@ func Test_Logger_ExceedBatchSize(t *testing.T) {
 	ctx, mockTransport := setupMockTransport()
 	l := NewLogger(context.Background())
 	for i := 0; i < 100; i++ {
-		l.Info(ctx, "test")
+		l.Info().WithCtx(ctx).Emit("test")
 	}
 
 	// sleep to wait for events to propagate
@@ -526,9 +637,9 @@ func Test_sentryLogger_TracePropagationWithTransaction(t *testing.T) {
 	expectedSpanID := txn.SpanID
 
 	logger := NewLogger(txn.Context())
-	logger.Info(txn.Context(), "message with tracing")
+	logger.Info().WithCtx(txn.Context()).Emit("message with tracing")
 
-	Flush(20 * time.Millisecond)
+	Flush(testutils.FlushTimeout())
 
 	events := mockTransport.Events()
 	if len(events) != 1 {
@@ -552,54 +663,48 @@ func Test_sentryLogger_TracePropagationWithTransaction(t *testing.T) {
 }
 
 func TestSentryLogger_DebugLogging(t *testing.T) {
-	var buf bytes.Buffer
-	debugLogger := log.New(&buf, "", 0)
-	originalLogger := DebugLogger
-	DebugLogger = debugLogger
-	defer func() {
-		DebugLogger = originalLogger
-	}()
-
 	tests := []struct {
-		name          string
-		debugEnabled  bool
-		message       string
-		expectedDebug string
+		name       string
+		enableLogs bool
+		message    string
 	}{
 		{
-			name:          "Debug enabled",
-			debugEnabled:  true,
-			message:       "test message",
-			expectedDebug: "test message\n",
+			name:       "Debug enabled",
+			enableLogs: true,
+			message:    "test message",
 		},
 		{
-			name:          "Debug disabled",
-			debugEnabled:  false,
-			message:       "test message",
-			expectedDebug: "",
+			name:       "Debug disabled",
+			enableLogs: false,
+			message:    "test message",
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			buf.Reset()
+			var buf bytes.Buffer
+
 			ctx := context.Background()
 			mockClient, _ := NewClient(ClientOptions{
 				Transport:  &MockTransport{},
-				EnableLogs: true,
-				Debug:      tt.debugEnabled,
+				EnableLogs: tt.enableLogs,
+				Debug:      true,
 			})
 			hub := CurrentHub()
 			hub.BindClient(mockClient)
 
+			// set the debug logger output after NewClient, so that it doesn't change.
+			debuglog.SetOutput(&buf)
+			defer debuglog.SetOutput(io.Discard)
+
 			logger := NewLogger(ctx)
-			logger.Info(ctx, tt.message)
+			logger.Info().WithCtx(ctx).Emit(tt.message)
 
 			got := buf.String()
-			if !tt.debugEnabled {
-				assertEqual(t, len(got), 0)
-			} else if strings.Contains(got, tt.expectedDebug) {
-				t.Errorf("Debug output = %q, want %q", got, tt.expectedDebug)
+			if tt.enableLogs {
+				assertEqual(t, strings.Contains(got, "test message"), true)
+			} else {
+				assertEqual(t, strings.Contains(got, "test message"), false)
 			}
 		})
 	}
@@ -632,7 +737,7 @@ func Test_sentryLogger_UserAttributes(t *testing.T) {
 	ctx = SetHubOnContext(ctx, hub)
 
 	l := NewLogger(ctx)
-	l.Info(ctx, "test message with PII")
+	l.Info().WithCtx(ctx).Emit("test message with PII")
 	Flush(20 * time.Millisecond)
 
 	events := mockTransport.Events()
@@ -666,3 +771,21 @@ func Test_sentryLogger_UserAttributes(t *testing.T) {
 		t.Errorf("unexpected user.email: got %v, want %v", val.Value, "[email protected]")
 	}
 }
+
+func TestLogEntryWithCtx_ShouldCopy(t *testing.T) {
+	ctx, _ := setupMockTransport()
+	l := NewLogger(ctx)
+
+	// using WithCtx should return a new log entry with the new ctx
+	newCtx := context.Background()
+	lentry := l.Info().String("key", "value").(*logEntry)
+	newlentry := lentry.WithCtx(newCtx).(*logEntry)
+	lentry.String("key2", "value")
+
+	assert.Equal(t, lentry.ctx, ctx)
+	assert.Equal(t, newlentry.ctx, newCtx)
+	assert.Contains(t, lentry.attributes, "key")
+	assert.Contains(t, lentry.attributes, "key2")
+	assert.Contains(t, newlentry.attributes, "key")
+	assert.NotContains(t, newlentry.attributes, "key2")
+}
diff --git logrus/go.mod logrus/go.mod
index fd5ab5f27..bc3fa072d 100644
--- logrus/go.mod
+++ logrus/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/logrus
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.1
 	github.com/google/go-cmp v0.6.0
 	github.com/pkg/errors v0.9.1
 	github.com/sirupsen/logrus v1.9.3
diff --git logrus/logrusentry.go logrus/logrusentry.go
index 3dd08bff9..995df3a45 100644
--- logrus/logrusentry.go
+++ logrus/logrusentry.go
@@ -13,6 +13,7 @@ import (
 
 	"github.com/getsentry/sentry-go"
 	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 	"github.com/sirupsen/logrus"
 )
 
@@ -21,6 +22,8 @@ const (
 	sdkIdentifier = "sentry.go.logrus"
 	// the name of the logger.
 	name = "logrus"
+
+	maxErrorDepth = 100
 )
 
 // These default log field keys are used to pass specific metadata in a way that
@@ -43,6 +46,8 @@ const (
 	// These fields are simply omitted, as they are duplicated by the Sentry SDK.
 	FieldGoVersion = "go_version"
 	FieldMaxProcs  = "go_maxprocs"
+
+	LogrusOrigin = "auto.logger.logrus"
 )
 
 var levelMap = map[logrus.Level]sentry.Level{
@@ -180,7 +185,14 @@ func (h *eventHook) entryToEvent(l *logrus.Entry) *sentry.Event {
 
 	if err, ok := s.Extra[logrus.ErrorKey].(error); ok {
 		delete(s.Extra, logrus.ErrorKey)
-		s.SetException(err, -1)
+
+		errorDepth := maxErrorDepth
+		if hub := h.hubProvider(); hub != nil {
+			if client := hub.Client(); client != nil {
+				errorDepth = client.Options().MaxErrorDepth
+			}
+		}
+		s.SetException(err, errorDepth)
 	}
 
 	key = h.key(FieldUser)
@@ -289,77 +301,89 @@ func (h *logHook) key(key string) string {
 	return key
 }
 
+func logrusFieldToLogEntry(logEntry sentry.LogEntry, key string, value interface{}) sentry.LogEntry {
+	switch val := value.(type) {
+	case int8:
+		return logEntry.Int64(key, int64(val))
+	case int16:
+		return logEntry.Int64(key, int64(val))
+	case int32:
+		return logEntry.Int64(key, int64(val))
+	case int64:
+		return logEntry.Int64(key, val)
+	case int:
+		return logEntry.Int64(key, int64(val))
+	case uint, uint8, uint16, uint32, uint64:
+		uval := reflect.ValueOf(val).Convert(reflect.TypeOf(uint64(0))).Uint()
+		if uval <= math.MaxInt64 {
+			return logEntry.Int64(key, int64(uval))
+		} else {
+			// For values larger than int64 can handle, we use string
+			return logEntry.String(key, strconv.FormatUint(uval, 10))
+		}
+	case string:
+		return logEntry.String(key, val)
+	case float32:
+		return logEntry.Float64(key, float64(val))
+	case float64:
+		return logEntry.Float64(key, val)
+	case bool:
+		return logEntry.Bool(key, val)
+	case time.Time:
+		return logEntry.String(key, val.Format(time.RFC3339))
+	case time.Duration:
+		return logEntry.String(key, val.String())
+	default:
+		// Fallback to string conversion for unknown types
+		return logEntry.String(key, fmt.Sprint(value))
+	}
+}
+
 func (h *logHook) Fire(entry *logrus.Entry) error {
 	ctx := context.Background()
 	if entry.Context != nil {
 		ctx = entry.Context
 	}
 
-	for k, v := range entry.Data {
-		// Skip specific fields that might be handled separately
-		if k == h.key(FieldRequest) || k == h.key(FieldUser) ||
-			k == h.key(FieldFingerprint) || k == FieldGoVersion ||
-			k == FieldMaxProcs || k == logrus.ErrorKey {
-			continue
-		}
-
-		switch val := v.(type) {
-		case int8:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int16:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int32:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int64:
-			h.logger.SetAttributes(attribute.Int(k, int(val)))
-		case int:
-			h.logger.SetAttributes(attribute.Int(k, val))
-		case uint, uint8, uint16, uint32, uint64:
-			uval := reflect.ValueOf(val).Convert(reflect.TypeOf(uint64(0))).Uint()
-			if uval <= math.MaxInt64 {
-				h.logger.SetAttributes(attribute.Int64(k, int64(uval)))
-			} else {
-				// For values larger than int64 can handle, we are using string.
-				h.logger.SetAttributes(attribute.String(k, strconv.FormatUint(uval, 10)))
-			}
-		case string:
-			h.logger.SetAttributes(attribute.String(k, val))
-		case float32:
-			h.logger.SetAttributes(attribute.Float64(k, float64(val)))
-		case float64:
-			h.logger.SetAttributes(attribute.Float64(k, val))
-		case bool:
-			h.logger.SetAttributes(attribute.Bool(k, val))
-		default:
-			// can't drop argument, fallback to string conversion
-			h.logger.SetAttributes(attribute.String(k, fmt.Sprint(v)))
-		}
-	}
-
-	h.logger.SetAttributes(attribute.String("sentry.origin", "auto.logger.logrus"))
-
+	// Create the base log entry for the appropriate level
+	var logEntry sentry.LogEntry
 	switch entry.Level {
 	case logrus.TraceLevel:
-		h.logger.Trace(ctx, entry.Message)
+		logEntry = h.logger.Trace().WithCtx(ctx)
 	case logrus.DebugLevel:
-		h.logger.Debug(ctx, entry.Message)
+		logEntry = h.logger.Debug().WithCtx(ctx)
 	case logrus.InfoLevel:
-		h.logger.Info(ctx, entry.Message)
+		logEntry = h.logger.Info().WithCtx(ctx)
 	case logrus.WarnLevel:
-		h.logger.Warn(ctx, entry.Message)
+		logEntry = h.logger.Warn().WithCtx(ctx)
 	case logrus.ErrorLevel:
-		h.logger.Error(ctx, entry.Message)
+		logEntry = h.logger.Error().WithCtx(ctx)
 	case logrus.FatalLevel:
-		h.logger.Fatal(ctx, entry.Message)
+		logEntry = h.logger.Fatal().WithCtx(ctx)
 	case logrus.PanicLevel:
-		h.logger.Panic(ctx, entry.Message)
+		logEntry = h.logger.Panic().WithCtx(ctx)
 	default:
-		sentry.DebugLogger.Printf("Invalid logrus logging level: %v. Dropping log.", entry.Level)
+		debuglog.Printf("Invalid logrus logging level: %v. Dropping log.", entry.Level)
 		if h.fallback != nil {
 			return h.fallback(entry)
 		}
 		return errors.New("invalid log level")
 	}
+
+	// Add all the fields as attributes to this specific log entry
+	for k, v := range entry.Data {
+		// Skip specific fields that might be handled separately
+		if k == h.key(FieldRequest) || k == h.key(FieldUser) ||
+			k == h.key(FieldFingerprint) || k == FieldGoVersion ||
+			k == FieldMaxProcs || k == logrus.ErrorKey {
+			continue
+		}
+
+		logEntry = logrusFieldToLogEntry(logEntry, k, v)
+	}
+
+	// Emit the log entry with the message
+	logEntry.Emit(entry.Message)
 	return nil
 }
 
@@ -395,8 +419,11 @@ func NewLogHook(levels []logrus.Level, opts sentry.ClientOptions) (Hook, error)
 func NewLogHookFromClient(levels []logrus.Level, client *sentry.Client) Hook {
 	defaultHub := sentry.NewHub(client, sentry.NewScope())
 	ctx := sentry.SetHubOnContext(context.Background(), defaultHub)
+	logger := sentry.NewLogger(ctx)
+	logger.SetAttributes(attribute.String("sentry.origin", LogrusOrigin))
+
 	return &logHook{
-		logger: sentry.NewLogger(ctx),
+		logger: logger,
 		levels: levels,
 		hubProvider: func() *sentry.Hub {
 			// Default to using the same hub if no specific provider is set
diff --git logrus/logrusentry_test.go logrus/logrusentry_test.go
index 7faa7506d..d1822658b 100644
--- logrus/logrusentry_test.go
+++ logrus/logrusentry_test.go
@@ -373,12 +373,15 @@ func TestEventHook_entryToEvent(t *testing.T) {
 				Extra: map[string]any{},
 				Exception: []sentry.Exception{
 					{
-						Type:  "*errors.errorString",
-						Value: "failure",
+						Type:       "*errors.errorString",
+						Value:      "failure",
+						Stacktrace: nil,
 						Mechanism: &sentry.Mechanism{
-							ExceptionID:      0,
-							IsExceptionGroup: true,
-							Type:             "generic",
+							ExceptionID:      1,
+							IsExceptionGroup: false,
+							ParentID:         sentry.Pointer(0),
+							Type:             sentry.MechanismTypeChained,
+							Source:           sentry.MechanismTypeUnwrap,
 						},
 					},
 					{
@@ -388,10 +391,11 @@ func TestEventHook_entryToEvent(t *testing.T) {
 							Frames: []sentry.Frame{},
 						},
 						Mechanism: &sentry.Mechanism{
-							ExceptionID:      1,
-							IsExceptionGroup: true,
-							ParentID:         sentry.Pointer(0),
-							Type:             "generic",
+							ExceptionID:      0,
+							IsExceptionGroup: false,
+							ParentID:         nil,
+							Type:             sentry.MechanismTypeGeneric,
+							Source:           "",
 						},
 					},
 				},
@@ -657,8 +661,6 @@ func TestLogHookFire(t *testing.T) {
 				Context: context.Background(),
 			}
 
-			// Since we're using a real logger, which is hard to verify,
-			// we're just checking that Fire doesn't error
 			err := logHook.Fire(entry)
 			assert.NoError(t, err)
 		})
diff --git marshal_test.go marshal_test.go
index ecf1d8134..ed290d111 100644
--- marshal_test.go
+++ marshal_test.go
@@ -180,6 +180,28 @@ func TestCheckInEventMarshalJSON(t *testing.T) {
 				Timezone:      "America/Los_Angeles",
 			},
 		},
+		{
+			Release:     "1.0.0",
+			Environment: "dev",
+			Type:        checkInType,
+			CheckIn: &CheckIn{
+				ID:          "c2f0ce1334c74564bf6631f6161173f5",
+				MonitorSlug: "my-monitor",
+				Status:      "ok",
+				Duration:    time.Second * 10,
+			},
+			MonitorConfig: &MonitorConfig{
+				Schedule: &crontabSchedule{
+					Type:  "crontab",
+					Value: "* * * * *",
+				},
+				CheckInMargin:         2,
+				MaxRuntime:            1,
+				Timezone:              "UTC",
+				FailureIssueThreshold: 5,
+				RecoveryThreshold:     1,
+			},
+		},
 	}
 
 	var buf bytes.Buffer
diff --git negroni/go.mod negroni/go.mod
index 7df60a45d..697c54d30 100644
--- negroni/go.mod
+++ negroni/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/negroni
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.1
 	github.com/google/go-cmp v0.5.9
 	github.com/urfave/negroni/v3 v3.1.1
 )
diff --git negroni/go.sum negroni/go.sum
index 17be47367..35d0b3a32 100644
--- negroni/go.sum
+++ negroni/go.sum
@@ -10,8 +10,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
-github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw=
 github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs=
 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
diff --git otel/go.mod otel/go.mod
index 1b9d6ce68..8311ceb66 100644
--- otel/go.mod
+++ otel/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/otel
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.1
 	github.com/google/go-cmp v0.5.9
 	github.com/stretchr/testify v1.8.4
 	go.opentelemetry.io/otel v1.11.0
diff --git otel/propagator_test.go otel/propagator_test.go
index d4d537941..5c0d4b682 100644
--- otel/propagator_test.go
+++ otel/propagator_test.go
@@ -41,9 +41,9 @@ func createTransactionAndMaybeSpan(transactionContext transactionTestContext, wi
 		// we "swap" span IDs from the transaction and the child span.
 		transaction.SpanID = span.SpanID
 		span.SpanID = SpanIDFromHex(transactionContext.spanID)
-		sentrySpanMap.Set(trace.SpanID(span.SpanID), span)
+		sentrySpanMap.Set(trace.SpanID(span.SpanID), span, trace.SpanID{})
 	}
-	sentrySpanMap.Set(trace.SpanID(transaction.SpanID), transaction)
+	sentrySpanMap.Set(trace.SpanID(transaction.SpanID), transaction, trace.SpanID{})
 
 	otelContext := trace.SpanContextConfig{
 		TraceID:    otelTraceIDFromHex(transactionContext.traceID),
diff --git otel/span_map.go otel/span_map.go
index 5340ff30a..52ac7e539 100644
--- otel/span_map.go
+++ otel/span_map.go
@@ -7,37 +7,111 @@ import (
 	otelTrace "go.opentelemetry.io/otel/trace"
 )
 
+type spanInfo struct {
+	span     *sentry.Span
+	finished bool
+	children map[otelTrace.SpanID]struct{}
+	parentID otelTrace.SpanID
+}
+
 // SentrySpanMap is a mapping between OpenTelemetry spans and Sentry spans.
 // It helps Sentry span processor and propagator to keep track of unfinished
 // Sentry spans and to establish parent-child links between spans.
 type SentrySpanMap struct {
-	spanMap map[otelTrace.SpanID]*sentry.Span
+	spanMap map[otelTrace.SpanID]*spanInfo
 	mu      sync.RWMutex
 }
 
 func (ssm *SentrySpanMap) Get(otelSpandID otelTrace.SpanID) (*sentry.Span, bool) {
 	ssm.mu.RLock()
 	defer ssm.mu.RUnlock()
-	span, ok := ssm.spanMap[otelSpandID]
-	return span, ok
+	info, ok := ssm.spanMap[otelSpandID]
+	if !ok {
+		return nil, false
+	}
+	return info.span, true
 }
 
-func (ssm *SentrySpanMap) Set(otelSpandID otelTrace.SpanID, sentrySpan *sentry.Span) {
+func (ssm *SentrySpanMap) Set(otelSpandID otelTrace.SpanID, sentrySpan *sentry.Span, parentID otelTrace.SpanID) {
 	ssm.mu.Lock()
 	defer ssm.mu.Unlock()
-	ssm.spanMap[otelSpandID] = sentrySpan
+
+	info := &spanInfo{
+		span:     sentrySpan,
+		finished: false,
+		children: make(map[otelTrace.SpanID]struct{}),
+		parentID: parentID,
+	}
+	ssm.spanMap[otelSpandID] = info
+
+	if parentID != (otelTrace.SpanID{}) {
+		if parentInfo, ok := ssm.spanMap[parentID]; ok {
+			parentInfo.children[otelSpandID] = struct{}{}
+		}
+	}
 }
 
-func (ssm *SentrySpanMap) Delete(otelSpandID otelTrace.SpanID) {
+func (ssm *SentrySpanMap) MarkFinished(otelSpandID otelTrace.SpanID) {
 	ssm.mu.Lock()
 	defer ssm.mu.Unlock()
-	delete(ssm.spanMap, otelSpandID)
+
+	info, ok := ssm.spanMap[otelSpandID]
+	if !ok {
+		return
+	}
+
+	info.finished = true
+	ssm.tryCleanupSpan(otelSpandID)
+}
+
+// tryCleanupSpan deletes a parent and all children only if the whole subtree is marked finished.
+// Must be called with lock held.
+func (ssm *SentrySpanMap) tryCleanupSpan(spanID otelTrace.SpanID) {
+	info, ok := ssm.spanMap[spanID]
+	if !ok || !info.finished {
+		return
+	}
+
+	if !info.span.IsTransaction() {
+		parentID := info.parentID
+		if parentID != (otelTrace.SpanID{}) {
+			if parentInfo, parentExists := ssm.spanMap[parentID]; parentExists && !parentInfo.finished {
+				return
+			}
+		}
+	}
+
+	// We need to have a lookup first to see if every child is marked as finished to actually cleanup everything.
+	// There probably is a better way to do this
+	for childID := range info.children {
+		if childInfo, exists := ssm.spanMap[childID]; exists && !childInfo.finished {
+			return
+		}
+	}
+
+	parentID := info.parentID
+	if parentID != (otelTrace.SpanID{}) {
+		if parentInfo, ok := ssm.spanMap[parentID]; ok {
+			delete(parentInfo.children, spanID)
+		}
+	}
+
+	for childID := range info.children {
+		if childInfo, exists := ssm.spanMap[childID]; exists && childInfo.finished {
+			ssm.tryCleanupSpan(childID)
+		}
+	}
+
+	delete(ssm.spanMap, spanID)
+	if parentID != (otelTrace.SpanID{}) {
+		ssm.tryCleanupSpan(parentID)
+	}
 }
 
 func (ssm *SentrySpanMap) Clear() {
 	ssm.mu.Lock()
 	defer ssm.mu.Unlock()
-	ssm.spanMap = make(map[otelTrace.SpanID]*sentry.Span)
+	ssm.spanMap = make(map[otelTrace.SpanID]*spanInfo)
 }
 
 func (ssm *SentrySpanMap) Len() int {
@@ -46,4 +120,4 @@ func (ssm *SentrySpanMap) Len() int {
 	return len(ssm.spanMap)
 }
 
-var sentrySpanMap = SentrySpanMap{spanMap: make(map[otelTrace.SpanID]*sentry.Span)}
+var sentrySpanMap = SentrySpanMap{spanMap: make(map[otelTrace.SpanID]*spanInfo)}
diff --git otel/span_processor.go otel/span_processor.go
index 263d77280..ba2f67a16 100644
--- otel/span_processor.go
+++ otel/span_processor.go
@@ -21,7 +21,7 @@ func NewSentrySpanProcessor() otelSdkTrace.SpanProcessor {
 		return sentrySpanProcessorInstance
 	}
 	sentry.AddGlobalEventProcessor(linkTraceContextToErrorEvent)
-	sentrySpanProcessorInstance := &sentrySpanProcessor{}
+	sentrySpanProcessorInstance = &sentrySpanProcessor{}
 	return sentrySpanProcessorInstance
 }
 
@@ -42,7 +42,7 @@ func (ssp *sentrySpanProcessor) OnStart(parent context.Context, s otelSdkTrace.R
 		span.SpanID = sentry.SpanID(otelSpanID)
 		span.StartTime = s.StartTime()
 
-		sentrySpanMap.Set(otelSpanID, span)
+		sentrySpanMap.Set(otelSpanID, span, otelParentSpanID)
 	} else {
 		traceParentContext := getTraceParentContext(parent)
 		transaction := sentry.StartTransaction(
@@ -58,7 +58,7 @@ func (ssp *sentrySpanProcessor) OnStart(parent context.Context, s otelSdkTrace.R
 			transaction.SetDynamicSamplingContext(dynamicSamplingContext)
 		}
 
-		sentrySpanMap.Set(otelSpanID, transaction)
+		sentrySpanMap.Set(otelSpanID, transaction, otelParentSpanID)
 	}
 }
 
@@ -71,7 +71,7 @@ func (ssp *sentrySpanProcessor) OnEnd(s otelSdkTrace.ReadOnlySpan) {
 	}
 
 	if utils.IsSentryRequestSpan(sentrySpan.Context(), s) {
-		sentrySpanMap.Delete(otelSpanId)
+		sentrySpanMap.MarkFinished(otelSpanId)
 		return
 	}
 
@@ -84,7 +84,7 @@ func (ssp *sentrySpanProcessor) OnEnd(s otelSdkTrace.ReadOnlySpan) {
 	sentrySpan.EndTime = s.EndTime()
 	sentrySpan.Finish()
 
-	sentrySpanMap.Delete(otelSpanId)
+	sentrySpanMap.MarkFinished(otelSpanId)
 }
 
 // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/sdk.md#shutdown-1
diff --git otel/span_processor_test.go otel/span_processor_test.go
index 9d6013f00..23d4a31df 100644
--- otel/span_processor_test.go
+++ otel/span_processor_test.go
@@ -75,7 +75,7 @@ func TestSpanProcessorShutdown(t *testing.T) {
 
 	assertEqual(t, sentrySpanMap.Len(), 1)
 
-	spanProcessor.Shutdown(ctx)
+	_ = spanProcessor.Shutdown(ctx)
 
 	// The span map should be empty
 	assertEqual(t, sentrySpanMap.Len(), 0)
@@ -399,3 +399,59 @@ func TestParseSpanAttributesHttpServer(t *testing.T) {
 	assertEqual(t, sentrySpan.Op, "http.server")
 	assertEqual(t, sentrySpan.Source, sentry.TransactionSource(""))
 }
+
+func TestSpanBecomesChildOfFinishedSpan(t *testing.T) {
+	_, _, tracer := setupSpanProcessorTest()
+	ctx, otelRootSpan := tracer.Start(
+		emptyContextWithSentry(),
+		"rootSpan",
+	)
+	sentryTransaction, _ := sentrySpanMap.Get(otelRootSpan.SpanContext().SpanID())
+
+	ctx, childSpan1 := tracer.Start(
+		ctx,
+		"span name 1",
+	)
+	sentrySpan1, _ := sentrySpanMap.Get(childSpan1.SpanContext().SpanID())
+	childSpan1.End()
+
+	_, childSpan2 := tracer.Start(
+		ctx,
+		"span name 2",
+	)
+	sentrySpan2, _ := sentrySpanMap.Get(childSpan2.SpanContext().SpanID())
+	childSpan2.End()
+
+	otelRootSpan.End()
+
+	assertEqual(t, sentryTransaction.IsTransaction(), true)
+	assertEqual(t, sentrySpan1.IsTransaction(), false)
+	assertEqual(t, sentrySpan1.ParentSpanID, sentryTransaction.SpanID)
+	assertEqual(t, sentrySpan2.IsTransaction(), false)
+	assertEqual(t, sentrySpan2.ParentSpanID, sentrySpan1.SpanID)
+}
+
+func TestSpanWithFinishedParentShouldBeDeleted(t *testing.T) {
+	_, _, tracer := setupSpanProcessorTest()
+
+	ctx, parent := tracer.Start(context.Background(), "parent")
+	parentSpanID := parent.SpanContext().SpanID()
+	_, child := tracer.Start(ctx, "child")
+	childSpanID := child.SpanContext().SpanID()
+
+	_, parentExists := sentrySpanMap.Get(parentSpanID)
+	_, childExists := sentrySpanMap.Get(childSpanID)
+	assertEqual(t, parentExists, true)
+	assertEqual(t, childExists, true)
+
+	parent.End()
+	_, parentExists = sentrySpanMap.Get(parentSpanID)
+	assertEqual(t, parentExists, true)
+	_, childExists = sentrySpanMap.Get(childSpanID)
+	assertEqual(t, childExists, true)
+
+	child.End()
+	_, childExists = sentrySpanMap.Get(childSpanID)
+	assertEqual(t, childExists, false)
+	assertEqual(t, sentrySpanMap.Len(), 0)
+}
diff --git scope.go scope.go
index 3c06279c2..a0bf3f633 100644
--- scope.go
+++ scope.go
@@ -6,6 +6,8 @@ import (
 	"net/http"
 	"sync"
 	"time"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // Scope holds contextual data for the current scope.
@@ -304,6 +306,9 @@ func (scope *Scope) SetPropagationContext(propagationContext PropagationContext)
 
 // GetSpan returns the span from the current scope.
 func (scope *Scope) GetSpan() *Span {
+	scope.mu.RLock()
+	defer scope.mu.RUnlock()
+
 	return scope.span
 }
 
@@ -469,7 +474,7 @@ func (scope *Scope) ApplyToEvent(event *Event, hint *EventHint, client *Client)
 		id := event.EventID
 		event = processor(event, hint)
 		if event == nil {
-			DebugLogger.Printf("Event dropped by one of the Scope EventProcessors: %s\n", id)
+			debuglog.Printf("Event dropped by one of the Scope EventProcessors: %s\n", id)
 			return nil
 		}
 	}
diff --git scope_test.go scope_test.go
index d11cd8ee4..c23163fcf 100644
--- scope_test.go
+++ scope_test.go
@@ -330,15 +330,15 @@ func TestScopeSetLevelOverrides(t *testing.T) {
 
 func TestAddBreadcrumbAddsBreadcrumb(t *testing.T) {
 	scope := NewScope()
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test"}, defaultMaxBreadcrumbs)
 	assertEqual(t, []*Breadcrumb{{Timestamp: testNow, Message: "test"}}, scope.breadcrumbs)
 }
 
 func TestAddBreadcrumbAppendsBreadcrumb(t *testing.T) {
 	scope := NewScope()
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test1"}, maxBreadcrumbs)
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test2"}, maxBreadcrumbs)
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test3"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test1"}, defaultMaxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test2"}, defaultMaxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test3"}, defaultMaxBreadcrumbs)
 
 	assertEqual(t, []*Breadcrumb{
 		{Timestamp: testNow, Message: "test1"},
@@ -350,7 +350,7 @@ func TestAddBreadcrumbAppendsBreadcrumb(t *testing.T) {
 func TestAddBreadcrumbDefaultLimit(t *testing.T) {
 	scope := NewScope()
 	for i := 0; i < 101; i++ {
-		scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test"}, maxBreadcrumbs)
+		scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "test"}, defaultMaxBreadcrumbs)
 	}
 
 	if len(scope.breadcrumbs) != 100 {
@@ -361,7 +361,7 @@ func TestAddBreadcrumbDefaultLimit(t *testing.T) {
 func TestAddBreadcrumbAddsTimestamp(t *testing.T) {
 	scope := NewScope()
 	before := time.Now()
-	scope.AddBreadcrumb(&Breadcrumb{Message: "test"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Message: "test"}, defaultMaxBreadcrumbs)
 	after := time.Now()
 	ts := scope.breadcrumbs[0].Timestamp
 
@@ -412,7 +412,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	clone.SetExtra("foo", "bar")
 	clone.SetLevel(LevelDebug)
 	clone.SetFingerprint([]string{"foo"})
-	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, maxBreadcrumbs)
+	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, defaultMaxBreadcrumbs)
 	clone.AddAttachment(&Attachment{Filename: "foo.txt", Payload: []byte("foo")})
 	clone.SetUser(User{ID: "foo"})
 	r1 := httptest.NewRequest("GET", "/foo", nil)
@@ -427,7 +427,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	scope.SetExtra("foo", "baz")
 	scope.SetLevel(LevelFatal)
 	scope.SetFingerprint([]string{"bar"})
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, defaultMaxBreadcrumbs)
 	scope.AddAttachment(&Attachment{Filename: "bar.txt", Payload: []byte("bar")})
 	scope.SetUser(User{ID: "bar"})
 	r2 := httptest.NewRequest("GET", "/bar", nil)
@@ -469,7 +469,7 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 	scope.SetExtra("foo", "baz")
 	scope.SetLevel(LevelFatal)
 	scope.SetFingerprint([]string{"bar"})
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "bar"}, defaultMaxBreadcrumbs)
 	scope.AddAttachment(&Attachment{Filename: "bar.txt", Payload: []byte("bar")})
 	scope.SetUser(User{ID: "bar"})
 	r1 := httptest.NewRequest("GET", "/bar", nil)
@@ -488,7 +488,7 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 	clone.SetExtra("foo", "bar")
 	clone.SetLevel(LevelDebug)
 	clone.SetFingerprint([]string{"foo"})
-	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, maxBreadcrumbs)
+	clone.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, defaultMaxBreadcrumbs)
 	clone.AddAttachment(&Attachment{Filename: "foo.txt", Payload: []byte("foo")})
 	clone.SetUser(User{ID: "foo"})
 	r2 := httptest.NewRequest("GET", "/foo", nil)
@@ -560,7 +560,7 @@ func TestClearAndReconfigure(t *testing.T) {
 	scope.SetExtra("foo", "bar")
 	scope.SetLevel(LevelDebug)
 	scope.SetFingerprint([]string{"foo"})
-	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, maxBreadcrumbs)
+	scope.AddBreadcrumb(&Breadcrumb{Timestamp: testNow, Message: "foo"}, defaultMaxBreadcrumbs)
 	scope.AddAttachment(&Attachment{Filename: "foo.txt", Payload: []byte("foo")})
 	scope.SetUser(User{ID: "foo"})
 	r := httptest.NewRequest("GET", "/foo", nil)
diff --git sentry.go sentry.go
index 423849d54..659f0e86c 100644
--- sentry.go
+++ sentry.go
@@ -6,7 +6,7 @@ import (
 )
 
 // The version of the SDK.
-const SDKVersion = "0.34.0"
+const SDKVersion = "0.36.1"
 
 // apiVersion is the minimum version of the Sentry API compatible with the
 // sentry-go SDK.
diff --git slog/converter.go slog/converter.go
index f23923c71..b5340c26f 100644
--- slog/converter.go
+++ slog/converter.go
@@ -10,9 +10,11 @@ import (
 	"time"
 
 	"github.com/getsentry/sentry-go"
-	"github.com/getsentry/sentry-go/attribute"
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
+const maxErrorDepth = 100
+
 var (
 	sourceKey = "source"
 	errorKeys = map[string]struct{}{
@@ -24,7 +26,7 @@ var (
 
 type Converter func(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, hub *sentry.Hub) *sentry.Event
 
-func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, _ *sentry.Hub) *sentry.Event {
+func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.Attr) slog.Attr, loggerAttr []slog.Attr, groups []string, record *slog.Record, hub *sentry.Hub) *sentry.Event {
 	// aggregate all attributes
 	attrs := appendRecordAttrsToAttrs(loggerAttr, groups, record)
 
@@ -42,7 +44,14 @@ func DefaultConverter(addSource bool, replaceAttr func(groups []string, a slog.A
 	event.Level = LogLevels[record.Level]
 	event.Message = record.Message
 	event.Logger = name
-	event.SetException(err, 10)
+
+	errorDepth := maxErrorDepth
+	if hub != nil {
+		if client := hub.Client(); client != nil {
+			errorDepth = client.Options().MaxErrorDepth
+		}
+	}
+	event.SetException(err, errorDepth)
 
 	for i := range attrs {
 		attrToSentryEvent(attrs[i], event)
@@ -134,45 +143,44 @@ func handleFingerprint(v slog.Value, event *sentry.Event) {
 	}
 }
 
-func attrToSentryLog(group string, a slog.Attr) []attribute.Builder {
+func slogAttrToLogEntry(logEntry sentry.LogEntry, group string, a slog.Attr) sentry.LogEntry {
 	key := group + a.Key
 	switch a.Value.Kind() {
 	case slog.KindAny:
-		return []attribute.Builder{attribute.String(key, fmt.Sprintf("%+v", a.Value.Any()))}
+		return logEntry.String(key, fmt.Sprintf("%+v", a.Value.Any()))
 	case slog.KindBool:
-		return []attribute.Builder{attribute.Bool(key, a.Value.Bool())}
+		return logEntry.Bool(key, a.Value.Bool())
 	case slog.KindDuration:
-		return []attribute.Builder{attribute.String(key, a.Value.Duration().String())}
+		return logEntry.String(key, a.Value.Duration().String())
 	case slog.KindFloat64:
-		return []attribute.Builder{attribute.Float64(key, a.Value.Float64())}
+		return logEntry.Float64(key, a.Value.Float64())
 	case slog.KindInt64:
-		return []attribute.Builder{attribute.Int64(key, a.Value.Int64())}
+		return logEntry.Int64(key, a.Value.Int64())
 	case slog.KindString:
-		return []attribute.Builder{attribute.String(key, a.Value.String())}
+		return logEntry.String(key, a.Value.String())
 	case slog.KindTime:
-		return []attribute.Builder{attribute.String(key, a.Value.Time().Format(time.RFC3339))}
+		return logEntry.String(key, a.Value.Time().Format(time.RFC3339))
 	case slog.KindUint64:
 		val := a.Value.Uint64()
 		if val <= math.MaxInt64 {
-			return []attribute.Builder{attribute.Int64(key, int64(val))}
+			return logEntry.Int64(key, int64(val))
 		} else {
-			return []attribute.Builder{attribute.String(key, strconv.FormatUint(val, 10))}
+			return logEntry.String(key, strconv.FormatUint(val, 10))
 		}
 	case slog.KindLogValuer:
-		return []attribute.Builder{attribute.String(key, a.Value.LogValuer().LogValue().String())}
+		return logEntry.String(key, a.Value.LogValuer().LogValue().String())
 	case slog.KindGroup:
 		// Handle nested group attributes
-		var attrs []attribute.Builder
 		groupPrefix := key
 		if groupPrefix != "" {
 			groupPrefix += "."
 		}
 		for _, subAttr := range a.Value.Group() {
-			attrs = append(attrs, attrToSentryLog(groupPrefix, subAttr)...)
+			logEntry = slogAttrToLogEntry(logEntry, groupPrefix, subAttr)
 		}
-		return attrs
+		return logEntry
 	}
 
-	sentry.DebugLogger.Printf("Invalid type: dropping attribute with key: %v and value: %v", a.Key, a.Value)
-	return []attribute.Builder{}
+	debuglog.Printf("Invalid type: dropping attribute with key: %v and value: %v", a.Key, a.Value)
+	return logEntry
 }
diff --git slog/go.mod slog/go.mod
index 5eeee4e99..967b32deb 100644
--- slog/go.mod
+++ slog/go.mod
@@ -1,11 +1,11 @@
 module github.com/getsentry/sentry-go/slog
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.1
 	github.com/stretchr/testify v1.9.0
 )
 
diff --git slog/sentryslog.go slog/sentryslog.go
index 2ebfc4efd..5cfb89570 100644
--- slog/sentryslog.go
+++ slog/sentryslog.go
@@ -44,7 +44,9 @@ var (
 	}
 )
 
+// LevelFatal is a custom [slog.Level] that maps to [sentry.LevelFatal]
 const LevelFatal = slog.Level(12)
+const SlogOrigin = "auto.logger.slog"
 
 type Option struct {
 	// Deprecated: Use EventLevel instead. Level is kept for backwards compatibility and defaults to EventLevel.
@@ -104,6 +106,8 @@ func (o Option) NewSentryHandler(ctx context.Context) slog.Handler {
 	}
 
 	logger := sentry.NewLogger(ctx)
+	logger.SetAttributes(attribute.String("sentry.origin", SlogOrigin))
+
 	eventHandler := &eventHandler{
 		option: o,
 		attrs:  []slog.Attr{},
@@ -189,10 +193,14 @@ func (h *eventHandler) Handle(ctx context.Context, record slog.Record) error {
 }
 
 func (h *eventHandler) WithAttrs(attrs []slog.Attr) *eventHandler {
+	// Create a copy of the groups slice to avoid sharing state
+	groupsCopy := make([]string, len(h.groups))
+	copy(groupsCopy, h.groups)
+
 	return &eventHandler{
 		option: h.option,
 		attrs:  appendAttrsToGroup(h.groups, h.attrs, attrs...),
-		groups: h.groups,
+		groups: groupsCopy,
 	}
 }
 
@@ -201,10 +209,15 @@ func (h *eventHandler) WithGroup(name string) *eventHandler {
 		return h
 	}
 
+	// Create a copy of the groups slice to avoid modifying the original
+	newGroups := make([]string, len(h.groups), len(h.groups)+1)
+	copy(newGroups, h.groups)
+	newGroups = append(newGroups, name)
+
 	return &eventHandler{
 		option: h.option,
 		attrs:  h.attrs,
-		groups: append(h.groups, name),
+		groups: newGroups,
 	}
 }
 
@@ -233,42 +246,63 @@ func (h *logHandler) Handle(ctx context.Context, record slog.Record) error {
 	attrs = replaceAttrs(h.option.ReplaceAttr, []string{}, attrs...)
 	attrs = removeEmptyAttrs(attrs)
 
-	var sentryAttributes []attribute.Builder
-	for _, attr := range attrs {
-		sentryAttributes = append(sentryAttributes, attrToSentryLog("", attr)...)
-	}
-	h.logger.SetAttributes(sentryAttributes...)
-	h.logger.SetAttributes(attribute.String("sentry.origin", "auto.logger.slog"))
-
 	// Use level ranges instead of exact matches to support custom levels
 	switch {
 	case record.Level < slog.LevelDebug:
 		// Levels below Debug (e.g., Trace)
-		h.logger.Trace(ctx, record.Message)
+		logEntry := h.logger.Trace().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < slog.LevelInfo:
 		// Debug level range: -4 to -1
-		h.logger.Debug(ctx, record.Message)
+		logEntry := h.logger.Debug().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < slog.LevelWarn:
 		// Info level range: 0 to 3
-		h.logger.Info(ctx, record.Message)
+		logEntry := h.logger.Info().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < slog.LevelError:
 		// Warn level range: 4 to 7
-		h.logger.Warn(ctx, record.Message)
+		logEntry := h.logger.Warn().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	case record.Level < LevelFatal: // custom Fatal level, keep +4 increments
-		h.logger.Error(ctx, record.Message)
+		logEntry := h.logger.Error().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	default:
 		// Fatal level range: 12 and above
-		h.logger.Fatal(ctx, record.Message)
+		logEntry := h.logger.Fatal().WithCtx(ctx)
+		for _, attr := range attrs {
+			logEntry = slogAttrToLogEntry(logEntry, "", attr)
+		}
+		logEntry.Emit(record.Message)
 	}
 
 	return nil
 }
 
 func (h *logHandler) WithAttrs(attrs []slog.Attr) *logHandler {
+	// Create a copy of the groups slice to avoid sharing state
+	groupsCopy := make([]string, len(h.groups))
+	copy(groupsCopy, h.groups)
+
 	return &logHandler{
 		option: h.option,
 		attrs:  appendAttrsToGroup(h.groups, h.attrs, attrs...),
-		groups: h.groups,
+		groups: groupsCopy,
 		logger: h.logger,
 	}
 }
@@ -278,10 +312,15 @@ func (h *logHandler) WithGroup(name string) *logHandler {
 		return h
 	}
 
+	// Create a copy of the groups slice to avoid modifying the original
+	newGroups := make([]string, len(h.groups), len(h.groups)+1)
+	copy(newGroups, h.groups)
+	newGroups = append(newGroups, name)
+
 	return &logHandler{
 		option: h.option,
 		attrs:  h.attrs,
-		groups: append(h.groups, name),
+		groups: newGroups,
 		logger: h.logger,
 	}
 }
diff --git span_recorder.go span_recorder.go
index a2a7d19ce..ba0410150 100644
--- span_recorder.go
+++ span_recorder.go
@@ -2,6 +2,8 @@ package sentry
 
 import (
 	"sync"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 // A spanRecorder stores a span tree that makes up a transaction. Safe for
@@ -24,7 +26,7 @@ func (r *spanRecorder) record(s *Span) {
 	if len(r.spans) >= maxSpans {
 		r.overflowOnce.Do(func() {
 			root := r.spans[0]
-			DebugLogger.Printf("Too many spans: dropping spans from transaction with TraceID=%s SpanID=%s limit=%d",
+			debuglog.Printf("Too many spans: dropping spans from transaction with TraceID=%s SpanID=%s limit=%d",
 				root.TraceID, root.SpanID, maxSpans)
 		})
 		// TODO(tracing): mark the transaction event in some way to
diff --git span_recorder_test.go span_recorder_test.go
index 65f432dc3..f8d8706ff 100644
--- span_recorder_test.go
+++ span_recorder_test.go
@@ -6,6 +6,8 @@ import (
 	"fmt"
 	"io"
 	"testing"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 func Test_spanRecorder_record(t *testing.T) {
@@ -32,8 +34,8 @@ func Test_spanRecorder_record(t *testing.T) {
 	} {
 		t.Run(tt.name, func(t *testing.T) {
 			logBuffer := bytes.Buffer{}
-			DebugLogger.SetOutput(&logBuffer)
-			defer DebugLogger.SetOutput(io.Discard)
+			debuglog.SetOutput(&logBuffer)
+			defer debuglog.SetOutput(io.Discard)
 			spanRecorder := spanRecorder{}
 
 			currentHub.BindClient(&Client{
@@ -54,7 +56,7 @@ func Test_spanRecorder_record(t *testing.T) {
 			} else {
 				assertEqual(t, len(spanRecorder.spans), tt.toRecordSpans, "expected no overflow")
 			}
-			// check if DebugLogger was called for overflow messages
+			// check if debuglog was called for overflow messages
 			if bytes.Contains(logBuffer.Bytes(), []byte("Too many spans")) && !tt.expectOverflow {
 				t.Error("unexpected overflow log")
 			}
diff --git a/testdata/json/checkin/003.json b/testdata/json/checkin/003.json
new file mode 100644
index 000000000..cbce42cf7
--- /dev/null
+++ testdata/json/checkin/003.json
@@ -0,0 +1,19 @@
+{
+  "check_in_id": "c2f0ce1334c74564bf6631f6161173f5",
+  "monitor_slug": "my-monitor",
+  "status": "ok",
+  "duration": 10,
+  "release": "1.0.0",
+  "environment": "dev",
+  "monitor_config": {
+    "schedule": {
+      "type": "crontab",
+      "value": "* * * * *"
+    },
+    "checkin_margin": 2,
+    "max_runtime": 1,
+    "timezone": "UTC",
+    "failure_issue_threshold": 5,
+    "recovery_threshold": 1
+  }
+}
diff --git tracing.go tracing.go
index 993c207f9..1ab03d3bb 100644
--- tracing.go
+++ tracing.go
@@ -12,6 +12,8 @@ import (
 	"strings"
 	"sync"
 	"time"
+
+	"github.com/getsentry/sentry-go/internal/debuglog"
 )
 
 const (
@@ -347,6 +349,58 @@ func (s *Span) SetDynamicSamplingContext(dsc DynamicSamplingContext) {
 	}
 }
 
+// shouldIgnoreStatusCode checks if the transaction should be ignored based on HTTP status code.
+func (s *Span) shouldIgnoreStatusCode() bool {
+	if !s.IsTransaction() {
+		return false
+	}
+
+	ignoreStatusCodes := s.clientOptions().TraceIgnoreStatusCodes
+	if len(ignoreStatusCodes) == 0 {
+		return false
+	}
+
+	s.mu.Lock()
+	statusCodeData, exists := s.Data["http.response.status_code"]
+	s.mu.Unlock()
+
+	if !exists {
+		return false
+	}
+
+	statusCode, ok := statusCodeData.(int)
+	if !ok {
+		return false
+	}
+
+	for _, ignoredRange := range ignoreStatusCodes {
+		switch len(ignoredRange) {
+		case 1:
+			// Single status code
+			if statusCode == ignoredRange[0] {
+				s.mu.Lock()
+				s.Sampled = SampledFalse
+				s.mu.Unlock()
+				debuglog.Printf("dropping transaction with status code %v: found in TraceIgnoreStatusCodes", statusCode)
+				return true
+			}
+		case 2:
+			// Range of status codes [min, max]
+			if ignoredRange[0] <= statusCode && statusCode <= ignoredRange[1] {
+				s.mu.Lock()
+				s.Sampled = SampledFalse
+				s.mu.Unlock()
+				debuglog.Printf("dropping transaction with status code %v: found in TraceIgnoreStatusCodes range [%d, %d]", statusCode, ignoredRange[0], ignoredRange[1])
+				return true
+			}
+		default:
+			debuglog.Printf("incorrect TraceIgnoreStatusCodes format: %v", ignoredRange)
+		}
+	}
+
+	return false
+}
+
 // doFinish runs the actual Span.Finish() logic.
 func (s *Span) doFinish() {
 	if s.EndTime.IsZero() {
@@ -360,6 +414,10 @@ func (s *Span) doFinish() {
 		}
 	}
 
+	if s.shouldIgnoreStatusCode() {
+		return
+	}
+
 	if !s.Sampled.Bool() {
 		return
 	}
@@ -449,14 +507,14 @@ func (s *Span) sample() Sampled {
 	// https://develop.sentry.dev/sdk/performance/#sampling
 	// #1 tracing is not enabled.
 	if !clientOptions.EnableTracing {
-		DebugLogger.Printf("Dropping transaction: EnableTracing is set to %t", clientOptions.EnableTracing)
+		debuglog.Printf("Dropping transaction: EnableTracing is set to %t", clientOptions.EnableTracing)
 		s.sampleRate = 0.0
 		return SampledFalse
 	}
 
 	// #2 explicit sampling decision via StartSpan/StartTransaction options.
 	if s.explicitSampled != SampledUndefined {
-		DebugLogger.Printf("Using explicit sampling decision from StartSpan/StartTransaction: %v", s.explicitSampled)
+		debuglog.Printf("Using explicit sampling decision from StartSpan/StartTransaction: %v", s.explicitSampled)
 		switch s.explicitSampled {
 		case SampledTrue:
 			s.sampleRate = 1.0
@@ -489,25 +547,25 @@ func (s *Span) sample() Sampled {
 			s.dynamicSamplingContext.Entries["sample_rate"] = strconv.FormatFloat(tracesSamplerSampleRate, 'f', -1, 64)
 		}
 		if tracesSamplerSampleRate < 0.0 || tracesSamplerSampleRate > 1.0 {
-			DebugLogger.Printf("Dropping transaction: Returned TracesSampler rate is out of range [0.0, 1.0]: %f", tracesSamplerSampleRate)
+			debuglog.Printf("Dropping transaction: Returned TracesSampler rate is out of range [0.0, 1.0]: %f", tracesSamplerSampleRate)
 			return SampledFalse
 		}
 		if tracesSamplerSampleRate == 0.0 {
-			DebugLogger.Printf("Dropping transaction: Returned TracesSampler rate is: %f", tracesSamplerSampleRate)
+			debuglog.Printf("Dropping transaction: Returned TracesSampler rate is: %f", tracesSamplerSampleRate)
 			return SampledFalse
 		}
 
 		if rng.Float64() < tracesSamplerSampleRate {
 			return SampledTrue
 		}
-		DebugLogger.Printf("Dropping transaction: TracesSampler returned rate: %f", tracesSamplerSampleRate)
+		debuglog.Printf("Dropping transaction: TracesSampler returned rate: %f", tracesSamplerSampleRate)
 
 		return SampledFalse
 	}
 
 	// #4 inherit parent decision.
 	if s.Sampled != SampledUndefined {
-		DebugLogger.Printf("Using sampling decision from parent: %v", s.Sampled)
+		debuglog.Printf("Using sampling decision from parent: %v", s.Sampled)
 		switch s.Sampled {
 		case SampledTrue:
 			s.sampleRate = 1.0
@@ -525,11 +583,11 @@ func (s *Span) sample() Sampled {
 		s.dynamicSamplingContext.Entries["sample_rate"] = strconv.FormatFloat(sampleRate, 'f', -1, 64)
 	}
 	if sampleRate < 0.0 || sampleRate > 1.0 {
-		DebugLogger.Printf("Dropping transaction: TracesSampleRate out of range [0.0, 1.0]: %f", sampleRate)
+		debuglog.Printf("Dropping transaction: TracesSampleRate out of range [0.0, 1.0]: %f", sampleRate)
 		return SampledFalse
 	}
 	if sampleRate == 0.0 {
-		DebugLogger.Printf("Dropping transaction: TracesSampleRate rate is: %f", sampleRate)
+		debuglog.Printf("Dropping transaction: TracesSampleRate rate is: %f", sampleRate)
 		return SampledFalse
 	}
 
@@ -552,7 +610,7 @@ func (s *Span) toEvent() *Event {
 	finished := make([]*Span, 0, len(children))
 	for _, child := range children {
 		if child.EndTime.IsZero() {
-			DebugLogger.Printf("Dropped unfinished span: Op=%q TraceID=%s SpanID=%s", child.Op, child.TraceID, child.SpanID)
+			debuglog.Printf("Dropped unfinished span: Op=%q TraceID=%s SpanID=%s", child.Op, child.TraceID, child.SpanID)
 			continue
 		}
 		finished = append(finished, child)
diff --git transport.go transport.go
index e2ec87abf..d2867418c 100644
--- transport.go
+++ transport.go
@@ -13,6 +13,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/getsentry/sentry-go/internal/debuglog"
 	"github.com/getsentry/sentry-go/internal/ratelimit"
 )
 
@@ -87,14 +88,14 @@ func getRequestBodyFromEvent(event *Event) []byte {
 	}
 	body, err = json.Marshal(event)
 	if err == nil {
-		DebugLogger.Println(msg)
+		debuglog.Println(msg)
 		return body
 	}
 
 	// This should _only_ happen when Event.Exception[0].Stacktrace.Frames[0].Vars is unserializable
 	// Which won't ever happen, as we don't use it now (although it's the part of public interface accepted by Sentry)
 	// Juuust in case something, somehow goes utterly wrong.
-	DebugLogger.Println("Event couldn't be marshaled, even with stripped contextual data. Skipping delivery. " +
+	debuglog.Println("Event couldn't be marshaled, even with stripped contextual data. Skipping delivery. " +
 		"Please notify the SDK owners with possibly broken payload.")
 	return nil
 }
@@ -262,17 +263,6 @@ func getRequestFromEvent(ctx context.Context, event *Event, dsn *Dsn) (r *http.R
 	)
 }
 
-func categoryFor(eventType string) ratelimit.Category {
-	switch eventType {
-	case "":
-		return ratelimit.CategoryError
-	case transactionType:
-		return ratelimit.CategoryTransaction
-	default:
-		return ratelimit.Category(eventType)
-	}
-}
-
 // ================================
 // HTTPTransport
 // ================================
@@ -303,7 +293,8 @@ type HTTPTransport struct {
 	// current in-flight items and starts a new batch for subsequent events.
 	buffer chan batch
 
-	start sync.Once
+	startOnce sync.Once
+	closeOnce sync.Once
 
 	// Size of the transport buffer. Defaults to 30.
 	BufferSize int
@@ -331,7 +322,7 @@ func NewHTTPTransport() *HTTPTransport {
 func (t *HTTPTransport) Configure(options ClientOptions) {
 	dsn, err := NewDsn(options.Dsn)
 	if err != nil {
-		DebugLogger.Printf("%v\n", err)
+		debuglog.Printf("%v\n", err)
 		return
 	}
 	t.dsn = dsn
@@ -364,7 +355,7 @@ func (t *HTTPTransport) Configure(options ClientOptions) {
 		}
 	}
 
-	t.start.Do(func() {
+	t.startOnce.Do(func() {
 		go t.worker()
 	})
 }
@@ -380,7 +371,7 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event)
 		return
 	}
 
-	category := categoryFor(event.Type)
+	category := event.toCategory()
 
 	if t.disabled(category) {
 		return
@@ -413,9 +404,9 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event)
 		if event.Type == transactionType {
 			eventType = "transaction"
 		} else {
-			eventType = fmt.Sprintf("%s event", event.Level)
+			eventType = fmt.Sprintf("%s event", event.Type)
 		}
-		DebugLogger.Printf(
+		debuglog.Printf(
 			"Sending %s [%s] to %s project: %s",
 			eventType,
 			event.EventID,
@@ -423,7 +414,7 @@ func (t *HTTPTransport) SendEventWithContext(ctx context.Context, event *Event)
 			t.dsn.projectID,
 		)
 	default:
-		DebugLogger.Println("Event dropped due to transport buffer being full.")
+		debuglog.Println("Event dropped due to transport buffer being full.")
 	}
 
 	t.buffer <- b
@@ -440,11 +431,9 @@ 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 {
-	timeoutCh := make(chan struct{})
-	time.AfterFunc(timeout, func() {
-		close(timeoutCh)
-	})
-	return t.flushInternal(timeoutCh)
+	ctx, cancel := context.WithTimeout(context.Background(), timeout)
+	defer cancel()
+	return t.FlushWithContext(ctx)
 }
 
 // FlushWithContext works like Flush, but it accepts a context.Context instead of a timeout.
@@ -489,14 +478,14 @@ started:
 	// Wait until the current batch is done or the timeout.
 	select {
 	case <-b.done:
-		DebugLogger.Println("Buffer flushed successfully.")
+		debuglog.Println("Buffer flushed successfully.")
 		return true
 	case <-timeout:
 		goto fail
 	}
 
 fail:
-	DebugLogger.Println("Buffer flushing was canceled or timed out.")
+	debuglog.Println("Buffer flushing was canceled or timed out.")
 	return false
 }
 
@@ -506,7 +495,9 @@ fail:
 // Close should be called after Flush and before terminating the program
 // otherwise some events may be lost.
 func (t *HTTPTransport) Close() {
-	close(t.done)
+	t.closeOnce.Do(func() {
+		close(t.done)
+	})
 }
 
 func (t *HTTPTransport) worker() {
@@ -534,15 +525,15 @@ func (t *HTTPTransport) worker() {
 
 				response, err := t.client.Do(item.request)
 				if err != nil {
-					DebugLogger.Printf("There was an issue with sending an event: %v", err)
+					debuglog.Printf("There was an issue with sending an event: %v", err)
 					continue
 				}
 				if response.StatusCode >= 400 && response.StatusCode <= 599 {
 					b, err := io.ReadAll(response.Body)
 					if err != nil {
-						DebugLogger.Printf("Error while reading response code: %v", err)
+						debuglog.Printf("Error while reading response code: %v", err)
 					}
-					DebugLogger.Printf("Sending %s failed with the following error: %s", eventType, string(b))
+					debuglog.Printf("Sending %s failed with the following error: %s", eventType, string(b))
 				}
 
 				t.mu.Lock()
@@ -569,7 +560,7 @@ func (t *HTTPTransport) disabled(c ratelimit.Category) bool {
 	defer t.mu.RUnlock()
 	disabled := t.limits.IsRateLimited(c)
 	if disabled {
-		DebugLogger.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
+		debuglog.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
 	}
 	return disabled
 }
@@ -615,7 +606,7 @@ func NewHTTPSyncTransport() *HTTPSyncTransport {
 func (t *HTTPSyncTransport) Configure(options ClientOptions) {
 	dsn, err := NewDsn(options.Dsn)
 	if err != nil {
-		DebugLogger.Printf("%v\n", err)
+		debuglog.Printf("%v\n", err)
 		return
 	}
 	t.dsn = dsn
@@ -652,7 +643,7 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve
 		return
 	}
 
-	if t.disabled(categoryFor(event.Type)) {
+	if t.disabled(event.toCategory()) {
 		return
 	}
 
@@ -670,7 +661,7 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve
 	default:
 		eventIdentifier = fmt.Sprintf("%s event", event.Level)
 	}
-	DebugLogger.Printf(
+	debuglog.Printf(
 		"Sending %s [%s] to %s project: %s",
 		eventIdentifier,
 		event.EventID,
@@ -680,15 +671,15 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve
 
 	response, err := t.client.Do(request)
 	if err != nil {
-		DebugLogger.Printf("There was an issue with sending an event: %v", err)
+		debuglog.Printf("There was an issue with sending an event: %v", err)
 		return
 	}
 	if response.StatusCode >= 400 && response.StatusCode <= 599 {
 		b, err := io.ReadAll(response.Body)
 		if err != nil {
-			DebugLogger.Printf("Error while reading response code: %v", err)
+			debuglog.Printf("Error while reading response code: %v", err)
 		}
-		DebugLogger.Printf("Sending %s failed with the following error: %s", eventIdentifier, string(b))
+		debuglog.Printf("Sending %s failed with the following error: %s", eventIdentifier, string(b))
 	}
 
 	t.mu.Lock()
@@ -720,7 +711,7 @@ func (t *HTTPSyncTransport) disabled(c ratelimit.Category) bool {
 	defer t.mu.Unlock()
 	disabled := t.limits.IsRateLimited(c)
 	if disabled {
-		DebugLogger.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
+		debuglog.Printf("Too many requests for %q, backing off till: %v", c, t.limits.Deadline(c))
 	}
 	return disabled
 }
@@ -736,11 +727,11 @@ type noopTransport struct{}
 var _ Transport = noopTransport{}
 
 func (noopTransport) Configure(ClientOptions) {
-	DebugLogger.Println("Sentry client initialized with an empty DSN. Using noopTransport. No events will be delivered.")
+	debuglog.Println("Sentry client initialized with an empty DSN. Using noopTransport. No events will be delivered.")
 }
 
 func (noopTransport) SendEvent(*Event) {
-	DebugLogger.Println("Event dropped due to noopTransport usage.")
+	debuglog.Println("Event dropped due to noopTransport usage.")
 }
 
 func (noopTransport) Flush(time.Duration) bool {
diff --git transport_test.go transport_test.go
index cf29596f1..f4a066ad2 100644
--- transport_test.go
+++ transport_test.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"net"
 	"net/http"
 	"net/http/httptest"
 	"net/http/httptrace"
@@ -490,6 +491,28 @@ func TestHTTPTransport(t *testing.T) {
 		wg.Wait()
 	})
 }
+func TestHTTPTransport_CloseMultipleTimes(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(),
+	})
+
+	// Closing multiple times should not panic.
+	for i := 0; i < 10; i++ {
+		transport.Close()
+	}
+
+	// Verify the done channel is closed
+	select {
+	case <-transport.done:
+		// Expected - channel should be closed
+	case <-time.After(time.Second):
+		t.Fatal("transport.done not closed")
+	}
+}
 
 func TestHTTPTransport_FlushWithContext(t *testing.T) {
 	server := newTestHTTPServer(t)
@@ -76,7,19 +790,31 @@ func TestHTTPTransportDoesntLeakGoroutines(t *testing.T) {
 
 	transport := NewHTTPTransport()
 	transport.Configure(ClientOptions{
-		Dsn:        "https://test@foobar/1",
-		HTTPClient: http.DefaultClient,
+		Dsn: "https://test@foobar/1",
+		HTTPClient: &http.Client{
+			Transport: &http.Transport{
+				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+					return nil, fmt.Errorf("mock transport - no real connections")
+				},
+			},
+		},
 	})
 
-	transport.Flush(0)
+	transport.Flush(testutils.FlushTimeout())
 	transport.Close()
 }
 
 func TestHTTPTransportClose(t *testing.T) {
 	transport := NewHTTPTransport()
 	transport.Configure(ClientOptions{
-		Dsn:        "https://test@foobar/1",
-		HTTPClient: http.DefaultClient,
+		Dsn: "https://test@foobar/1",
+		HTTPClient: &http.Client{
+			Transport: &http.Transport{
+				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
+					return nil, fmt.Errorf("mock transport - no real connections")
+				},
+			},
+		},
 	})
 
 	transport.Close()
diff --git util.go util.go
index 1dfb091fb..3a6a33c8d 100644
--- util.go
+++ util.go
@@ -10,6 +10,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/getsentry/sentry-go/internal/debuglog"
 	exec "golang.org/x/sys/execabs"
 )
 
@@ -62,7 +63,7 @@ func defaultRelease() (release string) {
 	}
 	for _, e := range envs {
 		if release = os.Getenv(e); release != "" {
-			DebugLogger.Printf("Using release from environment variable %s: %s", e, release)
+			debuglog.Printf("Using release from environment variable %s: %s", e, release)
 			return release
 		}
 	}
@@ -89,23 +90,23 @@ func defaultRelease() (release string) {
 			if err, ok := err.(*exec.ExitError); ok && len(err.Stderr) > 0 {
 				fmt.Fprintf(&s, ": %s", err.Stderr)
 			}
-			DebugLogger.Print(s.String())
+			debuglog.Print(s.String())
 		} else {
 			release = strings.TrimSpace(string(b))
-			DebugLogger.Printf("Using release from Git: %s", release)
+			debuglog.Printf("Using release from Git: %s", release)
 			return release
 		}
 	}
 
-	DebugLogger.Print("Some Sentry features will not be available. See https://docs.sentry.io/product/releases/.")
-	DebugLogger.Print("To stop seeing this message, pass a Release to sentry.Init or set the SENTRY_RELEASE environment variable.")
+	debuglog.Print("Some Sentry features will not be available. See https://docs.sentry.io/product/releases/.")
+	debuglog.Print("To stop seeing this message, pass a Release to sentry.Init or set the SENTRY_RELEASE environment variable.")
 	return ""
 }
 
 func revisionFromBuildInfo(info *debug.BuildInfo) string {
 	for _, setting := range info.Settings {
 		if setting.Key == "vcs.revision" && setting.Value != "" {
-			DebugLogger.Printf("Using release from debug info: %s", setting.Value)
+			debuglog.Printf("Using release from debug info: %s", setting.Value)
 			return setting.Value
 		}
 	}
diff --git zerolog/go.mod zerolog/go.mod
index 69e9b398f..0c5a421dd 100644
--- zerolog/go.mod
+++ zerolog/go.mod
@@ -1,12 +1,12 @@
 module github.com/getsentry/sentry-go/zerolog
 
-go 1.21
+go 1.23
 
 replace github.com/getsentry/sentry-go => ../
 
 require (
 	github.com/buger/jsonparser v1.1.1
-	github.com/getsentry/sentry-go v0.34.0
+	github.com/getsentry/sentry-go v0.36.1
 	github.com/rs/zerolog v1.33.0
 	github.com/stretchr/testify v1.9.0
 )

Description

This PR introduces significant improvements to the Sentry Go SDK including enhanced error handling, logging API improvements, CI/CD updates, and dependency version bumps. The major changes include:

  1. Enhanced error handling - Improved error chain handling for complex error scenarios, particularly with errors.Join() and multi-error patterns
  2. Logging API refactoring - Complete overhaul of the logging interface to support a fluent, chainable API for structured logging
  3. New features - Added TraceIgnoreStatusCodes option to filter HTTP transactions by status codes
  4. CI/CD improvements - Updated GitHub Actions versions, added Go 1.25 support, dropped Go 1.22
  5. Bug fixes - Fixed race conditions, panic prevention, and memory leaks

Possible Issues

  1. Breaking changes - The logging API changes will break existing code that uses the old interface (methods like logger.Infof(ctx, format, args...) are now logger.Info().WithCtx(ctx).Emitf(format, args...))
  2. Behavioral changes - Error grouping will change due to new exception handling, potentially creating new issue groups in Sentry
  3. Default breadcrumb limit change - Increased from 30 to 100, which may impact memory usage patterns
  4. Complex state management - The new logging API with chainable methods introduces complexity in concurrent usage patterns

Security Hotspots

None identified - the changes primarily involve internal improvements and API enhancements without introducing apparent security vulnerabilities.

Privacy Hotspots

  1. Debug logging expansion - The new debug logging infrastructure (internal/debuglog) could potentially log more sensitive information, though it appears to maintain the same privacy controls as before
Changes

Changes

Configuration and CI/CD:

  • Updated GitHub Actions workflows to use newer versions (checkout@v5, setup-go@v6, etc.)
  • Added Go 1.25 support, dropped Go 1.22
  • Updated minimum Go version to 1.23 across all modules

Core SDK Changes:

  • client.go - Added TraceIgnoreStatusCodes option, updated default max breadcrumbs to 100
  • exception.go (new) - Complete rewrite of exception handling with improved error chain support
  • interfaces.go - Updated Logger interface to support fluent API, added LogEntry interface
  • log.go - Major refactoring to implement chainable logging API

Error Handling:

  • New convertErrorToExceptions() function with better handling of errors.Join(), circular references, and non-comparable errors
  • Enhanced mechanism types (MechanismTypeChained, MechanismTypeUnwrap, etc.)
  • Improved parent-child relationships in exception chains

Logging Improvements:

  • Replaced procedural logging methods with chainable LogEntry interface
  • Thread-safe attribute management with proper mutex usage
  • Support for context-aware logging with WithCtx()

Transport and Infrastructure:

  • Enhanced rate limiting with new categories (log, monitor)
  • Improved batch logger with flush and shutdown capabilities
  • Better OpenTelemetry span lifecycle management

Integration Updates:

  • Updated fasthttp, fiber, gin, logrus, and other integration modules
  • Fixed URL parsing issues that could cause panics
  • Improved attribute handling in various logging integrations
sequenceDiagram
    participant App as Application
    participant Logger as Logger
    participant LogEntry as LogEntry
    participant BatchLogger as BatchLogger
    participant Transport as Transport
    participant Sentry as Sentry API

    App->>Logger: logger.Info()
    Logger->>LogEntry: Create LogEntry
    LogEntry->>LogEntry: String("key", "value")
    LogEntry->>LogEntry: Int("count", 42)
    LogEntry->>LogEntry: WithCtx(ctx)
    LogEntry->>Logger: Emitf("message %s", arg)
    Logger->>BatchLogger: Send log with attributes
    BatchLogger->>BatchLogger: Batch logs (up to 50)
    BatchLogger->>Transport: Send batched event
    Transport->>Sentry: HTTP POST /api/projects/{id}/store/
Loading

@renovate renovate bot force-pushed the renovate/github-com-getsentry-sentry-go-0-x branch from b9fc649 to 8b19d27 Compare November 4, 2025 16:57
@renovate renovate bot changed the title fix(deps): update module github.com/getsentry/sentry-go to v0.36.1 fix(deps): update module github.com/getsentry/sentry-go to v0.36.2 Nov 4, 2025
@renovate renovate bot force-pushed the renovate/github-com-getsentry-sentry-go-0-x branch from 8b19d27 to b17376a Compare November 5, 2025 00:39
@renovate renovate bot force-pushed the renovate/github-com-getsentry-sentry-go-0-x branch 3 times, most recently from 60ea6ec to cd95ea0 Compare November 20, 2025 14:03
@renovate renovate bot changed the title fix(deps): update module github.com/getsentry/sentry-go to v0.36.2 fix(deps): update module github.com/getsentry/sentry-go to v0.37.0 Nov 20, 2025
@renovate renovate bot force-pushed the renovate/github-com-getsentry-sentry-go-0-x branch from cd95ea0 to cf24c50 Compare November 24, 2025 14:31
@renovate renovate bot changed the title fix(deps): update module github.com/getsentry/sentry-go to v0.37.0 fix(deps): update module github.com/getsentry/sentry-go to v0.38.0 Nov 24, 2025
@renovate renovate bot force-pushed the renovate/github-com-getsentry-sentry-go-0-x branch from cf24c50 to 8b16741 Compare December 1, 2025 19:47
@renovate renovate bot changed the title fix(deps): update module github.com/getsentry/sentry-go to v0.38.0 fix(deps): update module github.com/getsentry/sentry-go to v0.39.0 Dec 1, 2025
@mihaiplesa mihaiplesa merged commit 5ea7b9d into master Dec 1, 2025
8 checks passed
@mihaiplesa mihaiplesa deleted the renovate/github-com-getsentry-sentry-go-0-x branch December 1, 2025 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants