From 1342baaddf9191474ded34d217ee1480617ddc39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Sun, 2 Jan 2022 15:20:17 +0100 Subject: [PATCH 01/18] Add support for dio --- .github/workflows/dart.yml | 1 + .github/workflows/dio.yml | 90 ++++++ .github/workflows/flutter.yml | 1 + .github/workflows/logging.yml | 3 + CHANGELOG.md | 2 + dart/lib/sentry.dart | 1 + dio/.gitignore | 10 + dio/CHANGELOG.md | 1 + dio/README.md | 54 ++++ dio/analysis_options.yaml | 34 +++ dio/example/dio_example.dart | 17 ++ dio/lib/sentry_dio.dart | 3 + dio/lib/src/breadcrumb_client_adapter.dart | 70 +++++ .../src/failed_request_client_adapter.dart | 164 +++++++++++ dio/lib/src/sentry_client_adapter.dart | 103 +++++++ dio/lib/src/tracing_client_adapter.dart | 58 ++++ dio/pubspec.yaml | 22 ++ dio/test/breadcrumb_client_adapter_test.dart | 216 ++++++++++++++ .../failed_request_client_adapter_test.dart | 271 ++++++++++++++++++ dio/test/mocks.dart | 164 +++++++++++ dio/test/mocks/mock_http_client_adapter.dart | 27 ++ dio/test/mocks/mock_hub.dart | 205 +++++++++++++ dio/test/mocks/mock_transport.dart | 68 +++++ dio/test/sentry_http_client_adapter_test.dart | 156 ++++++++++ dio/test/tracing_client_adapter_test.dart | 180 ++++++++++++ 25 files changed, 1921 insertions(+) create mode 100644 .github/workflows/dio.yml create mode 100644 dio/.gitignore create mode 120000 dio/CHANGELOG.md create mode 100644 dio/README.md create mode 100644 dio/analysis_options.yaml create mode 100644 dio/example/dio_example.dart create mode 100644 dio/lib/sentry_dio.dart create mode 100644 dio/lib/src/breadcrumb_client_adapter.dart create mode 100644 dio/lib/src/failed_request_client_adapter.dart create mode 100644 dio/lib/src/sentry_client_adapter.dart create mode 100644 dio/lib/src/tracing_client_adapter.dart create mode 100644 dio/pubspec.yaml create mode 100644 dio/test/breadcrumb_client_adapter_test.dart create mode 100644 dio/test/failed_request_client_adapter_test.dart create mode 100644 dio/test/mocks.dart create mode 100644 dio/test/mocks/mock_http_client_adapter.dart create mode 100644 dio/test/mocks/mock_hub.dart create mode 100644 dio/test/mocks/mock_transport.dart create mode 100644 dio/test/sentry_http_client_adapter_test.dart create mode 100644 dio/test/tracing_client_adapter_test.dart diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 69d9d69173..3d6972137c 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -6,6 +6,7 @@ on: pull_request: paths-ignore: - 'logging/**' + - 'dio/**' defaults: run: shell: bash diff --git a/.github/workflows/dio.yml b/.github/workflows/dio.yml new file mode 100644 index 0000000000..8a3e878f34 --- /dev/null +++ b/.github/workflows/dio.yml @@ -0,0 +1,90 @@ +name: sentry-dio +on: + push: + branches: + - main + pull_request: + paths-ignore: + - 'logging/**' + - 'flutter/**' +defaults: + run: + shell: bash +jobs: + build: + name: Build ${{matrix.sdk}} on ${{matrix.os}} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + defaults: + run: + working-directory: ./dio + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + sdk: [stable, beta] + exclude: + - os: macos-latest + sdk: beta + steps: + - uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ matrix.sdk }} + - uses: actions/checkout@v2 + # coverage with 'chrome' platform hangs the build + - name: Test (VM and browser) + run: | + dart pub get + dart test -p chrome + dart test -p vm --coverage=coverage + dart pub run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info --packages=.packages --report-on=lib + + - uses: codecov/codecov-action@v2 + if: runner.os == 'Linux' + with: + name: sentry + file: ./dio/coverage/lcov.info + + - uses: VeryGoodOpenSource/very_good_coverage@v1.2.0 + if: runner.os == 'Linux' + with: + path: "./dio/coverage/lcov.info" + min_coverage: 90 + + analyze: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ./dio + steps: + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + - uses: actions/checkout@v2 + - run: | + dart pub get + dart analyze --fatal-infos + dart format --set-exit-if-changed ./ + + package-analysis: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v2 + - uses: axel-op/dart-package-analyzer@v3 + id: analysis + with: + githubToken: ${{ secrets.GITHUB_TOKEN }} + relativePath: dio/ + - name: Check scores + env: + TOTAL: ${{ steps.analysis.outputs.total }} + TOTAL_MAX: ${{ steps.analysis.outputs.total_max }} + run: | + PERCENTAGE=$(( $TOTAL * 100 / $TOTAL_MAX )) + if (( $PERCENTAGE < 100 )) + then + echo Score too low! + exit 1 + fi diff --git a/.github/workflows/flutter.yml b/.github/workflows/flutter.yml index 2874b83bff..1ac750fe2f 100644 --- a/.github/workflows/flutter.yml +++ b/.github/workflows/flutter.yml @@ -6,6 +6,7 @@ on: pull_request: paths-ignore: - 'logging/**' + - 'dio/**' defaults: run: shell: bash diff --git a/.github/workflows/logging.yml b/.github/workflows/logging.yml index dab69e1ced..5f6b1d05df 100644 --- a/.github/workflows/logging.yml +++ b/.github/workflows/logging.yml @@ -4,6 +4,9 @@ on: branches: - main pull_request: + paths-ignore: + - 'dio/**' + - 'flutter/**' defaults: run: shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 90eada885b..f528a2b835 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +* Feat: Add support for [Dio](https://pub.dev/packages/dio) + # 6.3.0-alpha.1 * Feat: Automatically create transactions when navigating between screens (#643) diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index d9d3387eca..40fc29d8e8 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -22,6 +22,7 @@ export 'src/transport/transport.dart'; export 'src/integration.dart'; export 'src/event_processor.dart'; export 'src/http_client/sentry_http_client.dart'; +export 'src/http_client/sentry_http_client_error.dart'; export 'src/sentry_attachment/sentry_attachment.dart'; export 'src/sentry_user_feedback.dart'; // tracing diff --git a/dio/.gitignore b/dio/.gitignore new file mode 100644 index 0000000000..65c34dc86e --- /dev/null +++ b/dio/.gitignore @@ -0,0 +1,10 @@ +# Files and directories created by pub. +.dart_tool/ +.packages + +# Conventional directory for build outputs. +build/ + +# Omit committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md new file mode 120000 index 0000000000..04c99a55ca --- /dev/null +++ b/dio/CHANGELOG.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/dio/README.md b/dio/README.md new file mode 100644 index 0000000000..2eb90a44ce --- /dev/null +++ b/dio/README.md @@ -0,0 +1,54 @@ +

+ + + +
+

