Skip to content

Commit

Permalink
feat: capture touch breadcrumbs for all buttons (#2242)
Browse files Browse the repository at this point in the history
* chore: cleanup user interaction widget code

* renames & more cleanup

* more cleanup

* more refactoring & clenaup before actual functional changes

* more refactoring

* feat: collect touch element path

* update tests

* add tests for the new support of non-keyed button presses

* cleanup & improve existing code

* chore: update changelog

* update native replay integration with touch breadcrumb path

* fix tests

* Update CHANGELOG.md

* linter issues

---------

Co-authored-by: Giancarlo Buenaflor <[email protected]>
  • Loading branch information
vaind and buenaflor authored Sep 9, 2024
1 parent d5696bf commit 3751dbc
Show file tree
Hide file tree
Showing 11 changed files with 506 additions and 232 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
);
```

- Collect touch breadcrumbs for all buttons, not just those with `key` specified. ([#2242](https://github.com/getsentry/sentry-dart/pull/2242))

### Dependencies

- Bump Cocoa SDK from v8.35.1 to v8.36.0 ([#2252](https://github.com/getsentry/sentry-dart/pull/2252))
Expand Down
35 changes: 5 additions & 30 deletions dart/lib/src/protocol/breadcrumb.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,42 +105,17 @@ class Breadcrumb {
String? viewId,
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(
message: message,
level: level,
category: 'ui.$subCategory',
type: 'user',
timestamp: timestamp,
data: newData,
data: {
if (viewId != null) 'view.id': viewId,
if (viewClass != null) 'view.class': viewClass,
if (data != null) ...data,
},
);
}

Expand Down
1 change: 0 additions & 1 deletion dart/test/protocol/breadcrumb_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ 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 @@ -8,6 +8,8 @@ import io.sentry.rrweb.RRWebSpanEvent
import java.util.Date

private const val MILLIS_PER_SECOND = 1000.0
private const val MAX_PATH_ITEMS = 4
private const val MAX_PATH_IDENTIFIER_LENGTH = 20

class SentryFlutterReplayBreadcrumbConverter : DefaultReplayBreadcrumbConverter() {
internal companion object {
Expand All @@ -30,7 +32,7 @@ class SentryFlutterReplayBreadcrumbConverter : DefaultReplayBreadcrumbConverter(
"ui.click" ->
newRRWebBreadcrumb(breadcrumb).apply {
category = "ui.tap"
message = breadcrumb.data["path"] as String?
message = getTouchPathMessage(breadcrumb.data["path"])
}

else -> {
Expand Down Expand Up @@ -83,4 +85,34 @@ class SentryFlutterReplayBreadcrumbConverter : DefaultReplayBreadcrumbConverter(
}
return rrWebEvent
}

private fun getTouchPathMessage(maybePath: Any?): String? {
if (maybePath !is List<*> || maybePath.isEmpty()) {
return null
}

val message = StringBuilder()
for (i in Math.min(MAX_PATH_ITEMS, maybePath.size) - 1 downTo 0) {
val item = maybePath[i]
if (item !is Map<*, *>) {
continue
}

message.append(item["element"] ?: "?")

var identifier = item["label"] ?: item["name"]
if (identifier is String && identifier.isNotEmpty()) {
if (identifier.length > MAX_PATH_IDENTIFIER_LENGTH) {
identifier = identifier.substring(0, MAX_PATH_IDENTIFIER_LENGTH - "...".length) + "..."
}
message.append("(").append(identifier).append(")")
}

if (i > 0) {
message.append(" > ")
}
}

return message.toString()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal class SentryFlutterReplayRecorder(
private val channel: MethodChannel,
private val integration: ReplayIntegration,
) : Recorder {
override fun start(config: ScreenshotRecorderConfig) {
override fun start(recorderConfig: ScreenshotRecorderConfig) {
val cacheDirPath = integration.replayCacheDir?.absolutePath
if (cacheDirPath == null) {
Log.w("Sentry", "Replay cache directory is null, can't start replay recorder.")
Expand All @@ -24,9 +24,9 @@ internal class SentryFlutterReplayRecorder(
"ReplayRecorder.start",
mapOf(
"directory" to cacheDirPath,
"width" to config.recordingWidth,
"height" to config.recordingHeight,
"frameRate" to config.frameRate,
"width" to recorderConfig.recordingWidth,
"height" to recorderConfig.recordingHeight,
"frameRate" to recorderConfig.frameRate,
"replayId" to integration.getReplayId().toString(),
),
)
Expand Down
38 changes: 37 additions & 1 deletion flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ - (instancetype _Nonnull)init {
if ([breadcrumb.category isEqualToString:@"ui.click"]) {
return [self convertFrom:breadcrumb
withCategory:@"ui.tap"
andMessage:breadcrumb.data[@"path"]];
andMessage:[self getTouchPathMessage:breadcrumb.data[@"path"]]];
}

SentryRRWebEvent *nativeBreadcrumb =
Expand Down Expand Up @@ -112,6 +112,42 @@ - (NSDate *_Nonnull)dateFrom:(NSNumber *_Nonnull)timestamp {
return [NSDate dateWithTimeIntervalSince1970:(timestamp.doubleValue / 1000)];
}

- (NSString * _Nullable)getTouchPathMessage:(id _Nullable)maybePath {
if (![maybePath isKindOfClass:[NSArray class]]) {
return nil;
}

NSArray *path = (NSArray *)maybePath;
if (path.count == 0) {
return nil;
}

NSMutableString *message = [NSMutableString string];
for (NSInteger i = MIN(3, path.count - 1); i >= 0; i--) {
id item = path[i];
if (![item isKindOfClass:[NSDictionary class]]) {
continue;
}

NSDictionary *itemDict = (NSDictionary *)item;
[message appendString:itemDict[@"element"] ?: @"?"];

id identifier = itemDict[@"label"] ?: itemDict[@"name"];
if ([identifier isKindOfClass:[NSString class]] && [(NSString *)identifier length] > 0) {
NSString *identifierStr = (NSString *)identifier;
if (identifierStr.length > 20) {
identifierStr = [[identifierStr substringToIndex:17] stringByAppendingString:@"..."];
}
[message appendFormat:@"(%@)", identifierStr];
}

if (i > 0) {
[message appendString:@" > "];
}
}

return message.length > 0 ? message : nil;
}
@end

#endif
4 changes: 2 additions & 2 deletions flutter/lib/src/sentry_flutter_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,14 @@ class SentryFlutterOptions extends SentryOptions {
///
/// Requires adding the [SentryUserInteractionWidget] to the widget tree.
/// Example:
/// runApp(SentryUserInteractionWidget(child: App()));
/// runApp(SentryWidget(child: App()));
bool enableUserInteractionBreadcrumbs = true;

/// Enables the Auto instrumentation for user interaction tracing.
///
/// Requires adding the [SentryUserInteractionWidget] to the widget tree.
/// Example:
/// runApp(SentryUserInteractionWidget(child: App()));
/// runApp(SentryWidget(child: App()));
bool enableUserInteractionTracing = true;

/// Enable or disable the tracing of time to full display (TTFD).
Expand Down
Loading

0 comments on commit 3751dbc

Please sign in to comment.