diff --git a/CHANGELOG.md b/CHANGELOG.md index 74df90e45e..441bb07705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ - Ref: Sentry init with null and empty DSN and close method #126 - Ref: Hint is passed across Sentry static class, Hub and Client #124 - Ref: Remove stackFrameFilter in favor of beforeSendCallback #125 +- Ref: added Transport #123 +- Feat: apply sample rate # `package:sentry` changelog diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index 471852847b..85a27fd53e 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -5,8 +5,8 @@ /// A pure Dart client for Sentry.io crash reporting. export 'src/client.dart'; export 'src/protocol.dart'; +export 'src/scope.dart'; export 'src/sentry.dart'; export 'src/sentry_options.dart'; +export 'src/transport/transport.dart'; export 'src/version.dart'; -export 'src/scope.dart'; -export 'src/sentry_options.dart'; diff --git a/dart/lib/src/browser_client.dart b/dart/lib/src/browser_client.dart index b34c862e9d..26a8240d50 100644 --- a/dart/lib/src/browser_client.dart +++ b/dart/lib/src/browser_client.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. /// A pure Dart client for Sentry.io crash reporting. -import 'dart:convert'; import 'dart:html' show window; import 'package:http/browser_client.dart'; @@ -28,32 +27,15 @@ class SentryBrowserClient extends SentryClient { /// /// If [httpClient] is provided, it is used instead of the default client to /// make HTTP calls to Sentry.io. This is useful in tests. - factory SentryBrowserClient(SentryOptions options, {String origin}) { + factory SentryBrowserClient(SentryOptions options) { options.httpClient ??= BrowserClient(); - // origin is necessary for sentry to resolve stacktrace - origin ??= '${window.location.origin}/'; + options.sdk ??= Sdk(name: sdkName, version: sdkVersion); - return SentryBrowserClient._( - options, - origin: origin, - platform: browserPlatform, - ); + // origin is necessary for sentry to resolve stacktrace + return SentryBrowserClient._(options); } - SentryBrowserClient._(SentryOptions options, {String origin, String platform}) - : super.base( - options, - origin: origin, - sdk: Sdk(name: browserSdkName, version: sdkVersion), - platform: platform, - ); - - @override - List bodyEncoder( - Map data, - Map headers, - ) => - // Gzip compression is implicit on browser - utf8.encode(json.encode(data)); + SentryBrowserClient._(SentryOptions options) + : super.base(options, origin: '${window.location.origin}/'); } diff --git a/dart/lib/src/client.dart b/dart/lib/src/client.dart index 9318898207..9f100fda14 100644 --- a/dart/lib/src/client.dart +++ b/dart/lib/src/client.dart @@ -1,15 +1,14 @@ import 'dart:async'; -import 'dart:convert'; +import 'dart:math'; import 'package:meta/meta.dart'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/transport/noop_transport.dart'; import 'client_stub.dart' if (dart.library.html) 'browser_client.dart' if (dart.library.io) 'io_client.dart'; import 'protocol.dart'; -import 'utils.dart'; -import 'version.dart'; /// Logs crash reports and events to the Sentry.io service. abstract class SentryClient { @@ -19,133 +18,41 @@ abstract class SentryClient { /// `dart:html` is available, otherwise it will throw an unsupported error. factory SentryClient(SentryOptions options) => createSentryClient(options); - SentryClient.base( - this.options, { - String platform, - this.origin, - Sdk sdk, - }) : _dsn = Dsn.parse(options.dsn), - _platform = platform ?? sdkPlatform, - sdk = sdk ?? Sdk(name: sdkName, version: sdkVersion); - - final Dsn _dsn; - - @protected - SentryOptions options; - - /// The DSN URI. - @visibleForTesting - Uri get dsnUri => _dsn.uri; - - /// The Sentry.io public key for the project. - @visibleForTesting - // ignore: invalid_use_of_visible_for_testing_member - String get publicKey => _dsn.publicKey; - - /// The Sentry.io secret key for the project. - @visibleForTesting - // ignore: invalid_use_of_visible_for_testing_member - 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 - String origin; - - /// Used by sentry to differentiate browser from io environment - final String _platform; - - final Sdk sdk; - - String get clientId => sdk.identifier; - - @visibleForTesting - String get postUri { - final port = dsnUri.hasPort && - ((dsnUri.scheme == 'http' && dsnUri.port != 80) || - (dsnUri.scheme == 'https' && dsnUri.port != 443)) - ? ':${dsnUri.port}' - : ''; - final 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'; + SentryClient.base(this._options, {String origin}) { + _random = _options.sampleRate == null ? null : Random(); + if (_options.transport is NoOpTransport) { + _options.transport = Transport(options: _options, origin: origin); } - return '${dsnUri.scheme}://${dsnUri.host}$port/$apiPath/$projectId/store/'; } + SentryOptions _options; + + Random _random; + /// Reports an [event] to Sentry.io. Future captureEvent( SentryEvent event, { Scope scope, dynamic hint, }) async { - final now = options.clock(); - var authHeader = 'Sentry sentry_version=6, sentry_client=$clientId, ' - 'sentry_timestamp=${now.millisecondsSinceEpoch}, sentry_key=$publicKey'; - if (secretKey != null) { - authHeader += ', sentry_secret=$secretKey'; - } - - final headers = buildHeaders(authHeader); - - final data = { - 'project': projectId, - 'event_id': event.eventId.toString(), - 'timestamp': formatDateAsIso8601WithSecondPrecision(event.timestamp), - }; - - if (options.environmentAttributes != null) { - mergeAttributes(options.environmentAttributes.toJson(), into: data); - } + event = _processEvent(event, eventProcessors: _options.eventProcessors); - // Merge the user context. - if (userContext != null) { - mergeAttributes({'user': userContext.toJson()}, - into: data); + // dropped by sampling or event processors + if (event == null) { + return Future.value(SentryId.empty()); } - mergeAttributes( - event.toJson( - origin: origin, - ), - into: data, - ); - mergeAttributes({'platform': _platform}, into: data); - - final body = bodyEncoder(data, headers); + event = _applyScope(event: event, scope: scope); - final response = await options.httpClient.post( - postUri, - headers: headers, - body: body, + // TODO create eventProcessors ? + event = event.copyWith( + serverName: _options.serverName, + environment: _options.environment, + release: _options.release, + platform: event.platform ?? sdkPlatform, ); - if (response.statusCode != 200) { - return SentryId.empty(); - } - - final eventId = json.decode(response.body)['id']; - return eventId != null ? SentryId.fromId(eventId) : SentryId.empty(); + return _options.transport.send(event); } /// Reports the [throwable] and optionally its [stackTrace] to Sentry.io. @@ -158,7 +65,7 @@ abstract class SentryClient { final event = SentryEvent( exception: throwable, stackTrace: stackTrace, - timestamp: options.clock(), + timestamp: _options.clock(), ); return captureEvent(event, scope: scope, hint: hint); } @@ -173,63 +80,94 @@ abstract class SentryClient { dynamic hint, }) { final event = SentryEvent( - message: Message( - formatted, - template: template, - params: params, - ), + message: Message(formatted, template: template, params: params), level: level, - timestamp: options.clock(), + timestamp: _options.clock(), ); - return captureEvent(event, scope: scope, hint: hint); - } - void close() { - options.httpClient?.close(); + return captureEvent(event, scope: scope, hint: hint); } - @override - String toString() => '$SentryClient("$postUri")'; + void close() => _options.httpClient?.close(); - @protected - List bodyEncoder(Map data, Map headers); - - @protected - @mustCallSuper - Map buildHeaders(String authHeader) { - final headers = { - 'Content-Type': 'application/json', - }; + SentryEvent _processEvent( + SentryEvent event, { + dynamic hint, + List eventProcessors, + }) { + if (_sampleRate()) { + _options.logger( + SentryLevel.debug, + 'Event ${event.eventId.toString()} was dropped due to sampling decision.', + ); + return null; + } - if (authHeader != null) { - headers['X-Sentry-Auth'] = authHeader; + for (final processor in eventProcessors) { + try { + event = processor(event, hint); + } catch (err) { + _options.logger( + SentryLevel.error, + 'An exception occurred while processing event by a processor : $err', + ); + } + if (event == null) { + _options.logger(SentryLevel.debug, 'Event was dropped by a processor'); + break; + } } + return event; + } - return headers; + SentryEvent _applyScope({ + @required SentryEvent event, + @required Scope scope, + }) { + if (scope != null) { + // Merge the scope transaction. + if (event.transaction == null) { + event = event.copyWith(transaction: scope.transaction); + } + + // Merge the user context. + if (event.userContext == null) { + event = event.copyWith(userContext: scope.user); + } + + // Merge the scope fingerprint. + if (event.fingerprint == null) { + event = event.copyWith(fingerprint: scope.fingerprint); + } + + // Merge the scope breadcrumbs. + if (event.breadcrumbs == null) { + event = event.copyWith(breadcrumbs: scope.breadcrumbs); + } + + // TODO add tests + // Merge the scope tags. + event = event.copyWith( + tags: scope.tags.map((key, value) => MapEntry(key, value)) + ..addAll(event.tags ?? {})); + + // Merge the scope extra. + event = event.copyWith( + extra: scope.extra.map((key, value) => MapEntry(key, value)) + ..addAll(event.extra ?? {})); + + // Merge the scope level. + if (scope.level != null) { + event = event.copyWith(level: scope.level); + } + } + return event; } -} -/// 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; + bool _sampleRate() { + if (_options.sampleRate != null && _random != null) { + return (_options.sampleRate < _random.nextDouble()); + } + return false; + } } diff --git a/dart/lib/src/io_client.dart b/dart/lib/src/io_client.dart index a7f5d59297..8511553c06 100644 --- a/dart/lib/src/io_client.dart +++ b/dart/lib/src/io_client.dart @@ -2,60 +2,22 @@ // 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:sentry/sentry.dart'; +/// A pure Dart client for Sentry.io crash reporting. import 'client.dart'; +import 'sentry_options.dart'; SentryClient createSentryClient(SentryOptions options) => SentryIOClient(options); /// Logs crash reports and events to the Sentry.io service. class SentryIOClient 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.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. - factory SentryIOClient(SentryOptions options) => SentryIOClient._(options); - - SentryIOClient._(SentryOptions options) : super.base(options); - - @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'] = clientId; - - return headers; + /// Instantiates a client using [SentryOptions] + factory SentryIOClient(SentryOptions options) { + options.sdk ??= Sdk(name: sdkName, version: sdkVersion); + return SentryIOClient._(options); } - @override - List bodyEncoder( - Map data, - Map headers, - ) { - // [SentryIOClient] implement gzip compression - // gzip compression is not available on browser - var body = utf8.encode(json.encode(data)); - if (options.compressPayload) { - headers['Content-Encoding'] = 'gzip'; - body = gzip.encode(body); - } - return body; - } + SentryIOClient._(SentryOptions options) : super.base(options); } diff --git a/dart/lib/src/noop_client.dart b/dart/lib/src/noop_client.dart index 223ee17bc4..412495fec0 100644 --- a/dart/lib/src/noop_client.dart +++ b/dart/lib/src/noop_client.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'client.dart'; import 'protocol.dart'; import 'scope.dart'; -import 'sentry_options.dart'; class NoOpSentryClient implements SentryClient { NoOpSentryClient._(); @@ -14,25 +13,6 @@ class NoOpSentryClient implements SentryClient { return _instance; } - @override - User userContext; - - @override - SentryOptions options; - - @override - String origin; - - @override - List bodyEncoder( - Map data, - Map headers, - ) => - []; - - @override - Map buildHeaders(String authHeader) => {}; - @override Future captureEvent( SentryEvent event, { @@ -43,7 +23,7 @@ class NoOpSentryClient implements SentryClient { @override Future captureException( - throwable, { + dynamic throwable, { dynamic stackTrace, Scope scope, dynamic hint, @@ -61,29 +41,8 @@ class NoOpSentryClient implements SentryClient { }) => Future.value(SentryId.empty()); - @override - String get clientId => 'No-op'; - @override Future close() async { return; } - - @override - Uri get dsnUri => null; - - @override - String get postUri => null; - - @override - String get projectId => null; - - @override - String get publicKey => null; - - @override - Sdk get sdk => null; - - @override - String get secretKey => null; } diff --git a/dart/lib/src/protocol/dsn.dart b/dart/lib/src/protocol/dsn.dart index 35dc93c6dd..23c5b3edf2 100644 --- a/dart/lib/src/protocol/dsn.dart +++ b/dart/lib/src/protocol/dsn.dart @@ -9,11 +9,9 @@ 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. @@ -24,6 +22,26 @@ class Dsn { /// The DSN URI. final Uri uri; + String get postUri { + final port = uri.hasPort && + ((uri.scheme == 'http' && uri.port != 80) || + (uri.scheme == 'https' && uri.port != 443)) + ? ':${uri.port}' + : ''; + + final pathLength = uri.pathSegments.length; + + String apiPath; + if (pathLength > 1) { + // some paths would present before the projectID in the uri + apiPath = + (uri.pathSegments.sublist(0, pathLength - 1) + ['api']).join('/'); + } else { + apiPath = 'api'; + } + return '${uri.scheme}://${uri.host}$port/$apiPath/$projectId/store/'; + } + static Dsn parse(String dsn) { final uri = Uri.parse(dsn); final userInfo = uri.userInfo.split(':'); diff --git a/dart/lib/src/protocol/sentry_event.dart b/dart/lib/src/protocol/sentry_event.dart index 25452363b1..e721a485fc 100644 --- a/dart/lib/src/protocol/sentry_event.dart +++ b/dart/lib/src/protocol/sentry_event.dart @@ -12,7 +12,8 @@ class SentryEvent { SentryEvent({ SentryId eventId, DateTime timestamp, - String platform, + Sdk sdk, + this.platform, this.logger, this.serverName, this.release, @@ -31,10 +32,9 @@ class SentryEvent { this.userContext, this.contexts, this.breadcrumbs, - this.sdk, }) : eventId = eventId ?? SentryId.newId(), - platform = platform ?? sdkPlatform, - timestamp = timestamp ?? getUtcDateTime(); + timestamp = timestamp ?? getUtcDateTime(), + sdk = sdk ?? Sdk(name: sdkName, version: sdkVersion); /// Refers to the default fingerprinting algorithm. /// diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 58a8ccb725..8adadbe9d7 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -1,5 +1,7 @@ import 'package:http/http.dart'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/transport/noop_transport.dart'; + import 'diagnostic_logger.dart'; import 'hub.dart'; import 'protocol.dart'; @@ -14,15 +16,6 @@ class SentryOptions { /// just not send any events. String dsn; - /// Contains [Event] attributes that are automatically mixed into all events - /// captured through this client. - /// - /// This event is designed to contain static values that do not change from - /// event to event, such as local operating system version, the version of - /// Dart/Flutter SDK, etc. These attributes have lower precedence than those - /// supplied in the even passed to [capture]. - SentryEvent environmentAttributes; - /// If [compressPayload] is `true` the outgoing HTTP payloads are compressed /// using gzip. Otherwise, the payloads are sent in plain UTF8-encoded JSON /// text. If not specified, the compression is enabled by default. @@ -102,8 +95,8 @@ class SentryOptions { /// Configures the sample rate as a percentage of events to be sent in the range of 0.0 to 1.0. if /// 1.0 is set it means that 100% of events are sent. If set to 0.1 only 10% of events will be - /// sent. Events are picked randomly. Default is 1.0 (disabled) - double sampleRate = 1.0; + /// sent. Events are picked randomly. Default is null (disabled) + double sampleRate; /// A list of string prefixes of module names that do not belong to the app, but rather third-party /// packages. Modules considered not to be part of the app will be hidden from stack traces by @@ -118,7 +111,14 @@ class SentryOptions { List get inAppIncludes => List.unmodifiable(_inAppIncludes); - // TODO: transport, transportGate, connectionTimeoutMillis, readTimeoutMillis, hostnameVerifier, sslSocketFactory, proxy + Transport _transport = NoOpTransport(); + + Transport get transport => _transport; + + set transport(Transport transport) => + _transport = transport ?? NoOpTransport(); + + // TODO: transportGate, connectionTimeoutMillis, readTimeoutMillis, hostnameVerifier, sslSocketFactory, proxy /// Sets the distribution. Think about it together with release and environment String dist; @@ -126,8 +126,8 @@ class SentryOptions { /// The server name used in the Sentry messages. String serverName; - /// SdkVersion object that contains the Sentry Client Name and its version - Sdk sdkVersion; + /// Sdk object that contains the Sentry Client Name and its version + Sdk sdk; // TODO: Scope observers, enableScopeSync @@ -136,7 +136,6 @@ class SentryOptions { // TODO: those ctor params could be set on Sentry._setDefaultConfiguration or instantiate by default here SentryOptions({ this.dsn, - this.environmentAttributes, this.compressPayload, this.httpClient, ClockProvider clock = getUtcDateTime, diff --git a/dart/lib/src/transport/body_encoder.dart b/dart/lib/src/transport/body_encoder.dart new file mode 100644 index 0000000000..eb46bfc16d --- /dev/null +++ b/dart/lib/src/transport/body_encoder.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; +import 'dart:io'; + +List bodyEncoder( + Map data, + Map headers, { + bool compressPayload, +}) { + // [SentryIOClient] implement gzip compression + // gzip compression is not available on browser + var body = utf8.encode(json.encode(data)); + if (compressPayload) { + headers['Content-Encoding'] = 'gzip'; + body = gzip.encode(body); + } + return body; +} diff --git a/dart/lib/src/transport/body_encoder_browser.dart b/dart/lib/src/transport/body_encoder_browser.dart new file mode 100644 index 0000000000..5e587c8bf6 --- /dev/null +++ b/dart/lib/src/transport/body_encoder_browser.dart @@ -0,0 +1,12 @@ +import 'dart:convert'; + +List bodyEncoder( + Map data, + Map headers, { + bool compressPayload, +}) { + // [SentryIOClient] implement gzip compression + // gzip compression is not available on browser + var body = utf8.encode(json.encode(data)); + return body; +} diff --git a/dart/lib/src/transport/noop_transport.dart b/dart/lib/src/transport/noop_transport.dart new file mode 100644 index 0000000000..1488b89258 --- /dev/null +++ b/dart/lib/src/transport/noop_transport.dart @@ -0,0 +1,11 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; + +class NoOpTransport implements Transport { + @override + Dsn get dsn => null; + + @override + Future send(SentryEvent event) => Future.value(SentryId.empty()); +} diff --git a/dart/lib/src/transport/transport.dart b/dart/lib/src/transport/transport.dart new file mode 100644 index 0000000000..3fcb51f62b --- /dev/null +++ b/dart/lib/src/transport/transport.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:meta/meta.dart'; +import 'package:sentry/src/utils.dart'; + +import '../protocol.dart'; +import '../sentry_options.dart'; +import 'body_encoder_browser.dart' if (dart.library.io) 'body_encoder.dart'; + +typedef BodyEncoder = List Function( + Map data, + Map headers, { + bool compressPayload, +}); + +/// A transport is in charge of sending the event to the Sentry server. +class Transport { + final SentryOptions _options; + + @visibleForTesting + final Dsn dsn; + + /// Use for browser stacktrace + final String _origin; + + CredentialBuilder _credentialBuilder; + + final Map _headers; + + Transport({@required SentryOptions options, String origin}) + : _options = options, + _origin = origin, + dsn = Dsn.parse(options.dsn), + _headers = _buildHeaders(sdkIdentifier: options.sdk.identifier) { + _credentialBuilder = CredentialBuilder( + dsn: Dsn.parse(options.dsn), + clientId: options.sdk.identifier, + clock: options.clock, + ); + } + + Future send(SentryEvent event) async { + final data = event.toJson(origin: _origin); + + final body = bodyEncoder( + data, + _headers, + compressPayload: _options.compressPayload, + ); + + final response = await _options.httpClient.post( + dsn.postUri, + headers: _credentialBuilder.configure(_headers), + body: body, + ); + + if (response.statusCode != 200) { + return SentryId.empty(); + } + + final eventId = json.decode(response.body)['id']; + return eventId != null ? SentryId.fromId(eventId) : SentryId.empty(); + } +} + +class CredentialBuilder { + final String _authHeader; + + final ClockProvider clock; + + int get timestamp => clock().millisecondsSinceEpoch; + + CredentialBuilder({@required Dsn dsn, String clientId, @required this.clock}) + : _authHeader = buildAuthHeader( + publicKey: dsn.publicKey, + secretKey: dsn.secretKey, + clientId: clientId, + ); + + static String buildAuthHeader({ + String publicKey, + String secretKey, + String clientId, + }) { + var header = 'Sentry sentry_version=6, sentry_client=$clientId, ' + 'sentry_key=${publicKey}'; + + if (secretKey != null) { + header += ', sentry_secret=${secretKey}'; + } + + return header; + } + + Map configure(Map headers) { + return headers + ..addAll( + { + 'X-Sentry-Auth': '$_authHeader, sentry_timestamp=${timestamp}' + }, + ); + } +} + +Map _buildHeaders({String sdkIdentifier}) { + final headers = {'Content-Type': 'application/json'}; + // NOTE(lejard_h) overriding user agent on VM and Flutter not sure why + // for web it use browser user agent + if (!isWeb) { + headers['User-Agent'] = sdkIdentifier; + } + return headers; +} diff --git a/dart/lib/src/utils.dart b/dart/lib/src/utils.dart index d8ce547bbe..9cc4952dda 100644 --- a/dart/lib/src/utils.dart +++ b/dart/lib/src/utils.dart @@ -39,3 +39,6 @@ String formatDateAsIso8601WithSecondPrecision(DateTime date) { } return iso; } + +/// helper to detect a browser context +const isWeb = identical(1.0, 1); diff --git a/dart/lib/src/version.dart b/dart/lib/src/version.dart index a495d59cb1..820cd11bdf 100644 --- a/dart/lib/src/version.dart +++ b/dart/lib/src/version.dart @@ -8,19 +8,28 @@ /// This library contains Sentry.io SDK constants used by this package. library version; +import 'utils.dart'; + /// The SDK version reported to Sentry.io in the submitted events. const String sdkVersion = '4.0.0'; +String get sdkName => isWeb ? _browserSdkName : _ioSdkName; + /// The default SDK name reported to Sentry.io in the submitted events. -const String sdkName = 'sentry.dart'; +const String _ioSdkName = 'sentry.dart'; /// The SDK name for web projects reported to Sentry.io in the submitted events. -const String browserSdkName = 'sentry.dart.browser'; +const String _browserSdkName = 'sentry.dart.browser'; + +/// The name of the SDK platform reported to Sentry.io in the submitted events. +/// +/// Used for IO version. +String get sdkPlatform => isWeb ? _browserPlatform : _ioSdkPlatform; /// The name of the SDK platform reported to Sentry.io in the submitted events. /// /// Used for IO version. -const String sdkPlatform = 'dart'; +const String _ioSdkPlatform = 'dart'; /// Used to report browser Stacktrace to sentry. -const String browserPlatform = 'javascript'; +const String _browserPlatform = 'javascript'; diff --git a/dart/test/event_test.dart b/dart/test/event_test.dart index 81abef84c8..9d4d76c804 100644 --- a/dart/test/event_test.dart +++ b/dart/test/event_test.dart @@ -4,6 +4,7 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/stack_trace.dart'; +import 'package:sentry/src/utils.dart'; import 'package:test/test.dart'; void main() { @@ -26,15 +27,20 @@ void main() { }); test('$Sdk serializes', () { final event = SentryEvent( - eventId: SentryId.empty(), - timestamp: DateTime.utc(2019), - sdk: Sdk( - name: 'sentry.dart.flutter', - version: '4.3.2', - integrations: ['integration'], - packages: [Package('npm:@sentry/javascript', '1.3.4')])); + eventId: SentryId.empty(), + timestamp: DateTime.utc(2019), + platform: sdkPlatform, + sdk: Sdk( + name: 'sentry.dart.flutter', + version: '4.3.2', + integrations: ['integration'], + packages: [ + Package('npm:@sentry/javascript', '1.3.4'), + ], + ), + ); expect(event.toJson(), { - 'platform': 'dart', + 'platform': isWeb ? 'javascript' : 'dart', 'event_id': '00000000000000000000000000000000', 'timestamp': '2019-01-01T00:00:00', 'sdk': { @@ -67,6 +73,7 @@ void main() { SentryEvent( eventId: SentryId.empty(), timestamp: timestamp, + platform: sdkPlatform, message: Message( 'test-message 1 2', template: 'test-message %d %d', @@ -89,10 +96,10 @@ void main() { breadcrumbs: breadcrumbs, ).toJson(), { - 'platform': 'dart', + 'platform': isWeb ? 'javascript' : 'dart', 'event_id': '00000000000000000000000000000000', 'timestamp': '2019-01-01T00:00:00', - 'sdk': {'version': sdkVersion, 'name': 'sentry.dart'}, + 'sdk': {'version': sdkVersion, 'name': sdkName}, 'message': { 'formatted': 'test-message 1 2', 'message': 'test-message %d %d', diff --git a/dart/test/mocks.dart b/dart/test/mocks.dart index e50a8f0abf..57ca1da64d 100644 --- a/dart/test/mocks.dart +++ b/dart/test/mocks.dart @@ -4,6 +4,8 @@ import 'package:sentry/src/protocol.dart'; class MockSentryClient extends Mock implements SentryClient {} +class MockTransport extends Mock implements Transport {} + final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; final fakeException = Exception('Error'); diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart new file mode 100644 index 0000000000..80a7843855 --- /dev/null +++ b/dart/test/sentry_client_test.dart @@ -0,0 +1,44 @@ +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; + +void main() { + group('SentryClient sampling', () { + SentryOptions options; + Transport transport; + + setUp(() { + options = SentryOptions(dsn: fakeDsn); + transport = MockTransport(); + }); + + test('captures event, sample rate is 100% enabled', () { + options.sampleRate = 1.0; + final client = SentryClient(options); + options.transport = transport; + client.captureEvent(fakeEvent); + + verify(transport.send(any)).called(1); + }); + + test('do not capture event, sample rate is 0% disabled', () { + options.sampleRate = 0.0; + final client = SentryClient(options); + options.transport = transport; + client.captureEvent(fakeEvent); + + verifyNever(transport.send(any)); + }); + + test('captures event, sample rate is null, disabled', () { + options.sampleRate = null; + final client = SentryClient(options); + options.transport = transport; + client.captureEvent(fakeEvent); + + verify(transport.send(any)).called(1); + }); + }); +} diff --git a/dart/test/test_utils.dart b/dart/test/test_utils.dart index 1ffd29a6cc..f438ca1a8e 100644 --- a/dart/test/test_utils.dart +++ b/dart/test/test_utils.dart @@ -29,15 +29,16 @@ void testHeaders( 'Content-Type': 'application/json', 'X-Sentry-Auth': 'Sentry sentry_version=6, ' 'sentry_client=$sdkName/$sdkVersion, ' - 'sentry_timestamp=${fakeClockProvider().millisecondsSinceEpoch}, ' - 'sentry_key=public' + 'sentry_key=public, ' }; if (withSecret) { - expectedHeaders['X-Sentry-Auth'] += ', ' - 'sentry_secret=secret'; + expectedHeaders['X-Sentry-Auth'] += 'sentry_secret=secret, '; } + expectedHeaders['X-Sentry-Auth'] += + 'sentry_timestamp=${fakeClockProvider().millisecondsSinceEpoch}'; + if (withUserAgent) { expectedHeaders['User-Agent'] = '$sdkName/$sdkVersion'; } @@ -69,20 +70,18 @@ Future testCaptureException( fail('Unexpected request on ${request.method} ${request.url} in HttpMock'); }); + final options = SentryOptions( + dsn: testDsn, + httpClient: httpMock, + clock: fakeClockProvider, + compressPayload: compressPayload, + ) + ..serverName = 'test.server.com' + ..release = '1.2.3' + ..environment = 'staging'; + var sentryId = SentryId.empty(); - final client = SentryClient( - SentryOptions( - dsn: testDsn, - httpClient: httpMock, - clock: fakeClockProvider, - compressPayload: compressPayload, - environmentAttributes: SentryEvent( - serverName: 'test.server.com', - release: '1.2.3', - environment: 'staging', - ), - ), - ); + final client = SentryClient(options); try { throw ArgumentError('Test error'); @@ -91,14 +90,14 @@ Future testCaptureException( expect('$sentryId', 'testeventid'); } - expect(postUri, client.postUri); + expect(postUri, options.transport.dsn.postUri); testHeaders( headers, fakeClockProvider, compressPayload: compressPayload, withUserAgent: !isWeb, - sdkName: isWeb ? browserSdkName : sdkName, + sdkName: sdkName, ); Map data; @@ -141,11 +140,10 @@ Future testCaptureException( expect(topFrame['function'], 'Object.wrapException'); expect(data, { - 'project': '1', 'event_id': sentryId.toString(), 'timestamp': '2017-01-02T00:00:00', 'platform': 'javascript', - 'sdk': {'version': sdkVersion, 'name': 'sentry.dart'}, + 'sdk': {'version': sdkVersion, 'name': sdkName}, 'server_name': 'test.server.com', 'release': '1.2.3', 'environment': 'staging', @@ -159,7 +157,6 @@ Future testCaptureException( expect(topFrame['function'], 'testCaptureException'); expect(data, { - 'project': '1', 'event_id': sentryId.toString(), 'timestamp': '2017-01-02T00:00:00', 'platform': 'dart', @@ -181,41 +178,53 @@ Future testCaptureException( void runTest({Codec, List> gzip, bool isWeb = false}) { test('can parse DSN', () async { - final client = SentryClient(SentryOptions(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'); + final options = SentryOptions(dsn: testDsn); + final client = SentryClient(options); + expect(options.transport.dsn.uri, Uri.parse(testDsn)); + expect(options.transport.dsn.postUri, + 'https://sentry.example.com/api/1/store/'); + expect(options.transport.dsn.publicKey, 'public'); + expect(options.transport.dsn.secretKey, 'secret'); + expect(options.transport.dsn.projectId, '1'); await client.close(); }); test('can parse DSN without secret', () async { - final client = SentryClient(SentryOptions(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'); + final options = SentryOptions(dsn: _testDsnWithoutSecret); + final client = SentryClient(options); + expect(options.transport.dsn.uri, Uri.parse(_testDsnWithoutSecret)); + expect(options.transport.dsn.postUri, + 'https://sentry.example.com/api/1/store/'); + expect(options.transport.dsn.publicKey, 'public'); + expect(options.transport.dsn.secretKey, null); + expect(options.transport.dsn.projectId, '1'); await client.close(); }); test('can parse DSN with path', () async { - final client = SentryClient(SentryOptions(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'); + final options = SentryOptions(dsn: _testDsnWithPath); + final client = SentryClient(options); + expect(options.transport.dsn.uri, Uri.parse(_testDsnWithPath)); + expect( + options.transport.dsn.postUri, + 'https://sentry.example.com/path/api/1/store/', + ); + expect(options.transport.dsn.publicKey, 'public'); + expect(options.transport.dsn.secretKey, 'secret'); + expect(options.transport.dsn.projectId, '1'); await client.close(); }); test('can parse DSN with port', () async { - final client = SentryClient(SentryOptions(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'); + final options = SentryOptions(dsn: _testDsnWithPort); + final client = SentryClient(options); + expect(options.transport.dsn.uri, Uri.parse(_testDsnWithPort)); + expect( + options.transport.dsn.postUri, + 'https://sentry.example.com:8888/api/1/store/', + ); + expect(options.transport.dsn.publicKey, 'public'); + expect(options.transport.dsn.secretKey, 'secret'); + expect(options.transport.dsn.projectId, '1'); await client.close(); }); test('sends client auth header without secret', () async { @@ -238,12 +247,10 @@ void runTest({Codec, List> gzip, bool isWeb = false}) { httpClient: httpMock, clock: fakeClockProvider, compressPayload: false, - environmentAttributes: SentryEvent( - serverName: 'test.server.com', - release: '1.2.3', - environment: 'staging', - ), - ), + ) + ..serverName = 'test.server.com' + ..release = '1.2.3' + ..environment = 'staging', ); try { @@ -260,7 +267,7 @@ void runTest({Codec, List> gzip, bool isWeb = false}) { withUserAgent: !isWeb, compressPayload: false, withSecret: false, - sdkName: isWeb ? browserSdkName : sdkName, + sdkName: sdkName, ); await client.close(); @@ -295,12 +302,10 @@ void runTest({Codec, List> gzip, bool isWeb = false}) { httpClient: httpMock, clock: fakeClockProvider, compressPayload: false, - environmentAttributes: SentryEvent( - serverName: 'test.server.com', - release: '1.2.3', - environment: 'staging', - ), - ), + ) + ..serverName = 'test.server.com' + ..release = '1.2.3' + ..environment = 'staging', ); try { @@ -333,31 +338,30 @@ void runTest({Codec, List> gzip, bool isWeb = false}) { }); const clientUserContext = User( - id: 'client_user', - username: 'username', - email: 'email@email.com', - ipAddress: '127.0.0.1'); + id: 'client_user', + username: 'username', + email: 'email@email.com', + ipAddress: '127.0.0.1', + ); const eventUserContext = User( - id: 'event_user', - username: 'username', - email: 'email@email.com', - ipAddress: '127.0.0.1', - extras: {'foo': 'bar'}); - - final client = SentryClient( - SentryOptions( - dsn: testDsn, - httpClient: httpMock, - clock: fakeClockProvider, - compressPayload: false, - environmentAttributes: SentryEvent( - serverName: 'test.server.com', - release: '1.2.3', - environment: 'staging', - ), - ), + id: 'event_user', + username: 'username', + email: 'email@email.com', + ipAddress: '127.0.0.1', + extras: {'foo': 'bar'}, ); - client.userContext = clientUserContext; + + final options = SentryOptions( + dsn: testDsn, + httpClient: httpMock, + clock: fakeClockProvider, + compressPayload: false, + ) + ..serverName = 'test.server.com' + ..release = '1.2.3' + ..environment = 'staging'; + + final client = SentryClient(options); try { throw ArgumentError('Test error'); @@ -373,9 +377,13 @@ void runTest({Codec, List> gzip, bool isWeb = false}) { stackTrace: stackTrace, userContext: eventUserContext, ); - await client.captureEvent(eventWithoutContext); + await client.captureEvent(eventWithoutContext, + scope: Scope(options)..user = clientUserContext); expect(loggedUserId, clientUserContext.id); - await client.captureEvent(eventWithContext); + await client.captureEvent( + eventWithContext, + scope: Scope(options)..user = clientUserContext, + ); expect(loggedUserId, eventUserContext.id); } diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index 4b12dd8ca7..7c9dc8f024 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -40,7 +40,8 @@ Future main() async { stackTrace: stackTrace, // release is required on Web to match the source maps release: _release, - sdk: _sentry.sdk, + + // sdk: const Sdk(name: sdkName, version: sdkVersion), ); await _sentry.captureEvent(event); });