From 247f1c9848083ccf90ace6cafbe9655d26e254e3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 16 Apr 2024 09:07:25 +0200 Subject: [PATCH 1/6] WIP --- .../replay/capture/BaseCaptureStrategy.kt | 62 ++-- .../replay/capture/BufferCaptureStrategy.kt | 2 +- .../replay/capture/SessionCaptureStrategy.kt | 2 +- .../io/sentry/rrweb/RRWebBreadcrumbEvent.java | 293 ++++++++++++++++++ 4 files changed, 336 insertions(+), 23 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java 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 1aefae1a392..6004576e8c3 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 @@ -13,6 +13,8 @@ import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.util.gracefullyShutdown import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebBreadcrumbEvent +import io.sentry.rrweb.RRWebEvent import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider @@ -28,6 +30,7 @@ import java.util.concurrent.atomic.AtomicReference internal abstract class BaseCaptureStrategy( private val options: SentryOptions, + private val hub: IHub?, private val dateProvider: ICurrentDateProvider, protected var recorderConfig: ScreenshotRecorderConfig, executor: ScheduledExecutorService? = null @@ -134,38 +137,55 @@ internal abstract class BaseCaptureStrategy( duration: Long, replayType: ReplayType ): ReplaySegment { + val endTimestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) val replay = SentryReplayEvent().apply { eventId = currentReplayId replayId = currentReplayId this.segmentId = segmentId - this.timestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) + this.timestamp = endTimestamp replayStartTimestamp = segmentTimestamp this.replayType = replayType videoFile = video } - val recording = ReplayRecording().apply { + val recordingPayload = mutableListOf() + recordingPayload += RRWebMetaEvent().apply { + this.timestamp = segmentTimestamp.time + this.height = height + this.width = width + } + recordingPayload += RRWebVideoEvent().apply { + this.timestamp = segmentTimestamp.time this.segmentId = segmentId - payload = listOf( - RRWebMetaEvent().apply { - this.timestamp = segmentTimestamp.time - this.height = height - this.width = width - }, - RRWebVideoEvent().apply { - this.timestamp = segmentTimestamp.time - this.segmentId = segmentId - this.durationMs = duration - this.frameCount = frameCount - size = video.length() - frameRate = recorderConfig.frameRate - this.height = height - this.width = width - // TODO: support non-fullscreen windows later - left = 0 - top = 0 + this.durationMs = duration + this.frameCount = frameCount + size = video.length() + frameRate = recorderConfig.frameRate + this.height = height + this.width = width + // TODO: support non-fullscreen windows later + left = 0 + top = 0 + } + + hub?.configureScope { scope -> + scope.breadcrumbs.forEach { breadcrumb -> + if (breadcrumb.timestamp.after(segmentTimestamp) && breadcrumb.timestamp.before(endTimestamp)) { + recordingPayload += RRWebBreadcrumbEvent().apply { + timestamp = breadcrumb.timestamp.time + breadcrumbTimestamp = breadcrumb.timestamp.time + breadcrumbType = breadcrumb.type + category = breadcrumb.category + message = breadcrumb.message + data = breadcrumb.data + } } - ) + } + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + payload = recordingPayload } return ReplaySegment.Created(videoDuration = duration, replay = replay, recording = recording) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index 9e4f027ba95..e7cb39f48ae 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -23,7 +23,7 @@ internal class BufferCaptureStrategy( private val dateProvider: ICurrentDateProvider, recorderConfig: ScreenshotRecorderConfig, private val random: SecureRandom -) : BaseCaptureStrategy(options, dateProvider, recorderConfig) { +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig) { private val bufferedSegments = mutableListOf() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 0d068238033..5e32b57b6f9 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -21,7 +21,7 @@ internal class SessionCaptureStrategy( private val dateProvider: ICurrentDateProvider, recorderConfig: ScreenshotRecorderConfig, executor: ScheduledExecutorService? = null -) : BaseCaptureStrategy(options, dateProvider, recorderConfig, executor) { +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor) { internal companion object { private const val TAG = "SessionCaptureStrategy" diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java new file mode 100644 index 00000000000..d22848f2c92 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -0,0 +1,293 @@ +package io.sentry.rrweb; + +import io.sentry.Breadcrumb; +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.util.CollectionUtils; +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 RRWebBreadcrumbEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { + public static final String EVENT_TAG = "breadcrumb"; + + private @NotNull String tag; + private long breadcrumbTimestamp; + private @Nullable String breadcrumbType; + private @Nullable String category; + private @Nullable String message; + private @Nullable Map data; + // 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 payloadUnknown; + private @Nullable Map dataUnknown; + + + public RRWebBreadcrumbEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + public long getBreadcrumbTimestamp() { + return breadcrumbTimestamp; + } + + public void setBreadcrumbTimestamp(final long breadcrumbTimestamp) { + this.breadcrumbTimestamp = breadcrumbTimestamp; + } + + @Nullable + public String getBreadcrumbType() { + return breadcrumbType; + } + + public void setBreadcrumbType(final @Nullable String breadcrumbType) { + this.breadcrumbType = breadcrumbType; + } + + @Nullable + public String getCategory() { + return category; + } + + public void setCategory(final @Nullable String category) { + this.category = category; + } + + @Nullable + public String getMessage() { + return message; + } + + public void setMessage(final @Nullable String message) { + this.message = message; + } + + @Nullable + public Map getData() { + return data; + } + + public void setData(final @Nullable Map data) { + this.data = data == null ? null : new ConcurrentHashMap<>(data); + } + + public @Nullable Map getPayloadUnknown() { + return payloadUnknown; + } + + public void setPayloadUnknown(final @Nullable Map payloadUnknown) { + this.payloadUnknown = payloadUnknown; + } + + 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 + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String PAYLOAD = "payload"; + public static final String TIMESTAMP = "timestamp"; + public static final String TYPE = "type"; + public static final String CATEGORY = "category"; + public static final String MESSAGE = "message"; + } + + @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 (breadcrumbType != null) { + writer.name(JsonKeys.TYPE).value(breadcrumbType); + } + writer.name(JsonKeys.TIMESTAMP).value(breadcrumbTimestamp); + if (category != null) { + writer.name(JsonKeys.CATEGORY).value(category); + } + if (message != null) { + writer.name(JsonKeys.MESSAGE).value(message); + } + if (data != null) { + writer.name(Breadcrumb.JsonKeys.DATA).value(logger, data); + } + if (payloadUnknown != null) { + for (final String key : payloadUnknown.keySet()) { + final Object value = payloadUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @Override public @NotNull RRWebBreadcrumbEvent deserialize(@NotNull ObjectReader reader, + @NotNull ILogger logger) throws Exception { + reader.beginObject(); + @Nullable Map unknown = null; + + final RRWebBreadcrumbEvent event = new RRWebBreadcrumbEvent(); + 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 RRWebBreadcrumbEvent 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 RRWebBreadcrumbEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + @Nullable Map payloadUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + event.breadcrumbType = reader.nextStringOrNull(); + break; + case JsonKeys.TIMESTAMP: + event.breadcrumbTimestamp = reader.nextLong(); + break; + case JsonKeys.CATEGORY: + event.category = reader.nextStringOrNull(); + break; + case JsonKeys.MESSAGE: + event.message = reader.nextStringOrNull(); + break; + case JsonKeys.DATA: + Map deserializedData = + CollectionUtils.newConcurrentHashMap( + (Map) reader.nextObjectOrNull()); + if (deserializedData != null) { + event.data = deserializedData; + } + break; + default: + if (payloadUnknown == null) { + payloadUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, payloadUnknown, nextName); + } + } + event.setPayloadUnknown(payloadUnknown); + reader.endObject(); + } + } + // endregion json +} From 06d4b6d412043cbd50c9c714acacfe9a3dcfcbfa Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 16 Apr 2024 22:30:27 +0200 Subject: [PATCH 2/6] Spotless --- sentry/api/sentry.api | 40 +++++++++++++++++++ .../io/sentry/rrweb/RRWebBreadcrumbEvent.java | 40 ++++++++++--------- 2 files changed, 61 insertions(+), 19 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 02c49a4ef0d..334c2427aaa 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5035,6 +5035,46 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { public fun ()V } +public final class io/sentry/rrweb/RRWebBreadcrumbEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public fun ()V + public fun getBreadcrumbTimestamp ()J + public fun getBreadcrumbType ()Ljava/lang/String; + public fun getCategory ()Ljava/lang/String; + public fun getData ()Ljava/util/Map; + public fun getDataUnknown ()Ljava/util/Map; + public fun getMessage ()Ljava/lang/String; + public fun getPayloadUnknown ()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 setBreadcrumbTimestamp (J)V + public fun setBreadcrumbType (Ljava/lang/String;)V + public fun setCategory (Ljava/lang/String;)V + public fun setData (Ljava/util/Map;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setMessage (Ljava/lang/String;)V + public fun setPayloadUnknown (Ljava/util/Map;)V + public fun setTag (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/rrweb/RRWebBreadcrumbEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebBreadcrumbEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebBreadcrumbEvent$JsonKeys { + public static final field CATEGORY Ljava/lang/String; + public static final field DATA Ljava/lang/String; + public static final field MESSAGE Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public fun ()V +} + public abstract class io/sentry/rrweb/RRWebEvent { protected fun ()V protected fun (Lio/sentry/rrweb/RRWebEventType;)V diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java index d22848f2c92..7ca501c3fcb 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -16,7 +16,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public final class RRWebBreadcrumbEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { +public final class RRWebBreadcrumbEvent extends RRWebEvent + implements JsonUnknown, JsonSerializable { public static final String EVENT_TAG = "breadcrumb"; private @NotNull String tag; @@ -31,7 +32,6 @@ public final class RRWebBreadcrumbEvent extends RRWebEvent implements JsonUnknow private @Nullable Map payloadUnknown; private @Nullable Map dataUnknown; - public RRWebBreadcrumbEvent() { super(RRWebEventType.Custom); tag = EVENT_TAG; @@ -128,8 +128,8 @@ public static final class JsonKeys { public static final String MESSAGE = "message"; } - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @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); @@ -145,7 +145,7 @@ public static final class JsonKeys { } private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); writer.name(RRWebEvent.JsonKeys.TAG).value(tag); writer.name(JsonKeys.PAYLOAD); @@ -161,7 +161,7 @@ private void serializeData(final @NotNull ObjectWriter writer, final @NotNull IL } private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); if (breadcrumbType != null) { writer.name(JsonKeys.TYPE).value(breadcrumbType); @@ -188,8 +188,9 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull public static final class Deserializer implements JsonDeserializer { - @Override public @NotNull RRWebBreadcrumbEvent deserialize(@NotNull ObjectReader reader, - @NotNull ILogger logger) throws Exception { + @Override + public @NotNull RRWebBreadcrumbEvent deserialize( + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); @Nullable Map unknown = null; @@ -219,10 +220,10 @@ public static final class Deserializer implements JsonDeserializer dataUnknown = null; reader.beginObject(); @@ -247,11 +248,12 @@ private void deserializeData( reader.endObject(); } - @SuppressWarnings("unchecked") private void deserializePayload( - final @NotNull RRWebBreadcrumbEvent event, - final @NotNull ObjectReader reader, - final @NotNull ILogger logger) - throws Exception { + @SuppressWarnings("unchecked") + private void deserializePayload( + final @NotNull RRWebBreadcrumbEvent event, + final @NotNull ObjectReader reader, + final @NotNull ILogger logger) + throws Exception { @Nullable Map payloadUnknown = null; reader.beginObject(); @@ -272,8 +274,8 @@ private void deserializeData( break; case JsonKeys.DATA: Map deserializedData = - CollectionUtils.newConcurrentHashMap( - (Map) reader.nextObjectOrNull()); + CollectionUtils.newConcurrentHashMap( + (Map) reader.nextObjectOrNull()); if (deserializedData != null) { event.data = deserializedData; } From 0e951eb853b5414764de6038bce8b35df6312e0f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 16 Apr 2024 22:40:29 +0200 Subject: [PATCH 3/6] Remove breadcrumb import --- sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java index 7ca501c3fcb..a4e81328a5a 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -1,6 +1,5 @@ package io.sentry.rrweb; -import io.sentry.Breadcrumb; import io.sentry.ILogger; import io.sentry.JsonDeserializer; import io.sentry.JsonSerializable; @@ -174,7 +173,7 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull writer.name(JsonKeys.MESSAGE).value(message); } if (data != null) { - writer.name(Breadcrumb.JsonKeys.DATA).value(logger, data); + writer.name(JsonKeys.DATA).value(logger, data); } if (payloadUnknown != null) { for (final String key : payloadUnknown.keySet()) { From e33b29c1264622b3fa3f423de64c8c1cd8dd7754 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Apr 2024 14:46:45 +0200 Subject: [PATCH 4/6] Send temporary breadcrumbs and add test --- .../replay/capture/BaseCaptureStrategy.kt | 68 ++++++++++++++++--- sentry/api/sentry.api | 4 +- .../io/sentry/rrweb/RRWebBreadcrumbEvent.java | 11 +-- .../RRWebBreadcrumbEventSerializationTest.kt | 43 ++++++++++++ .../json/rrweb_breadcrumb_event.json | 17 +++++ 5 files changed, 127 insertions(+), 16 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt create mode 100644 sentry/src/test/resources/json/rrweb_breadcrumb_event.json 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 6004576e8c3..667318bf98a 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 @@ -170,14 +170,60 @@ internal abstract class BaseCaptureStrategy( hub?.configureScope { scope -> scope.breadcrumbs.forEach { breadcrumb -> - if (breadcrumb.timestamp.after(segmentTimestamp) && breadcrumb.timestamp.before(endTimestamp)) { - recordingPayload += RRWebBreadcrumbEvent().apply { - timestamp = breadcrumb.timestamp.time - breadcrumbTimestamp = breadcrumb.timestamp.time - breadcrumbType = breadcrumb.type - category = breadcrumb.category - message = breadcrumb.message - data = breadcrumb.data + if (breadcrumb.timestamp.after(segmentTimestamp) && breadcrumb.timestamp.before( + endTimestamp + ) + ) { + // TODO: rework this later when aligned with iOS and frontend + var breadcrumbMessage: String? = null + val breadcrumbCategory: String? + val breadcrumbData = mutableMapOf() + when { + breadcrumb.category == "http" -> return@forEach + + breadcrumb.category == "device.orientation" -> { + breadcrumbCategory = breadcrumb.category!! + breadcrumbMessage = breadcrumb.data["position"] as? String ?: "" + } + + breadcrumb.type == "navigation" -> { + breadcrumbCategory = "navigation" + breadcrumbData["to"] = when { + breadcrumb.data["state"] == "resumed" -> breadcrumb.data["screen"] as? String + breadcrumb.category == "app.lifecycle" -> breadcrumb.data["state"] as? String + "to" in breadcrumb.data -> breadcrumb.data["to"] as? String + else -> return@forEach + } ?: return@forEach + } + + breadcrumb.category in setOf("ui.click", "ui.scroll", "ui.swipe") -> { + breadcrumbCategory = breadcrumb.category!! + breadcrumbMessage = ( + breadcrumb.data["view.id"] + ?: breadcrumb.data["view.class"] + ?: breadcrumb.data["view.tag"] + ) as? String ?: "" + } + + breadcrumb.type == "system" -> { + breadcrumbCategory = breadcrumb.type!! + breadcrumbMessage = breadcrumb.data.entries.joinToString() as? String ?: "" + } + + else -> { + breadcrumbCategory = breadcrumb.category + breadcrumbMessage = breadcrumb.message + } + } + if (!breadcrumbCategory.isNullOrEmpty()) { + recordingPayload += RRWebBreadcrumbEvent().apply { + timestamp = breadcrumb.timestamp.time + breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0 + breadcrumbType = "default" + category = breadcrumbCategory + message = breadcrumbMessage + data = if (breadcrumbData.isEmpty()) null else breadcrumbData + } } } } @@ -188,7 +234,11 @@ internal abstract class BaseCaptureStrategy( payload = recordingPayload } - return ReplaySegment.Created(videoDuration = duration, replay = replay, recording = recording) + return ReplaySegment.Created( + videoDuration = duration, + replay = replay, + recording = recording + ) } override fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 334c2427aaa..22e86137306 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -5038,7 +5038,7 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { public final class io/sentry/rrweb/RRWebBreadcrumbEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { public static final field EVENT_TAG Ljava/lang/String; public fun ()V - public fun getBreadcrumbTimestamp ()J + public fun getBreadcrumbTimestamp ()D public fun getBreadcrumbType ()Ljava/lang/String; public fun getCategory ()Ljava/lang/String; public fun getData ()Ljava/util/Map; @@ -5048,7 +5048,7 @@ public final class io/sentry/rrweb/RRWebBreadcrumbEvent : io/sentry/rrweb/RRWebE public fun getTag ()Ljava/lang/String; public fun getUnknown ()Ljava/util/Map; public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V - public fun setBreadcrumbTimestamp (J)V + public fun setBreadcrumbTimestamp (D)V public fun setBreadcrumbType (Ljava/lang/String;)V public fun setCategory (Ljava/lang/String;)V public fun setData (Ljava/util/Map;)V diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java index a4e81328a5a..d98e91c0e54 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebBreadcrumbEvent.java @@ -9,6 +9,7 @@ import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; +import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -20,7 +21,7 @@ public final class RRWebBreadcrumbEvent extends RRWebEvent public static final String EVENT_TAG = "breadcrumb"; private @NotNull String tag; - private long breadcrumbTimestamp; + private double breadcrumbTimestamp; private @Nullable String breadcrumbType; private @Nullable String category; private @Nullable String message; @@ -45,11 +46,11 @@ public void setTag(final @NotNull String tag) { this.tag = tag; } - public long getBreadcrumbTimestamp() { + public double getBreadcrumbTimestamp() { return breadcrumbTimestamp; } - public void setBreadcrumbTimestamp(final long breadcrumbTimestamp) { + public void setBreadcrumbTimestamp(final double breadcrumbTimestamp) { this.breadcrumbTimestamp = breadcrumbTimestamp; } @@ -165,7 +166,7 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull if (breadcrumbType != null) { writer.name(JsonKeys.TYPE).value(breadcrumbType); } - writer.name(JsonKeys.TIMESTAMP).value(breadcrumbTimestamp); + writer.name(JsonKeys.TIMESTAMP).value(logger, BigDecimal.valueOf(breadcrumbTimestamp)); if (category != null) { writer.name(JsonKeys.CATEGORY).value(category); } @@ -263,7 +264,7 @@ private void deserializePayload( event.breadcrumbType = reader.nextStringOrNull(); break; case JsonKeys.TIMESTAMP: - event.breadcrumbTimestamp = reader.nextLong(); + event.breadcrumbTimestamp = reader.nextDouble(); break; case JsonKeys.CATEGORY: event.category = reader.nextStringOrNull(); diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt new file mode 100644 index 00000000000..cf711fd27f3 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebBreadcrumbEventSerializationTest.kt @@ -0,0 +1,43 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebBreadcrumbEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebBreadcrumbEvent().apply { + timestamp = 12345678901 + breadcrumbType = "default" + breadcrumbTimestamp = 12345678.901 + category = "navigation" + message = "message" + data = mapOf( + "screen" to "MainActivity", + "state" to "resumed" + ) + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_breadcrumb_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_breadcrumb_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebBreadcrumbEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/resources/json/rrweb_breadcrumb_event.json b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json new file mode 100644 index 00000000000..f41c6cb20d6 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_breadcrumb_event.json @@ -0,0 +1,17 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "breadcrumb", + "payload": { + "type": "default", + "timestamp": 12345678.901, + "category": "navigation", + "message": "message", + "data": { + "screen": "MainActivity", + "state": "resumed" + } + } + } +} From 27e17c1e8b630b53484aadcc6ae010d847d0f96a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Apr 2024 16:09:04 +0200 Subject: [PATCH 5/6] Formatting --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 667318bf98a..1df4a013f9f 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 @@ -170,10 +170,8 @@ internal abstract class BaseCaptureStrategy( hub?.configureScope { scope -> scope.breadcrumbs.forEach { breadcrumb -> - if (breadcrumb.timestamp.after(segmentTimestamp) && breadcrumb.timestamp.before( - endTimestamp - ) - ) { + if (breadcrumb.timestamp.after(segmentTimestamp) && + breadcrumb.timestamp.before(endTimestamp)) { // TODO: rework this later when aligned with iOS and frontend var breadcrumbMessage: String? = null val breadcrumbCategory: String? From 51cc43260dc5b3214153d5692f88cf534e2c19c4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 18 Apr 2024 18:01:50 +0200 Subject: [PATCH 6/6] Sort rrweb events --- .../io/sentry/android/replay/capture/BaseCaptureStrategy.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1df4a013f9f..615ebb26035 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 @@ -220,7 +220,7 @@ internal abstract class BaseCaptureStrategy( breadcrumbType = "default" category = breadcrumbCategory message = breadcrumbMessage - data = if (breadcrumbData.isEmpty()) null else breadcrumbData + data = breadcrumbData } } } @@ -229,7 +229,7 @@ internal abstract class BaseCaptureStrategy( val recording = ReplayRecording().apply { this.segmentId = segmentId - payload = recordingPayload + payload = recordingPayload.sortedBy { it.timestamp } } return ReplaySegment.Created(