diff --git a/CHANGELOG.md b/CHANGELOG.md index 4336a3aa82..893779a9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Internal - Session Replay: Allow overriding `SdkVersion` for replay events ([#4014](https://github.com/getsentry/sentry-java/pull/4014)) +- Session Replay: Send replay options as tags ([#4015](https://github.com/getsentry/sentry-java/pull/4015)) ## 7.19.1 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 2f7a5bef14..660a366ecd 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 @@ -16,6 +16,7 @@ import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebEvent import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebOptionsEvent import io.sentry.rrweb.RRWebVideoEvent import java.io.File import java.util.Date @@ -195,6 +196,10 @@ internal interface CaptureStrategy { } } + if (segmentId == 0) { + recordingPayload += RRWebOptionsEvent(options) + } + val recording = ReplayRecording().apply { this.segmentId = segmentId this.payload = recordingPayload.sortedBy { it.timestamp } 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 50adeae3a1..1a817609d0 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 @@ -9,6 +9,8 @@ import io.sentry.ScopeCallback import io.sentry.SentryOptions import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayOptions.SentryReplayQuality.HIGH +import io.sentry.android.replay.BuildConfig import io.sentry.android.replay.DefaultReplayBreadcrumbConverter import io.sentry.android.replay.GeneratedVideo import io.sentry.android.replay.ReplayCache @@ -22,9 +24,11 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH import io.sentry.android.replay.ReplayFrame import io.sentry.android.replay.ScreenshotRecorderConfig +import io.sentry.android.replay.maskAllImages import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebOptionsEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import org.junit.Rule @@ -43,8 +47,10 @@ import org.mockito.kotlin.whenever import java.io.File import java.util.Date import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class SessionCaptureStrategyTest { @@ -367,4 +373,81 @@ class SessionCaptureStrategyTest { "the current replay cache folder is not being deleted." ) } + + @Test + fun `records replay options event for segment 0`() { + fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.experimental.sessionReplay.maskAllImages = false + fixture.options.experimental.sessionReplay.quality = HIGH + fixture.options.experimental.sessionReplay.addMaskViewClass("my.custom.View") + + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + check { + val optionsEvent = + it.replayRecording?.payload?.filterIsInstance()!! + assertEquals("sentry.java", optionsEvent[0].optionsPayload["nativeSdkName"]) + assertEquals(BuildConfig.VERSION_NAME, optionsEvent[0].optionsPayload["nativeSdkVersion"]) + + assertEquals(null, optionsEvent[0].optionsPayload["errorSampleRate"]) + assertEquals(1.0, optionsEvent[0].optionsPayload["sessionSampleRate"]) + assertEquals(true, optionsEvent[0].optionsPayload["maskAllText"]) + assertEquals(false, optionsEvent[0].optionsPayload["maskAllImages"]) + assertEquals("high", optionsEvent[0].optionsPayload["quality"]) + assertContentEquals( + listOf( + "android.widget.TextView", + "android.webkit.WebView", + "android.widget.VideoView", + "androidx.media3.ui.PlayerView", + "com.google.android.exoplayer2.ui.PlayerView", + "com.google.android.exoplayer2.ui.StyledPlayerView", + "my.custom.View" + ), + optionsEvent[0].optionsPayload["maskedViewClasses"] as Collection<*> + ) + assertContentEquals( + listOf("android.widget.ImageView"), + optionsEvent[0].optionsPayload["unmaskedViewClasses"] as Collection<*> + ) + } + ) + } + + @Test + fun `does not record replay options event for segment above 0`() { + val now = + System.currentTimeMillis() + (fixture.options.experimental.sessionReplay.sessionSegmentDuration * 5) + val strategy = fixture.getSut(dateProvider = { now }) + strategy.start(fixture.recorderConfig) + + strategy.onScreenshotRecorded(mock()) {} + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 0 + }, + any() + ) + + strategy.onScreenshotRecorded(mock()) {} + verify(fixture.hub).captureReplay( + argThat { event -> + event is SentryReplayEvent && event.segmentId == 1 + }, + check { + val optionsEvent = + it.replayRecording?.payload?.find { it is RRWebOptionsEvent } + assertNull(optionsEvent) + } + ) + } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 92a8e90976..26f77fe3ac 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2760,6 +2760,7 @@ public final class io/sentry/SentryReplayOptions$SentryReplayQuality : java/lang public static final field MEDIUM Lio/sentry/SentryReplayOptions$SentryReplayQuality; public final field bitRate I public final field sizeScale F + public fun serializedName ()Ljava/lang/String; public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayOptions$SentryReplayQuality; public static fun values ()[Lio/sentry/SentryReplayOptions$SentryReplayQuality; } @@ -5415,6 +5416,33 @@ public final class io/sentry/rrweb/RRWebMetaEvent$JsonKeys { public fun ()V } +public final class io/sentry/rrweb/RRWebOptionsEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public fun ()V + public fun (Lio/sentry/SentryOptions;)V + public fun getDataUnknown ()Ljava/util/Map; + public fun getOptionsPayload ()Ljava/util/Map; + public fun getTag ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setOptionsPayload (Ljava/util/Map;)V + public fun setTag (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebOptionsEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebOptionsEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebOptionsEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public fun ()V +} + public final class io/sentry/rrweb/RRWebSpanEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field EVENT_TAG Ljava/lang/String; public fun ()V diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 73eb7a33e9..396c733815 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -2,6 +2,7 @@ import io.sentry.protocol.SdkVersion; import io.sentry.util.SampleRateUtils; +import java.util.Locale; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import org.jetbrains.annotations.ApiStatus; @@ -42,6 +43,10 @@ public enum SentryReplayQuality { this.sizeScale = sizeScale; this.bitRate = bitRate; } + + public @NotNull String serializedName() { + return name().toLowerCase(Locale.ROOT); + } } /** diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java new file mode 100644 index 0000000000..d7ad2b1b48 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebOptionsEvent.java @@ -0,0 +1,228 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; +import io.sentry.ObjectWriter; +import io.sentry.SentryOptions; +import io.sentry.SentryReplayOptions; +import io.sentry.protocol.SdkVersion; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebOptionsEvent extends RRWebEvent implements JsonSerializable, JsonUnknown { + public static final String EVENT_TAG = "options"; + + private @NotNull String tag; + // keeping this untyped so hybrids can easily set what they want + private @NotNull Map optionsPayload = new HashMap<>(); + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ..., "payload": { ... } } } + private @Nullable Map unknown; + private @Nullable Map dataUnknown; + + public RRWebOptionsEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + public RRWebOptionsEvent(final @NotNull SentryOptions options) { + this(); + final SdkVersion sdkVersion = options.getSdkVersion(); + if (sdkVersion != null) { + optionsPayload.put("nativeSdkName", sdkVersion.getName()); + optionsPayload.put("nativeSdkVersion", sdkVersion.getVersion()); + } + final @NotNull SentryReplayOptions replayOptions = options.getExperimental().getSessionReplay(); + optionsPayload.put("errorSampleRate", replayOptions.getOnErrorSampleRate()); + optionsPayload.put("sessionSampleRate", replayOptions.getSessionSampleRate()); + optionsPayload.put( + "maskAllImages", + replayOptions.getMaskViewClasses().contains(SentryReplayOptions.IMAGE_VIEW_CLASS_NAME)); + optionsPayload.put( + "maskAllText", + replayOptions.getMaskViewClasses().contains(SentryReplayOptions.TEXT_VIEW_CLASS_NAME)); + optionsPayload.put("quality", replayOptions.getQuality().serializedName()); + optionsPayload.put("maskedViewClasses", replayOptions.getMaskViewClasses()); + optionsPayload.put("unmaskedViewClasses", replayOptions.getUnmaskViewClasses()); + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + public @NotNull Map getOptionsPayload() { + return optionsPayload; + } + + public void setOptionsPayload(final @NotNull Map optionsPayload) { + this.optionsPayload = optionsPayload; + } + + public @Nullable Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + // region json + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String PAYLOAD = "payload"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + if (unknown != null) { + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(RRWebEvent.JsonKeys.TAG).value(tag); + writer.name(JsonKeys.PAYLOAD); + serializePayload(writer, logger); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (optionsPayload != null) { + for (final String key : optionsPayload.keySet()) { + final Object value = optionsPayload.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull RRWebOptionsEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebOptionsEvent event = new RRWebOptionsEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + + event.setUnknown(unknown); + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebOptionsEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map dataUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case RRWebEvent.JsonKeys.TAG: + final String tag = reader.nextStringOrNull(); + event.tag = tag == null ? "" : tag; + break; + case JsonKeys.PAYLOAD: + deserializePayload(event, reader, logger); + break; + default: + if (dataUnknown == null) { + dataUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, dataUnknown, nextName); + } + } + event.setDataUnknown(dataUnknown); + reader.endObject(); + } + + @SuppressWarnings("unchecked") + private void deserializePayload( + final @NotNull RRWebOptionsEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map optionsPayload = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + if (optionsPayload == null) { + optionsPayload = new HashMap<>(); + } + reader.nextUnknown(logger, optionsPayload, nextName); + } + if (optionsPayload != null) { + event.setOptionsPayload(optionsPayload); + } + reader.endObject(); + } + } + // endregion json +}