diff --git a/CHANGELOG.md b/CHANGELOG.md index 152a7a9d11..a412afb301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ [Trace origin](https://develop.sentry.dev/sdk/performance/trace-origin/) indicates what created a trace or a span. Not all transactions and spans contain enough information to tell whether the user or what precisely in the SDK created it. Origin solves this problem. The SDK now sends origin for transactions and spans. - Add `appHangTimeoutInterval` to `SentryFlutterOptions` ([#1568](https://github.com/getsentry/sentry-dart/pull/1568)) +- DioEventProcessor: Append http response body ([#1557](https://github.com/getsentry/sentry-dart/pull/1557)) + - This is opt-in and depends on `maxResponseBodySize` + - Only for `dio` package ### Dependencies diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index f23525bfda..9d06bb7e2a 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -46,3 +46,5 @@ export 'src/utils/url_details.dart'; export 'src/utils/http_header_utils.dart'; // ignore: invalid_export_of_internal_element export 'src/sentry_trace_origins.dart'; +// ignore: invalid_export_of_internal_element +export 'src/utils.dart'; diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index 34544076ba..55450be9dd 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -13,7 +13,6 @@ import 'diagnostic_logger.dart'; import 'environment/environment_variables.dart'; import 'noop_client.dart'; import 'transport/noop_transport.dart'; -import 'utils.dart'; import 'version.dart'; // TODO: shutdownTimeout, flushTimeoutMillis diff --git a/dart/lib/src/utils.dart b/dart/lib/src/utils.dart index f349635051..db5ca614c2 100644 --- a/dart/lib/src/utils.dart +++ b/dart/lib/src/utils.dart @@ -8,9 +8,11 @@ import 'package:meta/meta.dart'; /// Sentry does not take a timezone and instead expects the date-time to be /// submitted in UTC timezone. +@internal DateTime getUtcDateTime() => DateTime.now().toUtc(); /// Formats a Date as ISO8601 and UTC with millis precision +@internal String formatDateAsIso8601WithMillisPrecision(DateTime date) { var iso = date.toIso8601String(); final millisecondSeparatorIndex = iso.lastIndexOf('.'); @@ -22,9 +24,10 @@ String formatDateAsIso8601WithMillisPrecision(DateTime date) { return '${iso}Z'; } +@internal final utf8JsonEncoder = JsonUtf8Encoder(null, jsonSerializationFallback, null); -@visibleForTesting +@internal Object? jsonSerializationFallback(Object? nonEncodable) { if (nonEncodable == null) { return null; diff --git a/dart/test/protocol/breadcrumb_test.dart b/dart/test/protocol/breadcrumb_test.dart index 4062e1b895..ddd4008d98 100644 --- a/dart/test/protocol/breadcrumb_test.dart +++ b/dart/test/protocol/breadcrumb_test.dart @@ -1,7 +1,6 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:test/test.dart'; -import 'package:sentry/src/utils.dart'; void main() { final timestamp = DateTime.now(); diff --git a/dart/test/sentry_envelope_header_test.dart b/dart/test/sentry_envelope_header_test.dart index 5dff84aa87..cc10f97434 100644 --- a/dart/test/sentry_envelope_header_test.dart +++ b/dart/test/sentry_envelope_header_test.dart @@ -1,6 +1,5 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_envelope_header.dart'; -import 'package:sentry/src/utils.dart'; import 'package:test/test.dart'; import 'mocks.dart'; diff --git a/dart/test/sentry_envelope_item_test.dart b/dart/test/sentry_envelope_item_test.dart index 4fe01e5a8e..673f679740 100644 --- a/dart/test/sentry_envelope_item_test.dart +++ b/dart/test/sentry_envelope_item_test.dart @@ -8,7 +8,6 @@ import 'package:sentry/src/sentry_envelope_item_header.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/transport/data_category.dart'; -import 'package:sentry/src/utils.dart'; import 'package:test/test.dart'; import 'mocks/mock_hub.dart'; diff --git a/dart/test/sentry_envelope_test.dart b/dart/test/sentry_envelope_test.dart index 063be42ba9..375f62846e 100644 --- a/dart/test/sentry_envelope_test.dart +++ b/dart/test/sentry_envelope_test.dart @@ -6,7 +6,6 @@ import 'package:sentry/src/sentry_envelope_header.dart'; import 'package:sentry/src/sentry_envelope_item_header.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/sentry_tracer.dart'; -import 'package:sentry/src/utils.dart'; import 'package:test/test.dart'; import 'mocks.dart'; diff --git a/dart/test/sentry_event_test.dart b/dart/test/sentry_event_test.dart index 58fb946110..4e661638a8 100644 --- a/dart/test/sentry_event_test.dart +++ b/dart/test/sentry_event_test.dart @@ -5,7 +5,6 @@ import 'package:collection/collection.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/version.dart'; -import 'package:sentry/src/utils.dart'; import 'package:test/test.dart'; void main() { diff --git a/dart/test/sentry_span_test.dart b/dart/test/sentry_span_test.dart index 37a81309da..e878be5cf2 100644 --- a/dart/test/sentry_span_test.dart +++ b/dart/test/sentry_span_test.dart @@ -1,6 +1,5 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_tracer.dart'; -import 'package:sentry/src/utils.dart'; import 'package:test/test.dart'; import 'mocks/mock_hub.dart'; diff --git a/dart/test/sentry_tracer_test.dart b/dart/test/sentry_tracer_test.dart index f2c0bcdf7c..cb9f72fdfc 100644 --- a/dart/test/sentry_tracer_test.dart +++ b/dart/test/sentry_tracer_test.dart @@ -1,6 +1,5 @@ import 'package:sentry/sentry.dart'; import 'package:sentry/src/sentry_tracer.dart'; -import 'package:sentry/src/utils.dart'; import 'package:test/test.dart'; import 'mocks.dart'; diff --git a/dio/lib/src/dio_event_processor.dart b/dio/lib/src/dio_event_processor.dart index 0e9eedd5eb..3a9603abd1 100644 --- a/dio/lib/src/dio_event_processor.dart +++ b/dio/lib/src/dio_event_processor.dart @@ -1,5 +1,7 @@ // ignore_for_file: deprecated_member_use +import 'dart:convert'; + import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; @@ -62,7 +64,7 @@ class DioEventProcessor implements EventProcessor { } /// Returns the request data, if possible according to the users settings. - Object? _getRequestData(dynamic data) { + Object? _getRequestData(Object? data) { if (!_options.sendDefaultPii) { return null; } @@ -87,8 +89,74 @@ class DioEventProcessor implements EventProcessor { return SentryResponse( headers: _options.sendDefaultPii ? headers : null, - bodySize: dioError.response?.data?.length as int?, + bodySize: _getBodySize( + dioError.response?.data, + dioError.requestOptions.responseType, + ), statusCode: response?.statusCode, + data: _getResponseData( + dioError.response?.data, + dioError.requestOptions.responseType, + ), ); } + + /// Returns the response data, if possible according to the users settings. + Object? _getResponseData(Object? data, ResponseType responseType) { + if (!_options.sendDefaultPii || data == null) { + return null; + } + switch (responseType) { + case ResponseType.json: + // ignore: invalid_use_of_internal_member + final jsData = utf8JsonEncoder.convert(data); + if (_options.maxResponseBodySize.shouldAddBody(jsData.length)) { + return data; + } + break; + case ResponseType.stream: + break; // No support for logging stream body. + case ResponseType.plain: + if (data is String && + _options.maxResponseBodySize.shouldAddBody(data.codeUnits.length)) { + return data; + } + break; + case ResponseType.bytes: + if (data is List && + _options.maxResponseBodySize.shouldAddBody(data.length)) { + return data; + } + break; + } + return null; + } + + int? _getBodySize(Object? data, ResponseType responseType) { + if (data == null) { + return null; + } + switch (responseType) { + case ResponseType.json: + return json.encode(data).codeUnits.length; + case ResponseType.stream: + if (data is String) { + return data.length; + } else { + return null; + } + case ResponseType.plain: + if (data is String) { + return data.codeUnits.length; + } else { + return null; + } + case ResponseType.bytes: + if (data is List) { + return data.length; + } else { + return null; + } + } + } } diff --git a/dio/test/dio_event_processor_test.dart b/dio/test/dio_event_processor_test.dart index 62520b6f44..af45d0c681 100644 --- a/dio/test/dio_event_processor_test.dart +++ b/dio/test/dio_event_processor_test.dart @@ -132,6 +132,64 @@ void main() { expect(processedEvent.request?.headers, {}); }); + + test('request body is included according to $MaxResponseBodySize', + () async { + final scenarios = [ + // never + MaxBodySizeTestConfig(MaxRequestBodySize.never, 0, false), + MaxBodySizeTestConfig(MaxRequestBodySize.never, 4001, false), + MaxBodySizeTestConfig(MaxRequestBodySize.never, 10001, false), + // always + MaxBodySizeTestConfig(MaxRequestBodySize.always, 0, true), + MaxBodySizeTestConfig(MaxRequestBodySize.always, 4001, true), + MaxBodySizeTestConfig(MaxRequestBodySize.always, 10001, true), + // small + MaxBodySizeTestConfig(MaxRequestBodySize.small, 0, true), + MaxBodySizeTestConfig(MaxRequestBodySize.small, 4000, true), + MaxBodySizeTestConfig(MaxRequestBodySize.small, 4001, false), + // medium + MaxBodySizeTestConfig(MaxRequestBodySize.medium, 0, true), + MaxBodySizeTestConfig(MaxRequestBodySize.medium, 4001, true), + MaxBodySizeTestConfig(MaxRequestBodySize.medium, 10000, true), + MaxBodySizeTestConfig(MaxRequestBodySize.medium, 10001, false), + ]; + + for (final scenario in scenarios) { + final sut = fixture.getSut( + sendDefaultPii: true, + captureFailedRequests: true, + maxRequestBodySize: scenario.maxBodySize, + ); + + final data = List.generate(scenario.contentLength, (index) => 0); + final request = requestOptions.copyWith(method: 'POST', data: data); + final throwable = Exception(); + final dioError = DioError( + requestOptions: request, + response: Response( + requestOptions: request, + statusCode: 401, + data: data, + ), + ); + final event = SentryEvent( + throwable: throwable, + exceptions: [ + fixture.sentryError(throwable), + fixture.sentryError(dioError) + ], + ); + final processedEvent = sut.apply(event) as SentryEvent; + final capturedRequest = processedEvent.request; + + expect(capturedRequest, isNotNull); + expect( + capturedRequest?.data, + scenario.shouldBeIncluded ? isNotNull : isNull, + ); + } + }); }); group('response', () { @@ -140,6 +198,7 @@ void main() { final request = requestOptions.copyWith( method: 'POST', + responseType: ResponseType.plain, ); final throwable = Exception(); final dioError = DioError( @@ -181,6 +240,7 @@ void main() { final request = requestOptions.copyWith( method: 'POST', + responseType: ResponseType.plain, ); final throwable = Exception(); final dioError = DioError( @@ -211,6 +271,121 @@ void main() { expect(processedEvent.contexts.response?.statusCode, 200); expect(processedEvent.contexts.response?.headers, {}); }); + + test('response body is included according to $MaxResponseBodySize', + () async { + final scenarios = [ + // never + MaxBodySizeTestConfig(MaxResponseBodySize.never, 0, false), + MaxBodySizeTestConfig(MaxResponseBodySize.never, 4001, false), + MaxBodySizeTestConfig(MaxResponseBodySize.never, 10001, false), + // always + MaxBodySizeTestConfig(MaxResponseBodySize.always, 0, true), + MaxBodySizeTestConfig(MaxResponseBodySize.always, 4001, true), + MaxBodySizeTestConfig(MaxResponseBodySize.always, 10001, true), + // small + MaxBodySizeTestConfig(MaxResponseBodySize.small, 0, true), + MaxBodySizeTestConfig(MaxResponseBodySize.small, 4000, true), + MaxBodySizeTestConfig(MaxResponseBodySize.small, 4001, false), + // medium + MaxBodySizeTestConfig(MaxResponseBodySize.medium, 0, true), + MaxBodySizeTestConfig(MaxResponseBodySize.medium, 4001, true), + MaxBodySizeTestConfig(MaxResponseBodySize.medium, 10000, true), + MaxBodySizeTestConfig(MaxResponseBodySize.medium, 10001, false), + ]; + + for (final scenario in scenarios) { + final sut = fixture.getSut( + sendDefaultPii: true, + captureFailedRequests: true, + maxResponseBodySize: scenario.maxBodySize, + ); + + final data = List.generate(scenario.contentLength, (index) => 0); + final request = requestOptions.copyWith( + method: 'POST', + data: data, + responseType: ResponseType.bytes, + ); + final throwable = Exception(); + final dioError = DioError( + requestOptions: request, + response: Response( + requestOptions: request, + statusCode: 401, + data: data, + ), + ); + final event = SentryEvent( + throwable: throwable, + exceptions: [ + fixture.sentryError(throwable), + fixture.sentryError(dioError) + ], + ); + final processedEvent = sut.apply(event) as SentryEvent; + final capturedResponse = processedEvent.contexts.response; + + expect(capturedResponse, isNotNull); + expect( + capturedResponse?.data, + scenario.shouldBeIncluded ? isNotNull : isNull, + ); + } + }); + + test('data supports all response body types', () async { + final dataByType = { + ResponseType.plain: ['plain'], + ResponseType.bytes: [ + [1337] + ], + ResponseType.json: [ + 9001, + null, + 'string', + true, + ['list'], + {'map-key': 'map-value'}, + ] + }; + + for (final entry in dataByType.entries) { + final responseType = entry.key; + + for (final data in entry.value) { + final request = requestOptions.copyWith( + method: 'POST', + data: data, + responseType: responseType, + ); + final throwable = Exception(); + final dioError = DioError( + requestOptions: request, + response: Response( + requestOptions: request, + statusCode: 401, + data: data, + ), + ); + + final sut = fixture.getSut(sendDefaultPii: true); + + final event = SentryEvent( + throwable: throwable, + exceptions: [ + fixture.sentryError(throwable), + fixture.sentryError(dioError) + ], + ); + final processedEvent = sut.apply(event) as SentryEvent; + final capturedResponse = processedEvent.contexts.response; + + expect(capturedResponse, isNotNull); + expect(capturedResponse?.data, data); + } + } + }); }); test('$DioEventProcessor adds chained stacktraces', () { @@ -266,12 +441,18 @@ class Fixture { // ignore: invalid_use_of_internal_member SentryExceptionFactory get exceptionFactory => options.exceptionFactory; - DioEventProcessor getSut({bool sendDefaultPii = false}) { + DioEventProcessor getSut({ + bool sendDefaultPii = false, + bool captureFailedRequests = true, + MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.always, + MaxResponseBodySize maxResponseBodySize = MaxResponseBodySize.always, + }) { return DioEventProcessor( options ..sendDefaultPii = sendDefaultPii - ..maxRequestBodySize = MaxRequestBodySize.always - ..maxResponseBodySize = MaxResponseBodySize.always, + ..captureFailedRequests = captureFailedRequests + ..maxRequestBodySize = maxRequestBodySize + ..maxResponseBodySize = maxResponseBodySize, ); } @@ -283,3 +464,17 @@ class Fixture { ); } } + +class MaxBodySizeTestConfig { + MaxBodySizeTestConfig( + this.maxBodySize, + this.contentLength, + this.shouldBeIncluded, + ); + + final T maxBodySize; + final int contentLength; + final bool shouldBeIncluded; + + Matcher get matcher => shouldBeIncluded ? isNotNull : isNull; +}