Skip to content

Commit

Permalink
feat: replay breadcrumbs (android) (#2163)
Browse files Browse the repository at this point in the history
* feat: replay breadcrumbs

* ktlint format

* fixup tests

* cleanup

* linter issues

* detekt linter issue

* move touch path build to dart to deduplicate

* fix metrics app compilation

* linter issue
  • Loading branch information
vaind authored Jul 17, 2024
1 parent 1706c68 commit 5dc8bd6
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 5 deletions.
30 changes: 30 additions & 0 deletions dart/lib/src/protocol/breadcrumb.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ class Breadcrumb {
String? httpQuery,
String? httpFragment,
}) {
// The timestamp is used as the request-end time, so we need to set it right
// now and not rely on the default constructor.
timestamp ??= getUtcDateTime();

return Breadcrumb(
type: 'http',
category: 'http',
Expand All @@ -65,6 +69,11 @@ class Breadcrumb {
if (responseBodySize != null) 'response_body_size': responseBodySize,
if (httpQuery != null) 'http.query': httpQuery,
if (httpFragment != null) 'http.fragment': httpFragment,
if (requestDuration != null)
'start_timestamp':
timestamp.millisecondsSinceEpoch - requestDuration.inMilliseconds,
if (requestDuration != null)
'end_timestamp': timestamp.millisecondsSinceEpoch,
},
);
}
Expand Down Expand Up @@ -95,11 +104,32 @@ class Breadcrumb {
String? viewClass,
}) {
final newData = data ?? {};
var path = '';

if (viewId != null) {
newData['view.id'] = viewId;
path = viewId;
}

if (newData.containsKey('label')) {
if (path.isEmpty) {
path = newData['label'];
} else {
path = "$path, label: ${newData['label']}";
}
}

if (viewClass != null) {
newData['view.class'] = viewClass;
if (path.isEmpty) {
path = viewClass;
} else {
path = "$viewClass($path)";
}
}

if (path.isNotEmpty && !newData.containsKey('path')) {
newData['path'] = path;
}

return Breadcrumb(
Expand Down
33 changes: 30 additions & 3 deletions dart/test/protocol/breadcrumb_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ void main() {
level: SentryLevel.fatal,
reason: 'OK',
statusCode: 200,
requestDuration: Duration.zero,
requestDuration: Duration(milliseconds: 55),
timestamp: DateTime.now(),
requestBodySize: 2,
responseBodySize: 3,
Expand All @@ -103,17 +103,43 @@ void main() {
'method': 'GET',
'status_code': 200,
'reason': 'OK',
'duration': '0:00:00.000000',
'duration': '0:00:00.055000',
'request_body_size': 2,
'response_body_size': 3,
'http.query': 'foo=bar',
'http.fragment': 'baz'
'http.fragment': 'baz',
'start_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch - 55,
'end_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch
},
'level': 'fatal',
'type': 'http',
});
});

test('Breadcrumb http', () {
final breadcrumb = Breadcrumb.http(
url: Uri.parse('https://example.org'),
method: 'GET',
requestDuration: Duration(milliseconds: 10),
);
final json = breadcrumb.toJson();

expect(json, {
'timestamp':
formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp),
'category': 'http',
'data': {
'url': 'https://example.org',
'method': 'GET',
'duration': '0:00:00.010000',
'start_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch - 10,
'end_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch
},
'level': 'info',
'type': 'http',
});
});

test('Minimal Breadcrumb http', () {
final breadcrumb = Breadcrumb.http(
url: Uri.parse('https://example.org'),
Expand Down Expand Up @@ -192,6 +218,7 @@ void main() {
'foo': 'bar',
'view.id': 'foo',
'view.class': 'bar',
'path': 'bar(foo)',
},
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
recorderConfigProvider = null,
replayCacheProvider = null,
)

replay.breadcrumbConverter = SentryFlutterReplayBreadcrumbConverter()
options.addIntegration(replay)
options.setReplayController(replay)
} else {
Expand Down Expand Up @@ -425,7 +425,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
if (args.isNotEmpty()) {
val event = args.first() as ByteArray?
val containsUnhandledException = args[1] as Boolean
if (event != null && event.isNotEmpty() && containsUnhandledException != null) {
if (event != null && event.isNotEmpty()) {
val id = InternalSentrySdk.captureEnvelope(event, containsUnhandledException)
if (id != null) {
result.success("")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package io.sentry.flutter

import io.sentry.Breadcrumb
import io.sentry.android.replay.DefaultReplayBreadcrumbConverter
import io.sentry.rrweb.RRWebBreadcrumbEvent
import io.sentry.rrweb.RRWebEvent
import io.sentry.rrweb.RRWebSpanEvent
import java.util.Date

private const val MILLIS_PER_SECOND = 1000.0

class SentryFlutterReplayBreadcrumbConverter : DefaultReplayBreadcrumbConverter() {
internal companion object {
private val supportedNetworkData =
mapOf(
"status_code" to "statusCode",
"method" to "method",
"response_body_size" to "responseBodySize",
"request_body_size" to "requestBodySize",
)
}

override fun convert(breadcrumb: Breadcrumb): RRWebEvent? {
return when (breadcrumb.category) {
null -> null
"sentry.event" -> null
"sentry.transaction" -> null
"http" -> convertNetworkBreadcrumb(breadcrumb)
"navigation" -> newRRWebBreadcrumb(breadcrumb)
"ui.click" ->
newRRWebBreadcrumb(breadcrumb).apply {
category = "ui.tap"
message = breadcrumb.data["path"] as String?
}

else -> {
val nativeBreadcrumb = super.convert(breadcrumb)

// ignore native navigation breadcrumbs
if (nativeBreadcrumb is RRWebBreadcrumbEvent) {
if (nativeBreadcrumb.category == "navigation") {
return null
}
}

nativeBreadcrumb
}
}
}

private fun newRRWebBreadcrumb(breadcrumb: Breadcrumb): RRWebBreadcrumbEvent =
RRWebBreadcrumbEvent().apply {
category = breadcrumb.category
level = breadcrumb.level
data = breadcrumb.data
timestamp = breadcrumb.timestamp.time
breadcrumbTimestamp = doubleTimestamp(breadcrumb.timestamp)
breadcrumbType = "default"
}

private fun doubleTimestamp(date: Date) = doubleTimestamp(date.time)

private fun doubleTimestamp(timestamp: Long) = timestamp / MILLIS_PER_SECOND

private fun convertNetworkBreadcrumb(breadcrumb: Breadcrumb): RRWebEvent? {
var rrWebEvent = super.convert(breadcrumb)
if (rrWebEvent == null &&
breadcrumb.data.containsKey("start_timestamp") &&
breadcrumb.data.containsKey("end_timestamp")
) {
rrWebEvent =
RRWebSpanEvent().apply {
op = "resource.http"
timestamp = breadcrumb.timestamp.time
description = breadcrumb.data["url"] as String
startTimestamp = doubleTimestamp(breadcrumb.data["start_timestamp"] as Long)
endTimestamp = doubleTimestamp(breadcrumb.data["end_timestamp"] as Long)
data =
breadcrumb.data
.filterKeys { key -> supportedNetworkData.containsKey(key) }
.mapKeys { (key, _) -> supportedNetworkData[key] }
}
}
return rrWebEvent
}
}

0 comments on commit 5dc8bd6

Please sign in to comment.