From 81d2815cedf1525938a633f2a4c1057b7dfb4260 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 5 Jun 2026 08:33:14 +0200 Subject: [PATCH 01/39] collection: SDK Overhead Reduction From b5a0b0c5ad01ed53541e0d46e890f691892db8cb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 5 Jun 2026 08:35:22 +0200 Subject: [PATCH 02/39] perf(core): Skip java.specification.version lookup on Android Android is never Java 9+, so the System.getProperty + Double.valueOf parse in the Platform static initializer is unnecessary overhead on the Android cold-start path. Short-circuit to isJavaNinePlus=false when isAndroid is true. --- .../main/java/io/sentry/util/Platform.java | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/Platform.java b/sentry/src/main/java/io/sentry/util/Platform.java index b08b6e584fb..1f7cbeb2206 100644 --- a/sentry/src/main/java/io/sentry/util/Platform.java +++ b/sentry/src/main/java/io/sentry/util/Platform.java @@ -20,16 +20,21 @@ public final class Platform { isAndroid = false; } - try { - final @Nullable String javaStringVersion = System.getProperty("java.specification.version"); - if (javaStringVersion != null) { - final @NotNull double javaVersion = Double.valueOf(javaStringVersion); - isJavaNinePlus = javaVersion >= 9.0; - } else { + if (isAndroid) { + // Android is never Java 9+, skip the system property lookup + parse on the startup path. + isJavaNinePlus = false; + } else { + try { + final @Nullable String javaStringVersion = System.getProperty("java.specification.version"); + if (javaStringVersion != null) { + final @NotNull double javaVersion = Double.valueOf(javaStringVersion); + isJavaNinePlus = javaVersion >= 9.0; + } else { + isJavaNinePlus = false; + } + } catch (Throwable e) { isJavaNinePlus = false; } - } catch (Throwable e) { - isJavaNinePlus = false; } } From 7b15a6ca81712f20778fae4b06ad9dcfe975e747 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 5 Jun 2026 09:04:03 +0200 Subject: [PATCH 03/39] perf(android): Replace reflective OptionsContainer with direct subclass Replace OptionsContainer.create(SentryAndroidOptions.class) which uses getDeclaredConstructor().newInstance() with a direct SentryAndroidOptionsContainer subclass that returns new SentryAndroidOptions() without reflection. Make OptionsContainer non-final (@Open) with a protected no-arg constructor so Android can subclass it. --- .../io/sentry/android/core/SentryAndroid.java | 3 +-- .../core/SentryAndroidOptionsContainer.java | 16 ++++++++++++++++ sentry/api/sentry.api | 3 ++- .../main/java/io/sentry/OptionsContainer.java | 18 +++++++++++++++--- 4 files changed, 34 insertions(+), 6 deletions(-) create mode 100644 sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 0d249f73790..f27259fd635 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -9,7 +9,6 @@ import io.sentry.IScopes; import io.sentry.ISentryLifecycleToken; import io.sentry.Integration; -import io.sentry.OptionsContainer; import io.sentry.Sentry; import io.sentry.SentryLevel; import io.sentry.SentryOptions; @@ -98,7 +97,7 @@ public static void init( @NotNull Sentry.OptionsConfiguration configuration) { try (final @NotNull ISentryLifecycleToken ignored = staticLock.acquire()) { Sentry.init( - OptionsContainer.create(SentryAndroidOptions.class), + new SentryAndroidOptionsContainer(), options -> { final io.sentry.util.LoadClass classLoader = new io.sentry.util.LoadClass(); final boolean isTimberUpstreamAvailable = diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java new file mode 100644 index 00000000000..678f7ab29b2 --- /dev/null +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroidOptionsContainer.java @@ -0,0 +1,16 @@ +package io.sentry.android.core; + +import io.sentry.OptionsContainer; +import org.jetbrains.annotations.NotNull; + +/** + * Direct OptionsContainer for SentryAndroidOptions that avoids reflective + * getDeclaredConstructor().newInstance() on the Android startup path. + */ +final class SentryAndroidOptionsContainer extends OptionsContainer { + + @Override + public @NotNull SentryAndroidOptions createInstance() { + return new SentryAndroidOptions(); + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 4757be4894a..45268a9a894 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2068,7 +2068,8 @@ public abstract interface class io/sentry/ObjectWriter { public abstract fun value (Z)Lio/sentry/ObjectWriter; } -public final class io/sentry/OptionsContainer { +public class io/sentry/OptionsContainer { + protected fun ()V public static fun create (Ljava/lang/Class;)Lio/sentry/OptionsContainer; public fun createInstance ()Ljava/lang/Object; } diff --git a/sentry/src/main/java/io/sentry/OptionsContainer.java b/sentry/src/main/java/io/sentry/OptionsContainer.java index 52032880aaf..b29aef2e000 100644 --- a/sentry/src/main/java/io/sentry/OptionsContainer.java +++ b/sentry/src/main/java/io/sentry/OptionsContainer.java @@ -1,28 +1,40 @@ package io.sentry; +import com.jakewharton.nopen.annotation.Open; +import io.sentry.util.Objects; import java.lang.reflect.InvocationTargetException; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public final class OptionsContainer { +@Open +public class OptionsContainer { public @NotNull static OptionsContainer create(final @NotNull Class clazz) { return new OptionsContainer<>(clazz); } - private final @NotNull Class clazz; + private final @Nullable Class clazz; private OptionsContainer(final @NotNull Class clazz) { super(); this.clazz = clazz; } + /** Constructor for subclasses that create the instance directly without reflection. */ + protected OptionsContainer() { + super(); + this.clazz = null; + } + public @NotNull T createInstance() throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { - return clazz.getDeclaredConstructor().newInstance(); + return Objects.requireNonNull(clazz, "OptionsContainer clazz is required") + .getDeclaredConstructor() + .newInstance(); } } From 51c339c3a6a8819d3ab4bfd8c0e6c6cd8bf179b9 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 12 Jun 2026 10:36:35 +0200 Subject: [PATCH 04/39] collection: SDK Overhead reduction for JVM From 841a12e562a1bc37ba25a5edb6e65261d4286d0a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 12 Jun 2026 12:35:32 +0200 Subject: [PATCH 05/39] perf(core): Short-circuit combined scope breadcrumbs Avoid allocating and sorting a merged breadcrumb queue when only one component scope has breadcrumbs. This keeps the full merge path for multi-scope breadcrumbs and returns the default write scope queue when all scopes are empty. Co-Authored-By: Claude --- .../java/io/sentry/CombinedScopeView.java | 27 ++++++++++++-- .../java/io/sentry/CombinedScopeViewTest.kt | 37 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index f21f8697fa4..96482aa3833 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -171,10 +171,31 @@ public void setFingerprint(@NotNull List fingerprint) { @Override public @NotNull Queue getBreadcrumbs() { + final @NotNull Queue globalBreadcrumbs = globalScope.getBreadcrumbs(); + final @NotNull Queue isolationBreadcrumbs = isolationScope.getBreadcrumbs(); + final @NotNull Queue currentBreadcrumbs = scope.getBreadcrumbs(); + + final boolean hasGlobalBreadcrumbs = !globalBreadcrumbs.isEmpty(); + final boolean hasIsolationBreadcrumbs = !isolationBreadcrumbs.isEmpty(); + final boolean hasCurrentBreadcrumbs = !currentBreadcrumbs.isEmpty(); + + if (!hasGlobalBreadcrumbs && !hasIsolationBreadcrumbs && !hasCurrentBreadcrumbs) { + return getDefaultWriteScope().getBreadcrumbs(); + } + if (!hasIsolationBreadcrumbs && !hasCurrentBreadcrumbs) { + return globalBreadcrumbs; + } + if (!hasGlobalBreadcrumbs && !hasCurrentBreadcrumbs) { + return isolationBreadcrumbs; + } + if (!hasGlobalBreadcrumbs && !hasIsolationBreadcrumbs) { + return currentBreadcrumbs; + } + final @NotNull List allBreadcrumbs = new ArrayList<>(); - allBreadcrumbs.addAll(globalScope.getBreadcrumbs()); - allBreadcrumbs.addAll(isolationScope.getBreadcrumbs()); - allBreadcrumbs.addAll(scope.getBreadcrumbs()); + allBreadcrumbs.addAll(globalBreadcrumbs); + allBreadcrumbs.addAll(isolationBreadcrumbs); + allBreadcrumbs.addAll(currentBreadcrumbs); Collections.sort(allBreadcrumbs); final @NotNull Queue breadcrumbs = diff --git a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt index d768d6d32d6..6341ef7c514 100644 --- a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt +++ b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt @@ -11,6 +11,7 @@ import junit.framework.TestCase.assertTrue import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertSame import org.junit.Assert.assertNotEquals @@ -72,6 +73,42 @@ class CombinedScopeViewTest { assertEquals("current 2", breadcrumbs.poll().message) } + @Test + fun `returns single non-empty breadcrumb queue directly`() { + var combined = fixture.getSut() + fixture.globalScope.addBreadcrumb(Breadcrumb.info("global")) + assertSame(fixture.globalScope.breadcrumbs, combined.breadcrumbs) + + combined = fixture.getSut() + fixture.isolationScope.addBreadcrumb(Breadcrumb.info("isolation")) + assertSame(fixture.isolationScope.breadcrumbs, combined.breadcrumbs) + + combined = fixture.getSut() + fixture.scope.addBreadcrumb(Breadcrumb.info("current")) + assertSame(fixture.scope.breadcrumbs, combined.breadcrumbs) + } + + @Test + fun `returns default write scope breadcrumbs when all scopes are empty`() { + val combined = fixture.getSut(SentryOptions().also { it.defaultScopeType = ScopeType.CURRENT }) + + assertSame(fixture.scope.breadcrumbs, combined.breadcrumbs) + } + + @Test + fun `returns merged breadcrumb copy when multiple scopes have breadcrumbs`() { + val combined = fixture.getSut() + + fixture.globalScope.addBreadcrumb(Breadcrumb.info("global")) + fixture.isolationScope.addBreadcrumb(Breadcrumb.info("isolation")) + + val breadcrumbs = combined.breadcrumbs + + assertNotSame(fixture.globalScope.breadcrumbs, breadcrumbs) + assertNotSame(fixture.isolationScope.breadcrumbs, breadcrumbs) + assertEquals(2, breadcrumbs.size) + } + @Test fun `oldest breadcrumbs are dropped first`() { val options = SentryOptions().also { it.maxBreadcrumbs = 5 } From 781e28248c2b9db0a848b9b05f7737aa73898eb4 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 12 Jun 2026 15:12:01 +0200 Subject: [PATCH 06/39] perf(core): Reduce envelope writer buffer size Use an explicit 512-character BufferedWriter buffer for envelope item and envelope serialization. This avoids allocating the oversized default char buffer for each short-lived serialization writer while preserving the existing OutputStreamWriter-based encoding path. Co-Authored-By: Claude --- .../main/java/io/sentry/JsonSerializer.java | 5 ++- .../java/io/sentry/SentryEnvelopeItem.java | 41 ++++++++++++++----- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 2b24090d0cc..79a1c72bef3 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -64,6 +64,8 @@ public final class JsonSerializer implements ISerializer { @SuppressWarnings("CharsetObjectCanBeUsed") private static final Charset UTF_8 = Charset.forName("UTF-8"); + private static final int WRITER_BUFFER_SIZE = 512; + /** the SentryOptions */ private final @NotNull SentryOptions options; @@ -233,7 +235,8 @@ public void serialize(@NotNull SentryEnvelope envelope, @NotNull OutputStream ou // we do not want to close these as we would also close the stream that was passed in final BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(outputStream); - final Writer writer = new BufferedWriter(new OutputStreamWriter(bufferedOutputStream, UTF_8)); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(bufferedOutputStream, UTF_8), WRITER_BUFFER_SIZE); try { envelope diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index dbbc36524db..728478f5906 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -41,6 +41,8 @@ public final class SentryEnvelopeItem { @SuppressWarnings("CharsetObjectCanBeUsed") private static final Charset UTF_8 = Charset.forName("UTF-8"); + private static final int WRITER_BUFFER_SIZE = 512; + private final SentryEnvelopeItemHeader header; // Either dataFactory is set or data needs to be set. private final @Nullable Callable dataFactory; @@ -85,7 +87,9 @@ public final class SentryEnvelopeItem { new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(session, writer); return stream.toByteArray(); } @@ -119,7 +123,9 @@ public final class SentryEnvelopeItem { new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(event, writer); return stream.toByteArray(); } @@ -179,7 +185,9 @@ public static SentryEnvelopeItem fromUserFeedback( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(userFeedback, writer); return stream.toByteArray(); } @@ -206,7 +214,9 @@ public static SentryEnvelopeItem fromCheckIn( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(checkIn, writer); return stream.toByteArray(); } @@ -344,7 +354,9 @@ private static void ensureAttachmentSizeLimit( } try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(profileChunk, writer); return stream.toByteArray(); } catch (IOException e) { @@ -403,7 +415,9 @@ private static void ensureAttachmentSizeLimit( profilingTraceData.readDeviceCpuFrequencies(); try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(profilingTraceData, writer); return stream.toByteArray(); } catch (IOException e) { @@ -437,7 +451,9 @@ private static void ensureAttachmentSizeLimit( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(clientReport, writer); return stream.toByteArray(); } @@ -481,7 +497,8 @@ public static SentryEnvelopeItem fromReplay( try { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); final Writer writer = - new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { // relay expects the payload to be in this exact order: [event,rrweb,video] final Map replayPayload = new LinkedHashMap<>(); // first serialize replay event json bytes @@ -541,7 +558,9 @@ public static SentryEnvelopeItem fromLogs( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(logEvents, writer); return stream.toByteArray(); } @@ -571,7 +590,9 @@ public static SentryEnvelopeItem fromMetrics( new CachedItem( () -> { try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Writer writer = + new BufferedWriter( + new OutputStreamWriter(stream, UTF_8), WRITER_BUFFER_SIZE)) { serializer.serialize(metricsEvents, writer); return stream.toByteArray(); } From 5ce5a40bb1bd95f3b9c600620b69afb2424cb027 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 18 Jun 2026 14:55:02 +0200 Subject: [PATCH 07/39] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b42d4bd09d..f52fc0a84de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) +### Internal + +- Reduce writer buffer size from 8192 to 512 ([#5544](https://github.com/getsentry/sentry-java/pull/5544)) + ## 8.43.2 ### Improvements From 247f7862d9c94501dbd9383437336e4fab3e907c Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Fri, 12 Jun 2026 10:06:31 +0200 Subject: [PATCH 08/39] perf(core): Remove redundant event map copies Avoid creating temporary maps when applying scope and options tags or scope extras. The event setters already copy these maps, so this preserves snapshot semantics while reducing allocation overhead. Co-Authored-By: Claude --- .../java/io/sentry/MainEventProcessor.java | 3 +-- .../src/main/java/io/sentry/SentryClient.java | 9 ++++---- .../java/io/sentry/MainEventProcessorTest.kt | 13 +++++++++++ .../test/java/io/sentry/SentryClientTest.kt | 18 +++++++++++++++ .../SentryBaseEventSerializationTest.kt | 23 +++++++++++++++++++ 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 8c684bfb65a..d84c9e47be8 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -11,7 +11,6 @@ import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -191,7 +190,7 @@ private void setSdk(final @NotNull SentryBaseEvent event) { private void setTags(final @NotNull SentryBaseEvent event) { if (event.getTags() == null) { - event.setTags(new HashMap<>(options.getTags())); + event.setTags(options.getTags()); } else { for (Map.Entry item : options.getTags().entrySet()) { if (!event.getTags().containsKey(item.getKey())) { diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 78225f05d19..a739eddd9d9 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -27,7 +27,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -1425,7 +1424,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri event.setUser(scope.getUser()); } if (event.getTags() == null) { - event.setTags(new HashMap<>(scope.getTags())); + event.setTags(scope.getTags()); } else { for (Map.Entry item : scope.getTags().entrySet()) { if (!event.getTags().containsKey(item.getKey())) { @@ -1483,7 +1482,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri replayEvent.setUser(scope.getUser()); } if (replayEvent.getTags() == null) { - replayEvent.setTags(new HashMap<>(scope.getTags())); + replayEvent.setTags(scope.getTags()); } else { for (Map.Entry item : scope.getTags().entrySet()) { if (!replayEvent.getTags().containsKey(item.getKey())) { @@ -1523,7 +1522,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri sentryBaseEvent.setUser(scope.getUser()); } if (sentryBaseEvent.getTags() == null) { - sentryBaseEvent.setTags(new HashMap<>(scope.getTags())); + sentryBaseEvent.setTags(scope.getTags()); } else { for (Map.Entry item : scope.getTags().entrySet()) { if (!sentryBaseEvent.getTags().containsKey(item.getKey())) { @@ -1537,7 +1536,7 @@ public void captureBatchedMetricsEvents(final @NotNull SentryMetricsEvents metri sortBreadcrumbsByDate(sentryBaseEvent, scope.getBreadcrumbs()); } if (sentryBaseEvent.getExtras() == null) { - sentryBaseEvent.setExtras(new HashMap<>(scope.getExtras())); + sentryBaseEvent.setExtras(scope.getExtras()); } else { for (Map.Entry item : scope.getExtras().entrySet()) { if (!sentryBaseEvent.getExtras().containsKey(item.getKey())) { diff --git a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt index 229fd571871..fe5c835c90f 100644 --- a/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt +++ b/sentry/src/test/java/io/sentry/MainEventProcessorTest.kt @@ -358,6 +358,19 @@ class MainEventProcessorTest { } } + @Test + fun `options tags are copied when applied to event`() { + val sut = fixture.getSut(tags = mapOf("tag1" to "value1")) + val event = SentryEvent() + + sut.process(event, Hint()) + val eventTags = event.tags!! + + fixture.sentryOptions.setTag("tag2", "value2") + + assertFalse(eventTags.containsKey("tag2")) + } + @Test fun `when event has a tag set with the same name as SentryOptions tags, the tag value from the event is retained`() { val sut = fixture.getSut(tags = mapOf("tag1" to "value1", "tag2" to "value2")) diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index ab6fd2075a3..f51345957e4 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -534,6 +534,24 @@ class SentryClientTest { assertNotNull(event.request) { assertEquals("post", it.method) } } + @Test + fun `when captureEvent applies scope tags and extras, event map containers are copied`() { + val event = SentryEvent() + val scope = createScope() + + val sut = fixture.getSut() + + sut.captureEvent(event, scope) + val eventTags = event.tags!! + val eventExtras = event.extras!! + + scope.setTag("newTag", "newValue") + scope.setExtra("newExtra", "newValue") + + assertFalse(eventTags.containsKey("newTag")) + assertFalse(eventExtras.containsKey("newExtra")) + } + @Test fun `when breadcrumbs are not empty, sort them out by date`() { val b1 = Breadcrumb(DateUtils.getDateTime("2020-03-27T08:52:58.001Z")) diff --git a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt index 4cafb1ed8a8..35322d2659e 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt @@ -9,6 +9,7 @@ import io.sentry.SentryBaseEvent import io.sentry.SentryIntegrationPackageStorage import io.sentry.vendor.gson.stream.JsonToken import kotlin.test.assertEquals +import kotlin.test.assertFalse import org.junit.After import org.junit.Before import org.junit.Test @@ -102,4 +103,26 @@ class SentryBaseEventSerializationTest { assertEquals(expectedJson, actualJson) } + + @Test + fun `setTags copies source map`() { + val source = mutableMapOf("a" to "1") + val sut = Sut() + + sut.tags = source + source["b"] = "2" + + assertFalse(sut.tags!!.containsKey("b")) + } + + @Test + fun `setExtras copies source map`() { + val source = mutableMapOf("a" to "1") + val sut = Sut() + + sut.setExtras(source) + source["b"] = "2" + + assertFalse(sut.extras!!.containsKey("b")) + } } From 45c641ca1977e2cdfb9321fd3f983d00f1325982 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 18 Jun 2026 14:57:55 +0200 Subject: [PATCH 09/39] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8428f033b78..d5f18640464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,10 @@ - Fix attachments being duplicated on native events that carry scope attachments ([#5548](https://github.com/getsentry/sentry-java/pull/5548)) - Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) +### Internal + +- Remove redundant event map copies ([#5536](https://github.com/getsentry/sentry-java/pull/5536)) + ## 8.43.2 ### Improvements From c8f48acbf6e1691ab3e706d863352be3c77d1c47 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 18 Jun 2026 15:46:54 +0200 Subject: [PATCH 10/39] changelog --- CHANGELOG.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 429dd549a93..b0fd462e7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,24 @@ ## Unreleased +### Behavioral Changes + +- Collections returned by scope (e.g. `getBreadcrumbs`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) + - Previously, when going through `CombinedScopeView`, we were returning a copy where mutations didn't show up in the underlying scopes. + - This has now changed in order to reduce SDK overhead. + ### Features - Add experimental `SentrySQLiteDriver` to `sentry-android-sqlite` for instrumenting `androidx.sqlite.SQLiteDriver` ([#5563](https://github.com/getsentry/sentry-java/pull/5563)) - To use it, pass `SQLiteDriver` to `SentrySQLiteDriver.create(...)` - Requires `androidx.sqlite:sqlite` (2.5.0+) on runtime classpath (typically provided by Room or SQLDelight) +### Internal + +- Reduce writer buffer size from 8192 to 512 ([#5544](https://github.com/getsentry/sentry-java/pull/5544)) +- Remove redundant event map copies ([#5536](https://github.com/getsentry/sentry-java/pull/5536)) +- Optimize combined scope by adding an early return if only one scope has data ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) + ## 8.44.0 ### Features @@ -36,11 +48,6 @@ - Fix attachments being duplicated on native events that carry scope attachments ([#5548](https://github.com/getsentry/sentry-java/pull/5548)) - Fix performance collector scheduling many tasks in a row ([#5524](https://github.com/getsentry/sentry-java/pull/5524)) -### Internal - -- Reduce writer buffer size from 8192 to 512 ([#5544](https://github.com/getsentry/sentry-java/pull/5544)) -- Remove redundant event map copies ([#5536](https://github.com/getsentry/sentry-java/pull/5536)) - ## 8.43.2 ### Improvements From 6e98fdf58850dd9f7eaa0de728072eb46ab9db43 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 18 Jun 2026 15:58:22 +0200 Subject: [PATCH 11/39] perf(core): Short-circuit combined scope collections Avoid allocating merged collection copies when only one combined scope contains values. This extends the breadcrumbs optimization to tags, attributes, extras, and attachments while preserving merge behavior when multiple scopes contribute data. Co-Authored-By: Claude --- .../java/io/sentry/CombinedScopeView.java | 132 ++++++++++++++++-- .../java/io/sentry/CombinedScopeViewTest.kt | 32 +++++ 2 files changed, 149 insertions(+), 15 deletions(-) diff --git a/sentry/src/main/java/io/sentry/CombinedScopeView.java b/sentry/src/main/java/io/sentry/CombinedScopeView.java index 96482aa3833..ea2d752d44b 100644 --- a/sentry/src/main/java/io/sentry/CombinedScopeView.java +++ b/sentry/src/main/java/io/sentry/CombinedScopeView.java @@ -180,7 +180,7 @@ public void setFingerprint(@NotNull List fingerprint) { final boolean hasCurrentBreadcrumbs = !currentBreadcrumbs.isEmpty(); if (!hasGlobalBreadcrumbs && !hasIsolationBreadcrumbs && !hasCurrentBreadcrumbs) { - return getDefaultWriteScope().getBreadcrumbs(); + return getDefaultScopeValue(globalBreadcrumbs, isolationBreadcrumbs, currentBreadcrumbs); } if (!hasIsolationBreadcrumbs && !hasCurrentBreadcrumbs) { return globalBreadcrumbs; @@ -245,10 +245,31 @@ public void clear() { @Override public @NotNull Map getTags() { + final @NotNull Map globalTags = globalScope.getTags(); + final @NotNull Map isolationTags = isolationScope.getTags(); + final @NotNull Map currentTags = scope.getTags(); + + final boolean hasGlobalTags = !globalTags.isEmpty(); + final boolean hasIsolationTags = !isolationTags.isEmpty(); + final boolean hasCurrentTags = !currentTags.isEmpty(); + + if (!hasGlobalTags && !hasIsolationTags && !hasCurrentTags) { + return getDefaultScopeValue(globalTags, isolationTags, currentTags); + } + if (!hasIsolationTags && !hasCurrentTags) { + return globalTags; + } + if (!hasGlobalTags && !hasCurrentTags) { + return isolationTags; + } + if (!hasGlobalTags && !hasIsolationTags) { + return currentTags; + } + final @NotNull Map allTags = new ConcurrentHashMap<>(); - allTags.putAll(globalScope.getTags()); - allTags.putAll(isolationScope.getTags()); - allTags.putAll(scope.getTags()); + allTags.putAll(globalTags); + allTags.putAll(isolationTags); + allTags.putAll(currentTags); return allTags; } @@ -264,10 +285,32 @@ public void removeTag(@Nullable String key) { @Override public @NotNull Map getAttributes() { + final @NotNull Map globalAttributes = globalScope.getAttributes(); + final @NotNull Map isolationAttributes = + isolationScope.getAttributes(); + final @NotNull Map currentAttributes = scope.getAttributes(); + + final boolean hasGlobalAttributes = !globalAttributes.isEmpty(); + final boolean hasIsolationAttributes = !isolationAttributes.isEmpty(); + final boolean hasCurrentAttributes = !currentAttributes.isEmpty(); + + if (!hasGlobalAttributes && !hasIsolationAttributes && !hasCurrentAttributes) { + return getDefaultScopeValue(globalAttributes, isolationAttributes, currentAttributes); + } + if (!hasIsolationAttributes && !hasCurrentAttributes) { + return globalAttributes; + } + if (!hasGlobalAttributes && !hasCurrentAttributes) { + return isolationAttributes; + } + if (!hasGlobalAttributes && !hasIsolationAttributes) { + return currentAttributes; + } + final @NotNull Map allAttributes = new ConcurrentHashMap<>(); - allAttributes.putAll(globalScope.getAttributes()); - allAttributes.putAll(isolationScope.getAttributes()); - allAttributes.putAll(scope.getAttributes()); + allAttributes.putAll(globalAttributes); + allAttributes.putAll(isolationAttributes); + allAttributes.putAll(currentAttributes); return allAttributes; } @@ -293,11 +336,32 @@ public void removeAttribute(@Nullable String key) { @Override public @NotNull Map getExtras() { - final @NotNull Map allTags = new ConcurrentHashMap<>(); - allTags.putAll(globalScope.getExtras()); - allTags.putAll(isolationScope.getExtras()); - allTags.putAll(scope.getExtras()); - return allTags; + final @NotNull Map globalExtras = globalScope.getExtras(); + final @NotNull Map isolationExtras = isolationScope.getExtras(); + final @NotNull Map currentExtras = scope.getExtras(); + + final boolean hasGlobalExtras = !globalExtras.isEmpty(); + final boolean hasIsolationExtras = !isolationExtras.isEmpty(); + final boolean hasCurrentExtras = !currentExtras.isEmpty(); + + if (!hasGlobalExtras && !hasIsolationExtras && !hasCurrentExtras) { + return getDefaultScopeValue(globalExtras, isolationExtras, currentExtras); + } + if (!hasIsolationExtras && !hasCurrentExtras) { + return globalExtras; + } + if (!hasGlobalExtras && !hasCurrentExtras) { + return isolationExtras; + } + if (!hasGlobalExtras && !hasIsolationExtras) { + return currentExtras; + } + + final @NotNull Map allExtras = new ConcurrentHashMap<>(); + allExtras.putAll(globalExtras); + allExtras.putAll(isolationExtras); + allExtras.putAll(currentExtras); + return allExtras; } @Override @@ -363,6 +427,23 @@ public void removeContexts(@Nullable String key) { return getSpecificScope(null); } + private @NotNull T getDefaultScopeValue( + final @NotNull T globalValue, + final @NotNull T isolationValue, + final @NotNull T currentValue) { + switch (getOptions().getDefaultScopeType()) { + case CURRENT: + return currentValue; + case ISOLATION: + return isolationValue; + case GLOBAL: + return globalValue; + default: + // calm the compiler + return currentValue; + } + } + IScope getSpecificScope(final @Nullable ScopeType scopeType) { if (scopeType != null) { switch (scopeType) { @@ -394,10 +475,31 @@ IScope getSpecificScope(final @Nullable ScopeType scopeType) { @Override public @NotNull List getAttachments() { + final @NotNull List globalAttachments = globalScope.getAttachments(); + final @NotNull List isolationAttachments = isolationScope.getAttachments(); + final @NotNull List currentAttachments = scope.getAttachments(); + + final boolean hasGlobalAttachments = !globalAttachments.isEmpty(); + final boolean hasIsolationAttachments = !isolationAttachments.isEmpty(); + final boolean hasCurrentAttachments = !currentAttachments.isEmpty(); + + if (!hasGlobalAttachments && !hasIsolationAttachments && !hasCurrentAttachments) { + return getDefaultScopeValue(globalAttachments, isolationAttachments, currentAttachments); + } + if (!hasIsolationAttachments && !hasCurrentAttachments) { + return globalAttachments; + } + if (!hasGlobalAttachments && !hasCurrentAttachments) { + return isolationAttachments; + } + if (!hasGlobalAttachments && !hasIsolationAttachments) { + return currentAttachments; + } + final @NotNull List allAttachments = new CopyOnWriteArrayList<>(); - allAttachments.addAll(globalScope.getAttachments()); - allAttachments.addAll(isolationScope.getAttachments()); - allAttachments.addAll(scope.getAttachments()); + allAttachments.addAll(globalAttachments); + allAttachments.addAll(isolationAttachments); + allAttachments.addAll(currentAttachments); return allAttachments; } diff --git a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt index 6341ef7c514..fd187235a92 100644 --- a/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt +++ b/sentry/src/test/java/io/sentry/CombinedScopeViewTest.kt @@ -109,6 +109,38 @@ class CombinedScopeViewTest { assertEquals(2, breadcrumbs.size) } + @Test + fun `returns single non-empty combined collections directly`() { + val globalScope = mock() + val isolationScope = mock() + val scope = mock() + val combined = CombinedScopeView(globalScope, isolationScope, scope) + + val tags = mapOf("tag" to "value") + whenever(globalScope.tags).thenReturn(emptyMap()) + whenever(isolationScope.tags).thenReturn(emptyMap()) + whenever(scope.tags).thenReturn(tags) + assertSame(tags, combined.tags) + + val attributes = mapOf("attribute" to SentryAttribute.named("attribute", "value")) + whenever(globalScope.attributes).thenReturn(emptyMap()) + whenever(isolationScope.attributes).thenReturn(emptyMap()) + whenever(scope.attributes).thenReturn(attributes) + assertSame(attributes, combined.attributes) + + val extras = mapOf("extra" to "value") + whenever(globalScope.extras).thenReturn(emptyMap()) + whenever(isolationScope.extras).thenReturn(emptyMap()) + whenever(scope.extras).thenReturn(extras) + assertSame(extras, combined.extras) + + val attachments = listOf(createAttachment("attachment.png")) + whenever(globalScope.attachments).thenReturn(emptyList()) + whenever(isolationScope.attachments).thenReturn(emptyList()) + whenever(scope.attachments).thenReturn(attachments) + assertSame(attachments, combined.attachments) + } + @Test fun `oldest breadcrumbs are dropped first`() { val options = SentryOptions().also { it.maxBreadcrumbs = 5 } From 5850ef7c9214429629c3820380febd98d0e7a7ff Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 18 Jun 2026 16:02:13 +0200 Subject: [PATCH 12/39] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0fd462e7e9..3af6f8886dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Behavioral Changes -- Collections returned by scope (e.g. `getBreadcrumbs`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) +- Collections returned by scope (e.g. `getBreadcrumbs`, `getTags`, `getAttachments`) are shared state and should not be mutated. ([#5541](https://github.com/getsentry/sentry-java/pull/5541)) - Previously, when going through `CombinedScopeView`, we were returning a copy where mutations didn't show up in the underlying scopes. - This has now changed in order to reduce SDK overhead. From 093c050e1020c3a514367833320619ae270cd101 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 22 Jun 2026 14:02:02 +0200 Subject: [PATCH 13/39] perf(android): Use TimeZone.getDefault for device timezone Avoid constructing a Calendar only to read the default device timezone. The locale passed to Calendar does not affect the timezone value, so TimeZone.getDefault returns the same value with less work during device context collection. Co-Authored-By: Claude --- .../java/io/sentry/android/core/DeviceInfoUtil.java | 12 +----------- .../io/sentry/android/core/DeviceInfoUtilTest.kt | 9 +++++++++ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index f3b17c5854a..938e6737c17 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -10,7 +10,6 @@ import android.os.BatteryManager; import android.os.Build; import android.os.Environment; -import android.os.LocaleList; import android.os.StatFs; import android.os.SystemClock; import android.util.DisplayMetrics; @@ -25,7 +24,6 @@ import io.sentry.protocol.OperatingSystem; import io.sentry.util.AutoClosableReentrantLock; import java.io.File; -import java.util.Calendar; import java.util.Collections; import java.util.Date; import java.util.List; @@ -254,17 +252,9 @@ private void setDeviceIO( } } - @SuppressWarnings("NewApi") @NotNull private TimeZone getTimeZone() { - if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.N) { - LocaleList locales = context.getResources().getConfiguration().getLocales(); - if (!locales.isEmpty()) { - Locale locale = locales.get(0); - return Calendar.getInstance(locale).getTimeZone(); - } - } - return Calendar.getInstance().getTimeZone(); + return TimeZone.getDefault(); } @SuppressWarnings("JdkObsolete") diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index 6d90d6be538..287c44985af 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -6,6 +6,7 @@ import android.os.BatteryManager import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.internal.util.CpuInfoUtils +import java.util.TimeZone import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -47,6 +48,14 @@ class DeviceInfoUtilTest { assertNotNull(deviceInfo.memorySize) } + @Test + fun `sets default timezone`() { + val deviceInfoUtil = DeviceInfoUtil.getInstance(context, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals(TimeZone.getDefault(), deviceInfo.timezone) + } + @Test fun `does include cpu data`() { CpuInfoUtils.getInstance().setCpuMaxFrequencies(listOf(1024)) From d2ebaed7e37de43f1f43bf7bca039710cdb41066 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 22 Jun 2026 14:17:49 +0200 Subject: [PATCH 14/39] perf(core): Replace Calendar with Date in DateUtils Avoid constructing Calendar instances when DateUtils only needs the current epoch millis or a Date for an existing millis value. Date stores epoch millis without timezone state, so the returned values are unchanged while avoiding unnecessary Calendar allocation and field computation. Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/Breadcrumb.java | 2 +- sentry/src/main/java/io/sentry/DateUtils.java | 13 ++++--------- sentry/src/test/java/io/sentry/DateUtilsTest.kt | 1 + 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index d122d1459bf..a453bb2f6a9 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -555,7 +555,7 @@ public Breadcrumb(@Nullable String message) { if (timestamp != null) { return (Date) timestamp.clone(); } else if (timestampMs != null) { - // we memoize it here into timestamp to avoid instantiating Calendar again and again + // we memoize it here into timestamp to avoid creating a Date again and again timestamp = DateUtils.getDateTime(timestampMs); return timestamp; } diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index 31a8dcd76ea..be81be2da8d 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -1,13 +1,10 @@ package io.sentry; -import static io.sentry.vendor.gson.internal.bind.util.ISO8601Utils.TIMEZONE_UTC; - import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils; import java.math.BigDecimal; import java.math.RoundingMode; import java.text.ParseException; import java.text.ParsePosition; -import java.util.Calendar; import java.util.Date; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -24,10 +21,9 @@ private DateUtils() {} * * @return the UTC Date */ - @SuppressWarnings("JdkObsolete") + @SuppressWarnings("JavaUtilDate") public static @NotNull Date getCurrentDateTime() { - final Calendar calendar = Calendar.getInstance(TIMEZONE_UTC); - return calendar.getTime(); + return new Date(); } /** @@ -78,10 +74,9 @@ private DateUtils() {} * @param millis the UTC millis from the epoch * @return the UTC Date */ + @SuppressWarnings("JavaUtilDate") public static @NotNull Date getDateTime(final long millis) { - final Calendar calendar = Calendar.getInstance(TIMEZONE_UTC); - calendar.setTimeInMillis(millis); - return calendar.getTime(); + return new Date(millis); } /** diff --git a/sentry/src/test/java/io/sentry/DateUtilsTest.kt b/sentry/src/test/java/io/sentry/DateUtilsTest.kt index 9e234b50c1b..8c28311cd73 100644 --- a/sentry/src/test/java/io/sentry/DateUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/DateUtilsTest.kt @@ -86,6 +86,7 @@ class DateUtilsTest { val utcActual = convertDate(actual) val timestamp = utcActual.format(isoFormat) + assertEquals(millis, actual.time) assertEquals("2020-06-07T12:38:12.631Z", timestamp) } From 6755ac1f66fa311c7e0af7c744699ef75cb66724 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Mon, 22 Jun 2026 14:50:24 +0200 Subject: [PATCH 15/39] perf(core): Reduce JsonWriter stack allocation Shrink the vendored JsonWriter nesting stack from 32 entries to 8 entries. The stack still grows on demand for deeply nested payloads, while common SDK serialization avoids the larger initial array allocation. Co-Authored-By: Claude --- .../main/java/io/sentry/vendor/gson/stream/JsonWriter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java b/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java index b030bc174b7..3119c833fd2 100644 --- a/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java +++ b/sentry/src/main/java/io/sentry/vendor/gson/stream/JsonWriter.java @@ -17,7 +17,7 @@ // Source: https://github.com/google/gson // Tag: gson-parent-2.8.7 // Commit Hash: 4520489c29e770c64b11ca35e0a0fdf17a1874ab -// Changes: @ApiStatus.Internal, SuppressWarnings +// Changes: @ApiStatus.Internal, SuppressWarnings, reduced stack size package io.sentry.vendor.gson.stream; @@ -175,7 +175,7 @@ public class JsonWriter implements Closeable, Flushable { /** The output data, containing at most one top-level array or object. */ private final Writer out; - private int[] stack = new int[32]; + private int[] stack = new int[8]; private int stackSize = 0; { push(EMPTY_DOCUMENT); From dc1ae1e935a437df49f0cd33c901bf0991f7e094 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 05:59:46 +0200 Subject: [PATCH 16/39] perf(core): Lazily allocate Breadcrumb data Avoid allocating a ConcurrentHashMap for breadcrumbs that never set data. Initialize the data map on first write while preserving concurrent writes with double-checked locking. Co-Authored-By: Claude --- .../src/main/java/io/sentry/Breadcrumb.java | 52 ++++++++++++++----- .../src/test/java/io/sentry/BreadcrumbTest.kt | 27 ++++++++++ 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index d122d1459bf..c6703937257 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -34,8 +34,10 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab /** The type of breadcrumb. */ private @Nullable String type; + private static final @NotNull Map EMPTY_DATA = Collections.emptyMap(); + /** Data associated with this breadcrumb. */ - private @NotNull Map data = new ConcurrentHashMap<>(); + private volatile @NotNull Map data = EMPTY_DATA; /** Dotted strings that indicate what the crumb is or where it comes from. */ private @Nullable String category; @@ -78,9 +80,11 @@ public Breadcrumb(final long timestamp) { this.type = breadcrumb.type; this.category = breadcrumb.category; this.origin = breadcrumb.origin; - final Map dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data); - if (dataClone != null) { - this.data = dataClone; + if (!breadcrumb.data.isEmpty()) { + final Map dataClone = CollectionUtils.newConcurrentHashMap(breadcrumb.data); + if (dataClone != null) { + this.data = dataClone; + } } this.unknown = CollectionUtils.newConcurrentHashMap(breadcrumb.unknown); this.level = breadcrumb.level; @@ -100,7 +104,7 @@ public static Breadcrumb fromMap( @NotNull Date timestamp = DateUtils.getCurrentDateTime(); String message = null; String type = null; - @NotNull Map data = new ConcurrentHashMap<>(); + Map data = null; String category = null; String origin = null; SentryLevel level = null; @@ -129,6 +133,9 @@ public static Breadcrumb fromMap( if (untypedData != null) { for (Map.Entry dataEntry : untypedData.entrySet()) { if (dataEntry.getKey() instanceof String && dataEntry.getValue() != null) { + if (data == null) { + data = new ConcurrentHashMap<>(); + } data.put((String) dataEntry.getKey(), dataEntry.getValue()); } else { options @@ -166,7 +173,9 @@ public static Breadcrumb fromMap( final Breadcrumb breadcrumb = new Breadcrumb(timestamp); breadcrumb.message = message; breadcrumb.type = type; - breadcrumb.data = data; + if (data != null) { + breadcrumb.data = data; + } breadcrumb.category = category; breadcrumb.origin = origin; breadcrumb.level = level; @@ -494,7 +503,7 @@ public static Breadcrumb fromMap( breadcrumb.setData("view.tag", viewTag); } for (final Map.Entry entry : additionalData.entrySet()) { - breadcrumb.getData().put(entry.getKey(), entry.getValue()); + breadcrumb.setData(entry.getKey(), entry.getValue()); } breadcrumb.setLevel(SentryLevel.INFO); return breadcrumb; @@ -598,6 +607,20 @@ public void setType(@Nullable String type) { this.type = type; } + private @NotNull Map getOrCreateData() { + Map currentData = data; + if (currentData == EMPTY_DATA) { + synchronized (this) { + currentData = data; + if (currentData == EMPTY_DATA) { + currentData = new ConcurrentHashMap<>(); + data = currentData; + } + } + } + return currentData; + } + /** * Returns the data map * @@ -636,7 +659,7 @@ public void setData(@Nullable String key, @Nullable Object value) { if (value == null) { removeData(key); } else { - data.put(key, value); + getOrCreateData().put(key, value); } } @@ -649,7 +672,10 @@ public void removeData(@Nullable String key) { if (key == null) { return; } - data.remove(key); + final Map currentData = data; + if (currentData != EMPTY_DATA) { + currentData.remove(key); + } } /** @@ -859,7 +885,7 @@ public static final class Deserializer implements JsonDeserializer { @NotNull Date timestamp = DateUtils.getCurrentDateTime(); String message = null; String type = null; - @NotNull Map data = new ConcurrentHashMap<>(); + Map data = null; String category = null; String origin = null; SentryLevel level = null; @@ -884,7 +910,7 @@ public static final class Deserializer implements JsonDeserializer { Map deserializedData = CollectionUtils.newConcurrentHashMap( (Map) reader.nextObjectOrNull()); - if (deserializedData != null) { + if (deserializedData != null && !deserializedData.isEmpty()) { data = deserializedData; } break; @@ -913,7 +939,9 @@ public static final class Deserializer implements JsonDeserializer { Breadcrumb breadcrumb = new Breadcrumb(timestamp); breadcrumb.message = message; breadcrumb.type = type; - breadcrumb.data = data; + if (data != null) { + breadcrumb.data = data; + } breadcrumb.category = category; breadcrumb.origin = origin; breadcrumb.level = level; diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index 30c322641b8..6c99115a834 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -1,6 +1,9 @@ package io.sentry import java.util.Date +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -329,6 +332,30 @@ class BreadcrumbTest { breadcrumb.removeData(null) } + @Test + fun `concurrent first writes keep all data entries`() { + val breadcrumb = Breadcrumb() + val count = 32 + val executor = Executors.newFixedThreadPool(count) + val start = CountDownLatch(1) + val futures = + (0 until count).map { index -> + executor.submit { + start.await() + breadcrumb.setData("key-$index", index) + } + } + + start.countDown() + futures.forEach { it.get(5, TimeUnit.SECONDS) } + executor.shutdown() + + assertEquals(count, breadcrumb.data.size) + for (index in 0 until count) { + assertEquals(index, breadcrumb.data["key-$index"]) + } + } + class TestKey(val id: Long) { override fun toString(): String = id.toString() } From 3b13f34f249b518de31a9032dc17666205042c7b Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 06:40:36 +0200 Subject: [PATCH 17/39] perf(core): Reduce context serialization allocations Use sorted key arrays when serializing contexts to avoid allocating an ArrayList for each serialization. This preserves deterministic key ordering while keeping the snapshot representation smaller. Co-Authored-By: Claude --- .../main/java/io/sentry/MonitorContexts.java | 8 ++++---- .../java/io/sentry/protocol/Contexts.java | 8 ++++---- .../java/io/sentry/MonitorContextsTest.kt | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/MonitorContextsTest.kt diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java index 193d9ee5a6f..af8a963f63a 100644 --- a/sentry/src/main/java/io/sentry/MonitorContexts.java +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -3,8 +3,7 @@ import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.util.Collections; -import java.util.List; +import java.util.Arrays; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -12,6 +11,7 @@ public final class MonitorContexts extends ConcurrentHashMap implements JsonSerializable { private static final long serialVersionUID = 3987329379811822556L; + private static final String[] EMPTY_KEYS = new String[0]; public MonitorContexts() {} @@ -49,8 +49,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger throws IOException { writer.beginObject(); // Serialize in alphabetical order to keep determinism. - final List sortedKeys = Collections.list(keys()); - Collections.sort(sortedKeys); + final String[] sortedKeys = keySet().toArray(EMPTY_KEYS); + Arrays.sort(sortedKeys); for (final String key : sortedKeys) { final Object value = get(key); if (value != null) { diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index fd1e9b83eb6..82f70bb712f 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -14,10 +14,9 @@ import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.util.Collections; +import java.util.Arrays; import java.util.Enumeration; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -28,6 +27,7 @@ public class Contexts implements JsonSerializable { private static final long serialVersionUID = 252445813254943011L; public static final String REPLAY_ID = "replay_id"; + private static final String[] EMPTY_KEYS = new String[0]; private final @NotNull ConcurrentHashMap internalStorage = new ConcurrentHashMap<>(); @@ -302,8 +302,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger throws IOException { writer.beginObject(); // Serialize in alphabetical order to keep determinism. - final List sortedKeys = Collections.list(keys()); - Collections.sort(sortedKeys); + final String[] sortedKeys = internalStorage.keySet().toArray(EMPTY_KEYS); + Arrays.sort(sortedKeys); for (final String key : sortedKeys) { final Object value = get(key); if (value != null) { diff --git a/sentry/src/test/java/io/sentry/MonitorContextsTest.kt b/sentry/src/test/java/io/sentry/MonitorContextsTest.kt new file mode 100644 index 00000000000..2b0d57e605e --- /dev/null +++ b/sentry/src/test/java/io/sentry/MonitorContextsTest.kt @@ -0,0 +1,19 @@ +package io.sentry + +import io.sentry.protocol.SerializationUtils +import kotlin.test.Test +import kotlin.test.assertEquals +import org.mockito.kotlin.mock + +class MonitorContextsTest { + @Test + fun `serializes entries in alphabetical order`() { + val contexts = + MonitorContexts().apply { + put("b", 2) + put("a", 1) + } + + assertEquals("{\"a\":1,\"b\":2}", SerializationUtils.serializeToString(contexts, mock())) + } +} From e569b6f3c091e88862d7874c3702996f4a3da524 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 06:59:34 +0200 Subject: [PATCH 18/39] perf(core): Lazily allocate reflection serializer state Defer creation of the reflection serializer visiting set until reflection serialization is actually needed. Normal SDK payload serialization uses explicit serializers, so this avoids an unused HashSet allocation for each writer. --- .../java/io/sentry/JsonReflectionObjectSerializer.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java b/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java index 97c23031044..bb9ee1fcd3f 100644 --- a/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonReflectionObjectSerializer.java @@ -30,7 +30,7 @@ @ApiStatus.Internal public final class JsonReflectionObjectSerializer { - private final Set visiting = new HashSet<>(); + private @Nullable Set visiting; private final int maxDepth; JsonReflectionObjectSerializer(int maxDepth) { @@ -69,6 +69,7 @@ public final class JsonReflectionObjectSerializer { } else if (object.getClass().isEnum()) { return object.toString(); } else { + final Set visiting = getVisiting(); if (visiting.contains(object)) { logger.log(SentryLevel.INFO, "Cyclic reference detected. Calling toString() on object."); return object.toString(); @@ -135,6 +136,13 @@ public final class JsonReflectionObjectSerializer { // Helper + private @NotNull Set getVisiting() { + if (visiting == null) { + visiting = new HashSet<>(); + } + return visiting; + } + private @NotNull List list(@NotNull Object[] objectArray, @NotNull ILogger logger) throws Exception { List list = new ArrayList<>(); From fd46dbe1ef1ed81641dfb8883ee1028b9cc9495a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 07:38:28 +0200 Subject: [PATCH 19/39] perf(core): Lazily create reflection JSON serializer Defer creation of JsonReflectionObjectSerializer until unknown-object reflection serialization is needed. Normal SDK payloads use explicit serializers, so this avoids allocating unused reflection serializer state for each writer. --- sentry/api/sentry.api | 1 - .../java/io/sentry/JsonObjectSerializer.java | 14 ++++++++--- .../io/sentry/JsonObjectSerializerTest.kt | 24 +++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 45268a9a894..dd5a752a143 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1313,7 +1313,6 @@ public final class io/sentry/JsonObjectReader : io/sentry/ObjectReader { public final class io/sentry/JsonObjectSerializer { public static final field OBJECT_PLACEHOLDER Ljava/lang/String; - public final field jsonReflectionObjectSerializer Lio/sentry/JsonReflectionObjectSerializer; public fun (I)V public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;Ljava/lang/Object;)V } diff --git a/sentry/src/main/java/io/sentry/JsonObjectSerializer.java b/sentry/src/main/java/io/sentry/JsonObjectSerializer.java index 5f986746be9..38abc960521 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonObjectSerializer.java @@ -28,10 +28,11 @@ public final class JsonObjectSerializer { public static final String OBJECT_PLACEHOLDER = "[OBJECT]"; - public final JsonReflectionObjectSerializer jsonReflectionObjectSerializer; + private final int maxDepth; + private @Nullable JsonReflectionObjectSerializer jsonReflectionObjectSerializer; public JsonObjectSerializer(int maxDepth) { - jsonReflectionObjectSerializer = new JsonReflectionObjectSerializer(maxDepth); + this.maxDepth = maxDepth; } public void serialize( @@ -127,7 +128,7 @@ public void serialize( writer.value(object.toString()); } else { try { - Object serializableObject = jsonReflectionObjectSerializer.serialize(object, logger); + Object serializableObject = getJsonReflectionObjectSerializer().serialize(object, logger); serialize(writer, logger, serializableObject); } catch (Exception exception) { logger.log(SentryLevel.ERROR, "Failed serializing unknown object.", exception); @@ -138,6 +139,13 @@ public void serialize( // Helper + private @NotNull JsonReflectionObjectSerializer getJsonReflectionObjectSerializer() { + if (jsonReflectionObjectSerializer == null) { + jsonReflectionObjectSerializer = new JsonReflectionObjectSerializer(maxDepth); + } + return jsonReflectionObjectSerializer; + } + private void serializeDate( @NotNull ObjectWriter writer, @NotNull ILogger logger, @NotNull Date date) throws IOException { diff --git a/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt index 3323be84cda..572c27abced 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectSerializerTest.kt @@ -7,6 +7,8 @@ import java.util.Locale import java.util.TimeZone import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicIntegerArray +import kotlin.test.assertNotNull +import kotlin.test.assertNull import org.junit.Test import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock @@ -192,6 +194,21 @@ internal class JsonObjectSerializerTest { verify(jsonSerializable).serialize(fixture.writer, fixture.logger) } + @Test + fun `serialize json serializable does not create reflection serializer`() { + val serializer = fixture.getSUT() + val jsonSerializable: JsonSerializable = mock() + serializer.serialize(fixture.writer, fixture.logger, jsonSerializable) + assertNull(serializer.reflectionObjectSerializer) + } + + @Test + fun `serialize unknown object creates reflection serializer`() { + val serializer = fixture.getSUT() + serializer.serialize(fixture.writer, fixture.logger, object {}) + assertNotNull(serializer.reflectionObjectSerializer) + } + @Test fun `serialize unknown object without data`() { val value = object {} @@ -355,3 +372,10 @@ internal class JsonObjectSerializerTest { data class ClassWithEnumProperty(val enumProperty: DataCategory) data class ClassWithLocaleProperty(val localeProperty: Locale) + +private val JsonObjectSerializer.reflectionObjectSerializer: JsonReflectionObjectSerializer? + get() { + val field = JsonObjectSerializer::class.java.getDeclaredField("jsonReflectionObjectSerializer") + field.isAccessible = true + return field.get(this) as JsonReflectionObjectSerializer? + } From 2bcedece6dc9569660459d807e99cef4433dfa30 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 09:35:30 +0200 Subject: [PATCH 20/39] fix(android): Preserve locale timezone extension Keep the Calendar-based timezone path for Android 13+ locales that carry a Unicode tz extension. This preserves the existing device timezone behavior while keeping the direct default timezone fast path for normal locales. Co-Authored-By: Claude --- .../sentry/android/core/DeviceInfoUtil.java | 12 ++++++++++ .../sentry/android/core/DeviceInfoUtilTest.kt | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index 938e6737c17..b6678b61355 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -10,6 +10,7 @@ import android.os.BatteryManager; import android.os.Build; import android.os.Environment; +import android.os.LocaleList; import android.os.StatFs; import android.os.SystemClock; import android.util.DisplayMetrics; @@ -24,6 +25,7 @@ import io.sentry.protocol.OperatingSystem; import io.sentry.util.AutoClosableReentrantLock; import java.io.File; +import java.util.Calendar; import java.util.Collections; import java.util.Date; import java.util.List; @@ -252,8 +254,18 @@ private void setDeviceIO( } } + @SuppressWarnings("NewApi") @NotNull private TimeZone getTimeZone() { + if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { + LocaleList locales = context.getResources().getConfiguration().getLocales(); + if (!locales.isEmpty()) { + Locale locale = locales.get(0); + if (locale.getUnicodeLocaleType("tz") != null) { + return Calendar.getInstance(locale).getTimeZone(); + } + } + } return TimeZone.getDefault(); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt index 287c44985af..faf993e1610 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/DeviceInfoUtilTest.kt @@ -2,10 +2,14 @@ package io.sentry.android.core import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.os.BatteryManager +import android.os.Build +import android.os.LocaleList import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.android.core.internal.util.CpuInfoUtils +import java.util.Locale import java.util.TimeZone import kotlin.test.BeforeTest import kotlin.test.Test @@ -13,6 +17,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import org.junit.runner.RunWith +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class DeviceInfoUtilTest { @@ -56,6 +61,24 @@ class DeviceInfoUtilTest { assertEquals(TimeZone.getDefault(), deviceInfo.timezone) } + @Test + @Config(sdk = [Build.VERSION_CODES.TIRAMISU]) + fun `preserves timezone from locale unicode extension`() { + val defaultTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + val configuration = Configuration(context.resources.configuration) + configuration.setLocales(LocaleList(Locale.forLanguageTag("en-US-u-tz-usnyc"))) + val localizedContext = context.createConfigurationContext(configuration) + val deviceInfoUtil = DeviceInfoUtil(localizedContext, SentryAndroidOptions()) + val deviceInfo = deviceInfoUtil.collectDeviceInformation(false, false) + + assertEquals("America/New_York", deviceInfo.timezone?.id) + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + @Test fun `does include cpu data`() { CpuInfoUtils.getInstance().setCpuMaxFrequencies(listOf(1024)) From d3f5ecf14599841eb97e8873db8c2e96908e800d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 11:02:25 +0200 Subject: [PATCH 21/39] perf(core): Replace ISO8601 timestamp handling Replace the Calendar-backed vendored ISO8601 formatting and parsing path with a small Sentry-specific utility that works directly from epoch milliseconds. This avoids formatter and parser allocations on timestamp-heavy serialization paths while keeping the existing DateUtils API as the facade. Co-Authored-By: Claude --- THIRD_PARTY_NOTICES.md | 16 + .../src/main/java/io/sentry/Breadcrumb.java | 7 +- sentry/src/main/java/io/sentry/DateUtils.java | 21 +- .../java/io/sentry/SentryIso8601Utils.java | 281 ++++++++++++++++++ .../src/test/java/io/sentry/DateUtilsTest.kt | 65 ++++ 5 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/SentryIso8601Utils.java diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5a48d567fac..f34138048c0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -62,6 +62,22 @@ limitations under the License. --- +## Howard Hinnant — Date Algorithms (Public Domain) + +**Source:** https://howardhinnant.github.io/date_algorithms.html
+**License:** Public Domain
+**Copyright:** Copyright (c) 2011-2021 Howard Hinnant + +### Scope + +The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.SentryIso8601Utils`. + +``` +This paper and the algorithms contained herein are placed in the public domain. +``` + +--- + ## Android Open Source Project — Base64 (Apache 2.0) **Source:** https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Base64.java
diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index d122d1459bf..c45b15b4711 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -823,7 +823,12 @@ public static final class JsonKeys { public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { writer.beginObject(); - writer.name(JsonKeys.TIMESTAMP).value(logger, getTimestamp()); + writer + .name(JsonKeys.TIMESTAMP) + .value( + timestampMs != null + ? DateUtils.getTimestampFromMillis(timestampMs) + : DateUtils.getTimestamp(getTimestamp())); if (message != null) { writer.name(JsonKeys.MESSAGE).value(message); } diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index 31a8dcd76ea..2d2ab3a10f4 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -2,11 +2,8 @@ import static io.sentry.vendor.gson.internal.bind.util.ISO8601Utils.TIMEZONE_UTC; -import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils; import java.math.BigDecimal; import java.math.RoundingMode; -import java.text.ParseException; -import java.text.ParsePosition; import java.util.Calendar; import java.util.Date; import org.jetbrains.annotations.ApiStatus; @@ -15,6 +12,7 @@ /** Utilities to deal with dates */ @ApiStatus.Internal +@SuppressWarnings("JavaUtilDate") public final class DateUtils { private DateUtils() {} @@ -39,8 +37,8 @@ private DateUtils() {} public static @NotNull Date getDateTime(final @NotNull String timestamp) throws IllegalArgumentException { try { - return ISO8601Utils.parse(timestamp, new ParsePosition(0)); - } catch (ParseException e) { + return getDateTime(SentryIso8601Utils.parseTimestamp(timestamp)); + } catch (IllegalArgumentException e) { throw new IllegalArgumentException("timestamp is not ISO format " + timestamp); } } @@ -51,7 +49,6 @@ private DateUtils() {} * @param timestamp millis eg 1581410911.988 (1581410911 seconds and 988 millis) * @return the UTC Date */ - @SuppressWarnings("JdkObsolete") public static @NotNull Date getDateTimeWithMillisPrecision(final @NotNull String timestamp) throws IllegalArgumentException { try { @@ -69,7 +66,17 @@ private DateUtils() {} * @return the UTC/ISO 8601 timestamp */ public static @NotNull String getTimestamp(final @NotNull Date date) { - return ISO8601Utils.format(date, true); + return getTimestampFromMillis(date.getTime()); + } + + /** + * Get the UTC/ISO 8601 timestamp from millis. + * + * @param millis the UTC millis from the epoch + * @return the UTC/ISO 8601 timestamp + */ + static @NotNull String getTimestampFromMillis(final long millis) { + return SentryIso8601Utils.formatTimestamp(millis); } /** diff --git a/sentry/src/main/java/io/sentry/SentryIso8601Utils.java b/sentry/src/main/java/io/sentry/SentryIso8601Utils.java new file mode 100644 index 00000000000..919ecea436b --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryIso8601Utils.java @@ -0,0 +1,281 @@ +// Civil date conversion algorithms adapted from Howard Hinnant's date algorithms. +// Copyright (c) 2011-2021 Howard Hinnant. +// Licensed under the Public Domain. +// https://howardhinnant.github.io/date_algorithms.html + +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +final class SentryIso8601Utils { + + private static final long MILLIS_PER_SECOND = 1000L; + private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND; + private static final long MILLIS_PER_HOUR = 60L * MILLIS_PER_MINUTE; + private static final long MILLIS_PER_DAY = 24L * MILLIS_PER_HOUR; + private static final int DAYS_0000_TO_1970 = 719468; + + private SentryIso8601Utils() {} + + static long parseTimestamp(final @NotNull String timestamp) { + final int length = timestamp.length(); + int offset = 0; + + final int year = parseInt(timestamp, offset, offset += 4); + if (checkOffset(timestamp, offset, '-')) { + offset++; + } + + final int month = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, '-')) { + offset++; + } + + final int day = parseInt(timestamp, offset, offset += 2); + validateDate(year, month, day); + + if (!checkOffset(timestamp, offset, 'T')) { + if (offset != length) { + throw new IllegalArgumentException("Invalid date separator"); + } + return epochMillis(year, month, day, 0, 0, 0, 0, 0); + } + offset++; + + final int hour = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + + final int minute = parseInt(timestamp, offset, offset += 2); + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + + int second = 0; + int millisecond = 0; + if (length > offset) { + final char c = timestamp.charAt(offset); + if (c != 'Z' && c != '+' && c != '-') { + second = parseInt(timestamp, offset, offset += 2); + if (second > 59 && second < 63) { + second = 59; + } + if (checkOffset(timestamp, offset, '.')) { + offset++; + final int endOffset = indexOfNonDigit(timestamp, offset); + if (endOffset == offset) { + throw new IllegalArgumentException("Missing millisecond digits"); + } + final int parseEndOffset = Math.min(endOffset, offset + 3); + final int fraction = parseInt(timestamp, offset, parseEndOffset); + switch (parseEndOffset - offset) { + case 1: + millisecond = fraction * 100; + break; + case 2: + millisecond = fraction * 10; + break; + default: + millisecond = fraction; + break; + } + offset = endOffset; + } + } + } + validateTime(hour, minute, second, millisecond); + + if (length <= offset) { + throw new IllegalArgumentException("No time zone indicator"); + } + + final int timezoneOffsetMillis; + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z') { + timezoneOffsetMillis = 0; + offset++; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + final int sign = timezoneIndicator == '+' ? 1 : -1; + offset++; + final int timezoneHour = parseInt(timestamp, offset, offset += 2); + int timezoneMinute = 0; + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + if (length >= offset + 2) { + timezoneMinute = parseInt(timestamp, offset, offset += 2); + } + validateTimezone(timezoneHour, timezoneMinute); + timezoneOffsetMillis = + sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE); + } else { + throw new IllegalArgumentException("Invalid time zone indicator"); + } + + if (offset != length) { + throw new IllegalArgumentException("Invalid trailing characters"); + } + + return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); + } + + static @NotNull String formatTimestamp(final long millis) { + final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY); + int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY); + + final int[] yearMonthDay = epochDayToYearMonthDay(epochDay); + final int hour = millisOfDay / (int) MILLIS_PER_HOUR; + millisOfDay -= hour * (int) MILLIS_PER_HOUR; + final int minute = millisOfDay / (int) MILLIS_PER_MINUTE; + millisOfDay -= minute * (int) MILLIS_PER_MINUTE; + final int second = millisOfDay / (int) MILLIS_PER_SECOND; + final int millisecond = millisOfDay - second * (int) MILLIS_PER_SECOND; + + final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length()); + padInt(timestamp, yearMonthDay[0], "yyyy".length()); + timestamp.append('-'); + padInt(timestamp, yearMonthDay[1], "MM".length()); + timestamp.append('-'); + padInt(timestamp, yearMonthDay[2], "dd".length()); + timestamp.append('T'); + padInt(timestamp, hour, "hh".length()); + timestamp.append(':'); + padInt(timestamp, minute, "mm".length()); + timestamp.append(':'); + padInt(timestamp, second, "ss".length()); + timestamp.append('.'); + padInt(timestamp, millisecond, "sss".length()); + timestamp.append('Z'); + return timestamp.toString(); + } + + private static long epochMillis( + final int year, + final int month, + final int day, + final int hour, + final int minute, + final int second, + final int millisecond, + final int timezoneOffsetMillis) { + return daysFromYearMonthDay(year, month, day) * MILLIS_PER_DAY + + hour * MILLIS_PER_HOUR + + minute * MILLIS_PER_MINUTE + + second * MILLIS_PER_SECOND + + millisecond + - timezoneOffsetMillis; + } + + private static long daysFromYearMonthDay(int year, final int month, final int day) { + year -= month <= 2 ? 1 : 0; + final long era = Math.floorDiv(year, 400); + final int yearOfEra = (int) (year - era * 400); + final int dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + final int dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear; + return era * 146097 + dayOfEra - DAYS_0000_TO_1970; + } + + private static int[] epochDayToYearMonthDay(long epochDay) { + epochDay += DAYS_0000_TO_1970; + final long era = Math.floorDiv(epochDay, 146097); + final int dayOfEra = (int) (epochDay - era * 146097); + final int yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365; + final int year = (int) (yearOfEra + era * 400); + final int dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100); + final int monthPrime = (5 * dayOfYear + 2) / 153; + final int day = dayOfYear - (153 * monthPrime + 2) / 5 + 1; + final int month = monthPrime < 10 ? monthPrime + 3 : monthPrime - 9; + return new int[] {year + (month <= 2 ? 1 : 0), month, day}; + } + + private static void validateDate(final int year, final int month, final int day) { + if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) { + throw new IllegalArgumentException("Invalid date"); + } + } + + private static void validateTime( + final int hour, final int minute, final int second, final int millisecond) { + if (hour < 0 + || hour > 23 + || minute < 0 + || minute > 59 + || second < 0 + || second > 59 + || millisecond < 0 + || millisecond > 999) { + throw new IllegalArgumentException("Invalid time"); + } + } + + private static void validateTimezone(final int hour, final int minute) { + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + throw new IllegalArgumentException("Invalid time zone"); + } + } + + private static int daysInMonth(final int year, final int month) { + switch (month) { + case 2: + return isLeapYear(year) ? 29 : 28; + case 4: + case 6: + case 9: + case 11: + return 30; + default: + return 31; + } + } + + private static boolean isLeapYear(final int year) { + return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0); + } + + private static boolean checkOffset( + final @NotNull String value, final int offset, final char expected) { + return offset < value.length() && value.charAt(offset) == expected; + } + + private static int parseInt( + final @NotNull String value, final int beginIndex, final int endIndex) { + if (beginIndex < 0 || endIndex > value.length() || beginIndex >= endIndex) { + throw new NumberFormatException(value); + } + + int result = 0; + for (int i = beginIndex; i < endIndex; i++) { + final char c = value.charAt(i); + if (c < '0' || c > '9') { + throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); + } + result = result * 10 + c - '0'; + } + return result; + } + + private static void padInt( + final @NotNull StringBuilder buffer, final int value, final int length) { + if (value < 0) { + buffer.append('-'); + padInt(buffer, -value, length); + return; + } + final String strValue = Integer.toString(value); + for (int i = length - strValue.length(); i > 0; i--) { + buffer.append('0'); + } + buffer.append(strValue); + } + + private static int indexOfNonDigit(final @NotNull String string, final int offset) { + for (int i = offset; i < string.length(); i++) { + final char c = string.charAt(i); + if (c < '0' || c > '9') { + return i; + } + } + return string.length(); + } +} diff --git a/sentry/src/test/java/io/sentry/DateUtilsTest.kt b/sentry/src/test/java/io/sentry/DateUtilsTest.kt index 9e234b50c1b..0746b1a1b5f 100644 --- a/sentry/src/test/java/io/sentry/DateUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/DateUtilsTest.kt @@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter import java.util.Date import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -34,6 +35,54 @@ class DateUtilsTest { assertEquals("2020-03-27T08:52:58.000Z", timestamp) } + @Test + fun `When ISO date has offset`() { + val input = + mapOf( + "2020-03-27T10:52:58.015+02:00" to "2020-03-27T08:52:58.015Z", + "2020-03-27T10:52:58.015+0200" to "2020-03-27T08:52:58.015Z", + "2020-03-27T10:52:58.015+02" to "2020-03-27T08:52:58.015Z", + "2020-03-27T05:52:58.015-03:00" to "2020-03-27T08:52:58.015Z", + ) + + input.forEach { + val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat) + + assertEquals(it.value, timestamp) + } + } + + @Test + fun `When ISO date uses compact separators`() { + val date = DateUtils.getDateTime("20200327T085258.015Z") + + val utcDate = convertDate(date) + val timestamp = utcDate.format(isoFormat) + + assertEquals("2020-03-27T08:52:58.015Z", timestamp) + } + + @Test + fun `When ISO date has short fraction`() { + val input = + mapOf( + "2020-03-27T08:52:58.1Z" to "2020-03-27T08:52:58.100Z", + "2020-03-27T08:52:58.12Z" to "2020-03-27T08:52:58.120Z", + "2020-03-27T08:52:58.123456Z" to "2020-03-27T08:52:58.123Z", + ) + + input.forEach { + val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat) + + assertEquals(it.value, timestamp) + } + } + + @Test + fun `When ISO date is invalid`() { + assertFailsWith { DateUtils.getDateTime("2020-02-30T08:52:58Z") } + } + @Test fun `Converts from Date to ISO 8601 and back to Date`() { val currentDate = DateUtils.getCurrentDateTime() @@ -78,6 +127,21 @@ class DateUtilsTest { assertTrue { utcCurrentDate.minusSeconds(1).isBefore(utcDate) } } + @Test + fun `Formats millis to ISO 8601 timestamp`() { + val input = + mapOf( + Instant.parse("1970-01-01T00:00:00.000Z").toEpochMilli() to "1970-01-01T00:00:00.000Z", + Instant.parse("1969-12-31T23:59:59.999Z").toEpochMilli() to "1969-12-31T23:59:59.999Z", + Instant.parse("2000-02-29T12:34:56.789Z").toEpochMilli() to "2000-02-29T12:34:56.789Z", + Instant.parse("1900-03-01T00:00:00.000Z").toEpochMilli() to "1900-03-01T00:00:00.000Z", + Instant.parse("2100-03-01T00:00:00.000Z").toEpochMilli() to "2100-03-01T00:00:00.000Z", + Instant.parse("2400-02-29T23:59:59.999Z").toEpochMilli() to "2400-02-29T23:59:59.999Z", + ) + + input.forEach { assertEquals(it.value, DateUtils.getTimestampFromMillis(it.key)) } + } + @Test fun `Millis formats to Date`() { val millis = 1591533492L * 1000L + 631 @@ -86,6 +150,7 @@ class DateUtilsTest { val utcActual = convertDate(actual) val timestamp = utcActual.format(isoFormat) + assertEquals(millis, actual.time) assertEquals("2020-06-07T12:38:12.631Z", timestamp) } From fb38dbe9ab030844d9c666890ed56339e5063dde Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 11:20:11 +0200 Subject: [PATCH 22/39] ref(core): Move ISO8601 utility to vendor package Move the Sentry ISO8601 helper under the vendor package and mark it as internal API so the adapted public-domain date conversion code is isolated from core SDK classes. Update attribution metadata to reflect the public-domain dedication source. Co-Authored-By: Claude --- THIRD_PARTY_NOTICES.md | 6 +++--- sentry/api/sentry.api | 5 +++++ sentry/src/main/java/io/sentry/DateUtils.java | 1 + .../io/sentry/{ => vendor}/SentryIso8601Utils.java | 13 +++++++------ 4 files changed, 16 insertions(+), 9 deletions(-) rename sentry/src/main/java/io/sentry/{ => vendor}/SentryIso8601Utils.java (96%) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f34138048c0..8931402f98e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -66,14 +66,14 @@ limitations under the License. **Source:** https://howardhinnant.github.io/date_algorithms.html
**License:** Public Domain
-**Copyright:** Copyright (c) 2011-2021 Howard Hinnant +**Copyright:** None; public domain dedication by Howard Hinnant ### Scope -The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.SentryIso8601Utils`. +The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.vendor.SentryIso8601Utils`. ``` -This paper and the algorithms contained herein are placed in the public domain. +Consider these donated to the public domain. ``` --- diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 45268a9a894..df043e92e1c 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -8076,6 +8076,11 @@ public class io/sentry/vendor/Base64 { public static fun encodeToString ([BIII)Ljava/lang/String; } +public final class io/sentry/vendor/SentryIso8601Utils { + public static fun formatTimestamp (J)Ljava/lang/String; + public static fun parseTimestamp (Ljava/lang/String;)J +} + public class io/sentry/vendor/gson/internal/bind/util/ISO8601Utils { public static final field TIMEZONE_UTC Ljava/util/TimeZone; public fun ()V diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java index 2d2ab3a10f4..3fae5cf8264 100644 --- a/sentry/src/main/java/io/sentry/DateUtils.java +++ b/sentry/src/main/java/io/sentry/DateUtils.java @@ -2,6 +2,7 @@ import static io.sentry.vendor.gson.internal.bind.util.ISO8601Utils.TIMEZONE_UTC; +import io.sentry.vendor.SentryIso8601Utils; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Calendar; diff --git a/sentry/src/main/java/io/sentry/SentryIso8601Utils.java b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java similarity index 96% rename from sentry/src/main/java/io/sentry/SentryIso8601Utils.java rename to sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java index 919ecea436b..66fdc34e80b 100644 --- a/sentry/src/main/java/io/sentry/SentryIso8601Utils.java +++ b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java @@ -1,13 +1,14 @@ // Civil date conversion algorithms adapted from Howard Hinnant's date algorithms. -// Copyright (c) 2011-2021 Howard Hinnant. -// Licensed under the Public Domain. +// Placed in the public domain by Howard Hinnant. // https://howardhinnant.github.io/date_algorithms.html -package io.sentry; +package io.sentry.vendor; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; -final class SentryIso8601Utils { +@ApiStatus.Internal +public final class SentryIso8601Utils { private static final long MILLIS_PER_SECOND = 1000L; private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND; @@ -17,7 +18,7 @@ final class SentryIso8601Utils { private SentryIso8601Utils() {} - static long parseTimestamp(final @NotNull String timestamp) { + public static long parseTimestamp(final @NotNull String timestamp) { final int length = timestamp.length(); int offset = 0; @@ -120,7 +121,7 @@ static long parseTimestamp(final @NotNull String timestamp) { return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); } - static @NotNull String formatTimestamp(final long millis) { + public static @NotNull String formatTimestamp(final long millis) { final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY); int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY); From 2b933d6324e0f72d8b2e9870ed644dfba4e91751 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 12:14:16 +0200 Subject: [PATCH 23/39] perf(core): Avoid cloning Date getters --- sentry/src/main/java/io/sentry/Breadcrumb.java | 2 +- sentry/src/main/java/io/sentry/SentryEvent.java | 2 +- sentry/src/main/java/io/sentry/Session.java | 8 ++------ sentry/src/main/java/io/sentry/protocol/App.java | 3 +-- sentry/src/main/java/io/sentry/protocol/Device.java | 3 +-- sentry/src/test/java/io/sentry/protocol/AppTest.kt | 5 +++-- sentry/src/test/java/io/sentry/protocol/DeviceTest.kt | 5 +++-- 7 files changed, 12 insertions(+), 16 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index d122d1459bf..a7f4ab7b5b9 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -553,7 +553,7 @@ public Breadcrumb(@Nullable String message) { @SuppressWarnings("JavaUtilDate") public @NotNull Date getTimestamp() { if (timestamp != null) { - return (Date) timestamp.clone(); + return timestamp; } else if (timestampMs != null) { // we memoize it here into timestamp to avoid instantiating Calendar again and again timestamp = DateUtils.getDateTime(timestampMs); diff --git a/sentry/src/main/java/io/sentry/SentryEvent.java b/sentry/src/main/java/io/sentry/SentryEvent.java index 007d50681fb..8b8575fe7ec 100644 --- a/sentry/src/main/java/io/sentry/SentryEvent.java +++ b/sentry/src/main/java/io/sentry/SentryEvent.java @@ -114,7 +114,7 @@ public SentryEvent(final @NotNull Date timestamp) { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public Date getTimestamp() { - return (Date) timestamp.clone(); + return timestamp; } public void setTimestamp(final @NotNull Date timestamp) { diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 3ce2d70e89e..2fdfffb35d9 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -131,10 +131,7 @@ public boolean isTerminated() { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getStarted() { - if (started == null) { - return null; - } - return (Date) started.clone(); + return started; } public @Nullable String getDistinctId() { @@ -193,8 +190,7 @@ public int errorCount() { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getTimestamp() { - final Date timestampRef = timestamp; - return timestampRef != null ? (Date) timestampRef.clone() : null; + return timestamp; } /** Ends a session and update its values */ diff --git a/sentry/src/main/java/io/sentry/protocol/App.java b/sentry/src/main/java/io/sentry/protocol/App.java index 989c3464be8..878ad0ec960 100644 --- a/sentry/src/main/java/io/sentry/protocol/App.java +++ b/sentry/src/main/java/io/sentry/protocol/App.java @@ -98,8 +98,7 @@ public void setAppIdentifier(final @Nullable String appIdentifier) { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getAppStartTime() { - final Date appStartTimeRef = appStartTime; - return appStartTimeRef != null ? (Date) appStartTimeRef.clone() : null; + return appStartTime; } public void setAppStartTime(final @Nullable Date appStartTime) { diff --git a/sentry/src/main/java/io/sentry/protocol/Device.java b/sentry/src/main/java/io/sentry/protocol/Device.java index e6113efbcb5..5b765640a39 100644 --- a/sentry/src/main/java/io/sentry/protocol/Device.java +++ b/sentry/src/main/java/io/sentry/protocol/Device.java @@ -366,8 +366,7 @@ public void setScreenDpi(final @Nullable Integer screenDpi) { @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) public @Nullable Date getBootTime() { - final Date bootTimeRef = bootTime; - return bootTimeRef != null ? (Date) bootTimeRef.clone() : null; + return bootTime; } public void setBootTime(final @Nullable Date bootTime) { diff --git a/sentry/src/test/java/io/sentry/protocol/AppTest.kt b/sentry/src/test/java/io/sentry/protocol/AppTest.kt index 84b4c7088e3..cc0f504b7c8 100644 --- a/sentry/src/test/java/io/sentry/protocol/AppTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/AppTest.kt @@ -5,10 +5,11 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNotSame +import kotlin.test.assertSame class AppTest { @Test - fun `copying app wont have the same references`() { + fun `copying app keeps date reference and copies collections`() { val app = App() app.appBuild = "app build" app.appIdentifier = "app identifier" @@ -28,7 +29,7 @@ class AppTest { assertNotNull(clone) assertNotSame(app, clone) - assertNotSame(app.appStartTime, clone.appStartTime) + assertSame(app.appStartTime, clone.appStartTime) assertNotSame(app.permissions, clone.permissions) assertNotSame(app.viewNames, clone.viewNames) diff --git a/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt b/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt index 121cbe6537f..a67305c37ea 100644 --- a/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/DeviceTest.kt @@ -6,11 +6,12 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNotSame +import kotlin.test.assertSame class DeviceTest { @Test - fun `copying device wont have the same references`() { + fun `copying device keeps date reference and copies other mutable references`() { val device = Device() device.archs = arrayOf("archs1", "archs2") device.bootTime = Date() @@ -23,7 +24,7 @@ class DeviceTest { assertNotNull(clone) assertNotSame(device, clone) assertNotSame(device.archs, clone.archs) - assertNotSame(device.bootTime, clone.bootTime) + assertSame(device.bootTime, clone.bootTime) assertNotSame(device.timezone, clone.timezone) assertNotSame(device.unknown, clone.unknown) } From 9174e180bba33e5f89d15ff8f7ae9f8542a817fb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 23 Jun 2026 17:03:42 +0200 Subject: [PATCH 24/39] fix(core): Preserve ISO8601 utility compatibility Match edge-case behavior from the previous vendored ISO8601 utility for date-only timestamps, trailing characters after Z, and Gregorian cutover dates. --- .../io/sentry/vendor/SentryIso8601Utils.java | 73 +++++++++++- .../src/test/java/io/sentry/DateUtilsTest.kt | 108 ++++++++++++++++++ 2 files changed, 178 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java index 66fdc34e80b..622e86a8d6d 100644 --- a/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java +++ b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java @@ -4,6 +4,9 @@ package io.sentry.vendor; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.SimpleTimeZone; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -14,6 +17,7 @@ public final class SentryIso8601Utils { private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND; private static final long MILLIS_PER_HOUR = 60L * MILLIS_PER_MINUTE; private static final long MILLIS_PER_DAY = 24L * MILLIS_PER_HOUR; + private static final long GREGORIAN_CUTOVER_MILLIS = -12219292800000L; private static final int DAYS_0000_TO_1970 = 719468; private SentryIso8601Utils() {} @@ -33,14 +37,14 @@ public static long parseTimestamp(final @NotNull String timestamp) { } final int day = parseInt(timestamp, offset, offset += 2); - validateDate(year, month, day); if (!checkOffset(timestamp, offset, 'T')) { if (offset != length) { throw new IllegalArgumentException("Invalid date separator"); } - return epochMillis(year, month, day, 0, 0, 0, 0, 0); + return dateOnlyEpochMillis(year, month, day); } + validateDate(year, month, day); offset++; final int hour = parseInt(timestamp, offset, offset += 2); @@ -92,10 +96,12 @@ public static long parseTimestamp(final @NotNull String timestamp) { } final int timezoneOffsetMillis; + final boolean allowTrailingCharacters; final char timezoneIndicator = timestamp.charAt(offset); if (timezoneIndicator == 'Z') { timezoneOffsetMillis = 0; offset++; + allowTrailingCharacters = true; } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { final int sign = timezoneIndicator == '+' ? 1 : -1; offset++; @@ -110,18 +116,28 @@ public static long parseTimestamp(final @NotNull String timestamp) { validateTimezone(timezoneHour, timezoneMinute); timezoneOffsetMillis = sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE); + allowTrailingCharacters = false; } else { throw new IllegalArgumentException("Invalid time zone indicator"); } - if (offset != length) { + if (!allowTrailingCharacters && offset != length) { throw new IllegalArgumentException("Invalid trailing characters"); } + if (isBeforeGregorianCutover(year, month, day)) { + return epochMillisWithCalendar( + year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); + } + return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis); } public static @NotNull String formatTimestamp(final long millis) { + if (millis < GREGORIAN_CUTOVER_MILLIS) { + return formatTimestampWithCalendar(millis); + } + final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY); int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY); @@ -151,6 +167,53 @@ public static long parseTimestamp(final @NotNull String timestamp) { return timestamp.toString(); } + private static long dateOnlyEpochMillis(final int year, final int month, final int day) { + return new GregorianCalendar(year, month - 1, day).getTimeInMillis(); + } + + private static long epochMillisWithCalendar( + final int year, + final int month, + final int day, + final int hour, + final int minute, + final int second, + final int millisecond, + final int timezoneOffsetMillis) { + final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(timezoneOffsetMillis, "GMT")); + calendar.setLenient(false); + calendar.set(Calendar.YEAR, year); + calendar.set(Calendar.MONTH, month - 1); + calendar.set(Calendar.DAY_OF_MONTH, day); + calendar.set(Calendar.HOUR_OF_DAY, hour); + calendar.set(Calendar.MINUTE, minute); + calendar.set(Calendar.SECOND, second); + calendar.set(Calendar.MILLISECOND, millisecond); + return calendar.getTimeInMillis(); + } + + private static @NotNull String formatTimestampWithCalendar(final long millis) { + final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(0, "UTC")); + calendar.setTimeInMillis(millis); + + final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length()); + padInt(timestamp, calendar.get(Calendar.YEAR), "yyyy".length()); + timestamp.append('-'); + padInt(timestamp, calendar.get(Calendar.MONTH) + 1, "MM".length()); + timestamp.append('-'); + padInt(timestamp, calendar.get(Calendar.DAY_OF_MONTH), "dd".length()); + timestamp.append('T'); + padInt(timestamp, calendar.get(Calendar.HOUR_OF_DAY), "hh".length()); + timestamp.append(':'); + padInt(timestamp, calendar.get(Calendar.MINUTE), "mm".length()); + timestamp.append(':'); + padInt(timestamp, calendar.get(Calendar.SECOND), "ss".length()); + timestamp.append('.'); + padInt(timestamp, calendar.get(Calendar.MILLISECOND), "sss".length()); + timestamp.append('Z'); + return timestamp.toString(); + } + private static long epochMillis( final int year, final int month, @@ -190,6 +253,10 @@ private static int[] epochDayToYearMonthDay(long epochDay) { return new int[] {year + (month <= 2 ? 1 : 0), month, day}; } + private static boolean isBeforeGregorianCutover(final int year, final int month, final int day) { + return year < 1582 || (year == 1582 && (month < 10 || (month == 10 && day < 15))); + } + private static void validateDate(final int year, final int month, final int day) { if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) { throw new IllegalArgumentException("Invalid date"); diff --git a/sentry/src/test/java/io/sentry/DateUtilsTest.kt b/sentry/src/test/java/io/sentry/DateUtilsTest.kt index 0746b1a1b5f..ba60c43bfc4 100644 --- a/sentry/src/test/java/io/sentry/DateUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/DateUtilsTest.kt @@ -1,10 +1,13 @@ package io.sentry +import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils +import java.text.ParsePosition import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Date +import java.util.TimeZone import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -142,6 +145,101 @@ class DateUtilsTest { input.forEach { assertEquals(it.value, DateUtils.getTimestampFromMillis(it.key)) } } + @Test + fun `Fast timestamp formatter matches previous ISO8601 formatter`() { + val input = + listOf( + "1582-10-04T00:00:00.000Z", + "1582-10-15T00:00:00.000Z", + "1900-03-01T00:00:00.000Z", + "1969-12-31T23:59:59.999Z", + "1970-01-01T00:00:00.000Z", + "1999-12-31T23:59:59.999Z", + "2000-02-29T12:34:56.789Z", + "2020-03-27T08:52:58.015Z", + "2024-02-29T23:59:59.001Z", + "2100-03-01T00:00:00.000Z", + "2400-02-29T23:59:59.999Z", + ) + + input + .map { ISO8601Utils.parse(it, ParsePosition(0)).time } + .forEach { + assertEquals( + ISO8601Utils.format(Date(it), true), + DateUtils.getTimestampFromMillis(it), + "millis=$it", + ) + } + } + + @Test + fun `Fast timestamp parser matches previous ISO8601 parser`() { + val input = + listOf( + "2020-03-27T08:52Z", + "2020-03-27T08:52:58Z", + "2020-03-27T08:52:58.015Z", + "20200327T085258.015Z", + "2020-03-27T10:52:58.015+02:00", + "2020-03-27T10:52:58.015+0200", + "2020-03-27T10:52:58.015+02", + "2020-03-27T05:52:58.015-03:00", + "2020-03-27T05:22:58.015-0330", + "2020-03-27T08:52:58.1Z", + "2020-03-27T08:52:58.12Z", + "2020-03-27T08:52:58.123456Z", + "2020-03-27T08:52:58Ztrailing", + "2016-12-31T23:59:60Z", + "1582-10-04T00:00:00.000Z", + "1582-10-15T00:00:00.000Z", + "1900-03-01T00:00:00.000Z", + "2000-02-29T12:34:56.789Z", + "2100-03-01T00:00:00.000Z", + ) + + input.forEach { + assertEquals( + ISO8601Utils.parse(it, ParsePosition(0)).time, + DateUtils.getDateTime(it).time, + "timestamp=$it", + ) + } + } + + @Test + fun `Fast timestamp parser matches previous ISO8601 parser for date-only values`() { + withDefaultTimeZone("America/Los_Angeles") { + val input = listOf("2020-03-27", "20200327", "2020-02-30") + + input.forEach { + assertEquals( + ISO8601Utils.parse(it, ParsePosition(0)).time, + DateUtils.getDateTime(it).time, + "timestamp=$it", + ) + } + } + } + + @Test + fun `Fast timestamp parser rejects date-time without timezone like previous ISO8601 parser`() { + val input = listOf("2020-03-27T08:52", "2020-03-27T08:52:58", "2020-03-27T08:52:58.015") + + input.forEach { + assertFailsWith("timestamp=$it") { ISO8601Utils.parse(it, ParsePosition(0)) } + assertFailsWith("timestamp=$it") { DateUtils.getDateTime(it) } + } + } + + @Test + fun `Fast timestamp parser rejects Gregorian cutover gap like previous ISO8601 parser`() { + val timestamp = "1582-10-10T00:00:00.000Z" + + assertFailsWith { ISO8601Utils.parse(timestamp, ParsePosition(0)) } + assertFailsWith { DateUtils.getDateTime(timestamp) } + } + @Test fun `Millis formats to Date`() { val millis = 1591533492L * 1000L + 631 @@ -185,6 +283,16 @@ class DateUtilsTest { private fun convertDate(date: Date): LocalDateTime = Instant.ofEpochMilli(date.time).atZone(utcTimeZone).toLocalDateTime() + private fun withDefaultTimeZone(timeZoneId: String, block: () -> Unit) { + val previousTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId)) + block() + } finally { + TimeZone.setDefault(previousTimeZone) + } + } + private fun assertClose(expected: Double, actual: Double?) { assertNotNull(actual) val diff = Math.abs(expected - actual) From 11fd62195d06524978330b79d7b6c65ff2dfe899 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 09:22:12 +0200 Subject: [PATCH 25/39] fix(core): Preserve mutable breadcrumb data access Initialize the lazy breadcrumb data map when callers request the full map. This keeps getData() mutable for existing callers while preserving lazy allocation for breadcrumbs that only serialize or read individual values. Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/Breadcrumb.java | 2 +- sentry/src/test/java/io/sentry/BreadcrumbTest.kt | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index c6703937257..e9af53e578d 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -629,7 +629,7 @@ public void setType(@Nullable String type) { @ApiStatus.Internal @NotNull public Map getData() { - return data; + return getOrCreateData(); } /** diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index 6c99115a834..f51acca81cb 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -332,6 +332,15 @@ class BreadcrumbTest { breadcrumb.removeData(null) } + @Test + fun `getData returns mutable map for new breadcrumb`() { + val breadcrumb = Breadcrumb() + + breadcrumb.data["k"] = "v" + + assertEquals("v", breadcrumb.getData("k")) + } + @Test fun `concurrent first writes keep all data entries`() { val breadcrumb = Breadcrumb() From 9ac735ab8dac3dff39bd0130e88ec49bd92f2ff0 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 12:08:19 +0200 Subject: [PATCH 26/39] docs(android): Explain timezone Calendar fallback Document why Android 13+ locales with Unicode timezone extensions keep using Calendar while normal locales use the default timezone directly for performance. Co-Authored-By: Claude --- .../src/main/java/io/sentry/android/core/DeviceInfoUtil.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java index b6678b61355..63b88c0e440 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DeviceInfoUtil.java @@ -257,6 +257,9 @@ private void setDeviceIO( @SuppressWarnings("NewApi") @NotNull private TimeZone getTimeZone() { + // Only use the costly Calendar API on Android 13+ (API Level 33+) when the locale contains a + // Unicode timezone extension (for example "en-US-u-tz-usnyc"), because Calendar honors that + // extension. For all other cases, use the process default timezone directly for performance. if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.TIRAMISU) { LocaleList locales = context.getResources().getConfiguration().getLocales(); if (!locales.isEmpty()) { From 200bec9cde8f3b1a2abb623b37d3586f8603d717 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 12:14:59 +0200 Subject: [PATCH 27/39] fix(core): Avoid KeySetView in context serialization Use ConcurrentHashMap.keys() when creating sorted context key snapshots so the serialization path stays compatible with Android API 21. Keep the array snapshot optimization without relying on KeySetView, which AnimalSniffer rejects for the SDK's minSdk. Co-Authored-By: Claude --- sentry/api/sentry.api | 1 + .../main/java/io/sentry/MonitorContexts.java | 6 ++---- .../java/io/sentry/protocol/Contexts.java | 6 ++---- .../java/io/sentry/util/CollectionUtils.java | 21 +++++++++++++++++++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 45268a9a894..5fca9a1e4c1 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7619,6 +7619,7 @@ public final class io/sentry/util/CollectionUtils { public static fun newHashMap (Ljava/util/Map;)Ljava/util/Map; public static fun reverseListIterator (Ljava/util/concurrent/CopyOnWriteArrayList;)Ljava/util/ListIterator; public static fun size (Ljava/lang/Iterable;)I + public static fun toSortedStringArray (Ljava/util/Enumeration;I)[Ljava/lang/String; } public abstract interface class io/sentry/util/CollectionUtils$Mapper { diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java index af8a963f63a..a52ecc6b97f 100644 --- a/sentry/src/main/java/io/sentry/MonitorContexts.java +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -1,9 +1,9 @@ package io.sentry; +import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.util.Arrays; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -11,7 +11,6 @@ public final class MonitorContexts extends ConcurrentHashMap implements JsonSerializable { private static final long serialVersionUID = 3987329379811822556L; - private static final String[] EMPTY_KEYS = new String[0]; public MonitorContexts() {} @@ -49,8 +48,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger throws IOException { writer.beginObject(); // Serialize in alphabetical order to keep determinism. - final String[] sortedKeys = keySet().toArray(EMPTY_KEYS); - Arrays.sort(sortedKeys); + final String[] sortedKeys = CollectionUtils.toSortedStringArray(keys(), size()); for (final String key : sortedKeys) { final Object value = get(key); if (value != null) { diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 82f70bb712f..a50adb9f98f 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -10,11 +10,11 @@ import io.sentry.ProfileContext; import io.sentry.SpanContext; import io.sentry.util.AutoClosableReentrantLock; +import io.sentry.util.CollectionUtils; import io.sentry.util.HintUtils; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; -import java.util.Arrays; import java.util.Enumeration; import java.util.HashMap; import java.util.Map; @@ -27,7 +27,6 @@ public class Contexts implements JsonSerializable { private static final long serialVersionUID = 252445813254943011L; public static final String REPLAY_ID = "replay_id"; - private static final String[] EMPTY_KEYS = new String[0]; private final @NotNull ConcurrentHashMap internalStorage = new ConcurrentHashMap<>(); @@ -302,8 +301,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger throws IOException { writer.beginObject(); // Serialize in alphabetical order to keep determinism. - final String[] sortedKeys = internalStorage.keySet().toArray(EMPTY_KEYS); - Arrays.sort(sortedKeys); + final String[] sortedKeys = CollectionUtils.toSortedStringArray(keys(), internalStorage.size()); for (final String key : sortedKeys) { final Object value = get(key); if (value != null) { diff --git a/sentry/src/main/java/io/sentry/util/CollectionUtils.java b/sentry/src/main/java/io/sentry/util/CollectionUtils.java index 266055fa1ce..5b00eb6531c 100644 --- a/sentry/src/main/java/io/sentry/util/CollectionUtils.java +++ b/sentry/src/main/java/io/sentry/util/CollectionUtils.java @@ -1,7 +1,9 @@ package io.sentry.util; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.ListIterator; @@ -15,9 +17,28 @@ /** Util class for Collections */ @ApiStatus.Internal public final class CollectionUtils { + private static final String[] EMPTY_STRINGS = new String[0]; private CollectionUtils() {} + public static @NotNull String[] toSortedStringArray( + final @NotNull Enumeration source, final int size) { + String[] sorted = size == 0 ? EMPTY_STRINGS : new String[size]; + int index = 0; + while (source.hasMoreElements()) { + if (index == sorted.length) { + sorted = Arrays.copyOf(sorted, sorted.length + 1); + } + sorted[index] = source.nextElement(); + index++; + } + if (index != sorted.length) { + sorted = Arrays.copyOf(sorted, index); + } + Arrays.sort(sorted); + return sorted; + } + /** * Returns an Iterator size * From 3f09b42405924d78422108a8b534b261e054717a Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 16:52:34 +0200 Subject: [PATCH 28/39] test(core): Add breadcrumb timestamp serialization coverage Cover that breadcrumbs backed by timestamp milliseconds serialize the same timestamp as breadcrumbs backed by Date for the same instant. --- .../io/sentry/protocol/BreadcrumbSerializationTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt index 72856c3c27d..a33ddb91a2b 100644 --- a/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/BreadcrumbSerializationTest.kt @@ -11,6 +11,7 @@ import io.sentry.SentryLevel import io.sentry.SentryOptions import java.io.StringReader import java.io.StringWriter +import java.util.Date import kotlin.test.assertEquals import kotlin.test.assertTrue import org.junit.Test @@ -49,6 +50,13 @@ class BreadcrumbSerializationTest { assertEquals(expectedJson, actualJson) } + @Test + fun `timestampMs fast path serializes same timestamp as Date fallback`() { + val timestampMs = DateUtils.getDateTime("2009-11-16T01:08:47.123Z").time + + assertEquals(serialize(Breadcrumb(Date(timestampMs))), serialize(Breadcrumb(timestampMs))) + } + @Test fun deserializeFromMap() { val map: Map = From 62a5a413b51aa1fa4952e01870769d0ae985974d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:09:01 +0200 Subject: [PATCH 29/39] fix(core): Parse date-only timestamps with timezones Preserve ISO8601 parser compatibility for date-only values that include a timezone suffix. Keep modern date-only timezone parsing on the fast path and add parity coverage against the previous parser. --- .../io/sentry/vendor/SentryIso8601Utils.java | 54 +++++++++++++++++-- .../src/test/java/io/sentry/DateUtilsTest.kt | 31 +++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java index 622e86a8d6d..b5cb1811aa9 100644 --- a/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java +++ b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java @@ -39,10 +39,14 @@ public static long parseTimestamp(final @NotNull String timestamp) { final int day = parseInt(timestamp, offset, offset += 2); if (!checkOffset(timestamp, offset, 'T')) { - if (offset != length) { - throw new IllegalArgumentException("Invalid date separator"); + if (offset == length) { + return dateOnlyEpochMillis(year, month, day); } - return dateOnlyEpochMillis(year, month, day); + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z' || timezoneIndicator == '+' || timezoneIndicator == '-') { + return dateOnlyEpochMillisWithTimezone(timestamp, length, offset, year, month, day); + } + throw new IllegalArgumentException("Invalid date separator"); } validateDate(year, month, day); offset++; @@ -171,6 +175,50 @@ private static long dateOnlyEpochMillis(final int year, final int month, final i return new GregorianCalendar(year, month - 1, day).getTimeInMillis(); } + private static long dateOnlyEpochMillisWithTimezone( + final @NotNull String timestamp, + final int length, + int offset, + final int year, + final int month, + final int day) { + final int timezoneOffsetMillis; + final boolean allowTrailingCharacters; + final char timezoneIndicator = timestamp.charAt(offset); + if (timezoneIndicator == 'Z') { + timezoneOffsetMillis = 0; + offset++; + allowTrailingCharacters = true; + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + final int sign = timezoneIndicator == '+' ? 1 : -1; + offset++; + final int timezoneHour = parseInt(timestamp, offset, offset += 2); + int timezoneMinute = 0; + if (checkOffset(timestamp, offset, ':')) { + offset++; + } + if (length >= offset + 2) { + timezoneMinute = parseInt(timestamp, offset, offset += 2); + } + validateTimezone(timezoneHour, timezoneMinute); + timezoneOffsetMillis = + sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE); + allowTrailingCharacters = false; + } else { + throw new IllegalArgumentException("Invalid time zone indicator"); + } + + if (!allowTrailingCharacters && offset != length) { + throw new IllegalArgumentException("Invalid trailing characters"); + } + + if (isBeforeGregorianCutover(year, month, day)) { + return epochMillisWithCalendar(year, month, day, 0, 0, 0, 0, timezoneOffsetMillis); + } + validateDate(year, month, day); + return epochMillis(year, month, day, 0, 0, 0, 0, timezoneOffsetMillis); + } + private static long epochMillisWithCalendar( final int year, final int month, diff --git a/sentry/src/test/java/io/sentry/DateUtilsTest.kt b/sentry/src/test/java/io/sentry/DateUtilsTest.kt index ba60c43bfc4..97882198e62 100644 --- a/sentry/src/test/java/io/sentry/DateUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/DateUtilsTest.kt @@ -222,6 +222,37 @@ class DateUtilsTest { } } + @Test + fun `Fast timestamp parser matches previous ISO8601 parser for date-only values with timezone`() { + val input = + listOf( + "2020-03-27Z", + "2020-03-27+02:00", + "2020-03-27+0200", + "2020-03-27+02", + "2020-03-27-03:30", + "20200327Z", + "20200327+02:00", + "20200327-0330", + ) + + input.forEach { + assertEquals( + ISO8601Utils.parse(it, ParsePosition(0)).time, + DateUtils.getDateTime(it).time, + "timestamp=$it", + ) + } + } + + @Test + fun `Fast timestamp parser rejects invalid date-only values with timezone like previous ISO8601 parser`() { + val timestamp = "2020-02-30Z" + + assertFailsWith { ISO8601Utils.parse(timestamp, ParsePosition(0)) } + assertFailsWith { DateUtils.getDateTime(timestamp) } + } + @Test fun `Fast timestamp parser rejects date-time without timezone like previous ISO8601 parser`() { val input = listOf("2020-03-27T08:52", "2020-03-27T08:52:58", "2020-03-27T08:52:58.015") From 92262a22660978d22fefa308c62f862f0b8520fa Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:14:18 +0200 Subject: [PATCH 30/39] docs(core): Add timezone changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..f620816c8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Reduce Android startup overhead by using the default timezone directly on older devices or when no timezone info is available in the locale. ([#5587](https://github.com/getsentry/sentry-java/pull/5587)) + ## 8.43.1 ### Fixes From 6dc1c41bb05434f0f70587a4435d98feea9c37bb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:16:40 +0200 Subject: [PATCH 31/39] docs(core): Add DateUtils changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..c208972054e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Reduce timestamp helper overhead by replacing unnecessary `Calendar` usage in `DateUtils` with direct `Date` creation. ([#5589](https://github.com/getsentry/sentry-java/pull/5589)) + ## 8.43.1 ### Fixes From f3cf22c8f28051073c055a3a461549868124531c Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:17:45 +0200 Subject: [PATCH 32/39] docs(core): Add JsonWriter changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..dcd44754e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Reduce JSON serialization overhead by lowering the initial `JsonWriter` nesting stack size while preserving on-demand growth. ([#5591](https://github.com/getsentry/sentry-java/pull/5591)) + ## 8.43.1 ### Fixes From 2a32bebab490e7f8b7deb724db23ba27e98c12fb Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:18:36 +0200 Subject: [PATCH 33/39] docs(core): Add breadcrumb changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..644c6763914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Reduce breadcrumb allocation overhead by creating the `Breadcrumb` data map only when data is added. ([#5598](https://github.com/getsentry/sentry-java/pull/5598)) + ## 8.43.1 ### Fixes From e3e0df8c8394d790a1ff288a60dbd8694f82747e Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:19:07 +0200 Subject: [PATCH 34/39] docs(core): Add contexts changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..07c6bbb9194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Reduce context serialization overhead by sorting key snapshots with arrays instead of temporary lists. ([#5599](https://github.com/getsentry/sentry-java/pull/5599)) + ## 8.43.1 ### Fixes From 30db42a35004ba8989ebe33f6772f3d10074cedd Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:19:48 +0200 Subject: [PATCH 35/39] docs(core): Add reflection state changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..09bfd2e3ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Reduce JSON serialization overhead by allocating reflection cycle-tracking state only when reflection serialization is used. ([#5600](https://github.com/getsentry/sentry-java/pull/5600)) + ## 8.43.1 ### Fixes From 75cf8d2bc1afb092e8b79a1678c82393f96c7ddd Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:21:00 +0200 Subject: [PATCH 36/39] docs(core): Add reflection serializer changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..7273cfb4e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Reduce JSON serialization overhead by creating the reflection serializer only when unknown-object fallback serialization is needed. ([#5601](https://github.com/getsentry/sentry-java/pull/5601)) + ## 8.43.1 ### Fixes From 114578806027ccf9b7f3f5adc4ef02c4ef5d542d Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:21:39 +0200 Subject: [PATCH 37/39] docs(core): Add ISO8601 handling changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..dc4cde7459f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Internal + +- Reduce timestamp parsing and formatting overhead with Sentry-specific ISO-8601 handling. ([#5602](https://github.com/getsentry/sentry-java/pull/5602)) + ## 8.43.1 ### Fixes From 321d84d967acfc0f0aa99e0af41ec39fa4a16763 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Wed, 24 Jun 2026 17:25:01 +0200 Subject: [PATCH 38/39] docs(core): Add Date getter changelog entries --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a3b55b403..9422885bb8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +### Behavioral Changes + +- `Date` objects returned by SDK data model getters are shared state and should not be mutated. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) + - Previously, these getters returned defensive copies for some date fields. + - This has now changed in order to reduce SDK overhead. + +### Internal + +- Reduce model access overhead by avoiding defensive `Date` copies in SDK data model getters. ([#5603](https://github.com/getsentry/sentry-java/pull/5603)) + ## 8.43.1 ### Fixes From 8400bdff3e0131156aebb0fcf591b6058b299f03 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Thu, 25 Jun 2026 14:21:39 +0200 Subject: [PATCH 39/39] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdf8cd2a668..dafcc8ee1f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ - Previously, these getters returned defensive copies for some date fields. - This has now changed in order to reduce SDK overhead. -### Internal +### Performance - Reduce writer buffer size from 8192 to 512 ([#5544](https://github.com/getsentry/sentry-java/pull/5544)) - Remove redundant event map copies ([#5536](https://github.com/getsentry/sentry-java/pull/5536))