Integrated cupertino_http with package_http_profile (#1079)

diff --git a/.github/workflows/cupertino.yml b/.github/workflows/cupertino.yml
index 21fb23b..b790193 100644
--- a/.github/workflows/cupertino.yml
+++ b/.github/workflows/cupertino.yml
@@ -31,7 +31,7 @@
       matrix:
         # Test on the minimum supported flutter version and the latest
         # version.
-        flutter-version: ["3.19.0", "any"]
+        flutter-version: ["3.22.0", "any"]
     steps:
       - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
       - uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225
diff --git a/pkgs/cupertino_http/CHANGELOG.md b/pkgs/cupertino_http/CHANGELOG.md
index c347978..090475c 100644
--- a/pkgs/cupertino_http/CHANGELOG.md
+++ b/pkgs/cupertino_http/CHANGELOG.md
@@ -1,5 +1,7 @@
-## 1.4.1-wip
+## 1.5.0
 
+* Add integration to the
+  [DevTools Network View](https://docs.flutter.dev/tools/devtools/network).
 * Upgrade to `package:ffigen` 11.0.0.
 * Bring `WebSocket` behavior in line with the documentation by throwing
   `WebSocketConnectionClosed` rather than `StateError` when attempting to send
diff --git a/pkgs/cupertino_http/example/integration_test/client_conformance_test.dart b/pkgs/cupertino_http/example/integration_test/client_conformance_test.dart
index 3007123..7c936da 100644
--- a/pkgs/cupertino_http/example/integration_test/client_conformance_test.dart
+++ b/pkgs/cupertino_http/example/integration_test/client_conformance_test.dart
@@ -5,17 +5,39 @@
 import 'package:cupertino_http/cupertino_http.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:http_client_conformance_tests/http_client_conformance_tests.dart';
+import 'package:http_profile/http_profile.dart';
 import 'package:integration_test/integration_test.dart';
 
 void main() {
   IntegrationTestWidgetsFlutterBinding.ensureInitialized();
 
   group('defaultSessionConfiguration', () {
-    testAll(
-      CupertinoClient.defaultSessionConfiguration,
-      canReceiveSetCookieHeaders: true,
-      canSendCookieHeaders: true,
-    );
+    group('profile enabled', () {
+      final profile = HttpClientRequestProfile.profilingEnabled;
+      HttpClientRequestProfile.profilingEnabled = true;
+      try {
+        testAll(
+          CupertinoClient.defaultSessionConfiguration,
+          canReceiveSetCookieHeaders: true,
+          canSendCookieHeaders: true,
+        );
+      } finally {
+        HttpClientRequestProfile.profilingEnabled = profile;
+      }
+    });
+    group('profile disabled', () {
+      final profile = HttpClientRequestProfile.profilingEnabled;
+      HttpClientRequestProfile.profilingEnabled = false;
+      try {
+        testAll(
+          CupertinoClient.defaultSessionConfiguration,
+          canReceiveSetCookieHeaders: true,
+          canSendCookieHeaders: true,
+        );
+      } finally {
+        HttpClientRequestProfile.profilingEnabled = profile;
+      }
+    });
   });
   group('fromSessionConfiguration', () {
     final config = URLSessionConfiguration.ephemeralSessionConfiguration();
diff --git a/pkgs/cupertino_http/example/integration_test/client_profile_test.dart b/pkgs/cupertino_http/example/integration_test/client_profile_test.dart
new file mode 100644
index 0000000..d823370
--- /dev/null
+++ b/pkgs/cupertino_http/example/integration_test/client_profile_test.dart
@@ -0,0 +1,335 @@
+// Copyright (c) 2024, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:cupertino_http/src/cupertino_client.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart';
+import 'package:http_profile/http_profile.dart';
+import 'package:integration_test/integration_test.dart';
+
+void main() {
+  IntegrationTestWidgetsFlutterBinding.ensureInitialized();
+
+  group('profile', () {
+    final profilingEnabled = HttpClientRequestProfile.profilingEnabled;
+
+    setUpAll(() {
+      HttpClientRequestProfile.profilingEnabled = true;
+    });
+
+    tearDownAll(() {
+      HttpClientRequestProfile.profilingEnabled = profilingEnabled;
+    });
+
+    group('non-streamed POST', () {
+      late HttpServer successServer;
+      late Uri successServerUri;
+      late HttpClientRequestProfile profile;
+
+      setUpAll(() async {
+        successServer = (await HttpServer.bind('localhost', 0))
+          ..listen((request) async {
+            await request.drain<void>();
+            request.response.headers.set('Content-Type', 'text/plain');
+            request.response.headers.set('Content-Length', '11');
+            request.response.write('Hello World');
+            await request.response.close();
+          });
+        successServerUri = Uri.http('localhost:${successServer.port}');
+        final client = CupertinoClientWithProfile.defaultSessionConfiguration();
+        await client.post(successServerUri,
+            headers: {'Content-Type': 'text/plain'}, body: 'Hi');
+        profile = client.profile!;
+      });
+      tearDownAll(() {
+        successServer.close();
+      });
+
+      test('profile attributes', () {
+        expect(profile.events, isEmpty);
+        expect(profile.requestMethod, 'POST');
+        expect(profile.requestUri, successServerUri.toString());
+        expect(profile.connectionInfo,
+            containsPair('package', 'package:cupertino_http'));
+      });
+
+      test('request attributes', () {
+        expect(profile.requestData.bodyBytes, 'Hi'.codeUnits);
+        expect(profile.requestData.contentLength, 2);
+        expect(profile.requestData.endTime, isNotNull);
+        expect(profile.requestData.error, isNull);
+        expect(
+            profile.requestData.headers, containsPair('Content-Length', ['2']));
+        expect(profile.requestData.headers,
+            containsPair('Content-Type', ['text/plain; charset=utf-8']));
+        expect(profile.requestData.persistentConnection, isNull);
+        expect(profile.requestData.proxyDetails, isNull);
+        expect(profile.requestData.startTime, isNotNull);
+      });
+
+      test('response attributes', () {
+        expect(profile.responseData.bodyBytes, 'Hello World'.codeUnits);
+        expect(profile.responseData.compressionState, isNull);
+        expect(profile.responseData.contentLength, 11);
+        expect(profile.responseData.endTime, isNotNull);
+        expect(profile.responseData.error, isNull);
+        expect(profile.responseData.headers,
+            containsPair('content-type', ['text/plain']));
+        expect(profile.responseData.headers,
+            containsPair('content-length', ['11']));
+        expect(profile.responseData.isRedirect, false);
+        expect(profile.responseData.persistentConnection, isNull);
+        expect(profile.responseData.reasonPhrase, 'OK');
+        expect(profile.responseData.redirects, isEmpty);
+        expect(profile.responseData.startTime, isNotNull);
+        expect(profile.responseData.statusCode, 200);
+      });
+    });
+
+    group('streaming POST request', () {
+      late HttpServer successServer;
+      late Uri successServerUri;
+      late HttpClientRequestProfile profile;
+
+      setUpAll(() async {
+        successServer = (await HttpServer.bind('localhost', 0))
+          ..listen((request) async {
+            await request.drain<void>();
+            request.response.headers.set('Content-Type', 'text/plain');
+            request.response.headers.set('Content-Length', '11');
+            request.response.write('Hello World');
+            await request.response.close();
+          });
+        successServerUri = Uri.http('localhost:${successServer.port}');
+        final client = CupertinoClientWithProfile.defaultSessionConfiguration();
+        final request = StreamedRequest('POST', successServerUri);
+        final stream = () async* {
+          for (var i = 0; i < 1000; ++i) {
+            await Future<void>.delayed(const Duration());
+            // The request has started but not finished.
+            expect(client.profile!.requestData.startTime, isNotNull);
+            expect(client.profile!.requestData.endTime, isNull);
+            expect(client.profile!.responseData.startTime, isNull);
+            expect(client.profile!.responseData.endTime, isNull);
+            yield 'Hello'.codeUnits;
+          }
+        }();
+        unawaited(
+            request.sink.addStream(stream).then((_) => request.sink.close()));
+
+        await client.send(request);
+        profile = client.profile!;
+      });
+      tearDownAll(() {
+        successServer.close();
+      });
+
+      test('request attributes', () async {
+        expect(profile.requestData.bodyBytes, ('Hello' * 1000).codeUnits);
+        expect(profile.requestData.contentLength, isNull);
+        expect(profile.requestData.endTime, isNotNull);
+        expect(profile.requestData.startTime, isNotNull);
+        expect(profile.requestData.headers, isNot(contains('Content-Length')));
+      });
+    });
+
+    group('failed POST request', () {
+      late HttpClientRequestProfile profile;
+
+      setUpAll(() async {
+        final client = CupertinoClientWithProfile.defaultSessionConfiguration();
+        try {
+          await client.post(Uri.http('thisisnotahost'),
+              headers: {'Content-Type': 'text/plain'}, body: 'Hi');
+          fail('expected exception');
+        } on ClientException {
+          // Expected exception.
+        }
+        profile = client.profile!;
+      });
+
+      test('profile attributes', () {
+        expect(profile.events, isEmpty);
+        expect(profile.requestMethod, 'POST');
+        expect(profile.requestUri, 'http://thisisnotahost');
+        expect(profile.connectionInfo,
+            containsPair('package', 'package:cupertino_http'));
+      });
+
+      test('request attributes', () {
+        expect(profile.requestData.bodyBytes, 'Hi'.codeUnits);
+        expect(profile.requestData.contentLength, 2);
+        expect(profile.requestData.endTime, isNotNull);
+        expect(profile.requestData.error, startsWith('ClientException:'));
+        expect(
+            profile.requestData.headers, containsPair('Content-Length', ['2']));
+        expect(profile.requestData.headers,
+            containsPair('Content-Type', ['text/plain; charset=utf-8']));
+        expect(profile.requestData.persistentConnection, isNull);
+        expect(profile.requestData.proxyDetails, isNull);
+        expect(profile.requestData.startTime, isNotNull);
+      });
+
+      test('response attributes', () {
+        expect(profile.responseData.bodyBytes, isEmpty);
+        expect(profile.responseData.compressionState, isNull);
+        expect(profile.responseData.contentLength, isNull);
+        expect(profile.responseData.endTime, isNull);
+        expect(profile.responseData.error, isNull);
+        expect(profile.responseData.headers, isNull);
+        expect(profile.responseData.isRedirect, isNull);
+        expect(profile.responseData.persistentConnection, isNull);
+        expect(profile.responseData.reasonPhrase, isNull);
+        expect(profile.responseData.redirects, isEmpty);
+        expect(profile.responseData.startTime, isNull);
+        expect(profile.responseData.statusCode, isNull);
+      });
+    });
+
+    group('failed POST response', () {
+      late HttpServer successServer;
+      late Uri successServerUri;
+      late HttpClientRequestProfile profile;
+
+      setUpAll(() async {
+        successServer = (await HttpServer.bind('localhost', 0))
+          ..listen((request) async {
+            await request.drain<void>();
+            request.response.headers.set('Content-Type', 'text/plain');
+            request.response.headers.set('Content-Length', '11');
+            final socket = await request.response.detachSocket();
+            await socket.close();
+          });
+        successServerUri = Uri.http('localhost:${successServer.port}');
+        final client = CupertinoClientWithProfile.defaultSessionConfiguration();
+
+        try {
+          await client.post(successServerUri,
+              headers: {'Content-Type': 'text/plain'}, body: 'Hi');
+          fail('expected exception');
+        } on ClientException {
+          // Expected exception.
+        }
+        profile = client.profile!;
+      });
+      tearDownAll(() {
+        successServer.close();
+      });
+
+      test('profile attributes', () {
+        expect(profile.events, isEmpty);
+        expect(profile.requestMethod, 'POST');
+        expect(profile.requestUri, successServerUri.toString());
+        expect(profile.connectionInfo,
+            containsPair('package', 'package:cupertino_http'));
+      });
+
+      test('request attributes', () {
+        expect(profile.requestData.bodyBytes, 'Hi'.codeUnits);
+        expect(profile.requestData.contentLength, 2);
+        expect(profile.requestData.endTime, isNotNull);
+        expect(profile.requestData.error, isNull);
+        expect(
+            profile.requestData.headers, containsPair('Content-Length', ['2']));
+        expect(profile.requestData.headers,
+            containsPair('Content-Type', ['text/plain; charset=utf-8']));
+        expect(profile.requestData.persistentConnection, isNull);
+        expect(profile.requestData.proxyDetails, isNull);
+        expect(profile.requestData.startTime, isNotNull);
+      });
+
+      test('response attributes', () {
+        expect(profile.responseData.bodyBytes, isEmpty);
+        expect(profile.responseData.compressionState, isNull);
+        expect(profile.responseData.contentLength, 11);
+        expect(profile.responseData.endTime, isNotNull);
+        expect(profile.responseData.error, startsWith('ClientException:'));
+        expect(profile.responseData.headers,
+            containsPair('content-type', ['text/plain']));
+        expect(profile.responseData.headers,
+            containsPair('content-length', ['11']));
+        expect(profile.responseData.isRedirect, false);
+        expect(profile.responseData.persistentConnection, isNull);
+        expect(profile.responseData.reasonPhrase, 'OK');
+        expect(profile.responseData.redirects, isEmpty);
+        expect(profile.responseData.startTime, isNotNull);
+        expect(profile.responseData.statusCode, 200);
+      });
+    });
+
+    group('redirects', () {
+      late HttpServer successServer;
+      late Uri successServerUri;
+      late HttpClientRequestProfile profile;
+
+      setUpAll(() async {
+        successServer = (await HttpServer.bind('localhost', 0))
+          ..listen((request) async {
+            if (request.requestedUri.pathSegments.isEmpty) {
+              unawaited(request.response.close());
+            } else {
+              final n = int.parse(request.requestedUri.pathSegments.last);
+              final nextPath = n - 1 == 0 ? '' : '${n - 1}';
+              unawaited(request.response
+                  .redirect(successServerUri.replace(path: '/$nextPath')));
+            }
+          });
+        successServerUri = Uri.http('localhost:${successServer.port}');
+      });
+      tearDownAll(() {
+        successServer.close();
+      });
+
+      test('no redirects', () async {
+        final client = CupertinoClientWithProfile.defaultSessionConfiguration();
+        await client.get(successServerUri);
+        profile = client.profile!;
+
+        expect(profile.responseData.redirects, isEmpty);
+      });
+
+      test('follow redirects', () async {
+        final client = CupertinoClientWithProfile.defaultSessionConfiguration();
+        await client.send(Request('GET', successServerUri.replace(path: '/3'))
+          ..followRedirects = true
+          ..maxRedirects = 4);
+        profile = client.profile!;
+
+        expect(profile.requestData.followRedirects, true);
+        expect(profile.requestData.maxRedirects, 4);
+        expect(profile.responseData.isRedirect, false);
+
+        expect(profile.responseData.redirects, [
+          HttpProfileRedirectData(
+              statusCode: 302,
+              method: 'GET',
+              location: successServerUri.replace(path: '/2').toString()),
+          HttpProfileRedirectData(
+              statusCode: 302,
+              method: 'GET',
+              location: successServerUri.replace(path: '/1').toString()),
+          HttpProfileRedirectData(
+            statusCode: 302,
+            method: 'GET',
+            location: successServerUri.replace(path: '/').toString(),
+          )
+        ]);
+      });
+
+      test('no follow redirects', () async {
+        final client = CupertinoClientWithProfile.defaultSessionConfiguration();
+        await client.send(Request('GET', successServerUri.replace(path: '/3'))
+          ..followRedirects = false);
+        profile = client.profile!;
+
+        expect(profile.requestData.followRedirects, false);
+        expect(profile.responseData.isRedirect, true);
+        expect(profile.responseData.redirects, isEmpty);
+      });
+    });
+  });
+}
diff --git a/pkgs/cupertino_http/example/integration_test/main.dart b/pkgs/cupertino_http/example/integration_test/main.dart
index 0d4d5e1..a632b0c 100644
--- a/pkgs/cupertino_http/example/integration_test/main.dart
+++ b/pkgs/cupertino_http/example/integration_test/main.dart
@@ -5,6 +5,7 @@
 import 'package:integration_test/integration_test.dart';
 
 import 'client_conformance_test.dart' as client_conformance_test;
+import 'client_profile_test.dart' as profile_test;
 import 'client_test.dart' as client_test;
 import 'data_test.dart' as data_test;
 import 'error_test.dart' as error_test;
@@ -30,6 +31,7 @@
   IntegrationTestWidgetsFlutterBinding.ensureInitialized();
 
   client_conformance_test.main();
+  profile_test.main();
   client_test.main();
   data_test.main();
   error_test.main();
diff --git a/pkgs/cupertino_http/example/pubspec.yaml b/pkgs/cupertino_http/example/pubspec.yaml
index f394051..ba79752 100644
--- a/pkgs/cupertino_http/example/pubspec.yaml
+++ b/pkgs/cupertino_http/example/pubspec.yaml
@@ -6,8 +6,8 @@
 version: 1.0.0+1
 
 environment:
-  sdk: ^3.2.0
-  flutter: '>=3.16.0'
+  sdk: ^3.4.0
+  flutter: '>=3.22.0'
 
 dependencies:
   cupertino_http:
@@ -28,6 +28,7 @@
     sdk: flutter
   http_client_conformance_tests:
     path: ../../http_client_conformance_tests/
+  http_profile: ^0.1.0
   integration_test:
     sdk: flutter
   test: ^1.21.1
diff --git a/pkgs/cupertino_http/lib/cupertino_http.dart b/pkgs/cupertino_http/lib/cupertino_http.dart
index 68691b8..55d62d3 100644
--- a/pkgs/cupertino_http/lib/cupertino_http.dart
+++ b/pkgs/cupertino_http/lib/cupertino_http.dart
@@ -87,5 +87,5 @@
 import 'src/cupertino_client.dart';
 
 export 'src/cupertino_api.dart';
-export 'src/cupertino_client.dart';
+export 'src/cupertino_client.dart' show CupertinoClient;
 export 'src/cupertino_web_socket.dart';
diff --git a/pkgs/cupertino_http/lib/src/cupertino_client.dart b/pkgs/cupertino_http/lib/src/cupertino_client.dart
index 4cd0646..e2ec0a0 100644
--- a/pkgs/cupertino_http/lib/src/cupertino_client.dart
+++ b/pkgs/cupertino_http/lib/src/cupertino_client.dart
@@ -12,6 +12,7 @@
 
 import 'package:async/async.dart';
 import 'package:http/http.dart';
+import 'package:http_profile/http_profile.dart';
 
 import 'cupertino_api.dart';
 
@@ -36,10 +37,11 @@
   final responseCompleter = Completer<URLResponse>();
   final BaseRequest request;
   final responseController = StreamController<Uint8List>();
+  final HttpClientRequestProfile? profile;
   int numRedirects = 0;
   Uri? lastUrl; // The last URL redirected to.
 
-  _TaskTracker(this.request);
+  _TaskTracker(this.request, this.profile);
 
   void close() {
     responseController.close();
@@ -74,7 +76,7 @@
 
   URLSession? _urlSession;
 
-  CupertinoClient._(URLSession urlSession) : _urlSession = urlSession;
+  CupertinoClient._(this._urlSession);
 
   String? _findReasonPhrase(int statusCode) {
     switch (statusCode) {
@@ -168,18 +170,31 @@
   static void _onComplete(
       URLSession session, URLSessionTask task, Error? error) {
     final taskTracker = _tracker(task);
-
     if (error != null) {
       final exception = ClientException(
           error.localizedDescription ?? 'Unknown', taskTracker.request.url);
+      if (taskTracker.profile != null &&
+          taskTracker.profile!.requestData.endTime == null) {
+        // Error occurred during the request.
+        taskTracker.profile!.requestData.closeWithError(exception.toString());
+      } else {
+        // Error occurred during the response.
+        taskTracker.profile?.responseData.closeWithError(exception.toString());
+      }
       if (taskTracker.responseCompleter.isCompleted) {
         taskTracker.responseController.addError(exception);
       } else {
         taskTracker.responseCompleter.completeError(exception);
       }
-    } else if (!taskTracker.responseCompleter.isCompleted) {
-      taskTracker.responseCompleter.completeError(
-          StateError('task completed without an error or response'));
+    } else {
+      assert(taskTracker.profile == null ||
+          taskTracker.profile!.requestData.endTime != null);
+
+      taskTracker.profile?.responseData.close();
+      if (!taskTracker.responseCompleter.isCompleted) {
+        taskTracker.responseCompleter.completeError(
+            StateError('task completed without an error or response'));
+      }
     }
     taskTracker.close();
     _tasks.remove(task);
@@ -188,6 +203,7 @@
   static void _onData(URLSession session, URLSessionTask task, Data data) {
     final taskTracker = _tracker(task);
     taskTracker.responseController.add(data.bytes);
+    taskTracker.profile?.responseData.bodySink.add(data.bytes);
   }
 
   static URLRequest? _onRedirect(URLSession session, URLSessionTask task,
@@ -196,6 +212,10 @@
     ++taskTracker.numRedirects;
     if (taskTracker.request.followRedirects &&
         taskTracker.numRedirects <= taskTracker.request.maxRedirects) {
+      taskTracker.profile?.responseData.addRedirect(HttpProfileRedirectData(
+          statusCode: response.statusCode,
+          method: request.httpMethod,
+          location: request.url!.toString()));
       taskTracker.lastUrl = request.url;
       return request;
     }
@@ -206,6 +226,8 @@
       URLSession session, URLSessionTask task, URLResponse response) {
     final taskTracker = _tracker(task);
     taskTracker.responseCompleter.complete(response);
+    unawaited(taskTracker.profile?.requestData.close());
+
     return URLSessionResponseDisposition.urlSessionResponseAllow;
   }
 
@@ -246,6 +268,12 @@
     return (await queue.hasNext, queue.rest);
   }
 
+  HttpClientRequestProfile? _createProfile(BaseRequest request) =>
+      HttpClientRequestProfile.profile(
+          requestStartTime: DateTime.now(),
+          requestMethod: request.method,
+          requestUri: request.url.toString());
+
   @override
   Future<StreamedResponse> send(BaseRequest request) async {
     // The expected success case flow (without redirects) is:
@@ -268,6 +296,25 @@
 
     final stream = request.finalize();
 
+    final profile = _createProfile(request);
+    profile?.connectionInfo = {
+      'package': 'package:cupertino_http',
+      'client': 'CupertinoClient',
+      'configuration': _urlSession!.configuration.toString(),
+    };
+    profile?.requestData
+      ?..contentLength = request.contentLength
+      ..followRedirects = request.followRedirects
+      ..headersCommaValues = request.headers
+      ..maxRedirects = request.maxRedirects;
+
+    if (profile != null && request.contentLength != null) {
+      profile.requestData.headersListValues = {
+        'Content-Length': ['${request.contentLength}'],
+        ...profile.requestData.headers!
+      };
+    }
+
     final urlRequest = MutableURLRequest.fromUrl(request.url)
       ..httpMethod = request.method;
 
@@ -275,24 +322,32 @@
       // Optimize the (typical) `Request` case since assigning to
       // `httpBodyStream` requires a lot of expensive setup and data passing.
       urlRequest.httpBody = Data.fromList(request.bodyBytes);
+      profile?.requestData.bodySink.add(request.bodyBytes);
     } else if (await _hasData(stream) case (true, final s)) {
       // If the request is supposed to be bodyless (e.g. GET requests)
       // then setting `httpBodyStream` will cause the request to fail -
       // even if the stream is empty.
-      urlRequest.httpBodyStream = s;
+      if (profile == null) {
+        urlRequest.httpBodyStream = s;
+      } else {
+        final splitter = StreamSplitter(s);
+        urlRequest.httpBodyStream = splitter.split();
+        unawaited(profile.requestData.bodySink.addStream(splitter.split()));
+      }
     }
 
     // This will preserve Apple default headers - is that what we want?
     request.headers.forEach(urlRequest.setValueForHttpHeaderField);
-
     final task = urlSession.dataTaskWithRequest(urlRequest);
-    final taskTracker = _TaskTracker(request);
+    final taskTracker = _TaskTracker(request, profile);
     _tasks[task] = taskTracker;
     task.resume();
 
     final maxRedirects = request.followRedirects ? request.maxRedirects : 0;
 
-    final result = await taskTracker.responseCompleter.future;
+    late URLResponse result;
+    result = await taskTracker.responseCompleter.future;
+
     final response = result as HTTPURLResponse;
 
     if (request.followRedirects && taskTracker.numRedirects > maxRedirects) {
@@ -310,17 +365,48 @@
       );
     }
 
+    final contentLength = response.expectedContentLength == -1
+        ? null
+        : response.expectedContentLength;
+    final isRedirect = !request.followRedirects && taskTracker.numRedirects > 0;
+    profile?.responseData
+      ?..contentLength = contentLength
+      ..headersCommaValues = responseHeaders
+      ..isRedirect = isRedirect
+      ..reasonPhrase = _findReasonPhrase(response.statusCode)
+      ..startTime = DateTime.now()
+      ..statusCode = response.statusCode;
+
     return _StreamedResponseWithUrl(
       taskTracker.responseController.stream,
       response.statusCode,
       url: taskTracker.lastUrl ?? request.url,
-      contentLength: response.expectedContentLength == -1
-          ? null
-          : response.expectedContentLength,
+      contentLength: contentLength,
       reasonPhrase: _findReasonPhrase(response.statusCode),
       request: request,
-      isRedirect: !request.followRedirects && taskTracker.numRedirects > 0,
+      isRedirect: isRedirect,
       headers: responseHeaders,
     );
   }
 }
+
+/// A test-only class that makes the [HttpClientRequestProfile] data available.
+class CupertinoClientWithProfile extends CupertinoClient {
+  HttpClientRequestProfile? profile;
+
+  @override
+  HttpClientRequestProfile? _createProfile(BaseRequest request) =>
+      profile = super._createProfile(request);
+
+  CupertinoClientWithProfile._(super._urlSession) : super._();
+
+  factory CupertinoClientWithProfile.defaultSessionConfiguration() {
+    final config = URLSessionConfiguration.defaultSessionConfiguration();
+    final session = URLSession.sessionWithConfiguration(config,
+        onComplete: CupertinoClient._onComplete,
+        onData: CupertinoClient._onData,
+        onRedirect: CupertinoClient._onRedirect,
+        onResponse: CupertinoClient._onResponse);
+    return CupertinoClientWithProfile._(session);
+  }
+}
diff --git a/pkgs/cupertino_http/pubspec.yaml b/pkgs/cupertino_http/pubspec.yaml
index 7e9427b..ac6bf5b 100644
--- a/pkgs/cupertino_http/pubspec.yaml
+++ b/pkgs/cupertino_http/pubspec.yaml
@@ -1,13 +1,14 @@
 name: cupertino_http
-version: 1.4.1-wip
+version: 1.5.0-wip
 description: >-
   A macOS/iOS Flutter plugin that provides access to the Foundation URL
   Loading System.
 repository: https://github.com/dart-lang/http/tree/master/pkgs/cupertino_http
+publish_to: none
 
 environment:
-  sdk: ^3.3.0
-  flutter: '>=3.19.0'  # If changed, update test matrix.
+  sdk: ^3.4.0
+  flutter: '>=3.22.0' # If changed, update test matrix.
 
 dependencies:
   async: ^2.5.0
@@ -15,6 +16,7 @@
   flutter:
     sdk: flutter
   http: ^1.2.0
+  http_profile: ^0.1.0
   web_socket: ^0.1.0
 
 dev_dependencies: