Repository: getsentry/sentry-go

Issue:
Sentry Structured Logging for Go
https://develop.sentry.dev/sdk/telemetry/logs/

Sentry is adding support for [structured logging](https://github.com/getsentry/sentry/discussions/86804). Let's add it to the Go SDK!

1. Define the [logs protocol](https://develop.sentry.dev/sdk/telemetry/logs/#logs-protocol) and log envelope item in the SDK
1. Add the Public API (SDK options and methods) as per the [docs](https://develop.sentry.dev/sdk/telemetry/logs/#public-api)
1. Add [SDK behavior](https://develop.sentry.dev/sdk/telemetry/logs/#sdk-behavior) as per docs
1. Attach [default attributes](https://develop.sentry.dev/sdk/telemetry/logs/#default-attributes) to the SDK as per docs
1. Instrument popular logging libraries to send logs to Sentry. For Go we should aim to support the libraries we already have docs for:
a) https://pkg.go.dev/log/slog / https://docs.sentry.io/platforms/go/guides/slog/
b) https://pkg.go.dev/github.com/sirupsen/logrus / https://docs.sentry.io/platforms/go/guides/logrus/
c) https://pkg.go.dev/github.com/rs/zerolog / https://docs.sentry.io/platforms/go/guides/zerolog/
1. Create a GH discussion that contains instructions for setting up the SDK in your repo. Eventually this will be moved into the primary docs. See the [JS SDK's GH discussion](https://github.com/getsentry/sentry-javascript/discussions/15916) for inspiration.

Hints from issue comments:
@aldy505: One nit: the logging capability should support the `io.Writer` interface. Since most libraries with logging capabilities only ask that. It should be defaults to INFO or DEBUG level. @AbhiPrasad: Can we create follow up issues for logging library support?

We don’t have to work on them now, we can prioritize based on user feedback.@cleptric: @giortzisg called it 😬 @giortzisg: Opened #1015 

Solution patch:
diff --git a/.codecov.yml b/.codecov.yml
index df0b268..557e1aa 100644
--- a/.codecov.yml
+++ b/.codecov.yml
@@ -11,3 +11,5 @@ coverage:
       default:
         # Do not fail the commit status if the coverage was reduced up to this value
         threshold: 0.5%
+ignore:
+  - "log_fallback.go"
diff --git a/_examples/logs/main.go b/_examples/logs/main.go
new file mode 100644
index 0000000..c9785f2
--- /dev/null
+++ b/_examples/logs/main.go
@@ -0,0 +1,37 @@
+package main
+
+import (
+	"context"
+	"time"
+
+	"github.com/getsentry/sentry-go"
+	"github.com/getsentry/sentry-go/attribute"
+)
+
+func main() {
+	err := sentry.Init(sentry.ClientOptions{
+		Dsn:        "",
+		EnableLogs: true,
+	})
+	if err != nil {
+		panic(err)
+	}
+	defer sentry.Flush(2 * time.Second)
+
+	ctx := context.Background()
+	logger := sentry.NewLogger(ctx)
+
+	// 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")
+}
diff --git a/batch_logger.go b/batch_logger.go
new file mode 100644
index 0000000..8f25c00
--- /dev/null
+++ b/batch_logger.go
@@ -0,0 +1,94 @@
+package sentry
+
+import (
+	"context"
+	"sync"
+	"time"
+)
+
+const (
+	batchSize    = 100
+	batchTimeout = 5 * time.Second
+)
+
+type BatchLogger struct {
+	client    *Client
+	logCh     chan Log
+	cancel    context.CancelFunc
+	wg        sync.WaitGroup
+	startOnce sync.Once
+}
+
+func NewBatchLogger(client *Client) *BatchLogger {
+	return &BatchLogger{
+		client: client,
+		logCh:  make(chan Log, batchSize),
+	}
+}
+
+func (l *BatchLogger) Start() {
+	l.startOnce.Do(func() {
+		ctx, cancel := context.WithCancel(context.Background())
+		l.cancel = cancel
+		l.wg.Add(1)
+		go l.run(ctx)
+	})
+}
+
+func (l *BatchLogger) Flush() {
+	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)
+
+	for {
+		select {
+		case log := <-l.logCh:
+			logs = append(logs, log)
+			if len(logs) >= batchSize {
+				l.processEvent(logs)
+				logs = nil
+				if !timer.Stop() {
+					<-timer.C
+				}
+				timer.Reset(batchTimeout)
+			}
+		case <-timer.C:
+			if len(logs) > 0 {
+				l.processEvent(logs)
+				logs = nil
+			}
+			timer.Reset(batchTimeout)
+		case <-ctx.Done():
+			// Drain remaining logs from channel
+		drain:
+			for {
+				select {
+				case log := <-l.logCh:
+					logs = append(logs, log)
+				default:
+					break drain
+				}
+			}
+
+			if len(logs) > 0 {
+				l.processEvent(logs)
+			}
+			return
+		}
+	}
+}
+
+func (l *BatchLogger) processEvent(logs []Log) {
+	event := NewEvent()
+	event.Timestamp = time.Now()
+	event.Type = logEvent.Type
+	event.Logs = logs
+	l.client.CaptureEvent(event, nil, nil)
+}
diff --git a/client.go b/client.go
index fced455..5b6fc1b 100644
--- a/client.go
+++ b/client.go
@@ -144,8 +144,11 @@ type ClientOptions struct {
 	// By default, no such data is sent.
 	SendDefaultPII bool
 	// BeforeSend is called before error events are sent to Sentry.
-	// Use it to mutate the event or return nil to discard the event.
+	// You can use it to mutate the event or return nil to discard it.
 	BeforeSend func(event *Event, hint *EventHint) *Event
+	// BeforeSendLong is called before log events are sent to Sentry.
+	// You can use it to mutate the log event or return nil to discard it.
+	BeforeSendLog func(event *Log) *Log
 	// BeforeSendTransaction is called before transaction events are sent to Sentry.
 	// Use it to mutate the transaction or return nil to discard the transaction.
 	BeforeSendTransaction func(event *Event, hint *EventHint) *Event
@@ -223,6 +226,8 @@ type ClientOptions struct {
 	MaxErrorDepth int
 	// Default event tags. These are overridden by tags set on a scope.
 	Tags map[string]string
+	// EnableLogs controls when logs should be emitted.
+	EnableLogs bool
 }
 
 // Client is the underlying processor that is used by the main API and Hub
@@ -237,7 +242,8 @@ type Client struct {
 	sdkVersion      string
 	// Transport is read-only. Replacing the transport of an existing client is
 	// not supported, create a new client instead.
-	Transport Transport
+	Transport   Transport
+	batchLogger *BatchLogger
 }
 
 // NewClient creates and returns an instance of Client configured using
@@ -337,6 +343,11 @@ func NewClient(options ClientOptions) (*Client, error) {
 		sdkVersion:    SDKVersion,
 	}
 
+	if options.EnableLogs {
+		client.batchLogger = NewBatchLogger(&client)
+		client.batchLogger.Start()
+	}
+
 	client.setupTransport()
 	client.setupIntegrations()
 
@@ -351,15 +362,7 @@ func (client *Client) setupTransport() {
 		if opts.Dsn == "" {
 			transport = new(noopTransport)
 		} else {
-			httpTransport := NewHTTPTransport()
-			// When tracing is enabled, use larger buffer to
-			// accommodate more concurrent events.
-			// TODO(tracing): consider using separate buffers per
-			// event type.
-			if opts.EnableTracing {
-				httpTransport.BufferSize = 1000
-			}
-			transport = httpTransport
+			transport = NewHTTPTransport()
 		}
 	}
 
@@ -507,6 +510,9 @@ func (client *Client) RecoverWithContext(
 // the network synchronously, configure it to use the HTTPSyncTransport in the
 // call to Init.
 func (client *Client) Flush(timeout time.Duration) bool {
+	if client.batchLogger != nil {
+		client.batchLogger.Flush()
+	}
 	return client.Transport.Flush(timeout)
 }
 
@@ -617,17 +623,21 @@ func (client *Client) processEvent(event *Event, hint *EventHint, scope EventMod
 	if hint == nil {
 		hint = &EventHint{}
 	}
-	if event.Type == transactionType && client.options.BeforeSendTransaction != nil {
-		// Transaction events
-		if event = client.options.BeforeSendTransaction(event, hint); event == nil {
-			DebugLogger.Println("Transaction dropped due to BeforeSendTransaction callback.")
-			return nil
+	switch event.Type {
+	case transactionType:
+		if client.options.BeforeSendTransaction != nil {
+			if event = client.options.BeforeSendTransaction(event, hint); event == nil {
+				DebugLogger.Println("Transaction dropped due to BeforeSendTransaction callback.")
+				return nil
+			}
 		}
-	} else if event.Type != transactionType && event.Type != checkInType && client.options.BeforeSend != nil {
-		// All other events
-		if event = client.options.BeforeSend(event, hint); event == nil {
-			DebugLogger.Println("Event dropped due to BeforeSend callback.")
-			return nil
+	case checkInType: // not a default case, since we shouldn't apply BeforeSend on check-in events
+	default:
+		if client.options.BeforeSend != nil {
+			if event = client.options.BeforeSend(event, hint); event == nil {
+				DebugLogger.Println("Event dropped due to BeforeSend callback.")
+				return nil
+			}
 		}
 	}
 
diff --git a/interfaces.go b/interfaces.go
index c6461f1..28bcde4 100644
--- a/interfaces.go
+++ b/interfaces.go
@@ -11,17 +11,22 @@ import (
 	"slices"
 	"strings"
 	"time"
+
+	"github.com/getsentry/sentry-go/attribute"
 )
 
-// eventType is the type of an error event.
 const eventType = "event"
-
-// transactionType is the type of a transaction event.
 const transactionType = "transaction"
-
-// checkInType is the type of a check in event.
 const checkInType = "check_in"
 
+var logEvent = struct {
+	Type        string
+	ContentType string
+}{
+	"log",
+	"application/vnd.sentry.items.log+json",
+}
+
 // Level marks the severity of the event.
 type Level string
 
@@ -34,7 +39,7 @@ const (
 	LevelFatal   Level = "fatal"
 )
 
-// SdkInfo contains all metadata about about the SDK being used.
+// SdkInfo contains all metadata about the SDK.
 type SdkInfo struct {
 	Name         string       `json:"name,omitempty"`
 	Version      string       `json:"version,omitempty"`
@@ -96,6 +101,60 @@ func (b *Breadcrumb) MarshalJSON() ([]byte, error) {
 	return json.Marshal((*breadcrumb)(b))
 }
 
+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
+	// might result in incorrect span association on logs. If you need to use
+	// 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(...attribute.Builder)
+}
+
 // Attachment allows associating files with your events to aid in investigation.
 // An event may contain one or more attachments.
 type Attachment struct {
@@ -325,6 +384,9 @@ type Event struct {
 	CheckIn       *CheckIn       `json:"check_in,omitempty"`
 	MonitorConfig *MonitorConfig `json:"monitor_config,omitempty"`
 
+	// The fields below are only relevant for logs
+	Logs []Log `json:"items,omitempty"`
+
 	// The fields below are not part of the final JSON payload.
 
 	sdkMetaData SDKMetaData
@@ -547,3 +609,17 @@ type EventHint struct {
 	Request            *http.Request
 	Response           *http.Response
 }
+
+type Log struct {
+	Timestamp  time.Time            `json:"timestamp,omitempty"`
+	TraceID    TraceID              `json:"trace_id,omitempty"`
+	Level      Level                `json:"level"`
+	Severity   int                  `json:"severity_number,omitempty"`
+	Body       string               `json:"body,omitempty"`
+	Attributes map[string]Attribute `json:"attributes,omitempty"`
+}
+
+type Attribute struct {
+	Value any    `json:"value"`
+	Type  string `json:"type"`
+}
diff --git a/log.go b/log.go
new file mode 100644
index 0000000..5b49531
--- /dev/null
+++ b/log.go
@@ -0,0 +1,216 @@
+package sentry
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/getsentry/sentry-go/attribute"
+)
+
+type LogLevel string
+
+const (
+	LogLevelTrace Level = "trace"
+	LogLevelDebug Level = "debug"
+	LogLevelInfo  Level = "info"
+	LogLevelWarn  Level = "warn"
+	LogLevelError Level = "error"
+	LogLevelFatal Level = "fatal"
+)
+
+const (
+	LogSeverityTrace   int = 1
+	LogSeverityDebug   int = 5
+	LogSeverityInfo    int = 9
+	LogSeverityWarning int = 13
+	LogSeverityError   int = 17
+	LogSeverityFatal   int = 21
+)
+
+var mapTypesToStr = map[attribute.Type]string{
+	attribute.INVALID: "",
+	attribute.BOOL:    "boolean",
+	attribute.INT64:   "integer",
+	attribute.FLOAT64: "double",
+	attribute.STRING:  "string",
+}
+
+type sentryLogger struct {
+	client     *Client
+	attributes map[string]Attribute
+}
+
+// NewLogger returns a Logger that emits logs to Sentry. If logging is turned off, all logs get discarded.
+func NewLogger(ctx context.Context) Logger {
+	var hub *Hub
+	hub = GetHubFromContext(ctx)
+	if hub == nil {
+		hub = CurrentHub()
+	}
+
+	client := hub.Client()
+	if client != nil && client.batchLogger != nil {
+		return &sentryLogger{client, make(map[string]Attribute)}
+	}
+
+	DebugLogger.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(), LevelInfo, LogSeverityInfo, msg)
+	return len(p), nil
+}
+
+func (l *sentryLogger) log(ctx context.Context, level Level, severity int, message string, args ...interface{}) {
+	if message == "" {
+		return
+	}
+	hub := GetHubFromContext(ctx)
+	if hub == nil {
+		hub = CurrentHub()
+	}
+
+	var traceID TraceID
+	var spanID SpanID
+
+	span := hub.Scope().span
+	if span != nil {
+		traceID = span.TraceID
+		spanID = span.SpanID
+	} else {
+		traceID = hub.Scope().propagationContext.TraceID
+	}
+
+	attrs := map[string]Attribute{}
+	if len(args) > 0 {
+		attrs["sentry.message.template"] = Attribute{
+			Value: message, Type: "string",
+		}
+		for i, p := range args {
+			attrs[fmt.Sprintf("sentry.message.parameters.%d", i)] = Attribute{
+				Value: fmt.Sprint(p), Type: "string",
+			}
+		}
+	}
+
+	// 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)
+	}
+
+	// Set default attributes
+	if release := l.client.options.Release; release != "" {
+		attrs["sentry.release"] = Attribute{Value: release, Type: "string"}
+	}
+	if environment := l.client.options.Environment; environment != "" {
+		attrs["sentry.environment"] = Attribute{Value: environment, Type: "string"}
+	}
+	if serverName := l.client.options.ServerName; serverName != "" {
+		attrs["sentry.server.address"] = Attribute{Value: serverName, Type: "string"}
+	} else if serverAddr, err := os.Hostname(); err == nil {
+		attrs["sentry.server.address"] = Attribute{Value: serverAddr, Type: "string"}
+	}
+	if spanID.String() != "0000000000000000" {
+		attrs["sentry.trace.parent_span_id"] = Attribute{Value: spanID.String(), Type: "string"}
+	}
+	if sdkIdentifier := l.client.sdkIdentifier; sdkIdentifier != "" {
+		attrs["sentry.sdk.name"] = Attribute{Value: sdkIdentifier, Type: "string"}
+	}
+	if sdkVersion := l.client.sdkVersion; sdkVersion != "" {
+		attrs["sentry.sdk.version"] = Attribute{Value: sdkVersion, Type: "string"}
+	}
+	attrs["sentry.origin"] = Attribute{Value: "auto.logger.log", Type: "string"}
+
+	log := &Log{
+		Timestamp:  time.Now(),
+		TraceID:    traceID,
+		Level:      level,
+		Severity:   severity,
+		Body:       fmt.Sprintf(message, args...),
+		Attributes: attrs,
+	}
+
+	if l.client.options.BeforeSendLog != nil {
+		log = l.client.options.BeforeSendLog(log)
+	}
+
+	if log != nil {
+		l.client.batchLogger.logCh <- *log
+	}
+
+	if l.client.options.Debug {
+		DebugLogger.Printf(message, args...)
+	}
+}
+
+func (l *sentryLogger) SetAttributes(attrs ...attribute.Builder) {
+	for _, v := range attrs {
+		t, ok := mapTypesToStr[v.Value.Type()]
+		if !ok || t == "" {
+			DebugLogger.Printf("invalid attribute type set: %v", t)
+			continue
+		}
+
+		l.attributes[v.Key] = Attribute{
+			Value: v.Value.AsInterface(),
+			Type:  t,
+		}
+	}
+}
+
+func (l *sentryLogger) Trace(ctx context.Context, v ...interface{}) {
+	l.log(ctx, LogLevelTrace, LogSeverityTrace, fmt.Sprint(v...))
+}
+func (l *sentryLogger) Debug(ctx context.Context, v ...interface{}) {
+	l.log(ctx, LogLevelDebug, LogSeverityDebug, fmt.Sprint(v...))
+}
+func (l *sentryLogger) Info(ctx context.Context, v ...interface{}) {
+	l.log(ctx, LogLevelInfo, LogSeverityInfo, fmt.Sprint(v...))
+}
+func (l *sentryLogger) Warn(ctx context.Context, v ...interface{}) {
+	l.log(ctx, LogLevelWarn, LogSeverityWarning, fmt.Sprint(v...))
+}
+func (l *sentryLogger) Error(ctx context.Context, v ...interface{}) {
+	l.log(ctx, LogLevelError, LogSeverityError, fmt.Sprint(v...))
+}
+func (l *sentryLogger) Fatal(ctx context.Context, v ...interface{}) {
+	l.log(ctx, LogLevelFatal, LogSeverityFatal, fmt.Sprint(v...))
+	os.Exit(1)
+}
+func (l *sentryLogger) Panic(ctx context.Context, v ...interface{}) {
+	l.log(ctx, LogLevelFatal, LogSeverityFatal, fmt.Sprint(v...))
+	panic(fmt.Sprint(v...))
+}
+func (l *sentryLogger) Tracef(ctx context.Context, format string, v ...interface{}) {
+	l.log(ctx, LogLevelTrace, LogSeverityTrace, format, v...)
+}
+func (l *sentryLogger) Debugf(ctx context.Context, format string, v ...interface{}) {
+	l.log(ctx, LogLevelDebug, LogSeverityDebug, format, v...)
+}
+func (l *sentryLogger) Infof(ctx context.Context, format string, v ...interface{}) {
+	l.log(ctx, LogLevelInfo, LogSeverityInfo, format, v...)
+}
+func (l *sentryLogger) Warnf(ctx context.Context, format string, v ...interface{}) {
+	l.log(ctx, LogLevelWarn, LogSeverityWarning, format, v...)
+}
+func (l *sentryLogger) Errorf(ctx context.Context, format string, v ...interface{}) {
+	l.log(ctx, LogLevelError, LogSeverityError, format, v...)
+}
+func (l *sentryLogger) Fatalf(ctx context.Context, format string, v ...interface{}) {
+	l.log(ctx, LogLevelFatal, LogSeverityFatal, format, v...)
+	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...))
+}
diff --git a/log_fallback.go b/log_fallback.go
new file mode 100644
index 0000000..b9eb706
--- /dev/null
+++ b/log_fallback.go
@@ -0,0 +1,65 @@
+package sentry
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/getsentry/sentry-go/attribute"
+)
+
+// 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)
+}
+func (*noopLogger) Debug(_ context.Context, _ ...interface{}) {
+	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelDebug)
+}
+func (*noopLogger) Info(_ context.Context, _ ...interface{}) {
+	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelInfo)
+}
+func (*noopLogger) Warn(_ context.Context, _ ...interface{}) {
+	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelWarn)
+}
+func (*noopLogger) Error(_ context.Context, _ ...interface{}) {
+	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", LogLevelError)
+}
+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 (*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 (*noopLogger) Tracef(_ context.Context, _ string, _ ...interface{}) {
+	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", 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) Infof(_ context.Context, _ string, _ ...interface{}) {
+	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", 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) Errorf(_ context.Context, _ string, _ ...interface{}) {
+	DebugLogger.Printf("Log with level=[%v] is being dropped. Turn on logging via EnableLogs", 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) 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) 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)
+}
diff --git a/scope.go b/scope.go
index e038747..3c06279 100644
--- a/scope.go
+++ b/scope.go
@@ -302,6 +302,11 @@ func (scope *Scope) SetPropagationContext(propagationContext PropagationContext)
 	scope.propagationContext = propagationContext
 }
 
