diff --git a/.gitignore b/.gitignore index c57b12a34b3b..3c2252a2439a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,15 @@ .DS_Store .atom/ .idea +.dart_tool/ .packages .pub/ -pubspec.lock - Podfile.lock Pods/ GeneratedPluginRegistrant.h GeneratedPluginRegistrant.m - GeneratedPluginRegistrant.java - +pubspec.lock +packages/sentry/build/ +packages/sentry/android/ +packages/sentry/ios/ diff --git a/packages/sentry/.idea/modules.xml b/packages/sentry/.idea/modules.xml new file mode 100644 index 000000000000..96a30c72817d --- /dev/null +++ b/packages/sentry/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/sentry/.idea/sentry.iml b/packages/sentry/.idea/sentry.iml new file mode 100644 index 000000000000..7457fc5904d9 --- /dev/null +++ b/packages/sentry/.idea/sentry.iml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/sentry/.idea/vcs.xml b/packages/sentry/.idea/vcs.xml new file mode 100644 index 000000000000..94a25f7f4cb4 --- /dev/null +++ b/packages/sentry/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/sentry/.travis.yml b/packages/sentry/.travis.yml new file mode 100644 index 000000000000..b3a6410de40a --- /dev/null +++ b/packages/sentry/.travis.yml @@ -0,0 +1,5 @@ +language: dart +dart: + # - stable # there's no Dart 2 on the stable channel yet + - dev +script: ./tool/presubmit.sh diff --git a/packages/sentry/AUTHORS b/packages/sentry/AUTHORS new file mode 100644 index 000000000000..fa93e5ec4e78 --- /dev/null +++ b/packages/sentry/AUTHORS @@ -0,0 +1,7 @@ +# Below is a list of people and organizations that have contributed +# to package:sentry. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. +Simon Lightfoot diff --git a/packages/sentry/CHANGELOG.md b/packages/sentry/CHANGELOG.md new file mode 100644 index 000000000000..527ec150ece8 --- /dev/null +++ b/packages/sentry/CHANGELOG.md @@ -0,0 +1,58 @@ +# package:sentry changelog + +## 2.1.1 + +- Defensively copy internal maps event attributes to + avoid shared mutable state (https://github.com/flutter/sentry/commit/044e4c1f43c2d199ed206e5529e2a630c90e4434) + +## 2.1.0 + +- Support DNS format without secret key. +- Remove dependency on `package:quiver`. +- The `clock` argument to `SentryClient` constructor _should_ now be + `ClockProvider` (but still accepts `Clock` for backwards compatibility). + +## 2.0.2 + +- Add support for user context in Sentry events. + +## 2.0.1 + +- Invert stack frames to be compatible with Sentry's default culprit detection. + +## 2.0.0 + +- Fixed deprecation warnings for Dart 2 +- Refactored tests to work with Dart 2 + +## 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 + +## 0.0.6 + +- use UTC in the `timestamp` field + +## 0.0.5 + +- remove sub-seconds from the timestamp + +## 0.0.4 + +- parse and report async gaps in stack traces + +## 0.0.3 + +- environment attributes +- auto-generate event_id and timestamp for events + +## 0.0.2 + +- parse and report stack traces +- use x-sentry-error HTTP response header +- gzip outgoing payloads by default + +## 0.0.1 + +- basic ability to send exception reports to Sentry.io diff --git a/packages/sentry/LICENSE b/packages/sentry/LICENSE new file mode 100644 index 000000000000..6f2d1444dd98 --- /dev/null +++ b/packages/sentry/LICENSE @@ -0,0 +1,27 @@ +// Copyright 2014 The Chromium Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/sentry/PATENTS b/packages/sentry/PATENTS new file mode 100644 index 000000000000..ac39faf67938 --- /dev/null +++ b/packages/sentry/PATENTS @@ -0,0 +1,17 @@ +Google hereby grants to you a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this +section) patent license to make, have made, use, offer to sell, sell, +import, transfer, and otherwise run, modify and propagate the contents +of this implementation, where such license applies only to those +patent claims, both currently owned by Google and acquired in the +future, licensable by Google that are necessarily infringed by this +implementation. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute +or order or agree to the institution of patent litigation or any other +patent enforcement activity against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that this +implementation constitutes direct or contributory patent infringement, +or inducement of patent infringement, then any patent rights granted +to you under this License for this implementation shall terminate as +of the date such litigation is filed. \ No newline at end of file diff --git a/packages/sentry/README.md b/packages/sentry/README.md new file mode 100644 index 000000000000..1bd7d9579eca --- /dev/null +++ b/packages/sentry/README.md @@ -0,0 +1,60 @@ +# Sentry.io client for Dart + +[![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. + +## 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 compatible with Dart 2. + +## Usage + +Sign up for a Sentry.io account and get a DSN at http://sentry.io. + +Add `sentry` dependency to your `pubspec.yaml`: + +```yaml +dependencies: + sentry: any +``` + +In your Dart code, import `package:sentry/sentry.dart` and create a `SentryClient` using the DSN issued by Sentry.io: + +```dart +import 'package:sentry/sentry.dart'; + +final SentryClient sentry = new SentryClient(dsn: YOUR_DSN); +``` + +In an exception handler, call `captureException()`: + +```dart +main() async { + try { + doSomethingThatMightThrowAnError(); + } catch(error, stackTrace) { + await sentry.captureException( + exception: error, + stackTrace: stackTrace, + ); + } +} +``` + +## 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 + +[run_zoned]: https://api.dartlang.org/stable/dart-async/runZoned.html +[flutter_error]: https://docs.flutter.io/flutter/foundation/FlutterError/onError.html + +## Found a bug? + +Please file it at https://github.com/flutter/flutter/issues/new diff --git a/packages/sentry/bin/test.dart b/packages/sentry/bin/test.dart new file mode 100644 index 000000000000..bbeadbe1fcd0 --- /dev/null +++ b/packages/sentry/bin/test.dart @@ -0,0 +1,51 @@ +// 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:io'; + +import 'package:sentry/sentry.dart'; + +/// Sends a test exception report to Sentry.io using this Dart client. +Future main(List rawArgs) async { + if (rawArgs.length != 1) { + stderr.writeln( + 'Expected exactly one argument, which is the DSN issued by Sentry.io to your project.'); + exit(1); + } + + final String dsn = rawArgs.single; + final SentryClient client = new SentryClient(dsn: dsn); + + try { + await foo(); + } catch (error, stackTrace) { + print('Reporting the following stack trace: '); + print(stackTrace); + final SentryResponse response = await client.captureException( + exception: error, + stackTrace: stackTrace, + ); + + if (response.isSuccessful) { + print('SUCCESS\nid: ${response.eventId}'); + } else { + print('FAILURE: ${response.error}'); + } + } finally { + await client.close(); + } +} + +Future foo() async { + await bar(); +} + +Future bar() async { + await baz(); +} + +Future baz() async { + throw new StateError('This is a test error'); +} diff --git a/packages/sentry/lib/sentry.dart b/packages/sentry/lib/sentry.dart new file mode 100644 index 000000000000..ccf20f884b83 --- /dev/null +++ b/packages/sentry/lib/sentry.dart @@ -0,0 +1,489 @@ +// 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. +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/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 ??= new 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 new 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 new 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 => + '${dsnUri.scheme}://${dsnUri.host}/api/$projectId/store/'; + + /// Reports an [event] to Sentry.io. + Future capture({@required Event event}) 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(), 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 new SentryResponse.failure(errorMessage); + } + + final String eventId = json.decode(response.body)['id']; + return new SentryResponse.success(eventId: eventId); + } + + /// Reports the [exception] and optionally its [stackTrace] to Sentry.io. + Future captureException({ + @required dynamic exception, + dynamic stackTrace, + }) { + final Event event = new Event( + exception: exception, + stackTrace: stackTrace, + ); + return capture(event: event); + } + + 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() => + new 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'); + + 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() => new 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.exception, + this.stackTrace, + this.level, + this.culprit, + this.tags, + this.extra, + this.fingerprint, + this.userContext, + }); + + /// 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; + + /// 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; + + /// 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() { + 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 (exception != null) { + json['exception'] = [ + { + 'type': '${exception.runtimeType}', + 'value': '$exception', + } + ]; + } + + if (stackTrace != null) { + json['stacktrace'] = { + 'frames': encodeStackTrace(stackTrace), + }; + } + + 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; + + 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, + }; + } +} diff --git a/packages/sentry/lib/src/stack_trace.dart b/packages/sentry/lib/src/stack_trace.dart new file mode 100644 index 000000000000..d1175d69213f --- /dev/null +++ b/packages/sentry/lib/src/stack_trace.dart @@ -0,0 +1,57 @@ +// 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:stack_trace/stack_trace.dart'; + +/// Sentry.io JSON encoding of a stack frame for the asynchronous suspension, +/// which is the gap between asynchronous calls. +const Map asynchronousGapFrameJson = const { + 'abs_path': '', +}; + +/// Encodes [stackTrace] as JSON in the Sentry.io format. +/// +/// [stackTrace] must be [String] or [StackTrace]. +List> encodeStackTrace(dynamic stackTrace) { + assert(stackTrace is String || stackTrace is StackTrace); + final Chain chain = stackTrace is StackTrace + ? new Chain.forTrace(stackTrace) + : new Chain.parse(stackTrace); + + final List> frames = >[]; + for (int t = 0; t < chain.traces.length; t += 1) { + frames.addAll(chain.traces[t].frames.map(encodeStackTraceFrame)); + if (t < chain.traces.length - 1) frames.add(asynchronousGapFrameJson); + } + return frames.reversed.toList(); +} + +Map encodeStackTraceFrame(Frame frame) { + final Map json = { + 'abs_path': _absolutePathForCrashReport(frame), + 'function': frame.member, + 'lineno': frame.line, + 'in_app': !frame.isCore, + }; + + if (frame.uri.pathSegments.isNotEmpty) + json['filename'] = frame.uri.pathSegments.last; + + return json; +} + +/// A stack frame's code path may be one of "file:", "dart:" and "package:". +/// +/// Absolute file paths may contain personally identifiable information, and +/// therefore are stripped to only send the base file name. For example, +/// "/foo/bar/baz.dart" is reported as "baz.dart". +/// +/// "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') + return frame.uri.pathSegments.last; + + return '${frame.uri}'; +} diff --git a/packages/sentry/lib/src/utils.dart b/packages/sentry/lib/src/utils.dart new file mode 100644 index 000000000000..0e5e93e18113 --- /dev/null +++ b/packages/sentry/lib/src/utils.dart @@ -0,0 +1,35 @@ +// 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:meta/meta.dart'; + +/// Recursively merges [attributes] [into] another map of attributes. +/// +/// [attributes] take precedence over the target map. Recursion takes place +/// along [Map] values only. All other types are overwritten entirely. +void mergeAttributes(Map attributes, + {@required Map into}) { + assert(attributes != null && into != null); + attributes.forEach((String name, dynamic value) { + dynamic targetValue = into[name]; + if (value is Map) { + if (targetValue is! Map) { + // Let mergeAttributes make a deep copy, because assigning a reference + // of 'value' will expose 'value' to be mutated by further merges. + into[name] = targetValue = {}; + } + mergeAttributes(value, into: targetValue); + } else { + into[name] = value; + } + }); +} + +String formatDateAsIso8601WithSecondPrecision(DateTime date) { + String iso = date.toIso8601String(); + final millisecondSeparatorIndex = iso.lastIndexOf('.'); + if (millisecondSeparatorIndex != -1) + iso = iso.substring(0, millisecondSeparatorIndex); + return iso; +} diff --git a/packages/sentry/lib/src/version.dart b/packages/sentry/lib/src/version.dart new file mode 100644 index 000000000000..b7b759e2bc41 --- /dev/null +++ b/packages/sentry/lib/src/version.dart @@ -0,0 +1,18 @@ +// 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. + +/// Sentry.io has a concept of "SDK", which refers to the client library or +/// tool used to submit events to Sentry.io. +/// +/// This library contains Sentry.io SDK constants used by this package. +library version; + +/// The SDK version reported to Sentry.io in the submitted events. +const String sdkVersion = '2.1.1'; + +/// 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. +const String sdkPlatform = 'dart'; diff --git a/packages/sentry/pubspec.yaml b/packages/sentry/pubspec.yaml new file mode 100644 index 000000000000..6e133bb0ce88 --- /dev/null +++ b/packages/sentry/pubspec.yaml @@ -0,0 +1,20 @@ +name: sentry +version: 2.1.1 +description: A pure Dart Sentry.io client. +author: Flutter Authors +homepage: https://github.com/flutter/sentry + +environment: + sdk: ">=2.0.0-dev.28.0 <3.0.0" + +dependencies: + http: ">=0.11.0 <2.0.0" + meta: ">=1.0.0 <2.0.0" + stack_trace: ">=1.0.0 <2.0.0" + usage: ">=3.0.0 <4.0.0" + +dev_dependencies: + args: ">=0.13.0 <2.0.0" + test: ">=0.12.0 <2.0.0" + yaml: ">=2.1.0 <3.0.0" + mockito: ">=2.0.0 <4.0.0" diff --git a/packages/sentry/test/sentry_test.dart b/packages/sentry/test/sentry_test.dart new file mode 100644 index 000000000000..1a61518ed1f1 --- /dev/null +++ b/packages/sentry/test/sentry_test.dart @@ -0,0 +1,372 @@ +// 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'; + +void main() { + group('$SentryClient', () { + test('can parse DSN', () async { + final SentryClient client = new 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 = new 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('sends client auth header without secret', () async { + final MockClient httpMock = new MockClient(); + final ClockProvider fakeClockProvider = + () => new 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 new Response('{"id": "test-event-id"}', 200); + } + fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); + }); + + final SentryClient client = new 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 new 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 = new MockClient(); + final ClockProvider fakeClockProvider = + () => new 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 new Response('{"id": "test-event-id"}', 200); + } + fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); + }); + + final SentryClient client = new 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 new 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 isInstanceOf()); + 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 = new MockClient(); + final ClockProvider fakeClockProvider = + () => new 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: { + 'x-sentry-error': 'Invalid api key', + }); + } + fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); + }); + + final SentryClient client = new 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 new 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 = new MockClient(); + final ClockProvider fakeClockProvider = + () => new 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[new Symbol("body")]; + var decoded = new Utf8Codec().decode(bodyData); + var decodedJson = new JsonDecoder().convert(decoded); + loggedUserId = decodedJson['user']['id']; + return new Response('', 401, headers: { + 'x-sentry-error': 'Invalid api key', + }); + } + fail('Unexpected invocation of ${invocation.memberName} in HttpMock'); + }); + + final clientUserContext = new User( + id: "client_user", + username: "username", + email: "email@email.com", + ipAddress: "127.0.0.1"); + final eventUserContext = new User( + id: "event_user", + username: "username", + email: "email@email.com", + ipAddress: "127.0.0.1", + extras: {"foo": "bar"}); + + final SentryClient client = new 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 new ArgumentError('Test error'); + } catch (error, stackTrace) { + final eventWithoutContext = + new Event(exception: error, stackTrace: stackTrace); + final eventWithContext = new 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('serializes to JSON', () { + final user = new User( + id: "user_id", + username: "username", + email: "email@email.com", + ipAddress: "127.0.0.1", + extras: {"foo": "bar"}); + expect( + new Event( + message: 'test-message', + exception: new 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, + ).toJson(), + { + 'platform': 'dart', + 'sdk': {'version': sdkVersion, 'name': 'dart'}, + 'message': 'test-message', + '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'} + }, + }, + ); + }); + }); +} + +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/packages/sentry/test/stack_trace_test.dart b/packages/sentry/test/stack_trace_test.dart new file mode 100644 index 000000000000..3523f5c91c11 --- /dev/null +++ b/packages/sentry/test/stack_trace_test.dart @@ -0,0 +1,78 @@ +// 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/src/stack_trace.dart'; +import 'package:stack_trace/stack_trace.dart'; +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'); + expect(encodeStackTraceFrame(frame), { + 'abs_path': 'dart:core', + 'function': 'buzz', + 'lineno': 1, + 'in_app': false, + 'filename': 'core' + }); + }); + + test('cleanses absolute paths', () { + final Frame frame = + new Frame(Uri.parse('file://foo/bar/baz.dart'), 1, 2, 'buzz'); + expect(encodeStackTraceFrame(frame)['abs_path'], 'baz.dart'); + }); + }); + + group('encodeStackTrace', () { + test('encodes a simple stack trace', () { + expect(encodeStackTrace(''' +#0 baz (file:///pathto/test.dart:50:3) +#1 bar (file:///pathto/test.dart:46:9) + '''), [ + { + 'abs_path': 'test.dart', + 'function': 'bar', + 'lineno': 46, + 'in_app': true, + 'filename': 'test.dart' + }, + { + 'abs_path': 'test.dart', + 'function': 'baz', + 'lineno': 50, + 'in_app': true, + 'filename': 'test.dart' + }, + ]); + }); + + test('encodes an asynchronous stack trace', () { + expect(encodeStackTrace(''' +#0 baz (file:///pathto/test.dart:50:3) + +#1 bar (file:///pathto/test.dart:46:9) + '''), [ + { + 'abs_path': 'test.dart', + 'function': 'bar', + 'lineno': 46, + 'in_app': true, + 'filename': 'test.dart' + }, + { + 'abs_path': '', + }, + { + 'abs_path': 'test.dart', + 'function': 'baz', + 'lineno': 50, + 'in_app': true, + 'filename': 'test.dart' + }, + ]); + }); + }); +} diff --git a/packages/sentry/test/utils_test.dart b/packages/sentry/test/utils_test.dart new file mode 100644 index 000000000000..e86a57a2aae1 --- /dev/null +++ b/packages/sentry/test/utils_test.dart @@ -0,0 +1,68 @@ +// 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:test/test.dart'; + +import 'package:sentry/src/utils.dart'; + +void main() { + group('mergeAttributes', () { + test('merges attributes', () { + final Map target = { + 'overwritten': 1, + 'unchanged': 2, + 'recursed': { + 'overwritten_child': [1, 2, 3], + 'unchanged_child': 'qwerty', + }, + }; + + final Map attributes = { + 'overwritten': 2, + 'recursed': { + 'overwritten_child': [4, 5, 6], + }, + }; + + mergeAttributes(attributes, into: target); + expect(target, { + 'overwritten': 2, + 'unchanged': 2, + 'recursed': { + 'overwritten_child': [4, 5, 6], + 'unchanged_child': 'qwerty', + }, + }); + }); + + test('does not allow overriding original maps', () { + final environment = { + 'extra': { + 'device': 'Pixel 2', + }, + }; + + final event = { + 'extra': { + 'widget': 'Scaffold', + }, + }; + + final target = {}; + mergeAttributes(environment, into: target); + mergeAttributes(event, into: target); + expect(environment['extra'], {'device': 'Pixel 2'}); + }); + }); + + group('formatDateAsIso8601WithSecondPrecision', () { + test('strips sub-millisecond parts', () { + final DateTime testDate = + new 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/packages/sentry/test/version_test.dart b/packages/sentry/test/version_test.dart new file mode 100644 index 000000000000..4f32a66ea3cc --- /dev/null +++ b/packages/sentry/test/version_test.dart @@ -0,0 +1,19 @@ +// 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:io'; + +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart' as yaml; + +void main() { + group('sdkVersion', () { + test('matches that of pubspec.yaml', () { + final dynamic pubspec = + yaml.loadYaml(new File('pubspec.yaml').readAsStringSync()); + expect(sdkVersion, pubspec['version']); + }); + }); +} diff --git a/packages/sentry/tool/dart2_test.sh b/packages/sentry/tool/dart2_test.sh new file mode 100755 index 000000000000..d9f38d362c20 --- /dev/null +++ b/packages/sentry/tool/dart2_test.sh @@ -0,0 +1,7 @@ +#!/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/packages/sentry/tool/presubmit.sh b/packages/sentry/tool/presubmit.sh new file mode 100755 index 000000000000..a464495fd042 --- /dev/null +++ b/packages/sentry/tool/presubmit.sh @@ -0,0 +1,10 @@ +#!/bin/sh + +set -e +set -x + +pub get +dartanalyzer --strong --fatal-warnings ./ +pub run test --platform vm +./tool/dart2_test.sh +dartfmt -n --set-exit-if-changed ./