|
| 1 | +package io.sentry.android.replay |
| 2 | + |
| 3 | +import io.sentry.Breadcrumb |
| 4 | +import io.sentry.ReplayBreadcrumbConverter |
| 5 | +import io.sentry.SentryLevel |
| 6 | +import io.sentry.SpanDataConvention |
| 7 | +import io.sentry.rrweb.RRWebBreadcrumbEvent |
| 8 | +import io.sentry.rrweb.RRWebEvent |
| 9 | +import io.sentry.rrweb.RRWebSpanEvent |
| 10 | +import kotlin.LazyThreadSafetyMode.NONE |
| 11 | + |
| 12 | +public open class DefaultReplayBreadcrumbConverter : ReplayBreadcrumbConverter { |
| 13 | + internal companion object { |
| 14 | + private val snakecasePattern by lazy(NONE) { "_[a-z]".toRegex() } |
| 15 | + private val supportedNetworkData = setOf( |
| 16 | + "status_code", |
| 17 | + "method", |
| 18 | + "response_content_length", |
| 19 | + "request_content_length", |
| 20 | + "http.response_content_length", |
| 21 | + "http.request_content_length" |
| 22 | + ) |
| 23 | + } |
| 24 | + |
| 25 | + override fun convert(breadcrumb: Breadcrumb): RRWebEvent? { |
| 26 | + var breadcrumbMessage: String? = null |
| 27 | + var breadcrumbCategory: String? = null |
| 28 | + var breadcrumbLevel: SentryLevel? = null |
| 29 | + val breadcrumbData = mutableMapOf<String, Any?>() |
| 30 | + when { |
| 31 | + breadcrumb.category == "http" -> { |
| 32 | + return if (breadcrumb.isValidForRRWebSpan()) breadcrumb.toRRWebSpanEvent() else null |
| 33 | + } |
| 34 | + |
| 35 | + breadcrumb.type == "navigation" && |
| 36 | + breadcrumb.category == "app.lifecycle" -> { |
| 37 | + breadcrumbCategory = "app.${breadcrumb.data["state"]}" |
| 38 | + } |
| 39 | + |
| 40 | + breadcrumb.type == "navigation" && |
| 41 | + breadcrumb.category == "device.orientation" -> { |
| 42 | + breadcrumbCategory = breadcrumb.category!! |
| 43 | + val position = breadcrumb.data["position"] |
| 44 | + if (position == "landscape" || position == "portrait") { |
| 45 | + breadcrumbData["position"] = position |
| 46 | + } else { |
| 47 | + return null |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + breadcrumb.type == "navigation" -> { |
| 52 | + breadcrumbCategory = "navigation" |
| 53 | + breadcrumbData["to"] = when { |
| 54 | + breadcrumb.data["state"] == "resumed" -> (breadcrumb.data["screen"] as? String)?.substringAfterLast('.') |
| 55 | + "to" in breadcrumb.data -> breadcrumb.data["to"] as? String |
| 56 | + else -> null |
| 57 | + } ?: return null |
| 58 | + } |
| 59 | + |
| 60 | + breadcrumb.category == "ui.click" -> { |
| 61 | + breadcrumbCategory = "ui.tap" |
| 62 | + breadcrumbMessage = ( |
| 63 | + breadcrumb.data["view.id"] |
| 64 | + ?: breadcrumb.data["view.tag"] |
| 65 | + ?: breadcrumb.data["view.class"] |
| 66 | + ) as? String ?: return null |
| 67 | + breadcrumbData.putAll(breadcrumb.data) |
| 68 | + } |
| 69 | + |
| 70 | + breadcrumb.type == "system" && breadcrumb.category == "network.event" -> { |
| 71 | + breadcrumbCategory = "device.connectivity" |
| 72 | + breadcrumbData["state"] = when { |
| 73 | + breadcrumb.data["action"] == "NETWORK_LOST" -> "offline" |
| 74 | + "network_type" in breadcrumb.data -> if (!(breadcrumb.data["network_type"] as? String).isNullOrEmpty()) { |
| 75 | + breadcrumb.data["network_type"] |
| 76 | + } else { |
| 77 | + return null |
| 78 | + } |
| 79 | + |
| 80 | + else -> return null |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + breadcrumb.data["action"] == "BATTERY_CHANGED" -> { |
| 85 | + breadcrumbCategory = "device.battery" |
| 86 | + breadcrumbData.putAll( |
| 87 | + breadcrumb.data.filterKeys { it == "level" || it == "charging" } |
| 88 | + ) |
| 89 | + } |
| 90 | + |
| 91 | + else -> { |
| 92 | + breadcrumbCategory = breadcrumb.category |
| 93 | + breadcrumbMessage = breadcrumb.message |
| 94 | + breadcrumbLevel = breadcrumb.level |
| 95 | + breadcrumbData.putAll(breadcrumb.data) |
| 96 | + } |
| 97 | + } |
| 98 | + return if (!breadcrumbCategory.isNullOrEmpty()) { |
| 99 | + RRWebBreadcrumbEvent().apply { |
| 100 | + timestamp = breadcrumb.timestamp.time |
| 101 | + breadcrumbTimestamp = breadcrumb.timestamp.time / 1000.0 |
| 102 | + breadcrumbType = "default" |
| 103 | + category = breadcrumbCategory |
| 104 | + message = breadcrumbMessage |
| 105 | + level = breadcrumbLevel |
| 106 | + data = breadcrumbData |
| 107 | + } |
| 108 | + } else { |
| 109 | + null |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + private fun Breadcrumb.isValidForRRWebSpan(): Boolean { |
| 114 | + return !(data["url"] as? String).isNullOrEmpty() && |
| 115 | + SpanDataConvention.HTTP_START_TIMESTAMP in data && |
| 116 | + SpanDataConvention.HTTP_END_TIMESTAMP in data |
| 117 | + } |
| 118 | + |
| 119 | + private fun String.snakeToCamelCase(): String { |
| 120 | + return replace(snakecasePattern) { it.value.last().uppercase() } |
| 121 | + } |
| 122 | + |
| 123 | + private fun Breadcrumb.toRRWebSpanEvent(): RRWebSpanEvent { |
| 124 | + val breadcrumb = this |
| 125 | + return RRWebSpanEvent().apply { |
| 126 | + timestamp = breadcrumb.timestamp.time |
| 127 | + op = "resource.http" |
| 128 | + description = breadcrumb.data["url"] as String |
| 129 | + startTimestamp = |
| 130 | + (breadcrumb.data[SpanDataConvention.HTTP_START_TIMESTAMP] as Long) / 1000.0 |
| 131 | + endTimestamp = |
| 132 | + (breadcrumb.data[SpanDataConvention.HTTP_END_TIMESTAMP] as Long) / 1000.0 |
| 133 | + |
| 134 | + val breadcrumbData = mutableMapOf<String, Any?>() |
| 135 | + for ((key, value) in breadcrumb.data) { |
| 136 | + if (key in supportedNetworkData) { |
| 137 | + breadcrumbData[ |
| 138 | + key |
| 139 | + .replace("content_length", "body_size") |
| 140 | + .substringAfter(".") |
| 141 | + .snakeToCamelCase() |
| 142 | + ] = value |
| 143 | + } |
| 144 | + } |
| 145 | + data = breadcrumbData |
| 146 | + } |
| 147 | + } |
| 148 | +} |
0 commit comments