Repository: getsentry/sentry-java
Pull request #4374: [Logs 3] Add options for Logs
Author: login: adinauer
Created: 2025-05-07T08:56:01Z | Merged: 2025-05-13T12:41:39Z

#skip-changelog

## :scroll: Description
<!--- Describe your changes in detail -->


## :bulb: Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->


## :green_heart: How did you test it?


## :pencil: Checklist
<!--- Put an `x` in the boxes that apply -->

- [ ] I added tests to verify the changes.
- [ ] No new PII added or SDK only sends newly added PII if `sendDefaultPII` is enabled.
- [ ] I updated the docs if needed.
- [ ] I updated the wizard if needed.
- [ ] Review from the native team if needed.
- [ ] No breaking change or entry added to the changelog.
- [ ] No breaking change for hybrid SDKs or communicated to hybrid SDKs.


## :crystal_ball: Next steps
- Remove `sampleRate` for Logs again as there's some discussion around whether this is actually useful in its current state where it just randomly drops log events without considering log level or the trace it belongs to.



Diff:
diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt
index c9d57faec51..f16ea57b6f9 100644
--- a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt
+++ b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt
@@ -16,7 +16,7 @@ import io.sentry.ProfilingTraceData
 import io.sentry.Sentry
 import io.sentry.SentryEnvelope
 import io.sentry.SentryEvent
-import io.sentry.SentryLogEvents
+import io.sentry.SentryLogEvent
 import io.sentry.SentryOptions
 import io.sentry.SentryReplayEvent
 import io.sentry.Session
@@ -191,7 +191,7 @@ class SessionTrackingIntegrationTest {
             TODO("Not yet implemented")
         }
 
