From 8bf7636dc91fc82984b0bcaf2563b8a5ea8cf4a2 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 11 Sep 2026 15:47:33 +0200 Subject: [PATCH 1/4] fix(core): Order breadcrumbs by their own timestamp (JAVA-579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breadcrumb.compareTo ordered purely by a System.nanoTime() reading taken in the constructor. A breadcrumb rebuilt from a serialized one — read back from disk, or handed over by a hybrid SDK — got that reading at parse time, so a breadcrumb recorded yesterday sorted as if it had just happened, and the merged order in CombinedScopeView became parse order. The clone constructor had the same problem: copying a breadcrumb moved it to the end of the order. Order by the recorded timestamp instead, and keep the creation tick only as the tie-breaker it was added for in #3355, since timestamps are millisecond-granular. A deserialized breadcrumb carries no tick, and a clone carries the original's, so neither jumps position. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/io/sentry/Breadcrumb.java | 50 +++++++++++++++---- .../src/test/java/io/sentry/BreadcrumbTest.kt | 41 +++++++++++++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index fff6954ee56..9a91b1630d8 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -26,7 +26,14 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab /** A timestamp representing when the breadcrumb occurred as java.util.Date. */ private @Nullable Date timestamp; - private final @NotNull Long nanos; + /** + * The tick this breadcrumb was created at, used to order breadcrumbs that share a timestamp. + * + *

Null for a breadcrumb rebuilt from a serialized one. A tick is a reading of a counter whose + * origin is this process run, so a tick from an earlier run is a number from an unrelated origin + * rather than a position in this run's order. + */ + private final @Nullable Long creationTick; /** If a message is provided, its rendered as text and the whitespace is preserved. */ private @Nullable String message; @@ -59,21 +66,33 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab * * @param timestamp the timestamp */ - @SuppressWarnings("JavaUtilDate") public Breadcrumb(final @NotNull Date timestamp) { - this.nanos = System.nanoTime(); + this(timestamp, System.nanoTime()); + } + + @SuppressWarnings("JavaUtilDate") + private Breadcrumb(final @NotNull Date timestamp, final @Nullable Long creationTick) { + this.creationTick = creationTick; this.timestamp = timestamp; this.timestampMs = null; } + /** + * A breadcrumb rebuilt from a serialized one — read back from disk, or handed over by a hybrid + * SDK. It carries the timestamp it was serialized with and no creation tick. + */ + static @NotNull Breadcrumb deserialized(final @NotNull Date timestamp) { + return new Breadcrumb(timestamp, null); + } + public Breadcrumb(final long timestamp) { - this.nanos = System.nanoTime(); + this.creationTick = System.nanoTime(); this.timestampMs = timestamp; this.timestamp = null; } Breadcrumb(final @NotNull Breadcrumb breadcrumb) { - this.nanos = System.nanoTime(); + this.creationTick = breadcrumb.creationTick; this.timestamp = breadcrumb.timestamp; this.timestampMs = breadcrumb.timestampMs; this.message = breadcrumb.message; @@ -170,7 +189,7 @@ public static Breadcrumb fromMap( } } - final Breadcrumb breadcrumb = new Breadcrumb(timestamp); + final Breadcrumb breadcrumb = Breadcrumb.deserialized(timestamp); breadcrumb.message = message; breadcrumb.type = type; if (data != null) { @@ -831,8 +850,21 @@ public void setUnknown(@Nullable Map unknown) { @Override @SuppressWarnings("JavaUtilDate") - public int compareTo(@NotNull Breadcrumb o) { - return nanos.compareTo(o.nanos); + public int compareTo(final @NotNull Breadcrumb o) { + final int byTimestamp = getTimestamp().compareTo(o.getTimestamp()); + if (byTimestamp != 0) { + return byTimestamp; + } + // Timestamps are millisecond-granular, so breadcrumbs recorded in the same millisecond tie. + // Creation ticks break the tie in the order they were actually recorded. + if (creationTick == null) { + // Deserialized, so from an earlier process run than anything holding a tick. + return o.creationTick == null ? 0 : -1; + } + if (o.creationTick == null) { + return 1; + } + return creationTick.compareTo(o.creationTick); } public static final class JsonKeys { @@ -941,7 +973,7 @@ public static final class Deserializer implements JsonDeserializer { } } - Breadcrumb breadcrumb = new Breadcrumb(timestamp); + Breadcrumb breadcrumb = Breadcrumb.deserialized(timestamp); breadcrumb.message = message; breadcrumb.type = type; if (data != null) { diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index f51acca81cb..14742fecb8d 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -1,5 +1,6 @@ package io.sentry +import com.google.common.truth.Truth.assertThat import java.util.Date import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -365,6 +366,46 @@ class BreadcrumbTest { } } + @Test + fun `breadcrumbs sharing a timestamp keep the order they were recorded in`() { + val timestamp = Date(1_600_000_000_000) + val first = Breadcrumb(timestamp).apply { message = "first" } + val second = Breadcrumb(timestamp).apply { message = "second" } + val third = Breadcrumb(timestamp).apply { message = "third" } + + val sorted = listOf(third, first, second).sorted().map { it.message } + + assertThat(sorted).containsExactly("first", "second", "third").inOrder() + } + + @Test + fun `a deserialized breadcrumb is ordered by its own timestamp, not by when it was parsed`() { + val live = Breadcrumb(Date(1_600_000_000_000)).apply { message = "live" } + val restored = + Breadcrumb.fromMap( + mapOf( + Breadcrumb.JsonKeys.TIMESTAMP to DateUtils.getTimestamp(Date(1_500_000_000_000)), + Breadcrumb.JsonKeys.MESSAGE to "restored", + ), + SentryOptions(), + ) + + val sorted = listOf(live, restored).sorted().map { it.message } + + assertThat(sorted).containsExactly("restored", "live").inOrder() + } + + @Test + fun `cloning a breadcrumb keeps its position among breadcrumbs sharing its timestamp`() { + val timestamp = Date(1_600_000_000_000) + val first = Breadcrumb(timestamp).apply { message = "first" } + val second = Breadcrumb(timestamp).apply { message = "second" } + + val sorted = listOf(second, Breadcrumb(first)).sorted().map { it.message } + + assertThat(sorted).containsExactly("first", "second").inOrder() + } + class TestKey(val id: Long) { override fun toString(): String = id.toString() } From 162e99ed6470a0fbcf09129cdf81d1635f169850 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Fri, 11 Sep 2026 15:48:15 +0200 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ef1a8f2c0..0e0dc719613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ ### Fixes - Keep resolving the server name after `Sentry.close()` or a re-init. Closing the SDK shut down the shared hostname cache for the life of the process, so `server_name` silently froze at the value it had last resolved ([#6119](https://github.com/getsentry/sentry-java/pull/6119)) +- Order breadcrumbs by the timestamp they carry rather than by when they were created in the current process, so breadcrumbs restored from disk or handed over by a hybrid SDK no longer sort as if they had just happened ([#6097](https://github.com/getsentry/sentry-java/pull/6097)) ### Internal From a2de9abc4a534552be236508ab2d629cfea09a73 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 15 Sep 2026 13:28:42 +0200 Subject: [PATCH 3/4] ref(core): Keep the creation tick on deserialized breadcrumbs Always fill the tick and let the timestamp comparison carry the fix, so ordering no longer depends on every caller using a stable sort. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/io/sentry/Breadcrumb.java | 48 +++++-------------- .../src/test/java/io/sentry/BreadcrumbTest.kt | 15 ++++++ 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index 9a91b1630d8..159e8adddf2 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -26,14 +26,7 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab /** A timestamp representing when the breadcrumb occurred as java.util.Date. */ private @Nullable Date timestamp; - /** - * The tick this breadcrumb was created at, used to order breadcrumbs that share a timestamp. - * - *

Null for a breadcrumb rebuilt from a serialized one. A tick is a reading of a counter whose - * origin is this process run, so a tick from an earlier run is a number from an unrelated origin - * rather than a position in this run's order. - */ - private final @Nullable Long creationTick; + private final @NotNull Long nanos; /** If a message is provided, its rendered as text and the whitespace is preserved. */ private @Nullable String message; @@ -66,33 +59,23 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab * * @param timestamp the timestamp */ - public Breadcrumb(final @NotNull Date timestamp) { - this(timestamp, System.nanoTime()); - } - @SuppressWarnings("JavaUtilDate") - private Breadcrumb(final @NotNull Date timestamp, final @Nullable Long creationTick) { - this.creationTick = creationTick; + public Breadcrumb(final @NotNull Date timestamp) { + this.nanos = System.nanoTime(); this.timestamp = timestamp; this.timestampMs = null; } - /** - * A breadcrumb rebuilt from a serialized one — read back from disk, or handed over by a hybrid - * SDK. It carries the timestamp it was serialized with and no creation tick. - */ - static @NotNull Breadcrumb deserialized(final @NotNull Date timestamp) { - return new Breadcrumb(timestamp, null); - } - public Breadcrumb(final long timestamp) { - this.creationTick = System.nanoTime(); + this.nanos = System.nanoTime(); this.timestampMs = timestamp; this.timestamp = null; } Breadcrumb(final @NotNull Breadcrumb breadcrumb) { - this.creationTick = breadcrumb.creationTick; + // A clone stands in for the breadcrumb it was copied from, so it inherits its tie-breaker + // instead of taking a fresh one and sorting after everything recorded since. + this.nanos = breadcrumb.nanos; this.timestamp = breadcrumb.timestamp; this.timestampMs = breadcrumb.timestampMs; this.message = breadcrumb.message; @@ -189,7 +172,7 @@ public static Breadcrumb fromMap( } } - final Breadcrumb breadcrumb = Breadcrumb.deserialized(timestamp); + final Breadcrumb breadcrumb = new Breadcrumb(timestamp); breadcrumb.message = message; breadcrumb.type = type; if (data != null) { @@ -850,21 +833,14 @@ public void setUnknown(@Nullable Map unknown) { @Override @SuppressWarnings("JavaUtilDate") - public int compareTo(final @NotNull Breadcrumb o) { + public int compareTo(@NotNull Breadcrumb o) { final int byTimestamp = getTimestamp().compareTo(o.getTimestamp()); if (byTimestamp != 0) { return byTimestamp; } // Timestamps are millisecond-granular, so breadcrumbs recorded in the same millisecond tie. - // Creation ticks break the tie in the order they were actually recorded. - if (creationTick == null) { - // Deserialized, so from an earlier process run than anything holding a tick. - return o.creationTick == null ? 0 : -1; - } - if (o.creationTick == null) { - return 1; - } - return creationTick.compareTo(o.creationTick); + // nanos is only meaningful within a process run, which is all a tie-breaker has to cover. + return nanos.compareTo(o.nanos); } public static final class JsonKeys { @@ -973,7 +949,7 @@ public static final class Deserializer implements JsonDeserializer { } } - Breadcrumb breadcrumb = Breadcrumb.deserialized(timestamp); + Breadcrumb breadcrumb = new Breadcrumb(timestamp); breadcrumb.message = message; breadcrumb.type = type; if (data != null) { diff --git a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt index 14742fecb8d..d0341f8e58e 100644 --- a/sentry/src/test/java/io/sentry/BreadcrumbTest.kt +++ b/sentry/src/test/java/io/sentry/BreadcrumbTest.kt @@ -1,6 +1,7 @@ package io.sentry import com.google.common.truth.Truth.assertThat +import java.io.StringReader import java.util.Date import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -395,6 +396,20 @@ class BreadcrumbTest { assertThat(sorted).containsExactly("restored", "live").inOrder() } + @Test + fun `a breadcrumb read back from JSON is ordered by its own timestamp, not by when it was parsed`() { + val live = Breadcrumb(Date(1_600_000_000_000)).apply { message = "live" } + val json = + """{"timestamp":"${DateUtils.getTimestamp(Date(1_500_000_000_000))}","message":"restored"}""" + val restored = + Breadcrumb.Deserializer() + .deserialize(JsonObjectReader(StringReader(json)), NoOpLogger.getInstance()) + + val sorted = listOf(live, restored).sorted().map { it.message } + + assertThat(sorted).containsExactly("restored", "live").inOrder() + } + @Test fun `cloning a breadcrumb keeps its position among breadcrumbs sharing its timestamp`() { val timestamp = Date(1_600_000_000_000) From f2239f7b6bbc57cb8a0d48a68ff6ace5fbde7c9c Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Tue, 15 Sep 2026 14:17:04 +0200 Subject: [PATCH 4/4] ref(core): Sort breadcrumbs by their natural ordering in SentryClient SortBreadcrumbsByDate compared timestamps only, so ties fell through to the sort's stability. Breadcrumb.compareTo now defines that same order with a defined tie-breaker, leaving the comparator a weaker duplicate of it and the codebase with two definitions of breadcrumb order. Co-Authored-By: Claude Opus 5 (1M context) --- sentry/src/main/java/io/sentry/SentryClient.java | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 0e44812a491..4bba195feea 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -26,7 +26,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.Comparator; import java.util.List; import java.util.Map; import org.jetbrains.annotations.ApiStatus; @@ -41,7 +40,6 @@ public final class SentryClient implements ISentryClient { private final @NotNull SentryOptions options; private final @NotNull ITransport transport; - private final @NotNull SortBreadcrumbsByDate sortBreadcrumbsByDate = new SortBreadcrumbsByDate(); private final @NotNull ILoggerBatchProcessor loggerBatchProcessor; private final @NotNull IMetricsBatchProcessor metricsBatchProcessor; @@ -1660,7 +1658,7 @@ private void sortBreadcrumbsByDate( if (sortedBreadcrumbs != null && !breadcrumbs.isEmpty()) { sortedBreadcrumbs.addAll(breadcrumbs); - Collections.sort(sortedBreadcrumbs, sortBreadcrumbsByDate); + Collections.sort(sortedBreadcrumbs); } } @@ -1851,13 +1849,4 @@ private boolean sample() { } return true; } - - private static final class SortBreadcrumbsByDate implements Comparator { - - @SuppressWarnings({"JdkObsolete", "JavaUtilDate"}) - @Override - public int compare(final @NotNull Breadcrumb b1, final @NotNull Breadcrumb b2) { - return b1.getTimestamp().compareTo(b2.getTimestamp()); - } - } }