diff --git a/CHANGELOG.md b/CHANGELOG.md index 17642fb819..cdc3e33452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- Session Replay: Record segment names (transaction names) ([#5763](https://github.com/getsentry/sentry-java/pull/5763)) + - Add `io.sentry:sentry-opentelemetry-bom` to align Sentry OpenTelemetry modules with tested OpenTelemetry dependencies ([#5629](https://github.com/getsentry/sentry-java/pull/5629)) - Spring Boot Gradle plugin: add the Sentry BOM to `dependencyManagement`; explicit imports are applied after Spring Boot's implicit BOM ```kotlin diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 12fe214176..3efee26e37 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -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 registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index bae0e41179..d9cd15d891 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -297,6 +297,13 @@ public class ReplayIntegration( captureStrategy?.registerTraceId(traceId) } + override fun registerSegmentName(segmentName: String) { + if (!isEnabled.get() || !isRecording()) { + return + } + captureStrategy?.registerSegmentName(segmentName) + } + private fun pauseInternal() { lifecycleLock.acquire().use { if (!isEnabled.get() || !lifecycle.isAllowed(PAUSED)) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index fbc0ccfd4b..f505d21a15 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -54,7 +54,7 @@ internal abstract class BaseCaptureStrategy( 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 const val MAX_CONTEXT_VALUES = 100 } private val gestureConverter = ReplayGestureConverter(dateProvider) @@ -97,8 +97,9 @@ internal abstract class BaseCaptureStrategy( persistableAtomic(initialValue = false, propertyName = SEGMENT_KEY_FLUSHED) protected val currentEvents: Deque = ConcurrentLinkedDeque() - private val traceIdsLock = Any() - private val currentTraceIds: MutableList = mutableListOf() + private val replayContextLock = Any() + private val currentTraceIds: MutableSet = linkedSetOf() + private val currentSegmentNames: MutableSet = linkedSetOf() override fun start(segmentId: Int, replayId: SentryId, replayType: ReplayType?) { cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId) @@ -139,11 +140,12 @@ internal abstract class BaseCaptureStrategy( breadcrumbs: List? = null, events: Deque = this.currentEvents, ): ReplaySegment { - val traceIds = - synchronized(traceIdsLock) { - val ids = currentTraceIds.toList() + val (traceIds, segmentNames) = + synchronized(replayContextLock) { + val context = currentTraceIds.toList() to currentSegmentNames.toList() currentTraceIds.clear() - ids + currentSegmentNames.clear() + context } return createSegment( scopes, @@ -162,6 +164,7 @@ internal abstract class BaseCaptureStrategy( breadcrumbs, events, traceIds, + segmentNames, ) } @@ -180,12 +183,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) - } + synchronized(replayContextLock) { + if (currentTraceIds.size < MAX_CONTEXT_VALUES) { + currentTraceIds.add(traceId.toString()) + } + } + } + } + + override fun registerSegmentName(segmentName: String) { + if (segmentName.isNotEmpty()) { + synchronized(replayContextLock) { + if (currentSegmentNames.size < MAX_CONTEXT_VALUES) { + currentSegmentNames.add(segmentName) } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 096a93741b..780cdd9248 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -56,6 +56,8 @@ internal interface CaptureStrategy { fun registerTraceId(traceId: SentryId) + fun registerSegmentName(segmentName: String) + companion object { private fun Breadcrumb?.isNetworkAvailable(): Boolean = this != null && @@ -88,6 +90,7 @@ internal interface CaptureStrategy { breadcrumbs: List?, events: Deque, traceIds: List = emptyList(), + segmentNames: List = emptyList(), ): ReplaySegment { val generatedVideo = cache?.createVideoOf( @@ -127,6 +130,7 @@ internal interface CaptureStrategy { replayBreadcrumbs, events, traceIds, + segmentNames, ) } @@ -147,6 +151,7 @@ internal interface CaptureStrategy { breadcrumbs: List, events: Deque, traceIds: List, + segmentNames: List, ): ReplaySegment { val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + videoDuration) val replay = @@ -159,6 +164,7 @@ internal interface CaptureStrategy { this.replayType = replayType this.videoFile = video this.traceIds = traceIds + this.segmentNames = segmentNames } val recordingPayload = mutableListOf() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index dd9e6c6ce1..fc2354eb1c 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -562,4 +562,58 @@ class SessionCaptureStrategyTest { any(), ) } + + @Test + fun `registerSegmentName includes unique segment names in next segment and clears them`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerSegmentName("CheckoutActivity") + strategy.registerSegmentName("CheckoutActivity") + strategy.registerSegmentName("ProductDetailsActivity") + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && + event.segmentNames == listOf("CheckoutActivity", "ProductDetailsActivity") + }, + any(), + ) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 1 && event.segmentNames.isNullOrEmpty() + }, + any(), + ) + } + + @Test + fun `registerSegmentName ignores empty names and limits names to 100`() { + val now = + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start() + strategy.onConfigurationChanged(fixture.recorderConfig) + + strategy.registerSegmentName("") + repeat(101) { strategy.registerSegmentName("ProductActivity$it") } + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.scopes) + .captureReplay( + argThat { event -> event is SentryReplayEvent && event.segmentNames?.size == 100 }, + any(), + ) + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index e0bf144178..0e5aad4826 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1715,6 +1715,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public fun isDebugMaskingOverlayEnabled ()Z public fun isRecording ()Z public fun pause ()V + public fun registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V @@ -2357,6 +2358,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 registerSegmentName (Ljava/lang/String;)V public abstract fun registerTraceId (Lio/sentry/protocol/SentryId;)V public abstract fun resume ()V public abstract fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V @@ -4023,6 +4025,7 @@ public final class io/sentry/SentryReplayEvent : io/sentry/SentryBaseEvent, io/s public fun getReplayStartTimestamp ()Ljava/util/Date; public fun getReplayType ()Lio/sentry/SentryReplayEvent$ReplayType; public fun getSegmentId ()I + public fun getSegmentNames ()Ljava/util/List; public fun getTimestamp ()Ljava/util/Date; public fun getTraceIds ()Ljava/util/List; public fun getType ()Ljava/lang/String; @@ -4036,6 +4039,7 @@ public final class io/sentry/SentryReplayEvent : io/sentry/SentryBaseEvent, io/s public fun setReplayStartTimestamp (Ljava/util/Date;)V public fun setReplayType (Lio/sentry/SentryReplayEvent$ReplayType;)V public fun setSegmentId (I)V + public fun setSegmentNames (Ljava/util/List;)V public fun setTimestamp (Ljava/util/Date;)V public fun setTraceIds (Ljava/util/List;)V public fun setType (Ljava/lang/String;)V @@ -4056,6 +4060,7 @@ public final class io/sentry/SentryReplayEvent$JsonKeys { public static final field REPLAY_START_TIMESTAMP Ljava/lang/String; public static final field REPLAY_TYPE Ljava/lang/String; public static final field SEGMENT_ID Ljava/lang/String; + public static final field SEGMENT_NAMES Ljava/lang/String; public static final field TIMESTAMP Ljava/lang/String; public static final field TRACE_IDS Ljava/lang/String; public static final field TYPE Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 2f6de9740d..2b8a09cb1d 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -60,4 +60,7 @@ public void disableDebugMaskingOverlay() {} @Override public void registerTraceId(@NotNull SentryId traceId) {} + + @Override + public void registerSegmentName(@NotNull String segmentName) {} } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index f4baba40c9..2fb7b1c83a 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -36,4 +36,7 @@ public interface ReplayController extends IReplayApi { * @param traceId the trace ID to associate with the current replay */ void registerTraceId(@NotNull SentryId traceId); + + /** Registers a segment name to be associated with the current replay segment. */ + void registerSegmentName(@NotNull String segmentName); } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 4889d1629c..a0ff98a9af 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -1079,6 +1079,10 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint if (trace != null) { options.getReplayController().registerTraceId(trace.getTraceId()); } + final @Nullable String segmentName = transaction.getTransaction(); + if (segmentName != null && !segmentName.isEmpty()) { + options.getReplayController().registerSegmentName(segmentName); + } } return sentryId; diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index 95623d2ff6..0ed9dbf72f 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -49,6 +49,7 @@ public static final class Deserializer implements JsonDeserializer { private @Nullable List urls; private @Nullable List errorIds; private @Nullable List traceIds; + private @Nullable List segmentNames; private @Nullable Map unknown; public SentryReplayEvent() { @@ -58,6 +59,7 @@ public SentryReplayEvent() { this.replayType = ReplayType.SESSION; this.errorIds = new ArrayList<>(); this.traceIds = new ArrayList<>(); + this.segmentNames = new ArrayList<>(); this.urls = new ArrayList<>(); timestamp = DateUtils.getCurrentDateTime(); } @@ -142,6 +144,15 @@ public void setTraceIds(final @Nullable List traceIds) { this.traceIds = traceIds; } + @Nullable + public List getSegmentNames() { + return segmentNames; + } + + public void setSegmentNames(final @Nullable List segmentNames) { + this.segmentNames = segmentNames; + } + @NotNull public ReplayType getReplayType() { return replayType; @@ -162,12 +173,14 @@ public boolean equals(Object o) { && Objects.equals(replayId, that.replayId) && Objects.equals(urls, that.urls) && Objects.equals(errorIds, that.errorIds) - && Objects.equals(traceIds, that.traceIds); + && Objects.equals(traceIds, that.traceIds) + && Objects.equals(segmentNames, that.segmentNames); } @Override public int hashCode() { - return Objects.hash(type, replayType, replayId, segmentId, urls, errorIds, traceIds); + return Objects.hash( + type, replayType, replayId, segmentId, urls, errorIds, traceIds, segmentNames); } // region json @@ -181,6 +194,7 @@ public static final class JsonKeys { public static final String URLS = "urls"; public static final String ERROR_IDS = "error_ids"; public static final String TRACE_IDS = "trace_ids"; + public static final String SEGMENT_NAMES = "segment_names"; } @Override @@ -207,6 +221,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (traceIds != null) { writer.name(JsonKeys.TRACE_IDS).value(logger, traceIds); } + if (segmentNames != null) { + writer.name(JsonKeys.SEGMENT_NAMES).value(logger, segmentNames); + } new SentryBaseEvent.Serializer().serialize(this, writer, logger); @@ -250,6 +267,7 @@ public static final class Deserializer implements JsonDeserializer urls = null; @Nullable List errorIds = null; @Nullable List traceIds = null; + @Nullable List segmentNames = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -282,6 +300,9 @@ public static final class Deserializer implements JsonDeserializer) reader.nextObjectOrNull(); break; + case JsonKeys.SEGMENT_NAMES: + segmentNames = (List) reader.nextObjectOrNull(); + break; default: if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) { if (unknown == null) { @@ -311,6 +332,7 @@ public static final class Deserializer implements JsonDeserializer