diff --git a/CHANGELOG.md b/CHANGELOG.md index 338f92f7c8..ae7b2dbc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog -## Unreleased: +## Unreleased +* Feat: Client Reports (#829) * Fix: Add missing iOS contexts (#761) +Starting with version `6.6.0` of `sentry`, [Sentry's version >= v21.9.0](https://github.com/getsentry/self-hosted/releases) is required or you have to manually disable sending client reports via the `sendClientReports` option. This only applies to self-hosted Sentry. If you are using [sentry.io](https://sentry.io), no action is needed. + ## 6.5.1 - Update event contexts (#838) diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index 6a5435d009..77ac7a482f 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -14,6 +14,7 @@ export 'src/protocol.dart'; export 'src/scope.dart'; export 'src/sentry.dart'; export 'src/sentry_envelope.dart'; +export 'src/sentry_envelope_item.dart'; export 'src/sentry_client.dart'; export 'src/sentry_options.dart'; // useful for integrations diff --git a/dart/lib/src/client_reports/client_report.dart b/dart/lib/src/client_reports/client_report.dart new file mode 100644 index 0000000000..38a1a34c67 --- /dev/null +++ b/dart/lib/src/client_reports/client_report.dart @@ -0,0 +1,30 @@ +import 'package:meta/meta.dart'; + +import 'discarded_event.dart'; +import '../utils.dart'; + +@internal +class ClientReport { + ClientReport(this.timestamp, this.discardedEvents); + + final DateTime? timestamp; + final List discardedEvents; + + Map toJson() { + final json = {}; + + if (timestamp != null) { + json['timestamp'] = formatDateAsIso8601WithMillisPrecision(timestamp!); + } + + final eventsJson = discardedEvents + .map((e) => e.toJson()) + .where((e) => e.isNotEmpty) + .toList(growable: false); + if (eventsJson.isNotEmpty) { + json['discarded_events'] = eventsJson; + } + + return json; + } +} diff --git a/dart/lib/src/client_reports/client_report_recorder.dart b/dart/lib/src/client_reports/client_report_recorder.dart new file mode 100644 index 0000000000..7122e17c2a --- /dev/null +++ b/dart/lib/src/client_reports/client_report_recorder.dart @@ -0,0 +1,54 @@ +import 'package:meta/meta.dart'; + +import '../sentry_options.dart'; +import 'client_report.dart'; +import 'discarded_event.dart'; +import 'discard_reason.dart'; +import '../transport/data_category.dart'; + +@internal +class ClientReportRecorder { + ClientReportRecorder(this._clock); + + final ClockProvider _clock; + final Map<_QuantityKey, int> _quantities = {}; + + void recordLostEvent( + final DiscardReason reason, final DataCategory category) { + final key = _QuantityKey(reason, category); + var current = _quantities[key] ?? 0; + _quantities[key] = current + 1; + } + + ClientReport? flush() { + if (_quantities.isEmpty) { + return null; + } + + final events = _quantities.keys.map((key) { + final quantity = _quantities[key] ?? 0; + return DiscardedEvent(key.reason, key.category, quantity); + }).toList(growable: false); + + _quantities.clear(); + + return ClientReport(_clock(), events); + } +} + +class _QuantityKey { + _QuantityKey(this.reason, this.category); + + final DiscardReason reason; + final DataCategory category; + + @override + int get hashCode => Object.hash(reason, category); + + @override + bool operator ==(dynamic other) { + return other is _QuantityKey && + other.reason == reason && + other.category == category; + } +} diff --git a/dart/lib/src/client_reports/discard_reason.dart b/dart/lib/src/client_reports/discard_reason.dart new file mode 100644 index 0000000000..1b990f8dd2 --- /dev/null +++ b/dart/lib/src/client_reports/discard_reason.dart @@ -0,0 +1,35 @@ +import 'package:meta/meta.dart'; + +/// A reason that defines why events were lost, see +/// https://develop.sentry.dev/sdk/client-reports/#envelope-item-payload. +@internal +enum DiscardReason { + beforeSend, + eventProcessor, + sampleRate, + networkError, + queueOverflow, + cacheOverflow, + rateLimitBackoff, +} + +extension OutcomeExtension on DiscardReason { + String toStringValue() { + switch (this) { + case DiscardReason.beforeSend: + return 'before_send'; + case DiscardReason.eventProcessor: + return 'event_processor'; + case DiscardReason.sampleRate: + return 'sample_rate'; + case DiscardReason.networkError: + return 'network_error'; + case DiscardReason.queueOverflow: + return 'queue_overflow'; + case DiscardReason.cacheOverflow: + return 'cache_overflow'; + case DiscardReason.rateLimitBackoff: + return 'ratelimit_backoff'; + } + } +} diff --git a/dart/lib/src/client_reports/discarded_event.dart b/dart/lib/src/client_reports/discarded_event.dart new file mode 100644 index 0000000000..0b989aa4cf --- /dev/null +++ b/dart/lib/src/client_reports/discarded_event.dart @@ -0,0 +1,21 @@ +import 'package:meta/meta.dart'; + +import 'discard_reason.dart'; +import '../transport/data_category.dart'; + +@internal +class DiscardedEvent { + DiscardedEvent(this.reason, this.category, this.quantity); + + final DiscardReason reason; + final DataCategory category; + final int quantity; + + Map toJson() { + return { + 'reason': reason.toStringValue(), + 'category': category.toStringValue(), + 'quantity': quantity, + }; + } +} diff --git a/dart/lib/src/client_reports/noop_client_report_recorder.dart b/dart/lib/src/client_reports/noop_client_report_recorder.dart new file mode 100644 index 0000000000..acd6472347 --- /dev/null +++ b/dart/lib/src/client_reports/noop_client_report_recorder.dart @@ -0,0 +1,19 @@ +import 'package:meta/meta.dart'; + +import '../transport/data_category.dart'; +import 'client_report.dart'; +import 'client_report_recorder.dart'; +import 'discard_reason.dart'; + +@internal +class NoOpClientReportRecorder implements ClientReportRecorder { + const NoOpClientReportRecorder(); + + @override + ClientReport? flush() { + return null; + } + + @override + void recordLostEvent(DiscardReason reason, DataCategory category) {} +} diff --git a/dart/lib/src/hub.dart b/dart/lib/src/hub.dart index 63354581ed..92e295daf8 100644 --- a/dart/lib/src/hub.dart +++ b/dart/lib/src/hub.dart @@ -2,15 +2,12 @@ import 'dart:async'; import 'dart:collection'; import 'package:meta/meta.dart'; +import 'transport/data_category.dart'; -import 'protocol.dart'; -import 'scope.dart'; -import 'sentry_client.dart'; -import 'sentry_options.dart'; +import '../sentry.dart'; +import 'client_reports/discard_reason.dart'; import 'sentry_tracer.dart'; import 'sentry_traces_sampler.dart'; -import 'sentry_user_feedback.dart'; -import 'tracing.dart'; /// Configures the scope through the callback. typedef ScopeCallback = void Function(Scope); @@ -462,14 +459,18 @@ class Hub { 'Capturing unfinished transaction: ${transaction.eventId}', ); } else { + final item = _peek(); + if (!transaction.sampled) { + _options.recorder.recordLostEvent( + DiscardReason.sampleRate, + DataCategory.transaction, + ); _options.logger( SentryLevel.warning, 'Transaction ${transaction.eventId} was dropped due to sampling decision.', ); } else { - final item = _peek(); - try { sentryId = await item.client.captureTransaction( transaction, diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 020be3046e..5de96d0502 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -14,6 +14,9 @@ import 'transport/http_transport.dart'; import 'transport/noop_transport.dart'; import 'version.dart'; import 'sentry_envelope.dart'; +import 'client_reports/client_report_recorder.dart'; +import 'client_reports/discard_reason.dart'; +import 'transport/data_category.dart'; /// Default value for [User.ipAddress]. It gets set when an event does not have /// a user and IP address. Only applies if [SentryOptions.sendDefaultPii] is set @@ -34,10 +37,13 @@ class SentryClient { /// Instantiates a client using [SentryOptions] factory SentryClient(SentryOptions options) { + if (options.sendClientReports) { + options.recorder = ClientReportRecorder(options.clock); + } if (options.transport is NoOpTransport) { - options.transport = HttpTransport(options, RateLimiter(options.clock)); + final rateLimiter = RateLimiter(options); + options.transport = HttpTransport(options, rateLimiter); } - return SentryClient._(options); } @@ -53,6 +59,7 @@ class SentryClient { dynamic hint, }) async { if (_sampleRate()) { + _recordLostEvent(event, DiscardReason.sampleRate); _options.logger( SentryLevel.debug, 'Event ${event.eventId.toString()} was dropped due to sampling decision.', @@ -87,6 +94,7 @@ class SentryClient { final beforeSend = _options.beforeSend; if (beforeSend != null) { + final beforeSendEvent = preparedEvent; try { preparedEvent = await beforeSend(preparedEvent, hint: hint); } catch (exception, stackTrace) { @@ -98,6 +106,7 @@ class SentryClient { ); } if (preparedEvent == null) { + _recordLostEvent(beforeSendEvent, DiscardReason.beforeSend); _options.logger( SentryLevel.debug, 'Event was dropped by BeforeSend callback', @@ -264,7 +273,7 @@ class SentryClient { /// Reports the [envelope] to Sentry.io. Future captureEnvelope(SentryEnvelope envelope) { - return _options.transport.send(envelope); + return _attachClientReportsAndSend(envelope); } /// Reports the [userFeedback] to Sentry.io. @@ -273,7 +282,7 @@ class SentryClient { userFeedback, _options.sdk, ); - return _options.transport.send(envelope); + return _attachClientReportsAndSend(envelope); } void close() => _options.httpClient.close(); @@ -296,6 +305,7 @@ class SentryClient { ); } if (processedEvent == null) { + _recordLostEvent(event, DiscardReason.eventProcessor); _options.logger(SentryLevel.debug, 'Event was dropped by a processor'); break; } @@ -309,4 +319,20 @@ class SentryClient { } return false; } + + void _recordLostEvent(SentryEvent event, DiscardReason reason) { + DataCategory category; + if (event is SentryTransaction) { + category = DataCategory.transaction; + } else { + category = DataCategory.error; + } + _options.recorder.recordLostEvent(reason, category); + } + + Future _attachClientReportsAndSend(SentryEnvelope envelope) { + final clientReport = _options.recorder.flush(); + envelope.addClientReport(clientReport); + return _options.transport.send(envelope); + } } diff --git a/dart/lib/src/sentry_envelope.dart b/dart/lib/src/sentry_envelope.dart index 25bd031974..76182a8ab9 100644 --- a/dart/lib/src/sentry_envelope.dart +++ b/dart/lib/src/sentry_envelope.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'client_reports/client_report.dart'; import 'protocol.dart'; import 'sentry_item_type.dart'; import 'sentry_options.dart'; @@ -12,7 +13,7 @@ import 'sentry_user_feedback.dart'; class SentryEnvelope { SentryEnvelope(this.header, this.items); - /// Header descriping envelope content. + /// Header describing envelope content. final SentryEnvelopeHeader header; /// All items contained in the envelope. @@ -74,7 +75,7 @@ class SentryEnvelope { if (length < 0) { continue; } - // Olny attachments should be filtered according to + // Only attachments should be filtered according to // SentryOptions.maxAttachmentSize if (item.header.type == SentryItemType.attachment) { if (await item.header.length() > options.maxAttachmentSize) { @@ -88,4 +89,12 @@ class SentryEnvelope { } } } + + /// Add an envelope item containing client report data. + void addClientReport(ClientReport? clientReport) { + if (clientReport != null) { + final envelopeItem = SentryEnvelopeItem.fromClientReport(clientReport); + items.add(envelopeItem); + } + } } diff --git a/dart/lib/src/sentry_envelope_item.dart b/dart/lib/src/sentry_envelope_item.dart index b274334127..b9d96afecd 100644 --- a/dart/lib/src/sentry_envelope_item.dart +++ b/dart/lib/src/sentry_envelope_item.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'client_reports/client_report.dart'; import 'protocol.dart'; import 'utils.dart'; import 'sentry_attachment/sentry_attachment.dart'; @@ -12,21 +13,12 @@ class SentryEnvelopeItem { /// Creates an [SentryEnvelopeItem] which sends [SentryTransaction]. factory SentryEnvelopeItem.fromTransaction(SentryTransaction transaction) { - final cachedItem = _CachedItem(() async { - final jsonEncoded = jsonEncode( - transaction.toJson(), - toEncodable: jsonSerializationFallback, - ); - return utf8.encode(jsonEncoded); - }); - - final getLength = () async { - return (await cachedItem.getData()).length; - }; + final cachedItem = + _CachedItem(() async => _jsonToBytes(transaction.toJson())); final header = SentryEnvelopeItemHeader( SentryItemType.transaction, - getLength, + cachedItem.getDataLength, contentType: 'application/json', ); return SentryEnvelopeItem(header, cachedItem.getData); @@ -35,17 +27,9 @@ class SentryEnvelopeItem { factory SentryEnvelopeItem.fromAttachment(SentryAttachment attachment) { final cachedItem = _CachedItem(() async => await attachment.bytes); - final getLength = () async { - try { - return (await cachedItem.getData()).length; - } catch (_) { - return -1; - } - }; - final header = SentryEnvelopeItemHeader( SentryItemType.attachment, - getLength, + cachedItem.getDataLength, contentType: attachment.contentType, fileName: attachment.filename, attachmentType: attachment.attachmentType, @@ -55,27 +39,42 @@ class SentryEnvelopeItem { /// Create an [SentryEnvelopeItem] which sends [SentryUserFeedback]. factory SentryEnvelopeItem.fromUserFeedback(SentryUserFeedback feedback) { - final bytes = _jsonToBytes(feedback.toJson()); + final cachedItem = _CachedItem(() async => _jsonToBytes(feedback.toJson())); final header = SentryEnvelopeItemHeader( SentryItemType.userFeedback, - () async => bytes.length, + cachedItem.getDataLength, contentType: 'application/json', ); - return SentryEnvelopeItem(header, () async => bytes); + return SentryEnvelopeItem(header, cachedItem.getData); } /// Create an [SentryEnvelopeItem] which holds the [SentryEvent] data. factory SentryEnvelopeItem.fromEvent(SentryEvent event) { - final bytes = _jsonToBytes(event.toJson()); + final cachedItem = _CachedItem(() async => _jsonToBytes(event.toJson())); return SentryEnvelopeItem( SentryEnvelopeItemHeader( SentryItemType.event, - () async => bytes.length, + cachedItem.getDataLength, + contentType: 'application/json', + ), + cachedItem.getData, + ); + } + + /// Create an [SentryEnvelopeItem] which holds the [ClientReport] data. + factory SentryEnvelopeItem.fromClientReport(ClientReport clientReport) { + final cachedItem = + _CachedItem(() async => _jsonToBytes(clientReport.toJson())); + + return SentryEnvelopeItem( + SentryEnvelopeItemHeader( + SentryItemType.clientReport, + cachedItem.getDataLength, contentType: 'application/json', ), - () async => bytes, + cachedItem.getData, ); } @@ -88,13 +87,11 @@ class SentryEnvelopeItem { /// Stream binary data of `Envelope` item. Future> envelopeItemStream() async { // Each item needs to be encoded as one unit. - // Otherwise the header alredy got yielded if the content throws + // Otherwise the header already got yielded if the content throws // an exception. try { - final itemHeader = utf8.encode(jsonEncode( - await header.toJson(), - toEncodable: jsonSerializationFallback, - )); + final itemHeader = _jsonToBytes(await header.toJson()); + final newLine = utf8.encode('\n'); final data = await dataFactory(); return [...itemHeader, ...newLine, ...data]; @@ -123,4 +120,12 @@ class _CachedItem { _data ??= await _dataFactory(); return _data!; } + + Future getDataLength() async { + try { + return (await getData()).length; + } catch (_) { + return -1; + } + } } diff --git a/dart/lib/src/sentry_item_type.dart b/dart/lib/src/sentry_item_type.dart index b90dff8538..c74b6b6049 100644 --- a/dart/lib/src/sentry_item_type.dart +++ b/dart/lib/src/sentry_item_type.dart @@ -3,5 +3,6 @@ class SentryItemType { static const String userFeedback = 'user_report'; static const String attachment = 'attachment'; static const String transaction = 'transaction'; + static const String clientReport = 'client_report'; static const String unknown = '__unknown__'; } diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 3afa664ea8..47c88ba4e8 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -4,19 +4,15 @@ import 'dart:developer'; import 'package:meta/meta.dart'; import 'package:http/http.dart'; +import '../sentry.dart'; +import 'client_reports/client_report_recorder.dart'; +import 'client_reports/noop_client_report_recorder.dart'; import 'sentry_exception_factory.dart'; import 'sentry_stack_trace_factory.dart'; import 'diagnostic_logger.dart'; import 'environment/environment_variables.dart'; -import 'event_processor.dart'; -import 'http_client/sentry_http_client.dart'; -import 'integration.dart'; import 'noop_client.dart'; -import 'platform_checker.dart'; -import 'protocol.dart'; -import 'tracing.dart'; import 'transport/noop_transport.dart'; -import 'transport/transport.dart'; import 'utils.dart'; import 'version.dart'; @@ -262,6 +258,12 @@ class SentryOptions { /// to be sent to Sentry. TracesSamplerCallback? tracesSampler; + /// Send statistics to sentry when the client drops events. + bool sendClientReports = true; + + @internal + late ClientReportRecorder recorder = NoOpClientReportRecorder(); + SentryOptions({this.dsn, PlatformChecker? checker}) { if (checker != null) { platformChecker = checker; diff --git a/dart/lib/src/transport/data_category.dart b/dart/lib/src/transport/data_category.dart new file mode 100644 index 0000000000..aa43ef569b --- /dev/null +++ b/dart/lib/src/transport/data_category.dart @@ -0,0 +1,54 @@ +/// Different category types of data sent to Sentry. Used for rate limiting and client reports. +enum DataCategory { + all, + data_category_default, // default + error, + session, + transaction, + attachment, + security, + unknown +} + +extension DataCategoryExtension on DataCategory { + static DataCategory fromStringValue(String stringValue) { + switch (stringValue) { + case '__all__': + return DataCategory.all; + case 'default': + return DataCategory.data_category_default; + case 'error': + return DataCategory.error; + case 'session': + return DataCategory.session; + case 'transaction': + return DataCategory.transaction; + case 'attachment': + return DataCategory.attachment; + case 'security': + return DataCategory.security; + } + return DataCategory.unknown; + } + + String toStringValue() { + switch (this) { + case DataCategory.all: + return '__all__'; + case DataCategory.data_category_default: + return 'default'; + case DataCategory.error: + return 'error'; + case DataCategory.session: + return 'session'; + case DataCategory.transaction: + return 'transaction'; + case DataCategory.attachment: + return 'attachment'; + case DataCategory.security: + return 'security'; + case DataCategory.unknown: + return 'unknown'; + } + } +} diff --git a/dart/lib/src/transport/http_transport.dart b/dart/lib/src/transport/http_transport.dart index 17091d3c2b..317070695a 100644 --- a/dart/lib/src/transport/http_transport.dart +++ b/dart/lib/src/transport/http_transport.dart @@ -3,6 +3,9 @@ import 'dart:convert'; import 'package:http/http.dart'; +import '../client_reports/client_report_recorder.dart'; +import '../client_reports/discard_reason.dart'; +import 'data_category.dart'; import 'noop_encode.dart' if (dart.library.io) 'encode.dart'; import '../noop_client.dart'; import '../protocol.dart'; @@ -19,6 +22,8 @@ class HttpTransport implements Transport { final RateLimiter _rateLimiter; + final ClientReportRecorder _recorder; + late _CredentialBuilder _credentialBuilder; final Map _headers; @@ -33,6 +38,7 @@ class HttpTransport implements Transport { HttpTransport._(this._options, this._rateLimiter) : _dsn = Dsn.parse(_options.dsn!), + _recorder = _options.recorder, _headers = _buildHeaders( _options.platformChecker.isWeb, _options.sdk.identifier, @@ -68,6 +74,12 @@ class HttpTransport implements Transport { 'body = ${response.body}', ); } + + if (response.statusCode >= 400 && response.statusCode != 429) { + _recorder.recordLostEvent( + DiscardReason.networkError, DataCategory.error); + } + return SentryId.empty(); } else { _options.logger( diff --git a/dart/lib/src/transport/rate_limit.dart b/dart/lib/src/transport/rate_limit.dart index e95285f716..8f41b91d81 100644 --- a/dart/lib/src/transport/rate_limit.dart +++ b/dart/lib/src/transport/rate_limit.dart @@ -1,9 +1,9 @@ -import 'rate_limit_category.dart'; +import 'data_category.dart'; -/// `RateLimit` containing limited `RateLimitCategory` and duration in milliseconds. +/// `RateLimit` containing limited `DataCategory` and duration in milliseconds. class RateLimit { RateLimit(this.category, this.duration); - final RateLimitCategory category; + final DataCategory category; final Duration duration; } diff --git a/dart/lib/src/transport/rate_limit_category.dart b/dart/lib/src/transport/rate_limit_category.dart deleted file mode 100644 index cb0040a0e6..0000000000 --- a/dart/lib/src/transport/rate_limit_category.dart +++ /dev/null @@ -1,54 +0,0 @@ -/// Different category types of data sent to Sentry. Used for rate limiting. -enum RateLimitCategory { - all, - rate_limit_default, // default - error, - session, - transaction, - attachment, - security, - unknown -} - -extension RateLimitCategoryExtension on RateLimitCategory { - static RateLimitCategory fromStringValue(String stringValue) { - switch (stringValue) { - case '__all__': - return RateLimitCategory.all; - case 'default': - return RateLimitCategory.rate_limit_default; - case 'error': - return RateLimitCategory.error; - case 'session': - return RateLimitCategory.session; - case 'transaction': - return RateLimitCategory.transaction; - case 'attachment': - return RateLimitCategory.attachment; - case 'security': - return RateLimitCategory.security; - } - return RateLimitCategory.unknown; - } - - String toStringValue() { - switch (this) { - case RateLimitCategory.all: - return '__all__'; - case RateLimitCategory.rate_limit_default: - return 'default'; - case RateLimitCategory.error: - return 'error'; - case RateLimitCategory.session: - return 'session'; - case RateLimitCategory.transaction: - return 'transaction'; - case RateLimitCategory.attachment: - return 'attachment'; - case RateLimitCategory.security: - return 'security'; - case RateLimitCategory.unknown: - return 'unknown'; - } - } -} diff --git a/dart/lib/src/transport/rate_limit_parser.dart b/dart/lib/src/transport/rate_limit_parser.dart index fe30121162..63f4f179d1 100644 --- a/dart/lib/src/transport/rate_limit_parser.dart +++ b/dart/lib/src/transport/rate_limit_parser.dart @@ -1,4 +1,4 @@ -import 'rate_limit_category.dart'; +import 'data_category.dart'; import 'rate_limit.dart'; /// Parse rate limit categories and times from response header payloads. @@ -29,23 +29,20 @@ class RateLimitParser { if (allCategories.isNotEmpty) { final categoryValues = allCategories.split(';'); for (final categoryValue in categoryValues) { - final category = - RateLimitCategoryExtension.fromStringValue(categoryValue); - if (category != RateLimitCategory.unknown) { + final category = DataCategoryExtension.fromStringValue(categoryValue); + if (category != DataCategory.unknown) { rateLimits.add(RateLimit(category, duration)); } } } else { - rateLimits.add(RateLimit(RateLimitCategory.all, duration)); + rateLimits.add(RateLimit(DataCategory.all, duration)); } } return rateLimits; } List parseRetryAfterHeader() { - return [ - RateLimit(RateLimitCategory.all, _parseRetryAfterOrDefault(_header)) - ]; + return [RateLimit(DataCategory.all, _parseRetryAfterOrDefault(_header))]; } // Helper diff --git a/dart/lib/src/transport/rate_limiter.dart b/dart/lib/src/transport/rate_limiter.dart index 9c0c432a28..6d4d3c3e9a 100644 --- a/dart/lib/src/transport/rate_limiter.dart +++ b/dart/lib/src/transport/rate_limiter.dart @@ -1,17 +1,17 @@ import '../transport/rate_limit_parser.dart'; - import '../sentry_options.dart'; import '../sentry_envelope.dart'; import '../sentry_envelope_item.dart'; import 'rate_limit.dart'; -import 'rate_limit_category.dart'; +import 'data_category.dart'; +import '../client_reports/discard_reason.dart'; /// Controls retry limits on different category types sent to Sentry. class RateLimiter { - RateLimiter(this._clockProvider); + RateLimiter(this._options); - final ClockProvider _clockProvider; - final _rateLimitedUntil = {}; + final SentryOptions _options; + final _rateLimitedUntil = {}; /// Filter out envelopes that are rate limited. SentryEnvelope? filter(SentryEnvelope envelope) { @@ -22,6 +22,11 @@ class RateLimiter { if (_isRetryAfter(item.header.type)) { dropItems ??= []; dropItems.add(item); + + _options.recorder.recordLostEvent( + DiscardReason.rateLimitBackoff, + _categoryFromItemType(item.header.type), + ); } } @@ -48,7 +53,7 @@ class RateLimiter { /// Update rate limited categories void updateRetryAfterLimits( String? sentryRateLimitHeader, String? retryAfterHeader, int errorCode) { - final currentDateTime = _clockProvider().millisecondsSinceEpoch; + final currentDateTime = _options.clock().millisecondsSinceEpoch; var rateLimits = []; if (sentryRateLimitHeader != null) { @@ -72,10 +77,10 @@ class RateLimiter { bool _isRetryAfter(String itemType) { final dataCategory = _categoryFromItemType(itemType); final currentDate = DateTime.fromMillisecondsSinceEpoch( - _clockProvider().millisecondsSinceEpoch); + _options.clock().millisecondsSinceEpoch); // check all categories - final dateAllCategories = _rateLimitedUntil[RateLimitCategory.all]; + final dateAllCategories = _rateLimitedUntil[DataCategory.all]; if (dateAllCategories != null) { if (!currentDate.isAfter(dateAllCategories)) { return true; @@ -83,7 +88,7 @@ class RateLimiter { } // Unknown should not be rate limited - if (RateLimitCategory.unknown == dataCategory) { + if (DataCategory.unknown == dataCategory) { return false; } @@ -96,28 +101,27 @@ class RateLimiter { return false; } - RateLimitCategory _categoryFromItemType(String itemType) { + DataCategory _categoryFromItemType(String itemType) { switch (itemType) { case 'event': - return RateLimitCategory.error; + return DataCategory.error; case 'session': - return RateLimitCategory.session; + return DataCategory.session; case 'attachment': - return RateLimitCategory.attachment; + return DataCategory.attachment; case 'transaction': - return RateLimitCategory.transaction; + return DataCategory.transaction; default: - return RateLimitCategory.unknown; + return DataCategory.unknown; } } - void _applyRetryAfterOnlyIfLonger( - RateLimitCategory rateLimitCategory, DateTime date) { - final oldDate = _rateLimitedUntil[rateLimitCategory]; + void _applyRetryAfterOnlyIfLonger(DataCategory dataCategory, DateTime date) { + final oldDate = _rateLimitedUntil[dataCategory]; // only overwrite its previous date if the limit is even longer if (oldDate == null || date.isAfter(oldDate)) { - _rateLimitedUntil[rateLimitCategory] = date; + _rateLimitedUntil[dataCategory] = date; } } } diff --git a/dart/test/client_reports/client_report_recorder_test.dart b/dart/test/client_reports/client_report_recorder_test.dart new file mode 100644 index 0000000000..3466d89f4d --- /dev/null +++ b/dart/test/client_reports/client_report_recorder_test.dart @@ -0,0 +1,94 @@ +import 'package:sentry/src/client_reports/discard_reason.dart'; +import 'package:sentry/src/transport/data_category.dart'; +import 'package:test/test.dart'; + +import 'package:sentry/src/client_reports/client_report_recorder.dart'; + +void main() { + group(ClientReportRecorder, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('flush returns null when there was nothing recorded', () { + final sut = fixture.getSut(); + + final clientReport = sut.flush(); + + expect(clientReport, null); + }); + + test('flush returns client report with current date', () { + final sut = fixture.getSut(); + + sut.recordLostEvent(DiscardReason.rateLimitBackoff, DataCategory.error); + + final clientReport = sut.flush(); + + expect(clientReport?.timestamp, DateTime(0)); + }); + + test('record lost event', () { + final sut = fixture.getSut(); + + sut.recordLostEvent(DiscardReason.rateLimitBackoff, DataCategory.error); + sut.recordLostEvent(DiscardReason.rateLimitBackoff, DataCategory.error); + + final clientReport = sut.flush(); + + final event = clientReport?.discardedEvents + .firstWhere((element) => element.category == DataCategory.error); + + expect(event?.reason, DiscardReason.rateLimitBackoff); + expect(event?.category, DataCategory.error); + expect(event?.quantity, 2); + }); + + test('record outcomes with different categories recorded separately', () { + final sut = fixture.getSut(); + + sut.recordLostEvent(DiscardReason.rateLimitBackoff, DataCategory.error); + sut.recordLostEvent( + DiscardReason.rateLimitBackoff, DataCategory.transaction); + + final clientReport = sut.flush(); + + final first = clientReport?.discardedEvents + .firstWhere((event) => event.category == DataCategory.error); + + final second = clientReport?.discardedEvents + .firstWhere((event) => event.category == DataCategory.transaction); + + expect(first?.reason, DiscardReason.rateLimitBackoff); + expect(first?.category, DataCategory.error); + expect(first?.quantity, 1); + + expect(second?.reason, DiscardReason.rateLimitBackoff); + expect(second?.category, DataCategory.transaction); + expect(second?.quantity, 1); + }); + + test('calling flush multiple times returns null', () { + final sut = fixture.getSut(); + + sut.recordLostEvent(DiscardReason.rateLimitBackoff, DataCategory.error); + + sut.flush(); + final clientReport = sut.flush(); + + expect(clientReport, null); + }); + }); +} + +class Fixture { + final _dateTimeProvider = () { + return DateTime(0); + }; + + ClientReportRecorder getSut() { + return ClientReportRecorder(_dateTimeProvider); + } +} diff --git a/dart/test/client_reports/client_report_test.dart b/dart/test/client_reports/client_report_test.dart new file mode 100644 index 0000000000..00f5736cc2 --- /dev/null +++ b/dart/test/client_reports/client_report_test.dart @@ -0,0 +1,54 @@ +import 'package:collection/collection.dart'; +import 'package:sentry/src/client_reports/client_report.dart'; +import 'package:sentry/src/client_reports/discard_reason.dart'; +import 'package:sentry/src/client_reports/discarded_event.dart'; +import 'package:sentry/src/transport/data_category.dart'; +import 'package:test/test.dart'; +import 'package:sentry/src/utils.dart'; + +void main() { + group('json', () { + late Fixture fixture; + + setUp(() async { + fixture = Fixture(); + }); + + test('toJson', () { + final sut = fixture.getSut(); + final json = sut.toJson(); + + expect( + DeepCollectionEquality().equals(fixture.clientReportJson, json), + true, + ); + }); + }); +} + +class Fixture { + final timestamp = DateTime.fromMillisecondsSinceEpoch(0); + late Map clientReportJson; + + Fixture() { + clientReportJson = { + 'timestamp': formatDateAsIso8601WithMillisPrecision(timestamp), + 'discarded_events': [ + { + 'reason': 'ratelimit_backoff', + 'category': 'error', + 'quantity': 2, + } + ], + }; + } + + ClientReport getSut() { + return ClientReport( + timestamp, + [ + DiscardedEvent(DiscardReason.rateLimitBackoff, DataCategory.error, 2), + ], + ); + } +} diff --git a/dart/test/hub_test.dart b/dart/test/hub_test.dart index 8a4cbeef51..3957a5a66f 100644 --- a/dart/test/hub_test.dart +++ b/dart/test/hub_test.dart @@ -1,9 +1,12 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry/src/transport/data_category.dart'; import 'package:test/test.dart'; import 'mocks.dart'; +import 'mocks/mock_client_report_recorder.dart'; import 'mocks/mock_sentry_client.dart'; void main() { @@ -465,10 +468,30 @@ void main() { expect(calls[2].formatted, 'foo bar 2'); }); }); + + group('ClientReportRecorder', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('record sample rate dropping transaction', () async { + final hub = fixture.getSut(sampled: false); + var transaction = SentryTransaction(fixture.tracer); + + await hub.captureTransaction(transaction); + + expect(fixture.recorder.reason, DiscardReason.sampleRate); + expect(fixture.recorder.category, DataCategory.transaction); + }); + }); } class Fixture { final client = MockSentryClient(); + final recorder = MockClientReportRecorder(); + final options = SentryOptions(dsn: fakeDsn); late SentryTransactionContext _context; late SentryTracer tracer; @@ -491,6 +514,8 @@ class Fixture { tracer = SentryTracer(_context, hub); hub.bindClient(client); + options.recorder = recorder; + return hub; } } diff --git a/dart/test/mocks/mock_client_report_recorder.dart b/dart/test/mocks/mock_client_report_recorder.dart new file mode 100644 index 0000000000..fa1af54ed8 --- /dev/null +++ b/dart/test/mocks/mock_client_report_recorder.dart @@ -0,0 +1,25 @@ +import 'package:sentry/src/client_reports/client_report_recorder.dart'; +import 'package:sentry/src/client_reports/discard_reason.dart'; +import 'package:sentry/src/client_reports/client_report.dart'; +import 'package:sentry/src/transport/data_category.dart'; + +class MockClientReportRecorder implements ClientReportRecorder { + DiscardReason? reason; + DataCategory? category; + + ClientReport? clientReport; + + bool flushCalled = false; + + @override + ClientReport? flush() { + flushCalled = true; + return clientReport; + } + + @override + void recordLostEvent(DiscardReason reason, DataCategory category) { + this.reason = reason; + this.category = category; + } +} diff --git a/dart/test/mocks/mock_envelope.dart b/dart/test/mocks/mock_envelope.dart new file mode 100644 index 0000000000..9b43a41b8a --- /dev/null +++ b/dart/test/mocks/mock_envelope.dart @@ -0,0 +1,26 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/sentry_envelope_header.dart'; +import 'package:sentry/src/client_reports/client_report.dart'; + +class MockEnvelope implements SentryEnvelope { + ClientReport? clientReport; + + @override + void addClientReport(ClientReport? clientReport) { + this.clientReport = clientReport; + } + + @override + Stream> envelopeStream(SentryOptions options) async* { + yield [0]; + } + + @override + SentryEnvelopeHeader get header => SentryEnvelopeHeader( + SentryId.empty(), + SdkVersion(name: 'fixture-name', version: '1'), + ); + + @override + List items = []; +} diff --git a/dart/test/mocks/mock_transport.dart b/dart/test/mocks/mock_transport.dart index 5b03835b77..693debe1e9 100644 --- a/dart/test/mocks/mock_transport.dart +++ b/dart/test/mocks/mock_transport.dart @@ -2,9 +2,7 @@ import 'dart:convert'; import 'package:sentry/sentry.dart'; -import 'no_such_method_provider.dart'; - -class MockTransport with NoSuchMethodProvider implements Transport { +class MockTransport implements Transport { List envelopes = []; List events = []; int calls = 0; diff --git a/dart/test/protocol/rate_limit_parser_test.dart b/dart/test/protocol/rate_limit_parser_test.dart index a8d5905c0e..c898915e04 100644 --- a/dart/test/protocol/rate_limit_parser_test.dart +++ b/dart/test/protocol/rate_limit_parser_test.dart @@ -1,5 +1,5 @@ import 'package:sentry/src/transport/rate_limit_parser.dart'; -import 'package:sentry/src/transport/rate_limit_category.dart'; +import 'package:sentry/src/transport/data_category.dart'; import 'package:test/test.dart'; void main() { @@ -8,7 +8,7 @@ void main() { final sut = RateLimitParser('50:transaction').parseRateLimitHeader(); expect(sut.length, 1); - expect(sut[0].category, RateLimitCategory.transaction); + expect(sut[0].category, DataCategory.transaction); expect(sut[0].duration.inMilliseconds, 50000); }); @@ -17,9 +17,9 @@ void main() { RateLimitParser('50:transaction;session').parseRateLimitHeader(); expect(sut.length, 2); - expect(sut[0].category, RateLimitCategory.transaction); + expect(sut[0].category, DataCategory.transaction); expect(sut[0].duration.inMilliseconds, 50000); - expect(sut[1].category, RateLimitCategory.session); + expect(sut[1].category, DataCategory.session); expect(sut[1].duration.inMilliseconds, 50000); }); @@ -33,7 +33,7 @@ void main() { final sut = RateLimitParser('50::key').parseRateLimitHeader(); expect(sut.length, 1); - expect(sut[0].category, RateLimitCategory.all); + expect(sut[0].category, DataCategory.all); expect(sut[0].duration.inMilliseconds, 50000); }); @@ -42,9 +42,9 @@ void main() { RateLimitParser('50:transaction, 70:session').parseRateLimitHeader(); expect(sut.length, 2); - expect(sut[0].category, RateLimitCategory.transaction); + expect(sut[0].category, DataCategory.transaction); expect(sut[0].duration.inMilliseconds, 50000); - expect(sut[1].category, RateLimitCategory.session); + expect(sut[1].category, DataCategory.session); expect(sut[1].duration.inMilliseconds, 70000); }); @@ -53,9 +53,9 @@ void main() { .parseRateLimitHeader(); expect(sut.length, 2); - expect(sut[0].category, RateLimitCategory.transaction); + expect(sut[0].category, DataCategory.transaction); expect(sut[0].duration.inMilliseconds, 50000); - expect(sut[1].category, RateLimitCategory.transaction); + expect(sut[1].category, DataCategory.transaction); expect(sut[1].duration.inMilliseconds, 70000); }); @@ -63,7 +63,7 @@ void main() { final sut = RateLimitParser('50:TRANSACTION').parseRateLimitHeader(); expect(sut.length, 1); - expect(sut[0].category, RateLimitCategory.transaction); + expect(sut[0].category, DataCategory.transaction); expect(sut[0].duration.inMilliseconds, 50000); }); @@ -71,7 +71,7 @@ void main() { final sut = RateLimitParser('foobar:transaction').parseRateLimitHeader(); expect(sut.length, 1); - expect(sut[0].category, RateLimitCategory.transaction); + expect(sut[0].category, DataCategory.transaction); expect(sut[0].duration.inMilliseconds, RateLimitParser.httpRetryAfterDefaultDelay.inMilliseconds); }); @@ -82,7 +82,7 @@ void main() { final sut = RateLimitParser(null).parseRetryAfterHeader(); expect(sut.length, 1); - expect(sut[0].category, RateLimitCategory.all); + expect(sut[0].category, DataCategory.all); expect(sut[0].duration.inMilliseconds, RateLimitParser.httpRetryAfterDefaultDelay.inMilliseconds); }); @@ -91,7 +91,7 @@ void main() { final sut = RateLimitParser('8').parseRetryAfterHeader(); expect(sut.length, 1); - expect(sut[0].category, RateLimitCategory.all); + expect(sut[0].category, DataCategory.all); expect(sut[0].duration.inMilliseconds, 8000); }); @@ -99,7 +99,7 @@ void main() { final sut = RateLimitParser('foobar').parseRetryAfterHeader(); expect(sut.length, 1); - expect(sut[0].category, RateLimitCategory.all); + expect(sut[0].category, DataCategory.all); expect(sut[0].duration.inMilliseconds, RateLimitParser.httpRetryAfterDefaultDelay.inMilliseconds); }); diff --git a/dart/test/protocol/rate_limiter_test.dart b/dart/test/protocol/rate_limiter_test.dart index 23f6cf0cde..e95dd1afe4 100644 --- a/dart/test/protocol/rate_limiter_test.dart +++ b/dart/test/protocol/rate_limiter_test.dart @@ -1,10 +1,14 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/client_reports/discard_reason.dart'; +import 'package:sentry/src/transport/data_category.dart'; import 'package:test/test.dart'; import 'package:sentry/src/transport/rate_limiter.dart'; -import 'package:sentry/src/protocol/sentry_event.dart'; -import 'package:sentry/src/sentry_envelope.dart'; +import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/sentry_envelope_header.dart'; -import 'package:sentry/src/sentry_envelope_item.dart'; + +import '../mocks/mock_client_report_recorder.dart'; +import '../mocks/mock_hub.dart'; void main() { var fixture = Fixture(); @@ -14,7 +18,7 @@ void main() { }); test('uses X-Sentry-Rate-Limit and allows sending if time has passed', () { - final rateLimiter = fixture.getSUT(); + final rateLimiter = fixture.getSut(); fixture.dateTimeToReturn = 0; final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); @@ -36,7 +40,7 @@ void main() { test( 'parse X-Sentry-Rate-Limit and set its values and retry after should be true', () { - final rateLimiter = fixture.getSUT(); + final rateLimiter = fixture.getSut(); fixture.dateTimeToReturn = 0; final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); @@ -57,7 +61,7 @@ void main() { test( 'parse X-Sentry-Rate-Limit and set its values and retry after should be false', () { - final rateLimiter = fixture.getSUT(); + final rateLimiter = fixture.getSut(); fixture.dateTimeToReturn = 0; final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); @@ -79,7 +83,7 @@ void main() { test( 'When X-Sentry-Rate-Limit categories are empty, applies to all the categories', () { - final rateLimiter = fixture.getSUT(); + final rateLimiter = fixture.getSut(); fixture.dateTimeToReturn = 0; final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); final envelope = SentryEnvelope( @@ -96,7 +100,7 @@ void main() { test( 'When all categories is set but expired, applies only for specific category', () { - final rateLimiter = fixture.getSUT(); + final rateLimiter = fixture.getSut(); fixture.dateTimeToReturn = 0; final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); final envelope = SentryEnvelope( @@ -115,7 +119,7 @@ void main() { test('When category has shorter rate limiting, do not apply new timestamp', () { - final rateLimiter = fixture.getSUT(); + final rateLimiter = fixture.getSut(); fixture.dateTimeToReturn = 0; final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); final envelope = SentryEnvelope( @@ -133,7 +137,7 @@ void main() { }); test('When category has longer rate limiting, apply new timestamp', () { - final rateLimiter = fixture.getSUT(); + final rateLimiter = fixture.getSut(); fixture.dateTimeToReturn = 0; final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); final envelope = SentryEnvelope( @@ -151,7 +155,7 @@ void main() { }); test('When both retry headers are not present, default delay is set', () { - final rateLimiter = fixture.getSUT(); + final rateLimiter = fixture.getSut(); fixture.dateTimeToReturn = 0; final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); final envelope = SentryEnvelope( @@ -170,7 +174,7 @@ void main() { test( 'When no sentryRateLimitHeader available, it fallback to retryAfterHeader', () { - final rateLimiter = fixture.getSUT(); + final rateLimiter = fixture.getSut(); fixture.dateTimeToReturn = 0; final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); final envelope = SentryEnvelope( @@ -185,16 +189,71 @@ void main() { final result = rateLimiter.filter(envelope); expect(result, isNull); }); + + test('dropping of event recorded', () { + final rateLimiter = fixture.getSut(); + + final eventItem = SentryEnvelopeItem.fromEvent(SentryEvent()); + final eventEnvelope = SentryEnvelope( + SentryEnvelopeHeader.newEventId(), + [eventItem], + ); + + rateLimiter.updateRetryAfterLimits( + '1:error:key, 5:error:organization', null, 1); + + final result = rateLimiter.filter(eventEnvelope); + expect(result, isNull); + + expect(fixture.mockRecorder.category, DataCategory.error); + expect(fixture.mockRecorder.reason, DiscardReason.rateLimitBackoff); + }); + + test('dropping of transaction recorded', () { + final rateLimiter = fixture.getSut(); + + final transaction = fixture.getTransaction(); + final eventItem = SentryEnvelopeItem.fromTransaction(transaction); + final eventEnvelope = SentryEnvelope( + SentryEnvelopeHeader.newEventId(), + [eventItem], + ); + + rateLimiter.updateRetryAfterLimits( + '1:transaction:key, 5:transaction:organization', null, 1); + + final result = rateLimiter.filter(eventEnvelope); + expect(result, isNull); + + expect(fixture.mockRecorder.category, DataCategory.transaction); + expect(fixture.mockRecorder.reason, DiscardReason.rateLimitBackoff); + }); } class Fixture { var dateTimeToReturn = 0; - RateLimiter getSUT() { - return RateLimiter(_currentDateTime); + late var mockRecorder = MockClientReportRecorder(); + + RateLimiter getSut() { + final options = SentryOptions(); + options.clock = _currentDateTime; + options.recorder = mockRecorder; + + return RateLimiter(options); } DateTime _currentDateTime() { return DateTime.fromMillisecondsSinceEpoch(dateTimeToReturn); } + + SentryTransaction getTransaction() { + final context = SentryTransactionContext( + 'name', + 'op', + sampled: true, + ); + final tracer = SentryTracer(context, MockHub()); + return SentryTransaction(tracer); + } } diff --git a/dart/test/sentry_client_test.dart b/dart/test/sentry_client_test.dart index a6acb41ceb..2aab69c9d3 100644 --- a/dart/test/sentry_client_test.dart +++ b/dart/test/sentry_client_test.dart @@ -3,12 +3,20 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/client_reports/client_report.dart'; +import 'package:sentry/src/client_reports/discard_reason.dart'; +import 'package:sentry/src/client_reports/discarded_event.dart'; +import 'package:sentry/src/client_reports/noop_client_report_recorder.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_stack_trace_factory.dart'; import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry/src/transport/data_category.dart'; import 'package:test/test.dart'; import 'mocks.dart'; +import 'mocks/mock_client_report_recorder.dart'; +import 'mocks/mock_envelope.dart'; +import 'mocks/mock_hub.dart'; import 'mocks/mock_transport.dart'; void main() { @@ -805,6 +813,149 @@ void main() { expect(capturedEnvelope, fakeEnvelope); }); }); + + group('ClientReportRecorder', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('recorder is not noop if client reports are enabled', () async { + fixture.options.sendClientReports = true; + + fixture.getSut( + eventProcessor: DropAllEventProcessor(), + provideMockRecorder: false, + ); + + expect(fixture.options.recorder is NoOpClientReportRecorder, false); + expect(fixture.options.recorder is MockClientReportRecorder, false); + }); + + test('recorder is noop if client reports are disabled', () { + fixture.options.sendClientReports = false; + + fixture.getSut( + eventProcessor: DropAllEventProcessor(), + provideMockRecorder: false, + ); + + expect(fixture.options.recorder is NoOpClientReportRecorder, true); + }); + + test('captureEnvelope calls flush', () async { + final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); + + final envelope = MockEnvelope(); + envelope.items = [SentryEnvelopeItem.fromEvent(SentryEvent())]; + + await client.captureEnvelope(envelope); + + expect(fixture.recorder.flushCalled, true); + }); + + test('captureEnvelope adds client report', () async { + final clientReport = ClientReport( + DateTime(0), + [DiscardedEvent(DiscardReason.rateLimitBackoff, DataCategory.error, 1)], + ); + fixture.recorder.clientReport = clientReport; + + final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); + + final envelope = MockEnvelope(); + envelope.items = [SentryEnvelopeItem.fromEvent(SentryEvent())]; + + await client.captureEnvelope(envelope); + + expect(envelope.clientReport, clientReport); + }); + + test('captureUserFeedback calls flush', () async { + final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); + + final id = SentryId.newId(); + final feedback = SentryUserFeedback( + eventId: id, + comments: 'this is awesome', + email: 'sentry@example.com', + name: 'Rockstar Developer', + ); + await client.captureUserFeedback(feedback); + + expect(fixture.recorder.flushCalled, true); + }); + + test('captureUserFeedback adds client report', () async { + final clientReport = ClientReport( + DateTime(0), + [DiscardedEvent(DiscardReason.rateLimitBackoff, DataCategory.error, 1)], + ); + fixture.recorder.clientReport = clientReport; + + final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); + + final id = SentryId.newId(); + final feedback = SentryUserFeedback( + eventId: id, + comments: 'this is awesome', + email: 'sentry@example.com', + name: 'Rockstar Developer', + ); + await client.captureUserFeedback(feedback); + + final envelope = fixture.transport.envelopes.first; + final item = envelope.items.last; + + // Only partial test, as the envelope is created internally from feedback. + expect(item.header.type, SentryItemType.clientReport); + }); + + test('record event processor dropping event', () async { + final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); + + await client.captureEvent(fakeEvent); + + expect(fixture.recorder.reason, DiscardReason.eventProcessor); + expect(fixture.recorder.category, DataCategory.error); + }); + + test('record event processor dropping transaction', () async { + final client = fixture.getSut(eventProcessor: DropAllEventProcessor()); + + final context = SentryTransactionContext('name', 'op'); + final tracer = SentryTracer(context, MockHub()); + final transaction = SentryTransaction(tracer); + + await client.captureTransaction(transaction); + + expect(fixture.recorder.reason, DiscardReason.eventProcessor); + expect(fixture.recorder.category, DataCategory.transaction); + }); + + test('record beforeSend dropping event', () async { + final client = fixture.getSut(); + + fixture.options.beforeSend = fixture.droppingBeforeSend; + + await client.captureEvent(fakeEvent); + + expect(fixture.recorder.reason, DiscardReason.beforeSend); + expect(fixture.recorder.category, DataCategory.error); + }); + + test('record sample rate dropping event', () async { + final client = fixture.getSut(sampleRate: 0.0); + + fixture.options.beforeSend = fixture.droppingBeforeSend; + + await client.captureEvent(fakeEvent); + + expect(fixture.recorder.reason, DiscardReason.sampleRate); + expect(fixture.recorder.category, DataCategory.error); + }); + }); } Future eventFromEnvelope(SentryEnvelope envelope) async { @@ -852,9 +1003,11 @@ FutureOr beforeSendCallback(SentryEvent event, {dynamic hint}) { } class Fixture { + final recorder = MockClientReportRecorder(); final transport = MockTransport(); final options = SentryOptions(dsn: fakeDsn); + late SentryTransactionContext _context; late SentryTracer tracer; @@ -864,6 +1017,7 @@ class Fixture { double? sampleRate, BeforeSendCallback? beforeSend, EventProcessor? eventProcessor, + bool provideMockRecorder = true, }) { final hub = Hub(options); _context = SentryTransactionContext( @@ -883,7 +1037,14 @@ class Fixture { options.transport = transport; final client = SentryClient(options); // hub.bindClient(client); - + if (provideMockRecorder) { + options.recorder = recorder; + } return client; } + + FutureOr droppingBeforeSend(SentryEvent event, + {dynamic hint}) async { + return null; + } } diff --git a/dart/test/sentry_envelope_item_test.dart b/dart/test/sentry_envelope_item_test.dart index 35c553c468..4fe01e5a8e 100644 --- a/dart/test/sentry_envelope_item_test.dart +++ b/dart/test/sentry_envelope_item_test.dart @@ -1,10 +1,13 @@ import 'dart:convert'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/client_reports/client_report.dart'; +import 'package:sentry/src/client_reports/discard_reason.dart'; +import 'package:sentry/src/client_reports/discarded_event.dart'; import 'package:sentry/src/sentry_envelope_item_header.dart'; -import 'package:sentry/src/sentry_envelope_item.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry/src/transport/data_category.dart'; import 'package:sentry/src/utils.dart'; import 'package:test/test.dart'; @@ -82,5 +85,30 @@ void main() { expect(actualLength, expectedLength); expect(actualData, expectedData); }); + + test('fromClientReport', () async { + final timestamp = DateTime(0); + final discardedEvents = [ + DiscardedEvent(DiscardReason.rateLimitBackoff, DataCategory.error, 1) + ]; + + final cr = ClientReport(timestamp, discardedEvents); + + final sut = SentryEnvelopeItem.fromClientReport(cr); + + final expectedData = utf8.encode(jsonEncode( + cr.toJson(), + toEncodable: jsonSerializationFallback, + )); + final actualData = await sut.dataFactory(); + + final expectedLength = expectedData.length; + final actualLength = await sut.header.length(); + + expect(sut.header.contentType, 'application/json'); + expect(sut.header.type, SentryItemType.clientReport); + expect(actualLength, expectedLength); + expect(actualData, expectedData); + }); }); } diff --git a/dart/test/sentry_envelope_test.dart b/dart/test/sentry_envelope_test.dart index bc28c508de..d7eaf62045 100644 --- a/dart/test/sentry_envelope_test.dart +++ b/dart/test/sentry_envelope_test.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_envelope_header.dart'; import 'package:sentry/src/sentry_envelope_item_header.dart'; -import 'package:sentry/src/sentry_envelope_item.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/utils.dart'; diff --git a/dart/test/sentry_envelope_vm_test.dart b/dart/test/sentry_envelope_vm_test.dart index b1bd880f91..33dce0515d 100644 --- a/dart/test/sentry_envelope_vm_test.dart +++ b/dart/test/sentry_envelope_vm_test.dart @@ -4,7 +4,6 @@ import 'dart:io'; import 'package:sentry/sentry_io.dart'; import 'package:sentry/src/sentry_envelope_header.dart'; import 'package:sentry/src/sentry_envelope_item_header.dart'; -import 'package:sentry/src/sentry_envelope_item.dart'; import 'package:test/test.dart'; void main() { diff --git a/dart/test/test_utils.dart b/dart/test/test_utils.dart index fa0c410198..147479e05b 100644 --- a/dart/test/test_utils.dart +++ b/dart/test/test_utils.dart @@ -390,7 +390,8 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { ..compressPayload = false ..serverName = 'test.server.com' ..release = '1.2.3' - ..environment = 'staging'; + ..environment = 'staging' + ..sendClientReports = false; final client = SentryClient(options); diff --git a/dart/test/transport/http_transport_test.dart b/dart/test/transport/http_transport_test.dart index 6b7319b5ee..14a2fe19e7 100644 --- a/dart/test/transport/http_transport_test.dart +++ b/dart/test/transport/http_transport_test.dart @@ -2,17 +2,21 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/sentry_envelope_header.dart'; -import 'package:sentry/src/sentry_envelope_item.dart'; import 'package:sentry/src/sentry_envelope_item_header.dart'; import 'package:sentry/src/sentry_item_type.dart'; +import 'package:sentry/src/transport/data_category.dart'; import 'package:sentry/src/transport/rate_limiter.dart'; import 'package:test/test.dart'; +import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/transport/http_transport.dart'; import '../mocks.dart'; +import '../mocks/mock_client_report_recorder.dart'; +import '../mocks/mock_hub.dart'; void main() { SentryEnvelope givenEnvelope() { @@ -145,6 +149,59 @@ void main() { 'fixture-sentryRateLimitHeader'); }); }); + + group('client reports', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('does records lost event for error >= 400', () async { + final httpMock = MockClient((http.Request request) async { + return http.Response('{}', 400); + }); + final sut = fixture.getSut(httpMock, MockRateLimiter()); + + final sentryEvent = SentryEvent(); + final envelope = + SentryEnvelope.fromEvent(sentryEvent, fixture.options.sdk); + await sut.send(envelope); + + expect(fixture.clientReportRecorder.reason, DiscardReason.networkError); + expect(fixture.clientReportRecorder.category, DataCategory.error); + }); + + test('does not record lost event for error 429', () async { + final httpMock = MockClient((http.Request request) async { + return http.Response('{}', 429); + }); + final sut = fixture.getSut(httpMock, MockRateLimiter()); + + final sentryEvent = SentryEvent(); + final envelope = + SentryEnvelope.fromEvent(sentryEvent, fixture.options.sdk); + await sut.send(envelope); + + expect(fixture.clientReportRecorder.reason, null); + expect(fixture.clientReportRecorder.category, null); + }); + + test('does record lost event for error >= 500', () async { + final httpMock = MockClient((http.Request request) async { + return http.Response('{}', 500); + }); + final sut = fixture.getSut(httpMock, MockRateLimiter()); + + final sentryEvent = SentryEvent(); + final envelope = + SentryEnvelope.fromEvent(sentryEvent, fixture.options.sdk); + await sut.send(envelope); + + expect(fixture.clientReportRecorder.reason, DiscardReason.networkError); + expect(fixture.clientReportRecorder.category, DataCategory.error); + }); + }); } class Fixture { @@ -152,8 +209,22 @@ class Fixture { dsn: 'https://public:secret@sentry.example.com/1', ); + late var clientReportRecorder = MockClientReportRecorder(); + HttpTransport getSut(http.Client client, RateLimiter rateLimiter) { options.httpClient = client; + options.recorder = clientReportRecorder; return HttpTransport(options, rateLimiter); } + + SentryTracer createTracer({ + bool? sampled, + }) { + final context = SentryTransactionContext( + 'name', + 'op', + sampled: sampled, + ); + return SentryTracer(context, MockHub()); + } } diff --git a/dio/test/mocks/mock_transport.dart b/dio/test/mocks/mock_transport.dart index 85a2c625b1..5b9b13de03 100644 --- a/dio/test/mocks/mock_transport.dart +++ b/dio/test/mocks/mock_transport.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:sentry/sentry.dart'; - import 'no_such_method_provider.dart'; class MockTransport with NoSuchMethodProvider implements Transport { diff --git a/flutter/android/build.gradle b/flutter/android/build.gradle index 26ceb5a576..e1f1d77dd5 100644 --- a/flutter/android/build.gradle +++ b/flutter/android/build.gradle @@ -54,6 +54,6 @@ android { } dependencies { - api 'io.sentry:sentry-android:5.7.0' + api 'io.sentry:sentry-android:6.0.0-alpha.6' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" } diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index 5c63b44b97..a83aa0b018 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -136,7 +136,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { val nativeCrashHandling = (args["enableNativeCrashHandling"] as? Boolean) ?: true // nativeCrashHandling has priority over anrEnabled if (!nativeCrashHandling) { - options.enableUncaughtExceptionHandler = false + options.setEnableUncaughtExceptionHandler(false) options.isAnrEnabled = false // if split symbols are enabled, we need Ndk integration so we can't really offer the option // to turn it off @@ -150,6 +150,8 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { } } + args.getIfNotNull("sendClientReports") { options.setSendClientReports(it) } + options.setBeforeSend { event, _ -> setEventOriginTag(event) addPackages(event, options.sdkVersion) diff --git a/flutter/ios/Classes/SentryFlutterPluginApple.swift b/flutter/ios/Classes/SentryFlutterPluginApple.swift index 191f4df1f8..9dc1cee239 100644 --- a/flutter/ios/Classes/SentryFlutterPluginApple.swift +++ b/flutter/ios/Classes/SentryFlutterPluginApple.swift @@ -261,6 +261,10 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { if let enableOutOfMemoryTracking = arguments["enableOutOfMemoryTracking"] as? Bool { options.enableOutOfMemoryTracking = enableOutOfMemoryTracking } + + if let sendClientReports = arguments["sendClientReports"] as? Bool { + options.sendClientReports = sendClientReports + } } private func logLevelFrom(diagnosticLevel: String) -> SentryLevel { diff --git a/flutter/ios/sentry_flutter.podspec b/flutter/ios/sentry_flutter.podspec index 4a6271986c..1c56bf8361 100644 --- a/flutter/ios/sentry_flutter.podspec +++ b/flutter/ios/sentry_flutter.podspec @@ -12,7 +12,7 @@ Sentry SDK for Flutter with support to native through sentry-cocoa. :tag => s.version.to_s } s.source_files = 'Classes/**/*' s.public_header_files = 'Classes/**/*.h' - s.dependency 'Sentry', '~> 7.11.0' + s.dependency 'Sentry', '~> 7.13.0' s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' s.ios.deployment_target = '9.0' diff --git a/flutter/lib/src/default_integrations.dart b/flutter/lib/src/default_integrations.dart index 3bff9cf850..0657555776 100644 --- a/flutter/lib/src/default_integrations.dart +++ b/flutter/lib/src/default_integrations.dart @@ -361,6 +361,7 @@ class NativeSdkIntegration extends Integration { 'enableOutOfMemoryTracking': options.enableOutOfMemoryTracking, 'enableNdkScopeSync': options.enableNdkScopeSync, 'enableAutoPerformanceTracking': options.enableAutoPerformanceTracking, + 'sendClientReports': options.sendClientReports, }); options.sdk.addIntegration('nativeSdkIntegration'); diff --git a/flutter/test/file_system_transport_test.dart b/flutter/test/file_system_transport_test.dart index 680deb073d..f458fb2203 100644 --- a/flutter/test/file_system_transport_test.dart +++ b/flutter/test/file_system_transport_test.dart @@ -96,8 +96,9 @@ void main() { } class Fixture { + final options = SentryOptions(dsn: ''); + FileSystemTransport getSut(MethodChannel channel) { - final options = SentryOptions(dsn: ''); return FileSystemTransport(channel, options); } } diff --git a/flutter/test/mocks.mocks.dart b/flutter/test/mocks.mocks.dart index 9bbe98bbcc..de35587b3a 100644 --- a/flutter/test/mocks.mocks.dart +++ b/flutter/test/mocks.mocks.dart @@ -1,5 +1,5 @@ // Mocks generated by Mockito 5.1.0 from annotations -// in sentry_flutter/example/windows/flutter/ephemeral/.plugin_symlinks/sentry_flutter/example/ios/.symlinks/plugins/sentry_flutter/test/mocks.dart. +// in sentry_flutter/example/ios/.symlinks/plugins/sentry_flutter/test/mocks.dart. // Do not manually edit this file. import 'dart:async' as _i6; diff --git a/flutter/test/native_sdk_integration_test.dart b/flutter/test/native_sdk_integration_test.dart index da46090439..cffd9254a5 100644 --- a/flutter/test/native_sdk_integration_test.dart +++ b/flutter/test/native_sdk_integration_test.dart @@ -52,6 +52,7 @@ void main() { 'enableOutOfMemoryTracking': true, 'enableNdkScopeSync': false, 'enableAutoPerformanceTracking': true, + 'sendClientReports': true }); }); @@ -83,7 +84,8 @@ void main() { ..sendDefaultPii = true ..enableOutOfMemoryTracking = false ..enableNdkScopeSync = true - ..enableAutoPerformanceTracking = false; + ..enableAutoPerformanceTracking = false + ..sendClientReports = false; options.sdk.addIntegration('foo'); options.sdk.addPackage('bar', '1'); @@ -119,6 +121,7 @@ void main() { 'enableOutOfMemoryTracking': false, 'enableNdkScopeSync': true, 'enableAutoPerformanceTracking': false, + 'sendClientReports': false }); });