Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Fixes

- Session Replay: Populate `trace_ids` in replay events to enable searching replays by trace ID ([#5473](https://github.com/getsentry/sentry-java/pull/5473))

## 8.43.0

### Features
Expand Down
1 change: 1 addition & 0 deletions sentry-android-replay/api/sentry-android-replay.api
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne
public fun onWindowSizeChanged (II)V
public fun pause ()V
public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V
public fun registerTraceId (Lio/sentry/protocol/SentryId;)V
public fun resume ()V
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
public fun start ()V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,13 @@ public class ReplayIntegration(

override fun isDebugMaskingOverlayEnabled(): Boolean = debugMaskingEnabled

override fun registerTraceId(traceId: SentryId) {
if (!isEnabled.get() || !isRecording()) {
return
}
captureStrategy?.registerTraceId(traceId)
}

private fun pauseInternal() {
lifecycleLock.acquire().use {
if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ internal abstract class BaseCaptureStrategy(
) : CaptureStrategy {
internal companion object {
private const val TAG = "CaptureStrategy"
// https://github.com/getsentry/sentry-javascript/blob/30eb68fff5077211c30c61ba74625e66ab514870/packages/replay-internal/src/coreHandlers/handleAfterSendEvent.ts#L41
private const val MAX_TRACE_IDS = 100
}

private val persistingExecutor: ScheduledExecutorService by lazy {
Expand Down Expand Up @@ -96,6 +98,8 @@ internal abstract class BaseCaptureStrategy(
override var replayType by persistableAtomic<ReplayType>(propertyName = SEGMENT_KEY_REPLAY_TYPE)

protected val currentEvents: Deque<RRWebEvent> = ConcurrentLinkedDeque()
private val traceIdsLock = Any()
private val currentTraceIds: MutableList<String> = mutableListOf()

override fun start(segmentId: Int, replayId: SentryId, replayType: ReplayType?) {
cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId)
Expand Down Expand Up @@ -135,8 +139,14 @@ internal abstract class BaseCaptureStrategy(
screenAtStart: String? = this.screenAtStart,
breadcrumbs: List<Breadcrumb>? = null,
events: Deque<RRWebEvent> = this.currentEvents,
): ReplaySegment =
createSegment(
): ReplaySegment {
val traceIds =
synchronized(traceIdsLock) {
val ids = currentTraceIds.toList()
currentTraceIds.clear()
ids
}
return createSegment(
scopes,
options,
duration,
Expand All @@ -152,7 +162,9 @@ internal abstract class BaseCaptureStrategy(
screenAtStart,
breadcrumbs,
events,
traceIds,
)
}

override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) {
this.recorderConfig = recorderConfig
Expand All @@ -167,6 +179,19 @@ internal abstract class BaseCaptureStrategy(
}
}

override fun registerTraceId(traceId: SentryId) {
if (traceId != SentryId.EMPTY_ID) {
synchronized(traceIdsLock) {
if (currentTraceIds.size < MAX_TRACE_IDS) {
val id = traceId.toString()
if (!currentTraceIds.contains(id)) {
currentTraceIds.add(id)
}
}
}
}
}

private class ReplayPersistingExecutorServiceThreadFactory : ThreadFactory {
private var cnt = 0

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ internal interface CaptureStrategy {

fun convert(): CaptureStrategy

fun registerTraceId(traceId: SentryId)

companion object {
private fun Breadcrumb?.isNetworkAvailable(): Boolean =
this != null &&
Expand Down Expand Up @@ -84,6 +86,7 @@ internal interface CaptureStrategy {
screenAtStart: String?,
breadcrumbs: List<Breadcrumb>?,
events: Deque<RRWebEvent>,
traceIds: List<String> = emptyList(),
): ReplaySegment {
val generatedVideo =
cache?.createVideoOf(
Expand Down Expand Up @@ -122,6 +125,7 @@ internal interface CaptureStrategy {
screenAtStart,
replayBreadcrumbs,
events,
traceIds,
)
}

Expand All @@ -141,6 +145,7 @@ internal interface CaptureStrategy {
screenAtStart: String?,
breadcrumbs: List<Breadcrumb>,
events: Deque<RRWebEvent>,
traceIds: List<String>,
): ReplaySegment {
val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration)
val replay =
Expand All @@ -152,6 +157,7 @@ internal interface CaptureStrategy {
this.replayStartTimestamp = segmentTimestamp
this.replayType = replayType
this.videoFile = video
this.traceIds = traceIds
Comment thread
sentry[bot] marked this conversation as resolved.
}

val recordingPayload = mutableListOf<RRWebEvent>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,38 @@ class ReplayIntegrationTest {
verify(fixture.replayCache).addFrame(any<Bitmap>(), any(), anyOrNull())
}

@Test
fun `registerTraceId does nothing when replay is not started`() {
val replay = fixture.getSut(context)

replay.register(fixture.scopes, fixture.options)
// Don't call start()

// Should not throw
replay.registerTraceId(SentryId())
}

@Test
fun `registerTraceId forwards to capture strategy when recording`() {
var traceIdRegistered: SentryId? = null
val captureStrategy =
mock<CaptureStrategy> {
on { currentReplayId }.thenReturn(SentryId())
doAnswer { traceIdRegistered = it.arguments[0] as SentryId }
.whenever(mock)
.registerTraceId(any())
}
val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy })

replay.register(fixture.scopes, fixture.options)
replay.start()

val traceId = SentryId()
replay.registerTraceId(traceId)

assertEquals(traceId, traceIdRegistered)
}

private fun getSessionCaptureStrategy(options: SentryOptions): SessionCaptureStrategy =
SessionCaptureStrategy(
options,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,4 +475,83 @@ class SessionCaptureStrategyTest {
},
)
}

@Test
fun `registerTraceId includes trace IDs in next segment`() {
val now =
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
val strategy = fixture.getSut(dateProvider = { now })
strategy.start()
strategy.onConfigurationChanged(fixture.recorderConfig)

val traceId1 = SentryId()
val traceId2 = SentryId()
strategy.registerTraceId(traceId1)
strategy.registerTraceId(traceId2)

strategy.onScreenshotRecorded(mock<Bitmap>()) {}

verify(fixture.scopes)
.captureReplay(
argThat { event ->
event is SentryReplayEvent &&
event.traceIds?.size == 2 &&
event.traceIds!!.contains(traceId1.toString()) &&
event.traceIds!!.contains(traceId2.toString())
},
any(),
)
}

@Test
fun `registerTraceId clears trace IDs after segment is created`() {
val now =
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
val strategy = fixture.getSut(dateProvider = { now })
strategy.start()
strategy.onConfigurationChanged(fixture.recorderConfig)

val traceId = SentryId()
strategy.registerTraceId(traceId)

strategy.onScreenshotRecorded(mock<Bitmap>()) {}

verify(fixture.scopes)
.captureReplay(
argThat { event ->
event is SentryReplayEvent && event.traceIds?.contains(traceId.toString()) == true
},
any(),
)

// trigger another segment, trace IDs should be cleared
strategy.onScreenshotRecorded(mock<Bitmap>()) {}

verify(fixture.scopes)
.captureReplay(
argThat { event ->
event is SentryReplayEvent && event.segmentId == 1 && event.traceIds.isNullOrEmpty()
},
any(),
)
}

@Test
fun `registerTraceId ignores empty trace ID`() {
val now =
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5)
val strategy = fixture.getSut(dateProvider = { now })
strategy.start()
strategy.onConfigurationChanged(fixture.recorderConfig)

strategy.registerTraceId(SentryId.EMPTY_ID)

strategy.onScreenshotRecorded(mock<Bitmap>()) {}

verify(fixture.scopes)
.captureReplay(
argThat { event -> event is SentryReplayEvent && event.traceIds.isNullOrEmpty() },
any(),
)
}
}
2 changes: 2 additions & 0 deletions sentry/api/sentry.api
Original file line number Diff line number Diff line change
Expand Up @@ -1703,6 +1703,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController {
public fun isDebugMaskingOverlayEnabled ()Z
public fun isRecording ()Z
public fun pause ()V
public fun registerTraceId (Lio/sentry/protocol/SentryId;)V
public fun resume ()V
public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
public fun start ()V
Expand Down Expand Up @@ -2344,6 +2345,7 @@ public abstract interface class io/sentry/ReplayController : io/sentry/IReplayAp
public abstract fun isDebugMaskingOverlayEnabled ()Z
public abstract fun isRecording ()Z
public abstract fun pause ()V
public abstract fun registerTraceId (Lio/sentry/protocol/SentryId;)V
public abstract fun resume ()V
public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V
public abstract fun start ()V
Expand Down
3 changes: 3 additions & 0 deletions sentry/src/main/java/io/sentry/NoOpReplayController.java
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,7 @@ public void enableDebugMaskingOverlay() {}

@Override
public void disableDebugMaskingOverlay() {}

@Override
public void registerTraceId(@NotNull SentryId traceId) {}
}
8 changes: 8 additions & 0 deletions sentry/src/main/java/io/sentry/ReplayController.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,12 @@ public interface ReplayController extends IReplayApi {
ReplayBreadcrumbConverter getBreadcrumbConverter();

boolean isDebugMaskingOverlayEnabled();

/**
* Registers a trace ID to be associated with the current replay. This is called when a
* transaction is captured while replay is recording, to enable searching for replays by trace ID.
*
* @param traceId the trace ID to associate with the current replay
*/
void registerTraceId(@NotNull SentryId traceId);
}
7 changes: 7 additions & 0 deletions sentry/src/main/java/io/sentry/SentryClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,13 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint
sentryId = SentryId.EMPTY_ID;
}

if (!sentryId.equals(SentryId.EMPTY_ID)) {
final @Nullable SpanContext trace = transaction.getContexts().getTrace();
if (trace != null) {
options.getReplayController().registerTraceId(trace.getTraceId());
}
}

return sentryId;
}

Expand Down
17 changes: 17 additions & 0 deletions sentry/src/test/java/io/sentry/SentryClientTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1958,6 +1958,23 @@ class SentryClientTest {
assertEquals("abc", transaction.platform)
}

@Test
fun `captureTransaction registers trace ID with replay controller`() {
var registeredTraceId: SentryId? = null
fixture.sentryOptions.setReplayController(
object : ReplayController by NoOpReplayController.getInstance() {
override fun registerTraceId(traceId: SentryId) {
registeredTraceId = traceId
}
}
)
val sut = fixture.getSut()
val sentryTracer = SentryTracer(TransactionContext("name", "op"), fixture.scopes)
val transaction = SentryTransaction(sentryTracer)
sut.captureTransaction(transaction, sentryTracer.traceContext())
assertEquals(sentryTracer.spanContext.traceId, registeredTraceId)
}

@Test
fun `when exception type is ignored, capturing event does not send it`() {
fixture.sentryOptions.addIgnoredExceptionForType(IllegalStateException::class.java)
Expand Down
Loading