From 4069f3d0ec0bf561f5374229cc0b592651fcc232 Mon Sep 17 00:00:00 2001 From: Filip Hracek Date: Thu, 29 Aug 2019 21:25:43 -0700 Subject: [PATCH 01/12] Add an example for FlutterError.onError and runZoned (#41) --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1bd7d9579e..e062959bc4 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,48 @@ main() async { ## Tips for catching errors -- use a `try/catch` block -- create a `Zone` with an error handler, e.g. using [runZoned][run_zoned] -- in Flutter, use [FlutterError.onError][flutter_error] -- use `Isolate.current.addErrorListener` to capture uncaught errors in the root zone +- Use a `try/catch` block, like in the example above. +- Create a `Zone` with an error handler, e.g. using [runZoned][run_zoned]. + + ```dart + var sentry = SentryClient(dsn: "https://..."); + // Run the whole app in a zone to capture all uncaught errors. + runZoned( + () => runApp(MyApp()), + onError: (Object error, StackTrace stackTrace) { + try { + sentry.captureException( + exception: error, + stackTrace: stackTrace, + ); + print('Error sent to sentry.io: $error'); + } catch (e) { + print('Sending report to sentry.io failed: $e'); + print('Original error: $error'); + } + }, + ); + ``` +- For Flutter-specific errors (such as layout failures), use [FlutterError.onError][flutter_error]. For example: + + ```dart + var sentry = SentryClient(dsn: "https://..."); + FlutterError.onError = (details, {bool forceReport = false}) { + try { + sentry.captureException( + exception: details.exception, + stackTrace: details.stack, + ); + } catch (e) { + print('Sending report to sentry.io failed: $e'); + } finally { + // Also use Flutter's pretty error logging to the device's console. + FlutterError.dumpErrorToConsole(details, forceReport: forceReport); + } + }; + ``` +- Use `Isolate.current.addErrorListener` to capture uncaught errors + in the root zone. [run_zoned]: https://api.dartlang.org/stable/dart-async/runZoned.html [flutter_error]: https://docs.flutter.io/flutter/foundation/FlutterError/onError.html From 14e370c94e5fc28de85dad74148357b491e0982a Mon Sep 17 00:00:00 2001 From: Yrom Wang Date: Tue, 15 Oct 2019 22:40:04 +0800 Subject: [PATCH 02/12] Add 'transaction' and 'breadcrumbs' in event (#43) Add 'transaction' and 'breadcrumbs' in event transaction: https://docs.sentry.io/development/sdk-dev/event-payloads/#optional-attributes breadcrumbs: https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/ --- lib/sentry.dart | 81 +++++++++++++++++++++++++++++++++++++++++++ test/sentry_test.dart | 35 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/lib/sentry.dart b/lib/sentry.dart index aa46678652..7b0cbd7c81 100644 --- a/lib/sentry.dart +++ b/lib/sentry.dart @@ -306,6 +306,7 @@ class Event { this.release, this.environment, this.message, + this.transaction, this.exception, this.stackTrace, this.level, @@ -314,6 +315,7 @@ class Event { this.extra, this.fingerprint, this.userContext, + this.breadcrumbs, }); /// The logger that logged the event. @@ -344,6 +346,10 @@ class Event { /// Can be `null`, a [String], or a [StackTrace]. final dynamic stackTrace; + /// The name of the transaction which generated this event, + /// for example, the route name: `"/users//"`. + final String transaction; + /// How important this event is. final SeverityLevel level; @@ -359,6 +365,12 @@ class Event { /// they must be JSON-serializable. final Map extra; + /// List of breadcrumbs for this event. + /// + /// See also: + /// * https://docs.sentry.io/enriching-error-data/breadcrumbs/?platform=javascript + final List breadcrumbs; + /// Information about the current user. /// /// The value in this field overrides the user context @@ -401,6 +413,8 @@ class Event { if (message != null) json['message'] = message; + if (transaction != null) json['transaction'] = transaction; + if (exception != null) { json['exception'] = [ { @@ -433,6 +447,12 @@ class Event { if (fingerprint != null && fingerprint.isNotEmpty) json['fingerprint'] = fingerprint; + if (breadcrumbs != null && breadcrumbs.isNotEmpty) { + json['breadcrumbs'] = >>{ + 'values': breadcrumbs.map((b) => b.toJson()).toList(growable: false) + }; + } + return json; } } @@ -494,3 +514,64 @@ class User { }; } } + +/// Structed data to describe more information pior to the event [captured][SentryClient.capture]. +/// +/// The outgoing JSON representation is: +/// +/// ``` +/// { +/// "timestamp": 1000 +/// "message": "message", +/// "category": "category", +/// "data": {"key": "value"}, +/// "level": "info", +/// "type": "default" +/// } +/// ``` +/// See also: +/// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/ +class Breadcrumb { + final String message; + final String category; + final Map data; + final SeverityLevel level; + + /// Describes what type of breadcrumb this is. + /// + /// Possible values: "default", "http", "navigation". + /// + /// See also: + /// + /// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/#breadcrumb-types + final String type; + + /// The time the breadcrumb was recorded. + /// + /// The value is submitted to Sentry with second precision. + final DateTime timestamp; + const Breadcrumb(this.message, this.timestamp, + {this.category, this.data, this.level = SeverityLevel.info, this.type}); + + Map toJson() { + var json = { + 'timestamp': formatDateAsIso8601WithSecondPrecision(timestamp), + }; + if (message != null) { + json['message'] = message; + } + if (category != null) { + json['category'] = category; + } + if (data != null && data.isNotEmpty) { + json['data'] = Map.of(data); + } + if (level != null) { + json['level'] = level.name; + } + if (type != null) { + json['type'] = type; + } + return json; + } +} diff --git a/test/sentry_test.dart b/test/sentry_test.dart index 6dc563d1ad..330acf0f99 100644 --- a/test/sentry_test.dart +++ b/test/sentry_test.dart @@ -308,6 +308,22 @@ void main() { }); group('$Event', () { + test('$Breadcrumb serializes', () { + expect( + Breadcrumb( + "example log", + DateTime.utc(2019), + level: SeverityLevel.debug, + category: "test", + ).toJson(), + { + 'timestamp': '2019-01-01T00:00:00', + 'message': 'example log', + 'category': 'test', + 'level': 'debug', + }, + ); + }); test('serializes to JSON', () { final user = new User( id: "user_id", @@ -315,9 +331,16 @@ void main() { email: "email@email.com", ipAddress: "127.0.0.1", extras: {"foo": "bar"}); + + final breadcrumbs = [ + Breadcrumb("test log", DateTime.utc(2019), + level: SeverityLevel.debug, category: "test"), + ]; + expect( new Event( message: 'test-message', + transaction: '/test/1', exception: new StateError('test-error'), level: SeverityLevel.debug, culprit: 'Professor Moriarty', @@ -331,11 +354,13 @@ void main() { }, fingerprint: [Event.defaultFingerprint, 'foo'], userContext: user, + breadcrumbs: breadcrumbs, ).toJson(), { 'platform': 'dart', 'sdk': {'version': sdkVersion, 'name': 'dart'}, 'message': 'test-message', + 'transaction': '/test/1', 'exception': [ {'type': 'StateError', 'value': 'Bad state: test-error'} ], @@ -351,6 +376,16 @@ void main() { 'ip_address': '127.0.0.1', 'extras': {'foo': 'bar'} }, + 'breadcrumbs': { + 'values': [ + { + 'timestamp': '2019-01-01T00:00:00', + 'message': 'test log', + 'category': 'test', + 'level': 'debug', + }, + ] + }, }, ); }); From e7fe8365e15b1aea9f6199d8f849ea1aad0860b4 Mon Sep 17 00:00:00 2001 From: Yegor Date: Tue, 15 Oct 2019 10:07:17 -0700 Subject: [PATCH 03/12] clean-up; bump version to 2.3.0 (#44) --- CHANGELOG.md | 6 +++++- lib/sentry.dart | 39 +++++++++++++++++++++++++++++++++++++-- lib/src/version.dart | 2 +- pubspec.yaml | 2 +- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da65a6ea75..95be2b8d16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # package:sentry changelog +## 2.3.0 + +- Add [breadcrumb](https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/) support. + ## 2.2.0 - Add a `stackFrameFilter` argument to `SentryClient`'s `capture` method (96be842). @@ -33,7 +37,7 @@ ## 1.0.0 - first and last Dart 1-compatible release (we may fix bugs on a separate branch if there's demand) -- fix code for Dart 2 +- fix code for Dart 2 ## 0.0.6 diff --git a/lib/sentry.dart b/lib/sentry.dart index 7b0cbd7c81..f37c5850ce 100644 --- a/lib/sentry.dart +++ b/lib/sentry.dart @@ -532,15 +532,38 @@ class User { /// See also: /// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/ class Breadcrumb { + /// Describes the breadcrumb. + /// + /// This field is optional and may be set to null. final String message; + + /// A dot-separated string describing the source of the breadcrumb, e.g. "ui.click". + /// + /// This field is optional and may be set to null. final String category; + + /// Data associated with the breadcrumb. + /// + /// The contents depend on the [type] of breadcrumb. + /// + /// This field is optional and may be set to null. + /// + /// See also: + /// + /// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/#breadcrumb-types final Map data; + + /// Severity of the breadcrumb. + /// + /// This field is optional and may be set to null. final SeverityLevel level; /// Describes what type of breadcrumb this is. /// /// Possible values: "default", "http", "navigation". /// + /// This field is optional and may be set to null. + /// /// See also: /// /// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/#breadcrumb-types @@ -548,11 +571,23 @@ class Breadcrumb { /// The time the breadcrumb was recorded. /// + /// This field is required, it must not be null. + /// /// The value is submitted to Sentry with second precision. final DateTime timestamp; - const Breadcrumb(this.message, this.timestamp, - {this.category, this.data, this.level = SeverityLevel.info, this.type}); + /// Creates a breadcrumb that can be attached to an [Event]. + const Breadcrumb( + this.message, + this.timestamp, { + this.category, + this.data, + this.level = SeverityLevel.info, + this.type, + }) : assert(timestamp != null); + + /// Converts this breadcrumb to a map that can be serialized to JSON according + /// to the Sentry protocol. Map toJson() { var json = { 'timestamp': formatDateAsIso8601WithSecondPrecision(timestamp), diff --git a/lib/src/version.dart b/lib/src/version.dart index 8786d0eb13..c7d1b235c6 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -9,7 +9,7 @@ library version; /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '2.2.0'; +const String sdkVersion = '2.3.0'; /// The SDK name reported to Sentry.io in the submitted events. const String sdkName = 'dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 078828eda5..6dc800c36b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry -version: 2.2.0 +version: 2.3.0 description: A pure Dart Sentry.io client. author: Flutter Authors homepage: https://github.com/flutter/sentry From 79ea1531cf869fefddeb55df07c726374682f75e Mon Sep 17 00:00:00 2001 From: Yrom Wang Date: Thu, 17 Oct 2019 04:44:31 +0800 Subject: [PATCH 04/12] support dsn that use non-regular port or contains prefix path (#42) 1. dsn may specify non-regular port 2. dsn may contains some paths before the projectID --- lib/sentry.dart | 19 +++++++++++++++++-- test/sentry_test.dart | 23 ++++++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/sentry.dart b/lib/sentry.dart index f37c5850ce..ee2fe50d4f 100644 --- a/lib/sentry.dart +++ b/lib/sentry.dart @@ -160,8 +160,23 @@ class SentryClient { User userContext; @visibleForTesting - String get postUri => - '${dsnUri.scheme}://${dsnUri.host}/api/$projectId/store/'; + String get postUri { + String port = dsnUri.hasPort && + ((dsnUri.scheme == 'http' && dsnUri.port != 80) || + (dsnUri.scheme == 'https' && dsnUri.port != 443)) + ? ':${dsnUri.port}' + : ''; + int pathLength = dsnUri.pathSegments.length; + String apiPath; + if (pathLength > 1) { + // some paths would present before the projectID in the dsnUri + apiPath = + (dsnUri.pathSegments.sublist(0, pathLength - 1) + ['api']).join('/'); + } else { + apiPath = 'api'; + } + return '${dsnUri.scheme}://${dsnUri.host}${port}/$apiPath/$projectId/store/'; + } /// Reports an [event] to Sentry.io. Future capture( diff --git a/test/sentry_test.dart b/test/sentry_test.dart index 330acf0f99..5f03033c68 100644 --- a/test/sentry_test.dart +++ b/test/sentry_test.dart @@ -11,7 +11,10 @@ import 'package:test/test.dart'; const String _testDsn = 'https://public:secret@sentry.example.com/1'; const String _testDsnWithoutSecret = 'https://public@sentry.example.com/1'; - +const String _testDsnWithPath = + 'https://public:secret@sentry.example.com/path/1'; +const String _testDsnWithPort = + 'https://public:secret@sentry.example.com:8888/1'; void main() { group('$SentryClient', () { test('can parse DSN', () async { @@ -34,6 +37,24 @@ void main() { await client.close(); }); + test('can parse DSN with path', () async { + final SentryClient client = new SentryClient(dsn: _testDsnWithPath); + expect(client.dsnUri, Uri.parse(_testDsnWithPath)); + expect(client.postUri, 'https://sentry.example.com/path/api/1/store/'); + expect(client.publicKey, 'public'); + expect(client.secretKey, 'secret'); + expect(client.projectId, '1'); + await client.close(); + }); + test('can parse DSN with port', () async { + final SentryClient client = new SentryClient(dsn: _testDsnWithPort); + expect(client.dsnUri, Uri.parse(_testDsnWithPort)); + expect(client.postUri, 'https://sentry.example.com:8888/api/1/store/'); + expect(client.publicKey, 'public'); + expect(client.secretKey, 'secret'); + expect(client.projectId, '1'); + await client.close(); + }); test('sends client auth header without secret', () async { final MockClient httpMock = new MockClient(); final ClockProvider fakeClockProvider = From fe2c76695cd8898e304b4d976df74321a833b439 Mon Sep 17 00:00:00 2001 From: Yegor Date: Wed, 16 Oct 2019 14:56:52 -0700 Subject: [PATCH 05/12] Update version and changelog for 2.3.1 (#45) --- CHANGELOG.md | 4 ++++ lib/src/version.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95be2b8d16..1531f3b55b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # package:sentry changelog +## 2.3.1 + +- Support non-standard port numbers and paths in DSN URL. + ## 2.3.0 - Add [breadcrumb](https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/) support. diff --git a/lib/src/version.dart b/lib/src/version.dart index c7d1b235c6..aa5a95e4b8 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -9,7 +9,7 @@ library version; /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '2.3.0'; +const String sdkVersion = '2.3.1'; /// The SDK name reported to Sentry.io in the submitted events. const String sdkName = 'dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 6dc800c36b..5623613bb6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry -version: 2.3.0 +version: 2.3.1 description: A pure Dart Sentry.io client. author: Flutter Authors homepage: https://github.com/flutter/sentry From 240dc33b51f9a0ea2384ee865406c10d27f79597 Mon Sep 17 00:00:00 2001 From: Yegor Date: Tue, 22 Oct 2019 12:54:39 -0700 Subject: [PATCH 06/12] Implement analysis options suggested by pub (#46) * Add pedantic and analysis options * Fix analysis warnings --- analysis_options.yaml | 1 + bin/test.dart | 4 +- lib/sentry.dart | 84 +++++++++++++++++++++++++------------- lib/src/stack_trace.dart | 12 +++--- lib/src/utils.dart | 3 +- pubspec.yaml | 1 + test/sentry_test.dart | 72 +++++++++++++++----------------- test/stack_trace_test.dart | 4 +- test/utils_test.dart | 2 +- test/version_test.dart | 2 +- 10 files changed, 106 insertions(+), 79 deletions(-) create mode 100644 analysis_options.yaml diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000000..108d1058ac --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1 @@ +include: package:pedantic/analysis_options.yaml diff --git a/bin/test.dart b/bin/test.dart index bbeadbe1fc..ebff8ce8d1 100644 --- a/bin/test.dart +++ b/bin/test.dart @@ -16,7 +16,7 @@ Future main(List rawArgs) async { } final String dsn = rawArgs.single; - final SentryClient client = new SentryClient(dsn: dsn); + final SentryClient client = SentryClient(dsn: dsn); try { await foo(); @@ -47,5 +47,5 @@ Future bar() async { } Future baz() async { - throw new StateError('This is a test error'); + throw StateError('This is a test error'); } diff --git a/lib/sentry.dart b/lib/sentry.dart index ee2fe50d4f..d7ddcbfad2 100644 --- a/lib/sentry.dart +++ b/lib/sentry.dart @@ -64,7 +64,7 @@ class SentryClient { dynamic clock, UuidGenerator uuidGenerator, }) { - httpClient ??= new Client(); + httpClient ??= Client(); clock ??= _getUtcDateTime; uuidGenerator ??= _generateUuidV4WithoutDashes; compressPayload ??= true; @@ -76,9 +76,11 @@ class SentryClient { final List userInfo = uri.userInfo.split(':'); assert(() { - if (uri.pathSegments.isEmpty) - throw new ArgumentError( - 'Project ID not found in the URI path of the DSN URI: $dsn'); + if (uri.pathSegments.isEmpty) { + throw ArgumentError( + 'Project ID not found in the URI path of the DSN URI: $dsn', + ); + } return true; }()); @@ -87,7 +89,7 @@ class SentryClient { final String secretKey = userInfo.length >= 2 ? userInfo[1] : null; final String projectId = uri.pathSegments.last; - return new SentryClient._( + return SentryClient._( httpClient: httpClient, clock: clockProvider, uuidGenerator: uuidGenerator, @@ -201,8 +203,9 @@ class SentryClient { 'logger': defaultLoggerName, }; - if (environmentAttributes != null) + if (environmentAttributes != null) { mergeAttributes(environmentAttributes.toJson(), into: data); + } // Merge the user context. if (userContext != null) { @@ -223,13 +226,14 @@ class SentryClient { if (response.statusCode != 200) { String errorMessage = 'Sentry.io responded with HTTP ${response.statusCode}'; - if (response.headers['x-sentry-error'] != null) + if (response.headers['x-sentry-error'] != null) { errorMessage += ': ${response.headers['x-sentry-error']}'; - return new SentryResponse.failure(errorMessage); + } + return SentryResponse.failure(errorMessage); } final String eventId = json.decode(response.body)['id']; - return new SentryResponse.success(eventId: eventId); + return SentryResponse.success(eventId: eventId); } /// Reports the [exception] and optionally its [stackTrace] to Sentry.io. @@ -241,7 +245,7 @@ class SentryClient { dynamic stackTrace, StackFrameFilter stackFrameFilter, }) { - final Event event = new Event( + final Event event = Event( exception: exception, stackTrace: stackTrace, ); @@ -284,16 +288,16 @@ class SentryResponse { typedef UuidGenerator = String Function(); String _generateUuidV4WithoutDashes() => - new Uuid().generateV4().replaceAll('-', ''); + Uuid().generateV4().replaceAll('-', ''); /// Severity of the logged [Event]. @immutable class SeverityLevel { - static const fatal = const SeverityLevel._('fatal'); - static const error = const SeverityLevel._('error'); - static const warning = const SeverityLevel._('warning'); - static const info = const SeverityLevel._('info'); - static const debug = const SeverityLevel._('debug'); + static const fatal = SeverityLevel._('fatal'); + static const error = SeverityLevel._('error'); + static const warning = SeverityLevel._('warning'); + static const info = SeverityLevel._('info'); + static const debug = SeverityLevel._('debug'); const SeverityLevel._(this.name); @@ -303,7 +307,7 @@ class SeverityLevel { /// Sentry does not take a timezone and instead expects the date-time to be /// submitted in UTC timezone. -DateTime _getUtcDateTime() => new DateTime.now().toUtc(); +DateTime _getUtcDateTime() => DateTime.now().toUtc(); /// An event to be reported to Sentry.io. @immutable @@ -418,17 +422,29 @@ class Event { }, }; - if (loggerName != null) json['logger'] = loggerName; + if (loggerName != null) { + json['logger'] = loggerName; + } - if (serverName != null) json['server_name'] = serverName; + if (serverName != null) { + json['server_name'] = serverName; + } - if (release != null) json['release'] = release; + if (release != null) { + json['release'] = release; + } - if (environment != null) json['environment'] = environment; + if (environment != null) { + json['environment'] = environment; + } - if (message != null) json['message'] = message; + if (message != null) { + json['message'] = message; + } - if (transaction != null) json['transaction'] = transaction; + if (transaction != null) { + json['transaction'] = transaction; + } if (exception != null) { json['exception'] = [ @@ -446,21 +462,31 @@ class Event { }; } - if (level != null) json['level'] = level.name; + if (level != null) { + json['level'] = level.name; + } - if (culprit != null) json['culprit'] = culprit; + if (culprit != null) { + json['culprit'] = culprit; + } - if (tags != null && tags.isNotEmpty) json['tags'] = tags; + if (tags != null && tags.isNotEmpty) { + json['tags'] = tags; + } - if (extra != null && extra.isNotEmpty) json['extra'] = extra; + if (extra != null && extra.isNotEmpty) { + json['extra'] = extra; + } Map userContextMap; if (userContext != null && - (userContextMap = userContext.toJson()).isNotEmpty) + (userContextMap = userContext.toJson()).isNotEmpty) { json['user'] = userContextMap; + } - if (fingerprint != null && fingerprint.isNotEmpty) + if (fingerprint != null && fingerprint.isNotEmpty) { json['fingerprint'] = fingerprint; + } if (breadcrumbs != null && breadcrumbs.isNotEmpty) { json['breadcrumbs'] = >>{ diff --git a/lib/src/stack_trace.dart b/lib/src/stack_trace.dart index 807e001c08..6a20ffafdc 100644 --- a/lib/src/stack_trace.dart +++ b/lib/src/stack_trace.dart @@ -16,7 +16,7 @@ typedef StackFrameFilter = List> Function( /// Sentry.io JSON encoding of a stack frame for the asynchronous suspension, /// which is the gap between asynchronous calls. -const Map asynchronousGapFrameJson = const { +const Map asynchronousGapFrameJson = { 'abs_path': '', }; @@ -27,8 +27,8 @@ List> encodeStackTrace(dynamic stackTrace, {StackFrameFilter stackFrameFilter}) { assert(stackTrace is String || stackTrace is StackTrace); final Chain chain = stackTrace is StackTrace - ? new Chain.forTrace(stackTrace) - : new Chain.parse(stackTrace); + ? Chain.forTrace(stackTrace) + : Chain.parse(stackTrace); final List> frames = >[]; for (int t = 0; t < chain.traces.length; t += 1) { @@ -48,8 +48,9 @@ Map encodeStackTraceFrame(Frame frame) { 'in_app': !frame.isCore, }; - if (frame.uri.pathSegments.isNotEmpty) + if (frame.uri.pathSegments.isNotEmpty) { json['filename'] = frame.uri.pathSegments.last; + } return json; } @@ -63,8 +64,9 @@ Map encodeStackTraceFrame(Frame frame) { /// "dart:" and "package:" imports are always relative and are OK to send in /// full. String _absolutePathForCrashReport(Frame frame) { - if (frame.uri.scheme != 'dart' && frame.uri.scheme != 'package') + if (frame.uri.scheme != 'dart' && frame.uri.scheme != 'package') { return frame.uri.pathSegments.last; + } return '${frame.uri}'; } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 0e5e93e181..25f824c2f4 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -29,7 +29,8 @@ void mergeAttributes(Map attributes, String formatDateAsIso8601WithSecondPrecision(DateTime date) { String iso = date.toIso8601String(); final millisecondSeparatorIndex = iso.lastIndexOf('.'); - if (millisecondSeparatorIndex != -1) + if (millisecondSeparatorIndex != -1) { iso = iso.substring(0, millisecondSeparatorIndex); + } return iso; } diff --git a/pubspec.yaml b/pubspec.yaml index 5623613bb6..6ee6e7b797 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: meta: ">=1.0.0 <2.0.0" stack_trace: ">=1.0.0 <2.0.0" usage: ">=3.0.0 <4.0.0" + pedantic: ">=1.8.0 <2.0.0" dev_dependencies: args: ">=0.13.0 <2.0.0" diff --git a/test/sentry_test.dart b/test/sentry_test.dart index 5f03033c68..94dbcdfb93 100644 --- a/test/sentry_test.dart +++ b/test/sentry_test.dart @@ -18,7 +18,7 @@ const String _testDsnWithPort = void main() { group('$SentryClient', () { test('can parse DSN', () async { - final SentryClient client = new SentryClient(dsn: _testDsn); + final SentryClient client = SentryClient(dsn: _testDsn); expect(client.dsnUri, Uri.parse(_testDsn)); expect(client.postUri, 'https://sentry.example.com/api/1/store/'); expect(client.publicKey, 'public'); @@ -28,7 +28,7 @@ void main() { }); test('can parse DSN without secret', () async { - final SentryClient client = new SentryClient(dsn: _testDsnWithoutSecret); + final SentryClient client = SentryClient(dsn: _testDsnWithoutSecret); expect(client.dsnUri, Uri.parse(_testDsnWithoutSecret)); expect(client.postUri, 'https://sentry.example.com/api/1/store/'); expect(client.publicKey, 'public'); @@ -38,7 +38,7 @@ void main() { }); test('can parse DSN with path', () async { - final SentryClient client = new SentryClient(dsn: _testDsnWithPath); + final SentryClient client = SentryClient(dsn: _testDsnWithPath); expect(client.dsnUri, Uri.parse(_testDsnWithPath)); expect(client.postUri, 'https://sentry.example.com/path/api/1/store/'); expect(client.publicKey, 'public'); @@ -47,7 +47,7 @@ void main() { await client.close(); }); test('can parse DSN with port', () async { - final SentryClient client = new SentryClient(dsn: _testDsnWithPort); + final SentryClient client = SentryClient(dsn: _testDsnWithPort); expect(client.dsnUri, Uri.parse(_testDsnWithPort)); expect(client.postUri, 'https://sentry.example.com:8888/api/1/store/'); expect(client.publicKey, 'public'); @@ -56,9 +56,8 @@ void main() { await client.close(); }); test('sends client auth header without secret', () async { - final MockClient httpMock = new MockClient(); - final ClockProvider fakeClockProvider = - () => new DateTime.utc(2017, 1, 2); + final MockClient httpMock = MockClient(); + final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); Map headers; @@ -68,12 +67,12 @@ void main() { } if (invocation.memberName == #post) { headers = invocation.namedArguments[#headers]; - return new Response('{"id": "test-event-id"}', 200); + return Response('{"id": "test-event-id"}', 200); } fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); }); - final SentryClient client = new SentryClient( + final SentryClient client = SentryClient( dsn: _testDsnWithoutSecret, httpClient: httpMock, clock: fakeClockProvider, @@ -87,7 +86,7 @@ void main() { ); try { - throw new ArgumentError('Test error'); + throw ArgumentError('Test error'); } catch (error, stackTrace) { final SentryResponse response = await client.captureException( exception: error, stackTrace: stackTrace); @@ -111,9 +110,8 @@ void main() { }); testCaptureException(bool compressPayload) async { - final MockClient httpMock = new MockClient(); - final ClockProvider fakeClockProvider = - () => new DateTime.utc(2017, 1, 2); + final MockClient httpMock = MockClient(); + final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); String postUri; Map headers; @@ -126,12 +124,12 @@ void main() { postUri = invocation.positionalArguments.single; headers = invocation.namedArguments[#headers]; body = invocation.namedArguments[#body]; - return new Response('{"id": "test-event-id"}', 200); + return Response('{"id": "test-event-id"}', 200); } fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); }); - final SentryClient client = new SentryClient( + final SentryClient client = SentryClient( dsn: _testDsn, httpClient: httpMock, clock: fakeClockProvider, @@ -145,7 +143,7 @@ void main() { ); try { - throw new ArgumentError('Test error'); + throw ArgumentError('Test error'); } catch (error, stackTrace) { final SentryResponse response = await client.captureException( exception: error, stackTrace: stackTrace); @@ -217,23 +215,22 @@ void main() { }); test('reads error message from the x-sentry-error header', () async { - final MockClient httpMock = new MockClient(); - final ClockProvider fakeClockProvider = - () => new DateTime.utc(2017, 1, 2); + final MockClient httpMock = MockClient(); + final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); httpMock.answerWith((Invocation invocation) async { if (invocation.memberName == #close) { return null; } if (invocation.memberName == #post) { - return new Response('', 401, headers: { + return Response('', 401, headers: { 'x-sentry-error': 'Invalid api key', }); } fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); }); - final SentryClient client = new SentryClient( + final SentryClient client = SentryClient( dsn: _testDsn, httpClient: httpMock, clock: fakeClockProvider, @@ -247,7 +244,7 @@ void main() { ); try { - throw new ArgumentError('Test error'); + throw ArgumentError('Test error'); } catch (error, stackTrace) { final SentryResponse response = await client.captureException( exception: error, stackTrace: stackTrace); @@ -261,9 +258,8 @@ void main() { }); test('$Event userContext overrides client', () async { - final MockClient httpMock = new MockClient(); - final ClockProvider fakeClockProvider = - () => new DateTime.utc(2017, 1, 2); + final MockClient httpMock = MockClient(); + final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); String loggedUserId; // used to find out what user context was sent httpMock.answerWith((Invocation invocation) async { @@ -272,30 +268,30 @@ void main() { } if (invocation.memberName == #post) { // parse the body and detect which user context was sent - var bodyData = invocation.namedArguments[new Symbol("body")]; - var decoded = new Utf8Codec().decode(bodyData); - var decodedJson = new JsonDecoder().convert(decoded); + var bodyData = invocation.namedArguments[Symbol("body")]; + var decoded = Utf8Codec().decode(bodyData); + var decodedJson = JsonDecoder().convert(decoded); loggedUserId = decodedJson['user']['id']; - return new Response('', 401, headers: { + return Response('', 401, headers: { 'x-sentry-error': 'Invalid api key', }); } fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); }); - final clientUserContext = new User( + final clientUserContext = User( id: "client_user", username: "username", email: "email@email.com", ipAddress: "127.0.0.1"); - final eventUserContext = new User( + final eventUserContext = User( id: "event_user", username: "username", email: "email@email.com", ipAddress: "127.0.0.1", extras: {"foo": "bar"}); - final SentryClient client = new SentryClient( + final SentryClient client = SentryClient( dsn: _testDsn, httpClient: httpMock, clock: fakeClockProvider, @@ -310,11 +306,11 @@ void main() { client.userContext = clientUserContext; try { - throw new ArgumentError('Test error'); + throw ArgumentError('Test error'); } catch (error, stackTrace) { final eventWithoutContext = - new Event(exception: error, stackTrace: stackTrace); - final eventWithContext = new Event( + Event(exception: error, stackTrace: stackTrace); + final eventWithContext = Event( exception: error, stackTrace: stackTrace, userContext: eventUserContext); @@ -346,7 +342,7 @@ void main() { ); }); test('serializes to JSON', () { - final user = new User( + final user = User( id: "user_id", username: "username", email: "email@email.com", @@ -359,10 +355,10 @@ void main() { ]; expect( - new Event( + Event( message: 'test-message', transaction: '/test/1', - exception: new StateError('test-error'), + exception: StateError('test-error'), level: SeverityLevel.debug, culprit: 'Professor Moriarty', tags: { diff --git a/test/stack_trace_test.dart b/test/stack_trace_test.dart index 1793a3e37a..987bc4283f 100644 --- a/test/stack_trace_test.dart +++ b/test/stack_trace_test.dart @@ -9,7 +9,7 @@ import 'package:test/test.dart'; void main() { group('encodeStackTraceFrame', () { test('marks dart: frames as not app frames', () { - final Frame frame = new Frame(Uri.parse('dart:core'), 1, 2, 'buzz'); + final Frame frame = Frame(Uri.parse('dart:core'), 1, 2, 'buzz'); expect(encodeStackTraceFrame(frame), { 'abs_path': 'dart:core', 'function': 'buzz', @@ -21,7 +21,7 @@ void main() { test('cleanses absolute paths', () { final Frame frame = - new Frame(Uri.parse('file://foo/bar/baz.dart'), 1, 2, 'buzz'); + Frame(Uri.parse('file://foo/bar/baz.dart'), 1, 2, 'buzz'); expect(encodeStackTraceFrame(frame)['abs_path'], 'baz.dart'); }); }); diff --git a/test/utils_test.dart b/test/utils_test.dart index e86a57a2aa..54d52155a2 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -59,7 +59,7 @@ void main() { group('formatDateAsIso8601WithSecondPrecision', () { test('strips sub-millisecond parts', () { final DateTime testDate = - new DateTime.fromMillisecondsSinceEpoch(1502467721598, isUtc: true); + DateTime.fromMillisecondsSinceEpoch(1502467721598, isUtc: true); expect(testDate.toIso8601String(), '2017-08-11T16:08:41.598Z'); expect(formatDateAsIso8601WithSecondPrecision(testDate), '2017-08-11T16:08:41'); diff --git a/test/version_test.dart b/test/version_test.dart index 4f32a66ea3..ad53835626 100644 --- a/test/version_test.dart +++ b/test/version_test.dart @@ -12,7 +12,7 @@ void main() { group('sdkVersion', () { test('matches that of pubspec.yaml', () { final dynamic pubspec = - yaml.loadYaml(new File('pubspec.yaml').readAsStringSync()); + yaml.loadYaml(File('pubspec.yaml').readAsStringSync()); expect(sdkVersion, pubspec['version']); }); }); From 46e62878ce7055084af60bf693abe4fe021fc266 Mon Sep 17 00:00:00 2001 From: Hadrien Lejard Date: Thu, 28 Nov 2019 05:22:00 +0100 Subject: [PATCH 07/12] Add web support (#22) - [x] Refactor to support browser + `SentryClient` from `package:sentry/sentry.dart` with conditional import + `SentryBrowserClient` for web from `package:sentry/browser_client.dart` + `SentryIOClient` for VM and Flutter from `package:sentry/io_client.dart` - [x] Write test for browser - [x] Working Angular Sentry packages => https://github.com/leftyio/angular_sentry Main implementation differences are: - gzip compression is provided implicitly by the browser - javascript stacktraces need to be prefix with `window.location.origin` to be resolve by Sentry --- .idea/sentry.iml | 18 - .travis.yml | 4 + CHANGELOG.md | 7 + lib/browser_client.dart | 7 + lib/io_client.dart | 7 + lib/sentry.dart | 647 +----------------------------- lib/src/base.dart | 713 ++++++++++++++++++++++++++++++++++ lib/src/browser.dart | 105 +++++ lib/src/client_stub.dart | 20 + lib/src/io.dart | 129 ++++++ lib/src/stack_trace.dart | 22 +- lib/src/version.dart | 7 +- pubspec.yaml | 2 +- test/event_test.dart | 92 +++++ test/sentry_browser_test.dart | 21 + test/sentry_io_test.dart | 23 ++ test/sentry_test.dart | 424 -------------------- test/stack_trace_test.dart | 6 + test/test_utils.dart | 371 ++++++++++++++++++ test/version_test.dart | 4 +- tool/dart2_test.sh | 7 - tool/presubmit.sh | 3 +- 22 files changed, 1534 insertions(+), 1105 deletions(-) delete mode 100644 .idea/sentry.iml create mode 100644 lib/browser_client.dart create mode 100644 lib/io_client.dart create mode 100644 lib/src/base.dart create mode 100644 lib/src/browser.dart create mode 100644 lib/src/client_stub.dart create mode 100644 lib/src/io.dart create mode 100644 test/event_test.dart create mode 100644 test/sentry_browser_test.dart create mode 100644 test/sentry_io_test.dart delete mode 100644 test/sentry_test.dart create mode 100644 test/test_utils.dart delete mode 100755 tool/dart2_test.sh diff --git a/.idea/sentry.iml b/.idea/sentry.iml deleted file mode 100644 index 7457fc5904..0000000000 --- a/.idea/sentry.iml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 4bcdd1aeae..4cd5a7f7ca 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,7 @@ +sudo: required +addons: + chrome: stable + language: dart dart: - stable diff --git a/CHANGELOG.md b/CHANGELOG.md index 1531f3b55b..b5c5c224b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # package:sentry changelog +## 3.0.0 + +- Support Web + - `SentryClient` from `package:sentry/sentry.dart` with conditional import + - `SentryBrowserClient` for web from `package:sentry/browser_client.dart` + - `SentryIOClient` for VM and Flutter from `package:sentry/io_client.dart` + ## 2.3.1 - Support non-standard port numbers and paths in DSN URL. diff --git a/lib/browser_client.dart b/lib/browser_client.dart new file mode 100644 index 0000000000..c32b754eed --- /dev/null +++ b/lib/browser_client.dart @@ -0,0 +1,7 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'src/base.dart'; +export 'src/version.dart'; +export 'src/browser.dart'; diff --git a/lib/io_client.dart b/lib/io_client.dart new file mode 100644 index 0000000000..b9277bbd6b --- /dev/null +++ b/lib/io_client.dart @@ -0,0 +1,7 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +export 'src/base.dart'; +export 'src/version.dart'; +export 'src/io.dart'; diff --git a/lib/sentry.dart b/lib/sentry.dart index d7ddcbfad2..db3e206e96 100644 --- a/lib/sentry.dart +++ b/lib/sentry.dart @@ -3,651 +3,6 @@ // found in the LICENSE file. /// A pure Dart client for Sentry.io crash reporting. -library sentry; - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:meta/meta.dart'; -import 'package:usage/uuid/uuid.dart'; - -import 'src/stack_trace.dart'; -import 'src/utils.dart'; -import 'src/version.dart'; +export 'src/base.dart'; export 'src/version.dart'; - -/// Used to provide timestamp for logging. -typedef ClockProvider = DateTime Function(); - -/// Logs crash reports and events to the Sentry.io service. -class SentryClient { - /// Sentry.io client identifier for _this_ client. - @visibleForTesting - static const String sentryClient = '$sdkName/$sdkVersion'; - - /// The default logger name used if no other value is supplied. - static const String defaultLoggerName = 'SentryClient'; - - /// Instantiates a client using [dsn] issued to your project by Sentry.io as - /// the endpoint for submitting events. - /// - /// [environmentAttributes] contain event attributes that do not change over - /// the course of a program's lifecycle. These attributes will be added to - /// all events captured via this client. The following attributes often fall - /// under this category: [Event.loggerName], [Event.serverName], - /// [Event.release], [Event.environment]. - /// - /// 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. - /// - /// If [httpClient] is provided, it is used instead of the default client to - /// make HTTP calls to Sentry.io. This is useful in tests. - /// - /// 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). - /// - /// 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. - factory SentryClient({ - @required String dsn, - Event environmentAttributes, - bool compressPayload, - Client httpClient, - dynamic clock, - UuidGenerator uuidGenerator, - }) { - httpClient ??= Client(); - clock ??= _getUtcDateTime; - uuidGenerator ??= _generateUuidV4WithoutDashes; - compressPayload ??= true; - - final ClockProvider clockProvider = - clock is ClockProvider ? clock : clock.get; - - final Uri uri = Uri.parse(dsn); - final List userInfo = uri.userInfo.split(':'); - - assert(() { - if (uri.pathSegments.isEmpty) { - throw ArgumentError( - 'Project ID not found in the URI path of the DSN URI: $dsn', - ); - } - - return true; - }()); - - final String publicKey = userInfo[0]; - final String secretKey = userInfo.length >= 2 ? userInfo[1] : null; - final String projectId = uri.pathSegments.last; - - return SentryClient._( - httpClient: httpClient, - clock: clockProvider, - uuidGenerator: uuidGenerator, - environmentAttributes: environmentAttributes, - dsnUri: uri, - publicKey: publicKey, - secretKey: secretKey, - projectId: projectId, - compressPayload: compressPayload, - ); - } - - SentryClient._({ - @required Client httpClient, - @required ClockProvider clock, - @required UuidGenerator uuidGenerator, - @required this.environmentAttributes, - @required this.dsnUri, - @required this.publicKey, - this.secretKey, - @required this.compressPayload, - @required this.projectId, - }) : _httpClient = httpClient, - _clock = clock, - _uuidGenerator = uuidGenerator; - - final Client _httpClient; - final ClockProvider _clock; - final UuidGenerator _uuidGenerator; - - /// 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]. - final Event environmentAttributes; - - /// Whether to compress payloads sent to Sentry.io. - final bool compressPayload; - - /// The DSN URI. - @visibleForTesting - final Uri dsnUri; - - /// The Sentry.io public key for the project. - @visibleForTesting - final String publicKey; - - /// The Sentry.io secret key for the project. - @visibleForTesting - final String secretKey; - - /// The ID issued by Sentry.io to your project. - /// - /// Attached to the event payload. - final String projectId; - - /// Information about the current user. - /// - /// This information is sent with every logged event. If the value - /// of this field is updated, all subsequent events will carry the - /// new information. - /// - /// [Event.userContext] overrides the [User] context set here. - /// - /// See also: - /// * https://docs.sentry.io/learn/context/#capturing-the-user - User userContext; - - @visibleForTesting - String get postUri { - String port = dsnUri.hasPort && - ((dsnUri.scheme == 'http' && dsnUri.port != 80) || - (dsnUri.scheme == 'https' && dsnUri.port != 443)) - ? ':${dsnUri.port}' - : ''; - int pathLength = dsnUri.pathSegments.length; - String apiPath; - if (pathLength > 1) { - // some paths would present before the projectID in the dsnUri - apiPath = - (dsnUri.pathSegments.sublist(0, pathLength - 1) + ['api']).join('/'); - } else { - apiPath = 'api'; - } - return '${dsnUri.scheme}://${dsnUri.host}${port}/$apiPath/$projectId/store/'; - } - - /// Reports an [event] to Sentry.io. - Future capture( - {@required Event event, StackFrameFilter stackFrameFilter}) async { - final DateTime now = _clock(); - String authHeader = 'Sentry sentry_version=6, sentry_client=$sentryClient, ' - 'sentry_timestamp=${now.millisecondsSinceEpoch}, sentry_key=$publicKey'; - if (secretKey != null) { - authHeader += ', sentry_secret=$secretKey'; - } - - final Map headers = { - 'User-Agent': '$sentryClient', - 'Content-Type': 'application/json', - 'X-Sentry-Auth': authHeader, - }; - - final Map data = { - 'project': projectId, - 'event_id': _uuidGenerator(), - 'timestamp': formatDateAsIso8601WithSecondPrecision(now), - 'logger': defaultLoggerName, - }; - - if (environmentAttributes != null) { - mergeAttributes(environmentAttributes.toJson(), into: data); - } - - // Merge the user context. - if (userContext != null) { - mergeAttributes({'user': userContext.toJson()}, into: data); - } - mergeAttributes(event.toJson(stackFrameFilter: stackFrameFilter), - into: data); - - List body = utf8.encode(json.encode(data)); - if (compressPayload) { - headers['Content-Encoding'] = 'gzip'; - body = gzip.encode(body); - } - - final Response response = - await _httpClient.post(postUri, headers: headers, body: body); - - if (response.statusCode != 200) { - String errorMessage = - 'Sentry.io responded with HTTP ${response.statusCode}'; - if (response.headers['x-sentry-error'] != null) { - errorMessage += ': ${response.headers['x-sentry-error']}'; - } - return SentryResponse.failure(errorMessage); - } - - final String eventId = json.decode(response.body)['id']; - return SentryResponse.success(eventId: eventId); - } - - /// Reports the [exception] and optionally its [stackTrace] to Sentry.io. - /// - /// Optionally allows specifying a [stackFrameFilter] that receives the - /// list of stack frames just before sending to allow modifying it. - Future captureException({ - @required dynamic exception, - dynamic stackTrace, - StackFrameFilter stackFrameFilter, - }) { - final Event event = Event( - exception: exception, - stackTrace: stackTrace, - ); - return capture(event: event, stackFrameFilter: stackFrameFilter); - } - - Future close() async { - _httpClient.close(); - } - - @override - String toString() => '$SentryClient("$postUri")'; -} - -/// A response from Sentry.io. -/// -/// If [isSuccessful] the [eventId] field will contain the ID assigned to the -/// captured event by the Sentry.io backend. Otherwise, the [error] field will -/// contain the description of the error. -@immutable -class SentryResponse { - const SentryResponse.success({@required this.eventId}) - : isSuccessful = true, - error = null; - - const SentryResponse.failure(this.error) - : isSuccessful = false, - eventId = null; - - /// Whether event was submitted successfully. - final bool isSuccessful; - - /// The ID Sentry.io assigned to the submitted event for future reference. - final String eventId; - - /// Error message, if the response is not successful. - final String error; -} - -typedef UuidGenerator = String Function(); - -String _generateUuidV4WithoutDashes() => - Uuid().generateV4().replaceAll('-', ''); - -/// Severity of the logged [Event]. -@immutable -class SeverityLevel { - static const fatal = SeverityLevel._('fatal'); - static const error = SeverityLevel._('error'); - static const warning = SeverityLevel._('warning'); - static const info = SeverityLevel._('info'); - static const debug = SeverityLevel._('debug'); - - const SeverityLevel._(this.name); - - /// API name of the level as it is encoded in the JSON protocol. - final String name; -} - -/// Sentry does not take a timezone and instead expects the date-time to be -/// submitted in UTC timezone. -DateTime _getUtcDateTime() => DateTime.now().toUtc(); - -/// An event to be reported to Sentry.io. -@immutable -class Event { - /// Refers to the default fingerprinting algorithm. - /// - /// You do not need to specify this value unless you supplement the default - /// fingerprint with custom fingerprints. - static const String defaultFingerprint = '{{ default }}'; - - /// Creates an event. - const Event({ - this.loggerName, - this.serverName, - this.release, - this.environment, - this.message, - this.transaction, - this.exception, - this.stackTrace, - this.level, - this.culprit, - this.tags, - this.extra, - this.fingerprint, - this.userContext, - this.breadcrumbs, - }); - - /// The logger that logged the event. - final String loggerName; - - /// Identifies the server that logged this event. - final String serverName; - - /// The version of the application that logged the event. - final String release; - - /// The environment that logged the event, e.g. "production", "staging". - final String environment; - - /// Event message. - /// - /// Generally an event either contains a [message] or an [exception]. - final String message; - - /// An object that was thrown. - /// - /// It's `runtimeType` and `toString()` are logged. If this behavior is - /// undesirable, consider using a custom formatted [message] instead. - final dynamic exception; - - /// The stack trace corresponding to the thrown [exception]. - /// - /// Can be `null`, a [String], or a [StackTrace]. - final dynamic stackTrace; - - /// The name of the transaction which generated this event, - /// for example, the route name: `"/users//"`. - final String transaction; - - /// How important this event is. - final SeverityLevel level; - - /// What caused this event to be logged. - final String culprit; - - /// Name/value pairs that events can be searched by. - final Map tags; - - /// Arbitrary name/value pairs attached to the event. - /// - /// Sentry.io docs do not talk about restrictions on the values, other than - /// they must be JSON-serializable. - final Map extra; - - /// List of breadcrumbs for this event. - /// - /// See also: - /// * https://docs.sentry.io/enriching-error-data/breadcrumbs/?platform=javascript - final List breadcrumbs; - - /// Information about the current user. - /// - /// The value in this field overrides the user context - /// set in [SentryClient.userContext] for this logged event. - final User userContext; - - /// Used to deduplicate events by grouping ones with the same fingerprint - /// together. - /// - /// If not specified a default deduplication fingerprint is used. The default - /// fingerprint may be supplemented by additional fingerprints by specifying - /// multiple values. The default fingerprint can be specified by adding - /// [defaultFingerprint] to the list in addition to your custom values. - /// - /// Examples: - /// - /// // A completely custom fingerprint: - /// var custom = ['foo', 'bar', 'baz']; - /// // A fingerprint that supplements the default one with value 'foo': - /// var supplemented = [Event.defaultFingerprint, 'foo']; - final List fingerprint; - - /// Serializes this event to JSON. - Map toJson({StackFrameFilter stackFrameFilter}) { - final Map json = { - 'platform': sdkPlatform, - 'sdk': { - 'version': sdkVersion, - 'name': sdkName, - }, - }; - - if (loggerName != null) { - json['logger'] = loggerName; - } - - if (serverName != null) { - json['server_name'] = serverName; - } - - if (release != null) { - json['release'] = release; - } - - if (environment != null) { - json['environment'] = environment; - } - - if (message != null) { - json['message'] = message; - } - - if (transaction != null) { - json['transaction'] = transaction; - } - - if (exception != null) { - json['exception'] = [ - { - 'type': '${exception.runtimeType}', - 'value': '$exception', - } - ]; - } - - if (stackTrace != null) { - json['stacktrace'] = { - 'frames': - encodeStackTrace(stackTrace, stackFrameFilter: stackFrameFilter), - }; - } - - if (level != null) { - json['level'] = level.name; - } - - if (culprit != null) { - json['culprit'] = culprit; - } - - if (tags != null && tags.isNotEmpty) { - json['tags'] = tags; - } - - if (extra != null && extra.isNotEmpty) { - json['extra'] = extra; - } - - Map userContextMap; - if (userContext != null && - (userContextMap = userContext.toJson()).isNotEmpty) { - json['user'] = userContextMap; - } - - if (fingerprint != null && fingerprint.isNotEmpty) { - json['fingerprint'] = fingerprint; - } - - if (breadcrumbs != null && breadcrumbs.isNotEmpty) { - json['breadcrumbs'] = >>{ - 'values': breadcrumbs.map((b) => b.toJson()).toList(growable: false) - }; - } - - return json; - } -} - -/// Describes the current user associated with the application, such as the -/// currently signed in user. -/// -/// The user can be specified globally in the [SentryClient.userContext] field, -/// or per event in the [Event.userContext] field. -/// -/// You should provide at least either an [id] (a unique identifier for an -/// authenticated user) or [ipAddress] (their IP address). -/// -/// Conforms to the User Interface contract for Sentry -/// https://docs.sentry.io/clientdev/interfaces/user/. -/// -/// The outgoing JSON representation is: -/// -/// ``` -/// "user": { -/// "id": "unique_id", -/// "username": "my_user", -/// "email": "foo@example.com", -/// "ip_address": "127.0.0.1", -/// "subscription": "basic" -/// } -/// ``` -class User { - /// A unique identifier of the user. - final String id; - - /// The username of the user. - final String username; - - /// The email address of the user. - final String email; - - /// The IP of the user. - final String ipAddress; - - /// Any other user context information that may be helpful. - /// - /// These keys are stored as extra information but not specifically processed - /// by Sentry. - final Map extras; - - /// At a minimum you must set an [id] or an [ipAddress]. - const User({this.id, this.username, this.email, this.ipAddress, this.extras}) - : assert(id != null || ipAddress != null); - - /// Produces a [Map] that can be serialized to JSON. - Map toJson() { - return { - "id": id, - "username": username, - "email": email, - "ip_address": ipAddress, - "extras": extras, - }; - } -} - -/// Structed data to describe more information pior to the event [captured][SentryClient.capture]. -/// -/// The outgoing JSON representation is: -/// -/// ``` -/// { -/// "timestamp": 1000 -/// "message": "message", -/// "category": "category", -/// "data": {"key": "value"}, -/// "level": "info", -/// "type": "default" -/// } -/// ``` -/// See also: -/// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/ -class Breadcrumb { - /// Describes the breadcrumb. - /// - /// This field is optional and may be set to null. - final String message; - - /// A dot-separated string describing the source of the breadcrumb, e.g. "ui.click". - /// - /// This field is optional and may be set to null. - final String category; - - /// Data associated with the breadcrumb. - /// - /// The contents depend on the [type] of breadcrumb. - /// - /// This field is optional and may be set to null. - /// - /// See also: - /// - /// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/#breadcrumb-types - final Map data; - - /// Severity of the breadcrumb. - /// - /// This field is optional and may be set to null. - final SeverityLevel level; - - /// Describes what type of breadcrumb this is. - /// - /// Possible values: "default", "http", "navigation". - /// - /// This field is optional and may be set to null. - /// - /// See also: - /// - /// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/#breadcrumb-types - final String type; - - /// The time the breadcrumb was recorded. - /// - /// This field is required, it must not be null. - /// - /// The value is submitted to Sentry with second precision. - final DateTime timestamp; - - /// Creates a breadcrumb that can be attached to an [Event]. - const Breadcrumb( - this.message, - this.timestamp, { - this.category, - this.data, - this.level = SeverityLevel.info, - this.type, - }) : assert(timestamp != null); - - /// Converts this breadcrumb to a map that can be serialized to JSON according - /// to the Sentry protocol. - Map toJson() { - var json = { - 'timestamp': formatDateAsIso8601WithSecondPrecision(timestamp), - }; - if (message != null) { - json['message'] = message; - } - if (category != null) { - json['category'] = category; - } - if (data != null && data.isNotEmpty) { - json['data'] = Map.of(data); - } - if (level != null) { - json['level'] = level.name; - } - if (type != null) { - json['type'] = type; - } - return json; - } -} diff --git a/lib/src/base.dart b/lib/src/base.dart new file mode 100644 index 0000000000..772b5a37b0 --- /dev/null +++ b/lib/src/base.dart @@ -0,0 +1,713 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; +import 'package:usage/uuid/uuid.dart'; + +import 'client_stub.dart' + if (dart.library.html) 'browser.dart' + if (dart.library.io) 'io.dart'; + +import 'stack_trace.dart'; +import 'utils.dart'; +import 'version.dart'; + +/// Used to provide timestamp for logging. +typedef ClockProvider = DateTime Function(); + +/// Logs crash reports and events to the Sentry.io service. +abstract class SentryClient { + /// Creates a new platform appropriate client. + /// + /// Creates an `SentryIOClient` if `dart:io` is available and a `SentryBrowserClient` if + /// `dart:html` is available, otherwise it will throw an unsupported error. + factory SentryClient({ + @required String dsn, + Event environmentAttributes, + bool compressPayload, + Client httpClient, + dynamic clock, + UuidGenerator uuidGenerator, + }) => + createSentryClient( + dsn: dsn, + environmentAttributes: environmentAttributes, + httpClient: httpClient, + clock: clock, + uuidGenerator: uuidGenerator, + compressPayload: compressPayload, + ); + + /// Sentry.io client identifier for _this_ client. + static const String sentryClient = '$sdkName/$sdkVersion'; + + /// The default logger name used if no other value is supplied. + static const String defaultLoggerName = 'SentryClient'; + + @protected + final Client httpClient; + + ClockProvider _clock; + final UuidGenerator _uuidGenerator; + + /// 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]. + final Event environmentAttributes; + + final Dsn _dsn; + + /// The DSN URI. + @visibleForTesting + Uri get dsnUri => _dsn.uri; + + /// The Sentry.io public key for the project. + @visibleForTesting + String get publicKey => _dsn.publicKey; + + /// The Sentry.io secret key for the project. + @visibleForTesting + String get secretKey => _dsn.secretKey; + + /// The ID issued by Sentry.io to your project. + /// + /// Attached to the event payload. + String get projectId => _dsn.projectId; + + /// Information about the current user. + /// + /// This information is sent with every logged event. If the value + /// of this field is updated, all subsequent events will carry the + /// new information. + /// + /// [Event.userContext] overrides the [User] context set here. + /// + /// See also: + /// * https://docs.sentry.io/learn/context/#capturing-the-user + User userContext; + + /// Use for browser stacktrace + final String origin; + + /// Used by sentry to differentiate browser from io environment + final String _platform; + + SentryClient.base({ + this.httpClient, + dynamic clock, + UuidGenerator uuidGenerator, + String dsn, + this.environmentAttributes, + String platform, + this.origin, + }) : _dsn = Dsn.parse(dsn), + _uuidGenerator = uuidGenerator ?? generateUuidV4WithoutDashes, + _platform = platform ?? sdkPlatform { + if (clock == null) { + _clock = getUtcDateTime; + } else { + _clock = clock is ClockProvider ? clock : clock.get; + } + } + + @visibleForTesting + String get postUri { + String port = dsnUri.hasPort && + ((dsnUri.scheme == 'http' && dsnUri.port != 80) || + (dsnUri.scheme == 'https' && dsnUri.port != 443)) + ? ':${dsnUri.port}' + : ''; + int pathLength = dsnUri.pathSegments.length; + String apiPath; + if (pathLength > 1) { + // some paths would present before the projectID in the dsnUri + apiPath = + (dsnUri.pathSegments.sublist(0, pathLength - 1) + ['api']).join('/'); + } else { + apiPath = 'api'; + } + return '${dsnUri.scheme}://${dsnUri.host}${port}/$apiPath/$projectId/store/'; + } + + /// Reports an [event] to Sentry.io. + Future capture({ + @required Event event, + StackFrameFilter stackFrameFilter, + }) async { + final DateTime now = _clock(); + String authHeader = 'Sentry sentry_version=6, sentry_client=$sentryClient, ' + 'sentry_timestamp=${now.millisecondsSinceEpoch}, sentry_key=$publicKey'; + if (secretKey != null) { + authHeader += ', sentry_secret=$secretKey'; + } + + final Map headers = buildHeaders(authHeader); + + final Map data = { + 'project': projectId, + 'event_id': _uuidGenerator(), + 'timestamp': formatDateAsIso8601WithSecondPrecision(now), + 'logger': defaultLoggerName, + }; + + if (environmentAttributes != null) { + mergeAttributes(environmentAttributes.toJson(), into: data); + } + + // Merge the user context. + if (userContext != null) { + mergeAttributes({'user': userContext.toJson()}, into: data); + } + + mergeAttributes( + event.toJson( + stackFrameFilter: stackFrameFilter, + origin: origin, + ), + into: data, + ); + mergeAttributes({'platform': _platform}, into: data); + + final body = bodyEncoder(data, headers); + + final Response response = await httpClient.post( + postUri, + headers: headers, + body: body, + ); + + if (response.statusCode != 200) { + String errorMessage = + 'Sentry.io responded with HTTP ${response.statusCode}'; + if (response.headers['x-sentry-error'] != null) { + errorMessage += ': ${response.headers['x-sentry-error']}'; + } + return SentryResponse.failure(errorMessage); + } + + final String eventId = json.decode(response.body)['id']; + return SentryResponse.success(eventId: eventId); + } + + /// Reports the [exception] and optionally its [stackTrace] to Sentry.io. + Future captureException({ + @required dynamic exception, + dynamic stackTrace, + }) { + final Event event = Event( + exception: exception, + stackTrace: stackTrace, + ); + return capture(event: event); + } + + Future close() async { + httpClient.close(); + } + + @override + String toString() => '$SentryClient("$postUri")'; + + @protected + List bodyEncoder(Map data, Map headers); + + @protected + @mustCallSuper + Map buildHeaders(String authHeader) { + final headers = { + 'Content-Type': 'application/json', + }; + + if (authHeader != null) { + headers['X-Sentry-Auth'] = authHeader; + } + + return headers; + } +} + +/// A response from Sentry.io. +/// +/// If [isSuccessful] the [eventId] field will contain the ID assigned to the +/// captured event by the Sentry.io backend. Otherwise, the [error] field will +/// contain the description of the error. +@immutable +class SentryResponse { + const SentryResponse.success({@required this.eventId}) + : isSuccessful = true, + error = null; + + const SentryResponse.failure(this.error) + : isSuccessful = false, + eventId = null; + + /// Whether event was submitted successfully. + final bool isSuccessful; + + /// The ID Sentry.io assigned to the submitted event for future reference. + final String eventId; + + /// Error message, if the response is not successful. + final String error; +} + +typedef UuidGenerator = String Function(); + +String generateUuidV4WithoutDashes() => Uuid().generateV4().replaceAll('-', ''); + +/// Severity of the logged [Event]. +@immutable +class SeverityLevel { + static const fatal = SeverityLevel._('fatal'); + static const error = SeverityLevel._('error'); + static const warning = SeverityLevel._('warning'); + static const info = SeverityLevel._('info'); + static const debug = SeverityLevel._('debug'); + + const SeverityLevel._(this.name); + + /// API name of the level as it is encoded in the JSON protocol. + final String name; +} + +/// Sentry does not take a timezone and instead expects the date-time to be +/// submitted in UTC timezone. +DateTime getUtcDateTime() => DateTime.now().toUtc(); + +/// An event to be reported to Sentry.io. +@immutable +class Event { + /// Refers to the default fingerprinting algorithm. + /// + /// You do not need to specify this value unless you supplement the default + /// fingerprint with custom fingerprints. + static const String defaultFingerprint = '{{ default }}'; + + /// Creates an event. + const Event({ + this.loggerName, + this.serverName, + this.release, + this.environment, + this.message, + this.transaction, + this.exception, + this.stackTrace, + this.level, + this.culprit, + this.tags, + this.extra, + this.fingerprint, + this.userContext, + this.breadcrumbs, + }); + + /// The logger that logged the event. + final String loggerName; + + /// Identifies the server that logged this event. + final String serverName; + + /// The version of the application that logged the event. + final String release; + + /// The environment that logged the event, e.g. "production", "staging". + final String environment; + + /// Event message. + /// + /// Generally an event either contains a [message] or an [exception]. + final String message; + + /// An object that was thrown. + /// + /// It's `runtimeType` and `toString()` are logged. If this behavior is + /// undesirable, consider using a custom formatted [message] instead. + final dynamic exception; + + /// The stack trace corresponding to the thrown [exception]. + /// + /// Can be `null`, a [String], or a [StackTrace]. + final dynamic stackTrace; + + /// The name of the transaction which generated this event, + /// for example, the route name: `"/users//"`. + final String transaction; + + /// How important this event is. + final SeverityLevel level; + + /// What caused this event to be logged. + final String culprit; + + /// Name/value pairs that events can be searched by. + final Map tags; + + /// Arbitrary name/value pairs attached to the event. + /// + /// Sentry.io docs do not talk about restrictions on the values, other than + /// they must be JSON-serializable. + final Map extra; + + /// List of breadcrumbs for this event. + /// + /// See also: + /// * https://docs.sentry.io/enriching-error-data/breadcrumbs/?platform=javascript + final List breadcrumbs; + + /// Information about the current user. + /// + /// The value in this field overrides the user context + /// set in [SentryClient.userContext] for this logged event. + final User userContext; + + /// Used to deduplicate events by grouping ones with the same fingerprint + /// together. + /// + /// If not specified a default deduplication fingerprint is used. The default + /// fingerprint may be supplemented by additional fingerprints by specifying + /// multiple values. The default fingerprint can be specified by adding + /// [defaultFingerprint] to the list in addition to your custom values. + /// + /// Examples: + /// + /// // A completely custom fingerprint: + /// var custom = ['foo', 'bar', 'baz']; + /// // A fingerprint that supplements the default one with value 'foo': + /// var supplemented = [Event.defaultFingerprint, 'foo']; + final List fingerprint; + + Event copyWith({ + String loggerName, + String serverName, + String release, + String environment, + String message, + String transaction, + dynamic exception, + dynamic stackTrace, + SeverityLevel level, + String culprit, + Map tags, + Map extra, + List fingerprint, + User userContext, + List breadcrumbs, + }) => + Event( + loggerName: loggerName ?? this.loggerName, + serverName: serverName ?? this.serverName, + release: release ?? this.release, + environment: environment ?? this.environment, + message: message ?? this.message, + transaction: transaction ?? this.transaction, + exception: exception ?? this.exception, + stackTrace: stackTrace ?? this.stackTrace, + level: level ?? this.level, + culprit: culprit ?? this.culprit, + tags: tags ?? this.tags, + extra: extra ?? this.extra, + fingerprint: fingerprint ?? this.fingerprint, + userContext: userContext ?? this.userContext, + breadcrumbs: breadcrumbs ?? this.breadcrumbs, + ); + + /// Serializes this event to JSON. + Map toJson( + {StackFrameFilter stackFrameFilter, String origin}) { + final Map json = { + 'platform': sdkPlatform, + 'sdk': { + 'version': sdkVersion, + 'name': sdkName, + }, + }; + + if (loggerName != null) { + json['logger'] = loggerName; + } + + if (serverName != null) { + json['server_name'] = serverName; + } + + if (release != null) { + json['release'] = release; + } + + if (environment != null) { + json['environment'] = environment; + } + + if (message != null) { + json['message'] = message; + } + + if (transaction != null) { + json['transaction'] = transaction; + } + + if (exception != null) { + json['exception'] = [ + { + 'type': '${exception.runtimeType}', + 'value': '$exception', + } + ]; + } + + if (stackTrace != null) { + json['stacktrace'] = { + 'frames': encodeStackTrace( + stackTrace, + stackFrameFilter: stackFrameFilter, + origin: origin, + ), + }; + } + + if (level != null) { + json['level'] = level.name; + } + + if (culprit != null) { + json['culprit'] = culprit; + } + + if (tags != null && tags.isNotEmpty) { + json['tags'] = tags; + } + + if (extra != null && extra.isNotEmpty) { + json['extra'] = extra; + } + + Map userContextMap; + if (userContext != null && + (userContextMap = userContext.toJson()).isNotEmpty) { + json['user'] = userContextMap; + } + + if (fingerprint != null && fingerprint.isNotEmpty) { + json['fingerprint'] = fingerprint; + } + + if (breadcrumbs != null && breadcrumbs.isNotEmpty) { + json['breadcrumbs'] = >>{ + 'values': breadcrumbs.map((b) => b.toJson()).toList(growable: false) + }; + } + + return json; + } +} + +/// Describes the current user associated with the application, such as the +/// currently signed in user. +/// +/// The user can be specified globally in the [SentryClient.userContext] field, +/// or per event in the [Event.userContext] field. +/// +/// You should provide at least either an [id] (a unique identifier for an +/// authenticated user) or [ipAddress] (their IP address). +/// +/// Conforms to the User Interface contract for Sentry +/// https://docs.sentry.io/clientdev/interfaces/user/. +/// +/// The outgoing JSON representation is: +/// +/// ``` +/// "user": { +/// "id": "unique_id", +/// "username": "my_user", +/// "email": "foo@example.com", +/// "ip_address": "127.0.0.1", +/// "subscription": "basic" +/// } +/// ``` +class User { + /// A unique identifier of the user. + final String id; + + /// The username of the user. + final String username; + + /// The email address of the user. + final String email; + + /// The IP of the user. + final String ipAddress; + + /// Any other user context information that may be helpful. + /// + /// These keys are stored as extra information but not specifically processed + /// by Sentry. + final Map extras; + + /// At a minimum you must set an [id] or an [ipAddress]. + const User({this.id, this.username, this.email, this.ipAddress, this.extras}) + : assert(id != null || ipAddress != null); + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + return { + "id": id, + "username": username, + "email": email, + "ip_address": ipAddress, + "extras": extras, + }; + } +} + +/// Structed data to describe more information pior to the event [captured][SentryClient.capture]. +/// +/// The outgoing JSON representation is: +/// +/// ``` +/// { +/// "timestamp": 1000 +/// "message": "message", +/// "category": "category", +/// "data": {"key": "value"}, +/// "level": "info", +/// "type": "default" +/// } +/// ``` +/// See also: +/// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/ +class Breadcrumb { + /// Describes the breadcrumb. + /// + /// This field is optional and may be set to null. + final String message; + + /// A dot-separated string describing the source of the breadcrumb, e.g. "ui.click". + /// + /// This field is optional and may be set to null. + final String category; + + /// Data associated with the breadcrumb. + /// + /// The contents depend on the [type] of breadcrumb. + /// + /// This field is optional and may be set to null. + /// + /// See also: + /// + /// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/#breadcrumb-types + final Map data; + + /// Severity of the breadcrumb. + /// + /// This field is optional and may be set to null. + final SeverityLevel level; + + /// Describes what type of breadcrumb this is. + /// + /// Possible values: "default", "http", "navigation". + /// + /// This field is optional and may be set to null. + /// + /// See also: + /// + /// * https://docs.sentry.io/development/sdk-dev/event-payloads/breadcrumbs/#breadcrumb-types + final String type; + + /// The time the breadcrumb was recorded. + /// + /// This field is required, it must not be null. + /// + /// The value is submitted to Sentry with second precision. + final DateTime timestamp; + + /// Creates a breadcrumb that can be attached to an [Event]. + const Breadcrumb( + this.message, + this.timestamp, { + this.category, + this.data, + this.level = SeverityLevel.info, + this.type, + }) : assert(timestamp != null); + + /// Converts this breadcrumb to a map that can be serialized to JSON according + /// to the Sentry protocol. + Map toJson() { + var json = { + 'timestamp': formatDateAsIso8601WithSecondPrecision(timestamp), + }; + if (message != null) { + json['message'] = message; + } + if (category != null) { + json['category'] = category; + } + if (data != null && data.isNotEmpty) { + json['data'] = Map.of(data); + } + if (level != null) { + json['level'] = level.name; + } + if (type != null) { + json['type'] = type; + } + return json; + } +} + +class Dsn { + /// The Sentry.io public key for the project. + @visibleForTesting + final String publicKey; + + /// The Sentry.io secret key for the project. + @visibleForTesting + final String secretKey; + + /// The ID issued by Sentry.io to your project. + /// + /// Attached to the event payload. + final String projectId; + + /// The DSN URI. + final Uri uri; + + Dsn({ + @required this.publicKey, + @required this.projectId, + this.uri, + this.secretKey, + }); + + static Dsn parse(String dsn) { + final Uri uri = Uri.parse(dsn); + final List userInfo = uri.userInfo.split(':'); + + assert(() { + if (uri.pathSegments.isEmpty) { + throw ArgumentError( + 'Project ID not found in the URI path of the DSN URI: $dsn', + ); + } + + return true; + }()); + + return Dsn( + publicKey: userInfo[0], + secretKey: userInfo.length >= 2 ? userInfo[1] : null, + projectId: uri.pathSegments.last, + uri: uri, + ); + } +} diff --git a/lib/src/browser.dart b/lib/src/browser.dart new file mode 100644 index 0000000000..e4a34d796e --- /dev/null +++ b/lib/src/browser.dart @@ -0,0 +1,105 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A pure Dart client for Sentry.io crash reporting. +import 'dart:convert'; +import 'dart:html' hide Event, Client; + +import 'package:http/http.dart'; +import 'package:http/browser_client.dart'; +import 'package:meta/meta.dart'; +import 'base.dart'; +import 'version.dart'; + +/// Logs crash reports and events to the Sentry.io service. +class SentryBrowserClient extends SentryClient { + /// Instantiates a client using [dsn] issued to your project by Sentry.io as + /// the endpoint for submitting events. + /// + /// [environmentAttributes] contain event attributes that do not change over + /// the course of a program's lifecycle. These attributes will be added to + /// all events captured via this client. The following attributes often fall + /// under this category: [Event.loggerName], [Event.serverName], + /// [Event.release], [Event.environment]. + /// + /// If [httpClient] is provided, it is used instead of the default client to + /// make HTTP calls to Sentry.io. This is useful in tests. + /// + /// 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). + /// + /// 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. + factory SentryBrowserClient({ + @required String dsn, + Event environmentAttributes, + Client httpClient, + dynamic clock, + UuidGenerator uuidGenerator, + String origin, + }) { + httpClient ??= BrowserClient(); + clock ??= getUtcDateTime; + uuidGenerator ??= generateUuidV4WithoutDashes; + + // origin is necessary for sentry to resolve stacktrace + origin ??= '${window.location.origin}/'; + + return SentryBrowserClient._( + httpClient: httpClient, + clock: clock, + uuidGenerator: uuidGenerator, + environmentAttributes: environmentAttributes, + dsn: dsn, + origin: origin, + platform: browserPlatform, + ); + } + + SentryBrowserClient._({ + Client httpClient, + dynamic clock, + UuidGenerator uuidGenerator, + Event environmentAttributes, + String dsn, + String platform, + String origin, + }) : super.base( + httpClient: httpClient, + clock: clock, + uuidGenerator: uuidGenerator, + environmentAttributes: environmentAttributes, + dsn: dsn, + platform: platform, + origin: origin, + ); + + @override + List bodyEncoder( + Map data, + Map headers, + ) => + // Gzip compression is implicit on browser + utf8.encode(json.encode(data)); +} + +SentryClient createSentryClient({ + @required String dsn, + Event environmentAttributes, + bool compressPayload, + Client httpClient, + dynamic clock, + UuidGenerator uuidGenerator, +}) => + SentryBrowserClient( + dsn: dsn, + environmentAttributes: environmentAttributes, + httpClient: httpClient, + clock: clock, + uuidGenerator: uuidGenerator, + ); diff --git a/lib/src/client_stub.dart b/lib/src/client_stub.dart new file mode 100644 index 0000000000..5797bed81a --- /dev/null +++ b/lib/src/client_stub.dart @@ -0,0 +1,20 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; + +import 'base.dart'; + +/// Implemented in `browser_client.dart` and `io_client.dart`. +SentryClient createSentryClient({ + @required String dsn, + Event environmentAttributes, + bool compressPayload, + Client httpClient, + dynamic clock, + UuidGenerator uuidGenerator, +}) => + throw UnsupportedError( + 'Cannot create a client without dart:html or dart:io.'); diff --git a/lib/src/io.dart b/lib/src/io.dart new file mode 100644 index 0000000000..b9a4ffaf27 --- /dev/null +++ b/lib/src/io.dart @@ -0,0 +1,129 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A pure Dart client for Sentry.io crash reporting. +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; +import 'base.dart'; +import 'version.dart'; + +/// Logs crash reports and events to the Sentry.io service. +class SentryIOClient extends SentryClient { + /// Whether to compress payloads sent to Sentry.io. + final bool compressPayload; + + /// Instantiates a client using [dsn] issued to your project by Sentry.io as + /// the endpoint for submitting events. + /// + /// [environmentAttributes] contain event attributes that do not change over + /// the course of a program's lifecycle. These attributes will be added to + /// all events captured via this client. The following attributes often fall + /// under this category: [Event.loggerName], [Event.serverName], + /// [Event.release], [Event.environment]. + /// + /// 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. + /// + /// If [httpClient] is provided, it is used instead of the default client to + /// make HTTP calls to Sentry.io. This is useful in tests. + /// + /// 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). + /// + /// 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. + factory SentryIOClient({ + @required String dsn, + Event environmentAttributes, + bool compressPayload, + Client httpClient, + dynamic clock, + UuidGenerator uuidGenerator, + }) { + httpClient ??= Client(); + clock ??= getUtcDateTime; + uuidGenerator ??= generateUuidV4WithoutDashes; + compressPayload ??= true; + + return SentryIOClient._( + httpClient: httpClient, + clock: clock, + uuidGenerator: uuidGenerator, + environmentAttributes: environmentAttributes, + dsn: dsn, + compressPayload: compressPayload, + platform: sdkPlatform, + ); + } + + SentryIOClient._({ + Client httpClient, + dynamic clock, + UuidGenerator uuidGenerator, + Event environmentAttributes, + String dsn, + this.compressPayload = true, + String platform, + String origin, + }) : super.base( + httpClient: httpClient, + clock: clock, + uuidGenerator: uuidGenerator, + environmentAttributes: environmentAttributes, + dsn: dsn, + platform: platform, + origin: origin, + ); + + @override + Map buildHeaders(String authHeader) { + final headers = super.buildHeaders(authHeader); + + // NOTE(lejard_h) overriding user agent on VM and Flutter not sure why + // for web it use browser user agent + headers['User-Agent'] = SentryClient.sentryClient; + + return headers; + } + + @override + List bodyEncoder( + Map data, + Map headers, + ) { + // [SentryIOClient] implement gzip compression + // gzip compression is not available on browser + List body = utf8.encode(json.encode(data)); + if (compressPayload) { + headers['Content-Encoding'] = 'gzip'; + body = gzip.encode(body); + } + return body; + } +} + +SentryClient createSentryClient({ + @required String dsn, + Event environmentAttributes, + bool compressPayload, + Client httpClient, + dynamic clock, + UuidGenerator uuidGenerator, +}) => + SentryIOClient( + dsn: dsn, + environmentAttributes: environmentAttributes, + compressPayload: compressPayload, + httpClient: httpClient, + clock: clock, + uuidGenerator: uuidGenerator, + ); diff --git a/lib/src/stack_trace.dart b/lib/src/stack_trace.dart index 6a20ffafdc..effbbb7857 100644 --- a/lib/src/stack_trace.dart +++ b/lib/src/stack_trace.dart @@ -23,16 +23,25 @@ const Map asynchronousGapFrameJson = { /// Encodes [stackTrace] as JSON in the Sentry.io format. /// /// [stackTrace] must be [String] or [StackTrace]. -List> encodeStackTrace(dynamic stackTrace, - {StackFrameFilter stackFrameFilter}) { +List> encodeStackTrace( + dynamic stackTrace, { + StackFrameFilter stackFrameFilter, + String origin, +}) { assert(stackTrace is String || stackTrace is StackTrace); + origin ??= ''; + final Chain chain = stackTrace is StackTrace ? Chain.forTrace(stackTrace) : Chain.parse(stackTrace); final List> frames = >[]; for (int t = 0; t < chain.traces.length; t += 1) { - frames.addAll(chain.traces[t].frames.map(encodeStackTraceFrame)); + final encodedFrames = chain.traces[t].frames + .map((f) => encodeStackTraceFrame(f, origin: origin)); + + frames.addAll(encodedFrames); + if (t < chain.traces.length - 1) frames.add(asynchronousGapFrameJson); } @@ -40,11 +49,14 @@ List> encodeStackTrace(dynamic stackTrace, return stackFrameFilter != null ? stackFrameFilter(jsonFrames) : jsonFrames; } -Map encodeStackTraceFrame(Frame frame) { +Map encodeStackTraceFrame(Frame frame, {String origin}) { + origin ??= ''; + final Map json = { - 'abs_path': _absolutePathForCrashReport(frame), + 'abs_path': '$origin${_absolutePathForCrashReport(frame)}', 'function': frame.member, 'lineno': frame.line, + 'colno': frame.column, 'in_app': !frame.isCore, }; diff --git a/lib/src/version.dart b/lib/src/version.dart index aa5a95e4b8..9102f35d6e 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -9,10 +9,15 @@ library version; /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '2.3.1'; +const String sdkVersion = '3.0.0-dev0'; /// The SDK name reported to Sentry.io in the submitted events. const String sdkName = 'dart'; /// The name of the SDK platform reported to Sentry.io in the submitted events. +/// +/// Used for IO version. const String sdkPlatform = 'dart'; + +/// Used to report browser Stacktrace to sentry. +const String browserPlatform = 'javascript'; diff --git a/pubspec.yaml b/pubspec.yaml index 6ee6e7b797..486fe93e85 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry -version: 2.3.1 +version: 3.0.0-dev0 description: A pure Dart Sentry.io client. author: Flutter Authors homepage: https://github.com/flutter/sentry diff --git a/test/event_test.dart b/test/event_test.dart new file mode 100644 index 0000000000..47177641ac --- /dev/null +++ b/test/event_test.dart @@ -0,0 +1,92 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +void main() { + group(Event, () { + test('$Breadcrumb serializes', () { + expect( + Breadcrumb( + "example log", + DateTime.utc(2019), + level: SeverityLevel.debug, + category: "test", + ).toJson(), + { + 'timestamp': '2019-01-01T00:00:00', + 'message': 'example log', + 'category': 'test', + 'level': 'debug', + }, + ); + }); + test('serializes to JSON', () { + final user = User( + id: "user_id", + username: "username", + email: "email@email.com", + ipAddress: "127.0.0.1", + extras: {"foo": "bar"}); + + final breadcrumbs = [ + Breadcrumb("test log", DateTime.utc(2019), + level: SeverityLevel.debug, category: "test"), + ]; + + expect( + Event( + message: 'test-message', + transaction: '/test/1', + exception: StateError('test-error'), + level: SeverityLevel.debug, + culprit: 'Professor Moriarty', + tags: { + 'a': 'b', + 'c': 'd', + }, + extra: { + 'e': 'f', + 'g': 2, + }, + fingerprint: [Event.defaultFingerprint, 'foo'], + userContext: user, + breadcrumbs: breadcrumbs, + ).toJson(), + { + 'platform': 'dart', + 'sdk': {'version': sdkVersion, 'name': 'dart'}, + 'message': 'test-message', + 'transaction': '/test/1', + 'exception': [ + {'type': 'StateError', 'value': 'Bad state: test-error'} + ], + 'level': 'debug', + 'culprit': 'Professor Moriarty', + 'tags': {'a': 'b', 'c': 'd'}, + 'extra': {'e': 'f', 'g': 2}, + 'fingerprint': ['{{ default }}', 'foo'], + 'user': { + 'id': 'user_id', + 'username': 'username', + 'email': 'email@email.com', + 'ip_address': '127.0.0.1', + 'extras': {'foo': 'bar'} + }, + 'breadcrumbs': { + 'values': [ + { + 'timestamp': '2019-01-01T00:00:00', + 'message': 'test log', + 'category': 'test', + 'level': 'debug', + }, + ] + }, + }, + ); + }); + }); +} diff --git a/test/sentry_browser_test.dart b/test/sentry_browser_test.dart new file mode 100644 index 0000000000..c7713139c0 --- /dev/null +++ b/test/sentry_browser_test.dart @@ -0,0 +1,21 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +@TestOn("browser") + +import 'package:sentry/browser_client.dart'; +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group(SentryBrowserClient, () { + test('SentryClient constructor build browser client', () { + final client = SentryClient(dsn: testDsn); + expect(client is SentryBrowserClient, isTrue); + }); + + runTest(isWeb: true); + }); +} diff --git a/test/sentry_io_test.dart b/test/sentry_io_test.dart new file mode 100644 index 0000000000..db1e7255b0 --- /dev/null +++ b/test/sentry_io_test.dart @@ -0,0 +1,23 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +@TestOn("vm") + +import 'dart:io'; + +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; +import 'package:sentry/src/io.dart'; + +import 'test_utils.dart'; + +void main() { + group(SentryIOClient, () { + test('SentryClient constructor build io client', () { + final client = SentryClient(dsn: testDsn); + expect(client is SentryIOClient, isTrue); + }); + + runTest(gzip: gzip); + }); +} diff --git a/test/sentry_test.dart b/test/sentry_test.dart deleted file mode 100644 index 94dbcdfb93..0000000000 --- a/test/sentry_test.dart +++ /dev/null @@ -1,424 +0,0 @@ -// Copyright 2017 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:sentry/sentry.dart'; -import 'package:test/test.dart'; - -const String _testDsn = 'https://public:secret@sentry.example.com/1'; -const String _testDsnWithoutSecret = 'https://public@sentry.example.com/1'; -const String _testDsnWithPath = - 'https://public:secret@sentry.example.com/path/1'; -const String _testDsnWithPort = - 'https://public:secret@sentry.example.com:8888/1'; -void main() { - group('$SentryClient', () { - test('can parse DSN', () async { - final SentryClient client = SentryClient(dsn: _testDsn); - expect(client.dsnUri, Uri.parse(_testDsn)); - expect(client.postUri, 'https://sentry.example.com/api/1/store/'); - expect(client.publicKey, 'public'); - expect(client.secretKey, 'secret'); - expect(client.projectId, '1'); - await client.close(); - }); - - test('can parse DSN without secret', () async { - final SentryClient client = SentryClient(dsn: _testDsnWithoutSecret); - expect(client.dsnUri, Uri.parse(_testDsnWithoutSecret)); - expect(client.postUri, 'https://sentry.example.com/api/1/store/'); - expect(client.publicKey, 'public'); - expect(client.secretKey, null); - expect(client.projectId, '1'); - await client.close(); - }); - - test('can parse DSN with path', () async { - final SentryClient client = SentryClient(dsn: _testDsnWithPath); - expect(client.dsnUri, Uri.parse(_testDsnWithPath)); - expect(client.postUri, 'https://sentry.example.com/path/api/1/store/'); - expect(client.publicKey, 'public'); - expect(client.secretKey, 'secret'); - expect(client.projectId, '1'); - await client.close(); - }); - test('can parse DSN with port', () async { - final SentryClient client = SentryClient(dsn: _testDsnWithPort); - expect(client.dsnUri, Uri.parse(_testDsnWithPort)); - expect(client.postUri, 'https://sentry.example.com:8888/api/1/store/'); - expect(client.publicKey, 'public'); - expect(client.secretKey, 'secret'); - expect(client.projectId, '1'); - await client.close(); - }); - test('sends client auth header without secret', () async { - final MockClient httpMock = MockClient(); - final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); - - Map headers; - - httpMock.answerWith((Invocation invocation) async { - if (invocation.memberName == #close) { - return null; - } - if (invocation.memberName == #post) { - headers = invocation.namedArguments[#headers]; - return Response('{"id": "test-event-id"}', 200); - } - fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); - }); - - final SentryClient client = SentryClient( - dsn: _testDsnWithoutSecret, - httpClient: httpMock, - clock: fakeClockProvider, - compressPayload: false, - uuidGenerator: () => 'X' * 32, - environmentAttributes: const Event( - serverName: 'test.server.com', - release: '1.2.3', - environment: 'staging', - ), - ); - - try { - throw ArgumentError('Test error'); - } catch (error, stackTrace) { - final SentryResponse response = await client.captureException( - exception: error, stackTrace: stackTrace); - expect(response.isSuccessful, true); - expect(response.eventId, 'test-event-id'); - expect(response.error, null); - } - - final Map expectedHeaders = { - 'User-Agent': '$sdkName/$sdkVersion', - 'Content-Type': 'application/json', - 'X-Sentry-Auth': 'Sentry sentry_version=6, ' - 'sentry_client=${SentryClient.sentryClient}, ' - 'sentry_timestamp=${fakeClockProvider().millisecondsSinceEpoch}, ' - 'sentry_key=public', - }; - - expect(headers, expectedHeaders); - - await client.close(); - }); - - testCaptureException(bool compressPayload) async { - final MockClient httpMock = MockClient(); - final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); - - String postUri; - Map headers; - List body; - httpMock.answerWith((Invocation invocation) async { - if (invocation.memberName == #close) { - return null; - } - if (invocation.memberName == #post) { - postUri = invocation.positionalArguments.single; - headers = invocation.namedArguments[#headers]; - body = invocation.namedArguments[#body]; - return Response('{"id": "test-event-id"}', 200); - } - fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); - }); - - final SentryClient client = SentryClient( - dsn: _testDsn, - httpClient: httpMock, - clock: fakeClockProvider, - uuidGenerator: () => 'X' * 32, - compressPayload: compressPayload, - environmentAttributes: const Event( - serverName: 'test.server.com', - release: '1.2.3', - environment: 'staging', - ), - ); - - try { - throw ArgumentError('Test error'); - } catch (error, stackTrace) { - final SentryResponse response = await client.captureException( - exception: error, stackTrace: stackTrace); - expect(response.isSuccessful, true); - expect(response.eventId, 'test-event-id'); - expect(response.error, null); - } - - expect(postUri, client.postUri); - - final Map expectedHeaders = { - 'User-Agent': '$sdkName/$sdkVersion', - 'Content-Type': 'application/json', - 'X-Sentry-Auth': 'Sentry sentry_version=6, ' - 'sentry_client=${SentryClient.sentryClient}, ' - 'sentry_timestamp=${fakeClockProvider().millisecondsSinceEpoch}, ' - 'sentry_key=public, ' - 'sentry_secret=secret', - }; - - if (compressPayload) expectedHeaders['Content-Encoding'] = 'gzip'; - - expect(headers, expectedHeaders); - - Map data; - if (compressPayload) { - data = json.decode(utf8.decode(gzip.decode(body))); - } else { - data = json.decode(utf8.decode(body)); - } - final Map stacktrace = data.remove('stacktrace'); - expect(stacktrace['frames'], const TypeMatcher()); - expect(stacktrace['frames'], isNotEmpty); - - final Map topFrame = - (stacktrace['frames'] as Iterable).last; - expect(topFrame.keys, - ['abs_path', 'function', 'lineno', 'in_app', 'filename']); - expect(topFrame['abs_path'], 'sentry_test.dart'); - expect(topFrame['function'], 'main..testCaptureException'); - expect(topFrame['lineno'], greaterThan(0)); - expect(topFrame['in_app'], true); - expect(topFrame['filename'], 'sentry_test.dart'); - - expect(data, { - 'project': '1', - 'event_id': 'X' * 32, - 'timestamp': '2017-01-02T00:00:00', - 'platform': 'dart', - 'exception': [ - {'type': 'ArgumentError', 'value': 'Invalid argument(s): Test error'} - ], - 'sdk': {'version': sdkVersion, 'name': 'dart'}, - 'logger': SentryClient.defaultLoggerName, - 'server_name': 'test.server.com', - 'release': '1.2.3', - 'environment': 'staging', - }); - - await client.close(); - } - - test('sends an exception report (compressed)', () async { - await testCaptureException(true); - }); - - test('sends an exception report (uncompressed)', () async { - await testCaptureException(false); - }); - - test('reads error message from the x-sentry-error header', () async { - final MockClient httpMock = MockClient(); - final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); - - httpMock.answerWith((Invocation invocation) async { - if (invocation.memberName == #close) { - return null; - } - if (invocation.memberName == #post) { - return Response('', 401, headers: { - 'x-sentry-error': 'Invalid api key', - }); - } - fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); - }); - - final SentryClient client = SentryClient( - dsn: _testDsn, - httpClient: httpMock, - clock: fakeClockProvider, - uuidGenerator: () => 'X' * 32, - compressPayload: false, - environmentAttributes: const Event( - serverName: 'test.server.com', - release: '1.2.3', - environment: 'staging', - ), - ); - - try { - throw ArgumentError('Test error'); - } catch (error, stackTrace) { - final SentryResponse response = await client.captureException( - exception: error, stackTrace: stackTrace); - expect(response.isSuccessful, false); - expect(response.eventId, null); - expect(response.error, - 'Sentry.io responded with HTTP 401: Invalid api key'); - } - - await client.close(); - }); - - test('$Event userContext overrides client', () async { - final MockClient httpMock = MockClient(); - final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); - - String loggedUserId; // used to find out what user context was sent - httpMock.answerWith((Invocation invocation) async { - if (invocation.memberName == #close) { - return null; - } - if (invocation.memberName == #post) { - // parse the body and detect which user context was sent - var bodyData = invocation.namedArguments[Symbol("body")]; - var decoded = Utf8Codec().decode(bodyData); - var decodedJson = JsonDecoder().convert(decoded); - loggedUserId = decodedJson['user']['id']; - return Response('', 401, headers: { - 'x-sentry-error': 'Invalid api key', - }); - } - fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); - }); - - final clientUserContext = User( - id: "client_user", - username: "username", - email: "email@email.com", - ipAddress: "127.0.0.1"); - final eventUserContext = User( - id: "event_user", - username: "username", - email: "email@email.com", - ipAddress: "127.0.0.1", - extras: {"foo": "bar"}); - - final SentryClient client = SentryClient( - dsn: _testDsn, - httpClient: httpMock, - clock: fakeClockProvider, - uuidGenerator: () => 'X' * 32, - compressPayload: false, - environmentAttributes: const Event( - serverName: 'test.server.com', - release: '1.2.3', - environment: 'staging', - ), - ); - client.userContext = clientUserContext; - - try { - throw ArgumentError('Test error'); - } catch (error, stackTrace) { - final eventWithoutContext = - Event(exception: error, stackTrace: stackTrace); - final eventWithContext = Event( - exception: error, - stackTrace: stackTrace, - userContext: eventUserContext); - await client.capture(event: eventWithoutContext); - expect(loggedUserId, clientUserContext.id); - await client.capture(event: eventWithContext); - expect(loggedUserId, eventUserContext.id); - } - - await client.close(); - }); - }); - - group('$Event', () { - test('$Breadcrumb serializes', () { - expect( - Breadcrumb( - "example log", - DateTime.utc(2019), - level: SeverityLevel.debug, - category: "test", - ).toJson(), - { - 'timestamp': '2019-01-01T00:00:00', - 'message': 'example log', - 'category': 'test', - 'level': 'debug', - }, - ); - }); - test('serializes to JSON', () { - final user = User( - id: "user_id", - username: "username", - email: "email@email.com", - ipAddress: "127.0.0.1", - extras: {"foo": "bar"}); - - final breadcrumbs = [ - Breadcrumb("test log", DateTime.utc(2019), - level: SeverityLevel.debug, category: "test"), - ]; - - expect( - Event( - message: 'test-message', - transaction: '/test/1', - exception: StateError('test-error'), - level: SeverityLevel.debug, - culprit: 'Professor Moriarty', - tags: { - 'a': 'b', - 'c': 'd', - }, - extra: { - 'e': 'f', - 'g': 2, - }, - fingerprint: [Event.defaultFingerprint, 'foo'], - userContext: user, - breadcrumbs: breadcrumbs, - ).toJson(), - { - 'platform': 'dart', - 'sdk': {'version': sdkVersion, 'name': 'dart'}, - 'message': 'test-message', - 'transaction': '/test/1', - 'exception': [ - {'type': 'StateError', 'value': 'Bad state: test-error'} - ], - 'level': 'debug', - 'culprit': 'Professor Moriarty', - 'tags': {'a': 'b', 'c': 'd'}, - 'extra': {'e': 'f', 'g': 2}, - 'fingerprint': ['{{ default }}', 'foo'], - 'user': { - 'id': 'user_id', - 'username': 'username', - 'email': 'email@email.com', - 'ip_address': '127.0.0.1', - 'extras': {'foo': 'bar'} - }, - 'breadcrumbs': { - 'values': [ - { - 'timestamp': '2019-01-01T00:00:00', - 'message': 'test log', - 'category': 'test', - 'level': 'debug', - }, - ] - }, - }, - ); - }); - }); -} - -typedef Answer = dynamic Function(Invocation invocation); - -class MockClient implements Client { - Answer _answer; - - void answerWith(Answer answer) { - _answer = answer; - } - - noSuchMethod(Invocation invocation) { - return _answer(invocation); - } -} diff --git a/test/stack_trace_test.dart b/test/stack_trace_test.dart index 987bc4283f..3a7c3c355d 100644 --- a/test/stack_trace_test.dart +++ b/test/stack_trace_test.dart @@ -14,6 +14,7 @@ void main() { 'abs_path': 'dart:core', 'function': 'buzz', 'lineno': 1, + 'colno': 2, 'in_app': false, 'filename': 'core' }); @@ -36,6 +37,7 @@ void main() { 'abs_path': 'test.dart', 'function': 'bar', 'lineno': 46, + 'colno': 9, 'in_app': true, 'filename': 'test.dart' }, @@ -43,6 +45,7 @@ void main() { 'abs_path': 'test.dart', 'function': 'baz', 'lineno': 50, + 'colno': 3, 'in_app': true, 'filename': 'test.dart' }, @@ -59,6 +62,7 @@ void main() { 'abs_path': 'test.dart', 'function': 'bar', 'lineno': 46, + 'colno': 9, 'in_app': true, 'filename': 'test.dart' }, @@ -69,6 +73,7 @@ void main() { 'abs_path': 'test.dart', 'function': 'baz', 'lineno': 50, + 'colno': 3, 'in_app': true, 'filename': 'test.dart' }, @@ -87,6 +92,7 @@ void main() { 'abs_path': 'test.dart', 'function': 'baz', 'lineno': 50, + 'colno': 3, 'in_app': true, 'filename': 'test.dart' }, diff --git a/test/test_utils.dart b/test/test_utils.dart new file mode 100644 index 0000000000..3d47bba7df --- /dev/null +++ b/test/test_utils.dart @@ -0,0 +1,371 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:http/testing.dart'; +import 'package:http/http.dart'; +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +const String testDsn = 'https://public:secret@sentry.example.com/1'; +const String _testDsnWithoutSecret = 'https://public@sentry.example.com/1'; +const String _testDsnWithPath = + 'https://public:secret@sentry.example.com/path/1'; +const String _testDsnWithPort = + 'https://public:secret@sentry.example.com:8888/1'; + +void testHeaders( + Map headers, + ClockProvider fakeClockProvider, { + bool withUserAgent = true, + bool compressPayload = true, + bool withSecret = true, +}) { + final Map expectedHeaders = { + 'Content-Type': 'application/json', + 'X-Sentry-Auth': 'Sentry sentry_version=6, ' + 'sentry_client=${SentryClient.sentryClient}, ' + 'sentry_timestamp=${fakeClockProvider().millisecondsSinceEpoch}, ' + 'sentry_key=public' + }; + + if (withSecret) { + expectedHeaders['X-Sentry-Auth'] += ', ' + 'sentry_secret=secret'; + } + + if (withUserAgent) expectedHeaders['User-Agent'] = '$sdkName/$sdkVersion'; + + if (compressPayload) expectedHeaders['Content-Encoding'] = 'gzip'; + + expect(headers, expectedHeaders); +} + +testCaptureException( + bool compressPayload, + Codec, List> gzip, + bool isWeb, +) async { + final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); + + String postUri; + Map headers; + List body; + final MockClient httpMock = MockClient((Request request) async { + if (request.method == 'POST') { + postUri = request.url.toString(); + headers = request.headers; + body = request.bodyBytes; + return Response('{"id": "test-event-id"}', 200); + } + fail('Unexpected request on ${request.method} ${request.url} in HttpMock'); + }); + + final SentryClient client = SentryClient( + dsn: testDsn, + httpClient: httpMock, + clock: fakeClockProvider, + uuidGenerator: () => 'X' * 32, + compressPayload: compressPayload, + environmentAttributes: const Event( + serverName: 'test.server.com', + release: '1.2.3', + environment: 'staging', + ), + ); + + try { + throw ArgumentError('Test error'); + } catch (error, stackTrace) { + final SentryResponse response = + await client.captureException(exception: error, stackTrace: stackTrace); + expect(response.isSuccessful, true); + expect(response.eventId, 'test-event-id'); + expect(response.error, null); + } + + expect(postUri, client.postUri); + + testHeaders( + headers, + fakeClockProvider, + compressPayload: compressPayload, + withUserAgent: !isWeb, + ); + + Map data; + if (compressPayload) { + data = json.decode(utf8.decode(gzip.decode(body))); + } else { + data = json.decode(utf8.decode(body)); + } + final Map stacktrace = data.remove('stacktrace'); + expect(stacktrace['frames'], const TypeMatcher()); + expect(stacktrace['frames'], isNotEmpty); + + final Map topFrame = + (stacktrace['frames'] as Iterable).last; + expect(topFrame.keys, [ + 'abs_path', + 'function', + 'lineno', + 'colno', + 'in_app', + 'filename', + ]); + + if (isWeb) { + // can't test the full url + // the localhost port can change + final absPathUri = Uri.parse(topFrame['abs_path']); + expect(absPathUri.host, 'localhost'); + expect(absPathUri.path, '/sentry_browser_test.dart.browser_test.dart.js'); + + expect( + topFrame['filename'], + 'sentry_browser_test.dart.browser_test.dart.js', + ); + expect(topFrame['function'], 'Object.wrapException'); + + expect(data, { + 'project': '1', + 'event_id': 'X' * 32, + 'timestamp': '2017-01-02T00:00:00', + 'logger': 'SentryClient', + 'platform': 'javascript', + 'sdk': {'version': sdkVersion, 'name': 'dart'}, + 'server_name': 'test.server.com', + 'release': '1.2.3', + 'environment': 'staging', + 'exception': [ + {'type': 'ArgumentError', 'value': 'Invalid argument(s): Test error'} + ], + }); + } else { + expect(topFrame['abs_path'], 'test_utils.dart'); + expect(topFrame['filename'], 'test_utils.dart'); + expect(topFrame['function'], 'testCaptureException'); + + expect(data, { + 'project': '1', + 'event_id': 'X' * 32, + 'timestamp': '2017-01-02T00:00:00', + 'platform': 'dart', + 'exception': [ + {'type': 'ArgumentError', 'value': 'Invalid argument(s): Test error'} + ], + 'sdk': {'version': sdkVersion, 'name': 'dart'}, + 'logger': 'SentryClient', + 'server_name': 'test.server.com', + 'release': '1.2.3', + 'environment': 'staging', + }); + } + + expect(topFrame['lineno'], greaterThan(0)); + expect(topFrame['in_app'], true); + + await client.close(); +} + +void runTest({Codec, List> gzip, bool isWeb = false}) { + test('can parse DSN', () async { + final SentryClient client = SentryClient(dsn: testDsn); + expect(client.dsnUri, Uri.parse(testDsn)); + expect(client.postUri, 'https://sentry.example.com/api/1/store/'); + expect(client.publicKey, 'public'); + expect(client.secretKey, 'secret'); + expect(client.projectId, '1'); + await client.close(); + }); + + test('can parse DSN without secret', () async { + final SentryClient client = SentryClient(dsn: _testDsnWithoutSecret); + expect(client.dsnUri, Uri.parse(_testDsnWithoutSecret)); + expect(client.postUri, 'https://sentry.example.com/api/1/store/'); + expect(client.publicKey, 'public'); + expect(client.secretKey, null); + expect(client.projectId, '1'); + await client.close(); + }); + + test('can parse DSN with path', () async { + final SentryClient client = SentryClient(dsn: _testDsnWithPath); + expect(client.dsnUri, Uri.parse(_testDsnWithPath)); + expect(client.postUri, 'https://sentry.example.com/path/api/1/store/'); + expect(client.publicKey, 'public'); + expect(client.secretKey, 'secret'); + expect(client.projectId, '1'); + await client.close(); + }); + test('can parse DSN with port', () async { + final SentryClient client = SentryClient(dsn: _testDsnWithPort); + expect(client.dsnUri, Uri.parse(_testDsnWithPort)); + expect(client.postUri, 'https://sentry.example.com:8888/api/1/store/'); + expect(client.publicKey, 'public'); + expect(client.secretKey, 'secret'); + expect(client.projectId, '1'); + await client.close(); + }); + test('sends client auth header without secret', () async { + final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); + + Map headers; + + final MockClient httpMock = MockClient((Request request) async { + if (request.method == 'POST') { + headers = request.headers; + return Response('{"id": "test-event-id"}', 200); + } + fail( + 'Unexpected request on ${request.method} ${request.url} in HttpMock'); + }); + + final SentryClient client = SentryClient( + dsn: _testDsnWithoutSecret, + httpClient: httpMock, + clock: fakeClockProvider, + compressPayload: false, + uuidGenerator: () => 'X' * 32, + environmentAttributes: const Event( + serverName: 'test.server.com', + release: '1.2.3', + environment: 'staging', + ), + ); + + try { + throw ArgumentError('Test error'); + } catch (error, stackTrace) { + final SentryResponse response = await client.captureException( + exception: error, stackTrace: stackTrace); + expect(response.isSuccessful, true); + expect(response.eventId, 'test-event-id'); + expect(response.error, null); + } + + testHeaders( + headers, + fakeClockProvider, + withUserAgent: !isWeb, + compressPayload: false, + withSecret: false, + ); + + await client.close(); + }); + + test('sends an exception report (compressed)', () async { + await testCaptureException(true, gzip, isWeb); + }, onPlatform: { + 'browser': Skip(), + }); + + test('sends an exception report (uncompressed)', () async { + await testCaptureException(false, gzip, isWeb); + }); + + test('reads error message from the x-sentry-error header', () async { + final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); + + final MockClient httpMock = MockClient((Request request) async { + if (request.method == 'POST') { + return Response('', 401, headers: { + 'x-sentry-error': 'Invalid api key', + }); + } + fail( + 'Unexpected request on ${request.method} ${request.url} in HttpMock'); + }); + + final SentryClient client = SentryClient( + dsn: testDsn, + httpClient: httpMock, + clock: fakeClockProvider, + uuidGenerator: () => 'X' * 32, + compressPayload: false, + environmentAttributes: const Event( + serverName: 'test.server.com', + release: '1.2.3', + environment: 'staging', + ), + ); + + try { + throw ArgumentError('Test error'); + } catch (error, stackTrace) { + final SentryResponse response = await client.captureException( + exception: error, stackTrace: stackTrace); + expect(response.isSuccessful, false); + expect(response.eventId, null); + expect( + response.error, 'Sentry.io responded with HTTP 401: Invalid api key'); + } + + await client.close(); + }); + + test('$Event userContext overrides client', () async { + final ClockProvider fakeClockProvider = () => DateTime.utc(2017, 1, 2); + + String loggedUserId; // used to find out what user context was sent + final MockClient httpMock = MockClient((Request request) async { + if (request.method == 'POST') { + var bodyData = request.bodyBytes; + var decoded = Utf8Codec().decode(bodyData); + var decodedJson = JsonDecoder().convert(decoded); + loggedUserId = decodedJson['user']['id']; + return Response('', 401, headers: { + 'x-sentry-error': 'Invalid api key', + }); + } + fail( + 'Unexpected request on ${request.method} ${request.url} in HttpMock'); + }); + + final clientUserContext = User( + id: "client_user", + username: "username", + email: "email@email.com", + ipAddress: "127.0.0.1"); + final eventUserContext = User( + id: "event_user", + username: "username", + email: "email@email.com", + ipAddress: "127.0.0.1", + extras: {"foo": "bar"}); + + final SentryClient client = SentryClient( + dsn: testDsn, + httpClient: httpMock, + clock: fakeClockProvider, + uuidGenerator: () => 'X' * 32, + compressPayload: false, + environmentAttributes: const Event( + serverName: 'test.server.com', + release: '1.2.3', + environment: 'staging', + ), + ); + client.userContext = clientUserContext; + + try { + throw ArgumentError('Test error'); + } catch (error, stackTrace) { + final eventWithoutContext = + Event(exception: error, stackTrace: stackTrace); + final eventWithContext = Event( + exception: error, + stackTrace: stackTrace, + userContext: eventUserContext); + await client.capture(event: eventWithoutContext); + expect(loggedUserId, clientUserContext.id); + await client.capture(event: eventWithContext); + expect(loggedUserId, eventUserContext.id); + } + + await client.close(); + }); +} diff --git a/test/version_test.dart b/test/version_test.dart index ad53835626..f1ff8574a8 100644 --- a/test/version_test.dart +++ b/test/version_test.dart @@ -2,9 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +@TestOn('vm') + import 'dart:io'; -import 'package:sentry/sentry.dart'; +import 'package:sentry/src/version.dart'; import 'package:test/test.dart'; import 'package:yaml/yaml.dart' as yaml; diff --git a/tool/dart2_test.sh b/tool/dart2_test.sh deleted file mode 100755 index d9f38d362c..0000000000 --- a/tool/dart2_test.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh -# Temporary workaround until Pub supports --preview-dart-2 flag -set -e -set -x -for filename in test/*_test.dart; do - dart --preview-dart-2 --enable_asserts "$filename" -done diff --git a/tool/presubmit.sh b/tool/presubmit.sh index 11d6cc48b3..5ec4c3ad9c 100755 --- a/tool/presubmit.sh +++ b/tool/presubmit.sh @@ -5,6 +5,5 @@ set -x pub get dartanalyzer --fatal-warnings ./ -pub run test --platform vm -./tool/dart2_test.sh +pub run test -p vm -p chrome dartfmt -n --set-exit-if-changed ./ From 80e28a6dcc6a28412f9c46240ea4b32fc6051e11 Mon Sep 17 00:00:00 2001 From: Yegor Date: Thu, 5 Dec 2019 09:57:47 -0800 Subject: [PATCH 08/12] Update authors and readme (#47) --- AUTHORS | 1 + README.md | 15 ++++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index fa93e5ec4e..1739b1535d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -5,3 +5,4 @@ Google Inc. Simon Lightfoot +Hadrien Lejard diff --git a/README.md b/README.md index e062959bc4..782f7e9c64 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,19 @@ [![Build Status](https://travis-ci.org/flutter/sentry.svg?branch=master)](https://travis-ci.org/flutter/sentry) -Use this library in your Dart programs (Flutter, command-line and (TBD) AngularDart) to report errors thrown by your -program to https://sentry.io error tracking service. +Use this library in your Dart programs (Flutter for mobile, Flutter for web, +command-line, and AngularDart) to report errors thrown by your program to +https://sentry.io error tracking service. ## Versions -`>=0.0.0 <2.0.0` is the range of versions compatible with Dart 1. +`>=2.0.0 <3.0.0` is the range of versions that support Flutter for mobile and +Dart VM only. -`>=2.0.0 <3.0.0` is the range of versions compatible with Dart 2. +Versions `3.0.0` and higher supports Flutter for mobile, Flutter for web, +command-line, and AngularDart. + +Versions `<2.0.0` are deprecated. ## Usage @@ -19,7 +24,7 @@ Add `sentry` dependency to your `pubspec.yaml`: ```yaml dependencies: - sentry: any + sentry: >=2.0.0 <3.0.0 ``` In your Dart code, import `package:sentry/sentry.dart` and create a `SentryClient` using the DSN issued by Sentry.io: From 1321244337f678b73720e071155b3db391ba9917 Mon Sep 17 00:00:00 2001 From: Yegor Date: Wed, 11 Dec 2019 08:02:22 -0800 Subject: [PATCH 09/12] promote to stable 3.0.0 (#49) --- README.md | 14 +++++++------- lib/src/version.dart | 2 +- pubspec.yaml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 782f7e9c64..01578d323e 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ https://sentry.io error tracking service. ## Versions +Versions `3.0.0` and higher support Flutter for mobile, Flutter for web, +command-line, desktop, and AngularDart. + `>=2.0.0 <3.0.0` is the range of versions that support Flutter for mobile and Dart VM only. -Versions `3.0.0` and higher supports Flutter for mobile, Flutter for web, -command-line, and AngularDart. - Versions `<2.0.0` are deprecated. ## Usage @@ -24,7 +24,7 @@ Add `sentry` dependency to your `pubspec.yaml`: ```yaml dependencies: - sentry: >=2.0.0 <3.0.0 + sentry: >=3.0.0 <4.0.0 ``` In your Dart code, import `package:sentry/sentry.dart` and create a `SentryClient` using the DSN issued by Sentry.io: @@ -54,7 +54,7 @@ main() async { - Use a `try/catch` block, like in the example above. - Create a `Zone` with an error handler, e.g. using [runZoned][run_zoned]. - + ```dart var sentry = SentryClient(dsn: "https://..."); // Run the whole app in a zone to capture all uncaught errors. @@ -75,7 +75,7 @@ main() async { ); ``` - For Flutter-specific errors (such as layout failures), use [FlutterError.onError][flutter_error]. For example: - + ```dart var sentry = SentryClient(dsn: "https://..."); FlutterError.onError = (details, {bool forceReport = false}) { @@ -92,7 +92,7 @@ main() async { } }; ``` -- Use `Isolate.current.addErrorListener` to capture uncaught errors +- Use `Isolate.current.addErrorListener` to capture uncaught errors in the root zone. [run_zoned]: https://api.dartlang.org/stable/dart-async/runZoned.html diff --git a/lib/src/version.dart b/lib/src/version.dart index 9102f35d6e..3109a67e7e 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -9,7 +9,7 @@ library version; /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '3.0.0-dev0'; +const String sdkVersion = '3.0.0'; /// The SDK name reported to Sentry.io in the submitted events. const String sdkName = 'dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 486fe93e85..443d0de748 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: sentry -version: 3.0.0-dev0 +version: 3.0.0 description: A pure Dart Sentry.io client. author: Flutter Authors homepage: https://github.com/flutter/sentry From 545ce93dc4c8462cff3c624d0f9e3ae594e5cb64 Mon Sep 17 00:00:00 2001 From: Yegor Date: Wed, 11 Dec 2019 08:45:25 -0800 Subject: [PATCH 10/12] Implement pub recommendations (#50) --- CHANGELOG.md | 6 +++++- example/README.md | 12 ++++++++++++ bin/test.dart => example/main.dart | 0 lib/src/version.dart | 2 +- pubspec.yaml | 7 ++++--- 5 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 example/README.md rename bin/test.dart => example/main.dart (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c5c224b4..885c6a129a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ # package:sentry changelog +## 3.0.0+1 + +- `pubspec.yaml` and example code clean-up. + ## 3.0.0 -- Support Web +- Support Web - `SentryClient` from `package:sentry/sentry.dart` with conditional import - `SentryBrowserClient` for web from `package:sentry/browser_client.dart` - `SentryIOClient` for VM and Flutter from `package:sentry/io_client.dart` diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000000..4951541bfa --- /dev/null +++ b/example/README.md @@ -0,0 +1,12 @@ +# `package:sentry` example + +The example in this directory throws an error and sends it to Sentry.io. Use it +as a source of example code, or to smoke-test your Sentry.io configuration. + +To use the example, create a Sentry.io account and get a DSN for your project. +Then run the following command, replacing "{DSN}" with the one you got from +Sentry.io: + +``` +dart example/main.dart {DSN} +``` diff --git a/bin/test.dart b/example/main.dart similarity index 100% rename from bin/test.dart rename to example/main.dart diff --git a/lib/src/version.dart b/lib/src/version.dart index 3109a67e7e..723d2f223b 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -9,7 +9,7 @@ library version; /// The SDK version reported to Sentry.io in the submitted events. -const String sdkVersion = '3.0.0'; +const String sdkVersion = '3.0.0+1'; /// The SDK name reported to Sentry.io in the submitted events. const String sdkName = 'dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 443d0de748..878683a62f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,8 @@ name: sentry -version: 3.0.0 -description: A pure Dart Sentry.io client. -author: Flutter Authors +version: 3.0.0+1 +description: > + A crash reporting library for for Dart that sends crash reports to Sentry.io. + This library supports Dart VM, and Flutter for mobile, web, and desktop. homepage: https://github.com/flutter/sentry environment: From 8643fc7759e8572489a4624b1ff74e3d018ea367 Mon Sep 17 00:00:00 2001 From: Maksim Lin Date: Fri, 10 Jan 2020 15:53:24 +1100 Subject: [PATCH 11/12] move contexts implementation to new base class --- lib/src/base.dart | 477 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 477 insertions(+) diff --git a/lib/src/base.dart b/lib/src/base.dart index 772b5a37b0..09b5ed3818 100644 --- a/lib/src/base.dart +++ b/lib/src/base.dart @@ -308,6 +308,7 @@ class Event { this.extra, this.fingerprint, this.userContext, + this.contexts, this.breadcrumbs, }); @@ -370,6 +371,11 @@ class Event { /// set in [SentryClient.userContext] for this logged event. final User userContext; + /// The context interfaces provide additional context data. + /// Typically this is data related to the current user, + /// the current HTTP request. + final Contexts contexts; + /// Used to deduplicate events by grouping ones with the same fingerprint /// together. /// @@ -491,6 +497,11 @@ class Event { json['extra'] = extra; } + Map contextsMap; + if (contexts != null && (contextsMap = contexts.toJson()).isNotEmpty) { + json['contexts'] = contextsMap; + } + Map userContextMap; if (userContext != null && (userContextMap = userContext.toJson()).isNotEmpty) { @@ -511,6 +522,472 @@ class Event { } } +/// The context interfaces provide additional context data. +/// +/// Typically this is data related to the current user, +/// the current HTTP request. +/// +/// See also: https://docs.sentry.io/development/sdk-dev/event-payloads/contexts/. +class Contexts { + /// This describes the device that caused the event. + final Device device; + + /// Describes the operating system on which the event was created. + /// + /// In web contexts, this is the operating system of the browse + /// (normally pulled from the User-Agent string). + final OperatingSystem operatingSystem; + + /// Describes a runtime in more detail. + /// + /// Typically this context is used multiple times if multiple runtimes + /// are involved (for instance if you have a JavaScript application running + /// on top of JVM). + final List runtimes; + + /// App context describes the application. + /// + /// As opposed to the runtime, this is the actual application that was + /// running and carries metadata about the current session. + final App app; + + /// Carries information about the browser or user agent for web-related + /// errors. + /// + /// This can either be the browser this event ocurred in, or the user + /// agent of a web request that triggered the event. + final Browser browser; + + const Contexts({ + this.device, + this.operatingSystem, + this.runtimes, + this.app, + this.browser, + }); + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + final Map json = {}; + + Map deviceMap; + if (device != null && (deviceMap = device.toJson()).isNotEmpty) { + json['device'] = deviceMap; + } + + Map osMap; + if (operatingSystem != null && + (osMap = operatingSystem.toJson()).isNotEmpty) { + json['os'] = osMap; + } + + Map appMap; + if (app != null && (appMap = app.toJson()).isNotEmpty) { + json['app'] = appMap; + } + + Map browserMap; + if (browser != null && (browserMap = browser.toJson()).isNotEmpty) { + json['browser'] = browserMap; + } + + if (runtimes != null) { + if (runtimes.length == 1) { + final Runtime runtime = runtimes[0]; + if (runtime != null) { + final String key = runtime.key ?? 'runtime'; + json[key] = runtime.toJson(); + } + } else if (runtimes.length > 1) { + for (final runtime in runtimes) { + if (runtime != null) { + String key = runtime.key ?? runtime.name.toLowerCase(); + + if (json.containsKey(key)) { + int k = 0; + while (json.containsKey(key)) { + key = '$key$k'; + k++; + } + } + + json[key] = runtime.toJson()..addAll({"type": "runtime"}); + } + } + } + } + + return json; + } +} + +/// This describes the device that caused the event. +class Device { + /// The name of the device. This is typically a hostname. + final String name; + + /// The family of the device. + /// + /// This is normally the common part of model names across generations. + /// For instance `iPhone` would be a reasonable family, + /// so would be `Samsung Galaxy`. + final String family; + + /// The model name. This for instance can be `Samsung Galaxy S3`. + final String model; + + /// An internal hardware revision to identify the device exactly. + final String modelId; + + /// The CPU architecture. + final String arch; + + /// If the device has a battery, this can be an floating point value + /// defining the battery level (in the range 0-100). + final double batteryLevel; + + /// Defines the orientation of a device. + final Orientation orientation; + + /// The manufacturer of the device. + final String manufacturer; + + /// The brand of the device. + final String brand; + + /// The screen resolution. (e.g.: `800x600`, `3040x1444`). + final String screenResolution; + + /// A floating point denoting the screen density. + final String screenDensity; + + /// A decimal value reflecting the DPI (dots-per-inch) density. + final String screenDpi; + + /// Whether the device was online or not. + final bool online; + + /// Whether the device was charging or not. + final bool charging; + + /// Whether the device was low on memory. + final bool lowMemory; + + /// A flag indicating whether this device is a simulator or an actual device. + final bool simulator; + + /// Total system memory available in bytes. + final int memorySize; + + /// Free system memory in bytes. + final int freeMemory; + + /// Memory usable for the app in bytes. + final int usableMemory; + + /// Total device storage in bytes. + final int storageSize; + + /// Free device storage in bytes. + final int freeStorage; + + /// Total size of an attached external storage in bytes + /// (e.g.: android SDK card). + final int externalStorageSize; + + /// Free size of an attached external storage in bytes + /// (e.g.: android SDK card). + final int externalFreeStorage; + + /// When the system was booted + final DateTime bootTime; + + /// The timezone of the device, e.g.: `Europe/Vienna`. + final String timezone; + + const Device({ + this.name, + this.family, + this.model, + this.modelId, + this.arch, + this.batteryLevel, + this.orientation, + this.manufacturer, + this.brand, + this.screenResolution, + this.screenDensity, + this.screenDpi, + this.online, + this.charging, + this.lowMemory, + this.simulator, + this.memorySize, + this.freeMemory, + this.usableMemory, + this.storageSize, + this.freeStorage, + this.externalStorageSize, + this.externalFreeStorage, + this.bootTime, + this.timezone, + }) : assert( + batteryLevel == null || (batteryLevel >= 0 && batteryLevel <= 100)); + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + final Map json = {}; + + String orientation; + + switch (this.orientation) { + case Orientation.portrait: + orientation = "portait"; + break; + case Orientation.landscape: + orientation = "landscape"; + break; + } + + if (name != null) json['name'] = name; + + if (family != null) json['family'] = family; + + if (model != null) json['model'] = model; + + if (modelId != null) json['model_id'] = modelId; + + if (arch != null) json['arch'] = arch; + + if (batteryLevel != null) json['battery_level'] = batteryLevel; + + if (orientation != null) json['orientation'] = orientation; + + if (manufacturer != null) json['manufacturer'] = manufacturer; + + if (brand != null) json['brand'] = brand; + + if (screenResolution != null) json['screen_resolution'] = screenResolution; + + if (screenDensity != null) json['screen_density'] = screenDensity; + + if (screenDpi != null) json['screen_dpi'] = screenDpi; + + if (online != null) json['online'] = online; + + if (charging != null) json['charging'] = charging; + + if (lowMemory != null) json['low_memory'] = lowMemory; + + if (simulator != null) json['simulator'] = simulator; + + if (memorySize != null) json['memory_size'] = memorySize; + + if (freeMemory != null) json['free_memory'] = freeMemory; + + if (usableMemory != null) json['usable_memory'] = usableMemory; + + if (storageSize != null) json['storage_size'] = storageSize; + + if (externalStorageSize != null) { + json['external_storage_size'] = externalStorageSize; + } + + if (externalFreeStorage != null) { + json['exterenal_free_storage'] = externalFreeStorage; + } + + if (bootTime != null) json['boot_time'] = bootTime.toIso8601String(); + + if (timezone != null) json['timezone'] = timezone; + + return json; + } +} + +enum Orientation { portrait, landscape } + +/// Describes the operating system on which the event was created. +/// +/// In web contexts, this is the operating system of the browse +/// (normally pulled from the User-Agent string). +class OperatingSystem { + /// The name of the operating system. + final String name; + + /// The version of the operating system. + final String version; + + /// The internal build revision of the operating system. + final String build; + + /// An independent kernel version string. + /// + /// This is typically the entire output of the `uname` syscall. + final String kernelVersion; + + /// A flag indicating whether the OS has been jailbroken or rooted. + final bool rooted; + + /// An unprocessed description string obtained by the operating system. + /// + /// For some well-known runtimes, Sentry will attempt to parse name and + /// version from this string, if they are not explicitly given. + final String rawDescription; + + const OperatingSystem({ + this.name, + this.version, + this.build, + this.kernelVersion, + this.rooted, + this.rawDescription, + }); + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + final Map json = {}; + + if (name != null) json['name'] = name; + + if (version != null) json['version'] = version; + + if (build != null) json['build'] = build; + + if (kernelVersion != null) json['kernel_version'] = kernelVersion; + + if (rooted != null) json['rooted'] = rooted; + + if (rawDescription != null) json['raw_description'] = rawDescription; + + return json; + } +} + +/// Describes a runtime in more detail. +/// +/// Typically this context is used multiple times if multiple runtimes +/// are involved (for instance if you have a JavaScript application running +/// on top of JVM). +class Runtime { + /// Key used in the JSON and which will be displayed + /// in the Sentry UI. Defaults to lower case version of [name]. + /// + /// Unused if only one [Runtime] is provided in [Contexts]. + final String key; + + /// The name of the runtime. + final String name; + + /// The version identifier of the runtime. + final String version; + + /// An unprocessed description string obtained by the runtime. + /// + /// For some well-known runtimes, Sentry will attempt to parse name + /// and version from this string, if they are not explicitly given. + final String rawDescription; + + const Runtime({this.key, this.name, this.version, this.rawDescription}) + : assert(key == null || key.length >= 1); + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + final Map json = {}; + + if (name != null) json['name'] = name; + + if (version != null) json['version'] = version; + + if (rawDescription != null) json['raw_description'] = rawDescription; + + return json; + } +} + +/// App context describes the application. +/// +/// As opposed to the runtime, this is the actual application that was +/// running and carries metadata about the current session. +class App { + /// Human readable application name, as it appears on the platform. + final String name; + + /// Human readable application version, as it appears on the platform. + final String version; + + /// Version-independent application identifier, often a dotted bundle ID. + final String identifier; + + /// Internal build identifier, as it appears on the platform. + final String build; + + /// String identifying the kind of build, e.g. `testflight`. + final String buildType; + + /// When the application was started by the user. + final DateTime startTime; + + /// Application specific device identifier. + final String deviceAppHash; + + const App({ + this.name, + this.version, + this.identifier, + this.build, + this.buildType, + this.startTime, + this.deviceAppHash, + }); + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + final Map json = {}; + + if (name != null) json['app_name'] = name; + + if (version != null) json['app_version'] = version; + + if (identifier != null) json['app_identifier'] = identifier; + + if (build != null) json['app_build'] = build; + + if (buildType != null) json['build_type'] = buildType; + + if (startTime != null) json['app_start_time'] = startTime.toIso8601String(); + + if (deviceAppHash != null) json['device_app_hash'] = deviceAppHash; + + return json; + } +} + +/// Carries information about the browser or user agent for web-related errors. +/// +/// This can either be the browser this event ocurred in, or the user +/// agent of a web request that triggered the event. +class Browser { + /// Human readable application name, as it appears on the platform. + final String name; + + /// Human readable application version, as it appears on the platform. + final String version; + + const Browser({this.name, this.version}); + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + final Map json = {}; + + if (name != null) json['name'] = name; + + if (version != null) json['version'] = version; + + return json; + } +} + /// Describes the current user associated with the application, such as the /// currently signed in user. /// From 08e51f3fd6a475558ae0f2a4652dc5463733f960 Mon Sep 17 00:00:00 2001 From: Maksim Lin Date: Fri, 10 Jan 2020 17:26:23 +1100 Subject: [PATCH 12/12] contexts test, fix bug, missing field in device --- lib/src/base.dart | 4 +- test/contexts_test.dart | 95 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 test/contexts_test.dart diff --git a/lib/src/base.dart b/lib/src/base.dart index 09b5ed3818..3af2883a24 100644 --- a/lib/src/base.dart +++ b/lib/src/base.dart @@ -789,12 +789,14 @@ class Device { if (storageSize != null) json['storage_size'] = storageSize; + if (freeStorage != null) json['free_storage'] = freeStorage; + if (externalStorageSize != null) { json['external_storage_size'] = externalStorageSize; } if (externalFreeStorage != null) { - json['exterenal_free_storage'] = externalFreeStorage; + json['external_free_storage'] = externalFreeStorage; } if (bootTime != null) json['boot_time'] = bootTime.toIso8601String(); diff --git a/test/contexts_test.dart b/test/contexts_test.dart new file mode 100644 index 0000000000..228ba740f0 --- /dev/null +++ b/test/contexts_test.dart @@ -0,0 +1,95 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +void main() { + group(Contexts, () { + test('serializes to JSON', () { + final testDevice = Device( + name: 'testDevice', + family: 'testFamily', + model: 'testModel', + modelId: 'testModelId', + arch: 'testArch', + batteryLevel: 23, + orientation: Orientation.landscape, + manufacturer: 'testOEM', + brand: 'testBrand', + screenResolution: '123x345', + screenDensity: '99', + screenDpi: '100', + online: false, + charging: true, + lowMemory: false, + simulator: true, + memorySize: 1234567, + freeMemory: 12345, + usableMemory: 9876, + storageSize: 1234567, + freeStorage: 1234567, + externalStorageSize: 98765, + externalFreeStorage: 98765, + bootTime: DateTime.fromMicrosecondsSinceEpoch(0), + timezone: 'Australia/Melbourne', + ); + final testOS = OperatingSystem(name: 'testOS'); + final testRuntimes = [ + Runtime(name: 'testRT1', version: '1.0'), + Runtime(name: 'testRT2', version: '2.3.1'), + ]; + final testApp = App(version: '1.2.3'); + final testBrowser = Browser(version: '12.3.4'); + + final contexts = Contexts( + device: testDevice, + operatingSystem: testOS, + runtimes: testRuntimes, + app: testApp, + browser: testBrowser, + ); + + expect( + contexts.toJson(), + { + 'device': { + 'name': 'testDevice', + 'family': 'testFamily', + 'model': 'testModel', + 'model_id': 'testModelId', + 'arch': 'testArch', + 'battery_level': 23, + 'orientation': 'landscape', + 'manufacturer': 'testOEM', + 'brand': 'testBrand', + 'screen_resolution': '123x345', + 'screen_density': '99', + 'screen_dpi': '100', + 'online': false, + 'charging': true, + 'low_memory': false, + 'simulator': true, + 'memory_size': 1234567, + 'free_memory': 12345, + 'usable_memory': 9876, + 'storage_size': 1234567, + 'free_storage': 1234567, + 'external_storage_size': 98765, + 'external_free_storage': 98765, + 'boot_time': '1970-01-01T10:00:00.000', + 'timezone': 'Australia/Melbourne', + }, + 'os': { + 'name': 'testOS', + }, + 'testrt1': {'name': 'testRT1', 'type': 'runtime', 'version': '1.0'}, + 'testrt2': {'name': 'testRT2', 'type': 'runtime', 'version': '2.3.1'}, + 'app': {'app_version': '1.2.3'}, + 'browser': {'version': '12.3.4'}, + }, + ); + }); + }); +}