diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ca2ec553e..4c983f6680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Features - Add option to opt out of fatal level for automatically collected errors ([#1738](https://github.com/getsentry/sentry-dart/pull/1738)) +- Add `Hive` breadcrumbs ([#1773](https://github.com/getsentry/sentry-dart/pull/1773)) ### Dependencies diff --git a/hive/lib/src/sentry_span_helper.dart b/hive/lib/src/sentry_span_helper.dart index a1c780ef08..38a67501a4 100644 --- a/hive/lib/src/sentry_span_helper.dart +++ b/hive/lib/src/sentry_span_helper.dart @@ -35,24 +35,44 @@ class SentrySpanHelper { // ignore: invalid_use_of_internal_member span?.origin = _origin; - span?.setData(SentryHiveImpl.dbSystemKey, SentryHiveImpl.dbSystem); + var breadcrumb = Breadcrumb( + message: description, + data: {}, + type: 'query', + ); + span?.setData(SentryHiveImpl.dbSystemKey, SentryHiveImpl.dbSystem); if (dbName != null) { span?.setData(SentryHiveImpl.dbNameKey, dbName); } + breadcrumb.data?[SentryHiveImpl.dbSystemKey] = SentryHiveImpl.dbSystem; + if (dbName != null) { + breadcrumb.data?[SentryHiveImpl.dbNameKey] = dbName; + } + try { final result = await execute(); + span?.status = SpanStatus.ok(); + breadcrumb.data?['status'] = 'ok'; return result; } catch (exception) { span?.throwable = exception; span?.status = SpanStatus.internalError(); + breadcrumb.data?['status'] = 'internal_error'; + breadcrumb = breadcrumb.copyWith( + level: SentryLevel.warning, + ); + rethrow; } finally { await span?.finish(); + + // ignore: invalid_use_of_internal_member + await _hub.scope.addBreadcrumb(breadcrumb); } } } diff --git a/hive/test/sentry_box_base_test.dart b/hive/test/sentry_box_base_test.dart index 3caf5e06e5..3821b8b341 100644 --- a/hive/test/sentry_box_base_test.dart +++ b/hive/test/sentry_box_base_test.dart @@ -40,6 +40,23 @@ void main() { expect(span?.throwable, exception); } + void verifyBreadcrumb( + String message, + Breadcrumb? crumb, { + bool checkName = false, + String status = 'ok', + }) { + expect( + crumb?.message, + message, + ); + expect(crumb?.type, 'query'); + if (checkName) { + expect(crumb?.data?[SentryHiveImpl.dbNameKey], Fixture.dbName); + } + expect(crumb?.data?['status'], status); + } + group('adds span', () { late Fixture fixture; @@ -49,6 +66,7 @@ void main() { when(fixture.hub.options).thenReturn(fixture.options); when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.hub.scope).thenReturn(fixture.scope); }); tearDown(() async { @@ -131,6 +149,7 @@ void main() { when(fixture.hub.options).thenReturn(fixture.options); when(fixture.hub.getSpan()).thenReturn(fixture.tracer); when(fixture.mockBox.name).thenReturn(Fixture.dbName); + when(fixture.hub.scope).thenReturn(fixture.scope); }); tearDown(() async { @@ -253,6 +272,254 @@ void main() { verifyErrorSpan('deleteAt', fixture.exception, fixture.getCreatedSpan()); }); }); + + group('adds breadcrumb', () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + await fixture.setUp(); + + when(fixture.hub.options).thenReturn(fixture.options); + when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.hub.scope).thenReturn(fixture.scope); + }); + + tearDown(() async { + await fixture.tearDown(); + }); + + test('add adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.add(Person('Joe Dirt')); + + verifyBreadcrumb('add', fixture.getCreatedBreadcrumb()); + }); + + test('addAll adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.addAll([Person('Joe Dirt')]); + + verifyBreadcrumb('addAll', fixture.getCreatedBreadcrumb()); + }); + + test('clear adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.clear(); + + verifyBreadcrumb('clear', fixture.getCreatedBreadcrumb()); + }); + + test('close adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.close(); + + verifyBreadcrumb('close', fixture.getCreatedBreadcrumb()); + }); + + test('compact adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.compact(); + + verifyBreadcrumb('compact', fixture.getCreatedBreadcrumb()); + }); + + test('delete adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.delete('fixture-key'); + + verifyBreadcrumb('delete', fixture.getCreatedBreadcrumb()); + }); + + test('deleteAll adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.deleteAll(['fixture-key']); + + verifyBreadcrumb('deleteAll', fixture.getCreatedBreadcrumb()); + }); + + test('deleteAt adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.add(Person('Joe Dirt')); + await sut.deleteAt(0); + + verifyBreadcrumb('deleteAt', fixture.getCreatedBreadcrumb()); + }); + }); + + group('adds error breadcrumb', () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + await fixture.setUp(); + + when(fixture.hub.options).thenReturn(fixture.options); + when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.mockBox.name).thenReturn(Fixture.dbName); + when(fixture.hub.scope).thenReturn(fixture.scope); + }); + + tearDown(() async { + await fixture.tearDown(); + }); + + test('throwing add adds error breadcrumb', () async { + when(fixture.mockBox.add(any)).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + try { + await sut.add(Person('Joe Dirt')); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'add', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing addAll adds error breadcrumb', () async { + when(fixture.mockBox.addAll(any)).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + try { + await sut.addAll([Person('Joe Dirt')]); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'addAll', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing clear adds error breadcrumb', () async { + when(fixture.mockBox.clear()).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + try { + await sut.clear(); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'clear', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing close adds error breadcrumb', () async { + when(fixture.mockBox.close()).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + try { + await sut.close(); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'close', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing compact adds error breadcrumb', () async { + when(fixture.mockBox.compact()).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + try { + await sut.compact(); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'compact', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing delete adds error breadcrumb', () async { + when(fixture.mockBox.delete(any)).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + try { + await sut.delete('fixture-key'); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'delete', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing deleteAll adds error breadcrumb', () async { + when(fixture.mockBox.deleteAll(any)).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + try { + await sut.deleteAll(['fixture-key']); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'deleteAll', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing deleteAt adds error breadcrumb', () async { + when(fixture.mockBox.add(any)).thenAnswer((_) async { + return 1; + }); + when(fixture.mockBox.deleteAt(any)).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + await sut.add(Person('Joe Dirt')); + try { + await sut.deleteAt(0); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'deleteAt', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + }); } class Fixture { @@ -266,6 +533,7 @@ class Fixture { final _context = SentryTransactionContext('name', 'operation'); late final tracer = SentryTracer(_context, hub); + late final scope = Scope(options); Future setUp() async { Hive.init(Directory.systemTemp.path); @@ -294,4 +562,8 @@ class Fixture { SentrySpan? getCreatedSpan() { return tracer.children.last; } + + Breadcrumb? getCreatedBreadcrumb() { + return hub.scope.breadcrumbs.last; + } } diff --git a/hive/test/sentry_box_collection_test.dart b/hive/test/sentry_box_collection_test.dart index 4965896611..f13b95e1c8 100644 --- a/hive/test/sentry_box_collection_test.dart +++ b/hive/test/sentry_box_collection_test.dart @@ -42,6 +42,23 @@ void main() { expect(span?.throwable, exception); } + void verifyBreadcrumb( + String message, + Breadcrumb? crumb, { + bool checkName = false, + String status = 'ok', + }) { + expect( + crumb?.message, + message, + ); + expect(crumb?.type, 'query'); + if (checkName) { + expect(crumb?.data?[SentryHiveImpl.dbNameKey], Fixture.dbName); + } + expect(crumb?.data?['status'], status); + } + group('adds span when calling', () { late Fixture fixture; @@ -51,6 +68,7 @@ void main() { when(fixture.hub.options).thenReturn(fixture.options); when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.hub.scope).thenReturn(fixture.scope); }); tearDown(() async { @@ -111,6 +129,7 @@ void main() { when(fixture.hub.options).thenReturn(fixture.options); when(fixture.hub.getSpan()).thenReturn(fixture.tracer); when(fixture.mockBoxCollection.name).thenReturn(Fixture.dbName); + when(fixture.hub.scope).thenReturn(fixture.scope); }); tearDown(() async { @@ -184,6 +203,155 @@ void main() { ); }); }); + + group('adds breadcrumb when calling', () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + await fixture.setUp(); + + when(fixture.hub.options).thenReturn(fixture.options); + when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.hub.scope).thenReturn(fixture.scope); + }); + + tearDown(() async { + await fixture.tearDown(); + }); + + test('open', () async { + await SentryBoxCollection.open( + Fixture.dbName, + {'people'}, + hub: fixture.hub, + ); + + final span = fixture.getCreatedBreadcrumb(); + verifyBreadcrumb('open', span); + }); + + test('openBox', () async { + final sut = await fixture.getSut(); + + await sut.openBox('people'); + + final span = fixture.getCreatedBreadcrumb(); + verifyBreadcrumb('openBox', span); + }); + + test('transaction', () async { + final sut = await fixture.getSut(); + + final people = await sut.openBox('people'); + await sut.transaction( + () async { + print(people.name); + }, + boxNames: ['people'], + ); + final span = fixture.getCreatedBreadcrumb(); + verifyBreadcrumb('transaction', span); + }); + + test('deleteFromDisk', () async { + final sut = await fixture.getSut(); + + await sut.deleteFromDisk(); + + final span = fixture.getCreatedBreadcrumb(); + verifyBreadcrumb('deleteFromDisk', span); + }); + }); + + group('adds error breadcrumb when calling', () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + await fixture.setUp(); + + when(fixture.hub.options).thenReturn(fixture.options); + when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.mockBoxCollection.name).thenReturn(Fixture.dbName); + when(fixture.hub.scope).thenReturn(fixture.scope); + }); + + tearDown(() async { + await fixture.tearDown(); + }); + + // open is static and cannot be mocked + + test('throwing openBox', () async { + when( + // ignore: inference_failure_on_function_invocation + fixture.mockBoxCollection.openBox( + any, + preload: anyNamed('preload'), + boxCreator: anyNamed('boxCreator'), + ), + ).thenThrow(fixture.exception); + + final sut = await fixture.getSut(injectMock: true); + + try { + // ignore: inference_failure_on_function_invocation + await sut.openBox('people'); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'openBox', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing transaction', () async { + when( + fixture.mockBoxCollection.transaction( + any, + boxNames: anyNamed('boxNames'), + readOnly: anyNamed('readOnly'), + ), + ).thenThrow(fixture.exception); + + final sut = await fixture.getSut(injectMock: true); + + try { + await sut.transaction(() async {}); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'transaction', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing deleteFromDisk', () async { + when(fixture.mockBoxCollection.deleteFromDisk()) + .thenThrow(fixture.exception); + + final sut = await fixture.getSut(injectMock: true); + + try { + await sut.deleteFromDisk(); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'deleteFromDisk', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + }); } class Fixture { @@ -197,6 +365,7 @@ class Fixture { final _context = SentryTransactionContext('name', 'operation'); late final tracer = SentryTracer(_context, hub); + late final scope = Scope(options); Future setUp() async { SentryHive.init(Directory.systemTemp.path); @@ -222,4 +391,8 @@ class Fixture { SentrySpan? getCreatedSpan() { return tracer.children.last; } + + Breadcrumb? getCreatedBreadcrumb() { + return hub.scope.breadcrumbs.last; + } } diff --git a/hive/test/sentry_hive_impl_test.dart b/hive/test/sentry_hive_impl_test.dart index df5c8502f9..521415d1ed 100644 --- a/hive/test/sentry_hive_impl_test.dart +++ b/hive/test/sentry_hive_impl_test.dart @@ -41,6 +41,23 @@ void main() { expect(span?.throwable, error); } + void verifyBreadcrumb( + String message, + Breadcrumb? crumb, { + bool checkName = false, + String status = 'ok', + }) { + expect( + crumb?.message, + message, + ); + expect(crumb?.type, 'query'); + if (checkName) { + expect(crumb?.data?[SentryHiveImpl.dbNameKey], Fixture.dbName); + } + expect(crumb?.data?['status'], status); + } + group('adds span', () { late Fixture fixture; @@ -49,6 +66,7 @@ void main() { when(fixture.hub.options).thenReturn(fixture.options); when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.hub.scope).thenReturn(fixture.scope); await fixture.setUp(); }); @@ -119,6 +137,7 @@ void main() { when(fixture.hub.options).thenReturn(fixture.options); when(fixture.hub.getSpan()).thenReturn(fixture.tracer); when(fixture.mockHive.close()).thenAnswer((_) async => {}); + when(fixture.hub.scope).thenReturn(fixture.scope); await fixture.setUp(injectMockHive: true); }); @@ -274,6 +293,259 @@ void main() { }); }); + group('adds breadcrumbs', () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + + when(fixture.hub.options).thenReturn(fixture.options); + when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.hub.scope).thenReturn(fixture.scope); + + await fixture.setUp(); + }); + + tearDown(() async { + await fixture.tearDown(); + }); + + test('boxExists adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.openBox(Fixture.dbName); + await sut.boxExists(Fixture.dbName); + + verifyBreadcrumb('boxExists', fixture.getCreatedBreadcrumb()); + }); + + test('close adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.close(); + + verifyBreadcrumb('close', fixture.getCreatedBreadcrumb()); + }); + + test('deleteBoxFromDisk adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.openBox(Fixture.dbName); + await sut.deleteBoxFromDisk(Fixture.dbName); + + verifyBreadcrumb('deleteBoxFromDisk', fixture.getCreatedBreadcrumb()); + }); + + test('deleteFromDisk adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.deleteFromDisk(); + + verifyBreadcrumb('deleteFromDisk', fixture.getCreatedBreadcrumb()); + }); + + test('openBox adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.openBox(Fixture.dbName); + + verifyBreadcrumb( + 'openBox', + fixture.getCreatedBreadcrumb(), + checkName: true, + ); + }); + + test('openLazyBox adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.openLazyBox(Fixture.dbName); + + verifyBreadcrumb( + 'openLazyBox', + fixture.getCreatedBreadcrumb(), + checkName: true, + ); + }); + }); + + group('adds error breadcrumb', () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + + when(fixture.hub.options).thenReturn(fixture.options); + when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.mockHive.close()).thenAnswer((_) async => {}); + when(fixture.hub.scope).thenReturn(fixture.scope); + + await fixture.setUp(injectMockHive: true); + }); + + test('throwing boxExists adds error span', () async { + final Box box = MockBox(); + when( + fixture.mockHive.openBox( + any, + encryptionCipher: anyNamed('encryptionCipher'), + keyComparator: anyNamed('keyComparator'), + compactionStrategy: anyNamed('compactionStrategy'), + crashRecovery: anyNamed('crashRecovery'), + path: anyNamed('path'), + bytes: anyNamed('bytes'), + collection: anyNamed('collection'), + encryptionKey: anyNamed('encryptionKey'), + ), + ).thenAnswer((_) => Future(() => box)); + when(fixture.mockHive.boxExists(any)).thenThrow(fixture.exception); + + final sut = fixture.getSut(); + + await sut.openBox(Fixture.dbName); + try { + await sut.boxExists(Fixture.dbName); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'boxExists', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing close adds error span', () async { + when(fixture.mockHive.close()).thenThrow(fixture.exception); + + final sut = fixture.getSut(); + + try { + await sut.close(); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'close', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing deleteBoxFromDisk adds error span', () async { + final Box box = MockBox(); + when( + fixture.mockHive.openBox( + any, + encryptionCipher: anyNamed('encryptionCipher'), + keyComparator: anyNamed('keyComparator'), + compactionStrategy: anyNamed('compactionStrategy'), + crashRecovery: anyNamed('crashRecovery'), + path: anyNamed('path'), + bytes: anyNamed('bytes'), + collection: anyNamed('collection'), + encryptionKey: anyNamed('encryptionKey'), + ), + ).thenAnswer((_) => Future(() => box)); + when(fixture.mockHive.deleteBoxFromDisk(any)) + .thenThrow(fixture.exception); + + final sut = fixture.getSut(); + + await sut.openBox(Fixture.dbName); + try { + await sut.deleteBoxFromDisk(Fixture.dbName); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'deleteBoxFromDisk', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing deleteFromDisk adds error span', () async { + when(fixture.mockHive.deleteFromDisk()).thenThrow(fixture.exception); + + final sut = fixture.getSut(); + + try { + await sut.deleteFromDisk(); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'deleteFromDisk', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing openBox adds error span', () async { + when( + fixture.mockHive.openBox( + any, + encryptionCipher: anyNamed('encryptionCipher'), + keyComparator: anyNamed('keyComparator'), + compactionStrategy: anyNamed('compactionStrategy'), + crashRecovery: anyNamed('crashRecovery'), + path: anyNamed('path'), + bytes: anyNamed('bytes'), + collection: anyNamed('collection'), + encryptionKey: anyNamed('encryptionKey'), + ), + ).thenThrow(fixture.exception); + + final sut = fixture.getSut(); + + try { + await sut.openBox(Fixture.dbName); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'openBox', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing openLazyBox adds error span', () async { + when( + fixture.mockHive.openLazyBox( + any, + encryptionCipher: anyNamed('encryptionCipher'), + keyComparator: anyNamed('keyComparator'), + compactionStrategy: anyNamed('compactionStrategy'), + crashRecovery: anyNamed('crashRecovery'), + path: anyNamed('path'), + collection: anyNamed('collection'), + encryptionKey: anyNamed('encryptionKey'), + ), + ).thenThrow(fixture.exception); + + final sut = fixture.getSut(); + + try { + await sut.openLazyBox(Fixture.dbName); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'openLazyBox', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + }); + group('integrations', () { late Fixture fixture; @@ -282,6 +554,7 @@ void main() { when(fixture.hub.options).thenReturn(fixture.options); when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.hub.scope).thenReturn(fixture.scope); await fixture.setUp(); }); @@ -319,6 +592,7 @@ class Fixture { final _context = SentryTransactionContext('name', 'operation'); late final tracer = SentryTracer(_context, hub); late SentryHiveImpl sut; + late final scope = Scope(options); Future setUp({bool injectMockHive = false}) async { if (injectMockHive) { @@ -344,4 +618,8 @@ class Fixture { SentrySpan? getCreatedSpan() { return tracer.children.last; } + + Breadcrumb? getCreatedBreadcrumb() { + return hub.scope.breadcrumbs.last; + } } diff --git a/hive/test/sentry_lazy_box_test.dart b/hive/test/sentry_lazy_box_test.dart index 263658af8d..291dc3fc20 100644 --- a/hive/test/sentry_lazy_box_test.dart +++ b/hive/test/sentry_lazy_box_test.dart @@ -40,6 +40,23 @@ void main() { expect(span?.throwable, exception); } + void verifyBreadcrumb( + String message, + Breadcrumb? crumb, { + bool checkName = false, + String status = 'ok', + }) { + expect( + crumb?.message, + message, + ); + expect(crumb?.type, 'query'); + if (checkName) { + expect(crumb?.data?[SentryHiveImpl.dbNameKey], Fixture.dbName); + } + expect(crumb?.data?['status'], status); + } + group('adds span', () { late Fixture fixture; @@ -49,6 +66,7 @@ void main() { when(fixture.hub.options).thenReturn(fixture.options); when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.hub.scope).thenReturn(fixture.scope); }); tearDown(() async { @@ -84,6 +102,7 @@ void main() { when(fixture.hub.options).thenReturn(fixture.options); when(fixture.hub.getSpan()).thenReturn(fixture.tracer); when(fixture.mockBox.name).thenReturn(Fixture.dbName); + when(fixture.hub.scope).thenReturn(fixture.scope); }); tearDown(() async { @@ -126,6 +145,103 @@ void main() { verifyErrorSpan('getAt', fixture.exception, fixture.getCreatedSpan()); }); }); + + group('adds breadcrumb', () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + await fixture.setUp(); + + when(fixture.hub.options).thenReturn(fixture.options); + when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.hub.scope).thenReturn(fixture.scope); + }); + + tearDown(() async { + await fixture.tearDown(); + }); + + test('get adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.put('fixture-key', Person('John Malkovich')); + await sut.get('fixture-key'); + + verifyBreadcrumb('get', fixture.getCreatedBreadcrumb()); + }); + + test('getAt adds breadcrumb', () async { + final sut = fixture.getSut(); + + await sut.add(Person('John Malkovich')); + await sut.getAt(0); + + verifyBreadcrumb('getAt', fixture.getCreatedBreadcrumb()); + }); + }); + + group('adds error breadcrumbs', () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + await fixture.setUp(); + + when(fixture.hub.options).thenReturn(fixture.options); + when(fixture.hub.getSpan()).thenReturn(fixture.tracer); + when(fixture.mockBox.name).thenReturn(Fixture.dbName); + when(fixture.hub.scope).thenReturn(fixture.scope); + }); + + tearDown(() async { + await fixture.tearDown(); + }); + + test('throwing get adds error breadcrumbs', () async { + when(fixture.mockBox.add(any)).thenAnswer((_) async { + return 1; + }); + when(fixture.mockBox.get(any)).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + await sut.put('fixture-key', Person('John Malkovich')); + try { + await sut.get('fixture-key'); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'get', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + + test('throwing getAt adds error breadcrumbs', () async { + when(fixture.mockBox.add(any)).thenAnswer((_) async { + return 1; + }); + when(fixture.mockBox.getAt(any)).thenThrow(fixture.exception); + + final sut = fixture.getSut(injectMockBox: true); + + await sut.add(Person('John Malkovich')); + try { + await sut.getAt(0); + } catch (error) { + expect(error, fixture.exception); + } + + verifyBreadcrumb( + 'getAt', + fixture.getCreatedBreadcrumb(), + status: 'internal_error', + ); + }); + }); } class Fixture { @@ -139,6 +255,7 @@ class Fixture { final _context = SentryTransactionContext('name', 'operation'); late final tracer = SentryTracer(_context, hub); + late final scope = Scope(options); Future setUp() async { Hive.init(Directory.systemTemp.path); @@ -167,4 +284,8 @@ class Fixture { SentrySpan? getCreatedSpan() { return tracer.children.last; } + + Breadcrumb? getCreatedBreadcrumb() { + return hub.scope.breadcrumbs.last; + } }