Skip to content

Commit

Permalink
Feat: Failed requests HTTP client (#473)
Browse files Browse the repository at this point in the history
* Split clients + WIP FailedRequestClient

* More docs + some more code

* Update dart/lib/src/http_client/failed_request_client.dart

* Finish FailedRequestsClient + Tests

* fix export

* Some tests for SentryHttpClient

* Ordering matters

* changelog

* Add comment

* fix formatting

* Changelog

* Docs, Tests + WIP

* Send Default Pii as Constructor Param

* else if

* Breadcrumb http ctor

* Remove default values

Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com>
  • Loading branch information
ueman and marandaneto authored Jun 15, 2021
1 parent b9866b7 commit 3c8a240
Show file tree
Hide file tree
Showing 12 changed files with 1,057 additions and 204 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
94 changes: 94 additions & 0 deletions dart/lib/src/http_client/breadcrumb_client.dart
Original file line number Diff line number Diff line change
@@ -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<StreamedResponse> 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();
}
}
241 changes: 241 additions & 0 deletions dart/lib/src/http_client/failed_request_client.dart
Original file line number Diff line number Diff line change
@@ -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<SentryStatusCode> failedRequestStatusCodes;

final bool sendDefaultPii;

@override
Future<StreamedResponse> 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<void> _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 = <String, String>{...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<SentryStatusCode> {
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;
}
}
Loading

0 comments on commit 3c8a240

Please sign in to comment.