+// GetSpan returns the span from the current scope.
+func (scope *Scope) GetSpan() *Span {
+	return scope.span
+}
+
 // SetSpan sets a span for the current scope.
 func (scope *Scope) SetSpan(span *Span) {
 	scope.mu.Lock()
diff --git a/tracing.go b/tracing.go
index 52af59a..b292f98 100644
--- a/tracing.go
+++ b/tracing.go
@@ -198,10 +198,6 @@ func StartSpan(ctx context.Context, operation string, options ...SpanOption) *Sp
 	clientOptions := span.clientOptions()
 	if clientOptions.EnableTracing {
 		hub := hubFromContext(ctx)
-		if !span.IsTransaction() {
-			// Push a new scope to stack for non transaction span
-			hub.PushScope()
-		}
 		hub.Scope().SetSpan(&span)
 	}
 
@@ -359,8 +355,9 @@ func (s *Span) doFinish() {
 
 	hub := hubFromContext(s.ctx)
 	if !s.IsTransaction() {
-		// Referenced to StartSpan function that pushes current non-transaction span to scope stack
-		defer hub.PopScope()
+		if s.parent != nil {
+			hub.Scope().SetSpan(s.parent)
+		}
 	}
 
 	if !s.Sampled.Bool() {
diff --git a/transport.go b/transport.go
index 8f815d4..28f7713 100644
--- a/transport.go
+++ b/transport.go
@@ -16,8 +16,10 @@ import (
 	"github.com/getsentry/sentry-go/internal/ratelimit"
 )
 
-const defaultBufferSize = 30
-const defaultTimeout = time.Second * 30
+const (
+	defaultBufferSize = 1000
+	defaultTimeout    = time.Second * 30
+)
 
 // maxDrainResponseBytes is the maximum number of bytes that transport
 // implementations will read from response bodies when draining them.
@@ -144,6 +146,23 @@ func encodeEnvelopeItem(enc *json.Encoder, itemType string, body json.RawMessage
 	return err
 }
 
+func encodeEnvelopeLogs(enc *json.Encoder, itemsLength int, body json.RawMessage) error {
+	err := enc.Encode(
+		struct {
+			Type        string `json:"type"`
+			ItemCount   int    `json:"item_count"`
+			ContentType string `json:"content_type"`
+		}{
+			Type:        logEvent.Type,
+			ItemCount:   itemsLength,
+			ContentType: logEvent.ContentType,
+		})
+	if err == nil {
+		err = enc.Encode(body)
+	}
+	return err
+}
+
 func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMessage) (*bytes.Buffer, error) {
 	var b bytes.Buffer
 	enc := json.NewEncoder(&b)
@@ -180,6 +199,8 @@ func envelopeFromBody(event *Event, dsn *Dsn, sentAt time.Time, body json.RawMes
 	switch event.Type {
 	case transactionType, checkInType:
 		err = encodeEnvelopeItem(enc, event.Type, body)
+	case logEvent.Type:
+		err = encodeEnvelopeLogs(enc, len(event.Logs), body)
 	default:
 		err = encodeEnvelopeItem(enc, eventType, body)
 	}
@@ -627,16 +648,18 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve
 		return
 	}
 
-	var eventType string
+	var eventIdentifier string
 	switch event.Type {
 	case transactionType:
-		eventType = "transaction"
+		eventIdentifier = "transaction"
+	case logEvent.Type:
+		eventIdentifier = fmt.Sprintf("%v log events", len(event.Logs))
 	default:
-		eventType = fmt.Sprintf("%s event", event.Level)
+		eventIdentifier = fmt.Sprintf("%s event", event.Level)
 	}
 	DebugLogger.Printf(
 		"Sending %s [%s] to %s project: %s",
-		eventType,
+		eventIdentifier,
 		event.EventID,
 		t.dsn.host,
 		t.dsn.projectID,
@@ -652,7 +675,7 @@ func (t *HTTPSyncTransport) SendEventWithContext(ctx context.Context, event *Eve
 		if err != nil {
 			DebugLogger.Printf("Error while reading response code: %v", err)
 		}
-		DebugLogger.Printf("Sending %s failed with the following error: %s", eventType, string(b))
+		DebugLogger.Printf("Sending %s failed with the following error: %s", eventIdentifier, string(b))
 	}
 
 	t.mu.Lock()



Test patch:
diff --git a/log_test.go b/log_test.go
new file mode 100644
index 0000000..58852a0
--- /dev/null
+++ b/log_test.go
@@ -0,0 +1,594 @@
+package sentry
+
+import (
+	"bytes"
+	"context"
+	"log"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/getsentry/sentry-go/attribute"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+)
+
+const (
+	LogTraceID = "d49d9bf66f13450b81f65bc51cf49c03"
+)
+
+func setupMockTransport() (context.Context, *MockTransport) {
+	ctx := context.Background()
+	mockTransport := &MockTransport{}
+	mockClient, _ := NewClient(ClientOptions{
+		Dsn:           testDsn,
+		Transport:     mockTransport,
+		Release:       "v1.2.3",
+		Environment:   "testing",
+		ServerName:    "test-server",
+		EnableLogs:    true,
+		EnableTracing: true,
+	})
+	mockClient.sdkIdentifier = "sentry.go"
+	mockClient.sdkVersion = "0.10.0"
+	hub := CurrentHub()
+	hub.BindClient(mockClient)
+	hub.Scope().propagationContext.TraceID = TraceIDFromHex(LogTraceID)
+
+	ctx = SetHubOnContext(ctx, hub)
+	return ctx, mockTransport
+}
+
+func Test_sentryLogger_MethodsWithFormat(t *testing.T) {
+	attrs := map[string]Attribute{
+		"sentry.release":              {Value: "v1.2.3", Type: "string"},
+		"sentry.environment":          {Value: "testing", Type: "string"},
+		"sentry.server.address":       {Value: "test-server", Type: "string"},
+		"sentry.sdk.name":             {Value: "sentry.go", Type: "string"},
+		"sentry.sdk.version":          {Value: "0.10.0", Type: "string"},
+		"sentry.origin":               {Value: "auto.logger.log", Type: "string"},
+		"sentry.message.template":     {Value: "param matching: %v and %v", Type: "string"},
+		"sentry.message.parameters.0": {Value: "param1", Type: "string"},
+		"sentry.message.parameters.1": {Value: "param2", Type: "string"},
+		"key.int":                     {Value: int64(42), Type: "integer"},
+		"key.float":                   {Value: 42.2, Type: "double"},
+		"key.bool":                    {Value: true, Type: "boolean"},
+		"key.string":                  {Value: "str", Type: "string"},
+	}
+
+	tests := []struct {
+		name       string
+		logFunc    func(ctx context.Context, l Logger)
+		message    string
+		args       any
+		wantEvents []Event
+	}{
+		{
+			name: "Trace level",
+			logFunc: func(ctx context.Context, l Logger) {
+				l.Tracef(ctx, "param matching: %v and %v", "param1", "param2")
+			},
+			message: "param matching: %v and %v",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelTrace,
+							Severity:   LogSeverityTrace,
+							Body:       "param matching: param1 and param2",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "Debug level",
+			logFunc: func(ctx context.Context, l Logger) {
+				l.Debugf(ctx, "param matching: %v and %v", "param1", "param2")
+			},
+			message: "param matching: %v and %v",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelDebug,
+							Severity:   LogSeverityDebug,
+							Body:       "param matching: param1 and param2",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "Info level",
+			logFunc: func(ctx context.Context, l Logger) {
+				l.Infof(ctx, "param matching: %v and %v", "param1", "param2")
+			},
+			message: "param matching: %v and %v",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelInfo,
+							Severity:   LogSeverityInfo,
+							Body:       "param matching: param1 and param2",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "Warn level",
+			logFunc: func(ctx context.Context, l Logger) {
+				l.Warnf(ctx, "param matching: %v and %v", "param1", "param2")
+			},
+			message: "param matching: %v and %v",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelWarn,
+							Severity:   LogSeverityWarning,
+							Body:       "param matching: param1 and param2",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "Error level",
+			logFunc: func(ctx context.Context, l Logger) {
+				l.Errorf(ctx, "param matching: %v and %v", "param1", "param2")
+			},
+			message: "param matching: %v and %v",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelError,
+							Severity:   LogSeverityError,
+							Body:       "param matching: param1 and param2",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx, mockTransport := setupMockTransport()
+			l := NewLogger(ctx)
+			l.SetAttributes(
+				attribute.Int("key.int", 42),
+				attribute.Float64("key.float", 42.2),
+				attribute.Bool("key.bool", true),
+				attribute.String("key.string", "str"),
+			)
+			// invalid attribute should be dropped
+			l.SetAttributes(attribute.Builder{Key: "key.invalid", Value: attribute.Value{}})
+			tt.logFunc(ctx, l)
+			Flush(20 * time.Millisecond)
+
+			opts := cmp.Options{
+				cmpopts.IgnoreFields(Log{}, "Timestamp"),
+			}
+
+			gotEvents := mockTransport.Events()
+			if len(gotEvents) != len(tt.wantEvents) {
+				t.Fatalf("expected %d events, got %d", len(tt.wantEvents), len(gotEvents))
+			}
+			for i, event := range gotEvents {
+				assertEqual(t, event.Type, logEvent.Type)
+				if diff := cmp.Diff(tt.wantEvents[i].Logs, event.Logs, opts); diff != "" {
+					t.Errorf("Log mismatch (-want +got):\n%s", diff)
+				}
+				// pop used mock event
+				mockTransport.events = nil
+			}
+		})
+	}
+}
+
+func Test_sentryLogger_MethodsWithoutFormat(t *testing.T) {
+	attrs := map[string]Attribute{
+		"sentry.release":        {Value: "v1.2.3", Type: "string"},
+		"sentry.environment":    {Value: "testing", Type: "string"},
+		"sentry.server.address": {Value: "test-server", Type: "string"},
+		"sentry.sdk.name":       {Value: "sentry.go", Type: "string"},
+		"sentry.sdk.version":    {Value: "0.10.0", Type: "string"},
+		"sentry.origin":         {Value: "auto.logger.log", Type: "string"},
+	}
+
+	tests := []struct {
+		name       string
+		logFunc    func(ctx context.Context, l Logger, msg any)
+		args       any
+		wantEvents []Event
+	}{
+		{
+			name: "Trace level",
+			logFunc: func(ctx context.Context, l Logger, msg any) {
+				l.Trace(ctx, msg)
+			},
+			args: "trace",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelTrace,
+							Severity:   LogSeverityTrace,
+							Body:       "trace",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "Debug level",
+			logFunc: func(ctx context.Context, l Logger, msg any) {
+				l.Debug(ctx, msg)
+			},
+			args: "debug",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelDebug,
+							Severity:   LogSeverityDebug,
+							Body:       "debug",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "Info level",
+			logFunc: func(ctx context.Context, l Logger, msg any) {
+				l.Info(ctx, msg)
+			},
+			args: "info",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelInfo,
+							Severity:   LogSeverityInfo,
+							Body:       "info",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "Warn level",
+			logFunc: func(ctx context.Context, l Logger, msg any) {
+				l.Warn(ctx, msg)
+			},
+			args: "warn",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelWarn,
+							Severity:   LogSeverityWarning,
+							Body:       "warn",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "Error level",
+			logFunc: func(ctx context.Context, l Logger, msg any) {
+				l.Error(ctx, msg)
+			},
+			args: "error",
+			wantEvents: []Event{
+				{
+					Logs: []Log{
+						{
+							TraceID:    TraceIDFromHex(LogTraceID),
+							Level:      LogLevelError,
+							Severity:   LogSeverityError,
+							Body:       "error",
+							Attributes: attrs,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ctx, mockTransport := setupMockTransport()
+			l := NewLogger(ctx)
+			tt.logFunc(ctx, l, tt.args)
+			Flush(20 * time.Millisecond)
+
+			opts := cmp.Options{
+				cmpopts.IgnoreFields(Log{}, "Timestamp"),
+			}
+
+			gotEvents := mockTransport.Events()
+			if len(gotEvents) != len(tt.wantEvents) {
+				t.Fatalf("expected %d events, got %d", len(tt.wantEvents), len(gotEvents))
+			}
+			for i, event := range gotEvents {
+				assertEqual(t, event.Type, logEvent.Type)
+				if diff := cmp.Diff(tt.wantEvents[i].Logs, event.Logs, opts); diff != "" {
+					t.Errorf("Log mismatch (-want +got):\n%s", diff)
+				}
+				// pop used mock event
+				mockTransport.events = nil
+			}
+		})
+	}
+}
+
+func Test_sentryLogger_Panic(t *testing.T) {
+	t.Run("logger.Panic", func(t *testing.T) {
+		defer func() {
+			if r := recover(); r == nil {
+				t.Errorf("expected panic, but code did not panic")
+			} else {
+				t.Logf("recovered panic: %v", r)
+			}
+		}()
+		ctx, _ := setupMockTransport()
+		l := NewLogger(ctx)
+		l.Panic(context.Background(), "panic message") // This should panic
+	})
+
+	t.Run("logger.Panicf", func(t *testing.T) {
+		defer func() {
+			if r := recover(); r == nil {
+				t.Errorf("expected panic, but code did not panic")
+			} else {
+				t.Logf("recovered panic: %v", r)
+			}
+		}()
+		ctx, _ := setupMockTransport()
+		l := NewLogger(ctx)
+		l.Panicf(context.Background(), "panic message") // This should panic
+	})
+}
+
+func Test_sentryLogger_Write(t *testing.T) {
+	msg := []byte("message from writer\n")
+	attrs := map[string]Attribute{
+		"sentry.release":        {Value: "v1.2.3", Type: "string"},
+		"sentry.environment":    {Value: "testing", Type: "string"},
+		"sentry.server.address": {Value: "test-server", Type: "string"},
+		"sentry.sdk.name":       {Value: "sentry.go", Type: "string"},
+		"sentry.sdk.version":    {Value: "0.10.0", Type: "string"},
+		"sentry.origin":         {Value: "auto.logger.log", Type: "string"},
+	}
+	wantLogs := []Log{
+		{
+			TraceID:    TraceIDFromHex(LogTraceID),
+			Level:      LogLevelInfo,
+			Severity:   LogSeverityInfo,
+			Body:       "message from writer",
+			Attributes: attrs,
+		},
+	}
+
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+	n, err := l.Write(msg)
+
+	if err != nil {
+		t.Errorf("Write returned unexpected error: %v", err)
+	}
+	if n != len(msg) {
+		t.Errorf("Write returned wrong byte count: got %d, want %d", n, len(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.Type, logEvent.Type)
+
+	opts := cmp.Options{
+		cmpopts.IgnoreFields(Log{}, "Timestamp"),
+	}
+	if diff := cmp.Diff(wantLogs, event.Logs, opts); diff != "" {
+		t.Errorf("Logs mismatch (-want +got):\n%s", diff)
+	}
+}
+
+func Test_sentryLogger_FlushAttributesAfterSend(t *testing.T) {
+	msg := []byte("something")
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(ctx)
+	l.SetAttributes(attribute.Int("int", 42))
+	l.Info(ctx, msg)
+
+	l.SetAttributes(attribute.String("string", "some str"))
+	l.Warn(ctx, 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["int"].Value, int64(42))
+	if _, ok := event.Logs[1].Attributes["int"]; ok {
+		t.Fatalf("expected key to not exist")
+	}
+	assertEqual(t, event.Logs[1].Attributes["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)
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event, got %d", len(events))
+	}
+}
+
+func Test_sentryLogger_BeforeSendLog(t *testing.T) {
+	ctx := context.Background()
+	mockTransport := &MockTransport{}
+	mockClient, _ := NewClient(ClientOptions{
+		Dsn:           testDsn,
+		Transport:     mockTransport,
+		Release:       "v1.2.3",
+		Environment:   "testing",
+		ServerName:    "test-server",
+		EnableLogs:    true,
+		EnableTracing: true,
+		BeforeSendLog: func(_ *Log) *Log {
+			return nil
+		},
+	})
+	mockClient.sdkIdentifier = "sentry.go"
+	mockClient.sdkVersion = "0.10.0"
+	hub := CurrentHub()
+	hub.BindClient(mockClient)
+	hub.Scope().propagationContext.TraceID = TraceIDFromHex(LogTraceID)
+
+	ctx = SetHubOnContext(ctx, hub)
+
+	l := NewLogger(ctx)
+	l.Info(ctx, "context done log")
+	Flush(20 * time.Millisecond)
+
+	events := mockTransport.Events()
+	if len(events) != 0 {
+		t.Fatalf("expected no events, got %d", len(events))
+	}
+}
+
+func Test_Logger_ExceedBatchSize(t *testing.T) {
+	ctx, mockTransport := setupMockTransport()
+	l := NewLogger(context.Background())
+	for i := 0; i < 100; i++ {
+		l.Info(ctx, "test")
+	}
+
+	// sleep to wait for events to propagate
+	time.Sleep(20 * time.Millisecond)
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected only one event with 100 logs, got %d", len(events))
+	}
+}
+
+func Test_sentryLogger_TracePropagationWithTransaction(t *testing.T) {
+	ctx, mockTransport := setupMockTransport()
+
+	// Start a new transaction
+	txn := StartTransaction(ctx, "test-transaction")
+	defer txn.Finish()
+
+	expectedTraceID := txn.TraceID
+	expectedSpanID := txn.SpanID
+
+	logger := NewLogger(txn.Context())
+	logger.Info(txn.Context(), "message with tracing")
+
+	Flush(20 * time.Millisecond)
+
+	events := mockTransport.Events()
+	if len(events) != 1 {
+		t.Fatalf("expected 1 event, got %d", len(events))
+	}
+	logs := events[0].Logs
+	if len(logs) != 1 {
+		t.Fatalf("expected 1 log, got %d", len(logs))
+	}
+
+	log := logs[0]
+
+	if log.TraceID != expectedTraceID {
+		t.Errorf("unexpected TraceID: got %s, want %s", log.TraceID.String(), expectedTraceID.String())
+	}
+	if val, ok := log.Attributes["sentry.trace.parent_span_id"]; !ok {
+		t.Errorf("missing sentry.trace.parent_span_id attribute")
+	} else if val.Value != expectedSpanID.String() {
+		t.Errorf("unexpected SpanID: got %s, want %s", val.Value, expectedSpanID.String())
+	}
+}
+
+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:          "Debug enabled",
+			debugEnabled:  true,
+			message:       "test message",
+			expectedDebug: "test message\n",
+		},
+		{
+			name:          "Debug disabled",
+			debugEnabled:  false,
+			message:       "test message",
+			expectedDebug: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			buf.Reset()
+			ctx := context.Background()
+			mockClient, _ := NewClient(ClientOptions{
+				Transport:  &MockTransport{},
+				EnableLogs: true,
+				Debug:      tt.debugEnabled,
+			})
+			hub := CurrentHub()
+			hub.BindClient(mockClient)
+
+			logger := NewLogger(ctx)
+			logger.Info(ctx, 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)
+			}
+		})
+	}
+}
diff --git a/scope_test.go b/scope_test.go
index 03878ab..d11cd8e 100644
--- a/scope_test.go
+++ b/scope_test.go
@@ -447,7 +447,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	assertEqual(t, User{ID: "foo"}, clone.user)
 	assertEqual(t, r1, clone.request)
 	assertEqual(t, p1, clone.propagationContext)
-	assertEqual(t, s1, clone.span)
+	assertEqual(t, s1, clone.GetSpan())
 
 	assertEqual(t, map[string]string{"foo": "baz"}, scope.tags)
 	assertEqual(t, map[string]Context{"foo": {"foo": "baz"}}, scope.contexts)
@@ -458,7 +458,7 @@ func TestScopeParentChangedInheritance(t *testing.T) {
 	assertEqual(t, []*Attachment{{Filename: "bar.txt", Payload: []byte("bar")}}, scope.attachments)
 	assertEqual(t, User{ID: "bar"}, scope.user)
 	assertEqual(t, r2, scope.request)
-	assertEqual(t, s2, scope.span)
+	assertEqual(t, s2, scope.GetSpan())
 }
 
 func TestScopeChildOverrideInheritance(t *testing.T) {
@@ -517,7 +517,7 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 	assertEqual(t, User{ID: "foo"}, clone.user)
 	assertEqual(t, r2, clone.request)
 	assertEqual(t, p2, clone.propagationContext)
-	assertEqual(t, s2, clone.span)
+	assertEqual(t, s2, clone.GetSpan())
 
 	assertEqual(t, map[string]string{"foo": "baz"}, scope.tags)
 	assertEqual(t, map[string]Context{"foo": {"foo": "baz"}}, scope.contexts)
@@ -529,7 +529,7 @@ func TestScopeChildOverrideInheritance(t *testing.T) {
 	assertEqual(t, User{ID: "bar"}, scope.user)
 	assertEqual(t, r1, scope.request)
 	assertEqual(t, p1, scope.propagationContext)
-	assertEqual(t, s1, scope.span)
+	assertEqual(t, s1, scope.GetSpan())
 
 	assertEqual(t, len(scope.eventProcessors), 1)
 	assertEqual(t, len(clone.eventProcessors), 2)
@@ -548,7 +548,7 @@ func TestClear(t *testing.T) {
 	assertEqual(t, []string{}, scope.fingerprint)
 	assertEqual(t, Level(""), scope.level)
 	assertEqual(t, (*http.Request)(nil), scope.request)
-	assertEqual(t, (*Span)(nil), scope.span)
+	assertEqual(t, (*Span)(nil), scope.GetSpan())
 }
 
 func TestClearAndReconfigure(t *testing.T) {
@@ -580,7 +580,7 @@ func TestClearAndReconfigure(t *testing.T) {
 	assertEqual(t, User{ID: "foo"}, scope.user)
 	assertEqual(t, r, scope.request)
 	assertEqual(t, p, scope.propagationContext)
-	assertEqual(t, s, scope.span)
+	assertEqual(t, s, scope.GetSpan())
 }
 
 func TestClearBreadcrumbs(t *testing.T) {
diff --git a/transport_test.go b/transport_test.go
index e5fa89f..5204dc6 100644
--- a/transport_test.go
+++ b/transport_test.go
@@ -260,6 +260,45 @@ func TestEnvelopeFromCheckInEvent(t *testing.T) {
 	}
 }
 
+func TestEnvelopeFromLogEvent(t *testing.T) {
+	event := newTestEvent(logEvent.Type)
+	event.Logs = []Log{
+		{
+			TraceID:  TraceIDFromHex(LogTraceID),
+			Level:    LogLevelInfo,
+			Severity: LogSeverityInfo,
+			Body:     "test log message",
+			Attributes: map[string]Attribute{
+				"sentry.release":        {Value: "v1.2.3", Type: "string"},
+				"sentry.environment":    {Value: "testing", Type: "string"},
+				"sentry.server.address": {Value: "test-server", Type: "string"},
+				"sentry.sdk.name":       {Value: "sentry.go", Type: "string"},
+				"sentry.sdk.version":    {Value: "0.0.1", Type: "string"},
+				"sentry.origin":         {Value: "auto.logger.log", Type: "string"},
+				"key.int":               {Value: 42, Type: "integer"},
+				"key.string":            {Value: "str", Type: "string"},
+				"key.float":             {Value: 42.2, Type: "double"},
+				"key.bool":              {Value: true, Type: "boolean"},
+			},
+		},
+	}
+	sentAt := time.Unix(0, 0).UTC()
+
+	body := getRequestBodyFromEvent(event)
+	b, err := envelopeFromBody(event, newTestDSN(t), sentAt, body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	got := b.String()
+	want := `{"event_id":"b81c5be4d31e48959103a1f878a1efcb","sent_at":"1970-01-01T00:00:00Z","dsn":"http://public@example.com/sentry/1","sdk":{"name":"sentry.go","version":"0.0.1"}}
+{"type":"log","item_count":1,"content_type":"application/vnd.sentry.items.log+json"}
+{"event_id":"b81c5be4d31e48959103a1f878a1efcb","sdk":{"name":"sentry.go","version":"0.0.1"},"user":{},"items":[{"timestamp":"0001-01-01T00:00:00Z","trace_id":"d49d9bf66f13450b81f65bc51cf49c03","level":"info","severity_number":9,"body":"test log message","attributes":{"key.bool":{"value":true,"type":"boolean"},"key.float":{"value":42.2,"type":"double"},"key.int":{"value":42,"type":"integer"},"key.string":{"value":"str","type":"string"},"sentry.environment":{"value":"testing","type":"string"},"sentry.origin":{"value":"auto.logger.log","type":"string"},"sentry.release":{"value":"v1.2.3","type":"string"},"sentry.sdk.name":{"value":"sentry.go","type":"string"},"sentry.sdk.version":{"value":"0.0.1","type":"string"},"sentry.server.address":{"value":"test-server","type":"string"}}}]}
+`
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Errorf("Envelope mismatch (-want +got):\n%s", diff)
+	}
+}
+
 func TestGetRequestFromEvent(t *testing.T) {
 	testCases := []struct {
 		testName string