diff --git a/CHANGELOG.md b/CHANGELOG.md index b313feb811..c5a51ed029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ - new Dart code file structure #96 - Base the sdk name on the platform (`sentry.dart` for io & flutter, `sentry.dart.browser` in a browser context) #103 - Single changelog and readme for both packages #105 +- new static API : Sentry.init(), Sentry.captureEvent() #108 - expect a sdkName based on the test platform #105 +- Added Scope and Breadcrumb ring buffer #109 # `package:sentry` changelog diff --git a/dart/example/event_example.dart b/dart/example/event_example.dart new file mode 100644 index 0000000000..b1115cf7ef --- /dev/null +++ b/dart/example/event_example.dart @@ -0,0 +1,73 @@ +import 'package:sentry/src/protocol.dart'; + +final event = Event( + loggerName: 'main', + serverName: 'server.dart', + release: '1.4.0-preview.1', + environment: 'Test', + message: Message(formatted: 'This is an example Dart event.'), + transaction: '/example/app', + level: SeverityLevel.warning, + tags: const {'project-id': '7371'}, + extra: const {'company-name': 'Dart Inc'}, + fingerprint: const ['example-dart'], + userContext: const User( + id: '800', + username: 'first-user', + email: 'first@user.lan', + ipAddress: '127.0.0.1', + extras: {'first-sign-in': '2020-01-01'}), + breadcrumbs: [ + Breadcrumb('UI Lifecycle', DateTime.now().toUtc(), + category: 'ui.lifecycle', + type: 'navigation', + data: {'screen': 'MainActivity', 'state': 'created'}, + level: SeverityLevel.info) + ], + contexts: Contexts( + operatingSystem: const OperatingSystem( + name: 'Android', + version: '5.0.2', + build: 'LRX22G.P900XXS0BPL2', + kernelVersion: + 'Linux version 3.4.39-5726670 (dpi@SWHC3807) (gcc version 4.8 (GCC) ) #1 SMP PREEMPT Thu Dec 1 19:42:39 KST 2016', + rooted: false), + runtimes: [const Runtime(name: 'ART', version: '5')], + app: App( + name: 'Example Dart App', + version: '1.42.0', + identifier: 'HGT-App-13', + build: '93785', + buildType: 'release', + deviceAppHash: '5afd3a6', + startTime: DateTime.now().toUtc()), + browser: const Browser(name: 'Firefox', version: '42.0.1'), + device: Device( + name: 'SM-P900', + family: 'SM-P900', + model: 'SM-P900 (LRX22G)', + modelId: 'LRX22G', + arch: 'armeabi-v7a', + batteryLevel: 99, + orientation: Orientation.landscape, + manufacturer: 'samsung', + brand: 'samsung', + screenResolution: '2560x1600', + screenDensity: 2.1, + screenDpi: 320, + online: true, + charging: true, + lowMemory: true, + simulator: false, + memorySize: 1500, + freeMemory: 200, + usableMemory: 4294967296, + storageSize: 4294967296, + freeStorage: 2147483648, + externalStorageSize: 8589934592, + externalFreeStorage: 2863311530, + bootTime: DateTime.now().toUtc(), + timezone: 'America/Toronto', + ), + ), +); diff --git a/dart/example/main.dart b/dart/example/main.dart index 14ee2dbb3d..ff859af407 100644 --- a/dart/example/main.dart +++ b/dart/example/main.dart @@ -7,6 +7,8 @@ import 'dart:io'; import 'package:sentry/sentry.dart'; +import 'event_example.dart'; + /// Sends a test exception report to Sentry.io using this Dart client. Future main(List rawArgs) async { if (rawArgs.length != 1) { @@ -16,18 +18,26 @@ Future main(List rawArgs) async { } final dsn = rawArgs.single; - final client = SentryClient(dsn: dsn); + Sentry.init((options) => options.dsn = dsn); + + print('\nReporting a complete event example: '); // Sends a full Sentry event payload to show the different parts of the UI. - await captureCompleteExampleEvent(client); + final response = await Sentry.captureEvent(event); + + if (response.isSuccessful) { + print('SUCCESS\nid: ${response.eventId}'); + } else { + print('FAILURE: ${response.error}'); + } try { await foo(); } catch (error, stackTrace) { print('\nReporting the following stack trace: '); print(stackTrace); - final response = await client.captureException( - exception: error, + final response = await Sentry.captureException( + error, stackTrace: stackTrace, ); @@ -37,89 +47,10 @@ Future main(List rawArgs) async { print('FAILURE: ${response.error}'); } } finally { - await client.close(); + await Sentry.close(); } -} - -Future captureCompleteExampleEvent(SentryClient client) async { - final event = Event( - loggerName: 'main', - serverName: 'server.dart', - release: '1.4.0-preview.1', - environment: 'Test', - message: Message(formatted: 'This is an example Dart event.'), - transaction: '/example/app', - level: SeverityLevel.warning, - tags: const {'project-id': '7371'}, - extra: const {'company-name': 'Dart Inc'}, - fingerprint: const ['example-dart'], - userContext: const User( - id: '800', - username: 'first-user', - email: 'first@user.lan', - ipAddress: '127.0.0.1', - extras: {'first-sign-in': '2020-01-01'}), - breadcrumbs: [ - Breadcrumb('UI Lifecycle', DateTime.now().toUtc(), - category: 'ui.lifecycle', - type: 'navigation', - data: {'screen': 'MainActivity', 'state': 'created'}, - level: SeverityLevel.info) - ], - contexts: Contexts( - operatingSystem: const OperatingSystem( - name: 'Android', - version: '5.0.2', - build: 'LRX22G.P900XXS0BPL2', - kernelVersion: - 'Linux version 3.4.39-5726670 (dpi@SWHC3807) (gcc version 4.8 (GCC) ) #1 SMP PREEMPT Thu Dec 1 19:42:39 KST 2016', - rooted: false), - runtimes: [const Runtime(name: 'ART', version: '5')], - app: App( - name: 'Example Dart App', - version: '1.42.0', - identifier: 'HGT-App-13', - build: '93785', - buildType: 'release', - deviceAppHash: '5afd3a6', - startTime: DateTime.now().toUtc()), - browser: const Browser(name: 'Firefox', version: '42.0.1'), - device: Device( - name: 'SM-P900', - family: 'SM-P900', - model: 'SM-P900 (LRX22G)', - modelId: 'LRX22G', - arch: 'armeabi-v7a', - batteryLevel: 99, - orientation: Orientation.landscape, - manufacturer: 'samsung', - brand: 'samsung', - screenResolution: '2560x1600', - screenDensity: 2.1, - screenDpi: 320, - online: true, - charging: true, - lowMemory: true, - simulator: false, - memorySize: 1500, - freeMemory: 200, - usableMemory: 4294967296, - storageSize: 4294967296, - freeStorage: 2147483648, - externalStorageSize: 8589934592, - externalFreeStorage: 2863311530, - bootTime: DateTime.now().toUtc(), - timezone: 'America/Toronto', - ))); - - final response = await client.captureEvent(event: event); - print('\nReporting a complete event example: '); - if (response.isSuccessful) { - print('SUCCESS\nid: ${response.eventId}'); - } else { - print('FAILURE: ${response.error}'); - } + /* TODO(rxlabz) Sentry CaptureMessage(message, level) */ } Future foo() async { diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index dcaa278d42..471852847b 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -5,4 +5,8 @@ /// A pure Dart client for Sentry.io crash reporting. export 'src/client.dart'; export 'src/protocol.dart'; +export 'src/sentry.dart'; +export 'src/sentry_options.dart'; export 'src/version.dart'; +export 'src/scope.dart'; +export 'src/sentry_options.dart'; diff --git a/dart/lib/src/scope.dart b/dart/lib/src/scope.dart new file mode 100644 index 0000000000..63e1c85ec8 --- /dev/null +++ b/dart/lib/src/scope.dart @@ -0,0 +1,139 @@ +import 'dart:collection'; + +import 'protocol.dart'; +import 'sentry_options.dart'; + +/// Scope data to be sent with the event +class Scope { + /// How important this event is. + SeverityLevel _level; + + SeverityLevel get level => _level; + + set level(SeverityLevel level) { + _level = level; + } + + /// The name of the transaction which generated this event, + /// for example, the route name: `"/users//"`. + String _transaction; + + String get transaction => _transaction; + + set transaction(String transaction) { + _transaction = transaction; + } + + /// Information about the current user. + User _user; + + User get user => _user; + + set user(User user) { + _user = user; + } + + /// Used to deduplicate events by grouping ones with the same fingerprint + /// together. + /// + /// Example: + /// + /// // A completely custom fingerprint: + /// var custom = ['foo', 'bar', 'baz']; + List _fingerprint; + + List get fingerprint => + _fingerprint != null ? List.unmodifiable(_fingerprint) : null; + + set fingerprint(List fingerprint) { + _fingerprint = fingerprint; + } + + /// List of breadcrumbs for this scope. + /// + /// See also: + /// * https://docs.sentry.io/enriching-error-data/breadcrumbs/?platform=javascript + final Queue _breadcrumbs = Queue(); + + /// Unmodifiable List of breadcrumbs + List get breadcrumbs => List.unmodifiable(_breadcrumbs); + + /// Name/value pairs that events can be searched by. + final Map _tags = {}; + + Map get tags => Map.unmodifiable(_tags); + + /// Arbitrary name/value pairs attached to the scope. + /// + /// Sentry.io docs do not talk about restrictions on the values, other than + /// they must be JSON-serializable. + final Map _extra = {}; + + Map get extra => Map.unmodifiable(_extra); + + // TODO: EventProcessors, Contexts, BeforeBreadcrumbCallback, Breadcrumb Hint, clone + + final SentryOptions _options; + + Scope(this._options) : assert(_options != null, 'SentryOptions is required'); + + /// Adds a breadcrumb to the breadcrumbs queue + void addBreadcrumb(Breadcrumb breadcrumb) { + assert(breadcrumb != null, "Breadcrumb can't be null"); + + // bail out if maxBreadcrumbs is zero + if (_options.maxBreadcrumbs == 0) { + return; + } + + // remove first item if list if full + if (_breadcrumbs.length >= _options.maxBreadcrumbs && + _breadcrumbs.isNotEmpty) { + _breadcrumbs.removeFirst(); + } + + _breadcrumbs.add(breadcrumb); + } + + /// Clear all the breadcrumbs + void clearBreadcrumbs() { + _breadcrumbs.clear(); + } + + /// Resets the Scope to its default state + void clear() { + clearBreadcrumbs(); + _level = null; + _transaction = null; + _user = null; + _fingerprint = null; + _tags.clear(); + _extra.clear(); + } + + /// Sets a tag to the Scope + void setTag(String key, String value) { + assert(key != null, "Key can't be null"); + assert(value != null, "Key can't be null"); + + _tags[key] = value; + } + + /// Removes a tag from the Scope + void removeTag(String key) { + _tags.remove(key); + } + + /// Sets an extra to the Scope + void setExtra(String key, dynamic value) { + assert(key != null, "Key can't be null"); + assert(value != null, "Value can't be null"); + + _extra[key] = value; + } + + /// Removes an extra from the Scope + void removeExtra(String key) { + _extra.remove(key); + } +} diff --git a/dart/lib/src/sentry.dart b/dart/lib/src/sentry.dart new file mode 100644 index 0000000000..382026acd5 --- /dev/null +++ b/dart/lib/src/sentry.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import 'client.dart'; +import 'protocol.dart'; +import 'sentry_options.dart'; + +/// Configuration options callback +typedef OptionsConfiguration = void Function(SentryOptions); + +/// Sentry SDK main entry point +/// +class Sentry { + static SentryClient _client; + + Sentry._(); + + static void init(OptionsConfiguration optionsConfiguration) { + final options = SentryOptions(); + optionsConfiguration(options); + _client = SentryClient( + dsn: options.dsn, + environmentAttributes: options.environmentAttributes, + compressPayload: options.compressPayload, + httpClient: options.httpClient, + clock: options.clock, + uuidGenerator: options.uuidGenerator, + ); + } + + /// Reports an [event] to Sentry.io. + static Future captureEvent(Event event) async { + return _client.captureEvent(event: event); + } + + /// Reports the [exception] and optionally its [stackTrace] to Sentry.io. + static Future captureException( + dynamic error, { + dynamic stackTrace, + }) async { + return _client.captureException( + exception: error, + stackTrace: stackTrace, + ); + } + + /// Close the client SDK + static Future close() async => _client.close(); + + /// client injector only use for testing + @visibleForTesting + static void initClient(SentryClient client) => _client = client; +} diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart new file mode 100644 index 0000000000..dbcfa7ca6a --- /dev/null +++ b/dart/lib/src/sentry_options.dart @@ -0,0 +1,56 @@ +import 'package:http/http.dart'; + +import 'protocol.dart'; +import 'utils.dart'; + +/// Sentry SDK options +class SentryOptions { + /// Default Log level if not specified Default is DEBUG + static final SeverityLevel defaultDiagnosticLevel = SeverityLevel.debug; + + /// The DSN tells the SDK where to send the events to. If this value is not provided, the SDK will + /// just not send any events. + String dsn; + + /// Contains [Event] attributes that are automatically mixed into all events + /// captured through this client. + /// + /// This event is designed to contain static values that do not change from + /// event to event, such as local operating system version, the version of + /// Dart/Flutter SDK, etc. These attributes have lower precedence than those + /// supplied in the even passed to [capture]. + Event environmentAttributes; + + /// If [compressPayload] is `true` the outgoing HTTP payloads are compressed + /// using gzip. Otherwise, the payloads are sent in plain UTF8-encoded JSON + /// text. If not specified, the compression is enabled by default. + bool compressPayload; + + /// If [httpClient] is provided, it is used instead of the default client to + /// make HTTP calls to Sentry.io. This is useful in tests. + Client httpClient; + + /// If [clock] is provided, it is used to get time instead of the system + /// clock. This is useful in tests. Should be an implementation of [ClockProvider]. + /// This parameter is dynamic to maintain backwards compatibility with + /// previous use of [Clock](https://pub.dartlang.org/documentation/quiver/latest/quiver.time/Clock-class.html) + /// from [`package:quiver`](https://pub.dartlang.org/packages/quiver). + dynamic clock; + + /// If [uuidGenerator] is provided, it is used to generate the "event_id" + /// field instead of the built-in random UUID v4 generator. This is useful in + /// tests. + UuidGenerator uuidGenerator; + + int maxBreadcrumbs; + + SentryOptions({ + this.dsn, + this.environmentAttributes, + this.compressPayload, + this.httpClient, + this.clock, + this.uuidGenerator, + this.maxBreadcrumbs = 100, + }); +} diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index e0fd10bec6..389acefa7e 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: uuid: ^2.0.0 dev_dependencies: - mockito: ^4.1.2 + mockito: ^4.1.1 pedantic: ^1.9.2 test: ^1.15.4 yaml: ^2.2.1 diff --git a/dart/test/scope_test.dart b/dart/test/scope_test.dart new file mode 100644 index 0000000000..2139b3d772 --- /dev/null +++ b/dart/test/scope_test.dart @@ -0,0 +1,175 @@ +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +void main() { + final fixture = Fixture(); + + test('sets $SeverityLevel', () { + final sut = fixture.getSut(); + + sut.level = SeverityLevel.debug; + + expect(sut.level, SeverityLevel.debug); + }); + + test('sets transaction', () { + final sut = fixture.getSut(); + + sut.transaction = 'test'; + + expect(sut.transaction, 'test'); + }); + + test('sets $User', () { + final sut = fixture.getSut(); + + final user = User(id: 'test'); + sut.user = user; + + expect(sut.user, user); + }); + + test('sets fingerprint', () { + final sut = fixture.getSut(); + + final fingerprints = ['test']; + sut.fingerprint = fingerprints; + + expect(sut.fingerprint, fingerprints); + }); + + test('adds $Breadcrumb', () { + final sut = fixture.getSut(); + + final breadcrumb = Breadcrumb('test log', DateTime.utc(2019)); + sut.addBreadcrumb(breadcrumb); + + expect(sut.breadcrumbs.last, breadcrumb); + }); + + test('respects max $Breadcrumb', () { + final maxBreadcrumbs = 2; + final sut = fixture.getSut(maxBreadcrumbs: maxBreadcrumbs); + + final breadcrumb1 = Breadcrumb('test log', DateTime.utc(2019)); + final breadcrumb2 = Breadcrumb('test log', DateTime.utc(2019)); + final breadcrumb3 = Breadcrumb('test log', DateTime.utc(2019)); + sut.addBreadcrumb(breadcrumb1); + sut.addBreadcrumb(breadcrumb2); + sut.addBreadcrumb(breadcrumb3); + + expect(sut.breadcrumbs.length, maxBreadcrumbs); + }); + + test('rotates $Breadcrumb', () { + final sut = fixture.getSut(maxBreadcrumbs: 2); + + final breadcrumb1 = Breadcrumb('test log', DateTime.utc(2019)); + final breadcrumb2 = Breadcrumb('test log', DateTime.utc(2019)); + final breadcrumb3 = Breadcrumb('test log', DateTime.utc(2019)); + sut.addBreadcrumb(breadcrumb1); + sut.addBreadcrumb(breadcrumb2); + sut.addBreadcrumb(breadcrumb3); + + expect(sut.breadcrumbs.first, breadcrumb2); + + expect(sut.breadcrumbs.last, breadcrumb3); + }); + + test('empty $Breadcrumb list', () { + final maxBreadcrumbs = 0; + final sut = fixture.getSut(maxBreadcrumbs: maxBreadcrumbs); + + final breadcrumb1 = Breadcrumb('test log', DateTime.utc(2019)); + sut.addBreadcrumb(breadcrumb1); + + expect(sut.breadcrumbs.length, maxBreadcrumbs); + }); + + test('clears $Breadcrumb list', () { + final sut = fixture.getSut(); + + final breadcrumb1 = Breadcrumb('test log', DateTime.utc(2019)); + sut.addBreadcrumb(breadcrumb1); + sut.clear(); + + expect(sut.breadcrumbs.length, 0); + }); + + test('sets tag', () { + final sut = fixture.getSut(); + + sut.setTag('test', 'test'); + + expect(sut.tags['test'], 'test'); + }); + + test('removes tag', () { + final sut = fixture.getSut(); + + sut.setTag('test', 'test'); + sut.removeTag('test'); + + expect(sut.tags['test'], null); + }); + + test('sets extra', () { + final sut = fixture.getSut(); + + sut.setExtra('test', 'test'); + + expect(sut.extra['test'], 'test'); + }); + + test('removes extra', () { + final sut = fixture.getSut(); + + sut.setExtra('test', 'test'); + sut.removeExtra('test'); + + expect(sut.extra['test'], null); + }); + + test('clears $Scope', () { + final sut = fixture.getSut(); + + final breadcrumb1 = Breadcrumb('test log', DateTime.utc(2019)); + sut.addBreadcrumb(breadcrumb1); + + sut.level = SeverityLevel.debug; + sut.transaction = 'test'; + + final user = User(id: 'test'); + sut.user = user; + + final fingerprints = ['test']; + sut.fingerprint = fingerprints; + + sut.setTag('test', 'test'); + sut.setExtra('test', 'test'); + + sut.clear(); + + expect(sut.breadcrumbs.length, 0); + + expect(sut.level, null); + + expect(sut.transaction, null); + + expect(sut.user, null); + + expect(sut.fingerprint, null); + + expect(sut.tags.length, 0); + + expect(sut.extra.length, 0); + }); +} + +class Fixture { + Scope getSut({int maxBreadcrumbs = 100}) { + final options = SentryOptions(); + options.maxBreadcrumbs = maxBreadcrumbs; + return Scope(options); + } +} diff --git a/dart/test/sentry_test.dart b/dart/test/sentry_test.dart new file mode 100644 index 0000000000..8fa961361a --- /dev/null +++ b/dart/test/sentry_test.dart @@ -0,0 +1,109 @@ +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +void main() { + group('Sentry static entry', () { + SentryClient client; + + Exception anException; + + final dns = 'https://abc@def.ingest.sentry.io/1234567'; + + setUp(() { + Sentry.init((options) => options.dsn = dns); + + client = MockSentryClient(); + Sentry.initClient(client); + }); + + test('should capture the event', () { + Sentry.captureEvent(event); + verify(client.captureEvent(event: event)).called(1); + }); + + test('should capture the event', () { + Sentry.captureEvent(event); + verify(client.captureEvent(event: event)).called(1); + }); + + test('should capture the exception', () { + Sentry.captureException(anException); + verify(client.captureException(exception: anException)).called(1); + }); + }); +} + +class MockSentryClient extends Mock implements SentryClient {} + +final event = Event( + loggerName: 'main', + serverName: 'server.dart', + release: '1.4.0-preview.1', + environment: 'Test', + message: Message(formatted: 'This is an example Dart event.'), + transaction: '/example/app', + level: SeverityLevel.warning, + tags: const {'project-id': '7371'}, + extra: const {'company-name': 'Dart Inc'}, + fingerprint: const ['example-dart'], + userContext: const User( + id: '800', + username: 'first-user', + email: 'first@user.lan', + ipAddress: '127.0.0.1', + extras: {'first-sign-in': '2020-01-01'}), + breadcrumbs: [ + Breadcrumb('UI Lifecycle', DateTime.now().toUtc(), + category: 'ui.lifecycle', + type: 'navigation', + data: {'screen': 'MainActivity', 'state': 'created'}, + level: SeverityLevel.info) + ], + contexts: Contexts( + operatingSystem: const OperatingSystem( + name: 'Android', + version: '5.0.2', + build: 'LRX22G.P900XXS0BPL2', + kernelVersion: + 'Linux version 3.4.39-5726670 (dpi@SWHC3807) (gcc version 4.8 (GCC) ) #1 SMP PREEMPT Thu Dec 1 19:42:39 KST 2016', + rooted: false), + runtimes: [const Runtime(name: 'ART', version: '5')], + app: App( + name: 'Example Dart App', + version: '1.42.0', + identifier: 'HGT-App-13', + build: '93785', + buildType: 'release', + deviceAppHash: '5afd3a6', + startTime: DateTime.now().toUtc()), + browser: const Browser(name: 'Firefox', version: '42.0.1'), + device: Device( + name: 'SM-P900', + family: 'SM-P900', + model: 'SM-P900 (LRX22G)', + modelId: 'LRX22G', + arch: 'armeabi-v7a', + batteryLevel: 99, + orientation: Orientation.landscape, + manufacturer: 'samsung', + brand: 'samsung', + screenResolution: '2560x1600', + screenDensity: 2.1, + screenDpi: 320, + online: true, + charging: true, + lowMemory: true, + simulator: false, + memorySize: 1500, + freeMemory: 200, + usableMemory: 4294967296, + storageSize: 4294967296, + freeStorage: 2147483648, + externalStorageSize: 8589934592, + externalFreeStorage: 2863311530, + bootTime: DateTime.now().toUtc(), + timezone: 'America/Toronto', + ), + ), +);