diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml new file mode 100644 index 0000000..f45e642 --- /dev/null +++ b/.github/workflows/dart.yml @@ -0,0 +1,42 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Dart + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + # Note: This workflow uses the latest stable version of the Dart SDK. + # You can specify other versions if desired, see documentation here: + # https://github.com/dart-lang/setup-dart/blob/main/README.md + # - uses: dart-lang/setup-dart@v1 + - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 + + - name: Install dependencies + run: dart pub get + + # Uncomment this step to verify the use of 'dart format' on each commit. + # - name: Verify formatting + # run: dart format --output=none --set-exit-if-changed . + + # Consider passing '--fatal-infos' for slightly stricter analysis. + - name: Analyze project source + run: dart analyze + + # Your project will need to have tests in test/ and a dependency on + # package:test for this step to succeed. Note that Flutter projects will + # want to change this to 'flutter test'. + - name: Run tests + run: dart test diff --git a/CHANGELOG.md b/CHANGELOG.md index bcdb862..359e820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Fixed native errors not being reported correctly. * Fixed incorrect LoggingLevel values on Android. + # 22.12.0-beta.1 * Improved crash reporting by grouping exceptions and errors into different categories in the UI * controller. diff --git a/example/integration_test/features/request_tracker_test.dart b/example/integration_test/features/request_tracker_test.dart index ee044bd..c581524 100644 --- a/example/integration_test/features/request_tracker_test.dart +++ b/example/integration_test/features/request_tracker_test.dart @@ -116,6 +116,31 @@ extension on WidgetTester { expect(actualHeaders[key.toLowerCase()], value.first); }); } + + assertDioInterceptorBeaconSent() async { + final requestSentLabel = find.text("Success with 200."); + await ensureVisible(requestSentLabel); + expect(requestSentLabel, findsOneWidget); + + final trackerRequests = await findRequestsBy( + url: successURL, + type: "network-request", + hrc: "200", + $is: "Manual HttpTracker", + ); + expect(trackerRequests.length, 3); + + final actualRequests = await findRequestsBy(type: "trackeddiointerceptor"); + expect(actualRequests.length, 1); + + // Also assert correlation headers are added + final Map actualHeaders = + actualRequests[0]["request"]["headers"]; + final btHeaders = await RequestTracker.getServerCorrelationHeaders(); + btHeaders.forEach((key, value) { + expect(actualHeaders[key.toLowerCase()], value.first); + }); + } } void main() { @@ -147,6 +172,9 @@ void main() { await tester.tapAndSettle("manualDioClientGetRequestButton"); await tester.flushBeacons(); await tester.assertDioTrackerBeaconSent(); + await tester.tapAndSettle("manualDioInterceptorGetRequestButton"); + await tester.flushBeacons(); + await tester.assertDioInterceptorBeaconSent(); }); tearDown(() async { diff --git a/example/integration_test/tests.gradle b/example/integration_test/tests.gradle index 235207a..71255a4 100644 --- a/example/integration_test/tests.gradle +++ b/example/integration_test/tests.gradle @@ -9,7 +9,7 @@ // Configure these based on the system's emulator/simulator names. def android_device = "Android SDK built for x86" -def iOS_device = "iPhone 13" +def iOS_device = "iPhone 14" def test_driver_path = "integration_test/test_driver/integration_test.dart" def integration_tests_path = "integration_test/features/" diff --git a/example/lib/feature_list/features/manual_network_requests.dart b/example/lib/feature_list/features/manual_network_requests.dart index d1267ff..65c7ba3 100644 --- a/example/lib/feature_list/features/manual_network_requests.dart +++ b/example/lib/feature_list/features/manual_network_requests.dart @@ -152,6 +152,32 @@ class _ManualNetworkRequestsState extends State { } } + Future _sendDioInterceptorRequestButtonPressed() async { + var urlString = urlFieldController.text; + if (urlString.trim().isEmpty) { + return; + } + + try { + setState(() { + responseText = "Loading..."; + }); + + final dioClient = Dio(); + dioClient.interceptors.add(TrackedDioInterceptor()); + + final response = await dioClient.post(urlString, + data: "[{\"type\": \"trackeddiointerceptor\"}]"); + setState(() { + responseText = "Success with ${response.statusCode}."; + }); + } catch (e) { + setState(() { + responseText = "Failed with ${e.toString()}."; + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -218,18 +244,19 @@ class _ManualNetworkRequestsState extends State { onPressed: _sendPostRequestButtonPressed), ElevatedButton( key: const Key("manualHttpClientGetRequestButton"), - child: const Text( - 'TrackedHttpClient GET request\n' - '(has custom header: "foo")', + child: const Text('TrackedHttpClient GET request', textAlign: TextAlign.center), onPressed: _sendHttpClientRequestButtonPressed), ElevatedButton( key: const Key("manualDioClientGetRequestButton"), - child: const Text( - 'TrackedDioClient GET request\n' - '(has custom header: "foo")', + child: const Text('TrackedDioClient GET request', textAlign: TextAlign.center), onPressed: _sendDioClientRequestButtonPressed), + ElevatedButton( + key: const Key("manualDioInterceptorGetRequestButton"), + child: const Text('TrackedDioInterceptor GET request', + textAlign: TextAlign.center), + onPressed: _sendDioInterceptorRequestButtonPressed), const SizedBox( height: 30, ), diff --git a/lib/appdynamics_agent.dart b/lib/appdynamics_agent.dart index b179bce..3cb923d 100644 --- a/lib/appdynamics_agent.dart +++ b/lib/appdynamics_agent.dart @@ -10,6 +10,7 @@ export 'src/instrumentation.dart'; export 'src/request_tracker.dart'; export 'src/tracked_clients/tracked_http_client.dart'; export 'src/tracked_clients/tracked_dio_client.dart'; +export 'src/tracked_clients/tracked_dio_interceptor.dart'; export 'src/session_frame.dart' hide createSessionFrame; export 'src/activity_tracking/widget_tracker.dart' hide TrackedWidget; export 'src/activity_tracking/navigation_observer.dart'; diff --git a/lib/src/instrumentation.dart b/lib/src/instrumentation.dart index 1b935db..18f69f9 100644 --- a/lib/src/instrumentation.dart +++ b/lib/src/instrumentation.dart @@ -102,6 +102,7 @@ class Instrumentation { Map arguments = { "appKey": config.appKey, + "applicationName": config.applicationName, "loggingLevel": config.loggingLevel.index, "collectorURL": config.collectorURL, "screenshotURL": config.screenshotURL, diff --git a/lib/src/request_tracker.dart b/lib/src/request_tracker.dart index 9fd6be7..7908d03 100644 --- a/lib/src/request_tracker.dart +++ b/lib/src/request_tracker.dart @@ -109,7 +109,8 @@ class RequestTracker { /// Sets a dictionary representing the keys and values from the server /// response's headers. /// - /// If an error occurred and a response was not received, this not be called. + /// If an error occurred and a response was not received, this should not be + /// called. /// /// Method might throw [Exception]. Future setResponseHeaders( diff --git a/lib/src/tracked_clients/tracked_dio_client.dart b/lib/src/tracked_clients/tracked_dio_client.dart index fecdddf..7fd363f 100644 --- a/lib/src/tracked_clients/tracked_dio_client.dart +++ b/lib/src/tracked_clients/tracked_dio_client.dart @@ -5,11 +5,12 @@ */ import 'package:dio/dio.dart'; -import 'package:dio/native_imp.dart'; +import 'package:dio/io.dart'; import '../../appdynamics_agent.dart'; /// Use this client to track requests made via the `dio` package (version <5). +/// For Dio version 5 and above, see [TrackedDioInterceptor]. /// /// ```dart /// import 'package:dio/dio.dart'; diff --git a/lib/src/tracked_clients/tracked_dio_interceptor.dart b/lib/src/tracked_clients/tracked_dio_interceptor.dart new file mode 100644 index 0000000..3e98a50 --- /dev/null +++ b/lib/src/tracked_clients/tracked_dio_interceptor.dart @@ -0,0 +1,103 @@ +import 'package:appdynamics_agent/appdynamics_agent.dart'; +import 'package:dio/dio.dart'; + +/// Use this class to create a custom Dio [Interceptor] that tracks requests +/// automatically. Alternative to [TrackedDioClient]. +/// +/// ```dart +/// import 'package:dio/dio.dart'; +/// +/// try { +/// final dio = Dio(); +/// dio.interceptors.add(TrackedDioInterceptor()); +/// final response = await dio.post(urlString, data: {"foo": "bar"}); +/// // handle response +/// } catch (e) { +/// // handle error +/// } +/// ``` +class TrackedDioInterceptor implements Interceptor { + final bool addCorrelationHeaders; + + final Map _activeTrackers = {}; + + TrackedDioInterceptor({this.addCorrelationHeaders = true}); + + static const _trackerId = 'trackerId'; + + @override + void onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + try { + if (addCorrelationHeaders) { + final correlationHeaders = + await RequestTracker.getServerCorrelationHeaders(); + final headers = correlationHeaders.map( + (key, value) => MapEntry(key, value.first), + ); + options.headers.addAll(headers); + } + + var url = options.uri.toString(); + final tracker = await RequestTracker.create(url); + _activeTrackers[tracker.id] = tracker; + options.extra[_trackerId] = tracker.id; + } finally { + handler.next(options); + } + } + + @override + void onResponse( + Response response, + ResponseInterceptorHandler handler, + ) async { + try { + final tracker = _activeTrackers.remove( + response.requestOptions.extra[_trackerId], + ); + if (tracker != null) { + await tracker.setResponseStatusCode(response.statusCode ?? 404); + await _logResponse(response, tracker); + await tracker.reportDone(); + } + } finally { + handler.next(response); + } + } + + @override + void onError(DioError err, ErrorInterceptorHandler handler) async { + try { + final tracker = _activeTrackers.remove( + err.requestOptions.extra[_trackerId], + ); + if (tracker != null) { + final statusCode = err.response?.statusCode; + if (statusCode != null) { + // TODO: Find a way to test this (coverage). + // Errors for when status code is not in accepted range should be recorded as normal + await tracker.setResponseStatusCode(statusCode); + await _logResponse(err.response, tracker); + } else { + // If status code is null, it means that the error is not a response, but rather a network error + await tracker.setError(err.toString(), err.stackTrace.toString()); + } + await tracker.reportDone(); + } + } finally { + handler.next(err); + } + } + + Future _logResponse(Response? response, RequestTracker tracker) async { + if (response != null) { + await tracker.setRequestHeaders( + response.requestOptions.headers.map((k, v) => MapEntry(k, [v])), + ); + await tracker.setResponseHeaders(response.headers.map); + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index dad43b1..0e00275 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter http: ^0.13.3 - dio: ^4.0.6 + dio: ^5.1.0 dev_dependencies: flutter_test: diff --git a/test/tracked_dio_client_test.dart b/test/tracked_dio_client_test.dart index a72edae..6286664 100644 --- a/test/tracked_dio_client_test.dart +++ b/test/tracked_dio_client_test.dart @@ -39,7 +39,7 @@ void main() { // Used to not duplicate logic that has same results but only one different // parameter (i.e. request options). - void happyPathTestLogic( + Future happyPathTestLogic( Options? options, Map> expectedHeaders) async { final dio = Dio(); dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) { @@ -91,20 +91,20 @@ void main() { final headers = await RequestTracker.getServerCorrelationHeaders(); headers.addAll( customHeaders.map((key, value) => MapEntry(key, [value]))); - happyPathTestLogic(Options(headers: customHeaders), headers); + await happyPathTestLogic(Options(headers: customHeaders), headers); }); test('TrackedDioClient happy path works with `null` options', () async { log = []; final headers = await RequestTracker.getServerCorrelationHeaders(); - happyPathTestLogic(null, headers); + await happyPathTestLogic(null, headers); }); test('TrackedDioClient happy path works with `null` option headers', () async { log = []; final headers = await RequestTracker.getServerCorrelationHeaders(); - happyPathTestLogic(Options(headers: null), headers); + await happyPathTestLogic(Options(headers: null), headers); }); test('TrackedDioClient error-path methods are called correctly', () async { @@ -112,7 +112,7 @@ void main() { const urlString = "https://www.foo.com"; final error = DioError( - type: DioErrorType.other, + type: DioErrorType.unknown, error: Error(), requestOptions: RequestOptions(path: urlString)); diff --git a/test/tracked_dio_interceptor_test.dart b/test/tracked_dio_interceptor_test.dart new file mode 100644 index 0000000..30d3731 --- /dev/null +++ b/test/tracked_dio_interceptor_test.dart @@ -0,0 +1,204 @@ +import 'package:appdynamics_agent/appdynamics_agent.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'globals.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + List log = []; + + TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + switch (methodCall.method) { + case 'getRequestTrackerWithUrl': + case "setRequestTrackerErrorInfo": + case "setRequestTrackerStatusCode": + case "setRequestTrackerResponseHeaders": + case "setRequestTrackerRequestHeaders": + case "requestTrackerReport": + log.add(methodCall); + return null; + case "getServerCorrelationHeaders": + log.add(methodCall); + return { + "foo": ["bar"] + }; + default: + return null; + } + }); + + setUp(() { + log = []; + }); + + // Used to not duplicate logic that has same results but only one different + // parameter (i.e. request options). + Future happyPathTestLogic( + Options? options, + Map> expectedHeaders, + ) async { + final dio = Dio(); + + final trackingInterceptor = TrackedDioInterceptor(); + dio.interceptors.add(trackingInterceptor); + + dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) { + final response = Response( + requestOptions: options, + data: "{}", + statusCode: 200, + ); + handler.resolve(response, true); + })); + + const urlString = "https://www.foo.com"; + final response = await dio.get(urlString, options: options); + final trackedId = response.requestOptions.extra["trackerId"]; + + expect(log, hasLength(7)); + expect(log, [ + isMethodCall( + 'getServerCorrelationHeaders', + arguments: null, + ), + isMethodCall( + 'getServerCorrelationHeaders', + arguments: null, + ), + isMethodCall('getRequestTrackerWithUrl', + arguments: {"id": trackedId, "url": urlString}), + isMethodCall( + 'setRequestTrackerStatusCode', + arguments: {"id": trackedId, "statusCode": response.statusCode}, + ), + isMethodCall( + 'setRequestTrackerRequestHeaders', + arguments: {"id": trackedId, "headers": expectedHeaders}, + ), + isMethodCall( + 'setRequestTrackerResponseHeaders', + arguments: {"id": trackedId, "headers": {}}, + ), + isMethodCall( + 'requestTrackerReport', + arguments: {"id": trackedId}, + ) + ]); + } + + test('TrackingInterceptor happy path methods works with initial headers', + () async { + final customHeaders = {"custom": "header"}; + final headers = await RequestTracker.getServerCorrelationHeaders(); + headers.addAll( + customHeaders.map((key, value) => MapEntry(key, [value])), + ); + await happyPathTestLogic(Options(headers: customHeaders), headers); + }); + + test('TrackingInterceptor happy path works with `null` options', () async { + final headers = await RequestTracker.getServerCorrelationHeaders(); + await happyPathTestLogic(null, headers); + }); + + test('TrackingInterceptor happy path works with `null` option headers', + () async { + final headers = await RequestTracker.getServerCorrelationHeaders(); + await happyPathTestLogic(Options(headers: null), headers); + }); + + test('TrackingInterceptor error-path methods are called correctly', () async { + const urlString = "https://www.foo.com"; + + final dio = Dio(); + final trackingInterceptor = TrackedDioInterceptor(); + dio.interceptors.add(trackingInterceptor); + + dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) { + final DioError error = DioError( + type: DioErrorType.unknown, + error: Error(), + requestOptions: options, + ); + handler.reject(error, true); + })); + + try { + await dio.request(urlString); + } catch (e) { + final trackerId = (e as DioError).requestOptions.extra["trackerId"]; + expect(log, hasLength(4)); + expect(log, [ + isMethodCall( + 'getServerCorrelationHeaders', + arguments: null, + ), + isMethodCall( + 'getRequestTrackerWithUrl', + arguments: {"id": trackerId, "url": urlString}, + ), + isMethodCall('setRequestTrackerErrorInfo', arguments: { + "id": trackerId, + "errorDict": { + "message": e.toString(), + "stack": e.stackTrace.toString() + } + }), + isMethodCall( + 'requestTrackerReport', + arguments: {"id": trackerId}, + ), + ]); + } + }); + + test("TrackingInterceptor doesn't call correlation headers method", () async { + const urlString = "https://www.foo.com"; + final dio = Dio(); + + final trackingInterceptor = TrackedDioInterceptor( + addCorrelationHeaders: false, + ); + dio.interceptors.add(trackingInterceptor); + + dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) { + final response = Response( + requestOptions: options, + data: "{}", + statusCode: 200, + ); + handler.resolve(response, true); + })); + + final response = await dio.request(urlString); + final trackedId = response.requestOptions.extra["trackerId"]; + + expect(log, hasLength(5)); + expect(log, [ + isMethodCall( + 'getRequestTrackerWithUrl', + arguments: {"id": trackedId, "url": urlString}, + ), + isMethodCall( + 'setRequestTrackerStatusCode', + arguments: {"id": trackedId, "statusCode": response.statusCode}, + ), + isMethodCall('setRequestTrackerRequestHeaders', arguments: { + "id": trackedId, + "headers": {}, + }), + isMethodCall( + 'setRequestTrackerResponseHeaders', + arguments: {"id": trackedId, "headers": {}}, + ), + isMethodCall( + 'requestTrackerReport', + arguments: {"id": trackedId}, + ) + ]); + }); +}