diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bc53aeb35..8741a1f032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Feat: Enrich events with more context (#452) * Feat: Add Culture Context (#491) +* Feat: Capture failed requests as event (#473) * Feat: `beforeSend` callback accepts async code (#494) ## Breaking Changes: diff --git a/dart/lib/src/http_client/breadcrumb_client.dart b/dart/lib/src/http_client/breadcrumb_client.dart new file mode 100644 index 0000000000..f7066502d4 --- /dev/null +++ b/dart/lib/src/http_client/breadcrumb_client.dart @@ -0,0 +1,94 @@ +import 'package:http/http.dart'; +import '../protocol.dart'; +import '../hub.dart'; +import '../hub_adapter.dart'; + +/// A [http](https://pub.dev/packages/http)-package compatible HTTP client +/// which records requests as breadcrumbs. +/// +/// Remarks: +/// If this client is used as a wrapper, a call to close also closes the +/// given client. +/// +/// The `BreadcrumbClient` can be used as a standalone client like this: +/// ```dart +/// import 'package:sentry/sentry.dart'; +/// +/// var client = BreadcrumbClient(); +/// try { +/// var uriResponse = await client.post('https://example.com/whatsit/create', +/// body: {'name': 'doodle', 'color': 'blue'}); +/// print(await client.get(uriResponse.bodyFields['uri'])); +/// } finally { +/// client.close(); +/// } +/// ``` +/// +/// The `BreadcrumbClient` can also be used as a wrapper for your own +/// HTTP [Client](https://pub.dev/documentation/http/latest/http/Client-class.html): +/// ```dart +/// import 'package:sentry/sentry.dart'; +/// import 'package:http/http.dart' as http; +/// +/// final myClient = http.Client(); +/// +/// var client = BreadcrumbClient(client: myClient); +/// try { +/// var uriResponse = await client.post('https://example.com/whatsit/create', +/// body: {'name': 'doodle', 'color': 'blue'}); +/// print(await client.get(uriResponse.bodyFields['uri'])); +/// } finally { +/// client.close(); +/// } +/// ``` +class BreadcrumbClient extends BaseClient { + BreadcrumbClient({Client? client, Hub? hub}) + : _hub = hub ?? HubAdapter(), + _client = client ?? Client(); + + final Client _client; + final Hub _hub; + + @override + Future send(BaseRequest request) async { + // See https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/ + + var requestHadException = false; + int? statusCode; + String? reason; + + final stopwatch = Stopwatch(); + stopwatch.start(); + + try { + final response = await _client.send(request); + + statusCode = response.statusCode; + reason = response.reasonPhrase; + + return response; + } catch (_) { + requestHadException = true; + rethrow; + } finally { + stopwatch.stop(); + + var breadcrumb = Breadcrumb.http( + level: requestHadException ? SentryLevel.error : SentryLevel.info, + url: request.url, + method: request.method, + statusCode: statusCode, + reason: reason, + requestDuration: stopwatch.elapsed, + ); + + _hub.addBreadcrumb(breadcrumb); + } + } + + @override + void close() { + // See https://github.com/getsentry/sentry-dart/pull/226#discussion_r536984785 + _client.close(); + } +} diff --git a/dart/lib/src/http_client/failed_request_client.dart b/dart/lib/src/http_client/failed_request_client.dart new file mode 100644 index 0000000000..b72df6f680 --- /dev/null +++ b/dart/lib/src/http_client/failed_request_client.dart @@ -0,0 +1,241 @@ +import 'package:http/http.dart'; +import '../protocol.dart'; +import '../hub.dart'; +import '../hub_adapter.dart'; +import '../throwable_mechanism.dart'; +import 'sentry_http_client.dart'; + +/// A [http](https://pub.dev/packages/http)-package compatible HTTP client +/// which records events for failed requests. +/// +/// Configured with default values, this captures requests which throw an +/// exception. +/// This can be for example for the following reasons: +/// - In an browser environment this can be requests which fail because of CORS. +/// - In an mobile or desktop application this can be requests which failed +/// because the connection was interrupted. +/// +/// Additionally you can configure specific HTTP response codes to be considered +/// as a failed request. In the following example, the status codes 404 and 500 +/// are considered a failed request. +/// +/// ```dart +/// import 'package:sentry/sentry.dart'; +/// +/// var client = FailedRequestClient( +/// failedRequestStatusCodes: [SentryStatusCode.range(400, 404), SentryStatusCode(500)] +/// ); +/// ``` +/// +/// Remarks: +/// If this client is used as a wrapper, a call to close also closes the +/// given client. +/// +/// The `FailedRequestClient` can be used as a standalone client like this: +/// ```dart +/// import 'package:sentry/sentry.dart'; +/// +/// var client = FailedRequestClient(); +/// try { +/// var uriResponse = await client.post('https://example.com/whatsit/create', +/// body: {'name': 'doodle', 'color': 'blue'}); +/// print(await client.get(uriResponse.bodyFields['uri'])); +/// } finally { +/// client.close(); +/// } +/// ``` +/// +/// The `FailedRequestClient` can also be used as a wrapper for your own +/// HTTP [Client](https://pub.dev/documentation/http/latest/http/Client-class.html): +/// ```dart +/// import 'package:sentry/sentry.dart'; +/// import 'package:http/http.dart' as http; +/// +/// final myClient = http.Client(); +/// +/// var client = FailedRequestClient(client: myClient); +/// try { +/// var uriResponse = await client.post('https://example.com/whatsit/create', +/// body: {'name': 'doodle', 'color': 'blue'}); +/// print(await client.get(uriResponse.bodyFields['uri'])); +/// } finally { +/// client.close(); +/// } +/// ``` +class FailedRequestClient extends BaseClient { + FailedRequestClient({ + this.maxRequestBodySize = MaxRequestBodySize.small, + this.failedRequestStatusCodes = const [], + this.captureFailedRequests = true, + this.sendDefaultPii = false, + Client? client, + Hub? hub, + }) : _hub = hub ?? HubAdapter(), + _client = client ?? Client(); + + final Client _client; + final Hub _hub; + + /// Configures wether to record exceptions for failed requests. + /// Examples for captures exceptions are: + /// - In an browser environment this can be requests which fail because of CORS. + /// - In an mobile or desktop application this can be requests which failed + /// because the connection was interrupted. + final bool captureFailedRequests; + + /// Configures up to which size request bodies should be included in events. + /// This does not change wether an event is captured. + final MaxRequestBodySize maxRequestBodySize; + + /// Describes which HTTP status codes should be considered as a failed + /// requests. + /// + /// Per default no status code is considered a failed request. + final List failedRequestStatusCodes; + + final bool sendDefaultPii; + + @override + Future send(BaseRequest request) async { + int? statusCode; + Object? exception; + StackTrace? stackTrace; + + final stopwatch = Stopwatch(); + stopwatch.start(); + + try { + final response = await _client.send(request); + statusCode = response.statusCode; + return response; + } catch (e, st) { + exception = e; + stackTrace = st; + rethrow; + } finally { + stopwatch.stop(); + + // If captureFailedRequests is true, there statusCode is null. + // So just one of these blocks can be called. + + if (captureFailedRequests && exception != null) { + await _captureEvent( + exception: exception, + stackTrace: stackTrace, + request: request, + requestDuration: stopwatch.elapsed, + ); + } else if (failedRequestStatusCodes.containsStatusCode(statusCode)) { + // Capture an exception if the status code is considered bad + await _captureEvent( + request: request, + reason: failedRequestStatusCodes.toDescription(), + requestDuration: stopwatch.elapsed, + ); + } + } + } + + @override + void close() { + // See https://github.com/getsentry/sentry-dart/pull/226#discussion_r536984785 + _client.close(); + } + + // See https://develop.sentry.dev/sdk/event-payloads/request/ + Future _captureEvent({ + Object? exception, + StackTrace? stackTrace, + String? reason, + required Duration requestDuration, + required BaseRequest request, + }) { + // 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: '').toString(); + + final query = request.url.query.isEmpty ? null : request.url.query; + + final sentryRequest = SentryRequest( + method: request.method, + headers: sendDefaultPii ? request.headers : null, + url: urlWithoutQuery, + queryString: query, + cookies: sendDefaultPii ? request.headers['Cookie'] : null, + data: _getDataFromRequest(request), + other: { + 'content_length': request.contentLength.toString(), + 'duration': requestDuration.toString(), + }, + ); + + final mechanism = Mechanism( + type: 'SentryHttpClient', + description: reason, + ); + final throwableMechanism = ThrowableMechanism(mechanism, exception); + + final event = SentryEvent( + throwable: throwableMechanism, + request: sentryRequest, + ); + return _hub.captureEvent(event, stackTrace: stackTrace); + } + + // Types of Request can be found here: + // https://pub.dev/documentation/http/latest/http/http-library.html + Object? _getDataFromRequest(BaseRequest request) { + final contentLength = request.contentLength; + if (contentLength == null) { + return null; + } + if (!maxRequestBodySize.shouldAddBody(contentLength)) { + return null; + } + if (request is MultipartRequest) { + final data = {...request.fields}; + return data; + } + + if (request is Request) { + return request.body; + } + + // There's nothing we can do for a StreamedRequest + return null; + } +} + +extension _ListX on List { + bool containsStatusCode(int? statusCode) { + if (statusCode == null) { + return false; + } + return any((element) => element.isInRange(statusCode)); + } + + String toDescription() { + final ranges = join(', '); + return 'This event was captured because the ' + 'request status code was in [$ranges]'; + } +} + +extension _MaxRequestBodySizeX on MaxRequestBodySize { + bool shouldAddBody(int contentLength) { + if (this == MaxRequestBodySize.never) { + return false; + } + if (this == MaxRequestBodySize.always) { + return true; + } + if (this == MaxRequestBodySize.medium && contentLength <= 10000) { + return true; + } + + if (this == MaxRequestBodySize.small && contentLength <= 4000) { + return true; + } + return false; + } +} diff --git a/dart/lib/src/http_client/sentry_http_client.dart b/dart/lib/src/http_client/sentry_http_client.dart index a720b3b730..6c18f3a233 100644 --- a/dart/lib/src/http_client/sentry_http_client.dart +++ b/dart/lib/src/http_client/sentry_http_client.dart @@ -1,15 +1,39 @@ import 'package:http/http.dart'; -import '../protocol/sentry_level.dart'; -import '../protocol/breadcrumb.dart'; import '../hub.dart'; import '../hub_adapter.dart'; +import '../protocol.dart'; +import 'breadcrumb_client.dart'; +import 'failed_request_client.dart'; -/// A [http](https://pub.dev/packages/http)-package compatible HTTP client -/// which records requests as breadcrumbs. +/// A [http](https://pub.dev/packages/http)-package compatible HTTP client. /// -/// Remarks: -/// If this client is used as a wrapper, a call to close also closes the -/// given client. +/// It can record requests as breadcrumbs. This is on by default. +/// +/// It can also capture requests which throw an exception. This is off by +/// default, set [captureFailedRequests] to `true` to enable it. This can be for +/// example for the following reasons: +/// - In an browser environment this can be requests which fail because of CORS. +/// - In an mobile or desktop application this can be requests which failed +/// because the connection was interrupted. +/// +/// Additionally you can configure specific HTTP response codes to be considered +/// as a failed request. This is off by default. Enable it by using it like +/// shown in the following example: +/// The status codes 400 to 404 and 500 are considered a failed request. +/// +/// ```dart +/// import 'package:sentry/sentry.dart'; +/// +/// var client = SentryHttpClient( +/// failedRequestStatusCodes: [ +/// SentryStatusCode.range(400, 404), +/// SentryStatusCode(500), +/// ], +/// ); +/// ``` +/// +/// Remarks: If this client is used as a wrapper, a call to close also closes +/// the given client. /// /// The `SentryHttpClient` can be used as a standalone client like this: /// ```dart @@ -25,8 +49,8 @@ import '../hub_adapter.dart'; /// } /// ``` /// -/// The `SentryHttpClient` can also be used as a wrapper for your own -/// HTTP [Client](https://pub.dev/documentation/http/latest/http/Client-class.html): +/// The `SentryHttpClient` can also be used as a wrapper for your own HTTP +/// [Client](https://pub.dev/documentation/http/latest/http/Client-class.html): /// ```dart /// import 'package:sentry/sentry.dart'; /// import 'package:http/http.dart' as http; @@ -41,59 +65,75 @@ import '../hub_adapter.dart'; /// } finally { /// client.close(); /// } +/// +/// Remarks: +/// HTTP traffic can contain PII (personal identifiable information). +/// Read more on data scrubbing [here](https://docs.sentry.io/product/data-management-settings/advanced-datascrubbing/). /// ``` class SentryHttpClient extends BaseClient { - SentryHttpClient({Client? client, Hub? hub}) - : _hub = hub ?? HubAdapter(), - _client = client ?? Client(); + SentryHttpClient({ + Client? client, + Hub? hub, + bool recordBreadcrumbs = true, + MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.never, + List failedRequestStatusCodes = const [], + bool captureFailedRequests = false, + bool sendDefaultPii = false, + }) { + _hub = hub ?? HubAdapter(); - final Client _client; - final Hub _hub; + var innerClient = client ?? Client(); - @override - Future send(BaseRequest request) async { - // See https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/ + innerClient = FailedRequestClient( + failedRequestStatusCodes: failedRequestStatusCodes, + captureFailedRequests: captureFailedRequests, + maxRequestBodySize: maxRequestBodySize, + sendDefaultPii: sendDefaultPii, + hub: _hub, + client: innerClient, + ); - var requestHadException = false; - int? statusCode; - String? reason; + // The ordering here matters. + // We don't want to include the breadcrumbs for the current request + // when capturing it as a failed request. + // However it still should be added for following events. + if (recordBreadcrumbs) { + innerClient = BreadcrumbClient(client: innerClient, hub: _hub); + } - final stopwatch = Stopwatch(); - stopwatch.start(); + _client = innerClient; + } - try { - final response = await _client.send(request); + late Client _client; + late Hub _hub; - statusCode = response.statusCode; - reason = response.reasonPhrase; + @override + Future send(BaseRequest request) => _client.send(request); - return response; - } catch (_) { - requestHadException = true; - rethrow; - } finally { - stopwatch.stop(); + @override + void close() => _client.close(); +} - var breadcrumb = Breadcrumb( - level: requestHadException ? SentryLevel.error : SentryLevel.info, - type: 'http', - category: 'http', - data: { - 'url': request.url.toString(), - 'method': request.method, - if (statusCode != null) 'status_code': statusCode, - if (reason != null) 'reason': reason, - 'duration': stopwatch.elapsed.toString(), - }, - ); +class SentryStatusCode { + SentryStatusCode.range(this._min, this._max) + : assert(_min <= _max), + assert(_min > 0 && _max > 0); - _hub.addBreadcrumb(breadcrumb); - } - } + SentryStatusCode(int statusCode) + : _min = statusCode, + _max = statusCode, + assert(statusCode > 0); + + final int _min; + final int _max; + + bool isInRange(int statusCode) => statusCode >= _min && statusCode <= _max; @override - void close() { - // See https://github.com/getsentry/sentry-dart/pull/226#discussion_r536984785 - _client.close(); + String toString() { + if (_min == _max) { + return _min.toString(); + } + return '$_min..$_max'; } } diff --git a/dart/lib/src/protocol.dart b/dart/lib/src/protocol.dart index 0b8d7af9b4..163b7a656b 100644 --- a/dart/lib/src/protocol.dart +++ b/dart/lib/src/protocol.dart @@ -22,4 +22,5 @@ export 'protocol/sentry_runtime.dart'; export 'protocol/sentry_stack_frame.dart'; export 'protocol/sentry_stack_trace.dart'; export 'protocol/sentry_user.dart'; +export 'protocol/max_request_body_size.dart'; export 'protocol/sentry_culture.dart'; diff --git a/dart/lib/src/protocol/breadcrumb.dart b/dart/lib/src/protocol/breadcrumb.dart index cbbfa7cbd7..18ee0726b7 100644 --- a/dart/lib/src/protocol/breadcrumb.dart +++ b/dart/lib/src/protocol/breadcrumb.dart @@ -32,6 +32,30 @@ class Breadcrumb { }) : timestamp = timestamp ?? getUtcDateTime(), level = level ?? SentryLevel.info; + factory Breadcrumb.http({ + required Uri url, + required String method, + int? statusCode, + String? reason, + Duration? requestDuration, + SentryLevel? level, + DateTime? timestamp, + }) { + return Breadcrumb( + type: 'http', + category: 'http', + level: level, + timestamp: timestamp, + data: { + 'url': url.toString(), + 'method': method, + if (statusCode != null) 'status_code': statusCode, + if (reason != null) 'reason': reason, + if (requestDuration != null) 'duration': requestDuration.toString(), + }, + ); + } + /// Describes the breadcrumb. /// /// This field is optional and may be set to null. diff --git a/dart/lib/src/protocol/max_request_body_size.dart b/dart/lib/src/protocol/max_request_body_size.dart new file mode 100644 index 0000000000..f69ba57429 --- /dev/null +++ b/dart/lib/src/protocol/max_request_body_size.dart @@ -0,0 +1,17 @@ +// See https://docs.sentry.io/platforms/dotnet/guides/aspnetcore/configuration/options/#max-request-body-size +/// Describes the size of http request bodies which should be added to an event +enum MaxRequestBodySize { + /// Request bodies are never sent + never, + + /// Only small request bodies will be captured where the cutoff for small + /// depends on the SDK (typically 4KB) + small, + + /// Medium and small requests will be captured (typically 10KB) + medium, + + /// The SDK will always capture the request body for as long as Sentry can + /// make sense of it + always, +} diff --git a/dart/test/http_client/breadcrumb_client_test.dart b/dart/test/http_client/breadcrumb_client_test.dart new file mode 100644 index 0000000000..89a3331a5c --- /dev/null +++ b/dart/test/http_client/breadcrumb_client_test.dart @@ -0,0 +1,216 @@ +import 'dart:io'; + +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/http_client/breadcrumb_client.dart'; +import 'package:test/test.dart'; + +import '../mocks/mock_hub.dart'; + +final requestUri = Uri.parse('https://example.com'); + +void main() { + group(BreadcrumbClient, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('GET: happy path', () async { + final sut = + fixture.getSut(fixture.getClient(statusCode: 200, reason: 'OK')); + + final response = await sut.get(requestUri); + expect(response.statusCode, 200); + + expect(fixture.hub.addBreadcrumbCalls.length, 1); + final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; + + expect(breadcrumb.type, 'http'); + expect(breadcrumb.data?['url'], 'https://example.com'); + expect(breadcrumb.data?['method'], 'GET'); + expect(breadcrumb.data?['status_code'], 200); + expect(breadcrumb.data?['reason'], 'OK'); + expect(breadcrumb.data?['duration'], isNotNull); + }); + + test('GET: happy path for 404', () async { + final sut = fixture + .getSut(fixture.getClient(statusCode: 404, reason: 'NOT FOUND')); + + final response = await sut.get(requestUri); + + expect(response.statusCode, 404); + + expect(fixture.hub.addBreadcrumbCalls.length, 1); + final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; + + expect(breadcrumb.type, 'http'); + expect(breadcrumb.data?['url'], 'https://example.com'); + expect(breadcrumb.data?['method'], 'GET'); + expect(breadcrumb.data?['status_code'], 404); + expect(breadcrumb.data?['reason'], 'NOT FOUND'); + expect(breadcrumb.data?['duration'], isNotNull); + }); + + test('POST: happy path', () async { + final sut = fixture.getSut(fixture.getClient(statusCode: 200)); + + final response = await sut.post(requestUri); + expect(response.statusCode, 200); + + expect(fixture.hub.addBreadcrumbCalls.length, 1); + final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; + + expect(breadcrumb.type, 'http'); + expect(breadcrumb.data?['url'], 'https://example.com'); + expect(breadcrumb.data?['method'], 'POST'); + expect(breadcrumb.data?['status_code'], 200); + expect(breadcrumb.data?['duration'], isNotNull); + }); + + test('PUT: happy path', () async { + final sut = fixture.getSut(fixture.getClient(statusCode: 200)); + + final response = await sut.put(requestUri); + expect(response.statusCode, 200); + + expect(fixture.hub.addBreadcrumbCalls.length, 1); + final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; + + expect(breadcrumb.type, 'http'); + expect(breadcrumb.data?['url'], 'https://example.com'); + expect(breadcrumb.data?['method'], 'PUT'); + expect(breadcrumb.data?['status_code'], 200); + expect(breadcrumb.data?['duration'], isNotNull); + }); + + test('DELETE: happy path', () async { + final sut = fixture.getSut(fixture.getClient(statusCode: 200)); + + final response = await sut.delete(requestUri); + expect(response.statusCode, 200); + + expect(fixture.hub.addBreadcrumbCalls.length, 1); + final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; + + expect(breadcrumb.type, 'http'); + expect(breadcrumb.data?['url'], 'https://example.com'); + expect(breadcrumb.data?['method'], 'DELETE'); + expect(breadcrumb.data?['status_code'], 200); + expect(breadcrumb.data?['duration'], isNotNull); + }); + + /// Tests, that in case an exception gets thrown, that + /// no exception gets reported by Sentry, in case the user wants to + /// handle the exception + test('no captureException for ClientException', () async { + final sut = fixture.getSut(MockClient((request) async { + expect(request.url, requestUri); + throw ClientException('test', requestUri); + })); + + try { + await sut.get(requestUri); + fail('Method did not throw'); + } on ClientException catch (e) { + expect(e.message, 'test'); + expect(e.uri, requestUri); + } + + expect(fixture.hub.captureExceptionCalls.length, 0); + }); + + /// SocketException are only a thing on dart:io platforms. + /// otherwise this is equal to the test above + test('no captureException for SocketException', () async { + final sut = fixture.getSut(MockClient((request) async { + expect(request.url, requestUri); + throw SocketException('test'); + })); + + try { + await sut.get(requestUri); + fail('Method did not throw'); + } on SocketException catch (e) { + expect(e.message, 'test'); + } + + expect(fixture.hub.captureExceptionCalls.length, 0); + }); + + test('breadcrumb gets added when an exception gets thrown', () async { + final sut = fixture.getSut(MockClient((request) async { + expect(request.url, requestUri); + throw Exception('foo bar'); + })); + + try { + await sut.get(requestUri); + fail('Method did not throw'); + } on Exception catch (_) {} + + expect(fixture.hub.addBreadcrumbCalls.length, 1); + + final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; + + expect(breadcrumb.type, 'http'); + expect(breadcrumb.data?['url'], 'https://example.com'); + expect(breadcrumb.data?['method'], 'GET'); + expect(breadcrumb.level, SentryLevel.error); + expect(breadcrumb.data?['duration'], isNotNull); + }); + + test('close does get called for user defined client', () async { + final mockHub = MockHub(); + + final mockClient = CloseableMockClient(); + + final client = BreadcrumbClient(client: mockClient, hub: mockHub); + client.close(); + + expect(mockHub.addBreadcrumbCalls.length, 0); + expect(mockHub.captureExceptionCalls.length, 0); + verify(mockClient.close()); + }); + + test('Breadcrumb has correct duration', () async { + final sut = fixture.getSut(MockClient((request) async { + expect(request.url, requestUri); + await Future.delayed(Duration(seconds: 1)); + return Response('', 200, reasonPhrase: 'OK'); + })); + + final response = await sut.get(requestUri); + expect(response.statusCode, 200); + + expect(fixture.hub.addBreadcrumbCalls.length, 1); + final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; + + var durationString = breadcrumb.data!['duration']! as String; + // we don't check for anything below a second + expect(durationString.startsWith('0:00:01'), true); + }); + }); +} + +class CloseableMockClient extends Mock implements BaseClient {} + +class Fixture { + BreadcrumbClient getSut([MockClient? client]) { + final mc = client ?? getClient(); + return BreadcrumbClient(client: mc, hub: hub); + } + + late MockHub hub = MockHub(); + + MockClient getClient({int statusCode = 200, String? reason}) { + return MockClient((request) async { + expect(request.url, requestUri); + return Response('', statusCode, reasonPhrase: reason); + }); + } +} diff --git a/dart/test/http_client/failed_request_client_test.dart b/dart/test/http_client/failed_request_client_test.dart new file mode 100644 index 0000000000..42f3e4ad55 --- /dev/null +++ b/dart/test/http_client/failed_request_client_test.dart @@ -0,0 +1,277 @@ +import 'package:http/http.dart'; +import 'package:http/testing.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/http_client/failed_request_client.dart'; +import 'package:sentry/src/http_client/sentry_http_client.dart'; +import 'package:test/test.dart'; + +import '../mocks/mock_hub.dart'; + +final requestUri = Uri.parse('https://example.com?foo=bar'); + +void main() { + group(FailedRequestClient, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('no captured events when everything goes well', () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + + final response = await sut.get(requestUri); + expect(response.statusCode, 200); + + expect(fixture.hub.captureEventCalls.length, 0); + }); + + test('exception gets reported if client throws', () async { + final sut = fixture.getSut( + client: createThrowingClient(), + captureFailedRequests: true, + ); + + await expectLater( + () async => await sut.get(requestUri, headers: {'Cookie': 'foo=bar'}), + throwsException, + ); + + expect(fixture.hub.captureEventCalls.length, 1); + + final eventCall = fixture.hub.captureEventCalls.first; + final throwableMechanism = + eventCall.event.throwableMechanism as ThrowableMechanism; + + expect(eventCall.stackTrace, isNotNull); + expect(throwableMechanism.mechanism.type, 'SentryHttpClient'); + expect(throwableMechanism.throwable, isA()); + + final request = eventCall.event.request; + expect(request, isNotNull); + expect(request?.method, 'GET'); + expect(request?.url, 'https://example.com?'); + expect(request?.queryString, 'foo=bar'); + expect(request?.cookies, 'foo=bar'); + expect(request?.headers, {'Cookie': 'foo=bar'}); + expect(request?.other.keys.contains('duration'), true); + expect(request?.other.keys.contains('content_length'), true); + }); + + test('exception gets not reported if disabled', () async { + final sut = fixture.getSut( + client: createThrowingClient(), + captureFailedRequests: false, + ); + + await expectLater( + () async => await sut.get(requestUri, headers: {'Cookie': 'foo=bar'}), + throwsException, + ); + + expect(fixture.hub.captureEventCalls.length, 0); + }); + + test('exception gets reported if bad status code occurs', () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 404, reason: 'Not Found'), + badStatusCodes: [SentryStatusCode(404)], + ); + + await sut.get(requestUri, headers: {'Cookie': 'foo=bar'}); + + expect(fixture.hub.captureEventCalls.length, 1); + + final eventCall = fixture.hub.captureEventCalls.first; + final throwableMechanism = fixture.hub.captureEventCalls.first.event + .throwableMechanism as ThrowableMechanism; + + expect(eventCall.stackTrace, isNull); + expect(throwableMechanism, isNotNull); + expect(throwableMechanism.mechanism.type, 'SentryHttpClient'); + expect( + throwableMechanism.mechanism.description, + 'This event was captured because the ' + 'request status code was in [404]', + ); + + final request = eventCall.event.request; + expect(request, isNotNull); + expect(request?.method, 'GET'); + expect(request?.url, 'https://example.com?'); + expect(request?.queryString, 'foo=bar'); + expect(request?.cookies, 'foo=bar'); + expect(request?.headers, {'Cookie': 'foo=bar'}); + expect(request?.other.keys.contains('duration'), true); + expect(request?.other.keys.contains('content_length'), true); + }); + + test( + 'just one report on status code reporting with failing requests enabled', + () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 404, reason: 'Not Found'), + badStatusCodes: [SentryStatusCode(404)], + captureFailedRequests: true, + ); + + await sut.get(requestUri, headers: {'Cookie': 'foo=bar'}); + + expect(fixture.hub.captureEventCalls.length, 1); + }); + + test('close does get called for user defined client', () async { + final mockHub = MockHub(); + + final mockClient = CloseableMockClient(); + + final client = FailedRequestClient(client: mockClient, hub: mockHub); + client.close(); + + expect(mockHub.addBreadcrumbCalls.length, 0); + expect(mockHub.captureExceptionCalls.length, 0); + verify(mockClient.close()); + }); + + test('pii is not send on exception', () async { + final sut = fixture.getSut( + client: createThrowingClient(), + captureFailedRequests: true, + sendDefaultPii: false, + ); + + await expectLater( + () async => await sut.get(requestUri, headers: {'Cookie': 'foo=bar'}), + throwsException, + ); + + final event = fixture.hub.captureEventCalls.first.event; + expect(fixture.hub.captureEventCalls.length, 1); + expect(event.request?.headers.isEmpty, true); + expect(event.request?.cookies, isNull); + }); + + test('pii is not send on invalid status code', () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 404, reason: 'Not Found'), + badStatusCodes: [SentryStatusCode(404)], + captureFailedRequests: false, + sendDefaultPii: false, + ); + + await sut.get(requestUri, headers: {'Cookie': 'foo=bar'}); + + final event = fixture.hub.captureEventCalls.first.event; + expect(fixture.hub.captureEventCalls.length, 1); + expect(event.request?.headers.isEmpty, true); + expect(event.request?.cookies, isNull); + }); + + test('request body is included according to $MaxRequestBodySize', () async { + final scenarios = [ + // never + MaxRequestBodySizeTestConfig(MaxRequestBodySize.never, 0, false), + MaxRequestBodySizeTestConfig(MaxRequestBodySize.never, 4001, false), + MaxRequestBodySizeTestConfig(MaxRequestBodySize.never, 10001, false), + // always + MaxRequestBodySizeTestConfig(MaxRequestBodySize.always, 0, true), + MaxRequestBodySizeTestConfig(MaxRequestBodySize.always, 4001, true), + MaxRequestBodySizeTestConfig(MaxRequestBodySize.always, 10001, true), + // small + MaxRequestBodySizeTestConfig(MaxRequestBodySize.small, 0, true), + MaxRequestBodySizeTestConfig(MaxRequestBodySize.small, 4000, true), + MaxRequestBodySizeTestConfig(MaxRequestBodySize.small, 4001, false), + // medium + MaxRequestBodySizeTestConfig(MaxRequestBodySize.medium, 0, true), + MaxRequestBodySizeTestConfig(MaxRequestBodySize.medium, 4001, true), + MaxRequestBodySizeTestConfig(MaxRequestBodySize.medium, 10000, true), + MaxRequestBodySizeTestConfig(MaxRequestBodySize.medium, 10001, false), + ]; + + for (final scenario in scenarios) { + fixture.hub.reset(); + + final sut = fixture.getSut( + client: createThrowingClient(), + captureFailedRequests: true, + maxRequestBodySize: scenario.maxRequestBodySize, + ); + + final request = Request('GET', requestUri) + // This creates a a request of the specified size + ..bodyBytes = List.generate(scenario.contentLength, (index) => 0); + + await expectLater( + () async => await sut.send(request), + throwsException, + ); + + expect(fixture.hub.captureEventCalls.length, 1); + + final eventCall = fixture.hub.captureEventCalls.first; + final capturedRequest = eventCall.event.request; + expect( + capturedRequest?.data, + scenario.shouldBeIncluded ? isNotNull : isNull, + ); + } + }); + }); +} + +MockClient createThrowingClient() { + return MockClient( + (request) async { + expect(request.url, requestUri); + throw TestException(); + }, + ); +} + +class CloseableMockClient extends Mock implements BaseClient {} + +class Fixture { + FailedRequestClient getSut({ + MockClient? client, + bool captureFailedRequests = false, + MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.small, + List badStatusCodes = const [], + bool sendDefaultPii = true, + }) { + final mc = client ?? getClient(); + return FailedRequestClient( + client: mc, + hub: hub, + captureFailedRequests: captureFailedRequests, + failedRequestStatusCodes: badStatusCodes, + maxRequestBodySize: maxRequestBodySize, + sendDefaultPii: sendDefaultPii, + ); + } + + final MockHub hub = MockHub(); + + MockClient getClient({int statusCode = 200, String? reason}) { + return MockClient((request) async { + expect(request.url, requestUri); + return Response('', statusCode, reasonPhrase: reason); + }); + } +} + +class TestException implements Exception {} + +class MaxRequestBodySizeTestConfig { + MaxRequestBodySizeTestConfig( + this.maxRequestBodySize, + this.contentLength, + this.shouldBeIncluded, + ); + + final MaxRequestBodySize maxRequestBodySize; + final int contentLength; + final bool shouldBeIncluded; +} diff --git a/dart/test/http_client/sentry_http_client_test.dart b/dart/test/http_client/sentry_http_client_test.dart index df20f485ef..da8042241b 100644 --- a/dart/test/http_client/sentry_http_client_test.dart +++ b/dart/test/http_client/sentry_http_client_test.dart @@ -1,9 +1,8 @@ -import 'dart:io'; - import 'package:http/http.dart'; import 'package:http/testing.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; +import 'package:sentry/src/http_client/failed_request_client.dart'; import 'package:sentry/src/http_client/sentry_http_client.dart'; import 'package:test/test.dart'; @@ -13,155 +12,53 @@ final requestUri = Uri.parse('https://example.com'); void main() { group(SentryHttpClient, () { - late var fixture; + late Fixture fixture; setUp(() { fixture = Fixture(); }); - test('GET: happy path', () async { - final sut = - fixture.getSut(fixture.getClient(statusCode: 200, reason: 'OK')); + test( + 'no captured events & one captured breadcrumb when everything goes well', + () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); final response = await sut.get(requestUri); expect(response.statusCode, 200); + expect(fixture.hub.captureEventCalls.length, 0); expect(fixture.hub.addBreadcrumbCalls.length, 1); - final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; - - expect(breadcrumb.type, 'http'); - expect(breadcrumb.data?['url'], 'https://example.com'); - expect(breadcrumb.data?['method'], 'GET'); - expect(breadcrumb.data?['status_code'], 200); - expect(breadcrumb.data?['reason'], 'OK'); - expect(breadcrumb.data?['duration'], isNotNull); - }); - - test('GET: happy path for 404', () async { - final sut = fixture - .getSut(fixture.getClient(statusCode: 404, reason: 'NOT FOUND')); - - final response = await sut.get(requestUri); - - expect(response.statusCode, 404); - - expect(fixture.hub.addBreadcrumbCalls.length, 1); - final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; - - expect(breadcrumb.type, 'http'); - expect(breadcrumb.data?['url'], 'https://example.com'); - expect(breadcrumb.data?['method'], 'GET'); - expect(breadcrumb.data?['status_code'], 404); - expect(breadcrumb.data?['reason'], 'NOT FOUND'); - expect(breadcrumb.data?['duration'], isNotNull); - }); - - test('POST: happy path', () async { - final sut = fixture.getSut(fixture.getClient(statusCode: 200)); - - final response = await sut.post(requestUri); - expect(response.statusCode, 200); - - expect(fixture.hub.addBreadcrumbCalls.length, 1); - final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; - - expect(breadcrumb.type, 'http'); - expect(breadcrumb.data?['url'], 'https://example.com'); - expect(breadcrumb.data?['method'], 'POST'); - expect(breadcrumb.data?['status_code'], 200); - expect(breadcrumb.data?['duration'], isNotNull); - }); - - test('PUT: happy path', () async { - final sut = fixture.getSut(fixture.getClient(statusCode: 200)); - - final response = await sut.put(requestUri); - expect(response.statusCode, 200); - - expect(fixture.hub.addBreadcrumbCalls.length, 1); - final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; - - expect(breadcrumb.type, 'http'); - expect(breadcrumb.data?['url'], 'https://example.com'); - expect(breadcrumb.data?['method'], 'PUT'); - expect(breadcrumb.data?['status_code'], 200); - expect(breadcrumb.data?['duration'], isNotNull); }); - test('DELETE: happy path', () async { - final sut = fixture.getSut(fixture.getClient(statusCode: 200)); + test('no captured event with default config', () async { + final sut = fixture.getSut( + client: createThrowingClient(), + ); - final response = await sut.delete(requestUri); - expect(response.statusCode, 200); + await expectLater(() async => await sut.get(requestUri), throwsException); + expect(fixture.hub.captureEventCalls.length, 0); expect(fixture.hub.addBreadcrumbCalls.length, 1); - final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; - - expect(breadcrumb.type, 'http'); - expect(breadcrumb.data?['url'], 'https://example.com'); - expect(breadcrumb.data?['method'], 'DELETE'); - expect(breadcrumb.data?['status_code'], 200); - expect(breadcrumb.data?['duration'], isNotNull); - }); - - /// Tests, that in case an exception gets thrown, that - /// no exception gets reported by Sentry, in case the user wants to - /// handle the exception - test('no captureException for ClientException', () async { - final sut = fixture.getSut(MockClient((request) async { - expect(request.url, requestUri); - throw ClientException('test', requestUri); - })); - - try { - await sut.get(requestUri); - fail('Method did not throw'); - } on ClientException catch (e) { - expect(e.message, 'test'); - expect(e.uri, requestUri); - } - - expect(fixture.hub.captureExceptionCalls.length, 0); - }); - - /// SocketException are only a thing on dart:io platforms. - /// otherwise this is equal to the test above - test('no captureException for SocketException', () async { - final sut = fixture.getSut(MockClient((request) async { - expect(request.url, requestUri); - throw SocketException('test'); - })); - - try { - await sut.get(requestUri); - fail('Method did not throw'); - } on SocketException catch (e) { - expect(e.message, 'test'); - } - - expect(fixture.hub.captureExceptionCalls.length, 0); }); - test('breadcrumb gets added when an exception gets thrown', () async { - final sut = fixture.getSut(MockClient((request) async { - expect(request.url, requestUri); - throw Exception('foo bar'); - })); - - try { - await sut.get(requestUri); - fail('Method did not throw'); - } on Exception catch (_) {} - + test('one captured event with when enabling $FailedRequestClient', + () async { + final sut = fixture.getSut( + client: createThrowingClient(), + captureFailedRequests: true, + recordBreadcrumbs: true, + ); + + await expectLater(() async => await sut.get(requestUri), throwsException); + + expect(fixture.hub.captureEventCalls.length, 1); + // The event should not have breadcrumbs from the BreadcrumbClient + expect(fixture.hub.captureEventCalls.first.event.breadcrumbs, null); + // The breadcrumb for the request should still be added for every + // following event. expect(fixture.hub.addBreadcrumbCalls.length, 1); - - final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; - - expect(breadcrumb.type, 'http'); - expect(breadcrumb.data?['url'], 'https://example.com'); - expect(breadcrumb.data?['method'], 'GET'); - expect(breadcrumb.level, SentryLevel.error); - expect(breadcrumb.data?['duration'], isNotNull); }); test('close does get called for user defined client', () async { @@ -176,36 +73,40 @@ void main() { expect(mockHub.captureExceptionCalls.length, 0); verify(mockClient.close()); }); - - test('Breadcrumb has correct duration', () async { - final sut = fixture.getSut(MockClient((request) async { - expect(request.url, requestUri); - await Future.delayed(Duration(seconds: 1)); - return Response('', 200, reasonPhrase: 'OK'); - })); - - final response = await sut.get(requestUri); - expect(response.statusCode, 200); - - expect(fixture.hub.addBreadcrumbCalls.length, 1); - final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; - - var durationString = breadcrumb.data!['duration']! as String; - // we don't check for anything below a second - expect(durationString.startsWith('0:00:01'), true); - }); }); } +MockClient createThrowingClient() { + return MockClient( + (request) async { + expect(request.url, requestUri); + throw TestException(); + }, + ); +} + class CloseableMockClient extends Mock implements BaseClient {} class Fixture { - SentryHttpClient getSut([MockClient? client]) { + SentryHttpClient getSut({ + MockClient? client, + bool captureFailedRequests = false, + MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.never, + List badStatusCodes = const [], + bool recordBreadcrumbs = true, + }) { final mc = client ?? getClient(); - return SentryHttpClient(client: mc, hub: hub); + return SentryHttpClient( + client: mc, + hub: hub, + captureFailedRequests: captureFailedRequests, + failedRequestStatusCodes: badStatusCodes, + maxRequestBodySize: maxRequestBodySize, + recordBreadcrumbs: recordBreadcrumbs, + ); } - late MockHub hub = MockHub(); + final MockHub hub = MockHub(); MockClient getClient({int statusCode = 200, String? reason}) { return MockClient((request) async { @@ -214,3 +115,5 @@ class Fixture { }); } } + +class TestException implements Exception {} diff --git a/dart/test/mocks/mock_hub.dart b/dart/test/mocks/mock_hub.dart index be7a1072e5..ae6148c023 100644 --- a/dart/test/mocks/mock_hub.dart +++ b/dart/test/mocks/mock_hub.dart @@ -9,6 +9,17 @@ class MockHub implements Hub { int closeCalls = 0; bool _isEnabled = true; + /// Useful for tests. + void reset() { + captureEventCalls = []; + captureExceptionCalls = []; + captureMessageCalls = []; + addBreadcrumbCalls = []; + bindClientCalls = []; + closeCalls = 0; + _isEnabled = true; + } + @override void addBreadcrumb(Breadcrumb crumb, {dynamic hint}) { addBreadcrumbCalls.add(AddBreadcrumbCall(crumb, hint)); diff --git a/dart/test/protocol/breadcrumb_test.dart b/dart/test/protocol/breadcrumb_test.dart index e34b8d2553..cabd34c25e 100644 --- a/dart/test/protocol/breadcrumb_test.dart +++ b/dart/test/protocol/breadcrumb_test.dart @@ -33,6 +33,7 @@ void main() { true, ); }); + test('fromJson', () { final breadcrumb = Breadcrumb.fromJson(breadcrumbJson); final json = breadcrumb.toJson(); @@ -77,4 +78,31 @@ void main() { expect('type1', copy.type); }); }); + + test('Breadcrumb http ctor', () { + final breadcrumb = Breadcrumb.http( + url: Uri.parse('https://example.org'), + method: 'GET', + level: SentryLevel.fatal, + reason: 'OK', + statusCode: 200, + requestDuration: Duration.zero, + timestamp: DateTime.now(), + ); + final json = breadcrumb.toJson(); + + expect(json, { + 'timestamp': formatDateAsIso8601WithMillisPrecision(breadcrumb.timestamp), + 'category': 'http', + 'data': { + 'url': 'https://example.org', + 'method': 'GET', + 'status_code': 200, + 'reason': 'OK', + 'duration': '0:00:00.000000' + }, + 'level': 'fatal', + 'type': 'http', + }); + }); }