diff --git a/CHANGELOG.md b/CHANGELOG.md index c7051a67b6..34de81de61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Features + +- Add request context to `HttpException`, `SocketException` and `NetworkImageLoadException` ([#1118](https://github.com/getsentry/sentry-dart/pull/1118)) +- `SocketException` and `FileSystemException` with `OSError`s report the `OSError` as root exception ([#1118](https://github.com/getsentry/sentry-dart/pull/1118)) + ### Fixes - VendorId should be a String ([#1112](https://github.com/getsentry/sentry-dart/pull/1112)) diff --git a/dart/lib/src/enricher/enricher_event_processor.dart b/dart/lib/src/enricher/enricher_event_processor.dart deleted file mode 100644 index 8674fe2512..0000000000 --- a/dart/lib/src/enricher/enricher_event_processor.dart +++ /dev/null @@ -1,8 +0,0 @@ -import '../event_processor.dart'; -import '../sentry_options.dart'; -import 'io_enricher_event_processor.dart' - if (dart.library.html) 'web_enricher_event_processor.dart'; - -EventProcessor getEnricherEventProcessor(SentryOptions options) { - return enricherEventProcessor(options); -} diff --git a/dart/lib/src/event_processor/enricher/enricher_event_processor.dart b/dart/lib/src/event_processor/enricher/enricher_event_processor.dart new file mode 100644 index 0000000000..78d19738bd --- /dev/null +++ b/dart/lib/src/event_processor/enricher/enricher_event_processor.dart @@ -0,0 +1,9 @@ +import '../../event_processor.dart'; +import '../../sentry_options.dart'; +import 'io_enricher_event_processor.dart' + if (dart.library.html) 'web_enricher_event_processor.dart'; + +abstract class EnricherEventProcessor implements EventProcessor { + factory EnricherEventProcessor(SentryOptions options) => + enricherEventProcessor(options); +} diff --git a/dart/lib/src/enricher/io_enricher_event_processor.dart b/dart/lib/src/event_processor/enricher/io_enricher_event_processor.dart similarity index 94% rename from dart/lib/src/enricher/io_enricher_event_processor.dart rename to dart/lib/src/event_processor/enricher/io_enricher_event_processor.dart index 8a8699f1e0..63a7c3117a 100644 --- a/dart/lib/src/enricher/io_enricher_event_processor.dart +++ b/dart/lib/src/event_processor/enricher/io_enricher_event_processor.dart @@ -2,18 +2,18 @@ import 'dart:async'; import 'dart:io'; import 'dart:isolate'; -import '../event_processor.dart'; -import '../protocol.dart'; -import '../sentry_options.dart'; +import '../../protocol.dart'; +import '../../sentry_options.dart'; +import 'enricher_event_processor.dart'; -EventProcessor enricherEventProcessor(SentryOptions options) { +EnricherEventProcessor enricherEventProcessor(SentryOptions options) { return IoEnricherEventProcessor(options); } /// Enriches [SentryEvents] with various kinds of information. /// Uses Darts [Platform](https://api.dart.dev/stable/dart-io/Platform-class.html) /// class to read information. -class IoEnricherEventProcessor extends EventProcessor { +class IoEnricherEventProcessor implements EnricherEventProcessor { IoEnricherEventProcessor( this._options, ); diff --git a/dart/lib/src/enricher/web_enricher_event_processor.dart b/dart/lib/src/event_processor/enricher/web_enricher_event_processor.dart similarity index 92% rename from dart/lib/src/enricher/web_enricher_event_processor.dart rename to dart/lib/src/event_processor/enricher/web_enricher_event_processor.dart index 5f2d7666da..245df52127 100644 --- a/dart/lib/src/enricher/web_enricher_event_processor.dart +++ b/dart/lib/src/event_processor/enricher/web_enricher_event_processor.dart @@ -1,18 +1,18 @@ import 'dart:async'; import 'dart:html' as html show window, Window; -import '../event_processor.dart'; -import '../protocol.dart'; -import '../sentry_options.dart'; +import '../../protocol.dart'; +import '../../sentry_options.dart'; +import 'enricher_event_processor.dart'; -EventProcessor enricherEventProcessor(SentryOptions options) { +EnricherEventProcessor enricherEventProcessor(SentryOptions options) { return WebEnricherEventProcessor( html.window, options, ); } -class WebEnricherEventProcessor extends EventProcessor { +class WebEnricherEventProcessor implements EnricherEventProcessor { WebEnricherEventProcessor( this._window, this._options, diff --git a/dart/lib/src/event_processor/exception/exception_event_processor.dart b/dart/lib/src/event_processor/exception/exception_event_processor.dart new file mode 100644 index 0000000000..e928f476f0 --- /dev/null +++ b/dart/lib/src/event_processor/exception/exception_event_processor.dart @@ -0,0 +1,9 @@ +import '../../event_processor.dart'; +import '../../sentry_options.dart'; +import 'io_exception_event_processor.dart' + if (dart.library.html) 'web_exception_event_processor.dart'; + +abstract class ExceptionEventProcessor implements EventProcessor { + factory ExceptionEventProcessor(SentryOptions options) => + exceptionEventProcessor(options); +} diff --git a/dart/lib/src/event_processor/exception/io_exception_event_processor.dart b/dart/lib/src/event_processor/exception/io_exception_event_processor.dart new file mode 100644 index 0000000000..9a31ff047c --- /dev/null +++ b/dart/lib/src/event_processor/exception/io_exception_event_processor.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import '../../protocol.dart'; +import '../../sentry_options.dart'; +import 'exception_event_processor.dart'; + +ExceptionEventProcessor exceptionEventProcessor(SentryOptions options) => + IoExceptionEventProcessor(options); + +class IoExceptionEventProcessor implements ExceptionEventProcessor { + IoExceptionEventProcessor(this._options); + + final SentryOptions _options; + + @override + SentryEvent apply(SentryEvent event, {dynamic hint}) { + final throwable = event.throwable; + if (throwable is HttpException) { + return _applyHttpException(throwable, event); + } + if (throwable is SocketException) { + return _applySocketException(throwable, event); + } + if (throwable is FileSystemException) { + return _applyFileSystemException(throwable, event); + } + + return event; + } + + // https://api.dart.dev/stable/dart-io/HttpException-class.html + SentryEvent _applyHttpException(HttpException exception, SentryEvent event) { + final uri = exception.uri; + if (uri == null) { + return event; + } + return event.copyWith( + request: event.request ?? SentryRequest.fromUri(uri: uri), + ); + } + + // https://api.dart.dev/stable/dart-io/SocketException-class.html + SentryEvent _applySocketException( + SocketException exception, + SentryEvent event, + ) { + final address = exception.address; + final osError = exception.osError; + if (address == null) { + return event.copyWith( + exceptions: [ + // OSError is the underlying error + // https://api.dart.dev/stable/dart-io/SocketException/osError.html + // https://api.dart.dev/stable/dart-io/OSError-class.html + if (osError != null) _sentryExceptionfromOsError(osError), + ...?event.exceptions, + ], + ); + } + SentryRequest? request; + try { + var uri = Uri.parse(address.host); + request = SentryRequest.fromUri(uri: uri); + } catch (exception, stackTrace) { + _options.logger( + SentryLevel.error, + 'Could not parse ${address.host} to Uri', + exception: exception, + stackTrace: stackTrace, + ); + } + + return event.copyWith( + request: event.request ?? request, + exceptions: [ + // OSError is the underlying error + // https://api.dart.dev/stable/dart-io/SocketException/osError.html + // https://api.dart.dev/stable/dart-io/OSError-class.html + if (osError != null) _sentryExceptionfromOsError(osError), + ...?event.exceptions, + ], + ); + } + + // https://api.dart.dev/stable/dart-io/FileSystemException-class.html + SentryEvent _applyFileSystemException( + FileSystemException exception, + SentryEvent event, + ) { + final osError = exception.osError; + return event.copyWith( + exceptions: [ + // OSError is the underlying error + // https://api.dart.dev/stable/dart-io/FileSystemException/osError.html + // https://api.dart.dev/stable/dart-io/OSError-class.html + if (osError != null) _sentryExceptionfromOsError(osError), + ...?event.exceptions, + ], + ); + } +} + +SentryException _sentryExceptionfromOsError(OSError osError) { + return SentryException( + type: osError.runtimeType.toString(), + value: osError.toString(), + // osError.errorCode is likely a posix signal + // https://develop.sentry.dev/sdk/event-payloads/types/#mechanismmeta + mechanism: Mechanism( + type: 'OSError', + meta: { + 'errno': {'number': osError.errorCode}, + }, + ), + ); +} diff --git a/dart/lib/src/event_processor/exception/web_exception_event_processor.dart b/dart/lib/src/event_processor/exception/web_exception_event_processor.dart new file mode 100644 index 0000000000..c5cda3df50 --- /dev/null +++ b/dart/lib/src/event_processor/exception/web_exception_event_processor.dart @@ -0,0 +1,11 @@ +import '../../protocol.dart'; +import '../../sentry_options.dart'; +import 'exception_event_processor.dart'; + +ExceptionEventProcessor exceptionEventProcessor(SentryOptions _) => + WebExcptionEventProcessor(); + +class WebExcptionEventProcessor implements ExceptionEventProcessor { + @override + SentryEvent apply(SentryEvent event, {dynamic hint}) => event; +} diff --git a/dart/lib/src/http_client/failed_request_client.dart b/dart/lib/src/http_client/failed_request_client.dart index 964221ea4a..13ef3ca691 100644 --- a/dart/lib/src/http_client/failed_request_client.dart +++ b/dart/lib/src/http_client/failed_request_client.dart @@ -154,29 +154,16 @@ class FailedRequestClient extends BaseClient { required BaseRequest request, required StreamedResponse? response, }) async { - // As far as I can tell there's no way to get the uri without the query part - // so we replace it with an empty string. - final urlWithoutQuery = request.url - .replace(query: '', fragment: '') - .toString() - .replaceAll('?', '') - .replaceAll('#', ''); - - final query = request.url.query.isEmpty ? null : request.url.query; - final fragment = request.url.fragment.isEmpty ? null : request.url.fragment; - - final sentryRequest = SentryRequest( + final sentryRequest = SentryRequest.fromUri( method: request.method, headers: sendDefaultPii ? request.headers : null, - url: urlWithoutQuery, - queryString: query, + uri: request.url, data: sendDefaultPii ? _getDataFromRequest(request) : null, // ignore: deprecated_member_use_from_same_package other: { 'content_length': request.contentLength.toString(), 'duration': requestDuration.toString(), }, - fragment: fragment, ); final mechanism = Mechanism( diff --git a/dart/lib/src/protocol/sentry_request.dart b/dart/lib/src/protocol/sentry_request.dart index 157c97775f..7ae0f67406 100644 --- a/dart/lib/src/protocol/sentry_request.dart +++ b/dart/lib/src/protocol/sentry_request.dart @@ -81,6 +81,42 @@ class SentryRequest { _env = env != null ? Map.from(env) : null, _other = other != null ? Map.from(other) : null; + factory SentryRequest.fromUri({ + required Uri uri, + String? method, + String? cookies, + dynamic data, + Map? headers, + Map? env, + @Deprecated('Will be removed in v7.') Map? other, + }) { + // As far as I can tell there's no way to get the uri without the query part + // so we replace it with an empty string. + final urlWithoutQuery = uri + .replace(query: '', fragment: '') + .toString() + .replaceAll('?', '') + .replaceAll('#', ''); + + // Future proof, Dio does not support it yet and even if passing in the path, + // the parsing of the uri returns empty. + final query = uri.query.isEmpty ? null : uri.query; + final fragment = uri.fragment.isEmpty ? null : uri.fragment; + + return SentryRequest( + url: urlWithoutQuery, + fragment: fragment, + queryString: query, + method: method, + cookies: cookies, + data: data, + headers: headers, + env: env, + // ignore: deprecated_member_use_from_same_package + other: other, + ); + } + /// Deserializes a [SentryRequest] from JSON [Map]. factory SentryRequest.fromJson(Map json) { return SentryRequest( diff --git a/dart/lib/src/sentry.dart b/dart/lib/src/sentry.dart index 8314a37d3d..84c9f1dfd2 100644 --- a/dart/lib/src/sentry.dart +++ b/dart/lib/src/sentry.dart @@ -3,9 +3,10 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'default_integrations.dart'; -import 'enricher/enricher_event_processor.dart'; +import 'event_processor/enricher/enricher_event_processor.dart'; import 'environment/environment_variables.dart'; import 'event_processor/deduplication_event_processor.dart'; +import 'event_processor/exception/exception_event_processor.dart'; import 'hub.dart'; import 'hub_adapter.dart'; import 'integration.dart'; @@ -73,7 +74,8 @@ class Sentry { options.addIntegrationByIndex(0, IsolateErrorIntegration()); } - options.addEventProcessor(getEnricherEventProcessor(options)); + options.addEventProcessor(EnricherEventProcessor(options)); + options.addEventProcessor(ExceptionEventProcessor(options)); options.addEventProcessor(DeduplicationEventProcessor(options)); } diff --git a/dart/test/enricher/io_enricher_test.dart b/dart/test/event_processor/enricher/io_enricher_test.dart similarity index 97% rename from dart/test/enricher/io_enricher_test.dart rename to dart/test/event_processor/enricher/io_enricher_test.dart index ecd6211b68..6255405fed 100644 --- a/dart/test/enricher/io_enricher_test.dart +++ b/dart/test/event_processor/enricher/io_enricher_test.dart @@ -1,11 +1,11 @@ @TestOn('vm') import 'package:sentry/sentry.dart'; -import 'package:sentry/src/enricher/io_enricher_event_processor.dart'; +import 'package:sentry/src/event_processor/enricher/io_enricher_event_processor.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; -import '../mocks/mock_platform_checker.dart'; +import '../../mocks.dart'; +import '../../mocks/mock_platform_checker.dart'; void main() { group('io_enricher', () { diff --git a/dart/test/enricher/web_enricher_test.dart b/dart/test/event_processor/enricher/web_enricher_test.dart similarity index 97% rename from dart/test/enricher/web_enricher_test.dart rename to dart/test/event_processor/enricher/web_enricher_test.dart index 30cf3338cb..be44614345 100644 --- a/dart/test/enricher/web_enricher_test.dart +++ b/dart/test/event_processor/enricher/web_enricher_test.dart @@ -2,11 +2,11 @@ import 'dart:html' as html; import 'package:sentry/sentry.dart'; -import 'package:sentry/src/enricher/web_enricher_event_processor.dart'; +import 'package:sentry/src/event_processor/enricher/web_enricher_event_processor.dart'; import 'package:test/test.dart'; -import '../mocks.dart'; -import '../mocks/mock_platform_checker.dart'; +import '../../mocks.dart'; +import '../../mocks/mock_platform_checker.dart'; // can be tested on command line with // `dart test -p chrome --name web_enricher` diff --git a/dart/test/event_processor/exception/io_exception_event_processor_test.dart b/dart/test/event_processor/exception/io_exception_event_processor_test.dart new file mode 100644 index 0000000000..cdc6e0ff91 --- /dev/null +++ b/dart/test/event_processor/exception/io_exception_event_processor_test.dart @@ -0,0 +1,103 @@ +@TestOn('vm') + +import 'dart:io'; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/event_processor/exception/io_exception_event_processor.dart'; +import 'package:test/test.dart'; + +void main() { + group(IoExceptionEventProcessor, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('adds $SentryRequest for $HttpException with uris', () async { + final enricher = fixture.getSut(); + final event = enricher.apply( + SentryEvent( + throwable: HttpException( + '', + uri: Uri.parse('https://example.org/foo/bar?foo=bar'), + ), + ), + ); + + expect(event.request, isNotNull); + expect(event.request?.url, 'https://example.org/foo/bar'); + expect(event.request?.queryString, 'foo=bar'); + }); + + test('no $SentryRequest for $HttpException without uris', () async { + final enricher = fixture.getSut(); + final event = enricher.apply( + SentryEvent( + throwable: HttpException(''), + ), + ); + + expect(event.request, isNull); + }); + + test('adds $SentryRequest for $SocketException with addresses', () async { + final enricher = fixture.getSut(); + final event = enricher.apply( + SentryEvent( + throwable: SocketException( + 'Exception while connecting', + osError: OSError('Connection reset by peer', 54), + port: 12345, + address: InternetAddress( + '127.0.0.1', + type: InternetAddressType.IPv4, + ), + ), + ), + ); + + expect(event.request, isNotNull); + expect(event.request?.url, '127.0.0.1'); + + // Due to the test setup, there's no SentryException for the SocketException. + // And thus only one entry for the added OSError + expect(event.exceptions?.first.type, 'OSError'); + expect( + event.exceptions?.first.value, + 'OS Error: Connection reset by peer, errno = 54', + ); + expect(event.exceptions?.first.mechanism?.type, 'OSError'); + expect(event.exceptions?.first.mechanism?.meta['errno']['number'], 54); + }); + + test('adds OSError SentryException for $FileSystemException', () async { + final enricher = fixture.getSut(); + final event = enricher.apply( + SentryEvent( + throwable: FileSystemException( + 'message', + 'path', + OSError('Oh no :(', 42), + ), + ), + ); + + // Due to the test setup, there's no SentryException for the FileSystemException. + // And thus only one entry for the added OSError + expect(event.exceptions?.first.type, 'OSError'); + expect( + event.exceptions?.first.value, + 'OS Error: Oh no :(, errno = 42', + ); + expect(event.exceptions?.first.mechanism?.type, 'OSError'); + expect(event.exceptions?.first.mechanism?.meta['errno']['number'], 42); + }); + }); +} + +class Fixture { + IoExceptionEventProcessor getSut() { + return IoExceptionEventProcessor(SentryOptions.empty()); + } +} diff --git a/dart/test/protocol/sentry_request_test.dart b/dart/test/protocol/sentry_request_test.dart index 394da9119d..6e8342b51d 100644 --- a/dart/test/protocol/sentry_request_test.dart +++ b/dart/test/protocol/sentry_request_test.dart @@ -76,4 +76,14 @@ void main() { expect({'key1': 'value1'}, copy.data); }); }); + + test('SentryRequest.fromUri', () { + final request = SentryRequest.fromUri( + uri: Uri.parse('https://example.org/foo/bar?key=value#fragment'), + ); + + expect(request.url, 'https://example.org/foo/bar'); + expect(request.fragment, 'fragment'); + expect(request.queryString, 'key=value'); + }); } diff --git a/dio/lib/src/dio_event_processor.dart b/dio/lib/src/dio_event_processor.dart index 115bcd0954..a93e82f26c 100644 --- a/dio/lib/src/dio_event_processor.dart +++ b/dio/lib/src/dio_event_processor.dart @@ -117,30 +117,14 @@ class DioEventProcessor implements EventProcessor { SentryRequest? _requestFrom(DioError dioError) { final options = dioError.requestOptions; - // As far as I can tell there's no way to get the uri without the query part - // so we replace it with an empty string. - final urlWithoutQuery = options.uri - .replace(query: '', fragment: '') - .toString() - .replaceAll('?', '') - .replaceAll('#', ''); - - final query = options.uri.query.isEmpty ? null : options.uri.query; - - // future proof, Dio does not support it yet and even if passing in the path, - // the parsing of the uri returns empty. - final fragment = options.uri.fragment.isEmpty ? null : options.uri.fragment; - final headers = options.headers .map((key, dynamic value) => MapEntry(key, value?.toString() ?? '')); - return SentryRequest( + return SentryRequest.fromUri( + uri: options.uri, method: options.method, headers: _options.sendDefaultPii ? headers : null, - url: urlWithoutQuery, - queryString: query, data: _getRequestData(dioError.requestOptions.data), - fragment: fragment, ); } diff --git a/flutter/lib/src/event_processor/flutter_exception_event_processor.dart b/flutter/lib/src/event_processor/flutter_exception_event_processor.dart new file mode 100644 index 0000000000..94d9cb55c4 --- /dev/null +++ b/flutter/lib/src/event_processor/flutter_exception_event_processor.dart @@ -0,0 +1,27 @@ +import 'package:flutter/rendering.dart'; +import 'package:sentry/sentry.dart'; + +class FlutterExceptionEventProcessor implements EventProcessor { + @override + SentryEvent apply(SentryEvent event, {dynamic hint}) { + final exception = event.throwable; + if (exception is NetworkImageLoadException) { + return _applyNetworkImageLoadException(event, exception); + } + return event; + } + + /// https://api.flutter.dev/flutter/painting/NetworkImageLoadException-class.html + SentryEvent _applyNetworkImageLoadException( + SentryEvent event, + NetworkImageLoadException exception, + ) { + return event.copyWith( + request: event.request ?? SentryRequest.fromUri(uri: exception.uri), + contexts: event.contexts.copyWith( + response: event.contexts.response ?? + SentryResponse(statusCode: exception.statusCode), + ), + ); + } +} diff --git a/flutter/lib/src/sentry_flutter.dart b/flutter/lib/src/sentry_flutter.dart index fa83d58e47..73067cc0e7 100644 --- a/flutter/lib/src/sentry_flutter.dart +++ b/flutter/lib/src/sentry_flutter.dart @@ -7,6 +7,7 @@ import 'package:meta/meta.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../sentry_flutter.dart'; import 'event_processor/android_platform_exception_event_processor.dart'; +import 'event_processor/flutter_exception_event_processor.dart'; import 'integrations/screenshot_integration.dart'; import 'native_scope_observer.dart'; import 'renderer/renderer.dart'; @@ -83,6 +84,8 @@ mixin SentryFlutter { SentryFlutterOptions options, MethodChannel channel, ) async { + options.addEventProcessor(FlutterExceptionEventProcessor()); + // Not all platforms have a native integration. if (options.platformChecker.hasNativeIntegration) { options.transport = FileSystemTransport(channel, options); diff --git a/flutter/test/event_processor/flutter_exception_event_processor_test.dart b/flutter/test/event_processor/flutter_exception_event_processor_test.dart new file mode 100644 index 0000000000..66854820df --- /dev/null +++ b/flutter/test/event_processor/flutter_exception_event_processor_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_flutter/src/event_processor/flutter_exception_event_processor.dart'; + +void main() { + group(FlutterExceptionEventProcessor, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('adds $SentryRequest for $NetworkImageLoadException with uris', + () async { + final enricher = fixture.getSut(); + final event = enricher.apply( + SentryEvent( + throwable: NetworkImageLoadException( + statusCode: 401, + uri: Uri.parse('https://example.org/foo/bar?foo=bar'), + ), + ), + ); + + expect(event.request, isNotNull); + expect(event.request?.url, 'https://example.org/foo/bar'); + expect(event.request?.queryString, 'foo=bar'); + }); + }); +} + +class Fixture { + FlutterExceptionEventProcessor getSut() { + return FlutterExceptionEventProcessor(); + } +}