diff --git a/dart/lib/src/http_client/failed_request_client.dart b/dart/lib/src/http_client/failed_request_client.dart index 446b88826c..832bd9b11e 100644 --- a/dart/lib/src/http_client/failed_request_client.dart +++ b/dart/lib/src/http_client/failed_request_client.dart @@ -1,6 +1,7 @@ import 'package:http/http.dart'; import '../hint.dart'; import '../type_check_hint.dart'; +import '../utils/http_deep_copy_streamed_response.dart'; import '../utils/tracing_utils.dart'; import 'sentry_http_client_error.dart'; import '../protocol.dart'; @@ -100,152 +101,37 @@ class FailedRequestClient extends BaseClient { Object? exception; StackTrace? stackTrace; StreamedResponse? response; + List copiedResponses = []; final stopwatch = Stopwatch(); stopwatch.start(); try { response = await _client.send(request); - statusCode = response.statusCode; - return response; + copiedResponses = await deepCopyStreamedResponse(response, 2); + statusCode = copiedResponses[0].statusCode; + return copiedResponses[0]; } catch (e, st) { exception = e; stackTrace = st; rethrow; } finally { stopwatch.stop(); - await _captureEventIfNeeded( - request, - statusCode, - exception, - stackTrace, - response, - stopwatch.elapsed, - ); - } - } - Future _captureEventIfNeeded( - BaseRequest request, - int? statusCode, - Object? exception, - StackTrace? stackTrace, - StreamedResponse? response, - Duration duration) async { - if (!(_captureFailedRequests ?? _hub.options.captureFailedRequests)) { - return; - } - - // Only check `failedRequestStatusCodes` & `failedRequestTargets` if no exception was thrown. - if (exception == null) { - if (!failedRequestStatusCodes._containsStatusCode(statusCode)) { - return; - } - if (!containsTargetOrMatchesRegExp( - failedRequestTargets, request.url.toString())) { - return; - } + await captureEvent( + _hub, + exception: exception, + stackTrace: stackTrace, + request: request, + requestDuration: stopwatch.elapsed, + response: copiedResponses.isNotEmpty ? copiedResponses[1] : null, + reason: 'HTTP Client Event with status code: $statusCode', + ); } - - final reason = 'HTTP Client Error with status code: $statusCode'; - exception ??= SentryHttpClientError(reason); - - await _captureEvent( - exception: exception, - stackTrace: stackTrace, - request: request, - requestDuration: duration, - response: response, - reason: reason, - ); } @override void close() => _client.close(); - - // See https://develop.sentry.dev/sdk/event-payloads/request/ - Future _captureEvent({ - required Object? exception, - StackTrace? stackTrace, - String? reason, - required Duration requestDuration, - required BaseRequest request, - required StreamedResponse? response, - }) async { - final sentryRequest = SentryRequest.fromUri( - method: request.method, - headers: _hub.options.sendDefaultPii ? request.headers : null, - uri: request.url, - data: _hub.options.sendDefaultPii ? _getDataFromRequest(request) : null, - // ignore: deprecated_member_use_from_same_package - other: { - 'content_length': request.contentLength.toString(), - 'duration': requestDuration.toString(), - }, - ); - - final mechanism = Mechanism( - type: 'SentryHttpClient', - description: reason, - ); - - bool? snapshot; - if (exception is SentryHttpClientError) { - snapshot = true; - } - - final throwableMechanism = ThrowableMechanism( - mechanism, - exception, - snapshot: snapshot, - ); - - final event = SentryEvent( - throwable: throwableMechanism, - request: sentryRequest, - timestamp: _hub.options.clock(), - ); - - final hint = Hint.withMap({TypeCheckHint.httpRequest: request}); - - if (response != null) { - event.contexts.response = SentryResponse( - headers: _hub.options.sendDefaultPii ? response.headers : null, - bodySize: response.contentLength, - statusCode: response.statusCode, - ); - hint.set(TypeCheckHint.httpResponse, response); - } - - await _hub.captureEvent( - event, - stackTrace: stackTrace, - hint: hint, - ); - } - - // 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 (!_hub.options.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 { diff --git a/dart/lib/src/http_client/sentry_http_client.dart b/dart/lib/src/http_client/sentry_http_client.dart index 35fcae4b4f..ff3c36c8a7 100644 --- a/dart/lib/src/http_client/sentry_http_client.dart +++ b/dart/lib/src/http_client/sentry_http_client.dart @@ -1,4 +1,6 @@ import 'package:http/http.dart'; +import 'package:meta/meta.dart'; +import '../../sentry.dart'; import 'tracing_client.dart'; import '../hub.dart'; import '../hub_adapter.dart'; @@ -160,3 +162,95 @@ class SentryStatusCode { return '$_min..$_max'; } } + +@internal +// See https://develop.sentry.dev/sdk/event-payloads/request/ +Future captureEvent( + Hub hub, { + Object? exception, + StackTrace? stackTrace, + String? reason, + required Duration requestDuration, + required BaseRequest request, + required StreamedResponse? response, +}) async { + final sentryRequest = SentryRequest.fromUri( + method: request.method, + headers: hub.options.sendDefaultPii ? request.headers : null, + uri: request.url, + data: hub.options.sendDefaultPii ? _getDataFromRequest(hub, request) : null, + // ignore: deprecated_member_use_from_same_package + other: { + 'content_length': request.contentLength.toString(), + 'duration': requestDuration.toString(), + }, + ); + + final mechanism = Mechanism( + type: 'SentryHttpClient', + description: reason, + ); + + bool? snapshot; + ThrowableMechanism? throwableMechanism; + if (exception is SentryHttpClientError) { + snapshot = true; + throwableMechanism = ThrowableMechanism( + mechanism, + exception, + snapshot: snapshot, + ); + } + + final event = SentryEvent( + throwable: throwableMechanism, + request: sentryRequest, + timestamp: hub.options.clock(), + ); + + final hint = Hint.withMap({TypeCheckHint.httpRequest: request}); + + if (response != null) { + final responseBody = await response.stream.bytesToString(); + event.contexts.response = SentryResponse( + headers: hub.options.sendDefaultPii ? response.headers : null, + bodySize: response.contentLength, + statusCode: response.statusCode, + data: hub.options.sendDefaultPii && + hub.options.maxResponseBodySize + .shouldAddBody(response.contentLength!) + ? responseBody + : null, + ); + hint.set(TypeCheckHint.httpResponse, response); + } + + await hub.captureEvent( + event, + stackTrace: stackTrace, + hint: hint, + ); +} + +// Types of Request can be found here: +// https://pub.dev/documentation/http/latest/http/http-library.html +Object? _getDataFromRequest(Hub hub, BaseRequest request) { + final contentLength = request.contentLength; + if (contentLength == null) { + return null; + } + if (!hub.options.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; +} diff --git a/dart/lib/src/http_client/tracing_client.dart b/dart/lib/src/http_client/tracing_client.dart index 95627724c3..74161233cb 100644 --- a/dart/lib/src/http_client/tracing_client.dart +++ b/dart/lib/src/http_client/tracing_client.dart @@ -1,9 +1,11 @@ import 'package:http/http.dart'; +import '../../sentry.dart'; import '../hub.dart'; import '../hub_adapter.dart'; import '../protocol.dart'; import '../sentry_trace_origins.dart'; import '../tracing.dart'; +import '../utils/http_deep_copy_streamed_response.dart'; import '../utils/tracing_utils.dart'; import '../utils/http_sanitizer.dart'; @@ -21,6 +23,9 @@ class TracingClient extends BaseClient { @override Future send(BaseRequest request) async { // see https://develop.sentry.dev/sdk/performance/#header-sentry-trace + int? statusCode; + final stopwatch = Stopwatch(); + stopwatch.start(); final urlDetails = HttpSanitizer.sanitizeUrl(request.url.toString()); @@ -45,6 +50,7 @@ class TracingClient extends BaseClient { urlDetails?.applyToSpan(span); StreamedResponse? response; + List copiedResponses = []; try { if (containsTargetOrMatchesRegExp( _hub.options.tracePropagationTargets, request.url.toString())) { @@ -72,9 +78,19 @@ class TracingClient extends BaseClient { } response = await _client.send(request); - span?.setData('http.response.status_code', response.statusCode); - span?.setData('http.response_content_length', response.contentLength); - span?.status = SpanStatus.fromHttpStatusCode(response.statusCode); + copiedResponses = await deepCopyStreamedResponse(response, 2); + statusCode = copiedResponses[0].statusCode; + span?.setData('http.response.status_code', copiedResponses[1].statusCode); + span?.setData( + 'http.response_content_length', copiedResponses[1].contentLength); + if (_hub.options.sendDefaultPii && + _hub.options.maxResponseBodySize + .shouldAddBody(response.contentLength!)) { + final responseBody = await copiedResponses[1].stream.bytesToString(); + span?.setData('http.response_content', responseBody); + } + span?.status = + SpanStatus.fromHttpStatusCode(copiedResponses[1].statusCode); } catch (exception) { span?.throwable = exception; span?.status = SpanStatus.internalError(); @@ -82,8 +98,16 @@ class TracingClient extends BaseClient { rethrow; } finally { await span?.finish(); + stopwatch.stop(); + await captureEvent( + _hub, + request: request, + requestDuration: stopwatch.elapsed, + response: copiedResponses.isNotEmpty ? copiedResponses[1] : null, + reason: 'HTTP Client Event with status code: $statusCode', + ); } - return response; + return copiedResponses[0]; } @override diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index c9a9511c29..d73cdae120 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -278,6 +278,8 @@ class SentryOptions { /// because the connection was interrupted. /// Use with [SentryHttpClient] or `sentry_dio` integration for this to work, /// or iOS native where it sets the value to `enableCaptureFailedRequests`. + @Deprecated( + "All Request and Responses are now logged, if `sendDefaultPii` is `true` and `maxRequestBodySize` and `maxResponseBodySize` conditions are met.") bool captureFailedRequests = true; /// Whether to records requests as breadcrumbs. This is on by default. diff --git a/dart/lib/src/utils/http_deep_copy_streamed_response.dart b/dart/lib/src/utils/http_deep_copy_streamed_response.dart new file mode 100644 index 0000000000..95c7e6058c --- /dev/null +++ b/dart/lib/src/utils/http_deep_copy_streamed_response.dart @@ -0,0 +1,28 @@ +import 'package:http/http.dart'; +import 'package:meta/meta.dart'; + +/// Helper to deep copy the StreamedResponse of a web request +@internal +Future> deepCopyStreamedResponse( + StreamedResponse originalResponse, int copies) async { + final List bufferedData = []; + + await for (final List chunk in originalResponse.stream) { + bufferedData.addAll(chunk); + } + + List copiedElements = []; + for (int i = 1; i <= copies; i++) { + copiedElements.add(StreamedResponse( + Stream.fromIterable([bufferedData]), + originalResponse.statusCode, + contentLength: originalResponse.contentLength, + request: originalResponse.request, + headers: originalResponse.headers, + reasonPhrase: originalResponse.reasonPhrase, + isRedirect: originalResponse.isRedirect, + persistentConnection: originalResponse.persistentConnection, + )); + } + return copiedElements; +} diff --git a/dart/test/http_client/failed_request_client_test.dart b/dart/test/http_client/failed_request_client_test.dart index 2ac9a74c8e..47e90e7827 100644 --- a/dart/test/http_client/failed_request_client_test.dart +++ b/dart/test/http_client/failed_request_client_test.dart @@ -30,6 +30,25 @@ void main() { expect(fixture.transport.calls, 0); }); + test('capture event with response body on error', () async { + fixture._hub.options.sendDefaultPii = true; + fixture._hub.options.maxResponseBodySize = MaxResponseBodySize.always; + String responseBody = "this is the response body"; + int statusCode = 505; + + final sut = fixture.getSut( + client: fixture.getClient(statusCode: statusCode, body: responseBody), + ); + final response = await sut.get(requestUri); + + expect(response.statusCode, statusCode); + expect(response.body, responseBody); + expect(fixture.transport.calls, 1); + expect(fixture.transport.events.length, 1); + expect(fixture.transport.events.first.contexts["response"].data, + responseBody); + }); + test('exception gets reported if client throws', () async { fixture._hub.options.captureFailedRequests = true; fixture._hub.options.sendDefaultPii = true; diff --git a/dart/test/http_client/tracing_client_test.dart b/dart/test/http_client/tracing_client_test.dart index 6cefe626b3..b8235454d5 100644 --- a/dart/test/http_client/tracing_client_test.dart +++ b/dart/test/http_client/tracing_client_test.dart @@ -18,9 +18,44 @@ void main() { fixture = Fixture(); }); - test('captured span if successful request', () async { + test('captured span if successful request without Pii', () async { + final responseBody = "test response body"; final sut = fixture.getSut( - client: fixture.getClient(statusCode: 200, reason: 'OK'), + client: fixture.getClient( + statusCode: 200, reason: 'OK', body: responseBody), + ); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + await sut.get(requestUri); + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + + expect(span.status, SpanStatus.ok()); + expect(span.context.operation, 'http.client'); + expect(span.context.description, 'GET https://example.com'); + expect(span.data['http.request.method'], 'GET'); + expect(span.data['url'], 'https://example.com'); + expect(span.data['http.query'], 'foo=bar'); + expect(span.data['http.fragment'], 'baz'); + expect(span.data['http.response.status_code'], 200); + expect(span.data['http.response_content_length'], responseBody.length); + expect(span.data['http.response_content'], null); + expect(span.origin, SentryTraceOrigins.autoHttpHttp); + }); + + test('captured span if successful request with Pii', () async { + fixture._hub.options.sendDefaultPii = true; + final responseBody = "test response body"; + final sut = fixture.getSut( + client: fixture.getClient( + statusCode: 200, reason: 'OK', body: responseBody), ); final tr = fixture._hub.startTransaction( 'name', @@ -43,7 +78,8 @@ void main() { expect(span.data['http.query'], 'foo=bar'); expect(span.data['http.fragment'], 'baz'); expect(span.data['http.response.status_code'], 200); - expect(span.data['http.response_content_length'], 2); + expect(span.data['http.response_content_length'], responseBody.length); + expect(span.data['http.response_content'], responseBody); expect(span.origin, SentryTraceOrigins.autoHttpHttp); }); @@ -244,10 +280,15 @@ class Fixture { ); } - MockClient getClient({int statusCode = 200, String? reason}) { + MockClient getClient({ + int statusCode = 200, + // String body = '{}', + String body = '', + String? reason, + }) { return MockClient((request) async { expect(request.url, requestUri); - return Response('{}', statusCode, reasonPhrase: reason, request: request); + return Response(body, statusCode, reasonPhrase: reason, request: request); }); } }