-        override fun captureLogs(events: SentryLogEvents, scope: IScope?, hint: Hint?) {
+        override fun captureLog(event: SentryLogEvent, scope: IScope?, hint: Hint?) {
             TODO("Not yet implemented")
         }
 
diff --git a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties
index e00d4c855ea..b1df070f41c 100644
--- a/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties
+++ b/sentry-samples/sentry-samples-spring-boot-jakarta/src/main/resources/application.properties
@@ -16,6 +16,7 @@ sentry.enable-backpressure-handling=true
 sentry.enable-spotlight=true
 sentry.enablePrettySerializationOutput=false
 in-app-includes="io.sentry.samples"
+sentry.experimental.logs.enabled=true
 
 # Uncomment and set to true to enable aot compatibility
 # This flag disables all AOP related features (i.e. @SentryTransaction, @SentrySpan)
diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt
index bcc56f3bd99..737ee897398 100644
--- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt
+++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt
@@ -187,7 +187,9 @@ class SentryAutoConfigurationTest {
             "sentry.cron.default-max-runtime=30",
             "sentry.cron.default-timezone=America/New_York",
             "sentry.cron.default-failure-issue-threshold=40",
-            "sentry.cron.default-recovery-threshold=50"
+            "sentry.cron.default-recovery-threshold=50",
+            "sentry.experimental.logs.enabled=true",
+            "sentry.experimental.logs.sample-rate=0.4"
         ).run {
             val options = it.getBean(SentryProperties::class.java)
             assertThat(options.readTimeoutMillis).isEqualTo(10)
@@ -232,6 +234,8 @@ class SentryAutoConfigurationTest {
             assertThat(options.cron!!.defaultTimezone).isEqualTo("America/New_York")
             assertThat(options.cron!!.defaultFailureIssueThreshold).isEqualTo(40L)
             assertThat(options.cron!!.defaultRecoveryThreshold).isEqualTo(50L)
+            assertThat(options.experimental.logs.isEnabled).isEqualTo(true)
+            assertThat(options.experimental.logs.sampleRate).isEqualTo(0.4)
         }
     }
 
diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt
index ddaa51a7646..4a25e8e5254 100644
--- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt
+++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt
@@ -186,7 +186,9 @@ class SentryAutoConfigurationTest {
             "sentry.cron.default-max-runtime=30",
             "sentry.cron.default-timezone=America/New_York",
             "sentry.cron.default-failure-issue-threshold=40",
-            "sentry.cron.default-recovery-threshold=50"
+            "sentry.cron.default-recovery-threshold=50",
+            "sentry.experimental.logs.enabled=true",
+            "sentry.experimental.logs.sample-rate=0.4"
         ).run {
             val options = it.getBean(SentryProperties::class.java)
             assertThat(options.readTimeoutMillis).isEqualTo(10)
@@ -231,6 +233,8 @@ class SentryAutoConfigurationTest {
             assertThat(options.cron!!.defaultTimezone).isEqualTo("America/New_York")
             assertThat(options.cron!!.defaultFailureIssueThreshold).isEqualTo(40L)
             assertThat(options.cron!!.defaultRecoveryThreshold).isEqualTo(50L)
+            assertThat(options.experimental.logs.isEnabled).isEqualTo(true)
+            assertThat(options.experimental.logs.sampleRate).isEqualTo(0.4)
         }
     }
 
diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api
index d1ce5c6df79..7f9226246af 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -464,6 +464,8 @@ public abstract interface class io/sentry/EventProcessor {
 
 public final class io/sentry/ExperimentalOptions {
 	public fun <init> (ZLio/sentry/protocol/SdkVersion;)V
+	public fun getLogs ()Lio/sentry/SentryOptions$Logs;
+	public fun setLogs (Lio/sentry/SentryOptions$Logs;)V
 }
 
 public final class io/sentry/ExternalOptions {
@@ -491,6 +493,7 @@ public final class io/sentry/ExternalOptions {
 	public fun getIgnoredTransactions ()Ljava/util/List;
 	public fun getInAppExcludes ()Ljava/util/List;
 	public fun getInAppIncludes ()Ljava/util/List;
+	public fun getLogsSampleRate ()Ljava/lang/Double;
 	public fun getMaxRequestBodySize ()Lio/sentry/SentryOptions$RequestSize;
 	public fun getPrintUncaughtStackTrace ()Ljava/lang/Boolean;
 	public fun getProfilesSampleRate ()Ljava/lang/Double;
@@ -505,6 +508,7 @@ public final class io/sentry/ExternalOptions {
 	public fun getTracesSampleRate ()Ljava/lang/Double;
 	public fun isCaptureOpenTelemetryEvents ()Ljava/lang/Boolean;
 	public fun isEnableBackpressureHandling ()Ljava/lang/Boolean;
+	public fun isEnableLogs ()Ljava/lang/Boolean;
 	public fun isEnablePrettySerializationOutput ()Ljava/lang/Boolean;
 	public fun isEnableSpotlight ()Ljava/lang/Boolean;
 	public fun isEnabled ()Ljava/lang/Boolean;
@@ -519,6 +523,7 @@ public final class io/sentry/ExternalOptions {
 	public fun setDsn (Ljava/lang/String;)V
 	public fun setEnableBackpressureHandling (Ljava/lang/Boolean;)V
 	public fun setEnableDeduplication (Ljava/lang/Boolean;)V
+	public fun setEnableLogs (Ljava/lang/Boolean;)V
 	public fun setEnablePrettySerializationOutput (Ljava/lang/Boolean;)V
 	public fun setEnableSpotlight (Ljava/lang/Boolean;)V
 	public fun setEnableUncaughtExceptionHandler (Ljava/lang/Boolean;)V
@@ -530,6 +535,7 @@ public final class io/sentry/ExternalOptions {
 	public fun setIgnoredCheckIns (Ljava/util/List;)V
 	public fun setIgnoredErrors (Ljava/util/List;)V
 	public fun setIgnoredTransactions (Ljava/util/List;)V
+	public fun setLogsSampleRate (Ljava/lang/Double;)V
 	public fun setMaxRequestBodySize (Lio/sentry/SentryOptions$RequestSize;)V
 	public fun setPrintUncaughtStackTrace (Ljava/lang/Boolean;)V
 	public fun setProfilesSampleRate (Ljava/lang/Double;)V
@@ -1001,7 +1007,7 @@ public abstract interface class io/sentry/ISentryClient {
 	public fun captureException (Ljava/lang/Throwable;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId;
 	public fun captureException (Ljava/lang/Throwable;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId;
 	public abstract fun captureFeedback (Lio/sentry/protocol/Feedback;Lio/sentry/Hint;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId;
-	public abstract fun captureLogs (Lio/sentry/SentryLogEvents;Lio/sentry/IScope;Lio/sentry/Hint;)V
+	public abstract fun captureLog (Lio/sentry/SentryLogEvent;Lio/sentry/IScope;Lio/sentry/Hint;)V
 	public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId;
 	public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId;
 	public abstract fun captureProfileChunk (Lio/sentry/ProfileChunk;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId;
@@ -2734,7 +2740,7 @@ public final class io/sentry/SentryClient : io/sentry/ISentryClient {
 	public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId;
 	public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId;
 	public fun captureFeedback (Lio/sentry/protocol/Feedback;Lio/sentry/Hint;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId;
-	public fun captureLogs (Lio/sentry/SentryLogEvents;Lio/sentry/IScope;Lio/sentry/Hint;)V
+	public fun captureLog (Lio/sentry/SentryLogEvent;Lio/sentry/IScope;Lio/sentry/Hint;)V
 	public fun captureProfileChunk (Lio/sentry/ProfileChunk;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId;
 	public fun captureReplayEvent (Lio/sentry/SentryReplayEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId;
 	public fun captureSession (Lio/sentry/Session;Lio/sentry/Hint;)V
@@ -3033,14 +3039,16 @@ public final class io/sentry/SentryLockReason$JsonKeys {
 }
 
 public final class io/sentry/SentryLogEvent : io/sentry/JsonSerializable, io/sentry/JsonUnknown {
-	public fun <init> (Lio/sentry/protocol/SentryId;Lio/sentry/SentryDate;Ljava/lang/String;)V
-	public fun <init> (Lio/sentry/protocol/SentryId;Ljava/lang/Double;Ljava/lang/String;)V
+	public fun <init> (Lio/sentry/protocol/SentryId;Lio/sentry/SentryDate;Ljava/lang/String;Lio/sentry/SentryLevel;)V
+	public fun <init> (Lio/sentry/protocol/SentryId;Ljava/lang/Double;Ljava/lang/String;Lio/sentry/SentryLevel;)V
 	public fun getAttributes ()Ljava/util/Map;
+	public fun getBody ()Ljava/lang/String;
 	public fun getLevel ()Lio/sentry/SentryLevel;
 	public fun getTimestamp ()Ljava/lang/Double;
 	public fun getUnknown ()Ljava/util/Map;
 	public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V
 	public fun setAttributes (Ljava/util/Map;)V
+	public fun setBody (Ljava/lang/String;)V
 	public fun setLevel (Lio/sentry/SentryLevel;)V
 	public fun setTimestamp (Ljava/lang/Double;)V
 	public fun setUnknown (Ljava/util/Map;)V
@@ -3423,6 +3431,20 @@ public final class io/sentry/SentryOptions$Cron {
 	public fun setDefaultTimezone (Ljava/lang/String;)V
 }
 
+public final class io/sentry/SentryOptions$Logs {
+	public fun <init> ()V
+	public fun getBeforeSend ()Lio/sentry/SentryOptions$Logs$BeforeSendLogCallback;
+	public fun getSampleRate ()Ljava/lang/Double;
+	public fun isEnabled ()Z
+	public fun setBeforeSend (Lio/sentry/SentryOptions$Logs$BeforeSendLogCallback;)V
+	public fun setEnabled (Z)V
+	public fun setSampleRate (Ljava/lang/Double;)V
+}
+
+public abstract interface class io/sentry/SentryOptions$Logs$BeforeSendLogCallback {
+	public abstract fun execute (Lio/sentry/SentryLogEvent;Lio/sentry/Hint;)Lio/sentry/SentryLogEvent;
+}
+
 public abstract interface class io/sentry/SentryOptions$ProfilesSamplerCallback {
 	public abstract fun sample (Lio/sentry/SamplingContext;)Ljava/lang/Double;
 }
diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java
index 80d59d4f01b..7a525db3a2f 100644
--- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java
+++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java
@@ -1,6 +1,8 @@
 package io.sentry;
 
 import io.sentry.protocol.SdkVersion;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 /**
@@ -10,6 +12,17 @@
  * <p>Beware that experimental options can change at any time.
  */
 public final class ExperimentalOptions {
+  private @NotNull SentryOptions.Logs logs = new SentryOptions.Logs();
 
   public ExperimentalOptions(final boolean empty, final @Nullable SdkVersion sdkVersion) {}
+
+  @ApiStatus.Experimental
+  public @NotNull SentryOptions.Logs getLogs() {
+    return logs;
+  }
+
+  @ApiStatus.Experimental
+  public void setLogs(@NotNull SentryOptions.Logs logs) {
+    this.logs = logs;
+  }
 }
diff --git a/sentry/src/main/java/io/sentry/ExternalOptions.java b/sentry/src/main/java/io/sentry/ExternalOptions.java
index 15907342664..75711efbce6 100644
--- a/sentry/src/main/java/io/sentry/ExternalOptions.java
+++ b/sentry/src/main/java/io/sentry/ExternalOptions.java
@@ -25,6 +25,7 @@ public final class ExternalOptions {
   private @Nullable Boolean enableDeduplication;
   private @Nullable Double tracesSampleRate;
   private @Nullable Double profilesSampleRate;
+  private @Nullable Double logsSampleRate;
   private @Nullable SentryOptions.RequestSize maxRequestBodySize;
   private final @NotNull Map<String, @NotNull String> tags = new ConcurrentHashMap<>();
   private @Nullable SentryOptions.Proxy proxy;
@@ -43,6 +44,7 @@ public final class ExternalOptions {
   private @Nullable Boolean enabled;
   private @Nullable Boolean enablePrettySerializationOutput;
   private @Nullable Boolean enableSpotlight;
+  private @Nullable Boolean enableLogs;
   private @Nullable String spotlightConnectionUrl;
 
   private @Nullable List<String> ignoredCheckIns;
@@ -150,6 +152,9 @@ public final class ExternalOptions {
     options.setCaptureOpenTelemetryEvents(
         propertiesProvider.getBooleanProperty("capture-open-telemetry-events"));
 
+    options.setEnableLogs(propertiesProvider.getBooleanProperty("logs.enabled"));
+    options.setLogsSampleRate(propertiesProvider.getDoubleProperty("logs.sample-rate"));
+
     for (final String ignoredExceptionType :
         propertiesProvider.getList("ignored-exceptions-for-type")) {
       try {
@@ -518,4 +523,24 @@ public void setCaptureOpenTelemetryEvents(final @Nullable Boolean captureOpenTel
   public @Nullable Boolean isCaptureOpenTelemetryEvents() {
     return captureOpenTelemetryEvents;
   }
+
+  @ApiStatus.Experimental
+  public void setEnableLogs(final @Nullable Boolean enableLogs) {
+    this.enableLogs = enableLogs;
+  }
+
+  @ApiStatus.Experimental
+  public @Nullable Boolean isEnableLogs() {
+    return enableLogs;
+  }
+
+  @ApiStatus.Experimental
+  public @Nullable Double getLogsSampleRate() {
+    return logsSampleRate;
+  }
+
+  @ApiStatus.Experimental
+  public void setLogsSampleRate(final @Nullable Double logsSampleRate) {
+    this.logsSampleRate = logsSampleRate;
+  }
 }
diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java
index b853bdbb972..c2011f3e38e 100644
--- a/sentry/src/main/java/io/sentry/ISentryClient.java
+++ b/sentry/src/main/java/io/sentry/ISentryClient.java
@@ -305,7 +305,7 @@ SentryId captureProfileChunk(
   SentryId captureCheckIn(@NotNull CheckIn checkIn, @Nullable IScope scope, @Nullable Hint hint);
 
   @ApiStatus.Experimental
-  void captureLogs(@NotNull SentryLogEvents logEvents, @Nullable IScope scope, @Nullable Hint hint);
+  void captureLog(@NotNull SentryLogEvent logEvent, @Nullable IScope scope, @Nullable Hint hint);
 
   @ApiStatus.Internal
   @Nullable
diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java
index c758ee3510f..83b8d129f43 100644
--- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java
+++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java
@@ -86,8 +86,8 @@ public SentryId captureEnvelope(@NotNull SentryEnvelope envelope, @Nullable Hint
 
   @ApiStatus.Experimental
   @Override
-  public void captureLogs(
-      @NotNull SentryLogEvents logEvents, @Nullable IScope scope, @Nullable Hint hint) {
+  public void captureLog(
+      @NotNull SentryLogEvent logEvent, @Nullable IScope scope, @Nullable Hint hint) {
     // do nothing
   }
 
diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java
index 03fe5ee609d..aa572d81457 100644
--- a/sentry/src/main/java/io/sentry/SentryClient.java
+++ b/sentry/src/main/java/io/sentry/SentryClient.java
@@ -17,6 +17,7 @@
 import java.io.Closeable;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
@@ -1124,28 +1125,42 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint
 
   @ApiStatus.Experimental
   @Override
-  public void captureLogs(
-      @NotNull SentryLogEvents logEvents, @Nullable IScope scope, @Nullable Hint hint) {
+  public void captureLog(
+      @Nullable SentryLogEvent logEvent, @Nullable IScope scope, @Nullable Hint hint) {
     if (hint == null) {
       hint = new Hint();
     }
 
-    try {
-      @Nullable TraceContext traceContext = null;
-      if (scope != null) {
-        final @Nullable ITransaction transaction = scope.getTransaction();
-        if (transaction != null) {
-          traceContext = transaction.traceContext();
-        } else {
-          final @NotNull PropagationContext propagationContext =
-              TracingUtils.maybeUpdateBaggage(scope, options);
-          traceContext = propagationContext.traceContext();
-        }
+    @Nullable TraceContext traceContext = null;
+    if (scope != null) {
+      final @Nullable ITransaction transaction = scope.getTransaction();
+      if (transaction != null) {
+        traceContext = transaction.traceContext();
+      } else {
+        final @NotNull PropagationContext propagationContext =
+            TracingUtils.maybeUpdateBaggage(scope, options);
+        traceContext = propagationContext.traceContext();
+      }
+    }
+
+    if (logEvent != null) {
+      logEvent = executeBeforeSendLog(logEvent, hint);
+
+      if (logEvent == null) {
+        options.getLogger().log(SentryLevel.DEBUG, "Log Event was dropped by beforeSendLog");
+        options
+            .getClientReportRecorder()
+            .recordLostEvent(DiscardReason.BEFORE_SEND, DataCategory.LogItem);
+        return;
       }
+    }
 
-      final @NotNull SentryEnvelope envelope = buildEnvelope(logEvents, traceContext);
+    try {
+      final @NotNull SentryEnvelope envelope =
+          buildEnvelope(new SentryLogEvents(Arrays.asList(logEvent)), traceContext);
 
       hint.clear();
+      // TODO buffer
       sendEnvelope(envelope, hint);
     } catch (IOException e) {
       options.getLogger().log(SentryLevel.WARNING, e, "Capturing log failed.");
@@ -1428,6 +1443,28 @@ private void sortBreadcrumbsByDate(
     return event;
   }
 
+  private @Nullable SentryLogEvent executeBeforeSendLog(
+      @NotNull SentryLogEvent event, final @NotNull Hint hint) {
+    final SentryOptions.Logs.BeforeSendLogCallback beforeSendLog =
+        options.getExperimental().getLogs().getBeforeSend();
+    if (beforeSendLog != null) {
+      try {
+        event = beforeSendLog.execute(event, hint);
+      } catch (Throwable e) {
+        options
+            .getLogger()
+            .log(
+                SentryLevel.ERROR,
+                "The BeforeSendLog callback threw an exception. Dropping log event.",
+                e);
+
+        // drop event in case of an error in beforeSendLog due to PII concerns
+        event = null;
+      }
+    }
+    return event;
+  }
+
   @Override
   public void close() {
     close(false);
diff --git a/sentry/src/main/java/io/sentry/SentryLogEvent.java b/sentry/src/main/java/io/sentry/SentryLogEvent.java
index 512131dac90..a5af0d46905 100644
--- a/sentry/src/main/java/io/sentry/SentryLogEvent.java
+++ b/sentry/src/main/java/io/sentry/SentryLogEvent.java
@@ -14,27 +14,29 @@ public final class SentryLogEvent implements JsonUnknown, JsonSerializable {
 
   private @NotNull SentryId traceId;
   private @NotNull Double timestamp;
-
   private @NotNull String body;
+  private @NotNull SentryLevel level;
 
-  private @Nullable SentryLevel level;
   private @Nullable Map<String, SentryLogEventAttributeValue> attributes;
   private @Nullable Map<String, Object> unknown;
 
   public SentryLogEvent(
       final @NotNull SentryId traceId,
       final @NotNull SentryDate timestamp,
-      final @NotNull String body) {
-    this(traceId, DateUtils.nanosToSeconds(timestamp.nanoTimestamp()), body);
+      final @NotNull String body,
+      final @NotNull SentryLevel level) {
+    this(traceId, DateUtils.nanosToSeconds(timestamp.nanoTimestamp()), body, level);
   }
 
   public SentryLogEvent(
       final @NotNull SentryId traceId,
       final @NotNull Double timestamp,
-      final @NotNull String body) {
+      final @NotNull String body,
+      final @NotNull SentryLevel level) {
     this.traceId = traceId;
     this.timestamp = timestamp;
     this.body = body;
+    this.level = level;
   }
 
   @NotNull
@@ -46,11 +48,19 @@ public void setTimestamp(final @NotNull Double timestamp) {
     this.timestamp = timestamp;
   }
 
-  public @Nullable SentryLevel getLevel() {
+  public @NotNull String getBody() {
+    return body;
+  }
+
+  public void setBody(@NotNull String body) {
+    this.body = body;
+  }
+
+  public @NotNull SentryLevel getLevel() {
     return level;
   }
 
-  public void setLevel(final @Nullable SentryLevel level) {
+  public void setLevel(final @NotNull SentryLevel level) {
     this.level = level;
   }
 
@@ -79,9 +89,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger
     writer.name(JsonKeys.TIMESTAMP).value(logger, doubleToBigDecimal(timestamp));
     writer.name(JsonKeys.TRACE_ID).value(logger, traceId);
     writer.name(JsonKeys.BODY).value(body);
-    if (level != null) {
-      writer.name(JsonKeys.LEVEL).value(logger, level);
-    }
+    writer.name(JsonKeys.LEVEL).value(logger, level);
     if (attributes != null) {
       writer.name(JsonKeys.ATTRIBUTES).value(logger, attributes);
     }
@@ -169,9 +177,15 @@ public static final class Deserializer implements JsonDeserializer<SentryLogEven
         throw exception;
       }
 
-      final SentryLogEvent logEvent = new SentryLogEvent(traceId, timestamp, body);
+      if (level == null) {
+        String message = "Missing required field \"" + JsonKeys.LEVEL + "\"";
+        Exception exception = new IllegalStateException(message);
+        logger.log(SentryLevel.ERROR, message, exception);
+        throw exception;
+      }
+
+      final SentryLogEvent logEvent = new SentryLogEvent(traceId, timestamp, body, level);
 
-      logEvent.setLevel(level);
       logEvent.setAttributes(attributes);
       logEvent.setUnknown(unknown);
 
diff --git a/sentry/src/main/java/io/sentry/SentryLogEvents.java b/sentry/src/main/java/io/sentry/SentryLogEvents.java
index 28202015e66..06a1f04ed3b 100644
--- a/sentry/src/main/java/io/sentry/SentryLogEvents.java
+++ b/sentry/src/main/java/io/sentry/SentryLogEvents.java
@@ -27,7 +27,16 @@ public static final class JsonKeys {
   public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger)
       throws IOException {
     writer.beginObject();
+
     writer.name("items").value(logger, items);
+
+    if (unknown != null) {
+      for (String key : unknown.keySet()) {
+        Object value = unknown.get(key);
+        writer.name(key).value(logger, value);
+      }
+    }
+
     writer.endObject();
   }
 
diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java
index 801f46c8c1f..98f80f54475 100644
--- a/sentry/src/main/java/io/sentry/SentryOptions.java
+++ b/sentry/src/main/java/io/sentry/SentryOptions.java
@@ -3201,6 +3201,14 @@ public void merge(final @NotNull ExternalOptions options) {
         }
       }
     }
+
+    if (options.isEnableLogs() != null) {
+      getExperimental().getLogs().setEnabled(options.isEnableLogs());
+    }
+
+    if (options.getLogsSampleRate() != null) {
+      getExperimental().getLogs().setSampleRate(options.getLogsSampleRate());
+    }
   }
 
   private @NotNull SdkVersion createSdkVersion() {
@@ -3356,6 +3364,105 @@ public void setDefaultRecoveryThreshold(@Nullable Long defaultRecoveryThreshold)
     }
   }
 
+  public static final class Logs {
+
+    /** Whether Sentry Logs feature is enabled and Sentry.logger() usages are sent to Sentry. */
+    @ApiStatus.Experimental private boolean enable = false;
+
+    /**
+     * Configures the sample rate as a percentage of log items to be sent in the range of 0.0 to
+     * 1.0. if 1.0 is set it means that 100% of items are sent. If set to 0.1 only 10% of items will
+     * be sent. Items are picked randomly. Default is 1.0
+     */
+    @ApiStatus.Experimental private @Nullable Double sampleRate = 1.0;
+
+    /**
+     * This function is called with an SDK specific log event object and can return a modified event
+     * object or nothing to skip reporting the log item
+     */
+    @ApiStatus.Experimental private @Nullable BeforeSendLogCallback beforeSend;
+
+    /**
+     * Whether Sentry Logs feature is enabled and Sentry.logger() usages are sent to Sentry.
+     *
+     * @return true if Sentry Logs should be enabled
+     */
+    @ApiStatus.Experimental
+    public boolean isEnabled() {
+      return enable;
+    }
+
+    /**
+     * Whether Sentry Logs feature is enabled and Sentry.logger() usages are sent to Sentry.
+     *
+     * @param enableLogs true if Sentry Logs should be enabled
+     */
+    @ApiStatus.Experimental
+    public void setEnabled(boolean enableLogs) {
+      this.enable = enableLogs;
+    }
+
+    /**
+     * Returns the BeforeSendLog callback
+     *
+     * @return the beforeSendLog callback or null if not set
+     */
+    @ApiStatus.Experimental
+    public @Nullable BeforeSendLogCallback getBeforeSend() {
+      return beforeSend;
+    }
+
+    /**
+     * Sets the beforeSendLog callback
+     *
+     * @param beforeSendLog the beforeSendLog callback
+     */
+    @ApiStatus.Experimental
+    public void setBeforeSend(@Nullable BeforeSendLogCallback beforeSendLog) {
+      this.beforeSend = beforeSendLog;
+    }
+
+    /**
+     * Returns the logs sample rate Default is null (disabled)
+     *
+     * @return the logs sample rate
+     */
+    @ApiStatus.Experimental
+    public @Nullable Double getSampleRate() {
+      return sampleRate;
+    }
+
+    /**
+     * Sets the logsSampleRate Can be anything between 0.0 and 1.0 (default) or null to disable it.
+     *
+     * @param logsSampleRate the sample rate for log events
+     */
+    @ApiStatus.Experimental
+    public void setSampleRate(Double logsSampleRate) {
+      if (!SampleRateUtils.isValidSampleRate(logsSampleRate)) {
+        throw new IllegalArgumentException(
+            "The value "
+                + logsSampleRate
+                + " is not valid. Use null to disable or values >= 0.0 and <= 1.0.");
+      }
+      this.sampleRate = logsSampleRate;
+    }
+
+    /** The BeforeSendLog callback */
+    public interface BeforeSendLogCallback {
+
+      /**
+       * Mutates or drop a log event before being sent
+       *
+       * @param event the event
+       * @param hint the hints
+       * @return the original log event or the mutated event or null if event was dropped
+       */
+      @Nullable
+      SentryLogEvent execute(@NotNull SentryLogEvent event, @NotNull Hint hint);
+    }
+  }
+
   public enum RequestSize {
     NONE,
     SMALL,
diff --git a/sentry/src/main/java/io/sentry/logger/LoggerApi.java b/sentry/src/main/java/io/sentry/logger/LoggerApi.java
index 21147151787..b1a9eee5622 100644
--- a/sentry/src/main/java/io/sentry/logger/LoggerApi.java
+++ b/sentry/src/main/java/io/sentry/logger/LoggerApi.java
@@ -1,5 +1,6 @@
 package io.sentry.logger;
 
+import io.sentry.DataCategory;
 import io.sentry.Hint;
 import io.sentry.ISpan;
 import io.sentry.PropagationContext;
@@ -8,10 +9,13 @@
 import io.sentry.SentryLevel;
 import io.sentry.SentryLogEvent;
 import io.sentry.SentryLogEventAttributeValue;
-import io.sentry.SentryLogEvents;
+import io.sentry.SentryOptions;
 import io.sentry.SpanId;
+import io.sentry.clientreport.DiscardReason;
+import io.sentry.protocol.SdkVersion;
 import io.sentry.protocol.SentryId;
-import java.util.Arrays;
+import io.sentry.util.Random;
+import io.sentry.util.SentryRandom;
 import java.util.HashMap;
 import org.jetbrains.annotations.ApiStatus;
 import org.jetbrains.annotations.NotNull;
@@ -82,21 +86,38 @@ private void captureLog(
       final @Nullable Hint hint,
       final @Nullable String message,
       final @Nullable Object... args) {
+    final @NotNull SentryOptions options = scopes.getOptions();
     try {
       if (!scopes.isEnabled()) {
-        scopes
-            .getOptions()
+        options
             .getLogger()
             .log(SentryLevel.WARNING, "Instance is disabled and this 'logger' call is a no-op.");
         return;
       }
 
+      if (!options.getExperimental().getLogs().isEnabled()) {
+        options
+            .getLogger()
+            .log(SentryLevel.WARNING, "Sentry Log is disabled and this 'logger' call is a no-op.");
+        return;
+      }
+
       if (message == null) {
         return;
       }
 
+      if (!sampleLog(options)) {
+        options
+            .getLogger()
+            .log(SentryLevel.DEBUG, "Log Event was dropped due to sampling decision.");
+        options
+            .getClientReportRecorder()
+            .recordLostEvent(DiscardReason.SAMPLE_RATE, DataCategory.LogItem);
+        return;
+      }
+
       final @NotNull SentryDate timestampToUse =
-          timestamp == null ? scopes.getOptions().getDateProvider().now() : timestamp;
+          timestamp == null ? options.getDateProvider().now() : timestamp;
       final @NotNull String messageToUse = args == null ? message : String.format(message, args);
       final @NotNull PropagationContext propagationContext =
           scopes.getCombinedScopeView().getPropagationContext();
@@ -105,17 +126,24 @@ private void captureLog(
           span == null ? propagationContext.getTraceId() : span.getSpanContext().getTraceId();
       final @NotNull SpanId spanId =
           span == null ? propagationContext.getSpanId() : span.getSpanContext().getSpanId();
-      final SentryLogEvent logEvent = new SentryLogEvent(traceId, timestampToUse, messageToUse);
-      logEvent.setLevel(level);
+      final SentryLogEvent logEvent =
+          new SentryLogEvent(traceId, timestampToUse, messageToUse, level);
       logEvent.setAttributes(createAttributes(message, spanId, args));
 
-      final SentryLogEvents logEvents = new SentryLogEvents(Arrays.asList(logEvent));
-
-      // TODO buffer
-      scopes.getClient().captureLogs(logEvents, scopes.getCombinedScopeView(), hint);
+      scopes.getClient().captureLog(logEvent, scopes.getCombinedScopeView(), hint);
     } catch (Throwable e) {
-      scopes.getOptions().getLogger().log(SentryLevel.ERROR, "Error while capturing log event", e);
+      options.getLogger().log(SentryLevel.ERROR, "Error while capturing log event", e);
+    }
+  }
+
+  private boolean sampleLog(final @NotNull SentryOptions options) {
+    final @Nullable Random random =
+        options.getExperimental().getLogs().getSampleRate() == null ? null : SentryRandom.current();
+    if (options.getExperimental().getLogs().getSampleRate() != null && random != null) {
+      final double sampling = options.getExperimental().getLogs().getSampleRate();
+      return !(sampling < random.nextDouble()); // bad luck
     }
+    return false;
   }
 
   private @NotNull HashMap<String, SentryLogEventAttributeValue> createAttributes(
@@ -128,11 +156,20 @@ private void captureLog(
       for (Object arg : args) {
         final @NotNull String type = getType(arg);
         attributes.put(
-            "sentry.message.parameters." + i, new SentryLogEventAttributeValue(type, arg));
+            "sentry.message.parameter." + i, new SentryLogEventAttributeValue(type, arg));
         i++;
       }
     }
 
+    final @Nullable SdkVersion sdkVersion = scopes.getOptions().getSdkVersion();
+    if (sdkVersion != null) {
+      attributes.put(
+          "sentry.sdk.name", new SentryLogEventAttributeValue("string", sdkVersion.getName()));
+      attributes.put(
+          "sentry.sdk.version",
+          new SentryLogEventAttributeValue("string", sdkVersion.getVersion()));
+    }
+
     final @Nullable String environment = scopes.getOptions().getEnvironment();
     if (environment != null) {
       attributes.put("sentry.environment", new SentryLogEventAttributeValue("string", environment));
diff --git a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt
index dbf0001d1c1..e93eec4b261 100644
--- a/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt
+++ b/sentry/src/test/java/io/sentry/ExternalOptionsTest.kt
@@ -375,6 +375,20 @@ class ExternalOptionsTest {
         }
     }
 
+    @Test
+    fun `creates options with enableLogs set to true`() {
+        withPropertiesFile("logs.enabled=true") { options ->
+            assertTrue(options.isEnableLogs == true)
+        }
+    }
+
+    @Test
+    fun `creates options with logsSampleRate using external properties`() {
+        withPropertiesFile("logs.sample-rate=0.2") {
+            assertEquals(0.2, it.logsSampleRate)
+        }
+    }
+
     private fun withPropertiesFile(textLines: List<String> = emptyList(), logger: ILogger = mock(), fn: (ExternalOptions) -> Unit) {
         // create a sentry.properties file in temporary folder
         val temporaryFolder = TemporaryFolder()
diff --git a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt
index b777fe3af77..9e5dad34ef6 100644
--- a/sentry/src/test/java/io/sentry/SentryOptionsTest.kt
+++ b/sentry/src/test/java/io/sentry/SentryOptionsTest.kt
@@ -414,6 +414,8 @@ class SentryOptionsTest {
         externalOptions.isEnableSpotlight = true
         externalOptions.spotlightConnectionUrl = "http://local.sentry.io:1234"
         externalOptions.isGlobalHubMode = true
+        externalOptions.isEnableLogs = true
+        externalOptions.logsSampleRate = 0.6
 
         val options = SentryOptions()
 
@@ -458,6 +460,8 @@ class SentryOptionsTest {
         assertTrue(options.isEnableSpotlight)
         assertEquals("http://local.sentry.io:1234", options.spotlightConnectionUrl)
         assertTrue(options.isGlobalHubMode!!)
+        assertTrue(options.experimental.logs.isEnabled!!)
+        assertEquals(0.6, options.experimental.logs.sampleRate)
     }
 
     @Test
diff --git a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt
index 75b3e9cc92d..abf7f20deca 100644
--- a/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt
+++ b/sentry/src/test/java/io/sentry/transport/RateLimiterTest.kt
@@ -16,6 +16,7 @@ import io.sentry.SentryEnvelope
 import io.sentry.SentryEnvelopeHeader
 import io.sentry.SentryEnvelopeItem
 import io.sentry.SentryEvent
+import io.sentry.SentryLevel
 import io.sentry.SentryLogEvent
 import io.sentry.SentryLogEvents
 import io.sentry.SentryLongDate
@@ -353,7 +354,7 @@ class RateLimiterTest {
             fixture.serializer,
             SentryLogEvents(
                 listOf(
-                    SentryLogEvent(SentryId(), SentryLongDate(0), "hello")
+                    SentryLogEvent(SentryId(), SentryLongDate(0), "hello", SentryLevel.INFO)
                 )
             )
         )


Reviews:
user: login: lcian
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-07T17:08:47Z
user: login: lbloder
body: LGTM 👍 
Left some comments to be tackled in a later pr
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-09T08:38:54Z
user: login: adinauer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T13:43:54Z
user: login: adinauer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T13:44:08Z
user: login: adinauer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T13:44:21Z
user: login: stefanosiano
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T15:56:57Z
user: login: stefanosiano
body: just add a test, but it's good!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T15:57:16Z

Review comments:
diff_hunk: @@ -150,6 +152,9 @@ public final class ExternalOptions {
     options.setCaptureOpenTelemetryEvents(
         propertiesProvider.getBooleanProperty("capture-open-telemetry-events"));
 
+    options.setEnableLogs(propertiesProvider.getBooleanProperty("logs.enabled"));
path: sentry/src/main/java/io/sentry/ExternalOptions.java
user: login: lbloder
body: could also be experimental
created_at: 2025-05-09T08:27:14Z
author_association: COLLABORATOR
diff_hunk: @@ -25,6 +25,7 @@ public final class ExternalOptions {
   private @Nullable Boolean enableDeduplication;
   private @Nullable Double tracesSampleRate;
   private @Nullable Double profilesSampleRate;
+  private @Nullable Double logsSampleRate;
path: sentry/src/main/java/io/sentry/ExternalOptions.java
user: login: lbloder
body: to be removed as we don't sample logs
created_at: 2025-05-09T08:28:06Z
author_association: COLLABORATOR
diff_hunk: @@ -3332,6 +3340,105 @@ public void setDefaultRecoveryThreshold(@Nullable Long defaultRecoveryThreshold)
     }
   }
 
+  public static final class Logs {
+
+    /** Whether Sentry Logs feature is enabled and Sentry.logger() usages are sent to Sentry. */
+    @ApiStatus.Experimental private boolean enable = false;
+
+    /**
+     * Configures the sample rate as a percentage of log items to be sent in the range of 0.0 to
+     * 1.0. if 1.0 is set it means that 100% of items are sent. If set to 0.1 only 10% of items will
+     * be sent. Items are picked randomly. Default is 1.0
+     */
+    @ApiStatus.Experimental private @Nullable Double sampleRate = 1.0;
path: sentry/src/main/java/io/sentry/SentryOptions.java
user: login: lbloder
body: to be removed
created_at: 2025-05-09T08:35:57Z
author_association: COLLABORATOR
diff_hunk: @@ -150,6 +152,9 @@ public final class ExternalOptions {
     options.setCaptureOpenTelemetryEvents(
         propertiesProvider.getBooleanProperty("capture-open-telemetry-events"));
 
+    options.setEnableLogs(propertiesProvider.getBooleanProperty("logs.enabled"));
path: sentry/src/main/java/io/sentry/ExternalOptions.java
user: login: adinauer
body: We decided to not namespace options with `experimental`, see https://github.com/getsentry/sentry-java/pull/4390
created_at: 2025-05-12T13:43:54Z
author_association: MEMBER
diff_hunk: @@ -25,6 +25,7 @@ public final class ExternalOptions {
   private @Nullable Boolean enableDeduplication;
   private @Nullable Double tracesSampleRate;
   private @Nullable Double profilesSampleRate;
+  private @Nullable Double logsSampleRate;
path: sentry/src/main/java/io/sentry/ExternalOptions.java
user: login: adinauer
body: Removed in https://github.com/getsentry/sentry-java/pull/4389
created_at: 2025-05-12T13:44:08Z
author_association: MEMBER
diff_hunk: @@ -3332,6 +3340,105 @@ public void setDefaultRecoveryThreshold(@Nullable Long defaultRecoveryThreshold)
     }
   }
 
+  public static final class Logs {
+
+    /** Whether Sentry Logs feature is enabled and Sentry.logger() usages are sent to Sentry. */
+    @ApiStatus.Experimental private boolean enable = false;
+
+    /**
+     * Configures the sample rate as a percentage of log items to be sent in the range of 0.0 to
+     * 1.0. if 1.0 is set it means that 100% of items are sent. If set to 0.1 only 10% of items will
+     * be sent. Items are picked randomly. Default is 1.0
+     */
+    @ApiStatus.Experimental private @Nullable Double sampleRate = 1.0;
path: sentry/src/main/java/io/sentry/SentryOptions.java
user: login: adinauer
body: Removed in https://github.com/getsentry/sentry-java/pull/4389
created_at: 2025-05-12T13:44:21Z
author_association: MEMBER
diff_hunk: @@ -1011,28 +1012,42 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint
 
   @ApiStatus.Experimental
   @Override
-  public void captureLogs(
-      @NotNull SentryLogEvents logEvents, @Nullable IScope scope, @Nullable Hint hint) {
+  public void captureLog(
+      @Nullable SentryLogEvent logEvent, @Nullable IScope scope, @Nullable Hint hint) {
     if (hint == null) {
       hint = new Hint();
     }
 
-    try {
-      @Nullable TraceContext traceContext = null;
-      if (scope != null) {
-        final @Nullable ITransaction transaction = scope.getTransaction();
-        if (transaction != null) {
-          traceContext = transaction.traceContext();
-        } else {
-          final @NotNull PropagationContext propagationContext =
-              TracingUtils.maybeUpdateBaggage(scope, options);
-          traceContext = propagationContext.traceContext();
-        }
+    @Nullable TraceContext traceContext = null;
+    if (scope != null) {
+      final @Nullable ITransaction transaction = scope.getTransaction();
+      if (transaction != null) {
+        traceContext = transaction.traceContext();
+      } else {
+        final @NotNull PropagationContext propagationContext =
+            TracingUtils.maybeUpdateBaggage(scope, options);
+        traceContext = propagationContext.traceContext();
+      }
+    }
+
+    if (logEvent != null) {
+      logEvent = executeBeforeSendLog(logEvent, hint);
path: sentry/src/main/java/io/sentry/SentryClient.java
user: login: stefanosiano
body: can you add tests for this beforeSend?
created_at: 2025-05-12T15:56:57Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-13T12:08:11Z
body: 
<!--
  0 failure: 
  0 warning: 
  1 messages
  
  DangerID: danger-id-Danger;
-->



<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Messages</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:book:</td>
      <td>Do not forget to update <a href="https://github.com/getsentry/sentry-docs">Sentry-docs</a> with your feature once the pull request gets approved.</td>
    </tr>
  </tbody>
</table>


<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a> against 6680001b5b63880559fa85cc199eedd1c9cbf2e6
</p>

author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-13T12:16:29Z
body: ##  Performance metrics :rocket:
<table>
  <tr>
    <th>&nbsp;</th>
    <th align="right">Plain</th>
    <th align="right">With Sentry</th>
    <th align="right">Diff</th>
  </tr>
  <tr>
    <th align="left">Startup time</th>
    <td align="right">433.19 ms</td>
    <td align="right">446.77 ms</td>
    <td align="right"><strong>13.58 ms</strong></td>
  </tr>
  <tr>
    <th align="left">Size</th>
    <td align="right">1.58 MiB</td>
    <td align="right">2.08 MiB</td>
    <td align="right"><strong>508.50 KiB</strong></td>
  </tr>
</table>

author_association: CONTRIBUTOR