diff --git a/CHANGELOG.md b/CHANGELOG.md index 74f5245c2c..6478c4a286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ - This flag enables symbolication of Dart stack traces when native debug images are not available. - Useful when using Sentry.init() instead of SentryFlutter.init() in Flutter projects for example due to size limitations. - `true` by default but automatically set to `false` when using SentryFlutter.init() because the SentryFlutter fetches debug images from the native SDK integrations. +- 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/)): + + ```dart + await SentryFlutter.init( + (options) { + ... + options.experimental.replay.sessionSampleRate = 1.0; + options.experimental.replay.errorSampleRate = 1.0; + }, + appRunner: () => runApp(MyApp()), + ); + ``` ### Dependencies @@ -15,18 +42,24 @@ - [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 - 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 + - 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. -```dart -SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), -``` + - 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. + + ```dart + SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), + ``` ### Improvements @@ -41,12 +74,33 @@ SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7140) - [diff](https://github.com/getsentry/sentry-java/compare/7.13.0...7.14.0) +## 8.8.0-alpha.1 + +### Features + +- iOS Session Replay Alpha ([#2209](https://github.com/getsentry/sentry-dart/pull/2209)) +- Android replay touch tracking support ([#2228](https://github.com/getsentry/sentry-dart/pull/2228)) +- 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. + +```dart +SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), +``` + +### Dependencies + +- Bump Android SDK from v7.13.0 to v7.14.0 ([#2228](https://github.com/getsentry/sentry-dart/pull/2228)) + - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7140) + - [diff](https://github.com/getsentry/sentry-java/compare/7.13.0...7.14.0) + ## 8.7.0 ### Features - Add support for span level measurements. ([#2214](https://github.com/getsentry/sentry-dart/pull/2214)) - Add `ignoreTransactions` and `ignoreErrors` to options ([#2207](https://github.com/getsentry/sentry-dart/pull/2207)) + ```dart await SentryFlutter.init( (options) { @@ -58,8 +112,10 @@ SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), appRunner: () => runApp(MyApp()), ); ``` + - Add proxy support ([#2192](https://github.com/getsentry/sentry-dart/pull/2192)) - Configure a `SentryProxy` object and set it on `SentryFlutter.init` + ```dart import 'package:flutter/widgets.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; @@ -99,24 +155,25 @@ SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), - This is enabled automatically and will change grouping if you already have issues with obfuscated titles - If you want to disable this feature, set `enableExceptionTypeIdentification` to `false` in your Sentry options - You can add your custom exception identifier if there are exceptions that we do not identify out of the box -```dart -// How to add your own custom exception identifier -class MyCustomExceptionIdentifier implements ExceptionIdentifier { - @override - String? identifyType(Exception exception) { - if (exception is MyCustomException) { - return 'MyCustomException'; - } - if (exception is MyOtherCustomException) { - return 'MyOtherCustomException'; + + ```dart + // How to add your own custom exception identifier + class MyCustomExceptionIdentifier implements ExceptionIdentifier { + @override + String? identifyType(Exception exception) { + if (exception is MyCustomException) { + return 'MyCustomException'; + } + if (exception is MyOtherCustomException) { + return 'MyOtherCustomException'; + } + return null; } - return null; } -} -SentryFlutter.init((options) => - options..prependExceptionTypeIdentifier(MyCustomExceptionIdentifier())); -``` + SentryFlutter.init((options) => + options..prependExceptionTypeIdentifier(MyCustomExceptionIdentifier())); + ``` ### Deprecated @@ -132,6 +189,27 @@ SentryFlutter.init((options) => - [changelog](https://github.com/getsentry/sentry-java/blob/main/CHANGELOG.md#7130) - [diff](https://github.com/getsentry/sentry-java/compare/7.12.0...7.13.0) +## 8.6.0-alpha.2 + +### Features + +- Android Session Replay Alpha ([#2032](https://github.com/getsentry/sentry-dart/pull/2032)) + + To try out replay, you can set following options: + + ```dart + await SentryFlutter.init( + (options) { + ... + options.experimental.replay.sessionSampleRate = 1.0; + options.experimental.replay.errorSampleRate = 1.0; + }, + appRunner: () => runApp(MyApp()), + ); + ``` + + 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/) + ## 8.5.0 ### Features @@ -144,7 +222,7 @@ SentryFlutter.init((options) => ### Fixes - Disable sff & frame delay detection on web, linux and windows ([#2182](https://github.com/getsentry/sentry-dart/pull/2182)) - - Display refresh rate is locked at 60 for these platforms which can lead to inaccurate metrics + - Display refresh rate is locked at 60 for these platforms which can lead to inaccurate metrics ### Improvements diff --git a/dart/lib/src/protocol/breadcrumb.dart b/dart/lib/src/protocol/breadcrumb.dart index 86eabc2ffc..9ece68d032 100644 --- a/dart/lib/src/protocol/breadcrumb.dart +++ b/dart/lib/src/protocol/breadcrumb.dart @@ -52,6 +52,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', @@ -67,6 +71,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, }, ); } @@ -97,11 +106,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( diff --git a/dart/lib/src/protocol/sentry_trace_context.dart b/dart/lib/src/protocol/sentry_trace_context.dart index 1d80541fd4..e44eede721 100644 --- a/dart/lib/src/protocol/sentry_trace_context.dart +++ b/dart/lib/src/protocol/sentry_trace_context.dart @@ -18,6 +18,9 @@ class SentryTraceContext { /// Id of a parent span final SpanId? parentSpanId; + /// Replay associated with this trace. + final SentryId? replayId; + /// Whether the span is sampled or not final bool? sampled; @@ -50,6 +53,9 @@ class SentryTraceContext { ? null : SpanId.fromId(json['parent_span_id'] as String), traceId: SentryId.fromId(json['trace_id'] as String), + replayId: json['replay_id'] == null + ? null + : SentryId.fromId(json['replay_id'] as String), description: json['description'] as String?, status: json['status'] == null ? null @@ -68,6 +74,7 @@ class SentryTraceContext { 'trace_id': traceId.toString(), 'op': operation, if (parentSpanId != null) 'parent_span_id': parentSpanId!.toString(), + if (replayId != null) 'replay_id': replayId!.toString(), if (description != null) 'description': description, if (status != null) 'status': status!.toString(), if (origin != null) 'origin': origin, @@ -84,6 +91,7 @@ class SentryTraceContext { sampled: sampled, origin: origin, unknown: unknown, + replayId: replayId, ); SentryTraceContext({ @@ -96,6 +104,7 @@ class SentryTraceContext { this.status, this.origin, this.unknown, + this.replayId, }) : traceId = traceId ?? SentryId.newId(), spanId = spanId ?? SpanId.newId(); @@ -103,9 +112,9 @@ class SentryTraceContext { factory SentryTraceContext.fromPropagationContext( PropagationContext propagationContext) { return SentryTraceContext( - traceId: propagationContext.traceId, - spanId: propagationContext.spanId, - operation: 'default', - ); + traceId: propagationContext.traceId, + spanId: propagationContext.spanId, + operation: 'default', + replayId: propagationContext.baggage?.getReplayId()); } } diff --git a/dart/lib/src/scope.dart b/dart/lib/src/scope.dart index 15ce065752..03748445e6 100644 --- a/dart/lib/src/scope.dart +++ b/dart/lib/src/scope.dart @@ -97,6 +97,13 @@ class Scope { /// they must be JSON-serializable. Map get extra => Map.unmodifiable(_extra); + /// Active replay recording. + @internal + SentryId? get replayId => _replayId; + @internal + set replayId(SentryId? value) => _replayId = value; + SentryId? _replayId; + final Contexts _contexts = Contexts(); /// Unmodifiable map of the scope contexts key/value @@ -237,6 +244,7 @@ class Scope { _tags.clear(); _extra.clear(); _eventProcessors.clear(); + _replayId = null; _clearBreadcrumbsSync(); _setUserSync(null); @@ -429,7 +437,8 @@ class Scope { ..fingerprint = List.from(fingerprint) .._transaction = _transaction ..span = span - .._enableScopeSync = false; + .._enableScopeSync = false + .._replayId = _replayId; clone._setUserSync(user); diff --git a/dart/lib/src/sentry_baggage.dart b/dart/lib/src/sentry_baggage.dart index ebed8765b1..b6fc8b7dac 100644 --- a/dart/lib/src/sentry_baggage.dart +++ b/dart/lib/src/sentry_baggage.dart @@ -111,6 +111,9 @@ class SentryBaggage { // ignore: deprecated_member_use_from_same_package setUserSegment(scope.user!.segment!); } + if (scope.replayId != null && scope.replayId != SentryId.empty()) { + setReplayId(scope.replayId.toString()); + } } static Map _extractKeyValuesFromBaggageString( @@ -205,5 +208,12 @@ class SentryBaggage { return double.tryParse(sampleRate); } + void setReplayId(String value) => set('sentry-replay_id', value); + + SentryId? getReplayId() { + final replayId = get('sentry-replay_id'); + return replayId == null ? null : SentryId.fromId(replayId); + } + Map get keyValues => Map.unmodifiable(_keyValues); } diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 23eeed6ba0..e3809568da 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -27,6 +27,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'; @@ -169,15 +170,15 @@ class SentryClient { var traceContext = scope?.span?.traceContext(); if (traceContext == null) { - if (scope?.propagationContext.baggage == null) { - scope?.propagationContext.baggage = - SentryBaggage({}, logger: _options.logger); - scope?.propagationContext.baggage?.setValuesFromScope(scope, _options); - } if (scope != null) { + scope.propagationContext.baggage ??= + SentryBaggage({}, logger: _options.logger) + ..setValuesFromScope(scope, _options); traceContext = SentryTraceContextHeader.fromBaggage( scope.propagationContext.baggage!); } + } else { + traceContext.replayId = scope?.replayId; } final envelope = SentryEnvelope.fromEvent( @@ -198,7 +199,7 @@ class SentryClient { } var message = event.message!.formatted; - return _isMatchingRegexPattern(message, _options.ignoreErrors); + return isMatchingRegexPattern(message, _options.ignoreErrors); } SentryEvent _prepareEvent(SentryEvent event, {dynamic stackTrace}) { @@ -417,7 +418,7 @@ class SentryClient { } var name = transaction.tracer.name; - return _isMatchingRegexPattern(name, _options.ignoreTransactions); + return isMatchingRegexPattern(name, _options.ignoreTransactions); } /// Reports the [envelope] to Sentry.io. @@ -595,11 +596,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 4818642947..c9a9511c29 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/sentry_trace_context_header.dart b/dart/lib/src/sentry_trace_context_header.dart index ed7ec53653..e17b2b91f8 100644 --- a/dart/lib/src/sentry_trace_context_header.dart +++ b/dart/lib/src/sentry_trace_context_header.dart @@ -17,6 +17,7 @@ class SentryTraceContextHeader { this.sampleRate, this.sampled, this.unknown, + this.replayId, }); final SentryId traceId; @@ -34,6 +35,9 @@ class SentryTraceContextHeader { @internal final Map? unknown; + @internal + SentryId? replayId; + /// Deserializes a [SentryTraceContextHeader] from JSON [Map]. factory SentryTraceContextHeader.fromJson(Map data) { final json = AccessAwareMap(data); @@ -47,6 +51,8 @@ class SentryTraceContextHeader { transaction: json['transaction'], sampleRate: json['sample_rate'], sampled: json['sampled'], + replayId: + json['replay_id'] == null ? null : SentryId.fromId(json['replay_id']), unknown: json.notAccessed(), ); } @@ -65,6 +71,7 @@ class SentryTraceContextHeader { if (transaction != null) 'transaction': transaction, if (sampleRate != null) 'sample_rate': sampleRate, if (sampled != null) 'sampled': sampled, + if (replayId != null) 'replay_id': replayId.toString(), }; } @@ -98,6 +105,9 @@ class SentryTraceContextHeader { if (sampled != null) { baggage.setSampled(sampled!); } + if (replayId != null) { + baggage.setReplayId(replayId.toString()); + } return baggage; } @@ -107,6 +117,7 @@ class SentryTraceContextHeader { baggage.get('sentry-public_key').toString(), release: baggage.get('sentry-release'), environment: baggage.get('sentry-environment'), + replayId: baggage.getReplayId(), ); } } 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 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/protocol/breadcrumb_test.dart b/dart/test/protocol/breadcrumb_test.dart index ddda79df6e..2dea300561 100644 --- a/dart/test/protocol/breadcrumb_test.dart +++ b/dart/test/protocol/breadcrumb_test.dart @@ -90,7 +90,7 @@ void main() { level: SentryLevel.fatal, reason: 'OK', statusCode: 200, - requestDuration: Duration.zero, + requestDuration: Duration(milliseconds: 55), timestamp: DateTime.now(), requestBodySize: 2, responseBodySize: 3, @@ -107,17 +107,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'), @@ -196,6 +222,7 @@ void main() { 'foo': 'bar', 'view.id': 'foo', 'view.class': 'bar', + 'path': 'bar(foo)', }, }); }); diff --git a/dart/test/protocol/sentry_baggage_header_test.dart b/dart/test/protocol/sentry_baggage_header_test.dart index 3e8555aba9..910929776e 100644 --- a/dart/test/protocol/sentry_baggage_header_test.dart +++ b/dart/test/protocol/sentry_baggage_header_test.dart @@ -22,11 +22,23 @@ void main() { baggage.setTransaction('transaction'); baggage.setSampleRate('1.0'); baggage.setSampled('false'); + final replayId = SentryId.newId().toString(); + baggage.setReplayId(replayId); final baggageHeader = SentryBaggageHeader.fromBaggage(baggage); - expect(baggageHeader.value, - 'sentry-trace_id=$id,sentry-public_key=publicKey,sentry-release=release,sentry-environment=environment,sentry-user_id=userId,sentry-user_segment=userSegment,sentry-transaction=transaction,sentry-sample_rate=1.0,sentry-sampled=false'); + expect( + baggageHeader.value, + 'sentry-trace_id=$id,' + 'sentry-public_key=publicKey,' + 'sentry-release=release,' + 'sentry-environment=environment,' + 'sentry-user_id=userId,' + 'sentry-user_segment=userSegment,' + 'sentry-transaction=transaction,' + 'sentry-sample_rate=1.0,' + 'sentry-sampled=false,' + 'sentry-replay_id=$replayId'); }); }); } diff --git a/dart/test/scope_test.dart b/dart/test/scope_test.dart index 8a535541c1..0059a2e77a 100644 --- a/dart/test/scope_test.dart +++ b/dart/test/scope_test.dart @@ -88,6 +88,14 @@ void main() { expect(sut.fingerprint, fingerprints); }); + test('sets replay ID', () { + final sut = fixture.getSut(); + + sut.replayId = SentryId.fromId('1'); + + expect(sut.replayId, SentryId.fromId('1')); + }); + test('adds $Breadcrumb', () { final sut = fixture.getSut(); @@ -307,6 +315,7 @@ void main() { sut.level = SentryLevel.debug; sut.transaction = 'test'; sut.span = null; + sut.replayId = SentryId.newId(); final user = SentryUser(id: 'test'); sut.setUser(user); @@ -322,21 +331,15 @@ void main() { sut.clear(); expect(sut.breadcrumbs.length, 0); - expect(sut.level, null); - expect(sut.transaction, null); expect(sut.span, null); - expect(sut.user, null); - expect(sut.fingerprint.length, 0); - expect(sut.tags.length, 0); - expect(sut.extra.length, 0); - expect(sut.eventProcessors.length, 0); + expect(sut.replayId, isNull); }); test('clones', () async { @@ -349,6 +352,7 @@ void main() { sut.addAttachment(SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt')); sut.span = NoOpSentrySpan(); sut.level = SentryLevel.warning; + sut.replayId = SentryId.newId(); await sut.setUser(SentryUser(id: 'id')); await sut.setTag('key', 'vakye'); await sut.setExtra('key', 'vakye'); @@ -369,6 +373,7 @@ void main() { true, ); expect(sut.span, clone.span); + expect(sut.replayId, clone.replayId); }); test('clone does not additionally call observers', () async { diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 772699b14d..07d5aab834 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -813,7 +813,8 @@ void main() { ..addBreadcrumb(crumb) ..setTag(scopeTagKey, scopeTagValue) // ignore: deprecated_member_use_from_same_package - ..setExtra(scopeExtraKey, scopeExtraValue); + ..setExtra(scopeExtraKey, scopeExtraValue) + ..replayId = SentryId.fromId('1'); scope.setUser(user); }); @@ -839,6 +840,8 @@ void main() { scopeExtraKey: scopeExtraValue, eventExtraKey: eventExtraValue, }); + expect( + capturedEnvelope.header.traceContext?.replayId, SentryId.fromId('1')); }); }); @@ -1485,6 +1488,7 @@ void main() { final client = fixture.getSut(); final scope = Scope(fixture.options); + scope.replayId = SentryId.newId(); scope.span = SentrySpan(fixture.tracer, fixture.tracer.context, MockHub()); @@ -1492,6 +1496,7 @@ void main() { final envelope = fixture.transport.envelopes.first; expect(envelope.header.traceContext, isNotNull); + expect(envelope.header.traceContext?.replayId, scope.replayId); }); test('captureEvent adds attachments from hint', () async { @@ -1548,12 +1553,14 @@ void main() { final context = SentryTraceContextHeader.fromJson({ 'trace_id': '${tr.eventId}', 'public_key': '123', + 'replay_id': '456', }); await client.captureTransaction(tr, traceContext: context); final envelope = fixture.transport.envelopes.first; expect(envelope.header.traceContext, isNotNull); + expect(envelope.header.traceContext?.replayId, SentryId.fromId('456')); }); test('captureUserFeedback calls flush', () async { diff --git a/dart/test/sentry_trace_context_header_test.dart b/dart/test/sentry_trace_context_header_test.dart index db9a2d3621..04b2526c34 100644 --- a/dart/test/sentry_trace_context_header_test.dart +++ b/dart/test/sentry_trace_context_header_test.dart @@ -18,6 +18,7 @@ void main() { transaction: 'transaction', sampleRate: '1.0', sampled: 'false', + replayId: SentryId.fromId('456'), unknown: testUnknown, ); @@ -30,7 +31,8 @@ void main() { 'user_segment': 'user_segment', 'transaction': 'transaction', 'sample_rate': '1.0', - 'sampled': 'false' + 'sampled': 'false', + 'replay_id': '456', }; mapJson.addAll(testUnknown); @@ -45,6 +47,7 @@ void main() { expect(context.transaction, 'transaction'); expect(context.sampleRate, '1.0'); expect(context.sampled, 'false'); + expect(context.replayId, SentryId.fromId('456')); }); test('toJson', () { @@ -56,8 +59,19 @@ void main() { test('to baggage', () { final baggage = context.toBaggage(); - expect(baggage.toHeaderString(), - 'sentry-trace_id=${traceId.toString()},sentry-public_key=123,sentry-release=release,sentry-environment=environment,sentry-user_id=user_id,sentry-user_segment=user_segment,sentry-transaction=transaction,sentry-sample_rate=1.0,sentry-sampled=false'); + expect( + baggage.toHeaderString(), + 'sentry-trace_id=${traceId.toString()},' + 'sentry-public_key=123,' + 'sentry-release=release,' + 'sentry-environment=environment,' + 'sentry-user_id=user_id,' + 'sentry-user_segment=user_segment,' + 'sentry-transaction=transaction,' + 'sentry-sample_rate=1.0,' + 'sentry-sampled=false,' + 'sentry-replay_id=456', + ); }); }); } diff --git a/dart/test/sentry_trace_context_test.dart b/dart/test/sentry_trace_context_test.dart index 9030b48182..ab33512a2c 100644 --- a/dart/test/sentry_trace_context_test.dart +++ b/dart/test/sentry_trace_context_test.dart @@ -18,28 +18,32 @@ void main() { expect(map['description'], 'desc'); expect(map['status'], 'aborted'); expect(map['origin'], 'auto.ui'); + expect(map['replay_id'], isNotNull); }); test('fromJson deserializes', () { final map = { 'op': 'op', - 'span_id': '0000000000000000', - 'trace_id': '00000000000000000000000000000000', - 'parent_span_id': '0000000000000000', + 'span_id': '0000000000000001', + 'trace_id': '00000000000000000000000000000002', + 'parent_span_id': '0000000000000003', 'description': 'desc', 'status': 'aborted', - 'origin': 'auto.ui' + 'origin': 'auto.ui', + 'replay_id': '00000000000000000000000000000004' }; map.addAll(testUnknown); final traceContext = SentryTraceContext.fromJson(map); expect(traceContext.description, 'desc'); expect(traceContext.operation, 'op'); - expect(traceContext.spanId.toString(), '0000000000000000'); - expect(traceContext.traceId.toString(), '00000000000000000000000000000000'); - expect(traceContext.parentSpanId.toString(), '0000000000000000'); + expect(traceContext.spanId.toString(), '0000000000000001'); + expect(traceContext.traceId.toString(), '00000000000000000000000000000002'); + expect(traceContext.parentSpanId.toString(), '0000000000000003'); expect(traceContext.status.toString(), 'aborted'); expect(traceContext.sampled, true); + expect( + traceContext.replayId.toString(), '00000000000000000000000000000004'); }); } @@ -52,6 +56,7 @@ class Fixture { sampled: true, status: SpanStatus.aborted(), origin: 'auto.ui', + replayId: SentryId.newId(), unknown: testUnknown, ); } 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/drift/pubspec.yaml b/drift/pubspec.yaml index 321cae1ca4..1a6c4a96d6 100644 --- a/drift/pubspec.yaml +++ b/drift/pubspec.yaml @@ -32,4 +32,4 @@ dev_dependencies: yaml: ^3.1.0 # needed for version match (code and pubspec) sqlite3_flutter_libs: ^0.5.0 sqlite3: ^2.1.0 - archive: ^3.1.2 \ No newline at end of file + archive: ^3.1.2 diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt index 123778e9be..88d9016a47 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt @@ -3,6 +3,7 @@ package io.sentry.flutter import android.util.Log import io.sentry.SentryLevel import io.sentry.SentryOptions.Proxy +import io.sentry.SentryReplayOptions import io.sentry.android.core.BuildConfig import io.sentry.android.core.SentryAndroidOptions import io.sentry.protocol.SdkVersion @@ -146,6 +147,18 @@ class SentryFlutter( pass = proxyJson["pass"] as? String } } + + data.getIfNotNull>("replay") { + updateReplayOptions(options.experimental.sessionReplay, it) + } + } + + fun updateReplayOptions( + options: SentryReplayOptions, + data: Map, + ) { + options.sessionSampleRate = data["sessionSampleRate"] as? Double + options.errorSampleRate = data["errorSampleRate"] as? Double } } diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index 5bb1296a24..a10966fd50 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -26,16 +26,20 @@ import io.sentry.android.core.SentryAndroid import io.sentry.android.core.SentryAndroidOptions import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.core.performance.TimeSpan +import io.sentry.android.replay.ReplayIntegration import io.sentry.protocol.DebugImage import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId import io.sentry.protocol.User +import io.sentry.transport.CurrentDateProvider +import java.io.File import java.lang.ref.WeakReference class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private lateinit var channel: MethodChannel private lateinit var context: Context private lateinit var sentryFlutter: SentryFlutter + private lateinit var replay: ReplayIntegration private var activity: WeakReference? = null private var framesTracker: ActivityFramesTracker? = null @@ -55,7 +59,11 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { ) } - override fun onMethodCall(call: MethodCall, result: Result) { + @Suppress("CyclomaticComplexMethod") + override fun onMethodCall( + call: MethodCall, + result: Result, + ) { when (call.method) { "initNativeSdk" -> initNativeSdk(call, result) "captureEnvelope" -> captureEnvelope(call, result) @@ -76,6 +84,8 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { "loadContexts" -> loadContexts(result) "displayRefreshRate" -> displayRefreshRate(result) "nativeCrash" -> crash() + "addReplayScreenshot" -> addReplayScreenshot(call.argument("path"), call.argument("timestamp"), result) + "captureReplay" -> captureReplay(call.argument("isCrash"), result) else -> result.notImplemented() } } @@ -105,7 +115,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { // Stub } - private fun initNativeSdk(call: MethodCall, result: Result) { + private fun initNativeSdk( + call: MethodCall, + result: Result, + ) { if (!this::context.isInitialized) { result.error("1", "Context is null", null) return @@ -125,6 +138,27 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } options.beforeSend = BeforeSendCallbackImpl(options.sdkVersion) + + // Replace the default ReplayIntegration with a Flutter-specific recorder. + options.integrations.removeAll { it is ReplayIntegration } + val cacheDirPath = options.cacheDirPath + val replayOptions = options.experimental.sessionReplay + val isReplayEnabled = replayOptions.isSessionReplayEnabled || replayOptions.isSessionReplayForErrorsEnabled + if (cacheDirPath != null && isReplayEnabled) { + replay = + ReplayIntegration( + context, + dateProvider = CurrentDateProvider.getInstance(), + recorderProvider = { SentryFlutterReplayRecorder(channel, replay) }, + recorderConfigProvider = null, + replayCacheProvider = null, + ) + replay.breadcrumbConverter = SentryFlutterReplayBreadcrumbConverter() + options.addIntegration(replay) + options.setReplayController(replay) + } else { + options.setReplayController(null) + } } result.success("") } @@ -147,6 +181,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } else { val appStartTimeMillis = DateUtils.nanosToMillis(appStartTime.nanoTimestamp().toDouble()) val item = + mutableMapOf( "pluginRegistrationTime" to pluginRegistrationTime, "appStartTime" to appStartTimeMillis, @@ -230,7 +265,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success(null) } - private fun endNativeFrames(id: String?, result: Result) { + private fun endNativeFrames( + id: String?, + result: Result, + ) { val activity = activity?.get() if (!sentryFlutter.autoPerformanceTracingEnabled || activity == null || id == null) { if (id == null) { @@ -250,16 +288,21 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { if (total == 0 && slow == 0 && frozen == 0) { result.success(null) } else { - val frames = mapOf( - "totalFrames" to total, - "slowFrames" to slow, - "frozenFrames" to frozen, - ) + val frames = + mapOf( + "totalFrames" to total, + "slowFrames" to slow, + "frozenFrames" to frozen, + ) result.success(frames) } } - private fun setContexts(key: String?, value: Any?, result: Result) { + private fun setContexts( + key: String?, + value: Any?, + result: Result, + ) { if (key == null || value == null) { result.success("") return @@ -271,7 +314,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } - private fun removeContexts(key: String?, result: Result) { + private fun removeContexts( + key: String?, + result: Result, + ) { if (key == null) { result.success("") return @@ -283,7 +329,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } - private fun setUser(user: Map?, result: Result) { + private fun setUser( + user: Map?, + result: Result, + ) { if (user != null) { val options = HubAdapter.getInstance().options val userInstance = User.fromMap(user, options) @@ -294,7 +343,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun addBreadcrumb(breadcrumb: Map?, result: Result) { + private fun addBreadcrumb( + breadcrumb: Map?, + result: Result, + ) { if (breadcrumb != null) { val options = HubAdapter.getInstance().options val breadcrumbInstance = Breadcrumb.fromMap(breadcrumb, options) @@ -309,7 +361,11 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun setExtra(key: String?, value: String?, result: Result) { + private fun setExtra( + key: String?, + value: String?, + result: Result, + ) { if (key == null || value == null) { result.success("") return @@ -319,7 +375,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun removeExtra(key: String?, result: Result) { + private fun removeExtra( + key: String?, + result: Result, + ) { if (key == null) { result.success("") return @@ -329,7 +388,11 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun setTag(key: String?, value: String?, result: Result) { + private fun setTag( + key: String?, + value: String?, + result: Result, + ) { if (key == null || value == null) { result.success("") return @@ -339,7 +402,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun removeTag(key: String?, result: Result) { + private fun removeTag( + key: String?, + result: Result, + ) { if (key == null) { result.success("") return @@ -349,7 +415,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun captureEnvelope(call: MethodCall, result: Result) { + private fun captureEnvelope( + call: MethodCall, + result: Result, + ) { if (!Sentry.isEnabled()) { result.error("1", "The Sentry Android SDK is disabled", null) return @@ -358,7 +427,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("") @@ -407,7 +476,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private class BeforeSendCallbackImpl( private val sdkVersion: SdkVersion?, ) : SentryOptions.BeforeSendCallback { - override fun execute(event: SentryEvent, hint: Hint): SentryEvent { + override fun execute( + event: SentryEvent, + hint: Hint, + ): SentryEvent { setEventOriginTag(event) addPackages(event, sdkVersion) return event @@ -440,7 +512,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { event.setTag("event.environment", environment) } - private fun addPackages(event: SentryEvent, sdk: SdkVersion?) { + private fun addPackages( + event: SentryEvent, + sdk: SdkVersion?, + ) { event.sdk?.let { if (it.name == FLUTTER_SDK) { sdk?.packageSet?.forEach { sentryPackage -> @@ -476,4 +551,29 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { ) result.success(serializedScope) } + + private fun addReplayScreenshot( + path: String?, + timestamp: Long?, + result: Result, + ) { + if (path == null || timestamp == null) { + result.error("5", "Arguments are null", null) + return + } + replay.onScreenshotRecorded(File(path), timestamp) + result.success("") + } + + private fun captureReplay( + isCrash: Boolean?, + result: Result, + ) { + if (isCrash == null) { + result.error("5", "Arguments are null", null) + return + } + replay.captureReplay(isCrash) + result.success(replay.getReplayId().toString()) + } } diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt new file mode 100644 index 0000000000..3dd549802f --- /dev/null +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt @@ -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 + } +} diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt new file mode 100644 index 0000000000..41209f75b6 --- /dev/null +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt @@ -0,0 +1,72 @@ +package io.sentry.flutter + +import android.os.Handler +import android.os.Looper +import android.util.Log +import io.flutter.plugin.common.MethodChannel +import io.sentry.android.replay.Recorder +import io.sentry.android.replay.ReplayIntegration +import io.sentry.android.replay.ScreenshotRecorderConfig + +internal class SentryFlutterReplayRecorder( + private val channel: MethodChannel, + private val integration: ReplayIntegration, +) : Recorder { + override fun start(config: ScreenshotRecorderConfig) { + val cacheDirPath = integration.replayCacheDir?.absolutePath + if (cacheDirPath == null) { + Log.w("Sentry", "Replay cache directory is null, can't start replay recorder.") + return + } + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod( + "ReplayRecorder.start", + mapOf( + "directory" to cacheDirPath, + "width" to config.recordingWidth, + "height" to config.recordingHeight, + "frameRate" to config.frameRate, + "replayId" to integration.getReplayId().toString(), + ), + ) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to start replay recorder", ignored) + } + } + } + + override fun resume() { + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod("ReplayRecorder.resume", null) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to resume replay recorder", ignored) + } + } + } + + override fun pause() { + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod("ReplayRecorder.pause", null) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to pause replay recorder", ignored) + } + } + } + + override fun stop() { + Handler(Looper.getMainLooper()).post { + try { + channel.invokeMethod("ReplayRecorder.stop", null) + } catch (ignored: Exception) { + Log.w("Sentry", "Failed to stop replay recorder", ignored) + } + } + } + + override fun close() { + stop() + } +} diff --git a/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt b/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt index 9fa9183f33..c43b2807ed 100644 --- a/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt +++ b/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt @@ -67,6 +67,16 @@ class SentryFlutterTest { assertEquals(Proxy.Type.HTTP, fixture.options.proxy?.type) assertEquals("admin", fixture.options.proxy?.user) assertEquals("0000", fixture.options.proxy?.pass) + + assertEquals(0.5, fixture.options.experimental.sessionReplay.sessionSampleRate) + assertEquals(0.6, fixture.options.experimental.sessionReplay.errorSampleRate) + + // Note: these are currently read-only in SentryReplayOptions so we're only asserting the default values here to + // know when there's a change in the native SDK, as it may require a manual change in the Flutter implementation. + assertEquals(1, fixture.options.experimental.sessionReplay.frameRate) + assertEquals(30_000L, fixture.options.experimental.sessionReplay.errorReplayDuration) + assertEquals(5000L, fixture.options.experimental.sessionReplay.sessionSegmentDuration) + assertEquals(60 * 60 * 1000L, fixture.options.experimental.sessionReplay.sessionDuration) } @Test @@ -142,6 +152,11 @@ class Fixture { "user" to "admin", "pass" to "0000", ), + "replay" to + mapOf( + "sessionSampleRate" to 0.5, + "errorSampleRate" to 0.6, + ), ) fun getSut(): SentryFlutter = diff --git a/flutter/example/android/app/build.gradle b/flutter/example/android/app/build.gradle index e9ac4161a5..ed3e1a1b6b 100644 --- a/flutter/example/android/app/build.gradle +++ b/flutter/example/android/app/build.gradle @@ -65,8 +65,6 @@ android { } } - // TODO: we need to fix CI as the version 21.1 (default) is not installed by default on - // GH Actions. ndkVersion "25.1.8937393" externalNativeBuild { diff --git a/flutter/example/android/app/src/main/AndroidManifest.xml b/flutter/example/android/app/src/main/AndroidManifest.xml index c2029920e9..1b2b2012cf 100644 --- a/flutter/example/android/app/src/main/AndroidManifest.xml +++ b/flutter/example/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + setupSentry( options.maxResponseBodySize = MaxResponseBodySize.always; options.navigatorKey = navigatorKey; + options.experimental.replay.sessionSampleRate = 1.0; + options.experimental.replay.errorSampleRate = 1.0; + _isIntegrationTest = isIntegrationTest; if (_isIntegrationTest) { options.dist = '1'; diff --git a/flutter/ios/Classes/SentryFlutter.swift b/flutter/ios/Classes/SentryFlutter.swift index 769f595fba..3f77fad598 100644 --- a/flutter/ios/Classes/SentryFlutter.swift +++ b/flutter/ios/Classes/SentryFlutter.swift @@ -105,6 +105,14 @@ public final class SentryFlutter { options.urlSession = URLSession(configuration: configuration) } +#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS)) + if let replayOptions = data["replay"] as? [String: Any] { + options.experimental.sessionReplay.sessionSampleRate = + (replayOptions["sessionSampleRate"] as? NSNumber)?.floatValue ?? 0 + options.experimental.sessionReplay.onErrorSampleRate = + (replayOptions["errorSampleRate"] as? NSNumber)?.floatValue ?? 0 + } +#endif } private func logLevelFrom(diagnosticLevel: String) -> SentryLevel { diff --git a/flutter/ios/Classes/SentryFlutterPluginApple.swift b/flutter/ios/Classes/SentryFlutterPluginApple.swift index c2024f3779..24e50cc166 100644 --- a/flutter/ios/Classes/SentryFlutterPluginApple.swift +++ b/flutter/ios/Classes/SentryFlutterPluginApple.swift @@ -12,6 +12,7 @@ import CoreVideo // swiftlint:disable:next type_body_length public class SentryFlutterPluginApple: NSObject, FlutterPlugin { + private let channel: FlutterMethodChannel private static let nativeClientName = "sentry.cocoa.flutter" @@ -38,12 +39,16 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { let channel = FlutterMethodChannel(name: "sentry_flutter", binaryMessenger: registrar.messenger) #endif - let instance = SentryFlutterPluginApple() + let instance = SentryFlutterPluginApple(channel: channel) instance.registerObserver() - registrar.addMethodCallDelegate(instance, channel: channel) } + private init(channel: FlutterMethodChannel) { + self.channel = channel + super.init() + } + private lazy var sentryFlutter = SentryFlutter() private func registerObserver() { @@ -177,6 +182,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { case "nativeCrash": crash() + case "captureReplay": +#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS)) + PrivateSentrySDKOnly.captureReplay() + result(PrivateSentrySDKOnly.getReplayId()) +#else + result(nil) +#endif + default: result(FlutterMethodNotImplemented) } @@ -326,6 +339,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { didReceiveDidBecomeActiveNotification = false } +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) + let breadcrumbConverter = SentryFlutterReplayBreadcrumbConverter() + let screenshotProvider = SentryFlutterReplayScreenshotProvider(channel: self.channel) + PrivateSentrySDKOnly.configureSessionReplay(with: breadcrumbConverter, screenshotProvider: screenshotProvider) +#endif +#endif + result("") } diff --git a/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h new file mode 100644 index 0000000000..1260268ced --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h @@ -0,0 +1,15 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +@class SentryRRWebEvent; + +@interface SentryFlutterReplayBreadcrumbConverter + : NSObject + +- (instancetype _Nonnull)init; + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb; + +@end +#endif diff --git a/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m new file mode 100644 index 0000000000..75b073de82 --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m @@ -0,0 +1,117 @@ +#import "SentryFlutterReplayBreadcrumbConverter.h" + +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@implementation SentryFlutterReplayBreadcrumbConverter { + SentrySRDefaultBreadcrumbConverter *defaultConverter; +} + +- (instancetype _Nonnull)init { + if (self = [super init]) { + self->defaultConverter = + [SentrySessionReplayIntegration createDefaultBreadcrumbConverter]; + } + return self; +} + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb { + assert(breadcrumb.timestamp != nil); + + if (breadcrumb.category == nil + // Do not add Sentry Event breadcrumbs to replay + || [breadcrumb.category isEqualToString:@"sentry.event"] || + [breadcrumb.category isEqualToString:@"sentry.transaction"]) { + return nil; + } + + if ([breadcrumb.category isEqualToString:@"http"]) { + return [self convertNetwork:breadcrumb]; + } + + if ([breadcrumb.category isEqualToString:@"navigation"]) { + return [self convertFrom:breadcrumb withCategory:nil andMessage:nil]; + } + + if ([breadcrumb.category isEqualToString:@"ui.click"]) { + return [self convertFrom:breadcrumb + withCategory:@"ui.tap" + andMessage:breadcrumb.data[@"path"]]; + } + + SentryRRWebEvent *nativeBreadcrumb = + [self->defaultConverter convertFrom:breadcrumb]; + + // ignore native navigation breadcrumbs + if (nativeBreadcrumb && nativeBreadcrumb.data && + nativeBreadcrumb.data[@"payload"] && + nativeBreadcrumb.data[@"payload"][@"category"] && + [nativeBreadcrumb.data[@"payload"][@"category"] + isEqualToString:@"navigation"]) { + return nil; + } + + return nativeBreadcrumb; +} + +- (id _Nullable)convertFrom: + (SentryBreadcrumb *_Nonnull)breadcrumb + withCategory:(NSString *)category + andMessage:(NSString *)message { + return [SentrySessionReplayIntegration + createBreadcrumbwithTimestamp:breadcrumb.timestamp + category:category ?: breadcrumb.category + message:message ?: breadcrumb.message + level:breadcrumb.level + data:breadcrumb.data]; +} + +- (id _Nullable)convertNetwork: + (SentryBreadcrumb *_Nonnull)breadcrumb { + NSNumber *startTimestamp = + [breadcrumb.data[@"start_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"start_timestamp"] + : nil; + NSNumber *endTimestamp = + [breadcrumb.data[@"end_timestamp"] isKindOfClass:[NSNumber class]] + ? breadcrumb.data[@"end_timestamp"] + : nil; + NSString *url = [breadcrumb.data[@"url"] isKindOfClass:[NSString class]] + ? breadcrumb.data[@"url"] + : nil; + + if (startTimestamp == nil || endTimestamp == nil || url == nil) { + return nil; + } + + NSMutableDictionary *data = [[NSMutableDictionary alloc] init]; + if ([breadcrumb.data[@"method"] isKindOfClass:[NSString class]]) { + data[@"method"] = breadcrumb.data[@"method"]; + } + if ([breadcrumb.data[@"status_code"] isKindOfClass:[NSNumber class]]) { + data[@"statusCode"] = breadcrumb.data[@"status_code"]; + } + if ([breadcrumb.data[@"request_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"requestBodySize"] = breadcrumb.data[@"request_body_size"]; + } + if ([breadcrumb.data[@"response_body_size"] isKindOfClass:[NSNumber class]]) { + data[@"responseBodySize"] = breadcrumb.data[@"response_body_size"]; + } + + return [SentrySessionReplayIntegration + createNetworkBreadcrumbWithTimestamp:[self dateFrom:startTimestamp] + endTimestamp:[self dateFrom:endTimestamp] + operation:@"resource.http" + description:url + data:data]; +} + +- (NSDate *_Nonnull)dateFrom:(NSNumber *_Nonnull)timestamp { + return [NSDate dateWithTimeIntervalSince1970:(timestamp.doubleValue / 1000)]; +} + +@end + +#endif diff --git a/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h new file mode 100644 index 0000000000..d59e5f4612 --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h @@ -0,0 +1,12 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +@class SentryRRWebEvent; + +@interface SentryFlutterReplayScreenshotProvider + : NSObject + +- (instancetype)initWithChannel:(id)FlutterMethodChannel; + +@end +#endif diff --git a/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m new file mode 100644 index 0000000000..fc03fd5365 --- /dev/null +++ b/flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m @@ -0,0 +1,46 @@ +@import Sentry; + +#if SENTRY_TARGET_REPLAY_SUPPORTED +#import "SentryFlutterReplayScreenshotProvider.h" +#import + +@implementation SentryFlutterReplayScreenshotProvider { + FlutterMethodChannel *channel; +} + +- (instancetype _Nonnull)initWithChannel: + (FlutterMethodChannel *_Nonnull)channel { + if (self = [super init]) { + self->channel = channel; + } + return self; +} + +- (void)imageWithView:(UIView *_Nonnull)view + options:(id _Nonnull)options + onComplete:(void (^_Nonnull)(UIImage *_Nonnull))onComplete { + [self->channel + invokeMethod:@"captureReplayScreenshot" + arguments:@{@"replayId" : [PrivateSentrySDKOnly getReplayId]} + result:^(id value) { + if (value == nil) { + NSLog(@"SentryFlutterReplayScreenshotProvider received null " + @"result. " + @"Cannot capture a replay screenshot."); + } else if ([value + isKindOfClass:[FlutterStandardTypedData class]]) { + FlutterStandardTypedData *typedData = + (FlutterStandardTypedData *)value; + UIImage *image = [UIImage imageWithData:typedData.data]; + onComplete(image); + } else { + NSLog(@"SentryFlutterReplayScreenshotProvider received an " + @"unexpected result. " + @"Cannot capture a replay screenshot."); + } + }]; +} + +@end + +#endif diff --git a/flutter/lib/sentry_flutter.dart b/flutter/lib/sentry_flutter.dart index d15c8b7a70..bea9016630 100644 --- a/flutter/lib/sentry_flutter.dart +++ b/flutter/lib/sentry_flutter.dart @@ -8,6 +8,7 @@ export 'src/integrations/load_release_integration.dart'; export 'src/navigation/sentry_navigator_observer.dart'; 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/integrations/on_error_integration.dart'; diff --git a/flutter/lib/src/event_processor/replay_event_processor.dart b/flutter/lib/src/event_processor/replay_event_processor.dart new file mode 100644 index 0000000000..1d534f94b0 --- /dev/null +++ b/flutter/lib/src/event_processor/replay_event_processor.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; + +import '../native/sentry_native_binding.dart'; + +class ReplayEventProcessor implements EventProcessor { + final SentryNativeBinding _binding; + + ReplayEventProcessor(this._binding); + + @override + Future apply(SentryEvent event, Hint hint) async { + if (event.eventId != SentryId.empty() && + event.exceptions?.isNotEmpty == true) { + final isCrash = + event.exceptions!.any((e) => e.mechanism?.handled == false); + await _binding.captureReplay(isCrash); + } + return event; + } +} 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/integrations/native_sdk_integration.dart b/flutter/lib/src/integrations/native_sdk_integration.dart index 7178883d73..ad77711b63 100644 --- a/flutter/lib/src/integrations/native_sdk_integration.dart +++ b/flutter/lib/src/integrations/native_sdk_integration.dart @@ -20,7 +20,7 @@ class NativeSdkIntegration implements Integration { } try { - await _native.init(options); + await _native.init(hub); options.sdk.addIntegration('nativeSdkIntegration'); } catch (exception, stackTrace) { options.logger( diff --git a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart index 9bb5af98b6..5666246472 100644 --- a/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart +++ b/flutter/lib/src/native/cocoa/sentry_native_cocoa.dart @@ -1,17 +1,76 @@ import 'dart:ffi'; +import 'dart:typed_data'; +import 'dart:ui'; import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; +import '../../event_processor/replay_event_processor.dart'; +import '../../replay/recorder.dart'; +import '../../replay/recorder_config.dart'; import '../sentry_native_channel.dart'; import 'binding.dart' as cocoa; @internal class SentryNativeCocoa extends SentryNativeChannel { late final _lib = cocoa.SentryCocoa(DynamicLibrary.process()); + ScreenshotRecorder? _replayRecorder; + SentryId? _replayId; SentryNativeCocoa(super.options, super.channel); + @override + Future init(Hub hub) async { + // We only need these when replay is enabled (session or error capture) + // so let's set it up conditionally. This allows Dart to trim the code. + if (options.experimental.replay.isEnabled && + options.platformChecker.platform.isIOS) { + // We only need the integration when error-replay capture is enabled. + if ((options.experimental.replay.errorSampleRate ?? 0) > 0) { + options.addEventProcessor(ReplayEventProcessor(this)); + } + + channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'captureReplayScreenshot': + _replayRecorder ??= + ScreenshotRecorder(ScreenshotRecorderConfig(), options); + final replayId = + SentryId.fromId(call.arguments['replayId'] as String); + if (_replayId != replayId) { + _replayId = replayId; + hub.configureScope((s) { + // ignore: invalid_use_of_internal_member + s.replayId = replayId; + }); + } + + Uint8List? imageBytes; + await _replayRecorder?.capture((image) async { + final imageData = + await image.toByteData(format: ImageByteFormat.png); + if (imageData != null) { + options.logger( + SentryLevel.debug, + 'Replay: captured screenshot (' + '${image.width}x${image.height} pixels, ' + '${imageData.lengthInBytes} bytes)'); + imageBytes = imageData.buffer.asUint8List(); + } else { + options.logger(SentryLevel.warning, + 'Replay: failed to convert screenshot to PNG'); + } + }); + return imageBytes; + default: + throw UnimplementedError('Method ${call.method} not implemented'); + } + }); + } + + return super.init(hub); + } + @override int? startProfiler(SentryId traceId) => tryCatchSync('startProfiler', () { final cSentryId = cocoa.SentryId1.alloc(_lib) diff --git a/flutter/lib/src/native/java/sentry_native_java.dart b/flutter/lib/src/native/java/sentry_native_java.dart index 1b0ef13cc5..5ccd3a1c67 100644 --- a/flutter/lib/src/native/java/sentry_native_java.dart +++ b/flutter/lib/src/native/java/sentry_native_java.dart @@ -1,10 +1,128 @@ +import 'dart:ui'; + import 'package:meta/meta.dart'; +import '../../../sentry_flutter.dart'; +import '../../event_processor/replay_event_processor.dart'; +import '../../replay/scheduled_recorder.dart'; +import '../../replay/recorder_config.dart'; import '../sentry_native_channel.dart'; // Note: currently this doesn't do anything. Later, it shall be used with // generated JNI bindings. See https://github.com/getsentry/sentry-dart/issues/1444 @internal class SentryNativeJava extends SentryNativeChannel { + ScheduledScreenshotRecorder? _replayRecorder; SentryNativeJava(super.options, super.channel); + + @override + Future init(Hub hub) async { + // We only need these when replay is enabled (session or error capture) + // so let's set it up conditionally. This allows Dart to trim the code. + if (options.experimental.replay.isEnabled) { + // We only need the integration when error-replay capture is enabled. + if ((options.experimental.replay.errorSampleRate ?? 0) > 0) { + options.addEventProcessor(ReplayEventProcessor(this)); + } + + channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'ReplayRecorder.start': + final replayId = + SentryId.fromId(call.arguments['replayId'] as String); + + _startRecorder( + call.arguments['directory'] as String, + ScheduledScreenshotRecorderConfig( + width: call.arguments['width'] as int, + height: call.arguments['height'] as int, + frameRate: call.arguments['frameRate'] as int, + ), + ); + + hub.configureScope((s) { + // ignore: invalid_use_of_internal_member + s.replayId = replayId; + }); + + break; + case 'ReplayRecorder.stop': + await _replayRecorder?.stop(); + _replayRecorder = null; + + hub.configureScope((s) { + // ignore: invalid_use_of_internal_member + s.replayId = null; + }); + + break; + case 'ReplayRecorder.pause': + await _replayRecorder?.stop(); + break; + case 'ReplayRecorder.resume': + _replayRecorder?.start(); + break; + default: + throw UnimplementedError('Method ${call.method} not implemented'); + } + }); + } + + return super.init(hub); + } + + @override + Future close() async { + await _replayRecorder?.stop(); + _replayRecorder = null; + return super.close(); + } + + void _startRecorder( + String cacheDir, ScheduledScreenshotRecorderConfig config) { + // Note: time measurements using a Stopwatch in a debug build: + // save as rawRgba (1230876 bytes): 0.257 ms -- discarded + // save as PNG (25401 bytes): 43.110 ms -- used for the final image + // image size: 25401 bytes + // save to file: 3.677 ms + // onScreenshotRecorded1: 1.237 ms + // released and exiting callback: 0.021 ms + ScreenshotRecorderCallback callback = (image) async { + var imageData = await image.toByteData(format: ImageByteFormat.png); + if (imageData != null) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final filePath = "$cacheDir/$timestamp.png"; + + options.logger( + SentryLevel.debug, + 'Replay: Saving screenshot to $filePath (' + '${image.width}x${image.height} pixels, ' + '${imageData.lengthInBytes} bytes)'); + try { + await options.fileSystem + .file(filePath) + .writeAsBytes(imageData.buffer.asUint8List(), flush: true); + + await channel.invokeMethod( + 'addReplayScreenshot', + {'path': filePath, 'timestamp': timestamp}, + ); + } catch (error, stackTrace) { + options.logger( + SentryLevel.error, + 'Native call `addReplayScreenshot` failed', + exception: error, + stackTrace: stackTrace, + ); + // ignore: invalid_use_of_internal_member + if (options.automatedTestMode) { + rethrow; + } + } + } + }; + + _replayRecorder = ScheduledScreenshotRecorder(config, callback, options) + ..start(); + } } diff --git a/flutter/lib/src/native/sentry_native_binding.dart b/flutter/lib/src/native/sentry_native_binding.dart index 168326fcc9..44ee6432b5 100644 --- a/flutter/lib/src/native/sentry_native_binding.dart +++ b/flutter/lib/src/native/sentry_native_binding.dart @@ -10,7 +10,7 @@ import 'native_frames.dart'; /// Provide typed methods to access native layer. @internal abstract class SentryNativeBinding { - Future init(SentryFlutterOptions options); + Future init(Hub hub); Future close(); @@ -59,4 +59,6 @@ abstract class SentryNativeBinding { Future resumeAppHangTracking(); Future nativeCrash(); + + Future captureReplay(bool isCrash); } diff --git a/flutter/lib/src/native/sentry_native_channel.dart b/flutter/lib/src/native/sentry_native_channel.dart index 97245870db..2820ac28ef 100644 --- a/flutter/lib/src/native/sentry_native_channel.dart +++ b/flutter/lib/src/native/sentry_native_channel.dart @@ -22,15 +22,15 @@ class SentryNativeChannel @override final SentryFlutterOptions options; - final SentrySafeMethodChannel _channel; + @protected + final SentrySafeMethodChannel channel; SentryNativeChannel(this.options, MethodChannel channel) - : _channel = SentrySafeMethodChannel(channel, options); + : channel = SentrySafeMethodChannel(channel, options); @override - Future init(SentryFlutterOptions options) async { - assert(this.options == options); - return _channel.invokeMethod('initNativeSdk', { + Future init(Hub hub) async { + return channel.invokeMethod('initNativeSdk', { 'dsn': options.dsn, 'debug': options.debug, 'environment': options.environment, @@ -67,37 +67,40 @@ class SentryNativeChannel 'appHangTimeoutIntervalMillis': options.appHangTimeoutInterval.inMilliseconds, if (options.proxy != null) 'proxy': options.proxy?.toJson(), + 'replay': { + 'sessionSampleRate': options.experimental.replay.sessionSampleRate, + 'errorSampleRate': options.experimental.replay.errorSampleRate, + }, }); } @override - Future close() async => _channel.invokeMethod('closeNativeSdk'); + Future close() async => channel.invokeMethod('closeNativeSdk'); @override Future fetchNativeAppStart() async { final json = - await _channel.invokeMapMethod('fetchNativeAppStart'); + await channel.invokeMapMethod('fetchNativeAppStart'); return (json != null) ? NativeAppStart.fromJson(json) : null; } @override Future captureEnvelope( Uint8List envelopeData, bool containsUnhandledException) { - return _channel.invokeMethod( + return channel.invokeMethod( 'captureEnvelope', [envelopeData, containsUnhandledException]); } @override Future?> loadContexts() => - _channel.invokeMapMethod('loadContexts'); + channel.invokeMapMethod('loadContexts'); @override - Future beginNativeFrames() => - _channel.invokeMethod('beginNativeFrames'); + Future beginNativeFrames() => channel.invokeMethod('beginNativeFrames'); @override Future endNativeFrames(SentryId id) async { - final json = await _channel.invokeMapMethod( + final json = await channel.invokeMapMethod( 'endNativeFrames', {'id': id.toString()}); return (json != null) ? NativeFrames.fromJson(json) : null; } @@ -107,7 +110,7 @@ class SentryNativeChannel final normalizedUser = user?.copyWith( data: MethodChannelHelper.normalizeMap(user.data), ); - await _channel.invokeMethod( + await channel.invokeMethod( 'setUser', {'user': normalizedUser?.toJson()}, ); @@ -118,42 +121,42 @@ class SentryNativeChannel final normalizedBreadcrumb = breadcrumb.copyWith( data: MethodChannelHelper.normalizeMap(breadcrumb.data), ); - await _channel.invokeMethod( + await channel.invokeMethod( 'addBreadcrumb', {'breadcrumb': normalizedBreadcrumb.toJson()}, ); } @override - Future clearBreadcrumbs() => _channel.invokeMethod('clearBreadcrumbs'); + Future clearBreadcrumbs() => channel.invokeMethod('clearBreadcrumbs'); @override - Future setContexts(String key, dynamic value) => _channel.invokeMethod( + Future setContexts(String key, dynamic value) => channel.invokeMethod( 'setContexts', {'key': key, 'value': MethodChannelHelper.normalize(value)}, ); @override Future removeContexts(String key) => - _channel.invokeMethod('removeContexts', {'key': key}); + channel.invokeMethod('removeContexts', {'key': key}); @override - Future setExtra(String key, dynamic value) => _channel.invokeMethod( + Future setExtra(String key, dynamic value) => channel.invokeMethod( 'setExtra', {'key': key, 'value': MethodChannelHelper.normalize(value)}, ); @override Future removeExtra(String key) => - _channel.invokeMethod('removeExtra', {'key': key}); + channel.invokeMethod('removeExtra', {'key': key}); @override Future setTag(String key, String value) => - _channel.invokeMethod('setTag', {'key': key, 'value': value}); + channel.invokeMethod('setTag', {'key': key, 'value': value}); @override Future removeTag(String key) => - _channel.invokeMethod('removeTag', {'key': key}); + channel.invokeMethod('removeTag', {'key': key}); @override int? startProfiler(SentryId traceId) => @@ -161,12 +164,12 @@ class SentryNativeChannel @override Future discardProfiler(SentryId traceId) => - _channel.invokeMethod('discardProfiler', traceId.toString()); + channel.invokeMethod('discardProfiler', traceId.toString()); @override Future?> collectProfile( SentryId traceId, int startTimeNs, int endTimeNs) => - _channel.invokeMapMethod('collectProfile', { + channel.invokeMapMethod('collectProfile', { 'traceId': traceId.toString(), 'startTime': startTimeNs, 'endTime': endTimeNs, @@ -175,7 +178,7 @@ class SentryNativeChannel @override Future?> loadDebugImages() => tryCatchAsync('loadDebugImages', () async { - final images = await _channel + final images = await channel .invokeListMethod>('loadImageList'); return images ?.map((e) => e.cast()) @@ -185,16 +188,22 @@ class SentryNativeChannel @override Future displayRefreshRate() => - _channel.invokeMethod('displayRefreshRate'); + channel.invokeMethod('displayRefreshRate'); @override Future pauseAppHangTracking() => - _channel.invokeMethod('pauseAppHangTracking'); + channel.invokeMethod('pauseAppHangTracking'); @override Future resumeAppHangTracking() => - _channel.invokeMethod('resumeAppHangTracking'); + channel.invokeMethod('resumeAppHangTracking'); @override - Future nativeCrash() => _channel.invokeMethod('nativeCrash'); + Future nativeCrash() => channel.invokeMethod('nativeCrash'); + + @override + Future captureReplay(bool isCrash) => + channel.invokeMethod('captureReplay', { + 'isCrash': isCrash, + }).then((value) => SentryId.fromId(value as String)); } diff --git a/flutter/lib/src/native/sentry_safe_method_channel.dart b/flutter/lib/src/native/sentry_safe_method_channel.dart index aa44c08f44..8cd258c8dc 100644 --- a/flutter/lib/src/native/sentry_safe_method_channel.dart +++ b/flutter/lib/src/native/sentry_safe_method_channel.dart @@ -14,6 +14,10 @@ class SentrySafeMethodChannel with SentryNativeSafeInvoker { SentrySafeMethodChannel(this._channel, this.options); + void setMethodCallHandler( + Future Function(MethodCall call)? handler) => + _channel.setMethodCallHandler(handler); + @optionalTypeArgs Future invokeMethod(String method, [dynamic args]) => tryCatchAsync(method, () => _channel.invokeMethod(method, args)); diff --git a/flutter/lib/src/replay/recorder.dart b/flutter/lib/src/replay/recorder.dart new file mode 100644 index 0000000000..a1f4ea1a0b --- /dev/null +++ b/flutter/lib/src/replay/recorder.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/rendering.dart'; +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import 'recorder_config.dart'; +import 'widget_filter.dart'; + +@internal +typedef ScreenshotRecorderCallback = Future Function(Image); + +@internal +class ScreenshotRecorder { + @protected + final ScreenshotRecorderConfig config; + @protected + final SentryFlutterOptions options; + WidgetFilter? _widgetFilter; + bool warningLogged = false; + + ScreenshotRecorder(this.config, this.options) { + final replayOptions = options.experimental.replay; + if (replayOptions.redactAllText || replayOptions.redactAllImages) { + _widgetFilter = WidgetFilter( + redactText: replayOptions.redactAllText, + redactImages: replayOptions.redactAllImages, + logger: options.logger); + } + } + + Future capture(ScreenshotRecorderCallback callback) async { + final context = sentryScreenshotWidgetGlobalKey.currentContext; + final renderObject = context?.findRenderObject() as RenderRepaintBoundary?; + if (context == null || renderObject == null) { + if (!warningLogged) { + options.logger( + SentryLevel.warning, + "Replay: SentryScreenshotWidget is not attached. " + "Skipping replay capture."); + warningLogged = true; + } + return; + } + + try { + final watch = Stopwatch()..start(); + + // On Android, the desired resolution (coming from the configuration) + // is rounded to next multitude of 16 . Therefore, we scale the image. + // On iOS, the screenshot resolution is not adjusted. + final srcWidth = renderObject.size.width; + final srcHeight = renderObject.size.height; + final pixelRatio = config.getPixelRatio(srcWidth, srcHeight); + + // First, we synchronously capture the image and enumerate widgets on the main UI loop. + final futureImage = renderObject.toImage(pixelRatio: pixelRatio); + + final filter = _widgetFilter; + if (filter != null) { + filter.obscure( + context, + pixelRatio, + Rect.fromLTWH(0, 0, srcWidth * pixelRatio, srcHeight * pixelRatio), + ); + } + + final blockingTime = watch.elapsedMilliseconds; + + // Then we draw the image and obscure collected coordinates asynchronously. + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + final image = await futureImage; + try { + canvas.drawImage(image, Offset.zero, Paint()); + } finally { + image.dispose(); + } + + if (filter != null) { + _obscureWidgets(canvas, filter.items); + } + + final picture = recorder.endRecording(); + + try { + final finalImage = await picture.toImage( + (srcWidth * pixelRatio).round(), (srcHeight * pixelRatio).round()); + try { + await callback(finalImage); + } finally { + finalImage.dispose(); + } + } finally { + picture.dispose(); + } + + options.logger( + SentryLevel.debug, + "Replay: captured a screenshot in ${watch.elapsedMilliseconds}" + " ms ($blockingTime ms blocking)."); + } catch (e, stackTrace) { + options.logger(SentryLevel.error, "Replay: failed to capture screenshot.", + exception: e, stackTrace: stackTrace); + // ignore: invalid_use_of_internal_member + if (options.automatedTestMode) { + rethrow; + } + } + } + + void _obscureWidgets(Canvas canvas, List items) { + final paint = Paint()..style = PaintingStyle.fill; + for (var item in items) { + paint.color = item.color; + canvas.drawRect(item.bounds, paint); + } + } +} diff --git a/flutter/lib/src/replay/recorder_config.dart b/flutter/lib/src/replay/recorder_config.dart new file mode 100644 index 0000000000..9649a33823 --- /dev/null +++ b/flutter/lib/src/replay/recorder_config.dart @@ -0,0 +1,29 @@ +import 'dart:math'; + +import 'package:meta/meta.dart'; + +@internal +class ScreenshotRecorderConfig { + final int? width; + final int? height; + + const ScreenshotRecorderConfig({this.width, this.height}); + + double getPixelRatio(double srcWidth, double srcHeight) { + assert((width == null) == (height == null)); + if (width == null || height == null) { + return 1.0; + } + return min(width! / srcWidth, height! / srcHeight); + } +} + +class ScheduledScreenshotRecorderConfig extends ScreenshotRecorderConfig { + final int frameRate; + + const ScheduledScreenshotRecorderConfig({ + super.width, + super.height, + required this.frameRate, + }); +} diff --git a/flutter/lib/src/replay/scheduled_recorder.dart b/flutter/lib/src/replay/scheduled_recorder.dart new file mode 100644 index 0000000000..c575278a74 --- /dev/null +++ b/flutter/lib/src/replay/scheduled_recorder.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:meta/meta.dart'; + +import '../../sentry_flutter.dart'; +import 'recorder.dart'; +import 'recorder_config.dart'; +import 'scheduler.dart'; + +@internal +typedef ScreenshotRecorderCallback = Future Function(Image); + +@internal +class ScheduledScreenshotRecorder extends ScreenshotRecorder { + late final Scheduler _scheduler; + final ScreenshotRecorderCallback _callback; + + ScheduledScreenshotRecorder(ScheduledScreenshotRecorderConfig config, + this._callback, SentryFlutterOptions options) + : super(config, options) { + assert(config.frameRate > 0); + final frameDuration = Duration(milliseconds: 1000 ~/ config.frameRate); + _scheduler = Scheduler(frameDuration, _capture, + options.bindingUtils.instance!.addPostFrameCallback); + } + + void start() { + options.logger(SentryLevel.debug, "Replay: starting replay capture."); + _scheduler.start(); + } + + Future stop() async { + await _scheduler.stop(); + options.logger(SentryLevel.debug, "Replay: replay capture stopped."); + } + + Future _capture(Duration sinceSchedulerEpoch) async => + capture(_callback); +} diff --git a/flutter/lib/src/replay/scheduler.dart b/flutter/lib/src/replay/scheduler.dart new file mode 100644 index 0000000000..4d246360e3 --- /dev/null +++ b/flutter/lib/src/replay/scheduler.dart @@ -0,0 +1,55 @@ +import 'package:flutter/scheduler.dart'; +import 'package:meta/meta.dart'; + +@internal +typedef SchedulerCallback = Future Function(Duration); + +/// This is a low-priority scheduler. +/// We're not using Timer.periodic() because it may schedule a callback +/// even if the previous call hasn't finished (or started) yet. +/// Instead, we manually schedule a callback with a given delay after the +/// previous callback finished. Therefore, if the capture takes too long, we +/// won't overload the system. We sacrifice the frame rate for performance. +@internal +class Scheduler { + final SchedulerCallback _callback; + final Duration _interval; + bool _running = false; + Future? _scheduled; + + final void Function(FrameCallback callback) _addPostFrameCallback; + + Scheduler(this._interval, this._callback, this._addPostFrameCallback); + + void start() { + _running = true; + if (_scheduled == null) { + _runAfterNextFrame(); + } + } + + Future stop() async { + _running = false; + final scheduled = _scheduled; + _scheduled = null; + if (scheduled != null) { + await scheduled; + } + } + + @pragma('vm:prefer-inline') + void _scheduleNext() { + _scheduled ??= Future.delayed(_interval, _runAfterNextFrame); + } + + @pragma('vm:prefer-inline') + void _runAfterNextFrame() { + _scheduled = null; + _addPostFrameCallback(_run); + } + + void _run(Duration sinceSchedulerEpoch) { + if (!_running) return; + _callback(sinceSchedulerEpoch).then((_) => _scheduleNext()); + } +} diff --git a/flutter/lib/src/replay/widget_filter.dart b/flutter/lib/src/replay/widget_filter.dart new file mode 100644 index 0000000000..83e069cb97 --- /dev/null +++ b/flutter/lib/src/replay/widget_filter.dart @@ -0,0 +1,133 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; +import 'package:sentry/sentry.dart'; + +import '../../sentry_flutter.dart'; + +@internal +class WidgetFilter { + final items = []; + final SentryLogger logger; + final bool redactText; + final bool redactImages; + static const _defaultColor = Color.fromARGB(255, 0, 0, 0); + late double _pixelRatio; + late Rect _bounds; + final _warnedWidgets = {}; + + WidgetFilter( + {required this.redactText, + required this.redactImages, + required this.logger}); + + void obscure(BuildContext context, double pixelRatio, Rect bounds) { + _pixelRatio = pixelRatio; + _bounds = bounds; + items.clear(); + if (context is Element) { + _obscure(context); + } else { + context.visitChildElements(_obscure); + } + } + + void _obscure(Element element) { + final widget = element.widget; + + if (!_isVisible(widget)) { + assert(() { + logger(SentryLevel.debug, "WidgetFilter skipping invisible: $widget"); + return true; + }()); + return; + } + + final obscured = _obscureIfNeeded(element, widget); + if (!obscured) { + element.visitChildElements(_obscure); + } + } + + @pragma('vm:prefer-inline') + bool _obscureIfNeeded(Element element, Widget widget) { + Color? color; + + if (redactText && widget is Text) { + color = widget.style?.color; + } else if (redactText && widget is EditableText) { + color = widget.style.color; + } else if (redactImages && widget is Image) { + color = widget.color; + } else { + // No other type is currently obscured. + return false; + } + + final renderObject = element.renderObject; + if (renderObject is! RenderBox) { + _cantObscure(widget, "it's renderObject is not a RenderBox"); + return false; + } + + final size = element.size; + if (size == null) { + _cantObscure(widget, "it's renderObject has a null size"); + return false; + } + + final offset = renderObject.localToGlobal(Offset.zero); + + final rect = Rect.fromLTWH( + offset.dx * _pixelRatio, + offset.dy * _pixelRatio, + size.width * _pixelRatio, + size.height * _pixelRatio, + ); + + if (!rect.overlaps(_bounds)) { + assert(() { + logger(SentryLevel.debug, "WidgetFilter skipping offscreen: $widget"); + return true; + }()); + return false; + } + + items.add(WidgetFilterItem(color ?? _defaultColor, rect)); + assert(() { + logger(SentryLevel.debug, "WidgetFilter obscuring: $widget"); + return true; + }()); + + return true; + } + + // We cut off some widgets early because they're not visible at all. + bool _isVisible(Widget widget) { + if (widget is Visibility) { + return widget.visible; + } + if (widget is Opacity) { + return widget.opacity > 0; + } + if (widget is Offstage) { + return !widget.offstage; + } + return true; + } + + @pragma('vm:prefer-inline') + void _cantObscure(Widget widget, String message) { + if (!_warnedWidgets.contains(widget.hashCode)) { + _warnedWidgets.add(widget.hashCode); + logger(SentryLevel.warning, + "WidgetFilter cannot obscure widget $widget: $message"); + } + } +} + +class WidgetFilterItem { + final Color color; + final Rect bounds; + + const WidgetFilterItem(this.color, this.bounds); +} diff --git a/flutter/lib/src/screenshot/sentry_screenshot_widget.dart b/flutter/lib/src/screenshot/sentry_screenshot_widget.dart index e83d46d0c5..6eafb935a5 100644 --- a/flutter/lib/src/screenshot/sentry_screenshot_widget.dart +++ b/flutter/lib/src/screenshot/sentry_screenshot_widget.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; -import '../../sentry_flutter.dart'; - /// Key which is used to identify the [RepaintBoundary] @internal final sentryScreenshotWidgetGlobalKey = @@ -25,36 +23,19 @@ final sentryScreenshotWidgetGlobalKey = /// times. class SentryScreenshotWidget extends StatefulWidget { final Widget child; - late final Hub _hub; - - SentryFlutterOptions? get _options => - // ignore: invalid_use_of_internal_member - _hub.options is SentryFlutterOptions - // ignore: invalid_use_of_internal_member - ? _hub.options as SentryFlutterOptions - : null; - SentryScreenshotWidget({ - super.key, - required this.child, - @internal Hub? hub, - }) : _hub = hub ?? HubAdapter(); + const SentryScreenshotWidget({super.key, required this.child}); @override _SentryScreenshotWidgetState createState() => _SentryScreenshotWidgetState(); } class _SentryScreenshotWidgetState extends State { - SentryFlutterOptions? get _options => widget._options; - @override Widget build(BuildContext context) { - if (_options?.attachScreenshot ?? false) { - return RepaintBoundary( - key: sentryScreenshotWidgetGlobalKey, - child: widget.child, - ); - } - return widget.child; + return RepaintBoundary( + key: sentryScreenshotWidgetGlobalKey, + child: widget.child, + ); } } diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 4d875edea2..6d035b3740 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 347da9ada3..308ed805b0 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -1,6 +1,8 @@ import 'dart:async'; -import 'package:meta/meta.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:meta/meta.dart' as meta; import 'package:sentry/sentry.dart'; import 'package:flutter/widgets.dart'; @@ -10,6 +12,7 @@ import 'screenshot/sentry_screenshot_quality.dart'; import 'event_processor/screenshot_event_processor.dart'; import 'screenshot/sentry_screenshot_widget.dart'; import 'sentry_flutter.dart'; +import 'sentry_replay_options.dart'; import 'user_interaction/sentry_user_interaction_widget.dart'; /// This class adds options which are only available in a Flutter environment. @@ -143,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/ @@ -203,14 +221,14 @@ class SentryFlutterOptions extends SentryOptions { /// Sets the Proguard uuid for Android platform. String? proguardUuid; - @internal + @meta.internal late RendererWrapper rendererWrapper = RendererWrapper(); /// Enables the View Hierarchy feature. /// /// Renders an ASCII represention of the entire view hierarchy of the /// application when an error happens and includes it as an attachment. - @experimental + @meta.experimental bool attachViewHierarchy = false; /// Enables collection of view hierarchy element identifiers. @@ -302,14 +320,14 @@ class SentryFlutterOptions extends SentryOptions { } /// Setting this to a custom [BindingWrapper] allows you to use a custom [WidgetsBinding]. - @experimental + @meta.experimental BindingWrapper bindingUtils = BindingWrapper(); /// The sample rate for profiling traces in the range of 0.0 to 1.0. /// This is relative to tracesSampleRate - it is a ratio of profiled traces out of all sampled traces. /// At the moment, only apps targeting iOS and macOS are supported. @override - @experimental + @meta.experimental double? get profilesSampleRate { // ignore: invalid_use_of_internal_member return super.profilesSampleRate; @@ -319,7 +337,7 @@ class SentryFlutterOptions extends SentryOptions { /// This is relative to tracesSampleRate - it is a ratio of profiled traces out of all sampled traces. /// At the moment, only apps targeting iOS and macOS are supported. @override - @experimental + @meta.experimental set profilesSampleRate(double? value) { // ignore: invalid_use_of_internal_member super.profilesSampleRate = value; @@ -327,6 +345,20 @@ class SentryFlutterOptions extends SentryOptions { /// The [navigatorKey] is used to add information of the currently used locale to the contexts. GlobalKey? navigatorKey; + + @meta.internal + FileSystem fileSystem = LocalFileSystem(); + + /// Configuration of experimental features that may change or be removed + /// without prior notice. Additionally, these features may not be ready for + /// production use yet. + @meta.experimental + final experimental = _SentryFlutterExperimentalOptions(); +} + +class _SentryFlutterExperimentalOptions { + /// Replay recording configuration. + final replay = SentryReplayOptions(); } /// Callback being executed in [ScreenshotEventProcessor], deciding if a diff --git a/flutter/lib/src/sentry_replay_options.dart b/flutter/lib/src/sentry_replay_options.dart new file mode 100644 index 0000000000..e98aed7418 --- /dev/null +++ b/flutter/lib/src/sentry_replay_options.dart @@ -0,0 +1,40 @@ +import 'package:meta/meta.dart'; + +/// Configuration of the experimental replay feature. +class SentryReplayOptions { + double? _sessionSampleRate; + + /// A percentage of sessions in which a replay will be created. + /// The value needs to be >= 0.0 and <= 1.0. + /// Specifying 0 means none, 1.0 means 100 %. Defaults to null (disabled). + double? get sessionSampleRate => _sessionSampleRate; + set sessionSampleRate(double? value) { + assert(value == null || (value >= 0 && value <= 1)); + _sessionSampleRate = value; + } + + double? _errorSampleRate; + + /// A percentage of errors that will be accompanied by a 30 seconds replay. + /// The value needs to be >= 0.0 and <= 1.0. + /// Specifying 0 means none, 1.0 means 100 %. Defaults to null (disabled). + double? get errorSampleRate => _errorSampleRate; + set errorSampleRate(double? value) { + assert(value == null || (value >= 0 && value <= 1)); + _errorSampleRate = value; + } + + /// Redact all text content. Draws a rectangle of text bounds with text color + /// on top. Currently, only [Text] and [EditableText] Widgets are redacted. + /// Default is enabled. + var redactAllText = true; + + /// Redact all image content. Draws a rectangle of image bounds with image's + /// dominant color on top. Currently, only [Image] widgets are redacted. + /// Default is enabled. + var redactAllImages = true; + + @internal + bool get isEnabled => + ((sessionSampleRate ?? 0) > 0) || ((errorSampleRate ?? 0) > 0); +} 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; diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index dd5a871036..ad36c1252f 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: package_info_plus: '>=1.0.0' meta: ^1.3.0 ffi: ^2.0.0 + file: '>=6.1.4' dev_dependencies: build_runner: ^2.4.2 diff --git a/flutter/test/event_processor/screenshot_event_processor_test.dart b/flutter/test/event_processor/screenshot_event_processor_test.dart index 3a00f10ced..819e3b9b7b 100644 --- a/flutter/test/event_processor/screenshot_event_processor_test.dart +++ b/flutter/test/event_processor/screenshot_event_processor_test.dart @@ -34,7 +34,6 @@ void main() { final sut = fixture.getSut(renderer, isWeb); await tester.pumpWidget(SentryScreenshotWidget( - hub: fixture.hub, child: Text('Catching Pokémon is a snap!', textDirection: TextDirection.ltr))); 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/flutter/test/integrations/init_native_sdk_test.dart b/flutter/test/integrations/init_native_sdk_test.dart index d3faa93346..4b89d3cfe0 100644 --- a/flutter/test/integrations/init_native_sdk_test.dart +++ b/flutter/test/integrations/init_native_sdk_test.dart @@ -8,6 +8,7 @@ import 'package:sentry_flutter/src/native/sentry_native_channel.dart'; import 'package:sentry_flutter/src/version.dart'; import '../mocks.dart'; +import '../mocks.mocks.dart'; void main() { late Fixture fixture; @@ -25,7 +26,7 @@ void main() { }); var sut = fixture.getSut(channel); - await sut.init(fixture.options); + await sut.init(MockHub()); channel.setMethodCallHandler(null); @@ -64,6 +65,10 @@ void main() { 'connectionTimeoutMillis': 5000, 'readTimeoutMillis': 5000, 'appHangTimeoutIntervalMillis': 2000, + 'replay': { + 'sessionSampleRate': null, + 'errorSampleRate': null, + }, }); }); @@ -111,12 +116,14 @@ void main() { type: SentryProxyType.http, user: 'admin', pass: '0000', - ); + ) + ..experimental.replay.sessionSampleRate = 0.1 + ..experimental.replay.errorSampleRate = 0.2; fixture.options.sdk.addIntegration('foo'); fixture.options.sdk.addPackage('bar', '1'); - await sut.init(fixture.options); + await sut.init(MockHub()); channel.setMethodCallHandler(null); @@ -162,7 +169,11 @@ void main() { 'type': 'HTTP', 'user': 'admin', 'pass': '0000', - } + }, + 'replay': { + 'sessionSampleRate': 0.1, + 'errorSampleRate': 0.2, + }, }); }); } diff --git a/flutter/test/integrations/native_sdk_integration_test.dart b/flutter/test/integrations/native_sdk_integration_test.dart index f94f88698c..5c244b3d66 100644 --- a/flutter/test/integrations/native_sdk_integration_test.dart +++ b/flutter/test/integrations/native_sdk_integration_test.dart @@ -60,7 +60,7 @@ void main() { class _ThrowingMockSentryNative extends MockSentryNativeBinding { @override - Future init(SentryFlutterOptions? options) async { + Future init(Hub? hub) async { throw Exception(); } } diff --git a/flutter/test/mocks.dart b/flutter/test/mocks.dart index f5e5cb65ce..ee74889877 100644 --- a/flutter/test/mocks.dart +++ b/flutter/test/mocks.dart @@ -54,46 +54,24 @@ ISentrySpan startTransactionShim( void main() {} class MockPlatform with NoSuchMethodProvider implements Platform { - MockPlatform({ - String? os, - String? osVersion, - String? hostname, - }) : operatingSystem = os ?? '', - operatingSystemVersion = osVersion ?? '', - localHostname = hostname ?? ''; - - factory MockPlatform.android() { - return MockPlatform(os: 'android'); - } + const MockPlatform(this.operatingSystem, + {this.operatingSystemVersion = '', this.localHostname = ''}); - factory MockPlatform.iOs() { - return MockPlatform(os: 'ios'); - } - - factory MockPlatform.macOs() { - return MockPlatform(os: 'macos'); - } - - factory MockPlatform.windows() { - return MockPlatform(os: 'windows'); - } - - factory MockPlatform.linux() { - return MockPlatform(os: 'linux'); - } - - factory MockPlatform.fuchsia() { - return MockPlatform(os: 'fuchsia'); - } + const MockPlatform.android() : this('android'); + const MockPlatform.iOs() : this('ios'); + const MockPlatform.macOs() : this('macos'); + const MockPlatform.windows() : this('windows'); + const MockPlatform.linux() : this('linux'); + const MockPlatform.fuchsia() : this('fuchsia'); @override - String operatingSystem; + final String operatingSystem; @override - String operatingSystemVersion; + final String operatingSystemVersion; @override - String localHostname; + final String localHostname; @override bool get isLinux => (operatingSystem == 'linux'); @@ -122,7 +100,7 @@ class MockPlatformChecker with NoSuchMethodProvider implements PlatformChecker { this.isWebValue = false, this.hasNativeIntegration = false, Platform? mockPlatform, - }) : _mockPlatform = mockPlatform ?? MockPlatform(); + }) : _mockPlatform = mockPlatform ?? MockPlatform(''); final bool isDebug; final bool isProfile; @@ -206,3 +184,32 @@ final fakeFrameDurations = [ Duration(milliseconds: 40), Duration(milliseconds: 710), ]; + +@GenerateMocks([Callbacks]) +abstract class Callbacks { + Future? methodCallHandler(String method, [dynamic arguments]); +} + +class NativeChannelFixture { + late final MethodChannel channel; + late final Future? Function(String method, [dynamic arguments]) + handler; + static TestDefaultBinaryMessenger get _messenger => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + NativeChannelFixture() { + TestWidgetsFlutterBinding.ensureInitialized(); + channel = MethodChannel('test.channel', StandardMethodCodec(), _messenger); + handler = MockCallbacks().methodCallHandler; + _messenger.setMockMethodCallHandler( + channel, (call) => handler(call.method, call.arguments)); + } + + // Mock this call as if it was invoked by the native side. + Future invokeFromNative(String method, [dynamic arguments]) async { + final call = + StandardMethodCodec().encodeMethodCall(MethodCall(method, arguments)); + return _messenger.handlePlatformMessage( + channel.name, call, (ByteData? data) {}); + } +} diff --git a/flutter/test/mocks.mocks.dart b/flutter/test/mocks.mocks.dart index 01d2127efe..feb97b5927 100644 --- a/flutter/test/mocks.mocks.dart +++ b/flutter/test/mocks.mocks.dart @@ -3,27 +3,22 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i8; -import 'dart:typed_data' as _i16; +import 'dart:async' as _i7; +import 'dart:typed_data' as _i12; -import 'package:flutter/src/services/binary_messenger.dart' as _i6; -import 'package:flutter/src/services/message_codec.dart' as _i5; -import 'package:flutter/src/services/platform_channel.dart' as _i12; +import 'package:flutter/services.dart' as _i4; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i10; -import 'package:sentry/sentry.dart' as _i2; -import 'package:sentry/src/metrics/metric.dart' as _i19; -import 'package:sentry/src/metrics/metrics_api.dart' as _i7; -import 'package:sentry/src/profiling.dart' as _i11; -import 'package:sentry/src/protocol.dart' as _i3; -import 'package:sentry/src/sentry_envelope.dart' as _i9; -import 'package:sentry/src/sentry_tracer.dart' as _i4; -import 'package:sentry_flutter/sentry_flutter.dart' as _i14; -import 'package:sentry_flutter/src/native/native_app_start.dart' as _i15; -import 'package:sentry_flutter/src/native/native_frames.dart' as _i17; -import 'package:sentry_flutter/src/native/sentry_native_binding.dart' as _i13; - -import 'mocks.dart' as _i18; +import 'package:mockito/src/dummies.dart' as _i8; +import 'package:sentry/src/metrics/metric.dart' as _i14; +import 'package:sentry/src/metrics/metrics_api.dart' as _i5; +import 'package:sentry/src/profiling.dart' as _i9; +import 'package:sentry/src/sentry_tracer.dart' as _i3; +import 'package:sentry_flutter/sentry_flutter.dart' as _i2; +import 'package:sentry_flutter/src/native/native_app_start.dart' as _i11; +import 'package:sentry_flutter/src/native/native_frames.dart' as _i13; +import 'package:sentry_flutter/src/native/sentry_native_binding.dart' as _i10; + +import 'mocks.dart' as _i6; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -70,7 +65,7 @@ class _FakeISentrySpan_2 extends _i1.SmartFake implements _i2.ISentrySpan { } class _FakeSentryTraceHeader_3 extends _i1.SmartFake - implements _i3.SentryTraceHeader { + implements _i2.SentryTraceHeader { _FakeSentryTraceHeader_3( Object parent, Invocation parentInvocation, @@ -80,7 +75,7 @@ class _FakeSentryTraceHeader_3 extends _i1.SmartFake ); } -class _FakeSentryTracer_4 extends _i1.SmartFake implements _i4.SentryTracer { +class _FakeSentryTracer_4 extends _i1.SmartFake implements _i3.SentryTracer { _FakeSentryTracer_4( Object parent, Invocation parentInvocation, @@ -90,7 +85,7 @@ class _FakeSentryTracer_4 extends _i1.SmartFake implements _i4.SentryTracer { ); } -class _FakeSentryId_5 extends _i1.SmartFake implements _i3.SentryId { +class _FakeSentryId_5 extends _i1.SmartFake implements _i2.SentryId { _FakeSentryId_5( Object parent, Invocation parentInvocation, @@ -100,7 +95,7 @@ class _FakeSentryId_5 extends _i1.SmartFake implements _i3.SentryId { ); } -class _FakeContexts_6 extends _i1.SmartFake implements _i3.Contexts { +class _FakeContexts_6 extends _i1.SmartFake implements _i2.Contexts { _FakeContexts_6( Object parent, Invocation parentInvocation, @@ -111,7 +106,7 @@ class _FakeContexts_6 extends _i1.SmartFake implements _i3.Contexts { } class _FakeSentryTransaction_7 extends _i1.SmartFake - implements _i3.SentryTransaction { + implements _i2.SentryTransaction { _FakeSentryTransaction_7( Object parent, Invocation parentInvocation, @@ -121,7 +116,7 @@ class _FakeSentryTransaction_7 extends _i1.SmartFake ); } -class _FakeMethodCodec_8 extends _i1.SmartFake implements _i5.MethodCodec { +class _FakeMethodCodec_8 extends _i1.SmartFake implements _i4.MethodCodec { _FakeMethodCodec_8( Object parent, Invocation parentInvocation, @@ -132,7 +127,7 @@ class _FakeMethodCodec_8 extends _i1.SmartFake implements _i5.MethodCodec { } class _FakeBinaryMessenger_9 extends _i1.SmartFake - implements _i6.BinaryMessenger { + implements _i4.BinaryMessenger { _FakeBinaryMessenger_9( Object parent, Invocation parentInvocation, @@ -152,7 +147,7 @@ class _FakeSentryOptions_10 extends _i1.SmartFake implements _i2.SentryOptions { ); } -class _FakeMetricsApi_11 extends _i1.SmartFake implements _i7.MetricsApi { +class _FakeMetricsApi_11 extends _i1.SmartFake implements _i5.MetricsApi { _FakeMetricsApi_11( Object parent, Invocation parentInvocation, @@ -182,6 +177,28 @@ class _FakeHub_13 extends _i1.SmartFake implements _i2.Hub { ); } +/// A class which mocks [Callbacks]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockCallbacks extends _i1.Mock implements _i6.Callbacks { + MockCallbacks() { + _i1.throwOnMissingStub(this); + } + + @override + _i7.Future? methodCallHandler( + String? method, [ + dynamic arguments, + ]) => + (super.noSuchMethod(Invocation.method( + #methodCallHandler, + [ + method, + arguments, + ], + )) as _i7.Future?); +} + /// A class which mocks [Transport]. /// /// See the documentation for Mockito's code generation for more information. @@ -191,20 +208,20 @@ class MockTransport extends _i1.Mock implements _i2.Transport { } @override - _i8.Future<_i3.SentryId?> send(_i9.SentryEnvelope? envelope) => + _i7.Future<_i2.SentryId?> send(_i2.SentryEnvelope? envelope) => (super.noSuchMethod( Invocation.method( #send, [envelope], ), - returnValue: _i8.Future<_i3.SentryId?>.value(), - ) as _i8.Future<_i3.SentryId?>); + returnValue: _i7.Future<_i2.SentryId?>.value(), + ) as _i7.Future<_i2.SentryId?>); } /// A class which mocks [SentryTracer]. /// /// See the documentation for Mockito's code generation for more information. -class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { +class MockSentryTracer extends _i1.Mock implements _i3.SentryTracer { MockSentryTracer() { _i1.throwOnMissingStub(this); } @@ -212,7 +229,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { @override String get name => (super.noSuchMethod( Invocation.getter(#name), - returnValue: _i10.dummyValue( + returnValue: _i8.dummyValue( this, Invocation.getter(#name), ), @@ -228,15 +245,15 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ); @override - _i3.SentryTransactionNameSource get transactionNameSource => + _i2.SentryTransactionNameSource get transactionNameSource => (super.noSuchMethod( Invocation.getter(#transactionNameSource), - returnValue: _i3.SentryTransactionNameSource.custom, - ) as _i3.SentryTransactionNameSource); + returnValue: _i2.SentryTransactionNameSource.custom, + ) as _i2.SentryTransactionNameSource); @override set transactionNameSource( - _i3.SentryTransactionNameSource? _transactionNameSource) => + _i2.SentryTransactionNameSource? _transactionNameSource) => super.noSuchMethod( Invocation.setter( #transactionNameSource, @@ -246,7 +263,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ); @override - set profiler(_i11.SentryProfiler? _profiler) => super.noSuchMethod( + set profiler(_i9.SentryProfiler? _profiler) => super.noSuchMethod( Invocation.setter( #profiler, _profiler, @@ -255,7 +272,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ); @override - set profileInfo(_i11.SentryProfileInfo? _profileInfo) => super.noSuchMethod( + set profileInfo(_i9.SentryProfileInfo? _profileInfo) => super.noSuchMethod( Invocation.setter( #profileInfo, _profileInfo, @@ -303,10 +320,10 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ) as bool); @override - List<_i3.SentrySpan> get children => (super.noSuchMethod( + List<_i2.SentrySpan> get children => (super.noSuchMethod( Invocation.getter(#children), - returnValue: <_i3.SentrySpan>[], - ) as List<_i3.SentrySpan>); + returnValue: <_i2.SentrySpan>[], + ) as List<_i2.SentrySpan>); @override set throwable(dynamic throwable) => super.noSuchMethod( @@ -318,7 +335,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ); @override - set status(_i3.SpanStatus? status) => super.noSuchMethod( + set status(_i2.SpanStatus? status) => super.noSuchMethod( Invocation.setter( #status, status, @@ -339,8 +356,8 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ) as Map); @override - _i8.Future finish({ - _i3.SpanStatus? status, + _i7.Future finish({ + _i2.SpanStatus? status, DateTime? endTimestamp, }) => (super.noSuchMethod( @@ -352,9 +369,9 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { #endTimestamp: endTimestamp, }, ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override void removeData(String? key) => super.noSuchMethod( @@ -436,7 +453,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { @override _i2.ISentrySpan startChildWithParentSpanId( - _i3.SpanId? parentSpanId, + _i2.SpanId? parentSpanId, String? operation, { String? description, DateTime? startTimestamp, @@ -470,7 +487,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { ) as _i2.ISentrySpan); @override - _i3.SentryTraceHeader toSentryTrace() => (super.noSuchMethod( + _i2.SentryTraceHeader toSentryTrace() => (super.noSuchMethod( Invocation.method( #toSentryTrace, [], @@ -482,7 +499,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { [], ), ), - ) as _i3.SentryTraceHeader); + ) as _i2.SentryTraceHeader); @override void setMeasurement( @@ -516,7 +533,7 @@ class MockSentryTracer extends _i1.Mock implements _i4.SentryTracer { /// /// See the documentation for Mockito's code generation for more information. // ignore: must_be_immutable -class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { +class MockSentryTransaction extends _i1.Mock implements _i2.SentryTransaction { MockSentryTransaction() { _i1.throwOnMissingStub(this); } @@ -540,13 +557,13 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ); @override - List<_i3.SentrySpan> get spans => (super.noSuchMethod( + List<_i2.SentrySpan> get spans => (super.noSuchMethod( Invocation.getter(#spans), - returnValue: <_i3.SentrySpan>[], - ) as List<_i3.SentrySpan>); + returnValue: <_i2.SentrySpan>[], + ) as List<_i2.SentrySpan>); @override - set spans(List<_i3.SentrySpan>? _spans) => super.noSuchMethod( + set spans(List<_i2.SentrySpan>? _spans) => super.noSuchMethod( Invocation.setter( #spans, _spans, @@ -555,13 +572,13 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ); @override - _i4.SentryTracer get tracer => (super.noSuchMethod( + _i3.SentryTracer get tracer => (super.noSuchMethod( Invocation.getter(#tracer), returnValue: _FakeSentryTracer_4( this, Invocation.getter(#tracer), ), - ) as _i4.SentryTracer); + ) as _i3.SentryTracer); @override Map get measurements => (super.noSuchMethod( @@ -580,7 +597,7 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ); @override - set metricSummaries(Map>? _metricSummaries) => + set metricSummaries(Map>? _metricSummaries) => super.noSuchMethod( Invocation.setter( #metricSummaries, @@ -590,7 +607,7 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ); @override - set transactionInfo(_i3.SentryTransactionInfo? _transactionInfo) => + set transactionInfo(_i2.SentryTransactionInfo? _transactionInfo) => super.noSuchMethod( Invocation.setter( #transactionInfo, @@ -612,22 +629,22 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ) as bool); @override - _i3.SentryId get eventId => (super.noSuchMethod( + _i2.SentryId get eventId => (super.noSuchMethod( Invocation.getter(#eventId), returnValue: _FakeSentryId_5( this, Invocation.getter(#eventId), ), - ) as _i3.SentryId); + ) as _i2.SentryId); @override - _i3.Contexts get contexts => (super.noSuchMethod( + _i2.Contexts get contexts => (super.noSuchMethod( Invocation.getter(#contexts), returnValue: _FakeContexts_6( this, Invocation.getter(#contexts), ), - ) as _i3.Contexts); + ) as _i2.Contexts); @override Map toJson() => (super.noSuchMethod( @@ -639,8 +656,8 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { ) as Map); @override - _i3.SentryTransaction copyWith({ - _i3.SentryId? eventId, + _i2.SentryTransaction copyWith({ + _i2.SentryId? eventId, DateTime? timestamp, String? platform, String? logger, @@ -649,26 +666,26 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { String? dist, String? environment, Map? modules, - _i3.SentryMessage? message, + _i2.SentryMessage? message, String? transaction, dynamic throwable, - _i3.SentryLevel? level, + _i2.SentryLevel? level, String? culprit, Map? tags, Map? extra, List? fingerprint, - _i3.SentryUser? user, - _i3.Contexts? contexts, - List<_i3.Breadcrumb>? breadcrumbs, - _i3.SdkVersion? sdk, - _i3.SentryRequest? request, - _i3.DebugMeta? debugMeta, - List<_i3.SentryException>? exceptions, - List<_i3.SentryThread>? threads, + _i2.SentryUser? user, + _i2.Contexts? contexts, + List<_i2.Breadcrumb>? breadcrumbs, + _i2.SdkVersion? sdk, + _i2.SentryRequest? request, + _i2.DebugMeta? debugMeta, + List<_i2.SentryException>? exceptions, + List<_i2.SentryThread>? threads, String? type, Map? measurements, - Map>? metricSummaries, - _i3.SentryTransactionInfo? transactionInfo, + Map>? metricSummaries, + _i2.SentryTransactionInfo? transactionInfo, }) => (super.noSuchMethod( Invocation.method( @@ -744,13 +761,13 @@ class MockSentryTransaction extends _i1.Mock implements _i3.SentryTransaction { }, ), ), - ) as _i3.SentryTransaction); + ) as _i2.SentryTransaction); } /// A class which mocks [SentrySpan]. /// /// See the documentation for Mockito's code generation for more information. -class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { +class MockSentrySpan extends _i1.Mock implements _i2.SentrySpan { MockSentrySpan() { _i1.throwOnMissingStub(this); } @@ -762,16 +779,16 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { ) as bool); @override - _i4.SentryTracer get tracer => (super.noSuchMethod( + _i3.SentryTracer get tracer => (super.noSuchMethod( Invocation.getter(#tracer), returnValue: _FakeSentryTracer_4( this, Invocation.getter(#tracer), ), - ) as _i4.SentryTracer); + ) as _i3.SentryTracer); @override - set status(_i3.SpanStatus? status) => super.noSuchMethod( + set status(_i2.SpanStatus? status) => super.noSuchMethod( Invocation.setter( #status, status, @@ -834,8 +851,8 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { ) as Map); @override - _i8.Future finish({ - _i3.SpanStatus? status, + _i7.Future finish({ + _i2.SpanStatus? status, DateTime? endTimestamp, }) => (super.noSuchMethod( @@ -847,9 +864,9 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { #endTimestamp: endTimestamp, }, ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override void removeData(String? key) => super.noSuchMethod( @@ -939,7 +956,7 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { ) as Map); @override - _i3.SentryTraceHeader toSentryTrace() => (super.noSuchMethod( + _i2.SentryTraceHeader toSentryTrace() => (super.noSuchMethod( Invocation.method( #toSentryTrace, [], @@ -951,7 +968,7 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { [], ), ), - ) as _i3.SentryTraceHeader); + ) as _i2.SentryTraceHeader); @override void setMeasurement( @@ -984,7 +1001,7 @@ class MockSentrySpan extends _i1.Mock implements _i3.SentrySpan { /// A class which mocks [MethodChannel]. /// /// See the documentation for Mockito's code generation for more information. -class MockMethodChannel extends _i1.Mock implements _i12.MethodChannel { +class MockMethodChannel extends _i1.Mock implements _i4.MethodChannel { MockMethodChannel() { _i1.throwOnMissingStub(this); } @@ -992,32 +1009,32 @@ class MockMethodChannel extends _i1.Mock implements _i12.MethodChannel { @override String get name => (super.noSuchMethod( Invocation.getter(#name), - returnValue: _i10.dummyValue( + returnValue: _i8.dummyValue( this, Invocation.getter(#name), ), ) as String); @override - _i5.MethodCodec get codec => (super.noSuchMethod( + _i4.MethodCodec get codec => (super.noSuchMethod( Invocation.getter(#codec), returnValue: _FakeMethodCodec_8( this, Invocation.getter(#codec), ), - ) as _i5.MethodCodec); + ) as _i4.MethodCodec); @override - _i6.BinaryMessenger get binaryMessenger => (super.noSuchMethod( + _i4.BinaryMessenger get binaryMessenger => (super.noSuchMethod( Invocation.getter(#binaryMessenger), returnValue: _FakeBinaryMessenger_9( this, Invocation.getter(#binaryMessenger), ), - ) as _i6.BinaryMessenger); + ) as _i4.BinaryMessenger); @override - _i8.Future invokeMethod( + _i7.Future invokeMethod( String? method, [ dynamic arguments, ]) => @@ -1029,11 +1046,11 @@ class MockMethodChannel extends _i1.Mock implements _i12.MethodChannel { arguments, ], ), - returnValue: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future?> invokeListMethod( + _i7.Future?> invokeListMethod( String? method, [ dynamic arguments, ]) => @@ -1045,11 +1062,11 @@ class MockMethodChannel extends _i1.Mock implements _i12.MethodChannel { arguments, ], ), - returnValue: _i8.Future?>.value(), - ) as _i8.Future?>); + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); @override - _i8.Future?> invokeMapMethod( + _i7.Future?> invokeMapMethod( String? method, [ dynamic arguments, ]) => @@ -1061,12 +1078,12 @@ class MockMethodChannel extends _i1.Mock implements _i12.MethodChannel { arguments, ], ), - returnValue: _i8.Future?>.value(), - ) as _i8.Future?>); + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); @override void setMethodCallHandler( - _i8.Future Function(_i5.MethodCall)? handler) => + _i7.Future Function(_i4.MethodCall)? handler) => super.noSuchMethod( Invocation.method( #setMethodCallHandler, @@ -1080,44 +1097,43 @@ class MockMethodChannel extends _i1.Mock implements _i12.MethodChannel { /// /// See the documentation for Mockito's code generation for more information. class MockSentryNativeBinding extends _i1.Mock - implements _i13.SentryNativeBinding { + implements _i10.SentryNativeBinding { MockSentryNativeBinding() { _i1.throwOnMissingStub(this); } @override - _i8.Future init(_i14.SentryFlutterOptions? options) => - (super.noSuchMethod( + _i7.Future init(_i2.Hub? hub) => (super.noSuchMethod( Invocation.method( #init, - [options], + [hub], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future close() => (super.noSuchMethod( + _i7.Future close() => (super.noSuchMethod( Invocation.method( #close, [], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future<_i15.NativeAppStart?> fetchNativeAppStart() => (super.noSuchMethod( + _i7.Future<_i11.NativeAppStart?> fetchNativeAppStart() => (super.noSuchMethod( Invocation.method( #fetchNativeAppStart, [], ), - returnValue: _i8.Future<_i15.NativeAppStart?>.value(), - ) as _i8.Future<_i15.NativeAppStart?>); + returnValue: _i7.Future<_i11.NativeAppStart?>.value(), + ) as _i7.Future<_i11.NativeAppStart?>); @override - _i8.Future captureEnvelope( - _i16.Uint8List? envelopeData, + _i7.Future captureEnvelope( + _i12.Uint8List? envelopeData, bool? containsUnhandledException, ) => (super.noSuchMethod( @@ -1128,72 +1144,72 @@ class MockSentryNativeBinding extends _i1.Mock containsUnhandledException, ], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future beginNativeFrames() => (super.noSuchMethod( + _i7.Future beginNativeFrames() => (super.noSuchMethod( Invocation.method( #beginNativeFrames, [], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future<_i17.NativeFrames?> endNativeFrames(_i3.SentryId? id) => + _i7.Future<_i13.NativeFrames?> endNativeFrames(_i2.SentryId? id) => (super.noSuchMethod( Invocation.method( #endNativeFrames, [id], ), - returnValue: _i8.Future<_i17.NativeFrames?>.value(), - ) as _i8.Future<_i17.NativeFrames?>); + returnValue: _i7.Future<_i13.NativeFrames?>.value(), + ) as _i7.Future<_i13.NativeFrames?>); @override - _i8.Future setUser(_i3.SentryUser? user) => (super.noSuchMethod( + _i7.Future setUser(_i2.SentryUser? user) => (super.noSuchMethod( Invocation.method( #setUser, [user], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future addBreadcrumb(_i3.Breadcrumb? breadcrumb) => + _i7.Future addBreadcrumb(_i2.Breadcrumb? breadcrumb) => (super.noSuchMethod( Invocation.method( #addBreadcrumb, [breadcrumb], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future clearBreadcrumbs() => (super.noSuchMethod( + _i7.Future clearBreadcrumbs() => (super.noSuchMethod( Invocation.method( #clearBreadcrumbs, [], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future?> loadContexts() => (super.noSuchMethod( + _i7.Future?> loadContexts() => (super.noSuchMethod( Invocation.method( #loadContexts, [], ), - returnValue: _i8.Future?>.value(), - ) as _i8.Future?>); + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); @override - _i8.Future setContexts( + _i7.Future setContexts( String? key, dynamic value, ) => @@ -1205,22 +1221,22 @@ class MockSentryNativeBinding extends _i1.Mock value, ], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future removeContexts(String? key) => (super.noSuchMethod( + _i7.Future removeContexts(String? key) => (super.noSuchMethod( Invocation.method( #removeContexts, [key], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future setExtra( + _i7.Future setExtra( String? key, dynamic value, ) => @@ -1232,22 +1248,22 @@ class MockSentryNativeBinding extends _i1.Mock value, ], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future removeExtra(String? key) => (super.noSuchMethod( + _i7.Future removeExtra(String? key) => (super.noSuchMethod( Invocation.method( #removeExtra, [key], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future setTag( + _i7.Future setTag( String? key, String? value, ) => @@ -1259,50 +1275,50 @@ class MockSentryNativeBinding extends _i1.Mock value, ], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future removeTag(String? key) => (super.noSuchMethod( + _i7.Future removeTag(String? key) => (super.noSuchMethod( Invocation.method( #removeTag, [key], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - int? startProfiler(_i3.SentryId? traceId) => + int? startProfiler(_i2.SentryId? traceId) => (super.noSuchMethod(Invocation.method( #startProfiler, [traceId], )) as int?); @override - _i8.Future discardProfiler(_i3.SentryId? traceId) => + _i7.Future discardProfiler(_i2.SentryId? traceId) => (super.noSuchMethod( Invocation.method( #discardProfiler, [traceId], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future displayRefreshRate() => (super.noSuchMethod( + _i7.Future displayRefreshRate() => (super.noSuchMethod( Invocation.method( #displayRefreshRate, [], ), - returnValue: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future?> collectProfile( - _i3.SentryId? traceId, + _i7.Future?> collectProfile( + _i2.SentryId? traceId, int? startTimeNs, int? endTimeNs, ) => @@ -1315,37 +1331,52 @@ class MockSentryNativeBinding extends _i1.Mock endTimeNs, ], ), - returnValue: _i8.Future?>.value(), - ) as _i8.Future?>); + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); @override - _i8.Future?> loadDebugImages() => (super.noSuchMethod( + _i7.Future?> loadDebugImages() => (super.noSuchMethod( Invocation.method( #loadDebugImages, [], ), - returnValue: _i8.Future?>.value(), - ) as _i8.Future?>); + returnValue: _i7.Future?>.value(), + ) as _i7.Future?>); @override - _i8.Future pauseAppHangTracking() => (super.noSuchMethod( + _i7.Future pauseAppHangTracking() => (super.noSuchMethod( Invocation.method( #pauseAppHangTracking, [], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future resumeAppHangTracking() => (super.noSuchMethod( + _i7.Future resumeAppHangTracking() => (super.noSuchMethod( Invocation.method( #resumeAppHangTracking, [], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); + + @override + _i7.Future<_i2.SentryId> captureReplay(bool? isCrash) => (super.noSuchMethod( + Invocation.method( + #captureReplay, + [isCrash], + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #captureReplay, + [isCrash], + ), + )), + ) as _i7.Future<_i2.SentryId>); } /// A class which mocks [Hub]. @@ -1366,13 +1397,13 @@ class MockHub extends _i1.Mock implements _i2.Hub { ) as _i2.SentryOptions); @override - _i7.MetricsApi get metricsApi => (super.noSuchMethod( + _i5.MetricsApi get metricsApi => (super.noSuchMethod( Invocation.getter(#metricsApi), returnValue: _FakeMetricsApi_11( this, Invocation.getter(#metricsApi), ), - ) as _i7.MetricsApi); + ) as _i5.MetricsApi); @override bool get isEnabled => (super.noSuchMethod( @@ -1381,13 +1412,13 @@ class MockHub extends _i1.Mock implements _i2.Hub { ) as bool); @override - _i3.SentryId get lastEventId => (super.noSuchMethod( + _i2.SentryId get lastEventId => (super.noSuchMethod( Invocation.getter(#lastEventId), returnValue: _FakeSentryId_5( this, Invocation.getter(#lastEventId), ), - ) as _i3.SentryId); + ) as _i2.SentryId); @override _i2.Scope get scope => (super.noSuchMethod( @@ -1399,7 +1430,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { ) as _i2.Scope); @override - set profilerFactory(_i11.SentryProfilerFactory? value) => super.noSuchMethod( + set profilerFactory(_i9.SentryProfilerFactory? value) => super.noSuchMethod( Invocation.setter( #profilerFactory, value, @@ -1408,8 +1439,8 @@ class MockHub extends _i1.Mock implements _i2.Hub { ); @override - _i8.Future<_i3.SentryId> captureEvent( - _i3.SentryEvent? event, { + _i7.Future<_i2.SentryId> captureEvent( + _i2.SentryEvent? event, { dynamic stackTrace, _i2.Hint? hint, _i2.ScopeCallback? withScope, @@ -1424,7 +1455,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #withScope: withScope, }, ), - returnValue: _i8.Future<_i3.SentryId>.value(_FakeSentryId_5( + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( #captureEvent, @@ -1436,10 +1467,10 @@ class MockHub extends _i1.Mock implements _i2.Hub { }, ), )), - ) as _i8.Future<_i3.SentryId>); + ) as _i7.Future<_i2.SentryId>); @override - _i8.Future<_i3.SentryId> captureException( + _i7.Future<_i2.SentryId> captureException( dynamic throwable, { dynamic stackTrace, _i2.Hint? hint, @@ -1455,7 +1486,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #withScope: withScope, }, ), - returnValue: _i8.Future<_i3.SentryId>.value(_FakeSentryId_5( + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( #captureException, @@ -1467,12 +1498,12 @@ class MockHub extends _i1.Mock implements _i2.Hub { }, ), )), - ) as _i8.Future<_i3.SentryId>); + ) as _i7.Future<_i2.SentryId>); @override - _i8.Future<_i3.SentryId> captureMessage( + _i7.Future<_i2.SentryId> captureMessage( String? message, { - _i3.SentryLevel? level, + _i2.SentryLevel? level, String? template, List? params, _i2.Hint? hint, @@ -1490,7 +1521,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #withScope: withScope, }, ), - returnValue: _i8.Future<_i3.SentryId>.value(_FakeSentryId_5( + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( #captureMessage, @@ -1504,22 +1535,22 @@ class MockHub extends _i1.Mock implements _i2.Hub { }, ), )), - ) as _i8.Future<_i3.SentryId>); + ) as _i7.Future<_i2.SentryId>); @override - _i8.Future captureUserFeedback(_i2.SentryUserFeedback? userFeedback) => + _i7.Future captureUserFeedback(_i2.SentryUserFeedback? userFeedback) => (super.noSuchMethod( Invocation.method( #captureUserFeedback, [userFeedback], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.Future addBreadcrumb( - _i3.Breadcrumb? crumb, { + _i7.Future addBreadcrumb( + _i2.Breadcrumb? crumb, { _i2.Hint? hint, }) => (super.noSuchMethod( @@ -1528,9 +1559,9 @@ class MockHub extends _i1.Mock implements _i2.Hub { [crumb], {#hint: hint}, ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override void bindClient(_i2.SentryClient? client) => super.noSuchMethod( @@ -1557,21 +1588,21 @@ class MockHub extends _i1.Mock implements _i2.Hub { ) as _i2.Hub); @override - _i8.Future close() => (super.noSuchMethod( + _i7.Future close() => (super.noSuchMethod( Invocation.method( #close, [], ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i7.Future.value(), + returnValueForMissingStub: _i7.Future.value(), + ) as _i7.Future); @override - _i8.FutureOr configureScope(_i2.ScopeCallback? callback) => + _i7.FutureOr configureScope(_i2.ScopeCallback? callback) => (super.noSuchMethod(Invocation.method( #configureScope, [callback], - )) as _i8.FutureOr); + )) as _i7.FutureOr); @override _i2.ISentrySpan startTransaction( @@ -1604,7 +1635,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #customSamplingContext: customSamplingContext, }, ), - returnValue: _i18.startTransactionShim( + returnValue: _i6.startTransactionShim( name, operation, description: description, @@ -1662,8 +1693,8 @@ class MockHub extends _i1.Mock implements _i2.Hub { ) as _i2.ISentrySpan); @override - _i8.Future<_i3.SentryId> captureTransaction( - _i3.SentryTransaction? transaction, { + _i7.Future<_i2.SentryId> captureTransaction( + _i2.SentryTransaction? transaction, { _i2.SentryTraceContextHeader? traceContext, }) => (super.noSuchMethod( @@ -1672,7 +1703,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { [transaction], {#traceContext: traceContext}, ), - returnValue: _i8.Future<_i3.SentryId>.value(_FakeSentryId_5( + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( #captureTransaction, @@ -1680,24 +1711,24 @@ class MockHub extends _i1.Mock implements _i2.Hub { {#traceContext: traceContext}, ), )), - ) as _i8.Future<_i3.SentryId>); + ) as _i7.Future<_i2.SentryId>); @override - _i8.Future<_i3.SentryId> captureMetrics( - Map>? metricsBuckets) => + _i7.Future<_i2.SentryId> captureMetrics( + Map>? metricsBuckets) => (super.noSuchMethod( Invocation.method( #captureMetrics, [metricsBuckets], ), - returnValue: _i8.Future<_i3.SentryId>.value(_FakeSentryId_5( + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( #captureMetrics, [metricsBuckets], ), )), - ) as _i8.Future<_i3.SentryId>); + ) as _i7.Future<_i2.SentryId>); @override void setSpanContext( diff --git a/flutter/test/replay/recorder_config_test.dart b/flutter/test/replay/recorder_config_test.dart new file mode 100644 index 0000000000..d884073e91 --- /dev/null +++ b/flutter/test/replay/recorder_config_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/recorder_config.dart'; + +void main() async { + group('$ScreenshotRecorderConfig', () { + test('defaults', () { + var sut = ScreenshotRecorderConfig(); + expect(sut.height, isNull); + expect(sut.width, isNull); + }); + + test('pixel ratio calculation', () { + expect(ScreenshotRecorderConfig().getPixelRatio(100, 100), 1.0); + expect( + ScreenshotRecorderConfig(width: 5, height: 10) + .getPixelRatio(100, 100), + 0.05); + expect( + ScreenshotRecorderConfig(width: 20, height: 10) + .getPixelRatio(100, 100), + 0.1); + }); + }); +} diff --git a/flutter/test/replay/recorder_test.dart b/flutter/test/replay/recorder_test.dart new file mode 100644 index 0000000000..16db1513b5 --- /dev/null +++ b/flutter/test/replay/recorder_test.dart @@ -0,0 +1,48 @@ +// For some reason, this test is not working in the browser but that's OK, we +// don't support video recording anyway. +@TestOn('vm') +library dart_test; + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/replay/recorder.dart'; +import 'package:sentry_flutter/src/replay/recorder_config.dart'; + +import '../mocks.dart'; +import 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('captures images', (tester) async { + final fixture = await _Fixture.create(tester); + expect(fixture.capture(), completion('800x600')); + }); +} + +class _Fixture { + late final ScreenshotRecorder sut; + + _Fixture._() { + sut = ScreenshotRecorder( + ScreenshotRecorderConfig(), + SentryFlutterOptions()..bindingUtils = TestBindingWrapper(), + ); + } + + static Future<_Fixture> create(WidgetTester tester) async { + final fixture = _Fixture._(); + await pumpTestElement(tester); + return fixture; + } + + Future capture() async { + String? captured; + await sut.capture((Image image) async { + captured = "${image.width}x${image.height}"; + }); + return captured; + } +} diff --git a/flutter/test/replay/replay_native_test.dart b/flutter/test/replay/replay_native_test.dart new file mode 100644 index 0000000000..319bb5f88f --- /dev/null +++ b/flutter/test/replay/replay_native_test.dart @@ -0,0 +1,230 @@ +// ignore_for_file: invalid_use_of_internal_member + +@TestOn('vm') +library flutter_test; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/event_processor/replay_event_processor.dart'; +import 'package:sentry_flutter/src/native/factory.dart'; +import 'package:sentry_flutter/src/native/sentry_native_binding.dart'; + +import '../mocks.dart'; +import '../mocks.mocks.dart'; +import 'test_widget.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + for (final mockPlatform in [ + MockPlatform.android(), + MockPlatform.iOs(), + ]) { + group('$SentryNativeBinding ($mockPlatform)', () { + late SentryNativeBinding sut; + late NativeChannelFixture native; + late SentryFlutterOptions options; + late MockHub hub; + late FileSystem fs; + late Directory replayDir; + late final Map replayConfig; + + if (mockPlatform.isIOS) { + replayConfig = { + 'replayId': '123', + 'directory': 'dir', + }; + } else if (mockPlatform.isAndroid) { + replayConfig = { + 'replayId': '123', + 'directory': 'dir', + 'width': 800, + 'height': 600, + 'frameRate': 1000, + }; + } + + setUp(() { + hub = MockHub(); + + fs = MemoryFileSystem.test(); + replayDir = fs.directory(replayConfig['directory']) + ..createSync(recursive: true); + + options = defaultTestOptions() + ..platformChecker = MockPlatformChecker(mockPlatform: mockPlatform) + ..fileSystem = fs; + + native = NativeChannelFixture(); + when(native.handler('initNativeSdk', any)) + .thenAnswer((_) => Future.value()); + when(native.handler('closeNativeSdk', any)) + .thenAnswer((_) => Future.value()); + + sut = createBinding(options, channel: native.channel); + }); + + tearDown(() async { + await sut.close(); + }); + + test('init sets $ReplayEventProcessor when error replay is enabled', + () async { + options.experimental.replay.errorSampleRate = 0.1; + await sut.init(hub); + + expect(options.eventProcessors.map((e) => e.runtimeType.toString()), + contains('$ReplayEventProcessor')); + }); + + test( + 'init does not set $ReplayEventProcessor when error replay is disabled', + () async { + await sut.init(hub); + + expect(options.eventProcessors.map((e) => e.runtimeType.toString()), + isNot(contains('$ReplayEventProcessor'))); + }); + + group('replay recorder', () { + setUp(() async { + options.experimental.replay.sessionSampleRate = 0.1; + options.experimental.replay.errorSampleRate = 0.1; + await sut.init(hub); + }); + + test('sets replay ID to context', () async { + // verify there was no scope configured before + verifyNever(hub.configureScope(any)); + + // emulate the native platform invoking the method + await native.invokeFromNative( + mockPlatform.isAndroid + ? 'ReplayRecorder.start' + : 'captureReplayScreenshot', + replayConfig); + + // verify the replay ID was set + final closure = + verify(hub.configureScope(captureAny)).captured.single; + final scope = Scope(options); + expect(scope.replayId, isNull); + await closure(scope); + expect(scope.replayId.toString(), replayConfig['replayId']); + }); + + test('clears replay ID from context', () async { + // verify there was no scope configured before + verifyNever(hub.configureScope(any)); + + // emulate the native platform invoking the method + await native.invokeFromNative('ReplayRecorder.stop'); + + // verify the replay ID was cleared + final closure = + verify(hub.configureScope(captureAny)).captured.single; + final scope = Scope(options); + scope.replayId = SentryId.newId(); + expect(scope.replayId, isNotNull); + await closure(scope); + expect(scope.replayId, isNull); + }, skip: mockPlatform.isIOS ? 'iOS does not clear replay ID' : false); + + testWidgets('captures images', (tester) async { + await tester.runAsync(() async { + if (mockPlatform.isAndroid) { + var callbackFinished = Completer(); + + nextFrame({bool wait = true}) async { + tester.binding.scheduleFrame(); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + await callbackFinished.future.timeout( + Duration(milliseconds: wait ? 1000 : 100), onTimeout: () { + if (wait) { + fail('native callback not called'); + } + }); + callbackFinished = Completer(); + } + + imageInfo(File file) => file.readAsBytesSync().length; + + fileToImageMap(Iterable files) => + {for (var file in files) file.path: imageInfo(file)}; + + final capturedImages = {}; + when(native.handler('addReplayScreenshot', any)) + .thenAnswer((invocation) async { + callbackFinished.complete(); + final path = + invocation.positionalArguments[1]["path"] as String; + capturedImages[path] = imageInfo(fs.file(path)); + return null; + }); + + fsImages() { + final files = replayDir.listSync().map((f) => f as File); + return fileToImageMap(files); + } + + await pumpTestElement(tester); + + await nextFrame(wait: false); + expect(fsImages(), isEmpty); + verifyNever(native.handler('addReplayScreenshot', any)); + + await native.invokeFromNative( + 'ReplayRecorder.start', replayConfig); + + await nextFrame(); + expect(fsImages().values, isNotEmpty); + final size = fsImages().values.first; + expect(size, greaterThan(3000)); + expect(fsImages().values, [size]); + expect(capturedImages, equals(fsImages())); + + await nextFrame(); + expect(fsImages().values, [size, size]); + expect(capturedImages, equals(fsImages())); + + await native.invokeFromNative('ReplayRecorder.stop'); + + await nextFrame(wait: false); + expect(fsImages().values, [size, size]); + expect(capturedImages, equals(fsImages())); + + await nextFrame(wait: false); + expect(fsImages().values, [size, size]); + expect(capturedImages, equals(fsImages())); + } else if (mockPlatform.isIOS) { + // configureScope() is called on iOS + when(hub.configureScope(captureAny)).thenReturn(null); + + nextFrame() async { + tester.binding.scheduleFrame(); + await Future.delayed(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(const Duration(seconds: 1)); + } + + await pumpTestElement(tester); + await nextFrame(); + + final imagaData = await native.invokeFromNative( + 'captureReplayScreenshot', replayConfig) as ByteData; + expect(imagaData.lengthInBytes, greaterThan(3000)); + } else { + fail('unsupported platform'); + } + }); + }, timeout: Timeout(Duration(seconds: 10))); + }); + }); + } +} diff --git a/flutter/test/replay/scheduled_recorder_test.dart b/flutter/test/replay/scheduled_recorder_test.dart new file mode 100644 index 0000000000..f859b27d53 --- /dev/null +++ b/flutter/test/replay/scheduled_recorder_test.dart @@ -0,0 +1,63 @@ +// For some reason, this test is not working in the browser but that's OK, we +// don't support video recording anyway. +@TestOn('vm') +library dart_test; + +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sentry_flutter/src/replay/scheduled_recorder.dart'; +import 'package:sentry_flutter/src/replay/recorder_config.dart'; + +import '../mocks.dart'; +import 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('captures images', (tester) async { + final fixture = await _Fixture.create(tester); + expect(fixture.capturedImages, isEmpty); + await fixture.nextFrame(); + expect(fixture.capturedImages, ['1000x750']); + await fixture.nextFrame(); + expect(fixture.capturedImages, ['1000x750', '1000x750']); + final stopFuture = fixture.sut.stop(); + await fixture.nextFrame(); + await stopFuture; + expect(fixture.capturedImages, ['1000x750', '1000x750']); + }); +} + +class _Fixture { + final WidgetTester _tester; + late final ScheduledScreenshotRecorder sut; + final capturedImages = []; + + _Fixture._(this._tester) { + sut = ScheduledScreenshotRecorder( + ScheduledScreenshotRecorderConfig( + width: 1000, + height: 1000, + frameRate: 1000, + ), + (Image image) async { + capturedImages.add("${image.width}x${image.height}"); + }, + SentryFlutterOptions()..bindingUtils = TestBindingWrapper(), + ); + } + + static Future<_Fixture> create(WidgetTester tester) async { + final fixture = _Fixture._(tester); + await pumpTestElement(tester); + fixture.sut.start(); + return fixture; + } + + Future nextFrame() async { + _tester.binding.scheduleFrame(); + await _tester.pumpAndSettle(const Duration(seconds: 1)); + } +} diff --git a/flutter/test/replay/scheduler_test.dart b/flutter/test/replay/scheduler_test.dart new file mode 100644 index 0000000000..c41260c854 --- /dev/null +++ b/flutter/test/replay/scheduler_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/scheduler.dart'; + +void main() { + test('does not trigger callback between frames', () async { + var fixture = _Fixture.started(); + + expect(fixture.calls, 0); + await Future.delayed(const Duration(milliseconds: 100), () {}); + expect(fixture.calls, 0); + }); + + test('triggers callback after a frame', () async { + var fixture = _Fixture(); + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.drawFrame(); + await fixture.drawFrame(); + await fixture.drawFrame(); + expect(fixture.calls, 4); + }); + + test('does not trigger when stopped', () async { + var fixture = _Fixture(); + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.drawFrame(); + await fixture.sut.stop(); + await fixture.drawFrame(); + expect(fixture.calls, 2); + }); + + test('triggers after a restart', () async { + var fixture = _Fixture(); + fixture.sut.start(); + + expect(fixture.calls, 0); + await fixture.drawFrame(); + expect(fixture.calls, 1); + await fixture.sut.stop(); + await fixture.drawFrame(); + expect(fixture.calls, 1); + fixture.sut.start(); + await fixture.drawFrame(); + expect(fixture.calls, 2); + }); +} + +class _Fixture { + var calls = 0; + late final Scheduler sut; + FrameCallback? registeredCallback; + var _frames = 0; + + _Fixture() { + sut = Scheduler( + const Duration(milliseconds: 1), + (_) async => calls++, + (FrameCallback callback, {String debugLabel = 'callback'}) { + registeredCallback = callback; + }, + ); + } + + factory _Fixture.started() { + return _Fixture()..sut.start(); + } + + Future drawFrame() async { + await Future.delayed(const Duration(milliseconds: 8), () {}); + _frames++; + registeredCallback!(Duration(milliseconds: _frames)); + registeredCallback = null; + } +} diff --git a/flutter/test/replay/test_widget.dart b/flutter/test/replay/test_widget.dart new file mode 100644 index 0000000000..e85dfacaf8 --- /dev/null +++ b/flutter/test/replay/test_widget.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +Future pumpTestElement(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: SentryWidget( + child: SingleChildScrollView( + child: Visibility( + visible: true, + 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()), + ], + ), + ), + ), + ), + ), + ), + ); + 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, + ); diff --git a/flutter/test/replay/widget_filter_test.dart b/flutter/test/replay/widget_filter_test.dart new file mode 100644 index 0000000000..3e17f2b5b6 --- /dev/null +++ b/flutter/test/replay/widget_filter_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/src/replay/widget_filter.dart'; + +import 'test_widget.dart'; + +void main() async { + TestWidgetsFlutterBinding.ensureInitialized(); + const defaultBounds = Rect.fromLTRB(0, 0, 1000, 1000); + + final createSut = + ({bool redactImages = false, bool redactText = false}) => WidgetFilter( + logger: (level, message, {exception, logger, stackTrace}) {}, + redactImages: redactImages, + redactText: redactText, + ); + + group('redact text', () { + testWidgets('redacts the correct number of elements', (tester) async { + final sut = createSut(redactText: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 2); + }); + + testWidgets('does not redact text when disabled', (tester) async { + final sut = createSut(redactText: false); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 0); + }); + + testWidgets('does not redact elements that are outside the screen', + (tester) async { + final sut = createSut(redactText: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, Rect.fromLTRB(0, 0, 100, 100)); + expect(sut.items.length, 1); + }); + }); + + group('redact images', () { + testWidgets('redacts the correct number of elements', (tester) async { + final sut = createSut(redactImages: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 2); + }); + + testWidgets('does not redact text when disabled', (tester) async { + final sut = createSut(redactImages: false); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, defaultBounds); + expect(sut.items.length, 0); + }); + + testWidgets('does not redact elements that are outside the screen', + (tester) async { + final sut = createSut(redactImages: true); + final element = await pumpTestElement(tester); + sut.obscure(element, 1.0, Rect.fromLTRB(0, 0, 500, 100)); + expect(sut.items.length, 1); + }); + }); +} diff --git a/flutter/test/screenshot/sentry_screenshot_widget_test.dart b/flutter/test/screenshot/sentry_screenshot_widget_test.dart index 57379387d0..0b6df7ffad 100644 --- a/flutter/test/screenshot/sentry_screenshot_widget_test.dart +++ b/flutter/test/screenshot/sentry_screenshot_widget_test.dart @@ -64,7 +64,6 @@ class Fixture { hub = Hub(_options); return SentryScreenshotWidget( - hub: hub, child: MaterialApp(home: MyApp()), ); } 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`