From 94daf24d589a4fd7565ab41c8c2c80588af8d237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Uek=C3=B6tter?= Date: Mon, 25 Jul 2022 08:36:38 +0200 Subject: [PATCH] Feature: Add http response to event (#934) --- CHANGELOG.md | 6 + dart/lib/src/protocol.dart | 3 +- dart/lib/src/protocol/contexts.dart | 24 +++ dart/lib/src/protocol/max_body_size.dart | 79 +++++++++ .../src/protocol/max_request_body_size.dart | 36 ---- dart/lib/src/protocol/sentry_response.dart | 107 +++++++++++ dart/test/protocol/sentry_response_test.dart | 78 ++++++++ dio/example/example.dart | 2 + dio/lib/src/dio_event_processor.dart | 53 +++++- dio/lib/src/sentry_dio_extension.dart | 9 +- dio/test/dio_event_processor_test.dart | 167 ++++++++++++------ 11 files changed, 469 insertions(+), 95 deletions(-) create mode 100644 dart/lib/src/protocol/max_body_size.dart delete mode 100644 dart/lib/src/protocol/max_request_body_size.dart create mode 100644 dart/lib/src/protocol/sentry_response.dart create mode 100644 dart/test/protocol/sentry_response_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c6901286d6..226f33eaca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +* Dio Integration adds response data ([#934](https://github.com/getsentry/sentry-dart/pull/934)) + ## 6.7.0 ### Fixes diff --git a/dart/lib/src/protocol.dart b/dart/lib/src/protocol.dart index eae944558c..e0aaa24b37 100644 --- a/dart/lib/src/protocol.dart +++ b/dart/lib/src/protocol.dart @@ -11,6 +11,7 @@ export 'protocol/mechanism.dart'; export 'protocol/sentry_message.dart'; export 'protocol/sentry_operating_system.dart'; export 'protocol/sentry_request.dart'; +export 'protocol/sentry_response.dart'; export 'protocol/sdk_info.dart'; export 'protocol/sdk_version.dart'; export 'protocol/sentry_event.dart'; @@ -22,7 +23,7 @@ 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/max_body_size.dart'; export 'protocol/sentry_culture.dart'; export 'protocol/sentry_thread.dart'; export 'sentry_event_like.dart'; diff --git a/dart/lib/src/protocol/contexts.dart b/dart/lib/src/protocol/contexts.dart index 4abc21f954..8f4a204cef 100644 --- a/dart/lib/src/protocol/contexts.dart +++ b/dart/lib/src/protocol/contexts.dart @@ -1,5 +1,7 @@ import 'dart:collection'; +import 'package:meta/meta.dart'; + import '../protocol.dart'; /// The context interfaces provide additional context data. @@ -18,6 +20,7 @@ class Contexts extends MapView { SentryGpu? gpu, SentryCulture? culture, SentryTraceContext? trace, + SentryResponse? response, }) : super({ SentryDevice.type: device, SentryOperatingSystem.type: operatingSystem, @@ -27,6 +30,7 @@ class Contexts extends MapView { SentryGpu.type: gpu, SentryCulture.type: culture, SentryTraceContext.type: trace, + SentryResponse.type: response, }); /// Deserializes [Contexts] from JSON [Map]. @@ -57,6 +61,9 @@ class Contexts extends MapView { runtimes: data[SentryRuntime.type] != null ? [SentryRuntime.fromJson(Map.from(data[SentryRuntime.type]))] : null, + response: data[SentryResponse.type] != null + ? SentryResponse.fromJson(Map.from(data[SentryResponse.type])) + : null, ); data.keys @@ -126,6 +133,12 @@ class Contexts extends MapView { set trace(SentryTraceContext? trace) => this[SentryTraceContext.type] = trace; + /// Response context for a HTTP response. + @experimental + SentryResponse? get response => this[SentryResponse.type]; + + set response(SentryResponse? value) => this[SentryResponse.type] = value; + /// Produces a [Map] that can be serialized to JSON. Map toJson() { final json = {}; @@ -174,6 +187,13 @@ class Contexts extends MapView { } break; + case SentryResponse.type: + final responseMap = response?.toJson(); + if (responseMap?.isNotEmpty ?? false) { + json[SentryResponse.type] = responseMap; + } + break; + case SentryTraceContext.type: final traceMap = trace?.toJson(); if (traceMap?.isNotEmpty ?? false) { @@ -230,6 +250,7 @@ class Contexts extends MapView { culture: culture?.clone(), gpu: gpu?.clone(), trace: trace?.clone(), + response: response?.clone(), runtimes: runtimes.map((runtime) => runtime.clone()).toList(), )..addEntries( entries.where((element) => !_defaultFields.contains(element.key)), @@ -247,6 +268,7 @@ class Contexts extends MapView { SentryCulture? culture, SentryGpu? gpu, SentryTraceContext? trace, + SentryResponse? response, }) => Contexts( device: device ?? this.device, @@ -257,6 +279,7 @@ class Contexts extends MapView { gpu: gpu ?? this.gpu, culture: culture ?? this.culture, trace: trace ?? this.trace, + response: response ?? this.response, )..addEntries( entries.where((element) => !_defaultFields.contains(element.key)), ); @@ -271,5 +294,6 @@ class Contexts extends MapView { SentryBrowser.type, SentryCulture.type, SentryTraceContext.type, + SentryResponse.type, ]; } diff --git a/dart/lib/src/protocol/max_body_size.dart b/dart/lib/src/protocol/max_body_size.dart new file mode 100644 index 0000000000..58782d0e9b --- /dev/null +++ b/dart/lib/src/protocol/max_body_size.dart @@ -0,0 +1,79 @@ +// See https://docs.sentry.io/platforms/dotnet/guides/aspnetcore/configuration/options/#max-request-body-size +import 'package:meta/meta.dart'; + +const _mediumSize = 10000; +const _smallSize = 4000; + +/// 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, +} + +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 <= _mediumSize) { + return true; + } + + if (this == MaxRequestBodySize.small && contentLength <= _smallSize) { + return true; + } + return false; + } +} + +/// Describes the size of http response bodies which should be added to an event +/// This enum might be removed at any time. +@experimental +enum MaxResponseBodySize { + /// Response bodies are never sent + never, + + /// Only small response bodies will be captured where the cutoff for small + /// depends on the SDK (typically 4KB) + small, + + /// Medium and small response 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, +} + +extension MaxResponseBodySizeX on MaxResponseBodySize { + bool shouldAddBody(int contentLength) { + if (this == MaxResponseBodySize.never) { + return false; + } + if (this == MaxResponseBodySize.always) { + return true; + } + if (this == MaxResponseBodySize.medium && contentLength <= _mediumSize) { + return true; + } + + if (this == MaxResponseBodySize.small && contentLength <= _smallSize) { + return true; + } + return false; + } +} diff --git a/dart/lib/src/protocol/max_request_body_size.dart b/dart/lib/src/protocol/max_request_body_size.dart deleted file mode 100644 index 169017639a..0000000000 --- a/dart/lib/src/protocol/max_request_body_size.dart +++ /dev/null @@ -1,36 +0,0 @@ -// 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, -} - -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/protocol/sentry_response.dart b/dart/lib/src/protocol/sentry_response.dart new file mode 100644 index 0000000000..40c25de050 --- /dev/null +++ b/dart/lib/src/protocol/sentry_response.dart @@ -0,0 +1,107 @@ +import 'package:meta/meta.dart'; +import 'contexts.dart'; + +/// The response interface contains information on a HTTP request related to the event. +/// This is an experimental feature. It might be removed at any time. +@experimental +@immutable +class SentryResponse { + /// The tpye of this class in the [Contexts] field + static const String type = 'response'; + + /// The URL of the response if available. + /// This might be the redirected URL + final String? url; + + /// Indicates whether or not the response is the result of a redirect + /// (that is, its URL list has more than one entry). + final bool? redirected; + + /// The body of the response + final Object? body; + + /// The HTTP status code of the response. + /// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status + final int? statusCode; + + /// The status message for the corresponding [statusCode] + final String? status; + + /// An immutable dictionary of submitted headers. + /// If a header appears multiple times it, + /// needs to be merged according to the HTTP standard for header merging. + /// Header names are treated case-insensitively by Sentry. + Map get headers => Map.unmodifiable(_headers ?? const {}); + + final Map? _headers; + + Map get other => Map.unmodifiable(_other ?? const {}); + + final Map? _other; + + SentryResponse({ + this.url, + this.body, + this.redirected, + this.statusCode, + this.status, + Map? headers, + Map? other, + }) : _headers = headers != null ? Map.from(headers) : null, + _other = other != null ? Map.from(other) : null; + + /// Deserializes a [SentryResponse] from JSON [Map]. + factory SentryResponse.fromJson(Map json) { + return SentryResponse( + url: json['url'], + headers: json['headers'], + other: json['other'], + body: json['body'], + statusCode: json['status_code'], + status: json['status'], + redirected: json['redirected'], + ); + } + + /// Produces a [Map] that can be serialized to JSON. + Map toJson() { + return { + if (url != null) 'url': url, + if (headers.isNotEmpty) 'headers': headers, + if (other.isNotEmpty) 'other': other, + if (redirected != null) 'redirected': redirected, + if (body != null) 'body': body, + if (status != null) 'status': status, + if (statusCode != null) 'status_code': statusCode, + }; + } + + SentryResponse copyWith({ + String? url, + bool? redirected, + int? statusCode, + String? status, + Object? body, + Map? headers, + Map? other, + }) => + SentryResponse( + url: url ?? this.url, + headers: headers ?? _headers, + redirected: redirected ?? this.redirected, + other: other ?? _other, + body: body ?? this.body, + status: status ?? this.status, + statusCode: statusCode ?? this.statusCode, + ); + + SentryResponse clone() => SentryResponse( + body: body, + headers: headers, + other: other, + redirected: redirected, + status: status, + statusCode: statusCode, + url: url, + ); +} diff --git a/dart/test/protocol/sentry_response_test.dart b/dart/test/protocol/sentry_response_test.dart new file mode 100644 index 0000000000..957ff61d9f --- /dev/null +++ b/dart/test/protocol/sentry_response_test.dart @@ -0,0 +1,78 @@ +import 'package:collection/collection.dart'; +import 'package:sentry/sentry.dart'; +import 'package:test/test.dart'; + +void main() { + final sentryResponse = SentryResponse( + url: 'url', + body: 'foobar', + redirected: true, + status: 'OK', + statusCode: 200, + headers: {'header_key': 'header_value'}, + other: {'other_key': 'other_value'}, + ); + + final sentryResponseJson = { + 'url': 'url', + 'body': 'foobar', + 'redirected': true, + 'status': 'OK', + 'status_code': 200, + 'headers': {'header_key': 'header_value'}, + 'other': {'other_key': 'other_value'}, + }; + + group('json', () { + test('toJson', () { + final json = sentryResponse.toJson(); + + expect( + DeepCollectionEquality().equals(sentryResponseJson, json), + true, + ); + }); + test('fromJson', () { + final sentryResponse = SentryResponse.fromJson(sentryResponseJson); + final json = sentryResponse.toJson(); + + expect( + DeepCollectionEquality().equals(sentryResponseJson, json), + true, + ); + }); + }); + + group('copyWith', () { + test('copyWith keeps unchanged', () { + final data = sentryResponse; + + final copy = data.copyWith(); + + expect( + DeepCollectionEquality().equals(data.toJson(), copy.toJson()), + true, + ); + }); + + test('copyWith takes new values', () { + final data = sentryResponse; + + final copy = data.copyWith( + url: 'url1', + body: 'barfoo', + headers: {'key1': 'value1'}, + redirected: false, + statusCode: 301, + status: 'REDIRECT', + ); + + expect('url1', copy.url); + expect('barfoo', copy.body); + expect({'key1': 'value1'}, copy.headers); + expect(false, copy.redirected); + expect(301, copy.statusCode); + expect('REDIRECT', copy.status); + }); + }); +} diff --git a/dio/example/example.dart b/dio/example/example.dart index 8c5099d603..ec6e583dde 100644 --- a/dio/example/example.dart +++ b/dio/example/example.dart @@ -12,6 +12,7 @@ Future main() async { options.dsn = dsn; options.tracesSampleRate = 1.0; // needed for Dio `networkTracing` feature options.debug = true; + options.sendDefaultPii = true; }, appRunner: runApp, // Init your App. ); @@ -21,6 +22,7 @@ Future runApp() async { final dio = Dio(); dio.addSentry( maxRequestBodySize: MaxRequestBodySize.small, + maxResponseBodySize: MaxResponseBodySize.small, captureFailedRequests: true, ); diff --git a/dio/lib/src/dio_event_processor.dart b/dio/lib/src/dio_event_processor.dart index 0f9f2ff71d..4094ce3ce2 100644 --- a/dio/lib/src/dio_event_processor.dart +++ b/dio/lib/src/dio_event_processor.dart @@ -13,10 +13,15 @@ class DioEventProcessor implements EventProcessor { static final _dioErrorType = (DioError).toString(); /// This is an [EventProcessor], which improves crash reports of [DioError]s. - DioEventProcessor(this._options, this._maxRequestBodySize); + DioEventProcessor( + this._options, + this._maxRequestBodySize, + this._maxResponseBodySize, + ); final SentryOptions _options; final MaxRequestBodySize _maxRequestBodySize; + final MaxResponseBodySize _maxResponseBodySize; SentryExceptionFactory get _sentryExceptionFactory => // ignore: invalid_use_of_internal_member @@ -29,9 +34,18 @@ class DioEventProcessor implements EventProcessor { return event; } + final response = _responseFrom(dioError); + + Contexts contexts = event.contexts; + if (event.contexts.response == null) { + contexts = contexts.copyWith(response: response); + } // Don't override just parts of the original request. // Keep the original one or if there's none create one. - event = event.copyWith(request: event.request ?? _requestFrom(dioError)); + event = event.copyWith( + request: event.request ?? _requestFrom(dioError), + contexts: contexts, + ); final innerDioStackTrace = dioError.stackTrace; final innerDioErrorException = dioError.error as Object?; @@ -140,4 +154,39 @@ class DioEventProcessor implements EventProcessor { } return null; } + + SentryResponse? _responseFrom(DioError dioError) { + final response = dioError.response; + + final headers = response?.headers.map.map( + (key, value) => MapEntry(key, value.join('; ')), + ); + + return SentryResponse( + headers: _options.sendDefaultPii ? headers : null, + url: response?.realUri.toString(), + redirected: response?.isRedirect, + body: _getResponseData(dioError.response?.data), + statusCode: response?.statusCode, + status: response?.statusMessage, + ); + } + + /// Returns the request data, if possible according to the users settings. + /// Type checks are based on DIOs [ResponseType]. + Object? _getResponseData(dynamic data) { + if (!_options.sendDefaultPii) { + return null; + } + if (data is String) { + if (_maxResponseBodySize.shouldAddBody(data.codeUnits.length)) { + return data; + } + } else if (data is List) { + if (_maxResponseBodySize.shouldAddBody(data.length)) { + return data; + } + } + return null; + } } diff --git a/dio/lib/src/sentry_dio_extension.dart b/dio/lib/src/sentry_dio_extension.dart index a75226c779..d9168211de 100644 --- a/dio/lib/src/sentry_dio_extension.dart +++ b/dio/lib/src/sentry_dio_extension.dart @@ -15,6 +15,7 @@ extension SentryDioExtension on Dio { bool recordBreadcrumbs = true, bool networkTracing = true, MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.never, + MaxResponseBodySize maxResponseBodySize = MaxResponseBodySize.never, bool captureFailedRequests = false, Hub? hub, }) { @@ -26,7 +27,13 @@ extension SentryDioExtension on Dio { // Add DioEventProcessor when it's not already present if (options.eventProcessors.whereType().isEmpty) { options.sdk.addIntegration('sentry_dio'); - options.addEventProcessor(DioEventProcessor(options, maxRequestBodySize)); + options.addEventProcessor( + DioEventProcessor( + options, + maxRequestBodySize, + maxResponseBodySize, + ), + ); } if (captureFailedRequests) { diff --git a/dio/test/dio_event_processor_test.dart b/dio/test/dio_event_processor_test.dart index a21b749e60..596569191b 100644 --- a/dio/test/dio_event_processor_test.dart +++ b/dio/test/dio_event_processor_test.dart @@ -39,74 +39,130 @@ void main() { expect(event.request, processedEvent.request); }); - test('$DioEventProcessor adds request', () { - final sut = fixture.getSut(sendDefaultPii: true); + group('request', () { + test('$DioEventProcessor adds request', () { + final sut = fixture.getSut(sendDefaultPii: true); - final request = requestOptions.copyWith( - method: 'POST', - data: 'foobar', - ); - final event = SentryEvent( - throwable: DioError( - requestOptions: request, - response: Response( + final request = requestOptions.copyWith( + method: 'POST', + data: 'foobar', + ); + final event = SentryEvent( + throwable: DioError( requestOptions: request, + response: Response( + requestOptions: request, + ), ), - ), - ); - final processedEvent = sut.apply(event) as SentryEvent; - - expect(processedEvent.throwable, event.throwable); - expect(processedEvent.request?.method, 'POST'); - expect(processedEvent.request?.queryString, 'foo=bar'); - expect(processedEvent.request?.headers, { - 'foo': 'bar', - 'content-type': 'application/json; charset=utf-8' + ); + final processedEvent = sut.apply(event) as SentryEvent; + + expect(processedEvent.throwable, event.throwable); + expect(processedEvent.request?.method, 'POST'); + expect(processedEvent.request?.queryString, 'foo=bar'); + expect(processedEvent.request?.headers, { + 'foo': 'bar', + 'content-type': 'application/json; charset=utf-8' + }); + expect(processedEvent.request?.data, 'foobar'); }); - expect(processedEvent.request?.data, 'foobar'); - }); - test('$DioEventProcessor adds request without pii', () { - final sut = fixture.getSut(sendDefaultPii: false); + test('$DioEventProcessor adds request without pii', () { + final sut = fixture.getSut(sendDefaultPii: false); - final event = SentryEvent( - throwable: DioError( - requestOptions: requestOptions, - response: Response( + final event = SentryEvent( + throwable: DioError( requestOptions: requestOptions, - data: 'foobar', + response: Response( + requestOptions: requestOptions, + data: 'foobar', + ), ), - ), - ); - final processedEvent = sut.apply(event) as SentryEvent; - - expect(processedEvent.throwable, event.throwable); - expect(processedEvent.request?.method, 'GET'); - expect(processedEvent.request?.queryString, 'foo=bar'); - expect(processedEvent.request?.data, null); - expect(processedEvent.request?.headers, {}); + ); + final processedEvent = sut.apply(event) as SentryEvent; + + expect(processedEvent.throwable, event.throwable); + expect(processedEvent.request?.method, 'GET'); + expect(processedEvent.request?.queryString, 'foo=bar'); + expect(processedEvent.request?.data, null); + expect(processedEvent.request?.headers, {}); + }); }); - test('$DioEventProcessor adds request without pii', () { - final sut = fixture.getSut(sendDefaultPii: false); - final dioError = DioError( - error: Exception('foo bar'), - requestOptions: requestOptions, - response: Response( - requestOptions: requestOptions, - data: 'foobar', - ), - ); + group('response', () { + test('$DioEventProcessor adds response', () { + final sut = fixture.getSut(sendDefaultPii: true); - final event = SentryEvent(throwable: dioError); + final request = requestOptions.copyWith( + method: 'POST', + ); + final event = SentryEvent( + throwable: DioError( + requestOptions: request, + response: Response( + data: 'foobar', + headers: Headers.fromMap(>{ + 'foo': ['bar'] + }), + requestOptions: request, + isRedirect: true, + statusCode: 200, + statusMessage: 'OK', + ), + ), + ); + final processedEvent = sut.apply(event) as SentryEvent; + + expect(processedEvent.throwable, event.throwable); + expect(processedEvent.contexts.response, isNotNull); + expect(processedEvent.contexts.response?.body, 'foobar'); + expect(processedEvent.contexts.response?.redirected, true); + expect(processedEvent.contexts.response?.status, 'OK'); + expect(processedEvent.contexts.response?.statusCode, 200); + expect( + processedEvent.contexts.response?.url, + 'https://example.org/foo/bar?foo=bar', + ); + expect(processedEvent.contexts.response?.headers, { + 'foo': 'bar', + }); + }); - final processedEvent = sut.apply(event) as SentryEvent; + test('$DioEventProcessor adds response without PII', () { + final sut = fixture.getSut(sendDefaultPii: false); - expect(processedEvent.throwable, event.throwable); - expect(processedEvent.request?.method, 'GET'); - expect(processedEvent.request?.queryString, 'foo=bar'); - expect(processedEvent.request?.data, null); - expect(processedEvent.request?.headers, {}); + final request = requestOptions.copyWith( + method: 'POST', + ); + final event = SentryEvent( + throwable: DioError( + requestOptions: request, + response: Response( + data: 'foobar', + headers: Headers.fromMap(>{ + 'foo': ['bar'] + }), + requestOptions: request, + isRedirect: true, + statusCode: 200, + statusMessage: 'OK', + ), + ), + ); + final processedEvent = sut.apply(event) as SentryEvent; + + expect(processedEvent.throwable, event.throwable); + expect(processedEvent.contexts.response, isNotNull); + expect(processedEvent.contexts.response?.body, isNull); + expect(processedEvent.contexts.response?.redirected, true); + expect(processedEvent.contexts.response?.status, 'OK'); + expect(processedEvent.contexts.response?.statusCode, 200); + expect( + processedEvent.contexts.response?.url, + 'https://example.org/foo/bar?foo=bar', + ); + expect(processedEvent.contexts.response?.headers, {}); + }); }); test('$DioEventProcessor adds chained stacktraces', () { @@ -155,6 +211,7 @@ class Fixture { return DioEventProcessor( options..sendDefaultPii = sendDefaultPii, MaxRequestBodySize.always, + MaxResponseBodySize.always, ); } }