+ +Sentry integration for `dio` package +=========== + +| package | build | pub | likes | popularity | pub points | +| ------- | ------- | ------- | ------- | ------- | ------- | +| sentry | [![build](https://github.com/getsentry/sentry-dart/workflows/sentry-dio/badge.svg?branch=main)](https://github.com/getsentry/sentry-dart/actions?query=workflow%3Asentry-dio) | [![pub package](https://img.shields.io/pub/v/sentry_dio.svg)](https://pub.dev/packages/sentry_dio) | [![likes](https://badges.bar/sentry_dio/likes)](https://pub.dev/packages/sentry_dio/score) | [![popularity](https://badges.bar/sentry_dio/popularity)](https://pub.dev/packages/sentry_dio/score) | [![pub points](https://badges.bar/sentry_dio/pub%20points)](https://pub.dev/packages/sentry_dio/score) + +Integration for the [`dio`](https://pub.dev/packages/dio) package. + +#### Flutter + +For Flutter applications there's [`sentry_flutter`](https://pub.dev/packages/sentry_flutter) which builds on top of this package. +That will give you native crash support (for Android and iOS), [release health](https://docs.sentry.io/product/releases/health/), offline caching and more. + +#### Usage + +- Sign up for a Sentry.io account and get a DSN at http://sentry.io. + +- Follow the installing instructions on [pub.dev](https://pub.dev/packages/sentry/install). + +- Initialize the Sentry SDK using the DSN issued by Sentry.io: + +```dart +import 'package:sentry/sentry.dart'; + +Future main() async { + await Sentry.init( + (options) { + options.dsn = 'https://example@sentry.io/example'; + }, + appRunner: initDio, // Init your App. + ); +} + +void initDio() { + final dio = Dio(); + dio.httpClientAdapter = SentryHttpClientAdapter(); +} +``` + +#### Resources + +* [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dart/) +* [![Forum](https://img.shields.io/badge/forum-sentry-green.svg)](https://forum.sentry.io/c/sdks) +* [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr) +* [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](https://stackoverflow.com/questions/tagged/sentry) +* [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry) \ No newline at end of file diff --git a/dio/analysis_options.yaml b/dio/analysis_options.yaml new file mode 100644 index 0000000000..0dd5cf84dd --- /dev/null +++ b/dio/analysis_options.yaml @@ -0,0 +1,34 @@ +include: package:lints/recommended.yaml + +analyzer: + strong-mode: + implicit-casts: false + implicit-dynamic: false + language: + strict-raw-types: true + errors: + # treat missing required parameters as a warning (not a hint) + missing_required_param: error + # treat missing returns as a warning (not a hint) + missing_return: error + # allow having TODOs in the code + todo: ignore + # allow self-reference to deprecated members (we do this because otherwise we have + # to annotate every member in every test, assert, etc, when we deprecate something) + deprecated_member_use_from_same_package: warning + # ignore sentry/path on pubspec as we change it on deployment + invalid_dependency: ignore + unnecessary_import: ignore + exclude: + - example/** + +linter: + rules: + - prefer_final_locals + - public_member_api_docs + - prefer_single_quotes + - prefer_relative_imports + - unnecessary_brace_in_string_interps + - implementation_imports + - require_trailing_commas + - unawaited_futures \ No newline at end of file diff --git a/dio/example/dio_example.dart b/dio/example/dio_example.dart new file mode 100644 index 0000000000..d5bb7fa4f2 --- /dev/null +++ b/dio/example/dio_example.dart @@ -0,0 +1,17 @@ +import 'package:dio/dio.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_dio/sentry_dio.dart'; + +Future main() async { + await Sentry.init( + (options) { + options.dsn = 'https://example@sentry.io/example'; + }, + appRunner: initDio, // Init your App. + ); +} + +void initDio() { + final dio = Dio(); + dio.httpClientAdapter = SentryHttpClientAdapter(); +} diff --git a/dio/lib/sentry_dio.dart b/dio/lib/sentry_dio.dart new file mode 100644 index 0000000000..1a6d48e9bd --- /dev/null +++ b/dio/lib/sentry_dio.dart @@ -0,0 +1,3 @@ +library sentry_dio; + +export 'src/sentry_client_adapter.dart'; diff --git a/dio/lib/src/breadcrumb_client_adapter.dart b/dio/lib/src/breadcrumb_client_adapter.dart new file mode 100644 index 0000000000..0ede9bf9a4 --- /dev/null +++ b/dio/lib/src/breadcrumb_client_adapter.dart @@ -0,0 +1,70 @@ +// ignore_for_file: strict_raw_type + +import 'dart:typed_data'; + +import 'package:dio/adapter.dart'; +import 'package:dio/dio.dart'; +import 'package:sentry/sentry.dart'; + +/// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter +/// which records requests as breadcrumbs. +/// +/// Remarks: +/// If this client is used as a wrapper, a call to close also closes the +/// given client. +class BreadcrumbClientAdapter extends HttpClientAdapter { + // ignore: public_member_api_docs + BreadcrumbClientAdapter({HttpClientAdapter? client, Hub? hub}) + : _hub = hub ?? HubAdapter(), + _client = client ?? DefaultHttpClientAdapter(); + + final HttpClientAdapter _client; + final Hub _hub; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + // See https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/ + + var requestHadException = false; + int? statusCode; + String? reason; + int? responseBodySize; + + final stopwatch = Stopwatch(); + stopwatch.start(); + + try { + final response = + await _client.fetch(options, requestStream, cancelFuture); + + statusCode = response.statusCode; + reason = response.statusMessage; + + return response; + } catch (_) { + requestHadException = true; + rethrow; + } finally { + stopwatch.stop(); + + final breadcrumb = Breadcrumb.http( + level: requestHadException ? SentryLevel.error : SentryLevel.info, + url: options.uri, + method: options.method, + statusCode: statusCode, + reason: reason, + requestDuration: stopwatch.elapsed, + responseBodySize: responseBodySize, + ); + + _hub.addBreadcrumb(breadcrumb); + } + } + + @override + void close({bool force = false}) => _client.close(force: force); +} diff --git a/dio/lib/src/failed_request_client_adapter.dart b/dio/lib/src/failed_request_client_adapter.dart new file mode 100644 index 0000000000..1c911e44e4 --- /dev/null +++ b/dio/lib/src/failed_request_client_adapter.dart @@ -0,0 +1,164 @@ +// ignore_for_file: strict_raw_type + +import 'dart:typed_data'; + +import 'package:dio/adapter.dart'; +import 'package:dio/dio.dart'; +import 'package:sentry/sentry.dart'; + +/// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter +/// 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. +/// +/// Remarks: +/// If this client is used as a wrapper, a call to close also closes the +/// given client. +class FailedRequestClientAdapter extends HttpClientAdapter { + // ignore: public_member_api_docs + FailedRequestClientAdapter({ + this.maxRequestBodySize = MaxRequestBodySize.small, + this.failedRequestStatusCodes = const [], + this.captureFailedRequests = true, + this.sendDefaultPii = false, + HttpClientAdapter? client, + Hub? hub, + }) : _hub = hub ?? HubAdapter(), + _client = client ?? DefaultHttpClientAdapter(); + + final HttpClientAdapter _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; + + /// Configures wether default PII is enabled for this client adapter. + final bool sendDefaultPii; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + int? statusCode; + Object? exception; + StackTrace? stackTrace; + + final stopwatch = Stopwatch(); + stopwatch.start(); + + try { + final response = + await _client.fetch(options, requestStream, cancelFuture); + 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, + options: options, + requestDuration: stopwatch.elapsed, + ); + } else if (failedRequestStatusCodes.containsStatusCode(statusCode)) { + final message = + 'Event was captured because the request status code was $statusCode'; + final httpException = SentryHttpClientError(message); + + // Capture an exception if the status code is considered bad + await _captureEvent( + exception: exception ?? httpException, + options: options, + reason: message, + requestDuration: stopwatch.elapsed, + ); + } + } + } + + @override + void close({bool force = false}) => _client.close(force: force); + + // See https://develop.sentry.dev/sdk/event-payloads/request/ + Future _captureEvent({ + required Object? exception, + StackTrace? stackTrace, + String? reason, + required Duration requestDuration, + required RequestOptions options, + }) async { + // 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 = options.uri.replace(query: '').toString(); + + final query = options.uri.query.isEmpty ? null : options.uri.query; + + final headers = options.headers + .map((key, dynamic value) => MapEntry(key, value?.toString() ?? '')); + + final sentryRequest = SentryRequest( + method: options.method, + headers: sendDefaultPii ? headers : null, + url: urlWithoutQuery, + queryString: query, + cookies: sendDefaultPii ? options.headers['Cookie']?.toString() : null, + other: { + 'duration': requestDuration.toString(), + }, + ); + + final mechanism = Mechanism( + type: 'SentryHttpClient', + description: reason, + ); + final throwableMechanism = ThrowableMechanism(mechanism, exception); + + final event = SentryEvent( + throwable: throwableMechanism, + request: sentryRequest, + ); + await _hub.captureEvent(event, stackTrace: stackTrace); + } +} + +extension _ListX on List { + bool containsStatusCode(int? statusCode) { + if (statusCode == null) { + return false; + } + return any((element) => element.isInRange(statusCode)); + } +} diff --git a/dio/lib/src/sentry_client_adapter.dart b/dio/lib/src/sentry_client_adapter.dart new file mode 100644 index 0000000000..64e497b7ef --- /dev/null +++ b/dio/lib/src/sentry_client_adapter.dart @@ -0,0 +1,103 @@ +// ignore_for_file: strict_raw_type + +import 'dart:typed_data'; + +import 'package:dio/adapter.dart'; +import 'package:dio/dio.dart'; +import 'package:sentry/sentry.dart'; +import 'failed_request_client_adapter.dart'; +import 'tracing_client_adapter.dart'; +import 'breadcrumb_client_adapter.dart'; + +/// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter. +/// +/// It records requests as breadcrumbs. This is on by default. +/// +/// It captures requests which throws 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'; +/// +/// dio.httpClientAdapter = SentryHttpClientAdapter( +/// failedRequestStatusCodes: [ +/// SentryStatusCode.range(400, 404), +/// SentryStatusCode(500), +/// ], +/// ); +/// ``` +/// +/// It starts and finishes a Span if there's a transaction bound to the Scope +/// through the [TracingClientAdapter] client, it's disabled by default. +/// Set [networkTracing] to `true` to enable it. +/// +/// Remarks: If this client is used as a wrapper, a call to close also closes +/// the given client. +/// +/// 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 SentryHttpClientAdapter extends HttpClientAdapter { + // ignore: public_member_api_docs + SentryHttpClientAdapter({ + HttpClientAdapter? client, + Hub? hub, + bool recordBreadcrumbs = true, + bool networkTracing = false, + MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.never, + List failedRequestStatusCodes = const [], + bool captureFailedRequests = false, + bool sendDefaultPii = false, + }) { + _hub = hub ?? HubAdapter(); + + var innerClient = client ?? DefaultHttpClientAdapter(); + + innerClient = FailedRequestClientAdapter( + failedRequestStatusCodes: failedRequestStatusCodes, + captureFailedRequests: captureFailedRequests, + maxRequestBodySize: maxRequestBodySize, + sendDefaultPii: sendDefaultPii, + hub: _hub, + client: innerClient, + ); + + if (networkTracing) { + innerClient = TracingClientAdapter(client: innerClient, hub: _hub); + } + + // 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 = BreadcrumbClientAdapter(client: innerClient, hub: _hub); + } + + _client = innerClient; + } + + late HttpClientAdapter _client; + late Hub _hub; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) => + _client.fetch(options, requestStream, cancelFuture); + + @override + void close({bool force = false}) => _client.close(force: force); +} diff --git a/dio/lib/src/tracing_client_adapter.dart b/dio/lib/src/tracing_client_adapter.dart new file mode 100644 index 0000000000..bbfeb36c9c --- /dev/null +++ b/dio/lib/src/tracing_client_adapter.dart @@ -0,0 +1,58 @@ +// ignore_for_file: strict_raw_type + +import 'dart:typed_data'; + +import 'package:dio/adapter.dart'; +import 'package:dio/dio.dart'; +import 'package:sentry/sentry.dart'; + +/// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter +/// which adds support to Sentry Performance feature. +/// https://develop.sentry.dev/sdk/performance +class TracingClientAdapter extends HttpClientAdapter { + // ignore: public_member_api_docs + TracingClientAdapter({HttpClientAdapter? client, Hub? hub}) + : _hub = hub ?? HubAdapter(), + _client = client ?? DefaultHttpClientAdapter(); + + final HttpClientAdapter _client; + final Hub _hub; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + // see https://develop.sentry.dev/sdk/performance/#header-sentry-trace + final currentSpan = _hub.getSpan(); + final span = currentSpan?.startChild( + 'http.client', + description: '${options.method} ${options.uri}', + ); + + ResponseBody? response; + try { + if (span != null) { + final traceHeader = span.toSentryTrace(); + options.headers[traceHeader.name] = traceHeader.value; + } + + // TODO: tracingOrigins support + + response = await _client.fetch(options, requestStream, cancelFuture); + span?.status = SpanStatus.fromHttpStatusCode(response.statusCode ?? -1); + } catch (exception) { + span?.throwable = exception; + span?.status = const SpanStatus.internalError(); + + rethrow; + } finally { + await span?.finish(); + } + return response; + } + + @override + void close({bool force = false}) => _client.close(force: force); +} diff --git a/dio/pubspec.yaml b/dio/pubspec.yaml new file mode 100644 index 0000000000..eadc2a65b1 --- /dev/null +++ b/dio/pubspec.yaml @@ -0,0 +1,22 @@ +name: sentry_dio +description: An integration which adds support for performance tracing for the Dio package. +version: 6.3.0 +homepage: https://docs.sentry.io/platforms/dart/ +repository: https://github.com/getsentry/sentry-dart +issue_tracker: https://github.com/getsentry/sentry-dart/issues + +environment: + sdk: '>=2.12.0 <3.0.0' + + +dependencies: + dio: ^4.0.0 + sentry: + path: ../dart + +dev_dependencies: + lints: ^1.0.0 + test: ^1.16.0 + yaml: ^3.1.0 # needed for version match (code and pubspec) + coverage: ^1.0.3 + mockito: ^5.0.16 \ No newline at end of file diff --git a/dio/test/breadcrumb_client_adapter_test.dart b/dio/test/breadcrumb_client_adapter_test.dart new file mode 100644 index 0000000000..8aea14fb8e --- /dev/null +++ b/dio/test/breadcrumb_client_adapter_test.dart @@ -0,0 +1,216 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/http_client/breadcrumb_client.dart'; +import 'package:sentry_dio/sentry_dio.dart'; +import 'package:sentry_dio/src/breadcrumb_client_adapter.dart'; +import 'package:test/test.dart'; + +import 'mocks/mock_http_client_adapter.dart'; +import 'mocks/mock_hub.dart'; + +void main() { + group(BreadcrumbClientAdapter, () { + 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('/'); + 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'], null); + expect(breadcrumb.data?['duration'], isNotNull); + expect(breadcrumb.data?['request_body_size'], isNull); + expect(breadcrumb.data?['response_body_size'], isNull); + }); + + test('POST: happy path', () async { + final sut = fixture.getSut(fixture.getClient(statusCode: 200)); + + final response = await sut.post('/'); + 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('/'); + 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('/'); + 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( + MockHttpClientAdapter((options, requestStream, cancelFuture) async { + expect(options.uri, Uri.parse('https://example.com/')); + throw ClientException('test', Uri.parse('https://example.com/')); + }), + ); + + try { + await sut.get('/'); + fail('Method did not throw'); + } on DioError catch (e) { + expect(e.message, 'test'); + expect(e.requestOptions.uri, Uri.parse('https://example.com/')); + } + + 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( + MockHttpClientAdapter((options, requestStream, cancelFuture) async { + expect(options.uri, Uri.parse('https://example.com/')); + throw SocketException('test'); + }), + ); + + try { + await sut.get('/'); + fail('Method did not throw'); + } on DioError catch (e) { + expect(e.message, 'SocketException: test'); + } + + expect(fixture.hub.captureExceptionCalls.length, 0); + }); + + test('breadcrumb gets added when an exception gets thrown', () async { + final sut = fixture.getSut( + MockHttpClientAdapter((options, requestStream, cancelFuture) async { + expect(options.uri, Uri.parse('https://example.com/')); + throw Exception('foo bar'); + }), + ); + + try { + await sut.get('/'); + fail('Method did not throw'); + } on DioError 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( + MockHttpClientAdapter((options, _, __) async { + expect(options.uri, Uri.parse('https://example.com/')); + await Future.delayed(Duration(seconds: 1)); + return ResponseBody.fromString('', 200); + }), + ); + + final response = await sut.get('/'); + expect(response.statusCode, 200); + + expect(fixture.hub.addBreadcrumbCalls.length, 1); + final breadcrumb = fixture.hub.addBreadcrumbCalls.first.crumb; + + final 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 { + Dio getSut([MockHttpClientAdapter? client]) { + final mc = client ?? getClient(); + final dio = Dio( + BaseOptions(baseUrl: 'https://example.com/'), + ); + dio.httpClientAdapter = BreadcrumbClientAdapter(client: mc, hub: hub); + return dio; + } + + late MockHub hub = MockHub(); + + MockHttpClientAdapter getClient({int statusCode = 200, String? reason}) { + return MockHttpClientAdapter((request, requestStream, cancelFuture) async { + expect(request.uri, Uri.parse('https://example.com/')); + + return ResponseBody.fromString( + '', + statusCode, + ); + }); + } +} diff --git a/dio/test/failed_request_client_adapter_test.dart b/dio/test/failed_request_client_adapter_test.dart new file mode 100644 index 0000000000..f9a1742882 --- /dev/null +++ b/dio/test/failed_request_client_adapter_test.dart @@ -0,0 +1,271 @@ +import 'package:dio/dio.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/http_client/failed_request_client.dart'; +import 'package:sentry_dio/src/failed_request_client_adapter.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'mocks/mock_http_client_adapter.dart'; +import 'mocks/mock_hub.dart'; +import 'mocks/mock_transport.dart'; + +final requestUri = Uri.parse('https://example.com?foo=bar'); +final requestOptions = '?foo=bar'; + +void main() { + group(FailedRequestClientAdapter, () { + 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(requestOptions); + expect(response.statusCode, 200); + + expect(fixture.transport.calls, 0); + }); + + test('exception gets reported if client throws', () async { + final sut = fixture.getSut( + client: createThrowingClient(), + captureFailedRequests: true, + ); + + await expectLater( + () async => await sut.get( + requestOptions, + options: Options(headers: {'Cookie': 'foo=bar'}), + ), + throwsException, + ); + + expect(fixture.transport.calls, 1); + + final eventCall = fixture.transport.events.first; + final exception = eventCall.exceptions?.first; + final mechanism = exception?.mechanism; + + expect(exception?.stackTrace, isNotNull); + expect(mechanism?.type, 'SentryHttpClient'); + + final request = eventCall.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'), false); + }); + + test('exception gets not reported if disabled', () async { + final sut = fixture.getSut( + client: createThrowingClient(), + captureFailedRequests: false, + ); + + await expectLater( + () async => await sut.get( + requestOptions, + options: Options(headers: {'Cookie': 'foo=bar'}), + ), + throwsException, + ); + + expect(fixture.transport.calls, 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)], + ); + + try { + await sut.get( + requestOptions, + options: Options(headers: {'Cookie': 'foo=bar'}), + ); + } on DioError catch (_) { + // a 404 throws an exception with dio + } + + expect(fixture.transport.calls, 1); + + final eventCall = fixture.transport.events.first; + final exception = eventCall.exceptions?.first; + final mechanism = exception?.mechanism; + + expect(mechanism?.type, 'SentryHttpClient'); + expect( + mechanism?.description, + 'Event was captured because the request status code was 404', + ); + + expect(exception?.type, 'SentryHttpClientError'); + expect( + exception?.value, + 'Exception: Event was captured because the request status code was 404', + ); + + final request = eventCall.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'), false); + }); + + 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, + ); + + try { + await sut.get( + requestOptions, + options: Options(headers: {'Cookie': 'foo=bar'}), + ); + } on DioError catch (_) { + // dio throws on 404 + } + + expect(fixture.transport.calls, 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( + requestOptions, + options: Options(headers: {'Cookie': 'foo=bar'}), + ), + throwsException, + ); + + final event = fixture.transport.events.first; + expect(fixture.transport.calls, 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, + ); + + try { + await sut.get( + requestOptions, + options: Options(headers: {'Cookie': 'foo=bar'}), + ); + } on DioError catch (_) { + // dio throws on 404 + } + + final event = fixture.transport.events.first; + expect(fixture.transport.calls, 1); + expect(event.request?.headers.isEmpty, true); + expect(event.request?.cookies, isNull); + }); + }); +} + +MockHttpClientAdapter createThrowingClient() { + return MockHttpClientAdapter( + (options, _, __) async { + expect(options.uri, requestUri); + throw TestException(); + }, + ); +} + +class CloseableMockClient extends Mock implements BaseClient {} + +class Fixture { + final _options = SentryOptions(dsn: fakeDsn); + late Hub _hub; + final transport = MockTransport(); + Fixture() { + _options.transport = transport; + _hub = Hub(_options); + } + + Dio getSut({ + MockHttpClientAdapter? client, + bool captureFailedRequests = false, + MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.small, + List badStatusCodes = const [], + bool sendDefaultPii = true, + }) { + final mc = client ?? getClient(); + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + dio.httpClientAdapter = FailedRequestClientAdapter( + client: mc, + hub: _hub, + captureFailedRequests: captureFailedRequests, + failedRequestStatusCodes: badStatusCodes, + maxRequestBodySize: maxRequestBodySize, + sendDefaultPii: sendDefaultPii, + ); + return dio; + } + + MockHttpClientAdapter getClient({int statusCode = 200, String? reason}) { + return MockHttpClientAdapter((options, requestStream, cancelFuture) async { + expect(options.uri, requestUri); + return ResponseBody.fromString('', statusCode); + }); + } +} + +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/dio/test/mocks.dart b/dio/test/mocks.dart new file mode 100644 index 0000000000..b17a27e8b6 --- /dev/null +++ b/dio/test/mocks.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/transport/rate_limiter.dart'; + +final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; + +final fakeException = Exception('Error'); + +final fakeMessage = SentryMessage( + 'message 1', + template: 'message %d', + params: ['1'], +); + +final fakeUser = SentryUser(id: '1', email: 'test@test'); + +final fakeEvent = SentryEvent( + logger: 'main', + serverName: 'server.dart', + release: '1.4.0-preview.1', + environment: 'Test', + message: SentryMessage('This is an example Dart event.'), + transaction: '/example/app', + level: SentryLevel.warning, + tags: const {'project-id': '7371'}, + extra: const {'company-name': 'Dart Inc'}, + fingerprint: const ['example-dart'], + modules: const {'module1': 'factory'}, + sdk: SdkVersion(name: 'sdk1', version: '1.0.0'), + user: SentryUser( + id: '800', + username: 'first-user', + email: 'first@user.lan', + ipAddress: '127.0.0.1', + extras: {'first-sign-in': '2020-01-01'}, + ), + breadcrumbs: [ + Breadcrumb( + message: 'UI Lifecycle', + timestamp: DateTime.now().toUtc(), + category: 'ui.lifecycle', + type: 'navigation', + data: {'screen': 'MainActivity', 'state': 'created'}, + level: SentryLevel.info, + ) + ], + contexts: Contexts( + operatingSystem: const SentryOperatingSystem( + name: 'Android', + version: '5.0.2', + build: 'LRX22G.P900XXS0BPL2', + kernelVersion: + 'Linux version 3.4.39-5726670 (dpi@SWHC3807) (gcc version 4.8 (GCC) ) #1 SMP PREEMPT Thu Dec 1 19:42:39 KST 2016', + rooted: false, + ), + runtimes: [const SentryRuntime(name: 'ART', version: '5')], + app: SentryApp( + name: 'Example Dart App', + version: '1.42.0', + identifier: 'HGT-App-13', + build: '93785', + buildType: 'release', + deviceAppHash: '5afd3a6', + startTime: DateTime.now().toUtc(), + ), + browser: const SentryBrowser( + name: 'Firefox', + version: '42.0.1', + ), + device: SentryDevice( + name: 'SM-P900', + family: 'SM-P900', + model: 'SM-P900 (LRX22G)', + modelId: 'LRX22G', + arch: 'armeabi-v7a', + batteryLevel: 99, + orientation: SentryOrientation.landscape, + manufacturer: 'samsung', + brand: 'samsung', + screenResolution: '2560x1600', + screenDensity: 2.1, + screenDpi: 320, + online: true, + charging: true, + lowMemory: true, + simulator: false, + memorySize: 1500, + freeMemory: 200, + usableMemory: 4294967296, + storageSize: 4294967296, + freeStorage: 2147483648, + externalStorageSize: 8589934592, + externalFreeStorage: 2863311530, + bootTime: DateTime.now().toUtc(), + timezone: 'America/Toronto', + ), + ), +); + +/// Doesn't do anything with the events +class NoOpEventProcessor extends EventProcessor { + @override + FutureOr apply(SentryEvent event, {dynamic hint}) { + return event; + } +} + +/// Always returns null and thus drops all events +class DropAllEventProcessor extends EventProcessor { + @override + FutureOr apply(SentryEvent event, {dynamic hint}) { + return null; + } +} + +class FunctionEventProcessor extends EventProcessor { + FunctionEventProcessor(this.applyFunction); + + final EventProcessorFunction applyFunction; + + @override + FutureOr apply(SentryEvent event, {dynamic hint}) { + return applyFunction(event, hint: hint); + } +} + +typedef EventProcessorFunction = FutureOr + Function(SentryEvent event, {dynamic hint}); + +var fakeEnvelope = SentryEnvelope.fromEvent( + fakeEvent, + SdkVersion(name: 'sdk1', version: '1.0.0'), +); + +class MockRateLimiter implements RateLimiter { + bool filterReturnsNull = false; + SentryEnvelope? filteredEnvelope; + SentryEnvelope? envelopeToFilter; + + String? sentryRateLimitHeader; + String? retryAfterHeader; + int? errorCode; + + @override + SentryEnvelope? filter(SentryEnvelope envelope) { + if (filterReturnsNull) { + return null; + } + envelopeToFilter = envelope; + return filteredEnvelope ?? envelope; + } + + @override + void updateRetryAfterLimits( + String? sentryRateLimitHeader, + String? retryAfterHeader, + int errorCode, + ) { + this.sentryRateLimitHeader = sentryRateLimitHeader; + this.retryAfterHeader = retryAfterHeader; + this.errorCode = errorCode; + } +} diff --git a/dio/test/mocks/mock_http_client_adapter.dart b/dio/test/mocks/mock_http_client_adapter.dart new file mode 100644 index 0000000000..f655126220 --- /dev/null +++ b/dio/test/mocks/mock_http_client_adapter.dart @@ -0,0 +1,27 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; + +typedef MockFetchMethod = Future Function( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, +); + +class MockHttpClientAdapter extends HttpClientAdapter { + MockHttpClientAdapter(this.mockFetchMethod); + + final MockFetchMethod mockFetchMethod; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) { + return mockFetchMethod(options, requestStream, cancelFuture); + } + + @override + void close({bool force = false}) {} +} diff --git a/dio/test/mocks/mock_hub.dart b/dio/test/mocks/mock_hub.dart new file mode 100644 index 0000000000..18b9d46c79 --- /dev/null +++ b/dio/test/mocks/mock_hub.dart @@ -0,0 +1,205 @@ +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/noop_hub.dart'; + +class MockHub implements Hub { + List captureEventCalls = []; + List captureExceptionCalls = []; + List captureMessageCalls = []; + List addBreadcrumbCalls = []; + List bindClientCalls = []; + List userFeedbackCalls = []; + List captureTransactionCalls = []; + int closeCalls = 0; + bool _isEnabled = true; + int spanContextCals = 0; + int getSpanCalls = 0; + + /// Useful for tests. + void reset() { + captureEventCalls = []; + captureExceptionCalls = []; + captureMessageCalls = []; + addBreadcrumbCalls = []; + bindClientCalls = []; + closeCalls = 0; + _isEnabled = true; + spanContextCals = 0; + captureTransactionCalls = []; + getSpanCalls = 0; + } + + @override + void addBreadcrumb(Breadcrumb crumb, {dynamic hint}) { + addBreadcrumbCalls.add(AddBreadcrumbCall(crumb, hint)); + } + + @override + void bindClient(SentryClient client) { + bindClientCalls.add(client); + } + + @override + Future captureEvent( + SentryEvent event, { + dynamic stackTrace, + dynamic hint, + ScopeCallback? withScope, + }) async { + captureEventCalls.add( + CaptureEventCall( + event, + stackTrace, + hint, + ), + ); + return event.eventId; + } + + @override + Future captureException( + dynamic throwable, { + dynamic stackTrace, + dynamic hint, + ScopeCallback? withScope, + }) async { + captureExceptionCalls.add( + CaptureExceptionCall( + throwable, + stackTrace, + hint, + ), + ); + return SentryId.newId(); + } + + @override + Future captureMessage( + String? message, { + SentryLevel? level = SentryLevel.info, + String? template, + List? params, + dynamic hint, + ScopeCallback? withScope, + }) async { + captureMessageCalls.add( + CaptureMessageCall( + message, + level, + template, + params, + hint, + ), + ); + return SentryId.newId(); + } + + @override + Hub clone() { + return NoOpHub(); + } + + @override + Future close() async { + closeCalls = closeCalls + 1; + _isEnabled = false; + } + + @override + void configureScope(callback) {} + + @override + bool get isEnabled => _isEnabled; + + @override + SentryId get lastEventId => SentryId.empty(); + + @override + Future captureTransaction(SentryTransaction transaction) async { + captureTransactionCalls.add(transaction); + return transaction.eventId; + } + + @override + Future captureUserFeedback(SentryUserFeedback userFeedback) async { + userFeedbackCalls.add(userFeedback); + return SentryId.empty(); + } + + @override + ISentrySpan startTransaction( + String name, + String operation, { + String? description, + bool? bindToScope, + bool? waitForChildren, + Duration? autoFinishAfter, + Map? customSamplingContext, + }) { + return NoOpSentrySpan(); + } + + @override + ISentrySpan startTransactionWithContext( + SentryTransactionContext transactionContext, { + Map? customSamplingContext, + bool? bindToScope, + bool? waitForChildren, + Duration? autoFinishAfter, + }) { + return NoOpSentrySpan(); + } + + @override + ISentrySpan? getSpan() { + getSpanCalls++; + return null; + } + + @override + void setSpanContext(dynamic throwable, ISentrySpan span, String transaction) { + spanContextCals++; + } +} + +class CaptureEventCall { + final SentryEvent event; + final dynamic stackTrace; + final dynamic hint; + + CaptureEventCall(this.event, this.stackTrace, this.hint); +} + +class CaptureExceptionCall { + final dynamic throwable; + final dynamic stackTrace; + final dynamic hint; + + CaptureExceptionCall( + this.throwable, + this.stackTrace, + this.hint, + ); +} + +class CaptureMessageCall { + final String? message; + final SentryLevel? level; + final String? template; + final List? params; + final dynamic hint; + + CaptureMessageCall( + this.message, + this.level, + this.template, + this.params, + this.hint, + ); +} + +class AddBreadcrumbCall { + final Breadcrumb crumb; + final dynamic hint; + + AddBreadcrumbCall(this.crumb, this.hint); +} diff --git a/dio/test/mocks/mock_transport.dart b/dio/test/mocks/mock_transport.dart new file mode 100644 index 0000000000..1c000de2e3 --- /dev/null +++ b/dio/test/mocks/mock_transport.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:sentry/sentry.dart'; + +class MockTransport implements Transport { + List envelopes = []; + List events = []; + int calls = 0; + + bool called(int calls) { + return calls == calls; + } + + @override + Future send(SentryEnvelope envelope) async { + calls++; + + envelopes.add(envelope); + final event = await _eventFromEnvelope(envelope); + events.add(event); + + return envelope.header.eventId ?? SentryId.empty(); + } + + Future _eventFromEnvelope(SentryEnvelope envelope) async { + final envelopeItemData = []; + envelopeItemData.addAll(await envelope.items.first.envelopeItemStream()); + + final envelopeItem = utf8.decode(envelopeItemData); + final dynamic envelopeItemJson = jsonDecode(envelopeItem.split('\n').last); + final envelopeMap = envelopeItemJson as Map; + final requestJson = envelopeMap['request'] as Map?; + + // '_InternalLinkedHashMap' is not a subtype of type 'Map' + final headersMap = requestJson?['headers'] as Map?; + final newHeadersMap = {}; + if (headersMap != null) { + for (final entry in headersMap.entries) { + newHeadersMap[entry.key] = entry.value as String; + } + envelopeMap['request']['headers'] = newHeadersMap; + } + + final otherMap = requestJson?['other'] as Map?; + final newOtherMap = {}; + if (otherMap != null) { + for (final entry in otherMap.entries) { + newOtherMap[entry.key] = entry.value as String; + } + envelopeMap['request']['other'] = newOtherMap; + } + + return SentryEvent.fromJson(envelopeMap); + } + + void reset() { + envelopes.clear(); + events.clear(); + calls = 0; + } +} + +class ThrowingTransport implements Transport { + @override + Future send(SentryEnvelope envelope) async { + throw Exception('foo bar'); + } +} diff --git a/dio/test/sentry_http_client_adapter_test.dart b/dio/test/sentry_http_client_adapter_test.dart new file mode 100644 index 0000000000..2b247f8332 --- /dev/null +++ b/dio/test/sentry_http_client_adapter_test.dart @@ -0,0 +1,156 @@ +import 'package:dio/dio.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/http_client/failed_request_client.dart'; +import 'package:sentry_dio/src/sentry_client_adapter.dart'; +import 'package:test/test.dart'; + +import 'mocks/mock_http_client_adapter.dart'; +import 'mocks/mock_hub.dart'; + +final requestUri = Uri.parse('https://example.com/'); + +void main() { + group(SentryHttpClientAdapter, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + 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('/'); + expect(response.statusCode, 200); + + expect(fixture.hub.captureEventCalls.length, 0); + expect(fixture.hub.addBreadcrumbCalls.length, 1); + }); + + test('no captured event with default config', () async { + final sut = fixture.getSut( + client: createThrowingClient(), + ); + + await expectLater( + () async => await sut.get('/'), + throwsException, + ); + + expect(fixture.hub.captureEventCalls.length, 0); + expect(fixture.hub.addBreadcrumbCalls.length, 1); + }); + + 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('/'), + 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); + }); + + test('close does get called for user defined client', () async { + final mockHub = MockHub(); + + final mockClient = CloseableMockClient(); + + final client = SentryHttpClient(client: mockClient, hub: mockHub); + client.close(); + + expect(mockHub.addBreadcrumbCalls.length, 0); + expect(mockHub.captureExceptionCalls.length, 0); + verify(mockClient.close()); + }); + + test('no captured span if tracing disabled', () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + recordBreadcrumbs: false, + networkTracing: false, + ); + + final response = await sut.get('/'); + expect(response.statusCode, 200); + + expect(fixture.hub.getSpanCalls, 0); + }); + + test('captured span if tracing enabled', () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + recordBreadcrumbs: false, + networkTracing: true, + ); + + final response = await sut.get('/'); + expect(response.statusCode, 200); + + expect(fixture.hub.getSpanCalls, 1); + }); + }); +} + +MockHttpClientAdapter createThrowingClient() { + return MockHttpClientAdapter( + (options, _, __) async { + expect(options.uri, requestUri); + throw TestException(); + }, + ); +} + +class CloseableMockClient extends Mock implements BaseClient {} + +class Fixture { + Dio getSut({ + MockHttpClientAdapter? client, + bool captureFailedRequests = false, + MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.never, + List badStatusCodes = const [], + bool recordBreadcrumbs = true, + bool networkTracing = false, + }) { + final mc = client ?? getClient(); + final dio = Dio(BaseOptions(baseUrl: requestUri.toString())); + dio.httpClientAdapter = SentryHttpClientAdapter( + client: mc, + hub: hub, + captureFailedRequests: captureFailedRequests, + failedRequestStatusCodes: badStatusCodes, + maxRequestBodySize: maxRequestBodySize, + recordBreadcrumbs: recordBreadcrumbs, + networkTracing: networkTracing, + ); + return dio; + } + + final MockHub hub = MockHub(); + + MockHttpClientAdapter getClient({int statusCode = 200, String? reason}) { + return MockHttpClientAdapter((options, _, __) async { + expect(options.uri, requestUri); + return ResponseBody.fromString('', statusCode); + }); + } +} + +class TestException implements Exception {} diff --git a/dio/test/tracing_client_adapter_test.dart b/dio/test/tracing_client_adapter_test.dart new file mode 100644 index 0000000000..5d27224a24 --- /dev/null +++ b/dio/test/tracing_client_adapter_test.dart @@ -0,0 +1,180 @@ +// ignore_for_file: invalid_use_of_internal_member +// The lint above is okay, because we're using another Sentry package + +import 'package:dio/dio.dart'; +import 'package:http/http.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry/src/sentry_tracer.dart'; +import 'package:sentry_dio/src/tracing_client_adapter.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'mocks/mock_http_client_adapter.dart'; +import 'mocks/mock_transport.dart'; + +final requestUri = Uri.parse('https://example.com?foo=bar'); +final requestOptions = '?foo=bar'; + +void main() { + group(TracingClientAdapter, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('captured span if successful request', () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + await sut.get(requestOptions); + + 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?foo=bar'); + }); + + test('finish span if errored request', () async { + final sut = fixture.getSut( + client: createThrowingClient(), + ); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + try { + await sut.get(requestOptions); + } catch (_) { + // ignore + } + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + + expect(span.finished, isTrue); + }); + + test('associate exception to span if errored request', () async { + final sut = fixture.getSut( + client: createThrowingClient(), + ); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + dynamic exception; + try { + await sut.get(requestOptions); + } catch (error) { + exception = error; + } + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + + expect(span.status, SpanStatus.internalError()); + expect(span.throwable, isA()); + expect(exception, isA()); + expect((exception as DioError).error, isA()); + }); + + test('captured span adds sentry-trace header to the request', () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + final response = await sut.get(requestOptions); + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + + expect( + response.headers['sentry-trace'], + [span.toSentryTrace().value], + ); + }); + + test('do not throw if no span bound to the scope', () async { + final sut = fixture.getSut( + client: fixture.getClient(statusCode: 200, reason: 'OK'), + ); + + await sut.get(requestOptions); + }); + }); +} + +MockHttpClientAdapter createThrowingClient() { + return MockHttpClientAdapter( + (options, _, __) async { + expect(options.uri, requestUri); + throw TestException(); + }, + ); +} + +class CloseableMockClient extends Mock implements BaseClient {} + +class Fixture { + final _options = SentryOptions(dsn: fakeDsn); + late Hub _hub; + final transport = MockTransport(); + Fixture() { + _options.transport = transport; + _options.tracesSampleRate = 1.0; + _hub = Hub(_options); + } + + Dio getSut({MockHttpClientAdapter? client}) { + final mc = client ?? getClient(); + final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); + dio.httpClientAdapter = TracingClientAdapter( + client: mc, + hub: _hub, + ); + return dio; + } + + MockHttpClientAdapter getClient({int statusCode = 200, String? reason}) { + return MockHttpClientAdapter((options, requestStream, cancelFuture) async { + expect(options.uri, requestUri); + return ResponseBody.fromString( + '', + statusCode, + headers: options.headers.map( + (key, dynamic value) => + MapEntry(key, [value?.toString() ?? '']), + ), + ); + }); + } +} + +class TestException implements Exception {} From b35a9739624d80451c82de4070b0372dcb003df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Sun, 2 Jan 2022 17:48:14 +0100 Subject: [PATCH 02/18] fix tests --- dio/test/breadcrumb_client_adapter_test.dart | 37 ++++---------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/dio/test/breadcrumb_client_adapter_test.dart b/dio/test/breadcrumb_client_adapter_test.dart index 8aea14fb8e..29058db7d2 100644 --- a/dio/test/breadcrumb_client_adapter_test.dart +++ b/dio/test/breadcrumb_client_adapter_test.dart @@ -1,11 +1,6 @@ -import 'dart:io'; - import 'package:dio/dio.dart'; -import 'package:http/http.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; -import 'package:sentry/src/http_client/breadcrumb_client.dart'; -import 'package:sentry_dio/sentry_dio.dart'; import 'package:sentry_dio/src/breadcrumb_client_adapter.dart'; import 'package:test/test.dart'; @@ -91,11 +86,11 @@ void main() { /// 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 { + test('no captureException for Exception', () async { final sut = fixture.getSut( MockHttpClientAdapter((options, requestStream, cancelFuture) async { expect(options.uri, Uri.parse('https://example.com/')); - throw ClientException('test', Uri.parse('https://example.com/')); + throw Exception('test'); }), ); @@ -103,33 +98,13 @@ void main() { await sut.get('/'); fail('Method did not throw'); } on DioError catch (e) { - expect(e.message, 'test'); + expect(e.message, 'Exception: test'); expect(e.requestOptions.uri, Uri.parse('https://example.com/')); } 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( - MockHttpClientAdapter((options, requestStream, cancelFuture) async { - expect(options.uri, Uri.parse('https://example.com/')); - throw SocketException('test'); - }), - ); - - try { - await sut.get('/'); - fail('Method did not throw'); - } on DioError catch (e) { - expect(e.message, 'SocketException: test'); - } - - expect(fixture.hub.captureExceptionCalls.length, 0); - }); - test('breadcrumb gets added when an exception gets thrown', () async { final sut = fixture.getSut( MockHttpClientAdapter((options, requestStream, cancelFuture) async { @@ -157,9 +132,9 @@ void main() { test('close does get called for user defined client', () async { final mockHub = MockHub(); - final mockClient = CloseableMockClient(); + final mockClient = CloseableMockClientAdapter(); - final client = BreadcrumbClient(client: mockClient, hub: mockHub); + final client = BreadcrumbClientAdapter(client: mockClient, hub: mockHub); client.close(); expect(mockHub.addBreadcrumbCalls.length, 0); @@ -189,7 +164,7 @@ void main() { }); } -class CloseableMockClient extends Mock implements BaseClient {} +class CloseableMockClientAdapter extends Mock implements HttpClientAdapter {} class Fixture { Dio getSut([MockHttpClientAdapter? client]) { From 12cdc40fa27e81fe04dcfc3db2167eb9d3103c41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Sun, 2 Jan 2022 17:48:46 +0100 Subject: [PATCH 03/18] add pr id to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f528a2b835..f86ac36af9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Unreleased -* Feat: Add support for [Dio](https://pub.dev/packages/dio) +* Feat: Add support for [Dio](https://pub.dev/packages/dio) (#688) # 6.3.0-alpha.1 From 9b70009d5cf4f94ac93236224bf341eaa4017eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Sun, 2 Jan 2022 18:07:41 +0100 Subject: [PATCH 04/18] fix example --- dio/example/{dio_example.dart => example.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dio/example/{dio_example.dart => example.dart} (100%) diff --git a/dio/example/dio_example.dart b/dio/example/example.dart similarity index 100% rename from dio/example/dio_example.dart rename to dio/example/example.dart From 6de45ddf66a5ec1459a82a67e65043ba9237d261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Sun, 2 Jan 2022 18:07:58 +0100 Subject: [PATCH 05/18] import correct adapter based on environment --- dio/lib/src/adapter/_browser_adapter.dart | 6 ++++++ dio/lib/src/adapter/_io_adapter.dart | 6 ++++++ dio/lib/src/adapter/dio_adapter.dart | 8 ++++++++ dio/lib/src/breadcrumb_client_adapter.dart | 5 +++-- dio/lib/src/failed_request_client_adapter.dart | 4 ++-- dio/lib/src/sentry_client_adapter.dart | 4 ++-- dio/lib/src/tracing_client_adapter.dart | 4 ++-- 7 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 dio/lib/src/adapter/_browser_adapter.dart create mode 100644 dio/lib/src/adapter/_io_adapter.dart create mode 100644 dio/lib/src/adapter/dio_adapter.dart diff --git a/dio/lib/src/adapter/_browser_adapter.dart b/dio/lib/src/adapter/_browser_adapter.dart new file mode 100644 index 0000000000..5662e3fe19 --- /dev/null +++ b/dio/lib/src/adapter/_browser_adapter.dart @@ -0,0 +1,6 @@ +// ignore_for_file: public_member_api_docs + +import 'package:dio/adapter_browser.dart'; +import 'package:dio/dio.dart'; + +HttpClientAdapter createAdapter() => BrowserHttpClientAdapter(); diff --git a/dio/lib/src/adapter/_io_adapter.dart b/dio/lib/src/adapter/_io_adapter.dart new file mode 100644 index 0000000000..e2d60fcaf7 --- /dev/null +++ b/dio/lib/src/adapter/_io_adapter.dart @@ -0,0 +1,6 @@ +// ignore_for_file: public_member_api_docs + +import 'package:dio/adapter.dart'; +import 'package:dio/dio.dart'; + +HttpClientAdapter createAdapter() => DefaultHttpClientAdapter(); diff --git a/dio/lib/src/adapter/dio_adapter.dart b/dio/lib/src/adapter/dio_adapter.dart new file mode 100644 index 0000000000..2e181f2cd7 --- /dev/null +++ b/dio/lib/src/adapter/dio_adapter.dart @@ -0,0 +1,8 @@ +// ignore_for_file: public_member_api_docs + +import 'package:dio/dio.dart'; + +import '_io_adapter.dart' if (dart.library.html) '_browser_adapter.dart' + as adapter; + +HttpClientAdapter createAdapter() => adapter.createAdapter(); diff --git a/dio/lib/src/breadcrumb_client_adapter.dart b/dio/lib/src/breadcrumb_client_adapter.dart index 0ede9bf9a4..fb6a860dd4 100644 --- a/dio/lib/src/breadcrumb_client_adapter.dart +++ b/dio/lib/src/breadcrumb_client_adapter.dart @@ -2,10 +2,11 @@ import 'dart:typed_data'; -import 'package:dio/adapter.dart'; import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; +import 'adapter/dio_adapter.dart'; + /// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter /// which records requests as breadcrumbs. /// @@ -16,7 +17,7 @@ class BreadcrumbClientAdapter extends HttpClientAdapter { // ignore: public_member_api_docs BreadcrumbClientAdapter({HttpClientAdapter? client, Hub? hub}) : _hub = hub ?? HubAdapter(), - _client = client ?? DefaultHttpClientAdapter(); + _client = client ?? createAdapter(); final HttpClientAdapter _client; final Hub _hub; diff --git a/dio/lib/src/failed_request_client_adapter.dart b/dio/lib/src/failed_request_client_adapter.dart index 1c911e44e4..ef100888f5 100644 --- a/dio/lib/src/failed_request_client_adapter.dart +++ b/dio/lib/src/failed_request_client_adapter.dart @@ -2,9 +2,9 @@ import 'dart:typed_data'; -import 'package:dio/adapter.dart'; import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; +import 'adapter/dio_adapter.dart'; /// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter /// which records events for failed requests. @@ -33,7 +33,7 @@ class FailedRequestClientAdapter extends HttpClientAdapter { HttpClientAdapter? client, Hub? hub, }) : _hub = hub ?? HubAdapter(), - _client = client ?? DefaultHttpClientAdapter(); + _client = client ?? createAdapter(); final HttpClientAdapter _client; final Hub _hub; diff --git a/dio/lib/src/sentry_client_adapter.dart b/dio/lib/src/sentry_client_adapter.dart index 64e497b7ef..fb77e15960 100644 --- a/dio/lib/src/sentry_client_adapter.dart +++ b/dio/lib/src/sentry_client_adapter.dart @@ -2,9 +2,9 @@ import 'dart:typed_data'; -import 'package:dio/adapter.dart'; import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; +import 'adapter/dio_adapter.dart'; import 'failed_request_client_adapter.dart'; import 'tracing_client_adapter.dart'; import 'breadcrumb_client_adapter.dart'; @@ -61,7 +61,7 @@ class SentryHttpClientAdapter extends HttpClientAdapter { }) { _hub = hub ?? HubAdapter(); - var innerClient = client ?? DefaultHttpClientAdapter(); + var innerClient = client ?? createAdapter(); innerClient = FailedRequestClientAdapter( failedRequestStatusCodes: failedRequestStatusCodes, diff --git a/dio/lib/src/tracing_client_adapter.dart b/dio/lib/src/tracing_client_adapter.dart index bbfeb36c9c..0fad19dbed 100644 --- a/dio/lib/src/tracing_client_adapter.dart +++ b/dio/lib/src/tracing_client_adapter.dart @@ -2,9 +2,9 @@ import 'dart:typed_data'; -import 'package:dio/adapter.dart'; import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; +import 'adapter/dio_adapter.dart'; /// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter /// which adds support to Sentry Performance feature. @@ -13,7 +13,7 @@ class TracingClientAdapter extends HttpClientAdapter { // ignore: public_member_api_docs TracingClientAdapter({HttpClientAdapter? client, Hub? hub}) : _hub = hub ?? HubAdapter(), - _client = client ?? DefaultHttpClientAdapter(); + _client = client ?? createAdapter(); final HttpClientAdapter _client; final Hub _hub; From d5f953b87be958eaed22310c7aa80646d92ef740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Sun, 2 Jan 2022 18:18:35 +0100 Subject: [PATCH 06/18] ignore files for platform dependent imports in coverage --- dio/lib/src/adapter/_browser_adapter.dart | 1 + dio/lib/src/adapter/_io_adapter.dart | 1 + dio/lib/src/adapter/dio_adapter.dart | 1 + 3 files changed, 3 insertions(+) diff --git a/dio/lib/src/adapter/_browser_adapter.dart b/dio/lib/src/adapter/_browser_adapter.dart index 5662e3fe19..397ffaf2f7 100644 --- a/dio/lib/src/adapter/_browser_adapter.dart +++ b/dio/lib/src/adapter/_browser_adapter.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file // ignore_for_file: public_member_api_docs import 'package:dio/adapter_browser.dart'; diff --git a/dio/lib/src/adapter/_io_adapter.dart b/dio/lib/src/adapter/_io_adapter.dart index e2d60fcaf7..9727eb16c7 100644 --- a/dio/lib/src/adapter/_io_adapter.dart +++ b/dio/lib/src/adapter/_io_adapter.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file // ignore_for_file: public_member_api_docs import 'package:dio/adapter.dart'; diff --git a/dio/lib/src/adapter/dio_adapter.dart b/dio/lib/src/adapter/dio_adapter.dart index 2e181f2cd7..da9f4efe0f 100644 --- a/dio/lib/src/adapter/dio_adapter.dart +++ b/dio/lib/src/adapter/dio_adapter.dart @@ -1,3 +1,4 @@ +// coverage:ignore-file // ignore_for_file: public_member_api_docs import 'package:dio/dio.dart'; From 266f1a9cf34791e2e205b34d46d336a01b396882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Sun, 2 Jan 2022 18:30:26 +0100 Subject: [PATCH 07/18] reduce min coverage to 81 :( --- .github/workflows/dio.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dio.yml b/.github/workflows/dio.yml index 8a3e878f34..a2ff6b3d22 100644 --- a/.github/workflows/dio.yml +++ b/.github/workflows/dio.yml @@ -49,7 +49,7 @@ jobs: if: runner.os == 'Linux' with: path: "./dio/coverage/lcov.info" - min_coverage: 90 + min_coverage: 81 analyze: runs-on: ubuntu-latest From 1d35c8bcf5d4266908dfb4969c809ba0cf18cccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Wed, 5 Jan 2022 21:05:57 +0100 Subject: [PATCH 08/18] Remove adapter foo --- dio/lib/src/adapter/_browser_adapter.dart | 7 ------- dio/lib/src/adapter/_io_adapter.dart | 7 ------- dio/lib/src/adapter/dio_adapter.dart | 9 --------- dio/lib/src/breadcrumb_client_adapter.dart | 6 ++---- dio/lib/src/failed_request_client_adapter.dart | 5 ++--- dio/lib/src/sentry_client_adapter.dart | 5 ++--- dio/lib/src/tracing_client_adapter.dart | 5 ++--- dio/pubspec.yaml | 9 ++++++--- 8 files changed, 14 insertions(+), 39 deletions(-) delete mode 100644 dio/lib/src/adapter/_browser_adapter.dart delete mode 100644 dio/lib/src/adapter/_io_adapter.dart delete mode 100644 dio/lib/src/adapter/dio_adapter.dart diff --git a/dio/lib/src/adapter/_browser_adapter.dart b/dio/lib/src/adapter/_browser_adapter.dart deleted file mode 100644 index 397ffaf2f7..0000000000 --- a/dio/lib/src/adapter/_browser_adapter.dart +++ /dev/null @@ -1,7 +0,0 @@ -// coverage:ignore-file -// ignore_for_file: public_member_api_docs - -import 'package:dio/adapter_browser.dart'; -import 'package:dio/dio.dart'; - -HttpClientAdapter createAdapter() => BrowserHttpClientAdapter(); diff --git a/dio/lib/src/adapter/_io_adapter.dart b/dio/lib/src/adapter/_io_adapter.dart deleted file mode 100644 index 9727eb16c7..0000000000 --- a/dio/lib/src/adapter/_io_adapter.dart +++ /dev/null @@ -1,7 +0,0 @@ -// coverage:ignore-file -// ignore_for_file: public_member_api_docs - -import 'package:dio/adapter.dart'; -import 'package:dio/dio.dart'; - -HttpClientAdapter createAdapter() => DefaultHttpClientAdapter(); diff --git a/dio/lib/src/adapter/dio_adapter.dart b/dio/lib/src/adapter/dio_adapter.dart deleted file mode 100644 index da9f4efe0f..0000000000 --- a/dio/lib/src/adapter/dio_adapter.dart +++ /dev/null @@ -1,9 +0,0 @@ -// coverage:ignore-file -// ignore_for_file: public_member_api_docs - -import 'package:dio/dio.dart'; - -import '_io_adapter.dart' if (dart.library.html) '_browser_adapter.dart' - as adapter; - -HttpClientAdapter createAdapter() => adapter.createAdapter(); diff --git a/dio/lib/src/breadcrumb_client_adapter.dart b/dio/lib/src/breadcrumb_client_adapter.dart index fb6a860dd4..d488b47a55 100644 --- a/dio/lib/src/breadcrumb_client_adapter.dart +++ b/dio/lib/src/breadcrumb_client_adapter.dart @@ -5,8 +5,6 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; -import 'adapter/dio_adapter.dart'; - /// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter /// which records requests as breadcrumbs. /// @@ -15,9 +13,9 @@ import 'adapter/dio_adapter.dart'; /// given client. class BreadcrumbClientAdapter extends HttpClientAdapter { // ignore: public_member_api_docs - BreadcrumbClientAdapter({HttpClientAdapter? client, Hub? hub}) + BreadcrumbClientAdapter({required HttpClientAdapter client, Hub? hub}) : _hub = hub ?? HubAdapter(), - _client = client ?? createAdapter(); + _client = client; final HttpClientAdapter _client; final Hub _hub; diff --git a/dio/lib/src/failed_request_client_adapter.dart b/dio/lib/src/failed_request_client_adapter.dart index ef100888f5..5b3829f11d 100644 --- a/dio/lib/src/failed_request_client_adapter.dart +++ b/dio/lib/src/failed_request_client_adapter.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; -import 'adapter/dio_adapter.dart'; /// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter /// which records events for failed requests. @@ -26,14 +25,14 @@ import 'adapter/dio_adapter.dart'; class FailedRequestClientAdapter extends HttpClientAdapter { // ignore: public_member_api_docs FailedRequestClientAdapter({ + required HttpClientAdapter client, this.maxRequestBodySize = MaxRequestBodySize.small, this.failedRequestStatusCodes = const [], this.captureFailedRequests = true, this.sendDefaultPii = false, - HttpClientAdapter? client, Hub? hub, }) : _hub = hub ?? HubAdapter(), - _client = client ?? createAdapter(); + _client = client; final HttpClientAdapter _client; final Hub _hub; diff --git a/dio/lib/src/sentry_client_adapter.dart b/dio/lib/src/sentry_client_adapter.dart index fb77e15960..4f6e948662 100644 --- a/dio/lib/src/sentry_client_adapter.dart +++ b/dio/lib/src/sentry_client_adapter.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; -import 'adapter/dio_adapter.dart'; import 'failed_request_client_adapter.dart'; import 'tracing_client_adapter.dart'; import 'breadcrumb_client_adapter.dart'; @@ -50,7 +49,7 @@ import 'breadcrumb_client_adapter.dart'; class SentryHttpClientAdapter extends HttpClientAdapter { // ignore: public_member_api_docs SentryHttpClientAdapter({ - HttpClientAdapter? client, + required HttpClientAdapter client, Hub? hub, bool recordBreadcrumbs = true, bool networkTracing = false, @@ -61,7 +60,7 @@ class SentryHttpClientAdapter extends HttpClientAdapter { }) { _hub = hub ?? HubAdapter(); - var innerClient = client ?? createAdapter(); + var innerClient = client; innerClient = FailedRequestClientAdapter( failedRequestStatusCodes: failedRequestStatusCodes, diff --git a/dio/lib/src/tracing_client_adapter.dart b/dio/lib/src/tracing_client_adapter.dart index 0fad19dbed..cc8cb2b6d2 100644 --- a/dio/lib/src/tracing_client_adapter.dart +++ b/dio/lib/src/tracing_client_adapter.dart @@ -4,16 +4,15 @@ import 'dart:typed_data'; import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; -import 'adapter/dio_adapter.dart'; /// A [Dio](https://pub.dev/packages/dio)-package compatible HTTP client adapter /// which adds support to Sentry Performance feature. /// https://develop.sentry.dev/sdk/performance class TracingClientAdapter extends HttpClientAdapter { // ignore: public_member_api_docs - TracingClientAdapter({HttpClientAdapter? client, Hub? hub}) + TracingClientAdapter({required HttpClientAdapter client, Hub? hub}) : _hub = hub ?? HubAdapter(), - _client = client ?? createAdapter(); + _client = client; final HttpClientAdapter _client; final Hub _hub; diff --git a/dio/pubspec.yaml b/dio/pubspec.yaml index eadc2a65b1..c0a2bc9a4e 100644 --- a/dio/pubspec.yaml +++ b/dio/pubspec.yaml @@ -11,12 +11,15 @@ environment: dependencies: dio: ^4.0.0 - sentry: - path: ../dart + sentry: ^6.3.0-alpha.1 dev_dependencies: lints: ^1.0.0 test: ^1.16.0 yaml: ^3.1.0 # needed for version match (code and pubspec) coverage: ^1.0.3 - mockito: ^5.0.16 \ No newline at end of file + mockito: ^5.0.16 + +dependency_overrides: + sentry: + path: ../dart \ No newline at end of file From 2660b0f605198a075353589210145362fb3203ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Wed, 5 Jan 2022 21:06:27 +0100 Subject: [PATCH 09/18] fix example and add dio example --- flutter/analysis_options.yaml | 2 - flutter/example/lib/main.dart | 180 ++++++++++++++++++++++------------ flutter/example/pubspec.yaml | 4 + 3 files changed, 121 insertions(+), 65 deletions(-) diff --git a/flutter/analysis_options.yaml b/flutter/analysis_options.yaml index 99cfbd7e22..7a991010d4 100644 --- a/flutter/analysis_options.yaml +++ b/flutter/analysis_options.yaml @@ -14,8 +14,6 @@ analyzer: # ignore sentry/path on pubspec as we change it on deployment invalid_dependency: ignore unnecessary_import: ignore - exclude: - - example/** linter: rules: diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index e5bd062817..d2bc9b3f9b 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -9,6 +9,8 @@ import 'package:universal_platform/universal_platform.dart'; import 'package:feedback/feedback.dart' as feedback; import 'package:provider/provider.dart'; import 'user_feedback_dialog.dart'; +import 'package:dio/dio.dart'; +import 'package:sentry_dio/sentry_dio.dart'; // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io const String _exampleDsn = @@ -98,58 +100,61 @@ class MainScaffold extends StatelessWidget { child: Column( children: [ const Center(child: Text('Trigger an action:\n')), - RaisedButton( - child: const Text('Open another Scaffold'), + ElevatedButton( onPressed: () => SecondaryScaffold.openSecondaryScaffold(context), + child: const Text('Open another Scaffold'), ), - RaisedButton( - child: const Text('Dart: try catch'), + ElevatedButton( onPressed: () => tryCatch(), + child: const Text('Dart: try catch'), ), - RaisedButton( - child: const Text('Flutter error : Scaffold.of()'), + ElevatedButton( onPressed: () => Scaffold.of(context).showBottomSheet( - (context) => const Text('Scaffold error')), + (context) => const Text('Scaffold error'), + ), + child: const Text('Flutter error : Scaffold.of()'), ), - RaisedButton( - child: const Text('Dart: throw onPressed'), + ElevatedButton( // Warning : not captured if a debugger is attached // https://github.com/flutter/flutter/issues/48972 onPressed: () => throw Exception('Throws onPressed'), + child: const Text('Dart: throw onPressed'), ), - RaisedButton( - child: const Text('Dart: assert'), + ElevatedButton( onPressed: () { // Only relevant in debug builds // Warning : not captured if a debugger is attached // https://github.com/flutter/flutter/issues/48972 assert(false, 'assert failure'); }, + child: const Text('Dart: assert'), ), // Calling the SDK with an appRunner will handle errors from Futures // in SDKs runZonedGuarded onError handler - RaisedButton( - child: const Text('Dart: async throws'), - onPressed: () async => asyncThrows()), - RaisedButton( - child: const Text('Dart: Fail in microtask.'), + ElevatedButton( + onPressed: () async => asyncThrows(), + child: const Text('Dart: async throws'), + ), + ElevatedButton( onPressed: () async => { await Future.microtask( () => throw StateError('Failure in a microtask'), ) }, + child: const Text('Dart: Fail in microtask.'), ), - RaisedButton( - child: const Text('Dart: Fail in compute'), + ElevatedButton( onPressed: () async => {await compute(loop, 10)}, + child: const Text('Dart: Fail in compute'), ), - RaisedButton( + ElevatedButton( + onPressed: () => Future.delayed( + Duration(milliseconds: 100), + () => throw Exception('Throws in Future.delayed'), + ), child: const Text('Throws in Future.delayed'), - onPressed: () => Future.delayed(Duration(milliseconds: 100), - () => throw Exception('Throws in Future.delayed')), ), - RaisedButton( - child: const Text('Capture from FlutterError.onError'), + ElevatedButton( onPressed: () { // modeled after a real exception FlutterError.onError?.call(FlutterErrorDetails( @@ -167,21 +172,24 @@ class MainScaffold extends StatelessWidget { ], )); }, + child: const Text('Capture from FlutterError.onError'), ), - RaisedButton( - child: const Text('Dart: Web request'), + ElevatedButton( onPressed: () => makeWebRequest(context), + child: const Text('Dart: Web request'), ), - RaisedButton( - child: const Text('Record print() as breadcrumb'), + ElevatedButton( + onPressed: () => makeWebRequestWithDio(context), + child: const Text('Dio: Web request'), + ), + ElevatedButton( onPressed: () { print('A print breadcrumb'); Sentry.captureMessage('A message with a print() Breadcrumb'); }, + child: const Text('Record print() as breadcrumb'), ), - RaisedButton( - child: - const Text('Capture message with scope with additional tag'), + ElevatedButton( onPressed: () { Sentry.captureMessage( 'This event has an extra tag', @@ -190,9 +198,10 @@ class MainScaffold extends StatelessWidget { }, ); }, + child: + const Text('Capture message with scope with additional tag'), ), - RaisedButton( - child: const Text('Capture transaction'), + ElevatedButton( onPressed: () async { final transaction = Sentry.getSpan() ?? Sentry.startTransaction( @@ -236,9 +245,9 @@ class MainScaffold extends StatelessWidget { await transaction.finish(status: SpanStatus.ok()); }, + child: const Text('Capture transaction'), ), - RaisedButton( - child: const Text('Capture message with attachment'), + ElevatedButton( onPressed: () { Sentry.captureMessage( 'This message has an attachment', @@ -254,9 +263,9 @@ class MainScaffold extends StatelessWidget { }, ); }, + child: const Text('Capture message with attachment'), ), - RaisedButton( - child: const Text('Capture message with image attachment'), + ElevatedButton( onPressed: () { feedback.BetterFeedback.of(context) .show((feedback.UserFeedback feedback) { @@ -280,9 +289,9 @@ class MainScaffold extends StatelessWidget { ); }); }, + child: const Text('Capture message with image attachment'), ), - RaisedButton( - child: const Text('Capture User Feedback'), + ElevatedButton( onPressed: () async { final id = await Sentry.captureMessage('UserFeedback'); await showDialog( @@ -292,9 +301,9 @@ class MainScaffold extends StatelessWidget { }, ); }, + child: const Text('Capture User Feedback'), ), - RaisedButton( - child: const Text('Show UserFeedback Dialog without event'), + ElevatedButton( onPressed: () async { await showDialog( context: context, @@ -303,6 +312,7 @@ class MainScaffold extends StatelessWidget { }, ); }, + child: const Text('Show UserFeedback Dialog without event'), ), if (UniversalPlatform.isIOS || UniversalPlatform.isMacOS) const CocoaExample(), @@ -323,36 +333,36 @@ class AndroidExample extends StatelessWidget { @override Widget build(BuildContext context) { return Column(children: [ - RaisedButton( - child: const Text('Kotlin Throw unhandled exception'), + ElevatedButton( onPressed: () async { await execute('throw'); }, + child: const Text('Kotlin Throw unhandled exception'), ), - RaisedButton( - child: const Text('Kotlin Capture Exception'), + ElevatedButton( onPressed: () async { await execute('capture'); }, + child: const Text('Kotlin Capture Exception'), ), - RaisedButton( + ElevatedButton( // ANR is disabled by default, enable it to test it - child: const Text('ANR: UI blocked 6 seconds'), onPressed: () async { await execute('anr'); }, + child: const Text('ANR: UI blocked 6 seconds'), ), - RaisedButton( - child: const Text('C++ Capture message'), + ElevatedButton( onPressed: () async { await execute('cpp_capture_message'); }, + child: const Text('C++ Capture message'), ), - RaisedButton( - child: const Text('C++ SEGFAULT'), + ElevatedButton( onPressed: () async { await execute('crash'); }, + child: const Text('C++ SEGFAULT'), ), ]); } @@ -387,35 +397,35 @@ class CocoaExample extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ - RaisedButton( - child: const Text('Swift fatalError'), + ElevatedButton( onPressed: () async { await channel.invokeMethod('fatalError'); }, + child: const Text('Swift fatalError'), ), - RaisedButton( - child: const Text('Swift Capture NSException'), + ElevatedButton( onPressed: () async { await channel.invokeMethod('capture'); }, + child: const Text('Swift Capture NSException'), ), - RaisedButton( - child: const Text('Swift Capture message'), + ElevatedButton( onPressed: () async { await channel.invokeMethod('capture_message'); }, + child: const Text('Swift Capture message'), ), - RaisedButton( - child: const Text('Objective-C Throw unhandled exception'), + ElevatedButton( onPressed: () async { await channel.invokeMethod('throw'); }, + child: const Text('Objective-C Throw unhandled exception'), ), - RaisedButton( - child: const Text('Objective-C SEGFAULT'), + ElevatedButton( onPressed: () async { await channel.invokeMethod('crash'); }, + child: const Text('Objective-C SEGFAULT'), ), ], ); @@ -461,16 +471,16 @@ class SecondaryScaffold extends StatelessWidget { 'to the crash reports breadcrumbs.', ), MaterialButton( - child: const Text('Go back'), onPressed: () { Navigator.pop(context); }, + child: const Text('Go back'), ), MaterialButton( - child: const Text('throw uncaught exception'), onPressed: () { throw Exception('Exception from SecondaryScaffold'); }, + child: const Text('throw uncaught exception'), ), ], ), @@ -512,8 +522,52 @@ Future makeWebRequest(BuildContext context) async { ), actions: [ MaterialButton( - child: Text('Close'), onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ) + ], + ); + }, + ); +} + +Future makeWebRequestWithDio(BuildContext context) async { + final transaction = Sentry.getSpan() ?? + Sentry.startTransaction( + 'dio-web-erquest', + 'request', + bindToScope: true, + ); + + final dio = Dio(); + dio.httpClientAdapter = SentryHttpClientAdapter( + client: dio.httpClientAdapter, + captureFailedRequests: true, + networkTracing: true, + failedRequestStatusCodes: [SentryStatusCode.range(400, 500)], + ); + // We don't do any exception handling here. + // In case of an exception, let it get caught and reported to Sentry + final response = await dio.get('https://flutter.dev/'); + + await transaction.finish(status: SpanStatus.ok()); + + await showDialog( + context: context, + // gets tracked if using SentryNavigatorObserver + routeSettings: RouteSettings( + name: 'flutter.dev dialog', + ), + builder: (context) { + return AlertDialog( + title: Text('Response ${response.statusCode}'), + content: SingleChildScrollView( + child: Text(response.data!), + ), + actions: [ + MaterialButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), ) ], ); diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index f13e3c8d95..d1fb8627e3 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -13,9 +13,11 @@ dependencies: sdk: flutter sentry: sentry_flutter: + sentry_dio: universal_platform: ^1.0.0-nullsafety feedback: ^2.0.0 provider: ^6.0.0 + dio: ^4.0.0 dev_dependencies: sentry_dart_plugin: ^1.0.0-alpha.4 @@ -25,6 +27,8 @@ dependency_overrides: path: ../../dart sentry_flutter: path: ../ + sentry_dio: + path: ../../dio flutter: uses-material-design: true From ec7f9e043295228aa813b8a07d951cbab5dafa34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Wed, 5 Jan 2022 21:06:42 +0100 Subject: [PATCH 10/18] add content-length --- dio/lib/src/breadcrumb_client_adapter.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dio/lib/src/breadcrumb_client_adapter.dart b/dio/lib/src/breadcrumb_client_adapter.dart index d488b47a55..2876947757 100644 --- a/dio/lib/src/breadcrumb_client_adapter.dart +++ b/dio/lib/src/breadcrumb_client_adapter.dart @@ -42,6 +42,11 @@ class BreadcrumbClientAdapter extends HttpClientAdapter { statusCode = response.statusCode; reason = response.statusMessage; + final contentLengthHeader = response.headers['content-length']; + if (contentLengthHeader != null && contentLengthHeader.isNotEmpty) { + final headerValue = contentLengthHeader.first; + responseBodySize = int.tryParse(headerValue); + } return response; } catch (_) { From 37833ce6d5de7c172779a3063ce993f6e8ce26d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Thu, 6 Jan 2022 15:50:04 +0100 Subject: [PATCH 11/18] add transformer and extension method to enable dio interception --- dio/lib/sentry_dio.dart | 2 +- dio/lib/src/sentry_client_adapter.dart | 2 +- dio/lib/src/sentry_dio_extension.dart | 34 ++++++ dio/lib/src/sentry_transformer.dart | 50 ++++++++ dio/test/sentry_dio_extension_test.dart | 18 +++ dio/test/sentry_transformer_test.dart | 156 ++++++++++++++++++++++++ flutter/example/lib/main.dart | 5 +- 7 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 dio/lib/src/sentry_dio_extension.dart create mode 100644 dio/lib/src/sentry_transformer.dart create mode 100644 dio/test/sentry_dio_extension_test.dart create mode 100644 dio/test/sentry_transformer_test.dart diff --git a/dio/lib/sentry_dio.dart b/dio/lib/sentry_dio.dart index 1a6d48e9bd..0994656382 100644 --- a/dio/lib/sentry_dio.dart +++ b/dio/lib/sentry_dio.dart @@ -1,3 +1,3 @@ library sentry_dio; -export 'src/sentry_client_adapter.dart'; +export 'src/sentry_dio_extension.dart'; diff --git a/dio/lib/src/sentry_client_adapter.dart b/dio/lib/src/sentry_client_adapter.dart index 4f6e948662..a2ca3d7c61 100644 --- a/dio/lib/src/sentry_client_adapter.dart +++ b/dio/lib/src/sentry_client_adapter.dart @@ -52,7 +52,7 @@ class SentryHttpClientAdapter extends HttpClientAdapter { required HttpClientAdapter client, Hub? hub, bool recordBreadcrumbs = true, - bool networkTracing = false, + bool networkTracing = true, MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.never, List failedRequestStatusCodes = const [], bool captureFailedRequests = false, diff --git a/dio/lib/src/sentry_dio_extension.dart b/dio/lib/src/sentry_dio_extension.dart new file mode 100644 index 0000000000..d2503f073a --- /dev/null +++ b/dio/lib/src/sentry_dio_extension.dart @@ -0,0 +1,34 @@ +import 'package:dio/dio.dart'; +import 'package:sentry/sentry.dart'; +import 'sentry_transformer.dart'; +import 'sentry_client_adapter.dart'; + +/// Extension to add performance tracing for [Dio] +extension SentryDioExtension on Dio { + /// Adds support for automatic spans for http requests, + /// as well as request and response transformations. + /// This must be the last initialization step of the [Dio] setup, otherwise + /// your configuration of Dio might overwrite the Sentry configuration. + void addSentry({ + bool recordBreadcrumbs = true, + bool networkTracing = true, + MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.never, + List failedRequestStatusCodes = const [], + bool captureFailedRequests = false, + bool sendDefaultPii = false, + }) { + // intercept http requests + httpClientAdapter = SentryHttpClientAdapter( + client: httpClientAdapter, + recordBreadcrumbs: recordBreadcrumbs, + networkTracing: networkTracing, + maxRequestBodySize: maxRequestBodySize, + failedRequestStatusCodes: failedRequestStatusCodes, + captureFailedRequests: captureFailedRequests, + sendDefaultPii: sendDefaultPii, + ); + + // intercept transformations + transformer = SentryTransformer(transformer: transformer); + } +} diff --git a/dio/lib/src/sentry_transformer.dart b/dio/lib/src/sentry_transformer.dart new file mode 100644 index 0000000000..5ad45c87ee --- /dev/null +++ b/dio/lib/src/sentry_transformer.dart @@ -0,0 +1,50 @@ +import 'package:dio/dio.dart'; +import 'package:sentry/sentry.dart'; + +/// A transformer which wraps transforming in spans +class SentryTransformer implements Transformer { + // ignore: public_member_api_docs + SentryTransformer({required Transformer transformer, Hub? hub}) + : _hub = hub ?? HubAdapter(), + _transformer = transformer; + + final Transformer _transformer; + final Hub _hub; + + @override + Future transformRequest(RequestOptions options) async { + final span = _hub.getSpan()?.startChild( + 'transform-request', + description: '${options.method} ${options.uri}', + ); + try { + final request = await _transformer.transformRequest(options); + await span?.finish(status: SpanStatus.ok()); + return request; + } catch (_) { + await span?.finish(status: SpanStatus.internalError()); + rethrow; + } + } + + @override + // ignore: strict_raw_type + Future transformResponse( + RequestOptions options, + ResponseBody response, + ) async { + final span = _hub.getSpan()?.startChild( + 'transform-response', + description: '${options.method} ${options.uri}', + ); + try { + final dynamic transformedResponse = + await _transformer.transformResponse(options, response); + await span?.finish(status: SpanStatus.ok()); + return transformedResponse; + } catch (_) { + await span?.finish(status: SpanStatus.internalError()); + rethrow; + } + } +} diff --git a/dio/test/sentry_dio_extension_test.dart b/dio/test/sentry_dio_extension_test.dart new file mode 100644 index 0000000000..6a2c3417ce --- /dev/null +++ b/dio/test/sentry_dio_extension_test.dart @@ -0,0 +1,18 @@ +import 'package:dio/dio.dart'; +import 'package:sentry_dio/src/sentry_client_adapter.dart'; +import 'package:sentry_dio/src/sentry_dio_extension.dart'; +import 'package:sentry_dio/src/sentry_transformer.dart'; +import 'package:test/test.dart'; + +// todo: figure out a way to test if parameter are passed through correctly + +void main() { + group('SentryDioExtension', () { + test('addSentry add client and transformer', () { + final dio = Dio(); + dio.addSentry(); + expect(dio.httpClientAdapter, isA()); + expect(dio.transformer, isA()); + }); + }); +} diff --git a/dio/test/sentry_transformer_test.dart b/dio/test/sentry_transformer_test.dart new file mode 100644 index 0000000000..cd9af62bf3 --- /dev/null +++ b/dio/test/sentry_transformer_test.dart @@ -0,0 +1,156 @@ +// ignore_for_file: invalid_use_of_internal_member +// The lint above is okay, because we're using another Sentry package + +import 'package:dio/dio.dart'; +import 'package:sentry/sentry.dart'; +import 'package:sentry_dio/src/sentry_transformer.dart'; +import 'package:test/scaffolding.dart'; +import 'package:test/test.dart'; + +import 'mocks.dart'; +import 'mocks/mock_transport.dart'; +import 'package:sentry/src/sentry_tracer.dart'; + +void main() { + group(SentryTransformer, () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + test('transformRequest creates span', () async { + final sut = fixture.getSut(); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + await sut.transformRequest(RequestOptions(path: 'foo')); + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + + expect(span.status, SpanStatus.ok()); + expect(span.context.operation, 'transform-request'); + expect(span.context.description, 'GET foo'); + }); + + test('transformRequest finish span if errored request', () async { + final sut = fixture.getSut(throwException: true); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + try { + await sut.transformRequest(RequestOptions(path: 'foo')); + } catch (_) {} + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + + expect(span.status, SpanStatus.internalError()); + expect(span.context.operation, 'transform-request'); + expect(span.context.description, 'GET foo'); + expect(span.finished, true); + }); + + test('transformResponse creates span', () async { + final sut = fixture.getSut(); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + await sut.transformResponse( + RequestOptions(path: 'foo'), + ResponseBody.fromString('', 200), + ); + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + + expect(span.status, SpanStatus.ok()); + expect(span.context.operation, 'transform-response'); + expect(span.context.description, 'GET foo'); + }); + test('transformResponse finish span if errored request', () async { + final sut = fixture.getSut(throwException: true); + final tr = fixture._hub.startTransaction( + 'name', + 'op', + bindToScope: true, + ); + + try { + await sut.transformResponse( + RequestOptions(path: 'foo'), + ResponseBody.fromString('', 200), + ); + } catch (_) {} + + await tr.finish(); + + final tracer = (tr as SentryTracer); + final span = tracer.children.first; + + expect(span.status, SpanStatus.internalError()); + expect(span.context.operation, 'transform-response'); + expect(span.context.description, 'GET foo'); + expect(span.finished, true); + }); + }); +} + +class Fixture { + final _options = SentryOptions(dsn: fakeDsn); + late Hub _hub; + final transport = MockTransport(); + Fixture() { + _options.transport = transport; + _options.tracesSampleRate = 1.0; + _hub = Hub(_options); + } + + Transformer getSut({bool throwException = false}) { + return SentryTransformer( + transformer: MockTransformer(throwException), + hub: _hub, + ); + } +} + +class MockTransformer implements Transformer { + MockTransformer(this.throwException); + + final bool throwException; + + @override + Future transformRequest(RequestOptions options) async { + if (throwException) { + throw Exception('Exception'); + } + return ''; + } + + @override + // ignore: strict_raw_type + Future transformResponse( + RequestOptions options, + ResponseBody response, + ) async { + if (throwException) { + throw Exception('Exception'); + } + return ''; + } +} diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index d2bc9b3f9b..f6e0a9afc3 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -534,14 +534,13 @@ Future makeWebRequest(BuildContext context) async { Future makeWebRequestWithDio(BuildContext context) async { final transaction = Sentry.getSpan() ?? Sentry.startTransaction( - 'dio-web-erquest', + 'dio-web-request', 'request', bindToScope: true, ); final dio = Dio(); - dio.httpClientAdapter = SentryHttpClientAdapter( - client: dio.httpClientAdapter, + dio.addSentry( captureFailedRequests: true, networkTracing: true, failedRequestStatusCodes: [SentryStatusCode.range(400, 500)], From f13520bb680538d343ac7193ab9414c26943b128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Thu, 6 Jan 2022 15:53:01 +0100 Subject: [PATCH 12/18] improve readme --- dio/README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/dio/README.md b/dio/README.md index 2eb90a44ce..5306662d12 100644 --- a/dio/README.md +++ b/dio/README.md @@ -29,6 +29,7 @@ That will give you native crash support (for Android and iOS), [release health]( ```dart import 'package:sentry/sentry.dart'; +import 'package:sentry_dio/sentry_dio.dart'; Future main() async { await Sentry.init( @@ -41,10 +42,15 @@ Future main() async { void initDio() { final dio = Dio(); - dio.httpClientAdapter = SentryHttpClientAdapter(); + // Make sure this is the last initialization method, + // otherwise you might override Sentrys configuration. + dio.addSentry(...); } ``` +Dependending on you configuration, this can add performance tracing, +http breadcrumbs and automatic recording of bad http requests. + #### Resources * [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/platforms/dart/) From 3968daec23498711ca5127e79c4ebaac048f5638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Uek=C3=B6tter?= Date: Tue, 11 Jan 2022 20:15:37 +0100 Subject: [PATCH 13/18] Update dio/lib/src/sentry_client_adapter.dart --- dio/lib/src/sentry_client_adapter.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/dio/lib/src/sentry_client_adapter.dart b/dio/lib/src/sentry_client_adapter.dart index a2ca3d7c61..5b2eb03ca9 100644 --- a/dio/lib/src/sentry_client_adapter.dart +++ b/dio/lib/src/sentry_client_adapter.dart @@ -45,7 +45,6 @@ import 'breadcrumb_client_adapter.dart'; /// 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 SentryHttpClientAdapter extends HttpClientAdapter { // ignore: public_member_api_docs SentryHttpClientAdapter({ From 326226e60db99c268bc9ad26c9a194e8c193ec31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Wed, 12 Jan 2022 15:15:36 +0100 Subject: [PATCH 14/18] Change name --- ...y_client_adapter.dart => sentry_dio_client_adapter.dart} | 4 ++-- dio/lib/src/sentry_dio_extension.dart | 4 ++-- ...dapter_test.dart => sentry_dio_client_adapter_test.dart} | 6 +++--- dio/test/sentry_dio_extension_test.dart | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) rename dio/lib/src/{sentry_client_adapter.dart => sentry_dio_client_adapter.dart} (97%) rename dio/test/{sentry_http_client_adapter_test.dart => sentry_dio_client_adapter_test.dart} (96%) diff --git a/dio/lib/src/sentry_client_adapter.dart b/dio/lib/src/sentry_dio_client_adapter.dart similarity index 97% rename from dio/lib/src/sentry_client_adapter.dart rename to dio/lib/src/sentry_dio_client_adapter.dart index 5b2eb03ca9..0a9b981402 100644 --- a/dio/lib/src/sentry_client_adapter.dart +++ b/dio/lib/src/sentry_dio_client_adapter.dart @@ -45,9 +45,9 @@ import 'breadcrumb_client_adapter.dart'; /// 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 SentryHttpClientAdapter extends HttpClientAdapter { +class SentryDioClientAdapter extends HttpClientAdapter { // ignore: public_member_api_docs - SentryHttpClientAdapter({ + SentryDioClientAdapter({ required HttpClientAdapter client, Hub? hub, bool recordBreadcrumbs = true, diff --git a/dio/lib/src/sentry_dio_extension.dart b/dio/lib/src/sentry_dio_extension.dart index d2503f073a..cf3a18652b 100644 --- a/dio/lib/src/sentry_dio_extension.dart +++ b/dio/lib/src/sentry_dio_extension.dart @@ -1,7 +1,7 @@ import 'package:dio/dio.dart'; import 'package:sentry/sentry.dart'; import 'sentry_transformer.dart'; -import 'sentry_client_adapter.dart'; +import 'sentry_dio_client_adapter.dart'; /// Extension to add performance tracing for [Dio] extension SentryDioExtension on Dio { @@ -18,7 +18,7 @@ extension SentryDioExtension on Dio { bool sendDefaultPii = false, }) { // intercept http requests - httpClientAdapter = SentryHttpClientAdapter( + httpClientAdapter = SentryDioClientAdapter( client: httpClientAdapter, recordBreadcrumbs: recordBreadcrumbs, networkTracing: networkTracing, diff --git a/dio/test/sentry_http_client_adapter_test.dart b/dio/test/sentry_dio_client_adapter_test.dart similarity index 96% rename from dio/test/sentry_http_client_adapter_test.dart rename to dio/test/sentry_dio_client_adapter_test.dart index 2b247f8332..864f091cf4 100644 --- a/dio/test/sentry_http_client_adapter_test.dart +++ b/dio/test/sentry_dio_client_adapter_test.dart @@ -3,7 +3,7 @@ import 'package:http/http.dart'; import 'package:mockito/mockito.dart'; import 'package:sentry/sentry.dart'; import 'package:sentry/src/http_client/failed_request_client.dart'; -import 'package:sentry_dio/src/sentry_client_adapter.dart'; +import 'package:sentry_dio/src/sentry_dio_client_adapter.dart'; import 'package:test/test.dart'; import 'mocks/mock_http_client_adapter.dart'; @@ -12,7 +12,7 @@ import 'mocks/mock_hub.dart'; final requestUri = Uri.parse('https://example.com/'); void main() { - group(SentryHttpClientAdapter, () { + group(SentryDioClientAdapter, () { late Fixture fixture; setUp(() { @@ -131,7 +131,7 @@ class Fixture { }) { final mc = client ?? getClient(); final dio = Dio(BaseOptions(baseUrl: requestUri.toString())); - dio.httpClientAdapter = SentryHttpClientAdapter( + dio.httpClientAdapter = SentryDioClientAdapter( client: mc, hub: hub, captureFailedRequests: captureFailedRequests, diff --git a/dio/test/sentry_dio_extension_test.dart b/dio/test/sentry_dio_extension_test.dart index 6a2c3417ce..f1b1b395e0 100644 --- a/dio/test/sentry_dio_extension_test.dart +++ b/dio/test/sentry_dio_extension_test.dart @@ -1,5 +1,5 @@ import 'package:dio/dio.dart'; -import 'package:sentry_dio/src/sentry_client_adapter.dart'; +import 'package:sentry_dio/src/sentry_dio_client_adapter.dart'; import 'package:sentry_dio/src/sentry_dio_extension.dart'; import 'package:sentry_dio/src/sentry_transformer.dart'; import 'package:test/test.dart'; @@ -11,7 +11,7 @@ void main() { test('addSentry add client and transformer', () { final dio = Dio(); dio.addSentry(); - expect(dio.httpClientAdapter, isA()); + expect(dio.httpClientAdapter, isA()); expect(dio.transformer, isA()); }); }); From f9c063f71735543b4f3b6bfa81792482fd8a621f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Wed, 12 Jan 2022 15:19:26 +0100 Subject: [PATCH 15/18] Improve error handling --- dio/lib/src/sentry_transformer.dart | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/dio/lib/src/sentry_transformer.dart b/dio/lib/src/sentry_transformer.dart index d860482fc2..873c334d17 100644 --- a/dio/lib/src/sentry_transformer.dart +++ b/dio/lib/src/sentry_transformer.dart @@ -17,14 +17,18 @@ class SentryTransformer implements Transformer { 'serialize', description: 'Dio.transformRequest: ${options.method} ${options.uri}', ); + String? request; try { - final request = await _transformer.transformRequest(options); - await span?.finish(status: SpanStatus.ok()); - return request; - } catch (_) { - await span?.finish(status: SpanStatus.internalError()); + request = await _transformer.transformRequest(options); + span?.status = const SpanStatus.ok(); + } catch (exception) { + span?.throwable = exception; + span?.status = const SpanStatus.internalError(); rethrow; + } finally { + await span?.finish(); } + return request; } @override @@ -38,14 +42,18 @@ class SentryTransformer implements Transformer { description: 'Dio.transformResponse: ${options.method} ${options.uri}', ); + dynamic transformedResponse; try { - final dynamic transformedResponse = + transformedResponse = await _transformer.transformResponse(options, response); - await span?.finish(status: SpanStatus.ok()); - return transformedResponse; - } catch (_) { - await span?.finish(status: SpanStatus.internalError()); + span?.status = const SpanStatus.ok(); + } catch (exception) { + span?.throwable = exception; + span?.status = const SpanStatus.internalError(); rethrow; + } finally { + await span?.finish(); } + return transformedResponse; } } From 3f34d877a388d33909597db0302836abbce259b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Wed, 12 Jan 2022 15:23:25 +0100 Subject: [PATCH 16/18] rename description --- dio/lib/src/sentry_transformer.dart | 5 ++--- dio/test/sentry_transformer_test.dart | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/dio/lib/src/sentry_transformer.dart b/dio/lib/src/sentry_transformer.dart index 873c334d17..b886305477 100644 --- a/dio/lib/src/sentry_transformer.dart +++ b/dio/lib/src/sentry_transformer.dart @@ -15,7 +15,7 @@ class SentryTransformer implements Transformer { Future transformRequest(RequestOptions options) async { final span = _hub.getSpan()?.startChild( 'serialize', - description: 'Dio.transformRequest: ${options.method} ${options.uri}', + description: '${options.method} ${options.uri}', ); String? request; try { @@ -39,8 +39,7 @@ class SentryTransformer implements Transformer { ) async { final span = _hub.getSpan()?.startChild( 'serialize', - description: - 'Dio.transformResponse: ${options.method} ${options.uri}', + description: '${options.method} ${options.uri}', ); dynamic transformedResponse; try { diff --git a/dio/test/sentry_transformer_test.dart b/dio/test/sentry_transformer_test.dart index 98c6740514..554933159c 100644 --- a/dio/test/sentry_transformer_test.dart +++ b/dio/test/sentry_transformer_test.dart @@ -35,7 +35,7 @@ void main() { expect(span.status, SpanStatus.ok()); expect(span.context.operation, 'serialize'); - expect(span.context.description, 'Dio.transformRequest: GET foo'); + expect(span.context.description, 'GET foo'); }); test('transformRequest finish span if errored request', () async { @@ -57,7 +57,7 @@ void main() { expect(span.status, SpanStatus.internalError()); expect(span.context.operation, 'serialize'); - expect(span.context.description, 'Dio.transformRequest: GET foo'); + expect(span.context.description, 'GET foo'); expect(span.finished, true); }); @@ -81,7 +81,7 @@ void main() { expect(span.status, SpanStatus.ok()); expect(span.context.operation, 'serialize'); - expect(span.context.description, 'Dio.transformResponse: GET foo'); + expect(span.context.description, 'GET foo'); }); test('transformResponse finish span if errored request', () async { final sut = fixture.getSut(throwException: true); @@ -105,7 +105,7 @@ void main() { expect(span.status, SpanStatus.internalError()); expect(span.context.operation, 'serialize'); - expect(span.context.description, 'Dio.transformResponse: GET foo'); + expect(span.context.description, 'GET foo'); expect(span.finished, true); }); }); From b6fa29111cd6a463351f821953d7a2ca440d0beb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Uek=C3=B6tter?= Date: Fri, 14 Jan 2022 12:10:12 +0100 Subject: [PATCH 17/18] Update flutter/analysis_options.yaml Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com> --- flutter/analysis_options.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/flutter/analysis_options.yaml b/flutter/analysis_options.yaml index 7a991010d4..55e3702167 100644 --- a/flutter/analysis_options.yaml +++ b/flutter/analysis_options.yaml @@ -13,7 +13,6 @@ analyzer: deprecated_member_use_from_same_package: warning # ignore sentry/path on pubspec as we change it on deployment invalid_dependency: ignore - unnecessary_import: ignore linter: rules: From ebe0fc47641a7daae3ac716485439bd561fb4658 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Ueko=CC=88tter?= Date: Fri, 14 Jan 2022 12:42:27 +0100 Subject: [PATCH 18/18] fix analyzer warnings --- flutter/example/analysis_options.yaml | 21 +++++++++++++++++++ flutter/example/lib/user_feedback_dialog.dart | 1 - flutter/example/pubspec.yaml | 1 + 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 flutter/example/analysis_options.yaml diff --git a/flutter/example/analysis_options.yaml b/flutter/example/analysis_options.yaml new file mode 100644 index 0000000000..55e3702167 --- /dev/null +++ b/flutter/example/analysis_options.yaml @@ -0,0 +1,21 @@ +include: package:pedantic/analysis_options.yaml + +analyzer: + errors: + # treat missing required parameters as a warning (not a hint) + missing_required_param: error + # treat missing returns as a warning (not a hint) + missing_return: error + # allow having TODOs in the code + todo: ignore + # allow self-reference to deprecated members (we do this because otherwise we have + # to annotate every member in every test, assert, etc, when we deprecate something) + deprecated_member_use_from_same_package: warning + # ignore sentry/path on pubspec as we change it on deployment + invalid_dependency: ignore + +linter: + rules: + prefer_relative_imports: true + unnecessary_brace_in_string_interps: true + implementation_imports: true diff --git a/flutter/example/lib/user_feedback_dialog.dart b/flutter/example/lib/user_feedback_dialog.dart index 6781f3d886..7fdc077c94 100644 --- a/flutter/example/lib/user_feedback_dialog.dart +++ b/flutter/example/lib/user_feedback_dialog.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index f37a7288d3..e59c64bcde 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: dio: ^4.0.0 dev_dependencies: + pedantic: ^1.11.1 sentry_dart_plugin: ^1.0.0-alpha.4 dependency_overrides: