Skip to content

Commit bfbf498

Browse files
committed
fix(replay): Populate trace_ids in replay events for trace search
When a transaction is captured while replay is recording, the trace ID is now registered with the replay controller and included in the next replay segment. This enables searching for replays by trace ID in the Sentry UI. Fixes #5346 Slack thread: https://sentry.slack.com/archives/CP4UUUF1S/p1779889727948439?thread_ts=1777385469.860819&cid=CP4UUUF1S https://claude.ai/code/session_012wjHQtsEPzcrxSMCufxrDY
1 parent 14c1d7e commit bfbf498

9 files changed

Lines changed: 171 additions & 2 deletions

File tree

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,13 @@ public class ReplayIntegration(
280280

281281
override fun isDebugMaskingOverlayEnabled(): Boolean = debugMaskingEnabled
282282

283+
override fun registerTraceId(traceId: SentryId) {
284+
if (!isEnabled.get() || !isRecording()) {
285+
return
286+
}
287+
captureStrategy?.registerTraceId(traceId)
288+
}
289+
283290
private fun pauseInternal() {
284291
lifecycleLock.acquire().use {
285292
if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) {

sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import io.sentry.transport.ICurrentDateProvider
3333
import java.io.File
3434
import java.util.Date
3535
import java.util.Deque
36+
import java.util.concurrent.ConcurrentHashMap
3637
import java.util.concurrent.ConcurrentLinkedDeque
3738
import java.util.concurrent.Executors
3839
import java.util.concurrent.ScheduledExecutorService
@@ -96,6 +97,7 @@ internal abstract class BaseCaptureStrategy(
9697
override var replayType by persistableAtomic<ReplayType>(propertyName = SEGMENT_KEY_REPLAY_TYPE)
9798

9899
protected val currentEvents: Deque<RRWebEvent> = ConcurrentLinkedDeque()
100+
protected val currentTraceIds: MutableSet<String> = ConcurrentHashMap.newKeySet()
99101

100102
override fun start(segmentId: Int, replayId: SentryId, replayType: ReplayType?) {
101103
cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId)
@@ -135,8 +137,10 @@ internal abstract class BaseCaptureStrategy(
135137
screenAtStart: String? = this.screenAtStart,
136138
breadcrumbs: List<Breadcrumb>? = null,
137139
events: Deque<RRWebEvent> = this.currentEvents,
138-
): ReplaySegment =
139-
createSegment(
140+
): ReplaySegment {
141+
val traceIds = currentTraceIds.toList().ifEmpty { null }
142+
currentTraceIds.clear()
143+
return createSegment(
140144
scopes,
141145
options,
142146
duration,
@@ -152,7 +156,9 @@ internal abstract class BaseCaptureStrategy(
152156
screenAtStart,
153157
breadcrumbs,
154158
events,
159+
traceIds,
155160
)
161+
}
156162

157163
override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) {
158164
this.recorderConfig = recorderConfig
@@ -167,6 +173,12 @@ internal abstract class BaseCaptureStrategy(
167173
}
168174
}
169175

176+
override fun registerTraceId(traceId: SentryId) {
177+
if (traceId != SentryId.EMPTY_ID) {
178+
currentTraceIds.add(traceId.toString())
179+
}
180+
}
181+
170182
private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory {
171183
private var cnt = 0
172184

sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ internal interface CaptureStrategy {
5353

5454
fun convert(): CaptureStrategy
5555

56+
fun registerTraceId(traceId: SentryId)
57+
5658
companion object {
5759
private fun Breadcrumb?.isNetworkAvailable(): Boolean =
5860
this != null &&
@@ -84,6 +86,7 @@ internal interface CaptureStrategy {
8486
screenAtStart: String?,
8587
breadcrumbs: List<Breadcrumb>?,
8688
events: Deque<RRWebEvent>,
89+
traceIds: List<String>? = null,
8790
): ReplaySegment {
8891
val generatedVideo =
8992
cache?.createVideoOf(
@@ -122,6 +125,7 @@ internal interface CaptureStrategy {
122125
screenAtStart,
123126
replayBreadcrumbs,
124127
events,
128+
traceIds,
125129
)
126130
}
127131

@@ -141,6 +145,7 @@ internal interface CaptureStrategy {
141145
screenAtStart: String?,
142146
breadcrumbs: List<Breadcrumb>,
143147
events: Deque<RRWebEvent>,
148+
traceIds: List<String>?,
144149
): ReplaySegment {
145150
val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration)
146151
val replay =
@@ -152,6 +157,7 @@ internal interface CaptureStrategy {
152157
this.replayStartTimestamp = segmentTimestamp
153158
this.replayType = replayType
154159
this.videoFile = video
160+
this.traceIds = traceIds
155161
}
156162

157163
val recordingPayload = mutableListOf<RRWebEvent>()

sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,38 @@ class ReplayIntegrationTest {
10721072
verify(fixture.replayCache).addFrame(any<Bitmap>(), any(), anyOrNull())
10731073
}
10741074

1075+
@Test
1076+
fun `registerTraceId does nothing when replay is not started`() {
1077+
val replay = fixture.getSut(context)
1078+
1079+
replay.register(fixture.scopes, fixture.options)
1080+
// Don't call start()
1081+
1082+
// Should not throw
1083+
replay.registerTraceId(SentryId())
1084+
}
1085+
1086+
@Test
1087+
fun `registerTraceId forwards to capture strategy when recording`() {
1088+
var traceIdRegistered: SentryId? = null
1089+
val captureStrategy =
1090+
mock<CaptureStrategy> {
1091+
on { currentReplayId }.thenReturn(SentryId())
1092+
doAnswer { traceIdRegistered = it.arguments[0] as SentryId }
1093+
.whenever(mock)
1094+
.registerTraceId(any())
1095+
}
1096+
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })
1097+
1098+
replay.register(fixture.scopes, fixture.options)
1099+
replay.start()
1100+
1101+
val traceId = SentryId()
1102+
replay.registerTraceId(traceId)
1103+
1104+
assertEquals(traceId, traceIdRegistered)
1105+
}
1106+
10751107
private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy =
10761108
SessionCaptureStrategy(
10771109
options,

sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,4 +475,81 @@ class SessionCaptureStrategyTest {
475475
},
476476
)
477477
}
478+
479+
@Test
480+
fun `registerTraceId includes trace IDs in next segment`() {
481+
val now =
482+
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
483+
val strategy = fixture.getSut(dateProvider = { now })
484+
strategy.start()
485+
strategy.onConfigurationChanged(fixture.recorderConfig)
486+
487+
val traceId1 = SentryId()
488+
val traceId2 = SentryId()
489+
strategy.registerTraceId(traceId1)
490+
strategy.registerTraceId(traceId2)
491+
492+
strategy.onScreenshotRecorded(mock<Bitmap>()) {}
493+
494+
verify(fixture.scopes)
495+
.captureReplay(
496+
argThat { event ->
497+
event is SentryReplayEvent &&
498+
event.traceIds?.size == 2 &&
499+
event.traceIds!!.contains(traceId1.toString()) &&
500+
event.traceIds!!.contains(traceId2.toString())
501+
},
502+
any(),
503+
)
504+
}
505+
506+
@Test
507+
fun `registerTraceId clears trace IDs after segment is created`() {
508+
val now =
509+
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
510+
val strategy = fixture.getSut(dateProvider = { now })
511+
strategy.start()
512+
strategy.onConfigurationChanged(fixture.recorderConfig)
513+
514+
val traceId = SentryId()
515+
strategy.registerTraceId(traceId)
516+
517+
strategy.onScreenshotRecorded(mock<Bitmap>()) {}
518+
519+
verify(fixture.scopes)
520+
.captureReplay(
521+
argThat { event ->
522+
event is SentryReplayEvent && event.traceIds?.contains(traceId.toString()) == true
523+
},
524+
any(),
525+
)
526+
527+
// trigger another segment, trace IDs should be cleared
528+
strategy.onScreenshotRecorded(mock<Bitmap>()) {}
529+
530+
verify(fixture.scopes)
531+
.captureReplay(
532+
argThat { event -> event is SentryReplayEvent && event.segmentId == 1 && event.traceIds.isNullOrEmpty() },
533+
any(),
534+
)
535+
}
536+
537+
@Test
538+
fun `registerTraceId ignores empty trace ID`() {
539+
val now =
540+
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
541+
val strategy = fixture.getSut(dateProvider = { now })
542+
strategy.start()
543+
strategy.onConfigurationChanged(fixture.recorderConfig)
544+
545+
strategy.registerTraceId(SentryId.EMPTY_ID)
546+
547+
strategy.onScreenshotRecorded(mock<Bitmap>()) {}
548+
549+
verify(fixture.scopes)
550+
.captureReplay(
551+
argThat { event -> event is SentryReplayEvent && event.traceIds.isNullOrEmpty() },
552+
any(),
553+
)
554+
}
478555
}

sentry/src/main/java/io/sentry/NoOpReplayController.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,7 @@ public void enableDebugMaskingOverlay() {}
5757

5858
@Override
5959
public void disableDebugMaskingOverlay() {}
60+
61+
@Override
62+
public void registerTraceId(@NotNull SentryId traceId) {}
6063
}

sentry/src/main/java/io/sentry/ReplayController.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,12 @@ public interface ReplayController extends IReplayApi {
2828
ReplayBreadcrumbConverter getBreadcrumbConverter();
2929

3030
boolean isDebugMaskingOverlayEnabled();
31+
32+
/**
33+
* Registers a trace ID to be associated with the current replay. This is called when a
34+
* transaction is captured while replay is recording, to enable searching for replays by trace ID.
35+
*
36+
* @param traceId the trace ID to associate with the current replay
37+
*/
38+
void registerTraceId(@NotNull SentryId traceId);
3139
}

sentry/src/main/java/io/sentry/SentryClient.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,13 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint
10431043
sentryId = SentryId.EMPTY_ID;
10441044
}
10451045

1046+
if (!sentryId.equals(SentryId.EMPTY_ID)) {
1047+
final @Nullable SpanContext trace = transaction.getContexts().getTrace();
1048+
if (trace != null) {
1049+
options.getReplayController().registerTraceId(trace.getTraceId());
1050+
}
1051+
}
1052+
10461053
return sentryId;
10471054
}
10481055

sentry/src/test/java/io/sentry/SentryClientTest.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1958,6 +1958,23 @@ class SentryClientTest {
19581958
assertEquals("abc", transaction.platform)
19591959
}
19601960

1961+
@Test
1962+
fun `captureTransaction registers trace ID with replay controller`() {
1963+
var registeredTraceId: SentryId? = null
1964+
fixture.sentryOptions.setReplayController(
1965+
object : ReplayController by NoOpReplayController.getInstance() {
1966+
override fun registerTraceId(traceId: SentryId) {
1967+
registeredTraceId = traceId
1968+
}
1969+
}
1970+
)
1971+
val sut = fixture.getSut()
1972+
val sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes)
1973+
val transaction = SentryTransaction(sentryTracer)
1974+
sut.captureTransaction(transaction, sentryTracer.traceContext())
1975+
assertEquals(sentryTracer.spanContext.traceId, registeredTraceId)
1976+
}
1977+
19611978
@Test
19621979
fun `when exception type is ignored, capturing event does not send it`() {
19631980
fixture.sentryOptions.addIgnoredExceptionForType(IllegalStateException::class.java)

0 commit comments

Comments
 (0)