From 1bc4a8d78ef546b8ea010c018b07fb6f3a767466 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos <6349682+vaind@users.noreply.github.com> Date: Thu, 1 Aug 2024 15:05:51 +0200 Subject: [PATCH 1/8] Flutter replay for Android (#2032) * minor gradle fixes * tmp: local sentry-java build * tmp: use relative path to sentry-java * tmp: local java build patches * replay options * replay recorder * wip: JNI native bindings * use compatible jnigen * add missing gradlew to flutter/android * replay recorder JNI binding code * replay recorder binding jni code * jni 0.6 * wip: android jni replay * replay binding * glue code for jni * chore: update to cocoa 8.24.1-alpha.0 * wip: cocoa integration * wip: ios replay * cleanup * formatting * android fixes * move native setup to the native sdk integration * cleanup & improvements * improve widget filter and implement redact options * fix image scaling * ktlint format * ci fixes * fix tests * add jnigen scripts * use android 7.9.0 alpha.1 * move native init & close to SentryNative * cleanup * add macOS integration link * rollback cocoa changes * remove jni/jnigen * wip: methodchannel based android recorder * callback * linter issues * minor fixes * more fixes * linter issues * cleanup * improve logging * move replay to experimental, same as in other SDKs * improve tree shaking * test: scheduler * support browser test * fix compat with old flutter * cleanup * rename recorder_widget_filter.dart * fixup scheduler test * improve test coverage * pr cleanup * test: widget filter * cleanup * test widget filter visibility * cleanup * always add screenshot widget * recorder test * cleanup * limit recorder test to vm * wip: integration test * cleanup * ktlint format * detekt suppression * ktlint format * improve scheduler stop behavior * wip: error replay mapping * suppress detekt TooGenericExceptionThrown * Update flutter/lib/src/replay/recorder.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/native/java/sentry_native_java.dart Co-authored-by: Giancarlo Buenaflor * improve comments * feat: associate dart errors with replays (#2070) * feat: associate dart errors with replays * ktlint * cleanup * tests * chote: remove path dependency * fix tests * feat: replay breadcrumbs (android) (#2163) * feat: replay breadcrumbs * ktlint format * fixup tests * cleanup * linter issues * detekt linter issue * move touch path build to dart to deduplicate * fix metrics app compilation * linter issue * test: native replay integration binding (#2189) * wip: test native integration * test: native replay binding * update example * chore: update pubspec * fixup tests * Update flutter/test/mocks.dart * chore: update changelog * fix publishing * release: 8.6.0-alpha.2 --------- Co-authored-by: Giancarlo Buenaflor Co-authored-by: getsentry-bot Co-authored-by: getsentry-bot --- CHANGELOG.md | 25 +- dart/lib/src/protocol/breadcrumb.dart | 30 ++ .../src/protocol/sentry_trace_context.dart | 17 +- dart/lib/src/scope.dart | 11 +- dart/lib/src/sentry_baggage.dart | 10 + dart/lib/src/sentry_client.dart | 10 +- dart/lib/src/sentry_trace_context_header.dart | 13 + dart/lib/src/version.dart | 2 +- dart/pubspec.yaml | 2 +- dart/test/protocol/breadcrumb_test.dart | 33 +- .../protocol/sentry_baggage_header_test.dart | 16 +- dart/test/scope_test.dart | 19 +- dart/test/sentry_client_test.dart | 9 +- .../sentry_trace_context_header_test.dart | 19 +- dart/test/sentry_trace_context_test.dart | 21 +- dio/lib/src/version.dart | 2 +- dio/pubspec.yaml | 4 +- drift/lib/src/version.dart | 2 +- drift/pubspec.yaml | 4 +- file/lib/src/version.dart | 2 +- file/pubspec.yaml | 4 +- .../kotlin/io/sentry/flutter/SentryFlutter.kt | 13 + .../io/sentry/flutter/SentryFlutterPlugin.kt | 143 ++++- .../SentryFlutterReplayBreadcrumbConverter.kt | 86 +++ .../flutter/SentryFlutterReplayRecorder.kt | 72 +++ .../io/sentry/flutter/SentryFlutterTest.kt | 15 + flutter/example/android/app/build.gradle | 2 - flutter/example/integration_test/all.dart | 2 + .../example/integration_test/replay_test.dart | 39 ++ flutter/example/lib/main.dart | 3 + flutter/example/pubspec.yaml | 2 +- flutter/lib/sentry_flutter.dart | 1 + .../replay_event_processor.dart | 22 + .../integrations/native_sdk_integration.dart | 2 +- .../src/native/java/sentry_native_java.dart | 116 ++++ .../lib/src/native/sentry_native_binding.dart | 4 +- .../lib/src/native/sentry_native_channel.dart | 64 ++- .../native/sentry_safe_method_channel.dart | 4 + flutter/lib/src/replay/recorder.dart | 140 +++++ flutter/lib/src/replay/recorder_config.dart | 11 + flutter/lib/src/replay/scheduler.dart | 55 ++ flutter/lib/src/replay/widget_filter.dart | 133 +++++ .../screenshot/sentry_screenshot_widget.dart | 29 +- flutter/lib/src/sentry_flutter_options.dart | 29 +- flutter/lib/src/sentry_replay_options.dart | 40 ++ flutter/lib/src/version.dart | 2 +- flutter/pubspec.yaml | 5 +- .../screenshot_event_processor_test.dart | 1 - .../integrations/init_native_sdk_test.dart | 17 +- .../native_sdk_integration_test.dart | 2 +- flutter/test/mocks.dart | 29 + flutter/test/mocks.mocks.dart | 507 ++++++++++-------- flutter/test/replay/recorder_test.dart | 63 +++ flutter/test/replay/replay_native_test.dart | 193 +++++++ flutter/test/replay/scheduler_test.dart | 82 +++ flutter/test/replay/test_widget.dart | 59 ++ flutter/test/replay/widget_filter_test.dart | 65 +++ .../sentry_screenshot_widget_test.dart | 1 - hive/lib/src/version.dart | 2 +- hive/pubspec.yaml | 4 +- isar/lib/src/version.dart | 2 +- isar/pubspec.yaml | 4 +- logging/lib/src/version.dart | 2 +- logging/pubspec.yaml | 4 +- sqflite/lib/src/version.dart | 2 +- sqflite/pubspec.yaml | 4 +- 66 files changed, 1948 insertions(+), 384 deletions(-) create mode 100644 flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayBreadcrumbConverter.kt create mode 100644 flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterReplayRecorder.kt create mode 100644 flutter/example/integration_test/replay_test.dart create mode 100644 flutter/lib/src/event_processor/replay_event_processor.dart create mode 100644 flutter/lib/src/replay/recorder.dart create mode 100644 flutter/lib/src/replay/recorder_config.dart create mode 100644 flutter/lib/src/replay/scheduler.dart create mode 100644 flutter/lib/src/replay/widget_filter.dart create mode 100644 flutter/lib/src/sentry_replay_options.dart create mode 100644 flutter/test/replay/recorder_test.dart create mode 100644 flutter/test/replay/replay_native_test.dart create mode 100644 flutter/test/replay/scheduler_test.dart create mode 100644 flutter/test/replay/test_widget.dart create mode 100644 flutter/test/replay/widget_filter_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 086de1bd8f..59232c675a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 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/) + ## Unreleased ### Improvements @@ -50,11 +71,11 @@ SentryFlutter.init((options) => - This allows viewing the correct dart formatted raw stacktrace in the Sentry UI - Support `ignoredExceptionsForType` ([#2150](https://github.com/getsentry/sentry-dart/pull/2150)) - Filter out exception types by calling `SentryOptions.addExceptionFilterForType(Type exceptionType)` - + ### 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 f7eea55358..c1c283d8f2 100644 --- a/dart/lib/src/protocol/breadcrumb.dart +++ b/dart/lib/src/protocol/breadcrumb.dart @@ -50,6 +50,10 @@ class Breadcrumb { String? httpQuery, String? httpFragment, }) { + // The timestamp is used as the request-end time, so we need to set it right + // now and not rely on the default constructor. + timestamp ??= getUtcDateTime(); + return Breadcrumb( type: 'http', category: 'http', @@ -65,6 +69,11 @@ class Breadcrumb { if (responseBodySize != null) 'response_body_size': responseBodySize, if (httpQuery != null) 'http.query': httpQuery, if (httpFragment != null) 'http.fragment': httpFragment, + if (requestDuration != null) + 'start_timestamp': + timestamp.millisecondsSinceEpoch - requestDuration.inMilliseconds, + if (requestDuration != null) + 'end_timestamp': timestamp.millisecondsSinceEpoch, }, ); } @@ -95,11 +104,32 @@ class Breadcrumb { String? viewClass, }) { final newData = data ?? {}; + var path = ''; + if (viewId != null) { newData['view.id'] = viewId; + path = viewId; + } + + if (newData.containsKey('label')) { + if (path.isEmpty) { + path = newData['label']; + } else { + path = "$path, label: ${newData['label']}"; + } } + if (viewClass != null) { newData['view.class'] = viewClass; + if (path.isEmpty) { + path = viewClass; + } else { + path = "$viewClass($path)"; + } + } + + if (path.isNotEmpty && !newData.containsKey('path')) { + newData['path'] = path; } return Breadcrumb( diff --git a/dart/lib/src/protocol/sentry_trace_context.dart b/dart/lib/src/protocol/sentry_trace_context.dart index 25c4ca7ad8..2a9c3bb2fc 100644 --- a/dart/lib/src/protocol/sentry_trace_context.dart +++ b/dart/lib/src/protocol/sentry_trace_context.dart @@ -17,6 +17,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; @@ -45,6 +48,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 @@ -61,6 +67,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, @@ -76,6 +83,7 @@ class SentryTraceContext { parentSpanId: parentSpanId, sampled: sampled, origin: origin, + replayId: replayId, ); SentryTraceContext({ @@ -87,6 +95,7 @@ class SentryTraceContext { this.description, this.status, this.origin, + this.replayId, }) : traceId = traceId ?? SentryId.newId(), spanId = spanId ?? SpanId.newId(); @@ -94,9 +103,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 c319677832..de7ee5bea1 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -157,15 +157,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( diff --git a/dart/lib/src/sentry_trace_context_header.dart b/dart/lib/src/sentry_trace_context_header.dart index d1ee5368af..9b34ef72f5 100644 --- a/dart/lib/src/sentry_trace_context_header.dart +++ b/dart/lib/src/sentry_trace_context_header.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + import 'protocol/sentry_id.dart'; import 'sentry_baggage.dart'; import 'sentry_options.dart'; @@ -13,6 +15,7 @@ class SentryTraceContextHeader { this.transaction, this.sampleRate, this.sampled, + this.replayId, }); final SentryId traceId; @@ -27,6 +30,9 @@ class SentryTraceContextHeader { final String? sampleRate; final String? sampled; + @internal + SentryId? replayId; + /// Deserializes a [SentryTraceContextHeader] from JSON [Map]. factory SentryTraceContextHeader.fromJson(Map json) { return SentryTraceContextHeader( @@ -39,6 +45,8 @@ class SentryTraceContextHeader { transaction: json['transaction'], sampleRate: json['sample_rate'], sampled: json['sampled'], + replayId: + json['replay_id'] == null ? null : SentryId.fromId(json['replay_id']), ); } @@ -55,6 +63,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(), }; } @@ -88,6 +97,9 @@ class SentryTraceContextHeader { if (sampled != null) { baggage.setSampled(sampled!); } + if (replayId != null) { + baggage.setReplayId(replayId.toString()); + } return baggage; } @@ -97,6 +109,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/version.dart b/dart/lib/src/version.dart index 2d001beca0..86a36f11e0 100644 --- a/dart/lib/src/version.dart +++ b/dart/lib/src/version.dart @@ -9,7 +9,7 @@ library version; /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.5.0'; +const String sdkVersion = '8.6.0-alpha.2'; String sdkName(bool isWeb) => isWeb ? _browserSdkName : _ioSdkName; diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index 8736674d03..38cf1f0c5d 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry -version: 8.5.0 +version: 8.6.0-alpha.2 description: > A crash reporting library for Dart that sends crash reports to Sentry.io. This library supports Dart VM and Web. For Flutter consider sentry_flutter instead. diff --git a/dart/test/protocol/breadcrumb_test.dart b/dart/test/protocol/breadcrumb_test.dart index ddd4008d98..e7591ab73d 100644 --- a/dart/test/protocol/breadcrumb_test.dart +++ b/dart/test/protocol/breadcrumb_test.dart @@ -86,7 +86,7 @@ void main() { level: SentryLevel.fatal, reason: 'OK', statusCode: 200, - requestDuration: Duration.zero, + requestDuration: Duration(milliseconds: 55), timestamp: DateTime.now(), requestBodySize: 2, responseBodySize: 3, @@ -103,17 +103,43 @@ void main() { 'method': 'GET', 'status_code': 200, 'reason': 'OK', - 'duration': '0:00:00.000000', + 'duration': '0:00:00.055000', 'request_body_size': 2, 'response_body_size': 3, 'http.query': 'foo=bar', - 'http.fragment': 'baz' + 'http.fragment': 'baz', + 'start_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch - 55, + 'end_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch }, 'level': 'fatal', 'type': 'http', }); }); + test('Breadcrumb http', () { + final breadcrumb = Breadcrumb.http( + url: Uri.parse('https://example.org'), + method: 'GET', + requestDuration: Duration(milliseconds: 10), + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'timestamp': + formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), + 'category': 'http', + 'data': { + 'url': 'https://example.org', + 'method': 'GET', + 'duration': '0:00:00.010000', + 'start_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch - 10, + 'end_timestamp': breadcrumb.timestamp.millisecondsSinceEpoch + }, + 'level': 'info', + 'type': 'http', + }); + }); + test('Minimal Breadcrumb http', () { final breadcrumb = Breadcrumb.http( url: Uri.parse('https://example.org'), @@ -192,6 +218,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 5a2945a6ec..f475aa11ed 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')); }); }); @@ -1361,6 +1364,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()); @@ -1368,6 +1372,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 { @@ -1424,12 +1429,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 c4f856f344..f4aaa4620a 100644 --- a/dart/test/sentry_trace_context_header_test.dart +++ b/dart/test/sentry_trace_context_header_test.dart @@ -14,7 +14,8 @@ void main() { 'user_segment': 'user_segment', 'transaction': 'transaction', 'sample_rate': '1.0', - 'sampled': 'false' + 'sampled': 'false', + 'replay_id': '456', }; final context = SentryTraceContextHeader.fromJson(mapJson); @@ -29,6 +30,7 @@ void main() { expect(context.transaction, 'transaction'); expect(context.sampleRate, '1.0'); expect(context.sampled, 'false'); + expect(context.replayId, SentryId.fromId('456')); }); test('toJson', () { @@ -40,8 +42,19 @@ void main() { test('to baggage', () { final baggage = context.toBaggage(); - expect(baggage.toHeaderString(), - 'sentry-trace_id=${id.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=${id.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 dde599bef1..13dbe1fd62 100644 --- a/dart/test/sentry_trace_context_test.dart +++ b/dart/test/sentry_trace_context_test.dart @@ -16,27 +16,31 @@ 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' }; 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'); }); } @@ -48,6 +52,7 @@ class Fixture { description: 'desc', sampled: true, status: SpanStatus.aborted(), - origin: 'auto.ui'); + origin: 'auto.ui', + replayId: SentryId.newId()); } } diff --git a/dio/lib/src/version.dart b/dio/lib/src/version.dart index 93bceee223..6786fb4b51 100644 --- a/dio/lib/src/version.dart +++ b/dio/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.5.0'; +const String sdkVersion = '8.6.0-alpha.2'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_dio'; diff --git a/dio/pubspec.yaml b/dio/pubspec.yaml index e404ce6782..930c50b063 100644 --- a/dio/pubspec.yaml +++ b/dio/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_dio description: An integration which adds support for performance tracing for the Dio package. -version: 8.5.0 +version: 8.6.0-alpha.2 homepage: https://docs.sentry.io/platforms/dart/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -19,7 +19,7 @@ platforms: dependencies: dio: ^5.0.0 - sentry: 8.5.0 + sentry: 8.6.0-alpha.2 dev_dependencies: meta: ^1.3.0 diff --git a/drift/lib/src/version.dart b/drift/lib/src/version.dart index e41dcdb245..5ec909cade 100644 --- a/drift/lib/src/version.dart +++ b/drift/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.5.0'; +const String sdkVersion = '8.6.0-alpha.2'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_drift'; diff --git a/drift/pubspec.yaml b/drift/pubspec.yaml index ea5e3c962b..5da0c461a0 100644 --- a/drift/pubspec.yaml +++ b/drift/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_drift description: An integration which adds support for performance tracing for the drift package. -version: 8.5.0 +version: 8.6.0-alpha.2 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -17,7 +17,7 @@ platforms: web: dependencies: - sentry: 8.5.0 + sentry: 8.6.0-alpha.2 meta: ^1.3.0 drift: ^2.13.0 diff --git a/file/lib/src/version.dart b/file/lib/src/version.dart index f2698728d4..aca40166f4 100644 --- a/file/lib/src/version.dart +++ b/file/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.5.0'; +const String sdkVersion = '8.6.0-alpha.2'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_file'; diff --git a/file/pubspec.yaml b/file/pubspec.yaml index b845c8fdb8..048c565fa4 100644 --- a/file/pubspec.yaml +++ b/file/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_file description: An integration which adds support for performance tracing for dart.io.File. -version: 8.5.0 +version: 8.6.0-alpha.2 homepage: https://docs.sentry.io/platforms/dart/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -17,7 +17,7 @@ platforms: windows: dependencies: - sentry: 8.5.0 + sentry: 8.6.0-alpha.2 meta: ^1.3.0 dev_dependencies: 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 c06a8b0dc2..fb17c3af0f 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutter.kt @@ -1,6 +1,7 @@ package io.sentry.flutter import io.sentry.SentryLevel +import io.sentry.SentryReplayOptions import io.sentry.android.core.BuildConfig import io.sentry.android.core.SentryAndroidOptions import io.sentry.protocol.SdkVersion @@ -119,6 +120,18 @@ class SentryFlutter( data.getIfNotNull("readTimeoutMillis") { options.readTimeoutMillis = it } + + 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 4f154a2465..f4715dfb4e 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -25,16 +25,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 @@ -54,7 +58,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) @@ -74,6 +82,8 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { "removeTag" -> removeTag(call.argument("key"), result) "loadContexts" -> loadContexts(result) "displayRefreshRate" -> displayRefreshRate(result) + "addReplayScreenshot" -> addReplayScreenshot(call.argument("path"), call.argument("timestamp"), result) + "sendReplayForEvent" -> sendReplayForEvent(call.argument("eventId"), call.argument("isCrash"), result) else -> result.notImplemented() } } @@ -103,7 +113,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 @@ -123,6 +136,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("") } @@ -145,6 +179,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } else { val appStartTimeMillis = DateUtils.nanosToMillis(appStartTime.nanoTimestamp().toDouble()) val item = + mutableMapOf( "pluginRegistrationTime" to pluginRegistrationTime, "appStartTime" to appStartTimeMillis, @@ -228,7 +263,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) { @@ -248,16 +286,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 @@ -269,7 +312,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 @@ -281,7 +327,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) @@ -292,7 +341,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) @@ -307,7 +359,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 @@ -317,7 +373,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 @@ -327,7 +386,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 @@ -337,7 +400,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 @@ -347,7 +413,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 @@ -356,7 +425,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { if (args.isNotEmpty()) { val event = args.first() as ByteArray? val containsUnhandledException = args[1] as Boolean - if (event != null && event.isNotEmpty() && containsUnhandledException != null) { + if (event != null && event.isNotEmpty()) { val id = InternalSentrySdk.captureEnvelope(event, containsUnhandledException) if (id != null) { result.success("") @@ -405,7 +474,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 @@ -413,10 +485,10 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } companion object { - private const val flutterSdk = "sentry.dart.flutter" private const val androidSdk = "sentry.java.android.flutter" private const val nativeSdk = "sentry.native.android.flutter" + private fun setEventOriginTag(event: SentryEvent) { event.sdk?.let { when (it.name) { @@ -437,7 +509,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 == flutterSdk) { sdk?.packageSet?.forEach { sentryPackage -> @@ -466,4 +541,30 @@ 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 sendReplayForEvent( + eventId: String?, + isCrash: Boolean?, + result: Result, + ) { + if (eventId == null || isCrash == null) { + result.error("5", "Arguments are null", null) + return + } + replay.sendReplay(isCrash, eventId, null) + 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 724559bb76..50e6e125c9 100644 --- a/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt +++ b/flutter/android/src/test/kotlin/io/sentry/flutter/SentryFlutterTest.kt @@ -60,6 +60,16 @@ class SentryFlutterTest { assertEquals(9006, fixture.options.connectionTimeoutMillis) assertEquals(9007, fixture.options.readTimeoutMillis) + + 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 @@ -127,6 +137,11 @@ class Fixture { "enableAutoPerformanceTracing" to true, "connectionTimeoutMillis" to 9006, "readTimeoutMillis" to 9007, + "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/integration_test/all.dart b/flutter/example/integration_test/all.dart index 69cc5a6641..77a4b2923d 100644 --- a/flutter/example/integration_test/all.dart +++ b/flutter/example/integration_test/all.dart @@ -1,8 +1,10 @@ // Workaround for https://github.com/flutter/flutter/issues/101031 import 'integration_test.dart' as a; import 'profiling_test.dart' as b; +import 'replay_test.dart' as c; void main() { a.main(); b.main(); + c.main(); } diff --git a/flutter/example/integration_test/replay_test.dart b/flutter/example/integration_test/replay_test.dart new file mode 100644 index 0000000000..00d85ca14f --- /dev/null +++ b/flutter/example/integration_test/replay_test.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; + +void main() { + group('Replay recording', () { + setUp(() async { + await SentryFlutter.init((options) { + // ignore: invalid_use_of_internal_member + options.automatedTestMode = true; + options.dsn = 'https://abc@def.ingest.sentry.io/1234567'; + options.debug = true; + options.experimental.replay.sessionSampleRate = 1.0; + }); + }); + + tearDown(() async { + await Sentry.close(); + }); + + test('native binding is initialized', () async { + // ignore: invalid_use_of_internal_member + expect(SentryFlutter.native, isNotNull); + }); + + test('session replay is captured', () async { + // TODO add when the beforeSend callback is implemented for replays. + }); + + test('replay is captured on errors', () async { + // TODO we may need an HTTP server for this because Android sends replays + // in a separate envelope. + }); + }, + skip: Platform.isAndroid + ? false + : "Replay recording is not supported on this platform"); +} diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 784a6b30df..f31e5487c5 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -90,6 +90,9 @@ Future 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/example/pubspec.yaml b/flutter/example/pubspec.yaml index cf93f4f038..1f5fa20cff 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_flutter_example description: Demonstrates how to use the sentry_flutter plugin. -version: 8.5.0 +version: 8.6.0-alpha.2 publish_to: 'none' # Remove this line if you wish to publish to pub.dev 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..4be68a4d00 --- /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.sendReplayForEvent(event.eventId, isCrash); + } + return event; + } +} 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/java/sentry_native_java.dart b/flutter/lib/src/native/java/sentry_native_java.dart index 1b0ef13cc5..30c157e3ad 100644 --- a/flutter/lib/src/native/java/sentry_native_java.dart +++ b/flutter/lib/src/native/java/sentry_native_java.dart @@ -1,10 +1,126 @@ +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'; // 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 { + ScreenshotRecorder? _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, + ScreenshotRecorderConfig( + 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, ScreenshotRecorderConfig 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 = ScreenshotRecorder(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 002790fc32..15769c97d3 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(); @@ -57,4 +57,6 @@ abstract class SentryNativeBinding { Future pauseAppHangTracking(); Future resumeAppHangTracking(); + + Future sendReplayForEvent(SentryId eventId, bool isCrash); } diff --git a/flutter/lib/src/native/sentry_native_channel.dart b/flutter/lib/src/native/sentry_native_channel.dart index 0a3b97820d..20d0794f31 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, @@ -66,37 +66,40 @@ class SentryNativeChannel 'readTimeoutMillis': options.readTimeout.inMilliseconds, 'appHangTimeoutIntervalMillis': options.appHangTimeoutInterval.inMilliseconds, + '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; } @@ -106,7 +109,7 @@ class SentryNativeChannel final normalizedUser = user?.copyWith( data: MethodChannelHelper.normalizeMap(user.data), ); - await _channel.invokeMethod( + await channel.invokeMethod( 'setUser', {'user': normalizedUser?.toJson()}, ); @@ -117,42 +120,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) => @@ -160,12 +163,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, @@ -174,7 +177,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()) @@ -184,13 +187,20 @@ 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 sendReplayForEvent(SentryId eventId, bool isCrash) => + channel.invokeMethod('sendReplayForEvent', { + 'eventId': eventId.toString(), + '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..e17167f87f --- /dev/null +++ b/flutter/lib/src/replay/recorder.dart @@ -0,0 +1,140 @@ +import 'dart:async'; +import 'dart:math'; +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'; +import 'scheduler.dart'; + +@internal +typedef ScreenshotRecorderCallback = Future Function(Image); + +@internal +class ScreenshotRecorder { + final ScreenshotRecorderConfig _config; + final ScreenshotRecorderCallback _callback; + final SentryLogger _logger; + final SentryReplayOptions _options; + final bool rethrowExceptions; + WidgetFilter? _widgetFilter; + late final Scheduler _scheduler; + bool warningLogged = false; + + ScreenshotRecorder(this._config, this._callback, SentryFlutterOptions options) + : _logger = options.logger, + _options = options.experimental.replay, + // ignore: invalid_use_of_internal_member + rethrowExceptions = options.automatedTestMode { + final frameDuration = Duration(milliseconds: 1000 ~/ _config.frameRate); + _scheduler = Scheduler(frameDuration, _capture, + options.bindingUtils.instance!.addPostFrameCallback); + + if (_options.redactAllText || _options.redactAllImages) { + _widgetFilter = WidgetFilter( + redactText: _options.redactAllText, + redactImages: _options.redactAllImages, + logger: _logger); + } + } + + void start() { + _logger(SentryLevel.debug, "Replay: starting replay capture."); + _scheduler.start(); + } + + Future stop() async { + await _scheduler.stop(); + _logger(SentryLevel.debug, "Replay: replay capture stopped."); + } + + Future _capture(Duration sinceSchedulerEpoch) async { + final context = sentryScreenshotWidgetGlobalKey.currentContext; + final renderObject = context?.findRenderObject() as RenderRepaintBoundary?; + if (context == null || renderObject == null) { + if (!warningLogged) { + _logger( + SentryLevel.warning, + "Replay: SentryScreenshotWidget is not attached. " + "Skipping replay capture."); + warningLogged = true; + } + return; + } + + try { + final watch = Stopwatch()..start(); + + // The desired resolution (coming from the configuration) is usually + // rounded to next multitude of 16. Therefore, we scale the image. + final srcWidth = renderObject.size.width; + final srcHeight = renderObject.size.height; + final pixelRatio = + min(_config.width / srcWidth, _config.height / 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(); + } + + _logger( + SentryLevel.debug, + "Replay: captured a screenshot in ${watch.elapsedMilliseconds}" + " ms ($blockingTime ms blocking)."); + } catch (e, stackTrace) { + _logger(SentryLevel.error, "Replay: failed to capture screenshot.", + exception: e, stackTrace: stackTrace); + if (rethrowExceptions) { + 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..b7e4fd4c86 --- /dev/null +++ b/flutter/lib/src/replay/recorder_config.dart @@ -0,0 +1,11 @@ +import 'package:meta/meta.dart'; + +@internal +class ScreenshotRecorderConfig { + final int width; + final int height; + final int frameRate; + + ScreenshotRecorderConfig( + {required this.width, required this.height, required this.frameRate}); +} 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_options.dart b/flutter/lib/src/sentry_flutter_options.dart index 347da9ada3..8b6f7ed491 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. @@ -203,14 +206,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 +305,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 +322,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 +330,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/version.dart b/flutter/lib/src/version.dart index bc3ded86e6..c33b9752a2 100644 --- a/flutter/lib/src/version.dart +++ b/flutter/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.5.0'; +const String sdkVersion = '8.6.0-alpha.2'; /// The default SDK name reported to Sentry.io in the submitted events. const String sdkName = 'sentry.dart.flutter'; diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 926c440812..3549da422d 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry_flutter -version: 8.5.0 +version: 8.6.0-alpha.2 description: Sentry SDK for Flutter. This package aims to support different Flutter targets by relying on the many platforms supported by Sentry with native SDKs. homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart @@ -23,10 +23,11 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - sentry: 8.5.0 + sentry: 8.6.0-alpha.2 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/integrations/init_native_sdk_test.dart b/flutter/test/integrations/init_native_sdk_test.dart index 0dcf3af502..b33f852de0 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, + }, }); }); @@ -104,12 +109,14 @@ void main() { ..enableAppHangTracking = false ..connectionTimeout = Duration(milliseconds: 9001) ..readTimeout = Duration(milliseconds: 9002) - ..appHangTimeoutInterval = Duration(milliseconds: 9003); + ..appHangTimeoutInterval = Duration(milliseconds: 9003) + ..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); @@ -149,6 +156,10 @@ void main() { 'connectionTimeoutMillis': 9001, 'readTimeoutMillis': 9002, 'appHangTimeoutIntervalMillis': 9003, + '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..4e59f6d389 100644 --- a/flutter/test/mocks.dart +++ b/flutter/test/mocks.dart @@ -206,3 +206,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..3632555cc7 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,62 @@ 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> sendReplayForEvent( + _i2.SentryId? eventId, + bool? isCrash, + ) => + (super.noSuchMethod( + Invocation.method( + #sendReplayForEvent, + [ + eventId, + isCrash, + ], + ), + returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( + this, + Invocation.method( + #sendReplayForEvent, + [ + eventId, + isCrash, + ], + ), + )), + ) as _i7.Future<_i2.SentryId>); } /// A class which mocks [Hub]. @@ -1366,13 +1407,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 +1422,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 +1440,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 +1449,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 +1465,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 +1477,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 +1496,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 +1508,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 +1531,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 +1545,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 +1569,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 +1598,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 +1645,7 @@ class MockHub extends _i1.Mock implements _i2.Hub { #customSamplingContext: customSamplingContext, }, ), - returnValue: _i18.startTransactionShim( + returnValue: _i6.startTransactionShim( name, operation, description: description, @@ -1662,8 +1703,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 +1713,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 +1721,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_test.dart b/flutter/test/replay/recorder_test.dart new file mode 100644 index 0000000000..99176c4c89 --- /dev/null +++ b/flutter/test/replay/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/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 ScreenshotRecorder sut; + final capturedImages = []; + + _Fixture._(this._tester) { + sut = ScreenshotRecorder( + ScreenshotRecorderConfig( + 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/replay_native_test.dart b/flutter/test/replay/replay_native_test.dart new file mode 100644 index 0000000000..afff3343ae --- /dev/null +++ b/flutter/test/replay/replay_native_test.dart @@ -0,0 +1,193 @@ +// ignore_for_file: invalid_use_of_internal_member + +@TestOn('vm') +library flutter_test; + +import 'dart:async'; + +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 (var mockPlatform in [ + MockPlatform.android(), + ]) { + group('$SentryNativeBinding ($mockPlatform)', () { + late SentryNativeBinding sut; + late NativeChannelFixture native; + late SentryFlutterOptions options; + late MockHub hub; + late FileSystem fs; + late Directory replayDir; + final replayConfig = { + 'replayId': '123', + 'directory': 'dir', + 'width': 1000, + 'height': 1000, + '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('start() 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('ReplayRecorder.start', 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('stop() 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); + }); + + testWidgets('captures images', (tester) async { + await tester.runAsync(() async { + 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(5000)); + 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())); + }); + }, timeout: Timeout(Duration(seconds: 10))); + }); + }); + } +} 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/hive/lib/src/version.dart b/hive/lib/src/version.dart index 7472e09c59..102e1a68e4 100644 --- a/hive/lib/src/version.dart +++ b/hive/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.5.0'; +const String sdkVersion = '8.6.0-alpha.2'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_hive'; diff --git a/hive/pubspec.yaml b/hive/pubspec.yaml index 153869cfd4..2788a6494b 100644 --- a/hive/pubspec.yaml +++ b/hive/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_hive description: An integration which adds support for performance tracing for the hive package. -version: 8.5.0 +version: 8.6.0-alpha.2 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -17,7 +17,7 @@ platforms: web: dependencies: - sentry: 8.5.0 + sentry: 8.6.0-alpha.2 hive: ^2.2.3 meta: ^1.3.0 diff --git a/isar/lib/src/version.dart b/isar/lib/src/version.dart index fb919c5da5..e90c712373 100644 --- a/isar/lib/src/version.dart +++ b/isar/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.5.0'; +const String sdkVersion = '8.6.0-alpha.2'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_isar'; diff --git a/isar/pubspec.yaml b/isar/pubspec.yaml index 3b78763ec2..e0a56595ca 100644 --- a/isar/pubspec.yaml +++ b/isar/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_isar description: An integration which adds support for performance tracing for the isar package. -version: 8.5.0 +version: 8.6.0-alpha.2 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -20,7 +20,7 @@ platforms: dependencies: isar: ^3.1.0 isar_flutter_libs: ^3.1.0 # contains Isar Core - sentry: 8.5.0 + sentry: 8.6.0-alpha.2 meta: ^1.3.0 path: ^1.8.3 diff --git a/logging/lib/src/version.dart b/logging/lib/src/version.dart index c0e74aee64..e5c66e44ca 100644 --- a/logging/lib/src/version.dart +++ b/logging/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.5.0'; +const String sdkVersion = '8.6.0-alpha.2'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_logging'; diff --git a/logging/pubspec.yaml b/logging/pubspec.yaml index 8a616368a3..983c264bd4 100644 --- a/logging/pubspec.yaml +++ b/logging/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_logging description: An integration which adds support for recording log from the logging package. -version: 8.5.0 +version: 8.6.0-alpha.2 homepage: https://docs.sentry.io/platforms/dart/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -19,7 +19,7 @@ platforms: dependencies: logging: ^1.0.0 - sentry: 8.5.0 + sentry: 8.6.0-alpha.2 dev_dependencies: lints: ^4.0.0 diff --git a/sqflite/lib/src/version.dart b/sqflite/lib/src/version.dart index 927810fae1..3f4a7ec81d 100644 --- a/sqflite/lib/src/version.dart +++ b/sqflite/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.5.0'; +const String sdkVersion = '8.6.0-alpha.2'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_sqflite'; diff --git a/sqflite/pubspec.yaml b/sqflite/pubspec.yaml index 3c457be989..6ea2612b58 100644 --- a/sqflite/pubspec.yaml +++ b/sqflite/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_sqflite description: An integration which adds support for performance tracing for the sqflite package. -version: 8.5.0 +version: 8.6.0-alpha.2 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -15,7 +15,7 @@ platforms: macos: dependencies: - sentry: 8.5.0 + sentry: 8.6.0-alpha.2 sqflite: ^2.2.8 sqflite_common: ^2.0.0 meta: ^1.3.0 From 648f8bcf8f98946cd92aec6563c761ac862c4ac5 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos <6349682+vaind@users.noreply.github.com> Date: Fri, 2 Aug 2024 07:21:37 +0200 Subject: [PATCH 2/8] fix: update android calls after SDK update (#2211) * fix: update android calls after SDK update * ktlint --- .../io/sentry/flutter/SentryFlutterPlugin.kt | 9 ++++----- .../replay_event_processor.dart | 2 +- .../lib/src/native/sentry_native_binding.dart | 2 +- .../lib/src/native/sentry_native_channel.dart | 5 ++--- flutter/test/mocks.mocks.dart | 20 +++++-------------- 5 files changed, 13 insertions(+), 25 deletions(-) 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 f4715dfb4e..c49914470e 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -83,7 +83,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { "loadContexts" -> loadContexts(result) "displayRefreshRate" -> displayRefreshRate(result) "addReplayScreenshot" -> addReplayScreenshot(call.argument("path"), call.argument("timestamp"), result) - "sendReplayForEvent" -> sendReplayForEvent(call.argument("eventId"), call.argument("isCrash"), result) + "captureReplay" -> captureReplay(call.argument("isCrash"), result) else -> result.notImplemented() } } @@ -555,16 +555,15 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { result.success("") } - private fun sendReplayForEvent( - eventId: String?, + private fun captureReplay( isCrash: Boolean?, result: Result, ) { - if (eventId == null || isCrash == null) { + if (isCrash == null) { result.error("5", "Arguments are null", null) return } - replay.sendReplay(isCrash, eventId, null) + replay.captureReplay(isCrash) result.success(replay.getReplayId().toString()) } } diff --git a/flutter/lib/src/event_processor/replay_event_processor.dart b/flutter/lib/src/event_processor/replay_event_processor.dart index 4be68a4d00..1d534f94b0 100644 --- a/flutter/lib/src/event_processor/replay_event_processor.dart +++ b/flutter/lib/src/event_processor/replay_event_processor.dart @@ -15,7 +15,7 @@ class ReplayEventProcessor implements EventProcessor { event.exceptions?.isNotEmpty == true) { final isCrash = event.exceptions!.any((e) => e.mechanism?.handled == false); - await _binding.sendReplayForEvent(event.eventId, isCrash); + await _binding.captureReplay(isCrash); } return event; } diff --git a/flutter/lib/src/native/sentry_native_binding.dart b/flutter/lib/src/native/sentry_native_binding.dart index 15769c97d3..4fbbf1eea9 100644 --- a/flutter/lib/src/native/sentry_native_binding.dart +++ b/flutter/lib/src/native/sentry_native_binding.dart @@ -58,5 +58,5 @@ abstract class SentryNativeBinding { Future resumeAppHangTracking(); - Future sendReplayForEvent(SentryId eventId, bool isCrash); + 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 20d0794f31..57e3fd198f 100644 --- a/flutter/lib/src/native/sentry_native_channel.dart +++ b/flutter/lib/src/native/sentry_native_channel.dart @@ -198,9 +198,8 @@ class SentryNativeChannel channel.invokeMethod('resumeAppHangTracking'); @override - Future sendReplayForEvent(SentryId eventId, bool isCrash) => - channel.invokeMethod('sendReplayForEvent', { - 'eventId': eventId.toString(), + Future captureReplay(bool isCrash) => + channel.invokeMethod('captureReplay', { 'isCrash': isCrash, }).then((value) => SentryId.fromId(value as String)); } diff --git a/flutter/test/mocks.mocks.dart b/flutter/test/mocks.mocks.dart index 3632555cc7..feb97b5927 100644 --- a/flutter/test/mocks.mocks.dart +++ b/flutter/test/mocks.mocks.dart @@ -1364,26 +1364,16 @@ class MockSentryNativeBinding extends _i1.Mock ) as _i7.Future); @override - _i7.Future<_i2.SentryId> sendReplayForEvent( - _i2.SentryId? eventId, - bool? isCrash, - ) => - (super.noSuchMethod( + _i7.Future<_i2.SentryId> captureReplay(bool? isCrash) => (super.noSuchMethod( Invocation.method( - #sendReplayForEvent, - [ - eventId, - isCrash, - ], + #captureReplay, + [isCrash], ), returnValue: _i7.Future<_i2.SentryId>.value(_FakeSentryId_5( this, Invocation.method( - #sendReplayForEvent, - [ - eventId, - isCrash, - ], + #captureReplay, + [isCrash], ), )), ) as _i7.Future<_i2.SentryId>); From 3aacb23b71943a26389baf04f302aea80abaab3c Mon Sep 17 00:00:00 2001 From: Ivan Dlugos <6349682+vaind@users.noreply.github.com> Date: Wed, 7 Aug 2024 20:14:13 +0200 Subject: [PATCH 3/8] feat: iOS replay support (#2209) * minor gradle fixes * tmp: local sentry-java build * tmp: use relative path to sentry-java * tmp: local java build patches * replay options * replay recorder * wip: JNI native bindings * use compatible jnigen * add missing gradlew to flutter/android * replay recorder JNI binding code * replay recorder binding jni code * jni 0.6 * wip: android jni replay * replay binding * glue code for jni * chore: update to cocoa 8.24.1-alpha.0 * wip: cocoa integration * wip: ios replay * cleanup * formatting * android fixes * move native setup to the native sdk integration * cleanup & improvements * improve widget filter and implement redact options * fix image scaling * ktlint format * ci fixes * fix tests * add jnigen scripts * use android 7.9.0 alpha.1 * move native init & close to SentryNative * cleanup * add macOS integration link * rollback cocoa changes * remove jni/jnigen * wip: methodchannel based android recorder * callback * linter issues * minor fixes * more fixes * linter issues * cleanup * improve logging * move replay to experimental, same as in other SDKs * improve tree shaking * test: scheduler * support browser test * fix compat with old flutter * cleanup * rename recorder_widget_filter.dart * fixup scheduler test * improve test coverage * pr cleanup * test: widget filter * cleanup * test widget filter visibility * cleanup * always add screenshot widget * recorder test * cleanup * limit recorder test to vm * wip: integration test * cleanup * ktlint format * detekt suppression * ktlint format * improve scheduler stop behavior * wip: error replay mapping * suppress detekt TooGenericExceptionThrown * Update flutter/lib/src/replay/recorder.dart Co-authored-by: Giancarlo Buenaflor * Update flutter/lib/src/native/java/sentry_native_java.dart Co-authored-by: Giancarlo Buenaflor * improve comments * feat: associate dart errors with replays (#2070) * feat: associate dart errors with replays * ktlint * cleanup * tests * chote: remove path dependency * wip: ios replay * fix result callback * iOS related refactorings * logs * fix tests * call captureReplay on iOS & set * ios replay breadcrumbs * feat: replay breadcrumbs (android) (#2163) * feat: replay breadcrumbs * ktlint format * fixup tests * cleanup * linter issues * detekt linter issue * move touch path build to dart to deduplicate * fix metrics app compilation * linter issue * test: native replay integration binding (#2189) * wip: test native integration * test: native replay binding * update example * chore: update pubspec * fixup tests * Update flutter/test/mocks.dart * chore: update changelog * fix publishing * release: 8.6.0-alpha.2 * cleanup * fix macos compilation * test: iOS support * linter issues * linter issues * chore: update changelog * Update flutter/lib/src/native/cocoa/sentry_native_cocoa.dart Co-authored-by: Giancarlo Buenaflor --------- Co-authored-by: Giancarlo Buenaflor Co-authored-by: getsentry-bot Co-authored-by: getsentry-bot --- CHANGELOG.md | 6 + flutter/ios/Classes/SentryFlutter.swift | 8 + .../Classes/SentryFlutterPluginApple.swift | 25 ++- .../SentryFlutterReplayBreadcrumbConverter.h | 15 ++ .../SentryFlutterReplayBreadcrumbConverter.m | 117 ++++++++++++ .../SentryFlutterReplayScreenshotProvider.h | 12 ++ .../SentryFlutterReplayScreenshotProvider.m | 46 +++++ .../src/native/cocoa/sentry_native_cocoa.dart | 59 ++++++ .../src/native/java/sentry_native_java.dart | 12 +- flutter/lib/src/replay/recorder.dart | 62 ++---- flutter/lib/src/replay/recorder_config.dart | 26 ++- .../lib/src/replay/scheduled_recorder.dart | 40 ++++ flutter/test/mocks.dart | 46 ++--- flutter/test/replay/recorder_config_test.dart | 24 +++ flutter/test/replay/recorder_test.dart | 35 +--- flutter/test/replay/replay_native_test.dart | 179 +++++++++++------- .../test/replay/scheduled_recorder_test.dart | 63 ++++++ 17 files changed, 593 insertions(+), 182 deletions(-) create mode 100644 flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.h create mode 100644 flutter/ios/Classes/SentryFlutterReplayBreadcrumbConverter.m create mode 100644 flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.h create mode 100644 flutter/ios/Classes/SentryFlutterReplayScreenshotProvider.m create mode 100644 flutter/lib/src/replay/scheduled_recorder.dart create mode 100644 flutter/test/replay/recorder_config_test.dart create mode 100644 flutter/test/replay/scheduled_recorder_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b731e7af8e..4781fb4b1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- iOS Session Replay Alpha ([#2209](https://github.com/getsentry/sentry-dart/pull/2209)) + ## 8.6.0 ### Improvements diff --git a/flutter/ios/Classes/SentryFlutter.swift b/flutter/ios/Classes/SentryFlutter.swift index b26bcfc30d..120561d687 100644 --- a/flutter/ios/Classes/SentryFlutter.swift +++ b/flutter/ios/Classes/SentryFlutter.swift @@ -70,6 +70,14 @@ public final class SentryFlutter { if let appHangTimeoutIntervalMillis = data["appHangTimeoutIntervalMillis"] as? NSNumber { options.appHangTimeoutInterval = appHangTimeoutIntervalMillis.doubleValue / 1000 } +#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.errorSampleRate = + (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 35249ef5d1..15efcf2772 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() { @@ -174,6 +179,14 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { case "resumeAppHangTracking": resumeAppHangTracking(result) + case "sendReplayForEvent": +#if canImport(UIKit) && !SENTRY_NO_UIKIT && (os(iOS) || os(tvOS)) + PrivateSentrySDKOnly.captureReplay() + result(PrivateSentrySDKOnly.getReplayId()) +#else + result(nil) +#endif + default: result(FlutterMethodNotImplemented) } @@ -323,6 +336,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/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 30c157e3ad..5ccd3a1c67 100644 --- a/flutter/lib/src/native/java/sentry_native_java.dart +++ b/flutter/lib/src/native/java/sentry_native_java.dart @@ -4,7 +4,7 @@ import 'package:meta/meta.dart'; import '../../../sentry_flutter.dart'; import '../../event_processor/replay_event_processor.dart'; -import '../../replay/recorder.dart'; +import '../../replay/scheduled_recorder.dart'; import '../../replay/recorder_config.dart'; import '../sentry_native_channel.dart'; @@ -12,7 +12,7 @@ import '../sentry_native_channel.dart'; // generated JNI bindings. See https://github.com/getsentry/sentry-dart/issues/1444 @internal class SentryNativeJava extends SentryNativeChannel { - ScreenshotRecorder? _replayRecorder; + ScheduledScreenshotRecorder? _replayRecorder; SentryNativeJava(super.options, super.channel); @override @@ -33,7 +33,7 @@ class SentryNativeJava extends SentryNativeChannel { _startRecorder( call.arguments['directory'] as String, - ScreenshotRecorderConfig( + ScheduledScreenshotRecorderConfig( width: call.arguments['width'] as int, height: call.arguments['height'] as int, frameRate: call.arguments['frameRate'] as int, @@ -78,7 +78,8 @@ class SentryNativeJava extends SentryNativeChannel { return super.close(); } - void _startRecorder(String cacheDir, ScreenshotRecorderConfig config) { + 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 @@ -121,6 +122,7 @@ class SentryNativeJava extends SentryNativeChannel { } }; - _replayRecorder = ScreenshotRecorder(config, callback, options)..start(); + _replayRecorder = ScheduledScreenshotRecorder(config, callback, options) + ..start(); } } diff --git a/flutter/lib/src/replay/recorder.dart b/flutter/lib/src/replay/recorder.dart index e17167f87f..a1f4ea1a0b 100644 --- a/flutter/lib/src/replay/recorder.dart +++ b/flutter/lib/src/replay/recorder.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'dart:ui'; import 'package:flutter/rendering.dart'; @@ -8,55 +7,35 @@ import 'package:meta/meta.dart'; import '../../sentry_flutter.dart'; import 'recorder_config.dart'; import 'widget_filter.dart'; -import 'scheduler.dart'; @internal typedef ScreenshotRecorderCallback = Future Function(Image); @internal class ScreenshotRecorder { - final ScreenshotRecorderConfig _config; - final ScreenshotRecorderCallback _callback; - final SentryLogger _logger; - final SentryReplayOptions _options; - final bool rethrowExceptions; + @protected + final ScreenshotRecorderConfig config; + @protected + final SentryFlutterOptions options; WidgetFilter? _widgetFilter; - late final Scheduler _scheduler; bool warningLogged = false; - ScreenshotRecorder(this._config, this._callback, SentryFlutterOptions options) - : _logger = options.logger, - _options = options.experimental.replay, - // ignore: invalid_use_of_internal_member - rethrowExceptions = options.automatedTestMode { - final frameDuration = Duration(milliseconds: 1000 ~/ _config.frameRate); - _scheduler = Scheduler(frameDuration, _capture, - options.bindingUtils.instance!.addPostFrameCallback); - - if (_options.redactAllText || _options.redactAllImages) { + ScreenshotRecorder(this.config, this.options) { + final replayOptions = options.experimental.replay; + if (replayOptions.redactAllText || replayOptions.redactAllImages) { _widgetFilter = WidgetFilter( - redactText: _options.redactAllText, - redactImages: _options.redactAllImages, - logger: _logger); + redactText: replayOptions.redactAllText, + redactImages: replayOptions.redactAllImages, + logger: options.logger); } } - void start() { - _logger(SentryLevel.debug, "Replay: starting replay capture."); - _scheduler.start(); - } - - Future stop() async { - await _scheduler.stop(); - _logger(SentryLevel.debug, "Replay: replay capture stopped."); - } - - Future _capture(Duration sinceSchedulerEpoch) async { + Future capture(ScreenshotRecorderCallback callback) async { final context = sentryScreenshotWidgetGlobalKey.currentContext; final renderObject = context?.findRenderObject() as RenderRepaintBoundary?; if (context == null || renderObject == null) { if (!warningLogged) { - _logger( + options.logger( SentryLevel.warning, "Replay: SentryScreenshotWidget is not attached. " "Skipping replay capture."); @@ -68,12 +47,12 @@ class ScreenshotRecorder { try { final watch = Stopwatch()..start(); - // The desired resolution (coming from the configuration) is usually - // rounded to next multitude of 16. Therefore, we scale the image. + // 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 = - min(_config.width / srcWidth, _config.height / srcHeight); + 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); @@ -109,7 +88,7 @@ class ScreenshotRecorder { final finalImage = await picture.toImage( (srcWidth * pixelRatio).round(), (srcHeight * pixelRatio).round()); try { - await _callback(finalImage); + await callback(finalImage); } finally { finalImage.dispose(); } @@ -117,14 +96,15 @@ class ScreenshotRecorder { picture.dispose(); } - _logger( + options.logger( SentryLevel.debug, "Replay: captured a screenshot in ${watch.elapsedMilliseconds}" " ms ($blockingTime ms blocking)."); } catch (e, stackTrace) { - _logger(SentryLevel.error, "Replay: failed to capture screenshot.", + options.logger(SentryLevel.error, "Replay: failed to capture screenshot.", exception: e, stackTrace: stackTrace); - if (rethrowExceptions) { + // ignore: invalid_use_of_internal_member + if (options.automatedTestMode) { rethrow; } } diff --git a/flutter/lib/src/replay/recorder_config.dart b/flutter/lib/src/replay/recorder_config.dart index b7e4fd4c86..9649a33823 100644 --- a/flutter/lib/src/replay/recorder_config.dart +++ b/flutter/lib/src/replay/recorder_config.dart @@ -1,11 +1,29 @@ +import 'dart:math'; + import 'package:meta/meta.dart'; @internal class ScreenshotRecorderConfig { - final int width; - final int height; + 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; - ScreenshotRecorderConfig( - {required this.width, required this.height, required this.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/test/mocks.dart b/flutter/test/mocks.dart index 4e59f6d389..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'); - } - - factory MockPlatform.iOs() { - return MockPlatform(os: 'ios'); - } - - factory MockPlatform.macOs() { - return MockPlatform(os: 'macos'); - } + const MockPlatform(this.operatingSystem, + {this.operatingSystemVersion = '', this.localHostname = ''}); - 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; 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 index 99176c4c89..16db1513b5 100644 --- a/flutter/test/replay/recorder_test.dart +++ b/flutter/test/replay/recorder_test.dart @@ -18,46 +18,31 @@ void main() async { 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']); + expect(fixture.capture(), completion('800x600')); }); } class _Fixture { - final WidgetTester _tester; late final ScreenshotRecorder sut; - final capturedImages = []; - _Fixture._(this._tester) { + _Fixture._() { sut = ScreenshotRecorder( - ScreenshotRecorderConfig( - width: 1000, - height: 1000, - frameRate: 1000, - ), - (Image image) async { - capturedImages.add("${image.width}x${image.height}"); - }, + ScreenshotRecorderConfig(), SentryFlutterOptions()..bindingUtils = TestBindingWrapper(), ); } static Future<_Fixture> create(WidgetTester tester) async { - final fixture = _Fixture._(tester); + final fixture = _Fixture._(); await pumpTestElement(tester); - fixture.sut.start(); return fixture; } - Future nextFrame() async { - _tester.binding.scheduleFrame(); - await _tester.pumpAndSettle(const Duration(seconds: 1)); + 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 index afff3343ae..319bb5f88f 100644 --- a/flutter/test/replay/replay_native_test.dart +++ b/flutter/test/replay/replay_native_test.dart @@ -4,6 +4,7 @@ library flutter_test; import 'dart:async'; +import 'dart:typed_data'; import 'package:file/file.dart'; import 'package:file/memory.dart'; @@ -21,8 +22,9 @@ import 'test_widget.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - for (var mockPlatform in [ + for (final mockPlatform in [ MockPlatform.android(), + MockPlatform.iOs(), ]) { group('$SentryNativeBinding ($mockPlatform)', () { late SentryNativeBinding sut; @@ -31,13 +33,22 @@ void main() { late MockHub hub; late FileSystem fs; late Directory replayDir; - final replayConfig = { - 'replayId': '123', - 'directory': 'dir', - 'width': 1000, - 'height': 1000, - 'frameRate': 1000, - }; + 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(); @@ -88,12 +99,16 @@ void main() { await sut.init(hub); }); - test('start() sets replay ID to context', () async { + 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('ReplayRecorder.start', replayConfig); + await native.invokeFromNative( + mockPlatform.isAndroid + ? 'ReplayRecorder.start' + : 'captureReplayScreenshot', + replayConfig); // verify the replay ID was set final closure = @@ -104,7 +119,7 @@ void main() { expect(scope.replayId.toString(), replayConfig['replayId']); }); - test('stop() clears replay ID from context', () async { + test('clears replay ID from context', () async { // verify there was no scope configured before verifyNever(hub.configureScope(any)); @@ -119,72 +134,94 @@ void main() { 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 { - 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'); - } + 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; }); - 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); + 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'); } - - 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(5000)); - 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())); }); }, 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)); + } +} From 71c07109a2ed8148efe9cb9d75b0441575ee0732 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 13 Aug 2024 14:01:50 +0200 Subject: [PATCH 4/8] fix: cocoa sdk renamed errorSampleRate to onErrorSampleRate --- flutter/ios/Classes/SentryFlutter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter/ios/Classes/SentryFlutter.swift b/flutter/ios/Classes/SentryFlutter.swift index 7d480930cc..3f77fad598 100644 --- a/flutter/ios/Classes/SentryFlutter.swift +++ b/flutter/ios/Classes/SentryFlutter.swift @@ -109,7 +109,7 @@ public final class SentryFlutter { if let replayOptions = data["replay"] as? [String: Any] { options.experimental.sessionReplay.sessionSampleRate = (replayOptions["sessionSampleRate"] as? NSNumber)?.floatValue ?? 0 - options.experimental.sessionReplay.errorSampleRate = + options.experimental.sessionReplay.onErrorSampleRate = (replayOptions["errorSampleRate"] as? NSNumber)?.floatValue ?? 0 } #endif From b7928bc9504828fbb6daabab237bb0c3710b1591 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Tue, 13 Aug 2024 14:04:09 +0200 Subject: [PATCH 5/8] fixup changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dcd03a763..eb44fb58f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### 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. @@ -21,7 +23,6 @@ SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), ### Features -- iOS Session Replay Alpha ([#2209](https://github.com/getsentry/sentry-dart/pull/2209)) - 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 From d421f46701c91b5b4336cced98357920f07ca054 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Tue, 13 Aug 2024 14:40:47 +0000 Subject: [PATCH 6/8] release: 8.8.0-alpha.1 --- CHANGELOG.md | 2 +- dart/lib/src/version.dart | 2 +- dart/pubspec.yaml | 2 +- dio/lib/src/version.dart | 2 +- dio/pubspec.yaml | 4 ++-- drift/lib/src/version.dart | 2 +- drift/pubspec.yaml | 4 ++-- file/lib/src/version.dart | 2 +- file/pubspec.yaml | 4 ++-- flutter/example/pubspec.yaml | 2 +- flutter/lib/src/version.dart | 2 +- flutter/pubspec.yaml | 4 ++-- hive/lib/src/version.dart | 2 +- hive/pubspec.yaml | 4 ++-- isar/lib/src/version.dart | 2 +- isar/pubspec.yaml | 4 ++-- logging/lib/src/version.dart | 2 +- logging/pubspec.yaml | 4 ++-- sqflite/lib/src/version.dart | 2 +- sqflite/pubspec.yaml | 4 ++-- 20 files changed, 28 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb44fb58f7..cb21cf42dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 8.8.0-alpha.1 ### Features diff --git a/dart/lib/src/version.dart b/dart/lib/src/version.dart index 254e1efb4f..8c9254406c 100644 --- a/dart/lib/src/version.dart +++ b/dart/lib/src/version.dart @@ -9,7 +9,7 @@ library version; /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.7.0'; +const String sdkVersion = '8.8.0-alpha.1'; String sdkName(bool isWeb) => isWeb ? _browserSdkName : _ioSdkName; diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index 2783ac7038..8ed0ef8239 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry -version: 8.7.0 +version: 8.8.0-alpha.1 description: > A crash reporting library for Dart that sends crash reports to Sentry.io. This library supports Dart VM and Web. For Flutter consider sentry_flutter instead. diff --git a/dio/lib/src/version.dart b/dio/lib/src/version.dart index b8716dbb69..12b4ffc9e7 100644 --- a/dio/lib/src/version.dart +++ b/dio/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.7.0'; +const String sdkVersion = '8.8.0-alpha.1'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_dio'; diff --git a/dio/pubspec.yaml b/dio/pubspec.yaml index 48654aeccc..cbae0c3695 100644 --- a/dio/pubspec.yaml +++ b/dio/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_dio description: An integration which adds support for performance tracing for the Dio package. -version: 8.7.0 +version: 8.8.0-alpha.1 homepage: https://docs.sentry.io/platforms/dart/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -19,7 +19,7 @@ platforms: dependencies: dio: ^5.0.0 - sentry: 8.7.0 + sentry: 8.8.0-alpha.1 dev_dependencies: meta: ^1.3.0 diff --git a/drift/lib/src/version.dart b/drift/lib/src/version.dart index 1ee2e5162d..994a6fb6ee 100644 --- a/drift/lib/src/version.dart +++ b/drift/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.7.0'; +const String sdkVersion = '8.8.0-alpha.1'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_drift'; diff --git a/drift/pubspec.yaml b/drift/pubspec.yaml index b289220ef8..65464ac273 100644 --- a/drift/pubspec.yaml +++ b/drift/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_drift description: An integration which adds support for performance tracing for the drift package. -version: 8.7.0 +version: 8.8.0-alpha.1 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -17,7 +17,7 @@ platforms: web: dependencies: - sentry: 8.7.0 + sentry: 8.8.0-alpha.1 meta: ^1.3.0 drift: ^2.13.0 diff --git a/file/lib/src/version.dart b/file/lib/src/version.dart index 67deaf2942..fd1a1a1021 100644 --- a/file/lib/src/version.dart +++ b/file/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.7.0'; +const String sdkVersion = '8.8.0-alpha.1'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_file'; diff --git a/file/pubspec.yaml b/file/pubspec.yaml index 6e837e4636..be66c6fa0b 100644 --- a/file/pubspec.yaml +++ b/file/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_file description: An integration which adds support for performance tracing for dart.io.File. -version: 8.7.0 +version: 8.8.0-alpha.1 homepage: https://docs.sentry.io/platforms/dart/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -17,7 +17,7 @@ platforms: windows: dependencies: - sentry: 8.7.0 + sentry: 8.8.0-alpha.1 meta: ^1.3.0 dev_dependencies: diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index 88919c32a6..291be74908 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_flutter_example description: Demonstrates how to use the sentry_flutter plugin. -version: 8.7.0 +version: 8.8.0-alpha.1 publish_to: 'none' # Remove this line if you wish to publish to pub.dev diff --git a/flutter/lib/src/version.dart b/flutter/lib/src/version.dart index fec35b52f3..f026532767 100644 --- a/flutter/lib/src/version.dart +++ b/flutter/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.7.0'; +const String sdkVersion = '8.8.0-alpha.1'; /// The default SDK name reported to Sentry.io in the submitted events. const String sdkName = 'sentry.dart.flutter'; diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index f15842dc78..723e17dafe 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry_flutter -version: 8.7.0 +version: 8.8.0-alpha.1 description: Sentry SDK for Flutter. This package aims to support different Flutter targets by relying on the many platforms supported by Sentry with native SDKs. homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart @@ -23,7 +23,7 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - sentry: 8.7.0 + sentry: 8.8.0-alpha.1 package_info_plus: '>=1.0.0' meta: ^1.3.0 ffi: ^2.0.0 diff --git a/hive/lib/src/version.dart b/hive/lib/src/version.dart index 57e1b4759d..71a9a7a0d7 100644 --- a/hive/lib/src/version.dart +++ b/hive/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.7.0'; +const String sdkVersion = '8.8.0-alpha.1'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_hive'; diff --git a/hive/pubspec.yaml b/hive/pubspec.yaml index 97a7739681..0dea578365 100644 --- a/hive/pubspec.yaml +++ b/hive/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_hive description: An integration which adds support for performance tracing for the hive package. -version: 8.7.0 +version: 8.8.0-alpha.1 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -17,7 +17,7 @@ platforms: web: dependencies: - sentry: 8.7.0 + sentry: 8.8.0-alpha.1 hive: ^2.2.3 meta: ^1.3.0 diff --git a/isar/lib/src/version.dart b/isar/lib/src/version.dart index 675e35663c..6c243c5cd9 100644 --- a/isar/lib/src/version.dart +++ b/isar/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.7.0'; +const String sdkVersion = '8.8.0-alpha.1'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_isar'; diff --git a/isar/pubspec.yaml b/isar/pubspec.yaml index 65ebc08ba6..2952e6ed28 100644 --- a/isar/pubspec.yaml +++ b/isar/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_isar description: An integration which adds support for performance tracing for the isar package. -version: 8.7.0 +version: 8.8.0-alpha.1 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -20,7 +20,7 @@ platforms: dependencies: isar: ^3.1.0 isar_flutter_libs: ^3.1.0 # contains Isar Core - sentry: 8.7.0 + sentry: 8.8.0-alpha.1 meta: ^1.3.0 path: ^1.8.3 diff --git a/logging/lib/src/version.dart b/logging/lib/src/version.dart index 391c1e6341..3deecf9da6 100644 --- a/logging/lib/src/version.dart +++ b/logging/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.7.0'; +const String sdkVersion = '8.8.0-alpha.1'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_logging'; diff --git a/logging/pubspec.yaml b/logging/pubspec.yaml index 853c311da8..cc0fac5d09 100644 --- a/logging/pubspec.yaml +++ b/logging/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_logging description: An integration which adds support for recording log from the logging package. -version: 8.7.0 +version: 8.8.0-alpha.1 homepage: https://docs.sentry.io/platforms/dart/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -19,7 +19,7 @@ platforms: dependencies: logging: ^1.0.0 - sentry: 8.7.0 + sentry: 8.8.0-alpha.1 dev_dependencies: lints: ^4.0.0 diff --git a/sqflite/lib/src/version.dart b/sqflite/lib/src/version.dart index 17a9d7057b..feb46dc492 100644 --- a/sqflite/lib/src/version.dart +++ b/sqflite/lib/src/version.dart @@ -1,5 +1,5 @@ /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '8.7.0'; +const String sdkVersion = '8.8.0-alpha.1'; /// The package name reported to Sentry.io in the submitted events. const String packageName = 'pub:sentry_sqflite'; diff --git a/sqflite/pubspec.yaml b/sqflite/pubspec.yaml index ac73cdd728..27aeaec425 100644 --- a/sqflite/pubspec.yaml +++ b/sqflite/pubspec.yaml @@ -1,6 +1,6 @@ name: sentry_sqflite description: An integration which adds support for performance tracing for the sqflite package. -version: 8.7.0 +version: 8.8.0-alpha.1 homepage: https://docs.sentry.io/platforms/flutter/ repository: https://github.com/getsentry/sentry-dart issue_tracker: https://github.com/getsentry/sentry-dart/issues @@ -15,7 +15,7 @@ platforms: macos: dependencies: - sentry: 8.7.0 + sentry: 8.8.0-alpha.1 sqflite: ^2.2.8 sqflite_common: ^2.0.0 meta: ^1.3.0 From 8cce74e97809dd0f891ab1e9ec4da6c18a6c2957 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 19 Aug 2024 14:58:54 +0200 Subject: [PATCH 7/8] chore: update changelog --- CHANGELOG.md | 68 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 278ac51f6a..ce299103b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,22 +2,34 @@ ## Unreleased -### Improvements +### Features -- Debouncing of SentryWidgetsBindingObserver.didChangeMetrics with delay of 100ms. ([#2232](https://github.com/getsentry/sentry-dart/pull/2232)) +- Session replay Alpha for Android and iOS ([#2208](https://github.com/getsentry/sentry-dart/pull/2208)). -## 8.8.0-alpha.1 + 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/)): -### Features + ```dart + await SentryFlutter.init( + (options) { + ... + options.experimental.replay.sessionSampleRate = 1.0; + options.experimental.replay.errorSampleRate = 1.0; + }, + appRunner: () => runApp(MyApp()), + ); + ``` -- 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"]), -``` + - 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 + +- Debouncing of SentryWidgetsBindingObserver.didChangeMetrics with delay of 100ms. ([#2232](https://github.com/getsentry/sentry-dart/pull/2232)) ### Dependencies @@ -31,6 +43,7 @@ SentryNavigatorObserver(ignoreRoutes: ["/ignoreThisRoute"]), - 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) { @@ -42,8 +55,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'; @@ -83,24 +98,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 From 152602322f1dc90bd21932cb80d25254691d9482 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Mon, 19 Aug 2024 15:01:09 +0200 Subject: [PATCH 8/8] update changelog --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce299103b7..24d5143c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,26 @@ ### 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.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)