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 ./