Skip to content

Commit 384b9d2

Browse files
runningcodeclaude
andcommitted
fix(core): Order breadcrumbs by their own timestamp (JAVA-579)
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) <noreply@anthropic.com>
1 parent 7bf5574 commit 384b9d2

2 files changed

Lines changed: 82 additions & 9 deletions

File tree

‎sentry/src/main/java/io/sentry/Breadcrumb.java‎

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,14 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab
2626
/** A timestamp representing when the breadcrumb occurred as java.util.Date. */
2727
private @Nullable Date timestamp;
2828

29-
private final @NotNull Long nanos;
29+
/**
30+
* The tick this breadcrumb was created at, used to order breadcrumbs that share a timestamp.
31+
*
32+
* <p>Null for a breadcrumb rebuilt from a serialized one. A tick is a reading of a counter whose
33+
* origin is this process run, so a tick from an earlier run is a number from an unrelated origin
34+
* rather than a position in this run's order.
35+
*/
36+
private final @Nullable Long creationTick;
3037

3138
/** If a message is provided, its rendered as text and the whitespace is preserved. */
3239
private @Nullable String message;
@@ -59,21 +66,33 @@ public final class Breadcrumb implements JsonUnknown, JsonSerializable, Comparab
5966
*
6067
* @param timestamp the timestamp
6168
*/
62-
@SuppressWarnings("JavaUtilDate")
6369
public Breadcrumb(final @NotNull Date timestamp) {
64-
this.nanos = System.nanoTime();
70+
this(timestamp, System.nanoTime());
71+
}
72+
73+
@SuppressWarnings("JavaUtilDate")
74+
private Breadcrumb(final @NotNull Date timestamp, final @Nullable Long creationTick) {
75+
this.creationTick = creationTick;
6576
this.timestamp = timestamp;
6677
this.timestampMs = null;
6778
}
6879

80+
/**
81+
* A breadcrumb rebuilt from a serialized one — read back from disk, or handed over by a hybrid
82+
* SDK. It carries the timestamp it was serialized with and no creation tick.
83+
*/
84+
static @NotNull Breadcrumb deserialized(final @NotNull Date timestamp) {
85+
return new Breadcrumb(timestamp, null);
86+
}
87+
6988
public Breadcrumb(final long timestamp) {
70-
this.nanos = System.nanoTime();
89+
this.creationTick = System.nanoTime();
7190
this.timestampMs = timestamp;
7291
this.timestamp = null;
7392
}
7493

7594
Breadcrumb(final @NotNull Breadcrumb breadcrumb) {
76-
this.nanos = System.nanoTime();
95+
this.creationTick = breadcrumb.creationTick;
7796
this.timestamp = breadcrumb.timestamp;
7897
this.timestampMs = breadcrumb.timestampMs;
7998
this.message = breadcrumb.message;
@@ -170,7 +189,7 @@ public static Breadcrumb fromMap(
170189
}
171190
}
172191

173-
final Breadcrumb breadcrumb = new Breadcrumb(timestamp);
192+
final Breadcrumb breadcrumb = Breadcrumb.deserialized(timestamp);
174193
breadcrumb.message = message;
175194
breadcrumb.type = type;
176195
if (data != null) {
@@ -831,8 +850,21 @@ public void setUnknown(@Nullable Map<String, Object> unknown) {
831850

832851
@Override
833852
@SuppressWarnings("JavaUtilDate")
834-
public int compareTo(@NotNull Breadcrumb o) {
835-
return nanos.compareTo(o.nanos);
853+
public int compareTo(final @NotNull Breadcrumb o) {
854+
final int byTimestamp = getTimestamp().compareTo(o.getTimestamp());
855+
if (byTimestamp != 0) {
856+
return byTimestamp;
857+
}
858+
// Timestamps are millisecond-granular, so breadcrumbs recorded in the same millisecond tie.
859+
// Creation ticks break the tie in the order they were actually recorded.
860+
if (creationTick == null) {
861+
// Deserialized, so from an earlier process run than anything holding a tick.
862+
return o.creationTick == null ? 0 : -1;
863+
}
864+
if (o.creationTick == null) {
865+
return 1;
866+
}
867+
return creationTick.compareTo(o.creationTick);
836868
}
837869

838870
public static final class JsonKeys {
@@ -941,7 +973,7 @@ public static final class Deserializer implements JsonDeserializer<Breadcrumb> {
941973
}
942974
}
943975

944-
Breadcrumb breadcrumb = new Breadcrumb(timestamp);
976+
Breadcrumb breadcrumb = Breadcrumb.deserialized(timestamp);
945977
breadcrumb.message = message;
946978
breadcrumb.type = type;
947979
if (data != null) {

‎sentry/src/test/java/io/sentry/BreadcrumbTest.kt‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.sentry
22

3+
import com.google.common.truth.Truth.assertThat
34
import java.util.Date
45
import java.util.concurrent.CountDownLatch
56
import java.util.concurrent.Executors
@@ -365,6 +366,46 @@ class BreadcrumbTest {
365366
}
366367
}
367368

369+
@Test
370+
fun `breadcrumbs sharing a timestamp keep the order they were recorded in`() {
371+
val timestamp = Date(1_600_000_000_000)
372+
val first = Breadcrumb(timestamp).apply { message = "first" }
373+
val second = Breadcrumb(timestamp).apply { message = "second" }
374+
val third = Breadcrumb(timestamp).apply { message = "third" }
375+
376+
val sorted = listOf(third, first, second).sorted().map { it.message }
377+
378+
assertThat(sorted).containsExactly("first", "second", "third").inOrder()
379+
}
380+
381+
@Test
382+
fun `a deserialized breadcrumb is ordered by its own timestamp, not by when it was parsed`() {
383+
val live = Breadcrumb(Date(1_600_000_000_000)).apply { message = "live" }
384+
val restored =
385+
Breadcrumb.fromMap(
386+
mapOf(
387+
Breadcrumb.JsonKeys.TIMESTAMP to DateUtils.getTimestamp(Date(1_500_000_000_000)),
388+
Breadcrumb.JsonKeys.MESSAGE to "restored",
389+
),
390+
SentryOptions(),
391+
)
392+
393+
val sorted = listOf(live, restored).sorted().map { it.message }
394+
395+
assertThat(sorted).containsExactly("restored", "live").inOrder()
396+
}
397+
398+
@Test
399+
fun `cloning a breadcrumb keeps its position among breadcrumbs sharing its timestamp`() {
400+
val timestamp = Date(1_600_000_000_000)
401+
val first = Breadcrumb(timestamp).apply { message = "first" }
402+
val second = Breadcrumb(timestamp).apply { message = "second" }
403+
404+
val sorted = listOf(second, Breadcrumb(first)).sorted().map { it.message }
405+
406+
assertThat(sorted).containsExactly("first", "second").inOrder()
407+
}
408+
368409
class TestKey(val id: Long) {
369410
override fun toString(): String = id.toString()
370411
}

0 commit comments

Comments
 (0)