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