From f056db1dfac48edcdd700a8efbfdb64b6e8e39c9 Mon Sep 17 00:00:00 2001 From: Giancarlo Buenaflor Date: Mon, 2 Sep 2024 18:13:01 +0200 Subject: [PATCH 1/5] refactor: Remove workaround for Spotlight image handling (#2253) --- dart/lib/src/transport/spotlight_http_transport.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dart/lib/src/transport/spotlight_http_transport.dart b/dart/lib/src/transport/spotlight_http_transport.dart index 7567039d82..1889bb7339 100644 --- a/dart/lib/src/transport/spotlight_http_transport.dart +++ b/dart/lib/src/transport/spotlight_http_transport.dart @@ -38,10 +38,6 @@ class SpotlightHttpTransport extends Transport { Future _sendToSpotlight(SentryEnvelope envelope) async { envelope.header.sentAt = _options.clock(); - // Screenshots do not work currently https://github.com/getsentry/spotlight/issues/274 - envelope.items - .removeWhere((element) => element.header.contentType == 'image/png'); - final spotlightRequest = await _requestHandler.createRequest(envelope); final response = await _options.httpClient From 0210372fac176a538e9f73a48b48083e1a2df9ae Mon Sep 17 00:00:00 2001 From: Ivan Dlugos <6349682+vaind@users.noreply.github.com> Date: Mon, 2 Sep 2024 23:47:22 +0200 Subject: [PATCH 2/5] fix: capture replay call on iOS (#2264) --- flutter/ios/Classes/SentryFlutterPluginApple.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter/ios/Classes/SentryFlutterPluginApple.swift b/flutter/ios/Classes/SentryFlutterPluginApple.swift index 3e436c88bb..24e50cc166 100644 --- a/flutter/ios/Classes/SentryFlutterPluginApple.swift +++ b/flutter/ios/Classes/SentryFlutterPluginApple.swift @@ -182,7 +182,7 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { case "nativeCrash": crash() - case "sendReplayForEvent": + case "captureReplay": #if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS)) PrivateSentrySDKOnly.captureReplay() result(PrivateSentrySDKOnly.getReplayId()) From 7b2e0ad54ee7a20e1ca24a40ee897000e52b5ae3 Mon Sep 17 00:00:00 2001 From: Martin Haintz Date: Tue, 3 Sep 2024 11:35:19 +0200 Subject: [PATCH 3/5] Support allowUrls, denyUrls (#2227) * moved regex matcher into regex utils * add allowUrls, denyUrls for web * add changelog entry for allowUrls and denyUrls * add conditional import for non web platforms * fix multiplatform build * fix wording in sentry options * Update dart/lib/src/utils/regex_utils.dart Co-authored-by: Giancarlo Buenaflor * Update dart/lib/src/sentry_options.dart Co-authored-by: Giancarlo Buenaflor * Update dart/lib/src/sentry_options.dart Co-authored-by: Giancarlo Buenaflor * add tests for isMatchingRegexPattern * simplified allowUrls and denyUrls handling * moved allowUrls and denyUrls from dart to flutter * add event processor for html * rephrased documentation and split up tests for web and mobile platform. * add expected error * Update scripts/publish_validation/bin/publish_validation.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart Co-authored-by: Giancarlo Buenaflor --------- Co-authored-by: Giancarlo Buenaflor --- CHANGELOG.md | 14 ++ dart/lib/src/sentry_client.dart | 12 +- dart/lib/src/sentry_options.dart | 2 + dart/lib/src/utils/regex_utils.dart | 9 + dart/test/utils/regex_utils_test.dart | 24 +++ .../html_url_filter_event_processor.dart | 54 ++++++ .../io_url_filter_event_processor.dart | 10 + .../url_filter_event_processor.dart | 9 + .../web_url_filter_event_processor.dart | 56 ++++++ flutter/lib/src/sentry_flutter.dart | 2 + flutter/lib/src/sentry_flutter_options.dart | 15 ++ .../io_filter_event_processor_test.dart | 39 ++++ .../web_url_filter_event_processor_test.dart | 179 ++++++++++++++++++ .../bin/publish_validation.dart | 3 +- 14 files changed, 418 insertions(+), 10 deletions(-) create mode 100644 dart/lib/src/utils/regex_utils.dart create mode 100644 dart/test/utils/regex_utils_test.dart create mode 100644 flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart create mode 100644 flutter/lib/src/event_processor/url_filter/io_url_filter_event_processor.dart create mode 100644 flutter/lib/src/event_processor/url_filter/url_filter_event_processor.dart create mode 100644 flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart create mode 100644 flutter/test/event_processor/url_filter/io_filter_event_processor_test.dart create mode 100644 flutter/test/event_processor/url_filter/web_url_filter_event_processor_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index a486e6eb1a..666ed2bbd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ### Features +- Support allowUrls and denyUrls for Flutter Web ([#2227](https://github.com/getsentry/sentry-dart/pull/2227)) + + ```dart + await SentryFlutter.init( + (options) { + ... + options.allowUrls = ["^https://sentry.com.*\$", "my-custom-domain"]; + options.denyUrls = ["^.*ends-with-this\$", "denied-url"]; + }, + appRunner: () => runApp(MyApp()), + ); + ``` + - Session replay Alpha for Android and iOS ([#2208](https://github.com/getsentry/sentry-dart/pull/2208)). To try out replay, you can set following options (access is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/)): @@ -31,6 +44,7 @@ - Add `SentryFlutter.nativeCrash()` using MethodChannels for Android and iOS ([#2239](https://github.com/getsentry/sentry-dart/pull/2239)) - This can be used to test if native crash reporting works + - Add `ignoreRoutes` parameter to `SentryNavigatorObserver`. ([#2218](https://github.com/getsentry/sentry-dart/pull/2218)) - This will ignore the Routes and prevent the Route from being pushed to the Sentry server. - Ignored routes will also create no TTID and TTFD spans. diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index ba557aad2e..b66c0d25b5 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -26,6 +26,7 @@ import 'transport/rate_limiter.dart'; import 'transport/spotlight_http_transport.dart'; import 'transport/task_queue.dart'; import 'utils/isolate_utils.dart'; +import 'utils/regex_utils.dart'; import 'utils/stacktrace_utils.dart'; import 'version.dart'; @@ -196,7 +197,7 @@ class SentryClient { } var message = event.message!.formatted; - return _isMatchingRegexPattern(message, _options.ignoreErrors); + return isMatchingRegexPattern(message, _options.ignoreErrors); } SentryEvent _prepareEvent(SentryEvent event, {dynamic stackTrace}) { @@ -415,7 +416,7 @@ class SentryClient { } var name = transaction.tracer.name; - return _isMatchingRegexPattern(name, _options.ignoreTransactions); + return isMatchingRegexPattern(name, _options.ignoreTransactions); } /// Reports the [envelope] to Sentry.io. @@ -593,11 +594,4 @@ class SentryClient { SentryId.empty(), ); } - - bool _isMatchingRegexPattern(String value, List regexPattern, - {bool caseSensitive = false}) { - final combinedRegexPattern = regexPattern.join('|'); - final regExp = RegExp(combinedRegexPattern, caseSensitive: caseSensitive); - return regExp.hasMatch(value); - } } diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 2b1771a2b5..5cfbd0fdc7 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -186,10 +186,12 @@ class SentryOptions { /// The ignoreErrors tells the SDK which errors should be not sent to the sentry server. /// If an null or an empty list is used, the SDK will send all transactions. + /// To use regex add the `^` and the `$` to the string. List ignoreErrors = []; /// The ignoreTransactions tells the SDK which transactions should be not sent to the sentry server. /// If null or an empty list is used, the SDK will send all transactions. + /// To use regex add the `^` and the `$` to the string. List ignoreTransactions = []; final List _inAppExcludes = []; diff --git a/dart/lib/src/utils/regex_utils.dart b/dart/lib/src/utils/regex_utils.dart new file mode 100644 index 0000000000..ba64f7504e --- /dev/null +++ b/dart/lib/src/utils/regex_utils.dart @@ -0,0 +1,9 @@ +import 'package:meta/meta.dart'; + +@internal +bool isMatchingRegexPattern(String value, List regexPattern, + {bool caseSensitive = false}) { + final combinedRegexPattern = regexPattern.join('|'); + final regExp = RegExp(combinedRegexPattern, caseSensitive: caseSensitive); + return regExp.hasMatch(value); +} diff --git a/dart/test/utils/regex_utils_test.dart b/dart/test/utils/regex_utils_test.dart new file mode 100644 index 0000000000..ff098ab964 --- /dev/null +++ b/dart/test/utils/regex_utils_test.dart @@ -0,0 +1,24 @@ +import 'package:sentry/src/utils/regex_utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('regex_utils', () { + final testString = "this is a test"; + + test('testString contains string pattern', () { + expect(isMatchingRegexPattern(testString, ["is"]), isTrue); + }); + + test('testString does not contain string pattern', () { + expect(isMatchingRegexPattern(testString, ["not"]), isFalse); + }); + + test('testString contains regex pattern', () { + expect(isMatchingRegexPattern(testString, ["^this.*\$"]), isTrue); + }); + + test('testString does not contain regex pattern', () { + expect(isMatchingRegexPattern(testString, ["^is.*\$"]), isFalse); + }); + }); +} diff --git a/flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart b/flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart new file mode 100644 index 0000000000..7792f6a333 --- /dev/null +++ b/flutter/lib/src/event_processor/url_filter/html_url_filter_event_processor.dart @@ -0,0 +1,54 @@ +import 'dart:html' as html show window, Window; + +import '../../../sentry_flutter.dart'; +import 'url_filter_event_processor.dart'; +// ignore: implementation_imports +import 'package:sentry/src/utils/regex_utils.dart'; + +// ignore_for_file: invalid_use_of_internal_member + +UrlFilterEventProcessor urlFilterEventProcessor(SentryFlutterOptions options) => + WebUrlFilterEventProcessor(options); + +class WebUrlFilterEventProcessor implements UrlFilterEventProcessor { + WebUrlFilterEventProcessor( + this._options, + ); + + final SentryFlutterOptions _options; + + @override + SentryEvent? apply(SentryEvent event, Hint hint) { + final frames = _getStacktraceFrames(event); + final lastPath = frames?.first?.absPath; + + if (lastPath == null) { + return event; + } + + if (_options.allowUrls.isNotEmpty && + !isMatchingRegexPattern(lastPath, _options.allowUrls)) { + return null; + } + + if (_options.denyUrls.isNotEmpty && + isMatchingRegexPattern(lastPath, _options.denyUrls)) { + return null; + } + + return event; + } + + Iterable? _getStacktraceFrames(SentryEvent event) { + if (event.exceptions?.isNotEmpty == true) { + return event.exceptions?.first.stackTrace?.frames; + } + if (event.threads?.isNotEmpty == true) { + final stacktraces = event.threads?.map((e) => e.stacktrace); + return stacktraces + ?.where((element) => element != null) + .expand((element) => element!.frames); + } + return null; + } +} diff --git a/flutter/lib/src/event_processor/url_filter/io_url_filter_event_processor.dart b/flutter/lib/src/event_processor/url_filter/io_url_filter_event_processor.dart new file mode 100644 index 0000000000..b49573bbc5 --- /dev/null +++ b/flutter/lib/src/event_processor/url_filter/io_url_filter_event_processor.dart @@ -0,0 +1,10 @@ +import '../../../sentry_flutter.dart'; +import 'url_filter_event_processor.dart'; + +UrlFilterEventProcessor urlFilterEventProcessor(SentryFlutterOptions _) => + IoUrlFilterEventProcessor(); + +class IoUrlFilterEventProcessor implements UrlFilterEventProcessor { + @override + SentryEvent apply(SentryEvent event, Hint hint) => event; +} diff --git a/flutter/lib/src/event_processor/url_filter/url_filter_event_processor.dart b/flutter/lib/src/event_processor/url_filter/url_filter_event_processor.dart new file mode 100644 index 0000000000..5a1e5ed537 --- /dev/null +++ b/flutter/lib/src/event_processor/url_filter/url_filter_event_processor.dart @@ -0,0 +1,9 @@ +import '../../../sentry_flutter.dart'; +import 'io_url_filter_event_processor.dart' + if (dart.library.html) 'html_url_filter_event_processor.dart' + if (dart.library.js_interop) 'web_url_filter_event_processor.dart'; + +abstract class UrlFilterEventProcessor implements EventProcessor { + factory UrlFilterEventProcessor(SentryFlutterOptions options) => + urlFilterEventProcessor(options); +} diff --git a/flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart b/flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart new file mode 100644 index 0000000000..10cfee3478 --- /dev/null +++ b/flutter/lib/src/event_processor/url_filter/web_url_filter_event_processor.dart @@ -0,0 +1,56 @@ +// We would lose compatibility with old dart versions by adding web to pubspec. +// ignore: depend_on_referenced_packages +import 'package:web/web.dart' as web show window, Window; + +import '../../../sentry_flutter.dart'; +import 'url_filter_event_processor.dart'; +// ignore: implementation_imports +import 'package:sentry/src/utils/regex_utils.dart'; + +// ignore_for_file: invalid_use_of_internal_member + +UrlFilterEventProcessor urlFilterEventProcessor(SentryFlutterOptions options) => + WebUrlFilterEventProcessor(options); + +class WebUrlFilterEventProcessor implements UrlFilterEventProcessor { + WebUrlFilterEventProcessor( + this._options, + ); + + final SentryFlutterOptions _options; + + @override + SentryEvent? apply(SentryEvent event, Hint hint) { + final frames = _getStacktraceFrames(event); + final lastPath = frames?.first?.absPath; + + if (lastPath == null) { + return event; + } + + if (_options.allowUrls.isNotEmpty && + !isMatchingRegexPattern(lastPath, _options.allowUrls)) { + return null; + } + + if (_options.denyUrls.isNotEmpty && + isMatchingRegexPattern(lastPath, _options.denyUrls)) { + return null; + } + + return event; + } + + Iterable? _getStacktraceFrames(SentryEvent event) { + if (event.exceptions?.isNotEmpty == true) { + return event.exceptions?.first.stackTrace?.frames; + } + if (event.threads?.isNotEmpty == true) { + final stacktraces = event.threads?.map((e) => e.stacktrace); + return stacktraces + ?.where((element) => element != null) + .expand((element) => element!.frames); + } + return null; + } +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 3ff835284c..29f533d082 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -9,6 +9,7 @@ import 'event_processor/android_platform_exception_event_processor.dart'; import 'event_processor/flutter_enricher_event_processor.dart'; import 'event_processor/flutter_exception_event_processor.dart'; import 'event_processor/platform_exception_event_processor.dart'; +import 'event_processor/url_filter/url_filter_event_processor.dart'; import 'event_processor/widget_event_processor.dart'; import 'file_system_transport.dart'; import 'flutter_exception_type_identifier.dart'; @@ -131,6 +132,7 @@ mixin SentryFlutter { options.addEventProcessor(FlutterEnricherEventProcessor(options)); options.addEventProcessor(WidgetEventProcessor()); + options.addEventProcessor(UrlFilterEventProcessor(options)); if (options.platformChecker.platform.isAndroid) { options.addEventProcessor( diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index 8b6f7ed491..308ed805b0 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -146,6 +146,21 @@ class SentryFlutterOptions extends SentryOptions { /// See https://api.flutter.dev/flutter/foundation/FlutterErrorDetails/silent.html bool reportSilentFlutterErrors = false; + /// (Web only) Events only occurring on these Urls will be handled and sent to sentry. + /// If an empty list is used, the SDK will send all errors. + /// `allowUrls` uses regex for the matching. + /// + /// If used on a platform other than Web, this setting will be ignored. + List allowUrls = []; + + /// (Web only) Events occurring on these Urls will be ignored and are not sent to sentry. + /// If an empty list is used, the SDK will send all errors. + /// `denyUrls` uses regex for the matching. + /// In combination with `allowUrls` you can block subdomains of the domains listed in `allowUrls`. + /// + /// If used on a platform other than Web, this setting will be ignored. + List denyUrls = []; + /// Enables Out of Memory Tracking for iOS and macCatalyst. /// See the following link for more information and possible restrictions: /// https://docs.sentry.io/platforms/apple/guides/ios/configuration/out-of-memory/ diff --git a/flutter/test/event_processor/url_filter/io_filter_event_processor_test.dart b/flutter/test/event_processor/url_filter/io_filter_event_processor_test.dart new file mode 100644 index 0000000000..22708b95bb --- /dev/null +++ b/flutter/test/event_processor/url_filter/io_filter_event_processor_test.dart @@ -0,0 +1,39 @@ +@TestOn('vm') +library flutter_test; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/event_processor/url_filter/url_filter_event_processor.dart'; + +void main() { + group("ignore allowUrls and denyUrls for non Web", () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + }); + + test('returns the event and ignore allowUrls and denyUrls for non Web', + () async { + SentryEvent? event = SentryEvent( + request: SentryRequest( + url: 'another.url/for/a/special/test/testing/this-feature', + ), + ); + fixture.options.allowUrls = ["^this.is/.*\$"]; + fixture.options.denyUrls = ["special"]; + + var eventProcessor = fixture.getSut(); + event = await eventProcessor.apply(event, Hint()); + + expect(event, isNotNull); + }); + }); +} + +class Fixture { + SentryFlutterOptions options = SentryFlutterOptions(); + UrlFilterEventProcessor getSut() { + return UrlFilterEventProcessor(options); + } +} diff --git a/flutter/test/event_processor/url_filter/web_url_filter_event_processor_test.dart b/flutter/test/event_processor/url_filter/web_url_filter_event_processor_test.dart new file mode 100644 index 0000000000..f16f5c5003 --- /dev/null +++ b/flutter/test/event_processor/url_filter/web_url_filter_event_processor_test.dart @@ -0,0 +1,179 @@ +@TestOn('browser') +library flutter_test; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/event_processor/url_filter/url_filter_event_processor.dart'; + +// can be tested on command line with +// `flutter test --platform=chrome test/event_processor/url_filter/web_url_filter_event_processor_test.dart` +void main() { + group(UrlFilterEventProcessor, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('returns event if no allowUrl and no denyUrl is set', () async { + SentryEvent? event = SentryEvent( + request: SentryRequest( + url: 'foo.bar', + ), + ); + + var eventProcessor = fixture.getSut(); + event = await eventProcessor.apply(event, Hint()); + + expect(event, isNotNull); + }); + + test('returns null if allowUrl is set and does not match with url', + () async { + final event = _createEventWithException("foo.bar"); + fixture.options.allowUrls = ["another.url"]; + + var eventProcessor = fixture.getSut(); + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNull); + }); + + test('returns event if allowUrl is set and does partially match with url', + () async { + final event = _createEventWithException("foo.bar"); + fixture.options.allowUrls = ["bar"]; + + var eventProcessor = fixture.getSut(); + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNotNull); + }); + + test('returns event if denyUrl is set and does not match with url', + () async { + final event = _createEventWithException("foo.bar"); + fixture.options.denyUrls = ["another.url"]; + + var eventProcessor = fixture.getSut(); + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNotNull); + }); + + test('returns null if denyUrl is set and partially matches with url', + () async { + final event = _createEventWithException("foo.bar"); + fixture.options.denyUrls = ["bar"]; + + var eventProcessor = fixture.getSut(); + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNull); + }); + + test( + 'returns null if it is part of the allowed domain, but blocked for subdomain', + () async { + final event = _createEventWithException( + "this.is/a/special/url/for-testing/this-feature"); + + fixture.options.allowUrls = ["^this.is/.*\$"]; + fixture.options.denyUrls = ["special"]; + + var eventProcessor = fixture.getSut(); + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNull); + }); + + test( + 'returns event if it is part of the allowed domain, and not of the blocked for subdomain', + () async { + final event = _createEventWithException( + "this.is/a/test/url/for-testing/this-feature"); + fixture.options.allowUrls = ["^this.is/.*\$"]; + fixture.options.denyUrls = ["special"]; + + var eventProcessor = fixture.getSut(); + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNotNull); + }); + + test( + 'returns null if it is not part of the allowed domain, and not of the blocked for subdomain', + () async { + final event = _createEventWithException( + "another.url/for/a/test/testing/this-feature"); + fixture.options.allowUrls = ["^this.is/.*\$"]; + fixture.options.denyUrls = ["special"]; + + var eventProcessor = fixture.getSut(); + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNull); + }); + + test( + 'returns event if denyUrl is set and not matching with url of first exception', + () async { + final frame1 = SentryStackFrame(absPath: "test.url"); + final st1 = SentryStackTrace(frames: [frame1]); + final exception1 = SentryException( + type: "test-type", value: "test-value", stackTrace: st1); + + final frame2 = SentryStackFrame(absPath: "foo.bar"); + final st2 = SentryStackTrace(frames: [frame2]); + final exception2 = SentryException( + type: "test-type", value: "test-value", stackTrace: st2); + + SentryEvent event = SentryEvent(exceptions: [exception1, exception2]); + + fixture.options.denyUrls = ["bar"]; + + var eventProcessor = fixture.getSut(); + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNotNull); + }); + + test( + 'returns event if denyUrl is set and not matching with url of first stacktraceframe', + () async { + final frame1 = SentryStackFrame(absPath: "test.url"); + final st1 = SentryStackTrace(frames: [frame1]); + final thread1 = SentryThread(stacktrace: st1); + + final frame2 = SentryStackFrame(absPath: "foo.bar"); + final st2 = SentryStackTrace(frames: [frame2]); + final thread2 = SentryThread(stacktrace: st2); + + SentryEvent event = SentryEvent(threads: [thread1, thread2]); + + fixture.options.denyUrls = ["bar"]; + + var eventProcessor = fixture.getSut(); + final processedEvent = await eventProcessor.apply(event, Hint()); + + expect(processedEvent, isNotNull); + }); + }); +} + +class Fixture { + SentryFlutterOptions options = SentryFlutterOptions(); + UrlFilterEventProcessor getSut() { + return UrlFilterEventProcessor(options); + } +} + +SentryEvent _createEventWithException(String url) { + final frame = SentryStackFrame(absPath: url); + final st = SentryStackTrace(frames: [frame]); + final exception = + SentryException(type: "test-type", value: "test-value", stackTrace: st); + SentryEvent event = SentryEvent(exceptions: [exception]); + + return event; +} diff --git a/scripts/publish_validation/bin/publish_validation.dart b/scripts/publish_validation/bin/publish_validation.dart index ab871e910e..0585d7dd00 100644 --- a/scripts/publish_validation/bin/publish_validation.dart +++ b/scripts/publish_validation/bin/publish_validation.dart @@ -34,7 +34,8 @@ void main(List arguments) async { 'lib/src/integrations/connectivity/web_connectivity_provider.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', 'lib/src/event_processor/enricher/web_enricher_event_processor.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', 'lib/src/origin_web.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', - 'lib/src/platform/_web_platform.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`' + 'lib/src/platform/_web_platform.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', + 'lib/src/event_processor/url_filter/web_url_filter_event_processor.dart: This package does not have web in the `dependencies` section of `pubspec.yaml`', ]; // So far the expected errors all start with `* line` From 3a1617909872133e3cbb31acbb811bc3239c9806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Denis=20Andra=C5=A1ec?= Date: Tue, 3 Sep 2024 12:40:21 +0200 Subject: [PATCH 4/5] Only access renderObject if `hasSize` is true (#2263) --- CHANGELOG.md | 4 ++++ flutter/lib/src/view_hierarchy/sentry_tree_walker.dart | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 666ed2bbd6..b915e3dcf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ - [changelog](https://github.com/getsentry/sentry-cocoa/blob/main/CHANGELOG.md#8360) - [diff](https://github.com/getsentry/sentry-cocoa/compare/8.35.1...8.36.0) +### Fixes + +- Only access renderObject if `hasSize` is true ([#2263](https://github.com/getsentry/sentry-dart/pull/2263)) + ## 8.8.0 ### Features diff --git a/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart b/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart index b2e759b2c9..578792d18a 100644 --- a/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart +++ b/flutter/lib/src/view_hierarchy/sentry_tree_walker.dart @@ -255,7 +255,7 @@ class _TreeWalker { double? alpha; final renderObject = element.renderObject; - if (renderObject is RenderBox) { + if (renderObject is RenderBox && renderObject.hasSize) { final offset = renderObject.localToGlobal(Offset.zero); if (offset.dx > 0) { x = offset.dx; From a40bb7c712c6ca285e8712782b99c6ec1e1d6d03 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos <6349682+vaind@users.noreply.github.com> Date: Wed, 4 Sep 2024 19:56:24 +0200 Subject: [PATCH 5/5] feat: asset images don't need to be obscured in replay (#2269) * feat: asset images don't need to be obscured * chore: update changelog --- CHANGELOG.md | 16 ++--- flutter/example/lib/main.dart | 10 +++ flutter/lib/sentry_flutter.dart | 2 +- flutter/lib/src/replay/widget_filter.dart | 32 +++++++++- flutter/lib/src/sentry_asset_bundle.dart | 7 ++ flutter/test/replay/test_widget.dart | 71 ++++++++++----------- flutter/test/replay/widget_filter_test.dart | 41 +++++++++++- 7 files changed, 131 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b915e3dcf6..b12c189997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,29 +4,29 @@ ### Features -- Support allowUrls and denyUrls for Flutter Web ([#2227](https://github.com/getsentry/sentry-dart/pull/2227)) +- Session replay Alpha for Android and iOS ([#2208](https://github.com/getsentry/sentry-dart/pull/2208), [#2269](https://github.com/getsentry/sentry-dart/pull/2269)). + + To try out replay, you can set following options (access is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/)): ```dart await SentryFlutter.init( (options) { ... - options.allowUrls = ["^https://sentry.com.*\$", "my-custom-domain"]; - options.denyUrls = ["^.*ends-with-this\$", "denied-url"]; + options.experimental.replay.sessionSampleRate = 1.0; + options.experimental.replay.errorSampleRate = 1.0; }, appRunner: () => runApp(MyApp()), ); ``` -- Session replay Alpha for Android and iOS ([#2208](https://github.com/getsentry/sentry-dart/pull/2208)). - - To try out replay, you can set following options (access is limited to early access orgs on Sentry. If you're interested, [sign up for the waitlist](https://sentry.io/lp/mobile-replay-beta/)): +- Support allowUrls and denyUrls for Flutter Web ([#2227](https://github.com/getsentry/sentry-dart/pull/2227)) ```dart await SentryFlutter.init( (options) { ... - options.experimental.replay.sessionSampleRate = 1.0; - options.experimental.replay.errorSampleRate = 1.0; + options.allowUrls = ["^https://sentry.com.*\$", "my-custom-domain"]; + options.denyUrls = ["^.*ends-with-this\$", "denied-url"]; }, appRunner: () => runApp(MyApp()), ); diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index ed8961741c..0f1cd9279d 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -1043,6 +1043,8 @@ Future showDialogWithTextAndImage(BuildContext context) async { await DefaultAssetBundle.of(context).loadString('assets/lorem-ipsum.txt'); if (!context.mounted) return; + final imageBytes = + await DefaultAssetBundle.of(context).load('assets/sentry-wordmark.png'); await showDialog( context: context, // gets tracked if using SentryNavigatorObserver @@ -1056,7 +1058,15 @@ Future showDialogWithTextAndImage(BuildContext context) async { child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Use various ways an image is included in the app. + // Local asset images are not obscured in replay recording. Image.asset('assets/sentry-wordmark.png'), + Image.asset('assets/sentry-wordmark.png', bundle: rootBundle), + Image.asset('assets/sentry-wordmark.png', + bundle: DefaultAssetBundle.of(context)), + Image.network( + 'https://www.gstatic.com/recaptcha/api2/logo_48.png'), + Image.memory(imageBytes.buffer.asUint8List()), Text(text), ], ), diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index bea9016630..c74013e81e 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -10,7 +10,7 @@ export 'src/sentry_flutter.dart'; export 'src/sentry_flutter_options.dart'; export 'src/sentry_replay_options.dart'; export 'src/flutter_sentry_attachment.dart'; -export 'src/sentry_asset_bundle.dart'; +export 'src/sentry_asset_bundle.dart' show SentryAssetBundle; export 'src/integrations/on_error_integration.dart'; export 'src/screenshot/sentry_screenshot_widget.dart'; export 'src/screenshot/sentry_screenshot_quality.dart'; diff --git a/flutter/lib/src/replay/widget_filter.dart b/flutter/lib/src/replay/widget_filter.dart index 83e069cb97..f269dd041f 100644 --- a/flutter/lib/src/replay/widget_filter.dart +++ b/flutter/lib/src/replay/widget_filter.dart @@ -1,8 +1,9 @@ +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; -import 'package:sentry/sentry.dart'; import '../../sentry_flutter.dart'; +import '../sentry_asset_bundle.dart'; @internal class WidgetFilter { @@ -14,11 +15,14 @@ class WidgetFilter { late double _pixelRatio; late Rect _bounds; final _warnedWidgets = {}; + final AssetBundle _rootAssetBundle; WidgetFilter( {required this.redactText, required this.redactImages, - required this.logger}); + required this.logger, + @visibleForTesting AssetBundle? rootAssetBundle}) + : _rootAssetBundle = rootAssetBundle ?? rootBundle; void obscure(BuildContext context, double pixelRatio, Rect bounds) { _pixelRatio = pixelRatio; @@ -57,6 +61,14 @@ class WidgetFilter { } else if (redactText && widget is EditableText) { color = widget.style.color; } else if (redactImages && widget is Image) { + if (widget.image is AssetBundleImageProvider) { + final image = widget.image as AssetBundleImageProvider; + if (isBuiltInAssetImage(image)) { + logger(SentryLevel.debug, + "WidgetFilter skipping asset: $widget ($image)."); + return false; + } + } color = widget.color; } else { // No other type is currently obscured. @@ -115,6 +127,22 @@ class WidgetFilter { return true; } + @visibleForTesting + @pragma('vm:prefer-inline') + bool isBuiltInAssetImage(AssetBundleImageProvider image) { + late final AssetBundle? bundle; + if (image is AssetImage) { + bundle = image.bundle; + } else if (image is ExactAssetImage) { + bundle = image.bundle; + } else { + return false; + } + return (bundle == null || + bundle == _rootAssetBundle || + (bundle is SentryAssetBundle && bundle.bundle == _rootAssetBundle)); + } + @pragma('vm:prefer-inline') void _cantObscure(Widget widget, String message) { if (!_warnedWidgets.contains(widget.hashCode)) { diff --git a/flutter/lib/src/sentry_asset_bundle.dart b/flutter/lib/src/sentry_asset_bundle.dart index 52d1a2da0c..a41288209f 100644 --- a/flutter/lib/src/sentry_asset_bundle.dart +++ b/flutter/lib/src/sentry_asset_bundle.dart @@ -7,6 +7,7 @@ import 'dart:ui'; import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; import 'package:sentry/sentry.dart'; typedef _StringParser = Future Function(String value); @@ -375,3 +376,9 @@ class SentryAssetBundle implements AssetBundle { as Future; } } + +@internal +extension SentryAssetBundleInternal on SentryAssetBundle { + /// Returns the wrapped [AssetBundle]. + AssetBundle get bundle => _bundle; +} diff --git a/flutter/test/replay/test_widget.dart b/flutter/test/replay/test_widget.dart index e85dfacaf8..c5321ce750 100644 --- a/flutter/test/replay/test_widget.dart +++ b/flutter/test/replay/test_widget.dart @@ -1,10 +1,10 @@ -import 'dart:typed_data'; - import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; -Future pumpTestElement(WidgetTester tester) async { +Future pumpTestElement(WidgetTester tester, + {List? children}) async { await tester.pumpWidget( MaterialApp( home: SentryWidget( @@ -14,25 +14,26 @@ Future pumpTestElement(WidgetTester tester) async { child: Opacity( opacity: 0.5, child: Column( - children: [ - newImage(), - const Padding( - padding: EdgeInsets.all(15), - child: Center(child: Text('Centered text')), - ), - ElevatedButton( - onPressed: () {}, - child: Text('Button title'), - ), - newImage(), - // Invisible widgets won't be obscured. - Visibility(visible: false, child: Text('Invisible text')), - Visibility(visible: false, child: newImage()), - Opacity(opacity: 0, child: Text('Invisible text')), - Opacity(opacity: 0, child: newImage()), - Offstage(offstage: true, child: Text('Offstage text')), - Offstage(offstage: true, child: newImage()), - ], + children: children ?? + [ + newImage(), + const Padding( + padding: EdgeInsets.all(15), + child: Center(child: Text('Centered text')), + ), + ElevatedButton( + onPressed: () {}, + child: Text('Button title'), + ), + newImage(), + // Invisible widgets won't be obscured. + Visibility(visible: false, child: Text('Invisible text')), + Visibility(visible: false, child: newImage()), + Opacity(opacity: 0, child: Text('Invisible text')), + Opacity(opacity: 0, child: newImage()), + Offstage(offstage: true, child: Text('Offstage text')), + Offstage(offstage: true, child: newImage()), + ], ), ), ), @@ -43,17 +44,15 @@ Future pumpTestElement(WidgetTester tester) async { return TestWidgetsFlutterBinding.instance.rootElement!; } -Image newImage() => Image.memory( - Uint8List.fromList([ - 66, 77, 142, 0, 0, 0, 0, 0, 0, 0, 138, 0, 0, 0, 124, 0, 0, 0, 1, 0, - 0, 0, 255, 255, 255, 255, 1, 0, 32, 0, 3, 0, 0, 0, 4, 0, 0, 0, 19, - 11, 0, 0, 19, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, - 255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 66, 71, 82, 115, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 135, 135, 135, 255, - // This comment prevents dartfmt reformatting this to single-item lines. - ]), - width: 1, - height: 1, - ); +final testImageData = Uint8List.fromList([ + 66, 77, 142, 0, 0, 0, 0, 0, 0, 0, 138, 0, 0, 0, 124, 0, 0, 0, 1, 0, + 0, 0, 255, 255, 255, 255, 1, 0, 32, 0, 3, 0, 0, 0, 4, 0, 0, 0, 19, + 11, 0, 0, 19, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, + 255, 0, 0, 255, 0, 0, 0, 0, 0, 0, 255, 66, 71, 82, 115, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 135, 135, 135, 255, + // This comment prevents dartfmt reformatting this to single-item lines. +]); + +Image newImage() => Image.memory(testImageData, width: 1, height: 1); diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart index 3e17f2b5b6..3f4136b90a 100644 --- a/flutter/test/replay/widget_filter_test.dart +++ b/flutter/test/replay/widget_filter_test.dart @@ -1,5 +1,8 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/src/replay/widget_filter.dart'; import 'test_widget.dart'; @@ -7,12 +10,15 @@ import 'test_widget.dart'; void main() async { TestWidgetsFlutterBinding.ensureInitialized(); const defaultBounds = Rect.fromLTRB(0, 0, 1000, 1000); + final rootBundle = TestAssetBundle(); + final otherBundle = TestAssetBundle(); final createSut = ({bool redactImages = false, bool redactText = false}) => WidgetFilter( logger: (level, message, {exception, logger, stackTrace}) {}, redactImages: redactImages, redactText: redactText, + rootAssetBundle: rootBundle, ); group('redact text', () { @@ -47,6 +53,32 @@ void main() async { expect(sut.items.length, 2); }); + // Note: we cannot currently test actual asset images without either: + // - introducing assets to the package because those wouldn't get tree-shaken in final user apps (https://github.com/flutter/flutter/issues/64106) + // - using a mock asset bundle implementation, because the image widget loads AssetManifest.bin first and we don't have a way to mock that (https://github.com/flutter/flutter/issues/126860) + // Therefore we only check the function that actually decides whether the image is a built-in asset image. + for (var newAssetImage in [AssetImage.new, ExactAssetImage.new]) { + testWidgets( + 'recognizes ${newAssetImage('').runtimeType} from the root bundle', + (tester) async { + final sut = createSut(redactImages: true); + + expect(sut.isBuiltInAssetImage(newAssetImage('')), isTrue); + expect(sut.isBuiltInAssetImage(newAssetImage('', bundle: rootBundle)), + isTrue); + expect(sut.isBuiltInAssetImage(newAssetImage('', bundle: otherBundle)), + isFalse); + expect( + sut.isBuiltInAssetImage(newAssetImage('', + bundle: SentryAssetBundle(bundle: rootBundle))), + isTrue); + expect( + sut.isBuiltInAssetImage(newAssetImage('', + bundle: SentryAssetBundle(bundle: otherBundle))), + isFalse); + }); + } + testWidgets('does not redact text when disabled', (tester) async { final sut = createSut(redactImages: false); final element = await pumpTestElement(tester); @@ -63,3 +95,10 @@ void main() async { }); }); } + +class TestAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + return ByteData(0); + } +}