Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat View hierarchy #1189

Merged
merged 18 commits into from
Jan 10, 2023
8 changes: 8 additions & 0 deletions dart/lib/src/hint.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class Hint {

SentryAttachment? screenshot;

SentryAttachment? viewHierarchy;

Hint();

factory Hint.withMap(Map<String, Object> map) {
Expand All @@ -39,6 +41,12 @@ class Hint {
return hint;
}

factory Hint.withViewHierarchy(SentryAttachment viewHierarchy) {
final hint = Hint();
hint.viewHierarchy = viewHierarchy;
return hint;
}

// Objects

void addAll(Map<String, Object> keysAndValues) {
Expand Down
3 changes: 3 additions & 0 deletions dart/lib/src/protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ export 'protocol/sentry_trace_header.dart';
export 'protocol/sentry_transaction_name_source.dart';
export 'protocol/sentry_baggage_header.dart';
export 'protocol/sentry_transaction_info.dart';
// view hierarchy
export 'protocol/sentry_view_hierarchy.dart';
export 'protocol/sentry_view_hierarchy_element.dart';
22 changes: 22 additions & 0 deletions dart/lib/src/protocol/sentry_view_hierarchy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'package:meta/meta.dart';

import 'sentry_view_hierarchy_element.dart';

@immutable
class SentryViewHierarchy {
SentryViewHierarchy(this.renderingSystem);

final String renderingSystem;
final List<SentryViewHierarchyElement> windows = [];

/// Header encoded as JSON
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json['rendering_system'] = renderingSystem;
if (windows.isNotEmpty) {
json['windows'] = windows.map((e) => e.toJson()).toList(growable: false);
}

return json;
}
}
74 changes: 74 additions & 0 deletions dart/lib/src/protocol/sentry_view_hierarchy_element.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'package:meta/meta.dart';

@immutable
class SentryViewHierarchyElement {
SentryViewHierarchyElement(
this.type, {
this.depth,
this.identifier,
this.width,
this.height,
this.x,
this.y,
this.z,
this.visible,
this.alpha,
this.extra,
});

final String type;
final int? depth;
final String? identifier;
final List<SentryViewHierarchyElement> children = [];
final double? width;
final double? height;
final double? x;
final double? y;
final double? z;
final bool? visible;
final double? alpha;
final Map<String, dynamic>? extra;

/// Header encoded as JSON
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json['type'] = type;
json['depth'] = depth;
if (identifier != null) {
json['identifier'] = identifier;
}
if (children.isNotEmpty) {
json['children'] =
children.map((e) => e.toJson()).toList(growable: false);
}
if (width != null) {
json['width'] = width;
}
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
if (height != null) {
json['height'] = height;
}
if (x != null) {
json['x'] = x;
}
if (y != null) {
json['y'] = y;
}
if (z != null) {
json['z'] = z;
}
if (visible != null) {
json['visible'] = visible;
}
if (alpha != null) {
json['alpha'] = alpha;
}
final tempExtra = extra;
if (tempExtra != null) {
for (final key in tempExtra.keys) {
json[key] = tempExtra[key];
}
}

return json;
}
}
13 changes: 13 additions & 0 deletions dart/lib/src/sentry_attachment/sentry_attachment.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:typed_data';

import '../protocol/sentry_view_hierarchy.dart';
import '../utils.dart';

// https://develop.sentry.dev/sdk/features/#attachments
// https://develop.sentry.dev/sdk/envelopes/#attachment

Expand Down Expand Up @@ -28,6 +31,8 @@ class SentryAttachment {
/// breadcrumbs.
static const String typeUnrealLogs = 'unreal.logs';

static const String typeViewHierarchy = 'event.view_hierarchy';

SentryAttachment.fromLoader({
required ContentLoader loader,
required this.filename,
Expand Down Expand Up @@ -88,6 +93,14 @@ class SentryAttachment {
contentType: 'image/png',
attachmentType: SentryAttachment.typeAttachmentDefault);

SentryAttachment.fromViewHierrchy(SentryViewHierarchy sentryViewHierarchy)
marandaneto marked this conversation as resolved.
Show resolved Hide resolved
: this.fromLoader(
loader: () => Uint8List.fromList(
utf8JsonEncoder.convert(sentryViewHierarchy.toJson())),
filename: 'view-hierarchy.json',
contentType: 'application/json',
attachmentType: SentryAttachment.typeViewHierarchy);

/// Attachment type.
/// Should be one of types given in [AttachmentType].
final String attachmentType;
Expand Down
5 changes: 5 additions & 0 deletions dart/lib/src/sentry_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ class SentryClient {
attachments.add(screenshot);
}

var viewHierarchy = hint.viewHierarchy;
if (viewHierarchy != null) {
attachments.add(viewHierarchy);
}

final envelope = SentryEnvelope.fromEvent(
preparedEvent,
_options.sdk,
Expand Down
11 changes: 11 additions & 0 deletions flutter/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,17 @@ class MainScaffold extends StatelessWidget {
},
child: const Text('Capture from PlatformDispatcher.onError'),
),
ElevatedButton(
key: const Key('view hierarchy'),
onPressed: () => {},
child: const Visibility(
visible: true,
child: Opacity(
opacity: 0.5,
child: Text('view hierarchy'),
),
),
),
ElevatedButton(
onPressed: () => makeWebRequest(context),
child: const Text('Dart: Web request'),
Expand Down
4 changes: 4 additions & 0 deletions flutter/lib/src/sentry_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'event_processor/flutter_enricher_event_processor.dart';
import 'file_system_transport.dart';

import 'version.dart';
import 'view_hierarchy/view_hierarchy_integration.dart';

/// Configuration options callback
typedef FlutterOptionsConfiguration = FutureOr<void> Function(
Expand Down Expand Up @@ -157,6 +158,9 @@ mixin SentryFlutter {
integrations.add(ScreenshotIntegration());
}

// TODO: check if it works with all renderers
integrations.add(SentryViewHierarchyIntegration());

integrations.add(DebugPrintIntegration());

// This is an Integration because we want to execute it after all the
Expand Down
3 changes: 3 additions & 0 deletions flutter/lib/src/sentry_flutter_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ class SentryFlutterOptions extends SentryOptions {
@internal
late RendererWrapper rendererWrapper = RendererWrapper();

@experimental
bool attachViewHierarchy = true;

/// By using this, you are disabling native [Breadcrumb] tracking and instead
/// you are just tracking [Breadcrumb]s which result from events available
/// in the current Flutter environment.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:meta/meta.dart';

import '../../sentry_flutter.dart';
import '../widget_utils.dart';
import 'user_interaction_widget.dart';

// Adapted from https://github.com/ueman/sentry-dart-tools/blob/8e41418c0f2c62dc88292cf32a4f22e79112b744/sentry_flutter_plus/lib/src/widgets/click_tracker.dart
Expand Down Expand Up @@ -108,7 +109,7 @@ class _SentryUserInteractionWidgetState

void _onTappedAt(Offset position) {
final tappedWidget = _getElementAt(position);
final keyValue = tappedWidget?.keyValue;
final keyValue = tappedWidget?.element.widget.key?.toStringValue();
if (tappedWidget == null || keyValue == null) {
return;
}
Expand Down
13 changes: 0 additions & 13 deletions flutter/lib/src/user_interaction/user_interaction_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,4 @@ class UserInteractionWidget {
required this.type,
required this.eventType,
});

String? get keyValue {
final key = element.widget.key;
if (key == null) {
return null;
}
if (key is ValueKey<String>) {
return key.value;
} else if (key is ValueKey) {
return key.value?.toString();
}
return key.toString();
}
}
102 changes: 102 additions & 0 deletions flutter/lib/src/view_hierarchy/sentry_tree_walker.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import 'package:flutter/widgets.dart';

import '../../sentry_flutter.dart';
import '../widget_utils.dart';

// adapted from https://github.com/ueman/sentry-dart-tools/blob/8e41418c0f2c62dc88292cf32a4f22e79112b744/sentry_flutter_plus/lib/src/integrations/tree_walker_integration.dart

class _TreeWalker {
static const _privateDelimiter = '_';

_TreeWalker(this.rootElement);

final Element rootElement;

ValueChanged<Element> _visitor(
SentryViewHierarchyElement parentSentryElement) {
return (Element element) {
final sentryElement = _toSentryViewHierarchyElement(element);

var privateElement = false;
// TODO: check if that works with obfuscation enabled
if (sentryElement.type.startsWith(_privateDelimiter) ||
(sentryElement.identifier?.startsWith(_privateDelimiter) ?? false)) {
privateElement = true;
} else {
parentSentryElement.children.add(sentryElement);
}

// we don't want to add private children but we still want to walk the tree
markushi marked this conversation as resolved.
Show resolved Hide resolved
element.visitChildElements(
_visitor(privateElement ? parentSentryElement : sentryElement));
};
}

SentryViewHierarchy? toSentryViewHierarchy() {
final sentryRootElement = _toSentryViewHierarchyElement(rootElement);
rootElement.visitChildElements(_visitor(sentryRootElement));

final sentryViewHierarchy = SentryViewHierarchy('flutter');
sentryViewHierarchy.windows.add(sentryRootElement);
return sentryViewHierarchy;
}

SentryViewHierarchyElement _toSentryViewHierarchyElement(Element element) {
final widget = element.widget;

double? width;
double? height;
double? x;
double? y;
bool? visible;
double? alpha;

// Widget has to be RenderBox to have a size
if (widget is RenderBox) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @marandaneto, is it possible that this should be
if (element.renderObject is RenderBox) {
instead?
@narsaynorath is seeing elements in the View Hierarchy which have a x/y coordinate but no width/height.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, #1258

final size = element.size;
width = size?.width;
height = size?.height;
}

final renderObject = element.renderObject;
if (renderObject is RenderBox) {
final offset = renderObject.localToGlobal(Offset.zero);
if (offset.dx > 0) {
x = offset.dx;
}
if (offset.dy > 0) {
y = offset.dy;
}
}

if (widget is Visibility) {
visible = widget.visible;
}
if (widget is Opacity) {
alpha = widget.opacity;
}

return SentryViewHierarchyElement(
element.widget.runtimeType.toString(),
depth: element.depth,
identifier: element.widget.key?.toStringValue(),
width: width,
height: height,
x: x,
y: y,
visible: visible,
alpha: alpha,
);
}
}

SentryViewHierarchy? walkWidgetTree(WidgetsBinding instance) {
final rootElement = instance.renderViewElement;
if (rootElement == null) {
return null;
}

final walker = _TreeWalker(rootElement);

return walker.toSentryViewHierarchy();
}
Loading