diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 1f9c22a2ec..49e050e326 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -208,6 +208,7 @@ jobs: steps: - uses: actions/checkout@v3 # 1.20.0 + # To recreate baseline run: detekt -i flutter/android,flutter/example/android -b flutter/config/detekt-bl.xml -cb - uses: natiginfo/action-detekt-all@74990bda6bfc47977e1e06aae9f47f320e7587ce with: - args: -i flutter --baseline flutter/config/detekt-bl.xml + args: -i flutter/android,flutter/example/android --baseline flutter/config/detekt-bl.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2da16a8a09..029429ab30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ * Fix: Fix `SentryAssetBundle` on Flutter >= 3.1 (#877) * Feat: Add Android thread to platform stacktraces (#853) * Fix: Rename auto initialize property (#857) +* Feat: Sync Scope to Native (#858) +* Bump: Sentry-Android to 6.0.0-beta.4 (#871) * Bump: Sentry-Android to 6.0.0 (#879) ## 6.6.0-alpha.2 diff --git a/dart/example/bin/example.dart b/dart/example/bin/example.dart index 52ef0417b5..80a1aacdab 100644 --- a/dart/example/bin/example.dart +++ b/dart/example/bin/example.dart @@ -40,14 +40,14 @@ Future runApp() async { ); Sentry.configureScope((scope) { + scope.setUser(SentryUser( + id: '800', + username: 'first-user', + email: 'first@user.lan', + // ipAddress: '127.0.0.1', sendDefaultPii feature is enabled + extras: {'first-sign-in': '2020-01-01'}, + )); scope - ..user = SentryUser( - id: '800', - username: 'first-user', - email: 'first@user.lan', - // ipAddress: '127.0.0.1', sendDefaultPii feature is enabled - extras: {'first-sign-in': '2020-01-01'}, - ) // ..fingerprint = ['example-dart'], fingerprint forces events to group together ..transaction = '/example/app' ..level = SentryLevel.warning diff --git a/dart/example_web/web/main.dart b/dart/example_web/web/main.dart index a4db18f2d3..8e6d7ba832 100644 --- a/dart/example_web/web/main.dart +++ b/dart/example_web/web/main.dart @@ -40,18 +40,21 @@ void runApp() { Sentry.configureScope((scope) { scope - ..user = SentryUser( - id: '800', - username: 'first-user', - email: 'first@user.lan', - // ipAddress: '127.0.0.1', - extras: {'first-sign-in': '2020-01-01'}, - ) // ..fingerprint = ['example-dart'] ..transaction = '/example/app' ..level = SentryLevel.warning ..setTag('build', '579') ..setExtra('company-name', 'Dart Inc'); + + scope.setUser( + SentryUser( + id: '800', + username: 'first-user', + email: 'first@user.lan', + // ipAddress: '127.0.0.1', + extras: {'first-sign-in': '2020-01-01'}, + ), + ); }); querySelector('#btEvent') diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index 77ac7a482f..bb7db1cc95 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -12,6 +12,7 @@ export 'src/noop_isolate_error_integration.dart' if (dart.library.io) 'src/isolate_error_integration.dart'; export 'src/protocol.dart'; export 'src/scope.dart'; +export 'src/scope_observer.dart'; export 'src/sentry.dart'; export 'src/sentry_envelope.dart'; export 'src/sentry_envelope_item.dart'; diff --git a/dart/lib/src/scope.dart b/dart/lib/src/scope.dart index 20799f191a..bb70962640 100644 --- a/dart/lib/src/scope.dart +++ b/dart/lib/src/scope.dart @@ -1,11 +1,13 @@ +import 'dart:async'; import 'dart:collection'; -import 'sentry_attachment/sentry_attachment.dart'; import 'event_processor.dart'; import 'protocol.dart'; +import 'scope_observer.dart'; +import 'sentry_attachment/sentry_attachment.dart'; import 'sentry_options.dart'; +import 'sentry_span_interface.dart'; import 'sentry_tracer.dart'; -import 'tracing.dart'; /// Scope data to be sent with the event class Scope { @@ -46,8 +48,17 @@ class Scope { } } - /// Information about the current user. - SentryUser? user; + SentryUser? _user; + + /// Get the current user. + SentryUser? get user => _user; + + /// Set the current user. + Future setUser(SentryUser? user) async { + _user = user; + await _callScopeObservers( + (scopeObserver) async => await scopeObserver.setUser(user)); + } List _fingerprint = []; @@ -93,15 +104,21 @@ class Scope { Map get contexts => Map.unmodifiable(_contexts); /// add an entry to the Scope's contexts - void setContexts(String key, dynamic value) { + Future setContexts(String key, dynamic value) async { _contexts[key] = (value is num || value is bool || value is String) ? {'value': value} : value; + + await _callScopeObservers( + (scopeObserver) async => await scopeObserver.setContexts(key, value)); } /// Removes a value from the Scope's contexts - void removeContexts(String key) { + Future removeContexts(String key) async { _contexts.remove(key); + + await _callScopeObservers( + (scopeObserver) async => await scopeObserver.removeContexts(key)); } /// Scope's event processor list @@ -114,14 +131,17 @@ class Scope { final SentryOptions _options; - final List _attachements = []; + final List _attachments = []; + + List get attachments => List.unmodifiable(_attachments); - List get attachements => List.unmodifiable(_attachements); + @Deprecated('Use attachments instead') + List get attachements => attachments; Scope(this._options); /// Adds a breadcrumb to the breadcrumbs queue - void addBreadcrumb(Breadcrumb breadcrumb, {dynamic hint}) { + Future addBreadcrumb(Breadcrumb breadcrumb, {dynamic hint}) async { // bail out if maxBreadcrumbs is zero if (_options.maxBreadcrumbs == 0) { return; @@ -151,19 +171,25 @@ class Scope { } _breadcrumbs.add(breadcrumb); + + await _callScopeObservers( + (scopeObserver) async => await scopeObserver.addBreadcrumb(breadcrumb)); } void addAttachment(SentryAttachment attachment) { - _attachements.add(attachment); + _attachments.add(attachment); } void clearAttachments() { - _attachements.clear(); + _attachments.clear(); } /// Clear all the breadcrumbs - void clearBreadcrumbs() { + Future clearBreadcrumbs() async { _breadcrumbs.clear(); + + await _callScopeObservers( + (scopeObserver) async => await scopeObserver.clearBreadcrumbs()); } /// Adds an event processor @@ -172,13 +198,13 @@ class Scope { } /// Resets the Scope to its default state - void clear() { - clearBreadcrumbs(); + Future clear() async { + await clearBreadcrumbs(); clearAttachments(); level = null; _span = null; _transaction = null; - user = null; + await setUser(null); _fingerprint = []; _tags.clear(); _extra.clear(); @@ -186,22 +212,32 @@ class Scope { } /// Sets a tag to the Scope - void setTag(String key, String value) { + Future setTag(String key, String value) async { _tags[key] = value; + await _callScopeObservers( + (scopeObserver) async => await scopeObserver.setTag(key, value)); } /// Removes a tag from the Scope - void removeTag(String key) { + Future removeTag(String key) async { _tags.remove(key); + await _callScopeObservers( + (scopeObserver) async => await scopeObserver.removeTag(key)); } /// Sets an extra to the Scope - void setExtra(String key, dynamic value) { + Future setExtra(String key, dynamic value) async { _extra[key] = value; + await _callScopeObservers( + (scopeObserver) async => await scopeObserver.setExtra(key, value)); } /// Removes an extra from the Scope - void removeExtra(String key) => _extra.remove(key); + Future removeExtra(String key) async { + _extra.remove(key); + await _callScopeObservers( + (scopeObserver) async => await scopeObserver.removeExtra(key)); + } Future applyToEvent( SentryEvent event, { @@ -327,11 +363,12 @@ class Scope { Scope clone() { final clone = Scope(_options) ..level = level - ..user = user ..fingerprint = List.from(fingerprint) .._transaction = _transaction .._span = _span; + clone.setUser(user); + for (final tag in _tags.keys) { clone.setTag(tag, _tags[tag]!); } @@ -354,10 +391,19 @@ class Scope { } }); - for (final attachment in _attachements) { + for (final attachment in _attachments) { clone.addAttachment(attachment); } return clone; } + + Future _callScopeObservers( + Future Function(ScopeObserver) action) async { + if (_options.enableScopeSync) { + for (final scopeObserver in _options.scopeObservers) { + await action(scopeObserver); + } + } + } } diff --git a/dart/lib/src/scope_observer.dart b/dart/lib/src/scope_observer.dart new file mode 100644 index 0000000000..09af0c46a0 --- /dev/null +++ b/dart/lib/src/scope_observer.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import 'protocol/breadcrumb.dart'; +import 'protocol/sentry_user.dart'; + +abstract class ScopeObserver { + Future setContexts(String key, dynamic value); + Future removeContexts(String key); + Future setUser(SentryUser? user); + Future addBreadcrumb(Breadcrumb breadcrumb); + Future clearBreadcrumbs(); + Future setExtra(String key, dynamic value); + Future removeExtra(String key); + Future setTag(String key, String value); + Future removeTag(String key); +} diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index e07dc168c2..4d116f9e08 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -115,10 +115,24 @@ class SentryClient { return _sentryId; } } + + if (_options.platformChecker.platform.isAndroid && + _options.enableScopeSync) { + /* + We do this to avoid duplicate breadcrumbs on Android as sentry-android applies the breadcrumbs + from the native scope onto every envelope sent through it. This scope will contain the breadcrumbs + sent through the scope sync feature. This causes duplicate breadcrumbs. + We then remove the breadcrumbs in all cases but if it is handled == false, + this is a signal that the app would crash and android would lose the breadcrumbs by the time the app is restarted to read + the envelope. + */ + preparedEvent = _eventWithRemovedBreadcrumbsIfHandled(preparedEvent); + } + final envelope = SentryEnvelope.fromEvent( preparedEvent, _options.sdk, - attachments: scope?.attachements, + attachments: scope?.attachments, ); final id = await captureEnvelope(envelope); @@ -297,7 +311,7 @@ class SentryClient { final id = await captureEnvelope( SentryEnvelope.fromTransaction(preparedTransaction, _options.sdk, - attachments: scope?.attachements + attachments: scope?.attachments .where((element) => element.addToTransactions) .toList()), ); @@ -363,6 +377,19 @@ class SentryClient { _options.recorder.recordLostEvent(reason, category); } + SentryEvent _eventWithRemovedBreadcrumbsIfHandled(SentryEvent event) { + final exceptions = event.exceptions ?? []; + final handled = exceptions.isNotEmpty + ? exceptions.first.mechanism?.handled == true + : false; + + if (handled) { + return event.copyWith(breadcrumbs: []); + } else { + return event; + } + } + Future _attachClientReportsAndSend(SentryEnvelope envelope) { final clientReport = _options.recorder.flush(); envelope.addClientReport(clientReport); diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index f03a137218..5a1ac46de8 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -16,7 +16,6 @@ import 'transport/noop_transport.dart'; import 'utils.dart'; import 'version.dart'; -// TODO: Scope observers, enableScopeSync // TODO: shutdownTimeout, flushTimeoutMillis // https://api.dart.dev/stable/2.10.2/dart-io/HttpClient/close.html doesn't have a timeout param, we'd need to implement manually @@ -269,6 +268,17 @@ class SentryOptions { /// Send statistics to sentry when the client drops events. bool sendClientReports = true; + /// If enabled, [scopeObservers] will be called when mutating scope. + bool enableScopeSync = true; + + final List _scopeObservers = []; + + List get scopeObservers => _scopeObservers; + + void addScopeObserver(ScopeObserver scopeObserver) { + _scopeObservers.add(scopeObserver); + } + @internal late ClientReportRecorder recorder = NoOpClientReportRecorder(); diff --git a/dart/test/hub_test.dart b/dart/test/hub_test.dart index 3957a5a66f..6ece6ae3a2 100644 --- a/dart/test/hub_test.dart +++ b/dart/test/hub_test.dart @@ -337,9 +337,10 @@ void main() { test('should configure its scope', () async { hub.configureScope((Scope scope) { scope - ..user = fakeUser ..level = SentryLevel.debug ..fingerprint = ['1', '2']; + + scope.setUser(fakeUser); }); await hub.captureEvent(fakeEvent); @@ -348,13 +349,16 @@ void main() { expect(client.captureEventCalls.first.scope, isNotNull); final scope = client.captureEventCalls.first.scope; + final otherScope = Scope(SentryOptions(dsn: fakeDsn)) + ..level = SentryLevel.debug + ..fingerprint = ['1', '2']; + + otherScope.setUser(fakeUser); + expect( scopeEquals( scope, - Scope(SentryOptions(dsn: fakeDsn)) - ..level = SentryLevel.debug - ..user = fakeUser - ..fingerprint = ['1', '2'], + otherScope, ), true, ); @@ -417,7 +421,7 @@ void main() { final hub = fixture.getSut(); await hub.captureEvent(SentryEvent()); await hub.captureEvent(SentryEvent(), withScope: (scope) { - scope.user = SentryUser(id: 'foo bar'); + scope.setUser(SentryUser(id: 'foo bar')); }); await hub.captureEvent(SentryEvent()); @@ -432,7 +436,7 @@ void main() { final hub = fixture.getSut(); await hub.captureException(Exception('0')); await hub.captureException(Exception('1'), withScope: (scope) { - scope.user = SentryUser(id: 'foo bar'); + scope.setUser(SentryUser(id: 'foo bar')); }); await hub.captureException(Exception('2')); @@ -452,7 +456,7 @@ void main() { final hub = fixture.getSut(); await hub.captureMessage('foo bar 0'); await hub.captureMessage('foo bar 1', withScope: (scope) { - scope.user = SentryUser(id: 'foo bar'); + scope.setUser(SentryUser(id: 'foo bar')); }); await hub.captureMessage('foo bar 2'); diff --git a/dart/test/mocks/mock_platform.dart b/dart/test/mocks/mock_platform.dart index cdd09a082d..3faed41f49 100644 --- a/dart/test/mocks/mock_platform.dart +++ b/dart/test/mocks/mock_platform.dart @@ -3,5 +3,15 @@ import 'package:sentry/src/platform/platform.dart'; import 'no_such_method_provider.dart'; class MockPlatform extends Platform with NoSuchMethodProvider { - const MockPlatform(); + MockPlatform({String? os}) : operatingSystem = os ?? ''; + + factory MockPlatform.android() { + return MockPlatform(os: 'android'); + } + + @override + String operatingSystem; + + @override + bool get isAndroid => (operatingSystem == 'android'); } diff --git a/dart/test/mocks/mock_platform_checker.dart b/dart/test/mocks/mock_platform_checker.dart index 8584aece65..efb58d6c7a 100644 --- a/dart/test/mocks/mock_platform_checker.dart +++ b/dart/test/mocks/mock_platform_checker.dart @@ -1,3 +1,4 @@ +import 'package:sentry/src/platform/platform.dart'; import 'package:sentry/src/platform_checker.dart'; import 'no_such_method_provider.dart'; @@ -9,7 +10,10 @@ class MockPlatformChecker extends PlatformChecker with NoSuchMethodProvider { this.isRelease = false, this.isWebValue = false, this.hasNativeIntegration = false, - }); + Platform? platform, + }) : _platform = platform; + + final Platform? _platform; final bool isDebug; final bool isProfile; @@ -30,4 +34,7 @@ class MockPlatformChecker extends PlatformChecker with NoSuchMethodProvider { @override bool get isWeb => isWebValue; + + @override + Platform get platform => _platform ?? super.platform; } diff --git a/dart/test/mocks/mock_scope_observer.dart b/dart/test/mocks/mock_scope_observer.dart new file mode 100644 index 0000000000..1d7ac2adb1 --- /dev/null +++ b/dart/test/mocks/mock_scope_observer.dart @@ -0,0 +1,58 @@ +import 'package:sentry/sentry.dart'; + +class MockScopeObserver extends ScopeObserver { + bool calledAddBreadcrumb = false; + bool calledClearBreadcrumbs = false; + bool calledRemoveContexts = false; + bool calledRemoveExtra = false; + bool calledRemoveTag = false; + bool calledSetContexts = false; + bool calledSetExtra = false; + bool calledSetTag = false; + bool calledSetUser = false; + + @override + Future addBreadcrumb(Breadcrumb breadcrumb) async { + calledAddBreadcrumb = true; + } + + @override + Future clearBreadcrumbs() async { + calledClearBreadcrumbs = true; + } + + @override + Future removeContexts(String key) async { + calledRemoveContexts = true; + } + + @override + Future removeExtra(String key) async { + calledRemoveExtra = true; + } + + @override + Future removeTag(String key) async { + calledRemoveTag = true; + } + + @override + Future setContexts(String key, value) async { + calledSetContexts = true; + } + + @override + Future setExtra(String key, value) async { + calledSetExtra = true; + } + + @override + Future setTag(String key, String value) async { + calledSetTag = true; + } + + @override + Future setUser(SentryUser? user) async { + calledSetUser = true; + } +} diff --git a/dart/test/scope_test.dart b/dart/test/scope_test.dart index f01b4cf763..afc247f807 100644 --- a/dart/test/scope_test.dart +++ b/dart/test/scope_test.dart @@ -7,6 +7,7 @@ import 'package:test/test.dart'; import 'mocks.dart'; import 'mocks/mock_hub.dart'; +import 'mocks/mock_scope_observer.dart'; void main() { late Fixture fixture; @@ -56,7 +57,7 @@ void main() { final sut = fixture.getSut(); final user = SentryUser(id: 'test'); - sut.user = user; + sut.setUser(user); expect(sut.user, user); }); @@ -164,7 +165,7 @@ void main() { expect(sut.breadcrumbs.length, maxBreadcrumbs); }); - test('clears $Breadcrumb list', () { + test('clears $Breadcrumb list', () async { final sut = fixture.getSut(); final breadcrumb1 = Breadcrumb( @@ -172,7 +173,7 @@ void main() { timestamp: DateTime.utc(2019), ); sut.addBreadcrumb(breadcrumb1); - sut.clear(); + await sut.clear(); expect(sut.breadcrumbs.length, 0); }); @@ -183,19 +184,19 @@ void main() { final attachment = SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt'); sut.addAttachment(attachment); - expect(sut.attachements.last, attachment); - expect(sut.attachements.length, 1); + expect(sut.attachments.last, attachment); + expect(sut.attachments.length, 1); }); - test('clear() removes all $SentryAttachment', () { + test('clear() removes all $SentryAttachment', () async { final sut = fixture.getSut(); final attachment = SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt'); sut.addAttachment(attachment); - expect(sut.attachements.length, 1); - sut.clear(); + expect(sut.attachments.length, 1); + await sut.clear(); - expect(sut.attachements.length, 0); + expect(sut.attachments.length, 0); }); test('clearAttachments() removes all $SentryAttachment', () { @@ -203,10 +204,10 @@ void main() { final attachment = SentryAttachment.fromIntList([0, 0, 0, 0], 'test.txt'); sut.addAttachment(attachment); - expect(sut.attachements.length, 1); + expect(sut.attachments.length, 1); sut.clearAttachments(); - expect(sut.attachements.length, 0); + expect(sut.attachments.length, 0); }); test('sets tag', () { @@ -243,7 +244,7 @@ void main() { expect(sut.extra['test'], null); }); - test('clears $Scope', () { + test('clears $Scope', () async { final sut = fixture.getSut(); final breadcrumb1 = Breadcrumb( @@ -257,7 +258,7 @@ void main() { sut.span = null; final user = SentryUser(id: 'test'); - sut.user = user; + sut.setUser(user); final fingerprints = ['test']; sut.fingerprint = fingerprints; @@ -267,7 +268,7 @@ void main() { sut.addEventProcessor(fixture.processor); - sut.clear(); + await sut.clear(); expect(sut.breadcrumbs.length, 0); @@ -305,7 +306,7 @@ void main() { expect(sut.tags, clone.tags); expect(sut.breadcrumbs, clone.breadcrumbs); expect(sut.contexts, clone.contexts); - expect(sut.attachements, clone.attachements); + expect(sut.attachments, clone.attachments); expect(sut.level, clone.level); expect(ListEquality().equals(sut.fingerprint, clone.fingerprint), true); expect( @@ -332,7 +333,6 @@ void main() { extra: const {'e-infos': 'abc'}, ); final scope = Scope(SentryOptions(dsn: fakeDsn)) - ..user = scopeUser ..fingerprint = ['example-dart'] ..addBreadcrumb(breadcrumb) ..transaction = '/example/app' @@ -342,6 +342,8 @@ void main() { ..setContexts('theme', 'material') ..addEventProcessor(AddTagsEventProcessor({'page-locale': 'en-us'})); + scope.setUser(scopeUser); + final updatedEvent = await scope.applyToEvent(event); expect(updatedEvent?.user, scopeUser); @@ -378,11 +380,12 @@ void main() { breadcrumbs: [eventBreadcrumb], ); final scope = Scope(SentryOptions(dsn: fakeDsn)) - ..user = scopeUser ..fingerprint = ['example-dart'] ..addBreadcrumb(breadcrumb) ..transaction = '/example/app'; + scope.setUser(scopeUser); + final updatedEvent = await scope.applyToEvent(event); expect(updatedEvent?.user, isNotNull); @@ -518,6 +521,69 @@ void main() { expect(updatedTr?.level, isNull); }); + + test('addBreadcrumb should call scope observers', () async { + final sut = fixture.getSut(scopeObserver: fixture.mockScopeObserver); + sut.addBreadcrumb(Breadcrumb()); + + expect(true, fixture.mockScopeObserver.calledAddBreadcrumb); + }); + + test('clearBreadcrumbs should call scope observers', () async { + final sut = fixture.getSut(scopeObserver: fixture.mockScopeObserver); + sut.clearBreadcrumbs(); + + expect(true, fixture.mockScopeObserver.calledClearBreadcrumbs); + }); + + test('removeContexts should call scope observers', () async { + final sut = fixture.getSut(scopeObserver: fixture.mockScopeObserver); + sut.removeContexts('fixture-key'); + + expect(true, fixture.mockScopeObserver.calledRemoveContexts); + }); + + test('removeExtra should call scope observers', () async { + final sut = fixture.getSut(scopeObserver: fixture.mockScopeObserver); + sut.removeExtra('fixture-key'); + + expect(true, fixture.mockScopeObserver.calledRemoveExtra); + }); + + test('removeTag should call scope observers', () async { + final sut = fixture.getSut(scopeObserver: fixture.mockScopeObserver); + sut.removeTag('fixture-key'); + + expect(true, fixture.mockScopeObserver.calledRemoveTag); + }); + + test('setContexts should call scope observers', () async { + final sut = fixture.getSut(scopeObserver: fixture.mockScopeObserver); + sut.setContexts('fixture-key', 'fixture-value'); + + expect(true, fixture.mockScopeObserver.calledSetContexts); + }); + + test('setExtra should call scope observers', () async { + final sut = fixture.getSut(scopeObserver: fixture.mockScopeObserver); + sut.setExtra('fixture-key', 'fixture-value'); + + expect(true, fixture.mockScopeObserver.calledSetExtra); + }); + + test('setTag should call scope observers', () async { + final sut = fixture.getSut(scopeObserver: fixture.mockScopeObserver); + sut.setTag('fixture-key', 'fixture-value'); + + expect(true, fixture.mockScopeObserver.calledSetTag); + }); + + test('setUser should call scope observers', () async { + final sut = fixture.getSut(scopeObserver: fixture.mockScopeObserver); + sut.setUser(null); + + expect(true, fixture.mockScopeObserver.calledSetUser); + }); } class Fixture { @@ -525,14 +591,19 @@ class Fixture { 'name', 'op', ); + final mockScopeObserver = MockScopeObserver(); Scope getSut({ int maxBreadcrumbs = 100, BeforeBreadcrumbCallback? beforeBreadcrumbCallback, + ScopeObserver? scopeObserver, }) { final options = SentryOptions(dsn: fakeDsn); options.maxBreadcrumbs = maxBreadcrumbs; options.beforeBreadcrumb = beforeBreadcrumbCallback; + if (scopeObserver != null) { + options.addScopeObserver(scopeObserver); + } return Scope(options); } diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index 33c5047ccf..445bf6637d 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -17,6 +17,8 @@ import 'mocks.dart'; import 'mocks/mock_client_report_recorder.dart'; import 'mocks/mock_envelope.dart'; import 'mocks/mock_hub.dart'; +import 'mocks/mock_platform.dart'; +import 'mocks/mock_platform_checker.dart'; import 'mocks/mock_transport.dart'; void main() { @@ -498,13 +500,14 @@ void main() { fixture = Fixture(); scope = Scope(fixture.options) - ..user = user ..level = level ..transaction = transaction ..fingerprint = fingerprint ..addBreadcrumb(crumb) ..setTag(scopeTagKey, scopeTagValue) ..setExtra(scopeExtraKey, scopeExtraValue); + + scope.setUser(user); }); test('should apply the scope', () async { @@ -551,11 +554,12 @@ void main() { ); Scope createScope(SentryOptions options) { - return Scope(options) - ..user = user + final scope = Scope(options) ..transaction = transaction ..fingerprint = fingerprint ..addBreadcrumb(crumb); + scope.setUser(user); + return scope; } setUp(() { @@ -583,7 +587,7 @@ void main() { final client = fixture.getSut(sendDefaultPii: true); final scope = createScope(fixture.options); - scope.user = SentryUser(id: '987'); + scope.setUser(SentryUser(id: '987')); var eventWithUser = event.copyWith( user: SentryUser(id: '123', username: 'foo bar'), @@ -606,12 +610,14 @@ void main() { final client = fixture.getSut(sendDefaultPii: true); final scope = createScope(fixture.options); - scope.user = SentryUser( - id: 'id', - extras: { - 'foo': 'bar', - 'bar': 'foo', - }, + scope.setUser( + SentryUser( + id: 'id', + extras: { + 'foo': 'bar', + 'bar': 'foo', + }, + ), ); var eventWithUser = event.copyWith( @@ -858,6 +864,68 @@ void main() { }); }); + group('Breadcrumbs', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('Clears breadcrumbs on Android if mechanism.handled is true', + () async { + fixture.options.enableScopeSync = true; + fixture.options.platformChecker = + MockPlatformChecker(platform: MockPlatform.android()); + + final client = fixture.getSut(); + final event = SentryEvent(exceptions: [ + SentryException( + type: "type", + value: "value", + mechanism: Mechanism( + type: 'type', + handled: true, + ), + ) + ], breadcrumbs: [ + Breadcrumb() + ]); + await client.captureEvent(event); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final capturedEvent = await eventFromEnvelope(capturedEnvelope); + + expect((capturedEvent.breadcrumbs ?? []).isEmpty, true); + }); + + test('Does not clear breadcrumbs on Android if mechanism.handled is false', + () async { + fixture.options.enableScopeSync = true; + fixture.options.platformChecker = + MockPlatformChecker(platform: MockPlatform.android()); + + final client = fixture.getSut(); + final event = SentryEvent(exceptions: [ + SentryException( + type: "type", + value: "value", + mechanism: Mechanism( + type: 'type', + handled: false, + ), + ) + ], breadcrumbs: [ + Breadcrumb() + ]); + await client.captureEvent(event); + + final capturedEnvelope = (fixture.transport).envelopes.first; + final capturedEvent = await eventFromEnvelope(capturedEnvelope); + + expect((capturedEvent.breadcrumbs ?? []).isNotEmpty, true); + }); + }); + group('ClientReportRecorder', () { late Fixture fixture; diff --git a/dart/test/sentry_test.dart b/dart/test/sentry_test.dart index 5593da5836..989f63a58e 100644 --- a/dart/test/sentry_test.dart +++ b/dart/test/sentry_test.dart @@ -46,7 +46,7 @@ void main() { await Sentry.captureEvent( fakeEvent, withScope: (scope) { - scope.user = SentryUser(id: 'foo bar'); + scope.setUser(SentryUser(id: 'foo bar')); }, ); @@ -70,7 +70,7 @@ void main() { test('should capture exception withScope', () async { await Sentry.captureException(anException, withScope: (scope) { - scope.user = SentryUser(id: 'foo bar'); + scope.setUser(SentryUser(id: 'foo bar')); }); expect(client.captureEventCalls.length, 1); expect(client.captureEventCalls.first.event.throwable, anException); @@ -92,7 +92,7 @@ void main() { await Sentry.captureMessage( fakeMessage.formatted, withScope: (scope) { - scope.user = SentryUser(id: 'foo bar'); + scope.setUser(SentryUser(id: 'foo bar')); }, ); diff --git a/dart/test/test_utils.dart b/dart/test/test_utils.dart index 588651678b..3b5738913e 100644 --- a/dart/test/test_utils.dart +++ b/dart/test/test_utils.dart @@ -407,15 +407,22 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { throwable: error, user: eventUser, ); + + final scope = Scope(options); + scope.setUser(clientUser); + await client.captureEvent( eventWithoutContext, - scope: Scope(options)..user = clientUser, + scope: scope, ); expect(loggedUserId, clientUser.id); + final secondScope = Scope(options); + secondScope.setUser(clientUser); + await client.captureEvent( eventWithContext, - scope: Scope(options)..user = clientUser, + scope: secondScope, ); expect(loggedUserId, eventUser.id); } 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 a500caa67b..74b424e6a6 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -10,9 +10,11 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result +import io.sentry.Breadcrumb import io.sentry.HubAdapter import io.sentry.SentryEvent import io.sentry.SentryLevel +import io.sentry.Sentry import io.sentry.android.core.ActivityFramesTracker import io.sentry.android.core.AppStartState import io.sentry.android.core.LoadClass @@ -21,6 +23,7 @@ import io.sentry.android.core.SentryAndroidOptions import io.sentry.protocol.DebugImage import io.sentry.protocol.SdkVersion import io.sentry.protocol.SentryId +import io.sentry.protocol.User import java.io.File import java.lang.ref.WeakReference import java.util.Locale @@ -50,6 +53,15 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { "fetchNativeAppStart" -> fetchNativeAppStart(result) "beginNativeFrames" -> beginNativeFrames(result) "endNativeFrames" -> endNativeFrames(call.argument("id"), result) + "setContexts" -> setContexts(call.argument("key"), call.argument("value"), result) + "removeContexts" -> removeContexts(call.argument("key"), result) + "setUser" -> setUser(call.argument("user"), result) + "addBreadcrumb" -> addBreadcrumb(call.argument("breadcrumb"), result) + "clearBreadcrumbs" -> clearBreadcrumbs(result) + "setExtra" -> setExtra(call.argument("key"), call.argument("value"), result) + "removeExtra" -> removeExtra(call.argument("key"), result) + "setTag" -> setTag(call.argument("key"), call.argument("value"), result) + "removeTag" -> removeTag(call.argument("key"), result) else -> result.notImplemented() } } @@ -226,6 +238,135 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } + private fun setContexts(key: String?, value: Any?, result: Result) { + if (key == null || value == null) { + result.success("") + return + } + Sentry.configureScope { scope -> + scope.setContexts(key, value) + + result.success("") + } + } + + private fun removeContexts(key: String?, result: Result) { + if (key == null) { + result.success("") + return + } + Sentry.configureScope { scope -> + scope.removeContexts(key) + + result.success("") + } + } + + private fun setUser(user: Map?, result: Result) { + if (user == null) { + Sentry.setUser(null) + result.success("") + return + } + + val userInstance = User() + + (user["email"] as? String)?.let { userInstance.email = it } + (user["id"] as? String)?.let { userInstance.id = it } + (user["username"] as? String)?.let { userInstance.username = it } + (user["ip_address"] as? String)?.let { userInstance.ipAddress = it } + (user["extras"] as? Map)?.let { extras -> + val others = mutableMapOf() + extras.forEach { key, value -> + if (value != null) { + others[key] = value.toString() + } + } + userInstance.others = others + } + + Sentry.setUser(userInstance) + + result.success("") + } + + private fun addBreadcrumb(breadcrumb: Map?, result: Result) { + if (breadcrumb == null) { + result.success("") + return + } + val breadcrumbInstance = Breadcrumb() + + (breadcrumb["message"] as? String)?.let { breadcrumbInstance.message = it } + (breadcrumb["type"] as? String)?.let { breadcrumbInstance.type = it } + (breadcrumb["category"] as? String)?.let { breadcrumbInstance.category = it } + (breadcrumb["level"] as? String)?.let { + breadcrumbInstance.level = when (it) { + "fatal" -> SentryLevel.FATAL + "warning" -> SentryLevel.WARNING + "info" -> SentryLevel.INFO + "debug" -> SentryLevel.DEBUG + "error" -> SentryLevel.ERROR + else -> SentryLevel.ERROR + } + } + (breadcrumb["data"] as? Map)?.let { data -> + data.forEach { key, value -> + breadcrumbInstance.data[key] = value + } + } + + Sentry.addBreadcrumb(breadcrumbInstance) + + result.success("") + } + + private fun clearBreadcrumbs(result: Result) { + Sentry.clearBreadcrumbs() + + result.success("") + } + + private fun setExtra(key: String?, value: String?, result: Result) { + if (key == null || value == null) { + result.success("") + return + } + Sentry.setExtra(key, value) + + result.success("") + } + + private fun removeExtra(key: String?, result: Result) { + if (key == null) { + result.success("") + return + } + Sentry.removeExtra(key) + + result.success("") + } + + private fun setTag(key: String?, value: String?, result: Result) { + if (key == null || value == null) { + result.success("") + return + } + Sentry.setTag(key, value) + + result.success("") + } + + private fun removeTag(key: String?, result: Result) { + if (key == null) { + result.success("") + return + } + Sentry.removeTag(key) + + result.success("") + } + private fun captureEnvelope(call: MethodCall, result: Result) { val args = call.arguments() as List? ?: listOf() if (args.isNotEmpty()) { diff --git a/flutter/config/detekt-bl.xml b/flutter/config/detekt-bl.xml index 54e0a51fce..a3fafd4210 100644 --- a/flutter/config/detekt-bl.xml +++ b/flutter/config/detekt-bl.xml @@ -2,6 +2,7 @@ + ComplexMethod:SentryFlutterPlugin.kt$SentryFlutterPlugin$override fun onMethodCall(call: MethodCall, result: Result) MagicNumber:MainActivity.kt$MainActivity$6_000 TooGenericExceptionCaught:MainActivity.kt$MainActivity$e: Exception TooGenericExceptionThrown:MainActivity.kt$MainActivity$throw Exception("Catch this java exception thrown from Kotlin thread!") diff --git a/flutter/ios/Classes/SentryFlutterPluginApple.swift b/flutter/ios/Classes/SentryFlutterPluginApple.swift index 9dc1cee239..11faaa2f16 100644 --- a/flutter/ios/Classes/SentryFlutterPluginApple.swift +++ b/flutter/ios/Classes/SentryFlutterPluginApple.swift @@ -7,6 +7,8 @@ import FlutterMacOS import AppKit #endif +// swiftlint:disable file_length + // swiftlint:disable:next type_body_length public class SentryFlutterPluginApple: NSObject, FlutterPlugin { @@ -53,6 +55,7 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { } + // swiftlint:disable:next cyclomatic_complexity function_body_length public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method as String { case "loadContexts": @@ -76,11 +79,53 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { case "endNativeFrames": endNativeFrames(result: result) + case "setContexts": + let arguments = call.arguments as? [String: Any?] + let key = arguments?["key"] as? String + let value = arguments?["value"] as? Any + setContexts(key: key, value: value, result: result) + + case "setUser": + let arguments = call.arguments as? [String: Any?] + let user = arguments?["user"] as? [String: Any?] + setUser(user: user, result: result) + + case "addBreadcrumb": + let arguments = call.arguments as? [String: Any?] + let breadcrumb = arguments?["breadcrumb"] as? [String: Any?] + addBreadcrumb(breadcrumb: breadcrumb, result: result) + + case "clearBreadcrumbs": + clearBreadcrumbs(result: result) + + case "setExtra": + let arguments = call.arguments as? [String: Any?] + let key = arguments?["key"] as? String + let value = arguments?["value"] as? Any + setExtra(key: key, value: value, result: result) + + case "removeExtra": + let arguments = call.arguments as? [String: Any?] + let key = arguments?["key"] as? String + removeExtra(key: key, result: result) + + case "setTag": + let arguments = call.arguments as? [String: Any?] + let key = arguments?["key"] as? String + let value = arguments?["value"] as? String + setTag(key: key, value: value, result: result) + + case "removeTag": + let arguments = call.arguments as? [String: Any?] + let key = arguments?["key"] as? String + removeTag(key: key, result: result) + default: result(FlutterMethodNotImplemented) } } + // swiftlint:disable:next cyclomatic_complexity private func loadContexts(result: @escaping FlutterResult) { SentrySDK.configureScope { scope in let serializedScope = scope.serialize() @@ -413,4 +458,163 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { result(nil) #endif } + + private func setContexts(key: String?, value: Any?, result: @escaping FlutterResult) { + guard let key = key else { + result("") + return + } + + SentrySDK.configureScope { scope in + if let dictionary = value as? Dictionary { + scope.setContext(value: dictionary, key: key) + } else if let string = value as? String? { + scope.setContext(value: ["value": string], key: key) + } else if let int = value as? Int { + scope.setContext(value: ["value": int], key: key) + } else if let double = value as? Double { + scope.setContext(value: ["value": double], key: key) + } else if let bool = value as? Bool { + scope.setContext(value: ["value": bool], key: key) + } + result("") + } + } + + private func removeContexts(key: String?, result: @escaping FlutterResult) { + guard let key = key else { + result("") + return + } + SentrySDK.configureScope { scope in + scope.removeContext(key: key) + result("") + } + } + + private func setUser(user: Dictionary?, result: @escaping FlutterResult) { + if let user = user { + let userInstance = User() + + if let email = user["email"] as? String { + userInstance.email = email + } + if let id = user["id"] as? String { + userInstance.userId = id + } + if let username = user["username"] as? String { + userInstance.username = username + } + if let ipAddress = user["ip_address"] as? String { + userInstance.ipAddress = ipAddress + } + if let extras = user["extras"] as? Dictionary { + userInstance.data = extras + } + + SentrySDK.setUser(userInstance) + } else { + SentrySDK.setUser(nil) + } + result("") + } + + // swiftlint:disable:next cyclomatic_complexity + private func addBreadcrumb(breadcrumb: Dictionary?, result: @escaping FlutterResult) { + guard let breadcrumb = breadcrumb else { + result("") + return + } + + let breadcrumbInstance = Breadcrumb() + + if let message = breadcrumb["message"] as? String { + breadcrumbInstance.message = message + } + if let type = breadcrumb["type"] as? String { + breadcrumbInstance.type = type + } + if let category = breadcrumb["category"] as? String { + breadcrumbInstance.category = category + } + if let level = breadcrumb["level"] as? String { + switch level { + case "fatal": + breadcrumbInstance.level = SentryLevel.fatal + case "warning": + breadcrumbInstance.level = SentryLevel.warning + case "info": + breadcrumbInstance.level = SentryLevel.info + case "debug": + breadcrumbInstance.level = SentryLevel.debug + case "error": + breadcrumbInstance.level = SentryLevel.error + default: + breadcrumbInstance.level = SentryLevel.error + } + } + if let data = breadcrumb["data"] as? Dictionary { + breadcrumbInstance.data = data + } + + SentrySDK.addBreadcrumb(crumb: breadcrumbInstance) + + result("") + } + + private func clearBreadcrumbs(result: @escaping FlutterResult) { + SentrySDK.configureScope { scope in + scope.clearBreadcrumbs() + + result("") + } + } + + private func setExtra(key: String?, value: Any?, result: @escaping FlutterResult) { + guard let key = key else { + result("") + return + } + SentrySDK.configureScope { scope in + scope.setExtra(value: value, key: key) + + result("") + } + } + + private func removeExtra(key: String?, result: @escaping FlutterResult) { + guard let key = key else { + result("") + return + } + SentrySDK.configureScope { scope in + scope.removeExtra(key: key) + + result("") + } + } + + private func setTag(key: String?, value: String?, result: @escaping FlutterResult) { + guard let key = key, let value = value else { + result("") + return + } + SentrySDK.configureScope { scope in + scope.setTag(value: value, key: key) + + result("") + } + } + + private func removeTag(key: String?, result: @escaping FlutterResult) { + guard let key = key else { + result("") + return + } + SentrySDK.configureScope { scope in + scope.removeTag(key: key) + + result("") + } + } } diff --git a/flutter/lib/src/native_scope_observer.dart b/flutter/lib/src/native_scope_observer.dart new file mode 100644 index 0000000000..0d3dacc9f9 --- /dev/null +++ b/flutter/lib/src/native_scope_observer.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; + +import 'sentry_native.dart'; + +class NativeScopeObserver implements ScopeObserver { + NativeScopeObserver(this._sentryNative); + + final SentryNative _sentryNative; + + @override + Future setContexts(String key, value) async { + await _sentryNative.setContexts(key, value); + } + + @override + Future removeContexts(String key) async { + await _sentryNative.removeContexts(key); + } + + @override + Future setUser(SentryUser? user) async { + await _sentryNative.setUser(user); + } + + @override + Future addBreadcrumb(Breadcrumb breadcrumb) async { + await _sentryNative.addBreadcrumb(breadcrumb); + } + + @override + Future clearBreadcrumbs() async { + await _sentryNative.clearBreadcrumbs(); + } + + @override + Future setExtra(String key, dynamic value) async { + await _sentryNative.setExtra(key, value); + } + + @override + Future removeExtra(String key) async { + await _sentryNative.removeExtra(key); + } + + @override + Future setTag(String key, String value) async { + await _sentryNative.setExtra(key, value); + } + + @override + Future removeTag(String key) async { + await _sentryNative.removeTag(key); + } +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index 879f687a15..2681a8e768 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -6,6 +6,7 @@ import 'package:meta/meta.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:sentry/sentry.dart'; import 'event_processor/android_platform_exception_event_processor.dart'; +import 'native_scope_observer.dart'; import 'sentry_native.dart'; import 'sentry_native_channel.dart'; @@ -83,6 +84,7 @@ mixin SentryFlutter { if (options.platformChecker.platform.isAndroid) { options .addEventProcessor(AndroidPlatformExceptionEventProcessor(options)); + options.addScopeObserver(NativeScopeObserver(SentryNative())); } _setSdk(options); diff --git a/flutter/lib/src/sentry_flutter_options.dart b/flutter/lib/src/sentry_flutter_options.dart index 01e1e505f4..9026d48e3f 100644 --- a/flutter/lib/src/sentry_flutter_options.dart +++ b/flutter/lib/src/sentry_flutter_options.dart @@ -1,7 +1,5 @@ import 'package:sentry/sentry.dart'; -// TODO: Scope observers, enableScopeSync - /// This class adds options which are only availble in a Flutter environment. /// Note that some of these options require native Sentry integration, which is /// not available on all platforms. diff --git a/flutter/lib/src/sentry_native.dart b/flutter/lib/src/sentry_native.dart index 3f2e47ed51..15c48381ff 100644 --- a/flutter/lib/src/sentry_native.dart +++ b/flutter/lib/src/sentry_native.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:meta/meta.dart'; import '../sentry_flutter.dart'; @@ -50,6 +52,44 @@ class SentryNative { return await _nativeChannel?.endNativeFrames(traceId); } + // Scope + + Future setContexts(String key, dynamic value) async { + return await _nativeChannel?.setContexts(key, value); + } + + Future removeContexts(String key) async { + return await _nativeChannel?.removeContexts(key); + } + + Future setUser(SentryUser? sentryUser) async { + return await _nativeChannel?.setUser(sentryUser); + } + + Future addBreadcrumb(Breadcrumb breadcrumb) async { + return await _nativeChannel?.addBreadcrumb(breadcrumb); + } + + Future clearBreadcrumbs() async { + return await _nativeChannel?.clearBreadcrumbs(); + } + + Future setExtra(String key, dynamic value) async { + return await _nativeChannel?.setExtra(key, value); + } + + Future removeExtra(String key) async { + return await _nativeChannel?.removeExtra(key); + } + + Future setTag(String key, String value) async { + return await _nativeChannel?.setTag(key, value); + } + + Future removeTag(String key) async { + return await _nativeChannel?.removeTag(key); + } + /// Reset state void reset() { appStartEnd = null; diff --git a/flutter/lib/src/sentry_native_channel.dart b/flutter/lib/src/sentry_native_channel.dart index c3b693c766..3767158ef7 100644 --- a/flutter/lib/src/sentry_native_channel.dart +++ b/flutter/lib/src/sentry_native_channel.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:meta/meta.dart'; @@ -19,12 +21,7 @@ class SentryNativeChannel { .invokeMapMethod('fetchNativeAppStart'); return (json != null) ? NativeAppStart.fromJson(json) : null; } catch (error, stackTrace) { - _options.logger( - SentryLevel.warning, - 'Native call `fetchNativeAppStart` failed', - exception: error, - stackTrace: stackTrace, - ); + _logError('fetchNativeAppStart', error, stackTrace); return null; } } @@ -33,12 +30,7 @@ class SentryNativeChannel { try { await _channel.invokeMapMethod('beginNativeFrames'); } catch (error, stackTrace) { - _options.logger( - SentryLevel.error, - 'Native call `beginNativeFrames` failed', - exception: error, - stackTrace: stackTrace, - ); + _logError('beginNativeFrames', error, stackTrace); } } @@ -48,15 +40,94 @@ class SentryNativeChannel { 'endNativeFrames', {'id': id.toString()}); return (json != null) ? NativeFrames.fromJson(json) : null; } catch (error, stackTrace) { - _options.logger( - SentryLevel.error, - 'Native call `endNativeFrames` failed', - exception: error, - stackTrace: stackTrace, - ); + _logError('endNativeFrames', error, stackTrace); return null; } } + + Future setUser(SentryUser? user) async { + try { + await _channel.invokeMethod('setUser', {'user': user?.toJson()}); + } catch (error, stackTrace) { + _logError('setUser', error, stackTrace); + } + } + + Future addBreadcrumb(Breadcrumb breadcrumb) async { + try { + await _channel + .invokeMethod('addBreadcrumb', {'breadcrumb': breadcrumb.toJson()}); + } catch (error, stackTrace) { + _logError('addBreadcrumb', error, stackTrace); + } + } + + Future clearBreadcrumbs() async { + try { + await _channel.invokeMethod('clearBreadcrumbs'); + } catch (error, stackTrace) { + _logError('clearBreadcrumbs', error, stackTrace); + } + } + + Future setContexts(String key, dynamic value) async { + try { + await _channel.invokeMethod('setContexts', {'key': key, 'value': value}); + } catch (error, stackTrace) { + _logError('setContexts', error, stackTrace); + } + } + + Future removeContexts(String key) async { + try { + await _channel.invokeMethod('removeContexts', {'key': key}); + } catch (error, stackTrace) { + _logError('removeContexts', error, stackTrace); + } + } + + Future setExtra(String key, dynamic value) async { + try { + await _channel.invokeMethod('setExtra', {'key': key, 'value': value}); + } catch (error, stackTrace) { + _logError('setExtra', error, stackTrace); + } + } + + Future removeExtra(String key) async { + try { + await _channel.invokeMethod('removeExtra', {'key': key}); + } catch (error, stackTrace) { + _logError('removeExtra', error, stackTrace); + } + } + + Future setTag(String key, dynamic value) async { + try { + await _channel.invokeMethod('setTag', {'key': key, 'value': value}); + } catch (error, stackTrace) { + _logError('setTag', error, stackTrace); + } + } + + Future removeTag(String key) async { + try { + await _channel.invokeMethod('removeTag', {'key': key}); + } catch (error, stackTrace) { + _logError('removeTag', error, stackTrace); + } + } + + // Helper + + void _logError(String nativeMethodName, Object error, StackTrace stackTrace) { + _options.logger( + SentryLevel.error, + 'Native call `$nativeMethodName` failed', + exception: error, + stackTrace: stackTrace, + ); + } } class NativeAppStart { diff --git a/flutter/test/mocks.dart b/flutter/test/mocks.dart index 72fe0707ad..0070aaf7f6 100644 --- a/flutter/test/mocks.dart +++ b/flutter/test/mocks.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:mockito/annotations.dart'; import 'package:sentry/sentry.dart'; @@ -152,6 +154,15 @@ class MockNativeChannel implements SentryNativeChannel { int numberOfBeginNativeFramesCalls = 0; int numberOfEndNativeFramesCalls = 0; + int numberOfSetUserCalls = 0; + int numberOfAddBreadcrumbCalls = 0; + int numberOfClearBreadcrumbCalls = 0; + int numberOfRemoveContextsCalls = 0; + int numberOfRemoveExtraCalls = 0; + int numberOfRemoveTagCalls = 0; + int numberOfSetContextsCalls = 0; + int numberOfSetExtraCalls = 0; + int numberOfSetTagCalls = 0; @override Future fetchNativeAppStart() async => nativeAppStart; @@ -167,4 +178,49 @@ class MockNativeChannel implements SentryNativeChannel { numberOfEndNativeFramesCalls += 1; return nativeFrames; } + + @override + Future setUser(SentryUser? user) async { + numberOfSetUserCalls += 1; + } + + @override + Future addBreadcrumb(Breadcrumb breadcrumb) async { + numberOfAddBreadcrumbCalls += 1; + } + + @override + Future clearBreadcrumbs() async { + numberOfClearBreadcrumbCalls += 1; + } + + @override + Future removeContexts(String key) async { + numberOfRemoveContextsCalls += 1; + } + + @override + Future removeExtra(String key) async { + numberOfRemoveExtraCalls += 1; + } + + @override + Future removeTag(String key) async { + numberOfRemoveTagCalls += 1; + } + + @override + Future setContexts(String key, value) async { + numberOfSetContextsCalls += 1; + } + + @override + Future setExtra(String key, value) async { + numberOfSetExtraCalls += 1; + } + + @override + Future setTag(String key, value) async { + numberOfSetTagCalls += 1; + } } diff --git a/flutter/test/sentry_native_channel_test.dart b/flutter/test/sentry_native_channel_test.dart index 29672ecddc..cb145b711a 100644 --- a/flutter/test/sentry_native_channel_test.dart +++ b/flutter/test/sentry_native_channel_test.dart @@ -61,6 +61,111 @@ void main() { expect(actual?.slowFrames, 2); expect(actual?.frozenFrames, 1); }); + + test('setUser', () async { + when(fixture.methodChannel.invokeMethod('setUser', {'user': null})) + .thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.setUser(null); + + verify(fixture.methodChannel.invokeMethod('setUser', {'user': null})); + }); + + test('addBreadcrumb', () async { + final breadcrumb = Breadcrumb(); + when(fixture.methodChannel.invokeMethod( + 'addBreadcrumb', {'breadcrumb': breadcrumb.toJson()})) + .thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.addBreadcrumb(breadcrumb); + + verify(fixture.methodChannel + .invokeMethod('addBreadcrumb', {'breadcrumb': breadcrumb.toJson()})); + }); + + test('clearBreadcrumbs', () async { + when(fixture.methodChannel.invokeMethod('clearBreadcrumbs')) + .thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.clearBreadcrumbs(); + + verify(fixture.methodChannel.invokeMethod('clearBreadcrumbs')); + }); + + test('setContexts', () async { + when(fixture.methodChannel.invokeMethod( + 'setContexts', {'key': 'fixture-key', 'value': 'fixture-value'})) + .thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.setContexts('fixture-key', 'fixture-value'); + + verify(fixture.methodChannel.invokeMethod( + 'setContexts', {'key': 'fixture-key', 'value': 'fixture-value'})); + }); + + test('removeContexts', () async { + when(fixture.methodChannel + .invokeMethod('removeContexts', {'key': 'fixture-key'})) + .thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.removeContexts('fixture-key'); + + verify(fixture.methodChannel + .invokeMethod('removeContexts', {'key': 'fixture-key'})); + }); + + test('setExtra', () async { + when(fixture.methodChannel.invokeMethod( + 'setExtra', {'key': 'fixture-key', 'value': 'fixture-value'})) + .thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.setExtra('fixture-key', 'fixture-value'); + + verify(fixture.methodChannel.invokeMethod( + 'setExtra', {'key': 'fixture-key', 'value': 'fixture-value'})); + }); + + test('removeExtra', () async { + when(fixture.methodChannel + .invokeMethod('removeExtra', {'key': 'fixture-key'})) + .thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.removeExtra('fixture-key'); + + verify(fixture.methodChannel + .invokeMethod('removeExtra', {'key': 'fixture-key'})); + }); + + test('setTag', () async { + when(fixture.methodChannel.invokeMethod( + 'setTag', {'key': 'fixture-key', 'value': 'fixture-value'})) + .thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.setTag('fixture-key', 'fixture-value'); + + verify(fixture.methodChannel.invokeMethod( + 'setTag', {'key': 'fixture-key', 'value': 'fixture-value'})); + }); + + test('removeTag', () async { + when(fixture.methodChannel + .invokeMethod('removeTag', {'key': 'fixture-key'})) + .thenAnswer((_) => Future.value()); + + final sut = fixture.getSut(); + await sut.removeTag('fixture-key'); + + verify(fixture.methodChannel + .invokeMethod('removeTag', {'key': 'fixture-key'})); + }); }); } diff --git a/flutter/test/sentry_native_test.dart b/flutter/test/sentry_native_test.dart index 33587ea3b9..f3ff2b3133 100644 --- a/flutter/test/sentry_native_test.dart +++ b/flutter/test/sentry_native_test.dart @@ -54,6 +54,69 @@ void main() { expect(fixture.channel.numberOfEndNativeFramesCalls, 1); }); + test('setUser', () async { + final sut = fixture.getSut(); + await sut.setUser(null); + + expect(fixture.channel.numberOfSetUserCalls, 1); + }); + + test('addBreadcrumb', () async { + final sut = fixture.getSut(); + await sut.addBreadcrumb(Breadcrumb()); + + expect(fixture.channel.numberOfAddBreadcrumbCalls, 1); + }); + + test('clearBreadcrumbs', () async { + final sut = fixture.getSut(); + await sut.clearBreadcrumbs(); + + expect(fixture.channel.numberOfClearBreadcrumbCalls, 1); + }); + + test('setContexts', () async { + final sut = fixture.getSut(); + await sut.setContexts('fixture-key', 'fixture-value'); + + expect(fixture.channel.numberOfSetContextsCalls, 1); + }); + + test('removeContexts', () async { + final sut = fixture.getSut(); + await sut.removeContexts('fixture-key'); + + expect(fixture.channel.numberOfRemoveContextsCalls, 1); + }); + + test('setExtra', () async { + final sut = fixture.getSut(); + await sut.setExtra('fixture-key', 'fixture-value'); + + expect(fixture.channel.numberOfSetExtraCalls, 1); + }); + + test('removeExtra', () async { + final sut = fixture.getSut(); + await sut.removeExtra('fixture-key'); + + expect(fixture.channel.numberOfRemoveExtraCalls, 1); + }); + + test('setTag', () async { + final sut = fixture.getSut(); + await sut.setTag('fixture-key', 'fixture-value'); + + expect(fixture.channel.numberOfSetTagCalls, 1); + }); + + test('removeTag', () async { + final sut = fixture.getSut(); + await sut.removeTag('fixture-key'); + + expect(fixture.channel.numberOfRemoveTagCalls, 1); + }); + test('reset', () async { final sut = fixture.getSut();