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..a2ff6b3d22 --- /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: 81 + + 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 cba2d17656..2f9d0a2fee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +* Feat: Add support for [Dio](https://pub.dev/packages/dio) (#688) * Bump: Sentry-Android to 5.5.2 and Sentry-Cocoa to 7.8.0 (#696) # 6.3.0-beta.1 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..5306662d12 --- /dev/null +++ b/dio/README.md @@ -0,0 +1,60 @@ +

+ + + +
+

+ +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'; +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(); + // 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/) +* [![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/example.dart b/dio/example/example.dart new file mode 100644 index 0000000000..d5bb7fa4f2 --- /dev/null +++ b/dio/example/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..0994656382 --- /dev/null +++ b/dio/lib/sentry_dio.dart @@ -0,0 +1,3 @@ +library sentry_dio; + +export 'src/sentry_dio_extension.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..2876947757 --- /dev/null +++ b/dio/lib/src/breadcrumb_client_adapter.dart @@ -0,0 +1,74 @@ +// ignore_for_file: strict_raw_type + +import 'dart:typed_data'; + +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({required HttpClientAdapter client, Hub? hub}) + : _hub = hub ?? HubAdapter(), + _client = client; + + 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; + final contentLengthHeader = response.headers['content-length']; + if (contentLengthHeader != null && contentLengthHeader.isNotEmpty) { + final headerValue = contentLengthHeader.first; + responseBodySize = int.tryParse(headerValue); + } + + 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..5b3829f11d --- /dev/null +++ b/dio/lib/src/failed_request_client_adapter.dart @@ -0,0 +1,163 @@ +// ignore_for_file: strict_raw_type + +import 'dart:typed_data'; + +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({ + required HttpClientAdapter client, + this.maxRequestBodySize = MaxRequestBodySize.small, + this.failedRequestStatusCodes = const [], + this.captureFailedRequests = true, + this.sendDefaultPii = false, + Hub? hub, + }) : _hub = hub ?? HubAdapter(), + _client = client; + + 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_dio_client_adapter.dart b/dio/lib/src/sentry_dio_client_adapter.dart new file mode 100644 index 0000000000..0a9b981402 --- /dev/null +++ b/dio/lib/src/sentry_dio_client_adapter.dart @@ -0,0 +1,101 @@ +// ignore_for_file: strict_raw_type + +import 'dart:typed_data'; + +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 SentryDioClientAdapter extends HttpClientAdapter { + // ignore: public_member_api_docs + SentryDioClientAdapter({ + required HttpClientAdapter client, + Hub? hub, + bool recordBreadcrumbs = true, + bool networkTracing = true, + MaxRequestBodySize maxRequestBodySize = MaxRequestBodySize.never, + List failedRequestStatusCodes = const [], + bool captureFailedRequests = false, + bool sendDefaultPii = false, + }) { + _hub = hub ?? HubAdapter(); + + var innerClient = client; + + 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/sentry_dio_extension.dart b/dio/lib/src/sentry_dio_extension.dart new file mode 100644 index 0000000000..cf3a18652b --- /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_dio_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 = SentryDioClientAdapter( + 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..b886305477 --- /dev/null +++ b/dio/lib/src/sentry_transformer.dart @@ -0,0 +1,58 @@ +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( + 'serialize', + description: '${options.method} ${options.uri}', + ); + String? request; + try { + 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 + // ignore: strict_raw_type + Future transformResponse( + RequestOptions options, + ResponseBody response, + ) async { + final span = _hub.getSpan()?.startChild( + 'serialize', + description: '${options.method} ${options.uri}', + ); + dynamic transformedResponse; + try { + transformedResponse = + await _transformer.transformResponse(options, response); + span?.status = const SpanStatus.ok(); + } catch (exception) { + span?.throwable = exception; + span?.status = const SpanStatus.internalError(); + rethrow; + } finally { + await span?.finish(); + } + return transformedResponse; + } +} diff --git a/dio/lib/src/tracing_client_adapter.dart b/dio/lib/src/tracing_client_adapter.dart new file mode 100644 index 0000000000..cc8cb2b6d2 --- /dev/null +++ b/dio/lib/src/tracing_client_adapter.dart @@ -0,0 +1,57 @@ +// ignore_for_file: strict_raw_type + +import 'dart:typed_data'; + +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({required HttpClientAdapter client, Hub? hub}) + : _hub = hub ?? HubAdapter(), + _client = client; + + 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..c0a2bc9a4e --- /dev/null +++ b/dio/pubspec.yaml @@ -0,0 +1,25 @@ +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: ^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 + +dependency_overrides: + sentry: + path: ../dart \ 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..29058db7d2 --- /dev/null +++ b/dio/test/breadcrumb_client_adapter_test.dart @@ -0,0 +1,191 @@ +import 'package:dio/dio.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sentry/sentry.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 Exception', () async { + final sut = fixture.getSut( + MockHttpClientAdapter((options, requestStream, cancelFuture) async { + expect(options.uri, Uri.parse('https://example.com/')); + throw Exception('test'); + }), + ); + + try { + await sut.get('/'); + fail('Method did not throw'); + } on DioError catch (e) { + expect(e.message, 'Exception: test'); + expect(e.requestOptions.uri, Uri.parse('https://example.com/')); + } + + 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 = CloseableMockClientAdapter(); + + final client = BreadcrumbClientAdapter(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 CloseableMockClientAdapter extends Mock implements HttpClientAdapter {} + +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_dio_client_adapter_test.dart b/dio/test/sentry_dio_client_adapter_test.dart new file mode 100644 index 0000000000..864f091cf4 --- /dev/null +++ b/dio/test/sentry_dio_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_dio_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(SentryDioClientAdapter, () { + 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 = SentryDioClientAdapter( + 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/sentry_dio_extension_test.dart b/dio/test/sentry_dio_extension_test.dart new file mode 100644 index 0000000000..f1b1b395e0 --- /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_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'; + +// 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..554933159c --- /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, 'serialize'); + 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, 'serialize'); + 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, 'serialize'); + 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, 'serialize'); + 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/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 {} diff --git a/flutter/analysis_options.yaml b/flutter/analysis_options.yaml index a34301a93e..55e3702167 100644 --- a/flutter/analysis_options.yaml +++ b/flutter/analysis_options.yaml @@ -13,8 +13,6 @@ analyzer: deprecated_member_use_from_same_package: warning # ignore sentry/path on pubspec as we change it on deployment invalid_dependency: ignore - exclude: - - example/** linter: rules: 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/main.dart b/flutter/example/lib/main.dart index e5bd062817..f6e0a9afc3 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,51 @@ 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-request', + 'request', + bindToScope: true, + ); + + final dio = Dio(); + dio.addSentry( + 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/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 254fde5339..e59c64bcde 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -13,11 +13,14 @@ 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: + pedantic: ^1.11.1 sentry_dart_plugin: ^1.0.0-alpha.4 dependency_overrides: @@ -25,6 +28,8 @@ dependency_overrides: path: ../../dart sentry_flutter: path: ../ + sentry_dio: + path: ../../dio flutter: uses-material-design: true