Răsfoiți Sursa

Add network metrics collection for download tasks

- Add NetworkMetrics struct to capture URLSessionTaskMetrics data
- Collect metrics in SessionDelegate through URLSession delegate method
- Pass metrics through ImageLoadingResult to RetrieveImageResult
- Metrics only available for network downloads, nil for cached results
- Support timing breakdown (DNS, TCP, TLS), data transfer, and HTTP details

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
FunnyValentine 6 luni în urmă
părinte
comite
4907841340

+ 4 - 0
Kingfisher.xcodeproj/project.pbxproj

@@ -125,6 +125,7 @@
 		D8FCF6A821C5A0E500F9ABC0 /* RedirectHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8FCF6A721C5A0E500F9ABC0 /* RedirectHandler.swift */; };
 		D9638BA61C7DC71F0046523D /* ImagePrefetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9638BA41C7DC71F0046523D /* ImagePrefetcherTests.swift */; };
 		E9E3ED8B2B1F66B200734CFF /* HasImageComponent+Kingfisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E3ED8A2B1F66B200734CFF /* HasImageComponent+Kingfisher.swift */; };
+		F39B68C82E33AC2A00404B02 /* NetworkMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39B68C72E33AC2A00404B02 /* NetworkMetrics.swift */; };
 		F72CE9CE1FCF17ED00CC522A /* ImageModifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F72CE9CD1FCF17ED00CC522A /* ImageModifierTests.swift */; };
 /* End PBXBuildFile section */
 
@@ -308,6 +309,7 @@
 		D8FCF6A721C5A0E500F9ABC0 /* RedirectHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RedirectHandler.swift; sourceTree = "<group>"; };
 		D9638BA41C7DC71F0046523D /* ImagePrefetcherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImagePrefetcherTests.swift; sourceTree = "<group>"; };
 		E9E3ED8A2B1F66B200734CFF /* HasImageComponent+Kingfisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HasImageComponent+Kingfisher.swift"; sourceTree = "<group>"; };
+		F39B68C72E33AC2A00404B02 /* NetworkMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMetrics.swift; sourceTree = "<group>"; };
 		F72CE9CD1FCF17ED00CC522A /* ImageModifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageModifierTests.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
@@ -375,6 +377,7 @@
 				4BD821612189FC0C0084CC21 /* SessionDelegate.swift */,
 				4BD821662189FD330084CC21 /* SessionDataTask.swift */,
 				4B8E2916216F3F7F0095FAD1 /* ImageDownloaderDelegate.swift */,
+				F39B68C72E33AC2A00404B02 /* NetworkMetrics.swift */,
 				4B8E291B216F40AA0095FAD1 /* AuthenticationChallengeResponsable.swift */,
 				4B10480C216F157000300C61 /* ImageDataProcessor.swift */,
 				D12AB6A0215D2BB50013BA68 /* ImageModifier.swift */,
@@ -854,6 +857,7 @@
 				D18B3222251852E100662F63 /* KF.swift in Sources */,
 				D12AB704215D2BB50013BA68 /* Kingfisher.swift in Sources */,
 				D1AEB09725890DEA008556DF /* KFImage.swift in Sources */,
+				F39B68C82E33AC2A00404B02 /* NetworkMetrics.swift in Sources */,
 				D1BA781D2174D07800C69D7B /* CallbackQueue.swift in Sources */,
 				D12AB71C215D2BB50013BA68 /* FormatIndicatedCacheSerializer.swift in Sources */,
 				D1A37BF2215D3850009B39B7 /* SizeExtensions.swift in Sources */,

+ 33 - 1
Sources/General/KingfisherManager.swift

@@ -80,6 +80,37 @@ public struct RetrieveImageResult: Sendable {
     /// - Note: Retrieving this data can be a time-consuming operation, so it is advisable to store it if you need to 
     /// use it multiple times and avoid frequent calls to this method.
     public let data: @Sendable () -> Data?
+    
+    /// The network metrics collected during the download process.
+    ///
+    /// This property contains network performance metrics when the image was downloaded from the network
+    /// (`cacheType == .none`). For cached images (`cacheType == .memory` or `.disk`), this will be `nil`.
+    public let metrics: NetworkMetrics?
+    
+    /// Creates a RetrieveImageResult.
+    ///
+    /// - Parameters:
+    ///   - image: The retrieved image.
+    ///   - cacheType: The cache source type.
+    ///   - source: The source of the image.
+    ///   - originalSource: The original source that initiated the retrieval.
+    ///   - data: A closure that provides the image data.
+    ///   - metrics: The network metrics collected during download. Defaults to nil for cached images.
+    public init(
+        image: KFCrossPlatformImage,
+        cacheType: CacheType,
+        source: Source,
+        originalSource: Source,
+        data: @escaping @Sendable () -> Data?,
+        metrics: NetworkMetrics? = nil
+    ) {
+        self.image = image
+        self.cacheType = cacheType
+        self.source = source
+        self.originalSource = originalSource
+        self.data = data
+        self.metrics = metrics
+    }
 }
 
 /// A structure that stores related information about a ``KingfisherError``. It provides contextual information
@@ -475,7 +506,8 @@ public class KingfisherManager: @unchecked Sendable {
                 cacheType: .none,
                 source: source,
                 originalSource: context.originalSource,
-                data: {  value.originalData }
+                data: { value.originalData },
+                metrics: value.metrics
             )
             // Add image to cache.
             let targetCache = options.targetCache ?? self.cache

+ 7 - 2
Sources/Networking/ImageDownloader.swift

@@ -43,6 +43,9 @@ public struct ImageLoadingResult: Sendable {
 
     /// The raw data received from the downloader.
     public let originalData: Data
+    
+    /// The network metrics collected during the download process.
+    public let metrics: NetworkMetrics?
 
     /// Creates an `ImageDownloadResult` object.
     ///
@@ -50,10 +53,12 @@ public struct ImageLoadingResult: Sendable {
     ///   - image: The image of the download result.
     ///   - url: The URL from which the image was downloaded.
     ///   - originalData: The binary data of the image.
-    public init(image: KFCrossPlatformImage, url: URL? = nil, originalData: Data) {
+    ///   - metrics: The network metrics collected during the download.
+    public init(image: KFCrossPlatformImage, url: URL? = nil, originalData: Data, metrics: NetworkMetrics? = nil) {
         self.image = image
         self.url = url
         self.originalData = originalData
+        self.metrics = metrics
     }
 }
 
@@ -444,7 +449,7 @@ open class ImageDownloader: @unchecked Sendable {
 
                     self.reportDidProcessImage(result: result, url: context.url, response: response)
 
-                    let imageResult = result.map { ImageLoadingResult(image: $0, url: context.url, originalData: data) }
+                    let imageResult = result.map { ImageLoadingResult(image: $0, url: context.url, originalData: data, metrics: sessionTask.metrics) }
                     let queue = callback.options.callbackQueue
                     queue.execute { callback.onCompleted?.call(imageResult) }
                 }

+ 151 - 0
Sources/Networking/NetworkMetrics.swift

@@ -0,0 +1,151 @@
+//
+//  NetworkMetrics.swift
+//  Kingfisher
+//
+//  Created by FunnyValentine on 2025/07/25.
+//
+//  Copyright (c) 2025 Wei Wang <onevcat@gmail.com>
+//
+//  Permission is hereby granted, free of charge, to any person obtaining a copy
+//  of this software and associated documentation files (the "Software"), to deal
+//  in the Software without restriction, including without limitation the rights
+//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+//  copies of the Software, and to permit persons to whom the Software is
+//  furnished to do so, subject to the following conditions:
+//
+//  The above copyright notice and this permission notice shall be included in
+//  all copies or substantial portions of the Software.
+//
+//  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+//  THE SOFTWARE.
+
+import Foundation
+
+/// Represents the network performance metrics collected during an image download task.
+public struct NetworkMetrics: Sendable {
+
+    /// The original URLSessionTaskMetrics for advanced use cases.
+    public let rawMetrics: URLSessionTaskMetrics
+
+    /// The duration of the actual image retrieval (excluding redirects).
+    public let retrieveImageDuration: TimeInterval
+
+    /// The total time from request start to completion (including redirects).
+    public let totalRequestDuration: TimeInterval
+
+    /// The time it took to perform DNS lookup.
+    public let domainLookupDuration: TimeInterval?
+    
+    /// The time it took to establish the TCP connection.
+    public let connectDuration: TimeInterval?
+    
+    /// The time it took to perform TLS handshake.
+    public let secureConnectionDuration: TimeInterval?
+    
+    /// The number of bytes sent in the request body.
+    public let requestBodyBytesSent: Int64
+    
+    /// The number of bytes received in the response body.
+    public let responseBodyBytesReceived: Int64
+    
+    /// The HTTP response status code, if available.
+    public let httpStatusCode: Int?
+    
+    /// The number of redirects that occurred during the request.
+    public let redirectCount: Int
+    
+    /// Creates a NetworkMetrics instance from URLSessionTaskMetrics
+    init?(from urlMetrics: URLSessionTaskMetrics) {
+        // Find the first successful transaction (200-299 status) ignoring redirects
+        // We need to ensure we get metrics from an actual successful download, not from 
+        // intermediate redirects (301/302) which don't represent real download performance
+        var successfulTransaction: URLSessionTaskTransactionMetrics?
+        for transaction in urlMetrics.transactionMetrics {
+            if let httpResponse = transaction.response as? HTTPURLResponse,
+               (200...299).contains(httpResponse.statusCode) {
+                successfulTransaction = transaction
+                break
+            }
+        }
+
+        // make sure we have a valid successful transaction
+        guard let successfulTransaction else {
+            return nil
+        }
+
+        // Store raw metrics for advanced use cases
+        self.rawMetrics = urlMetrics
+        
+        // Calculate the image retrieval duration from the successful transaction
+        self.retrieveImageDuration = Self.calculateRetrieveImageDuration(from: successfulTransaction)
+        
+        // Calculate the total request duration from the task interval
+        self.totalRequestDuration = urlMetrics.taskInterval.duration
+        
+        // Calculate timing metrics from the successful transaction
+        self.domainLookupDuration = Self.calculateDomainLookupDuration(from: successfulTransaction)
+        self.connectDuration = Self.calculateConnectDuration(from: successfulTransaction)
+        self.secureConnectionDuration = Self.calculateSecureConnectionDuration(from: successfulTransaction)
+        
+        // Extract data transfer information from the successful transaction
+        self.requestBodyBytesSent = successfulTransaction.countOfRequestBodyBytesSent
+        self.responseBodyBytesReceived = successfulTransaction.countOfResponseBodyBytesReceived
+        
+        // Extract HTTP status code from the successful transaction
+        self.httpStatusCode = Self.extractHTTPStatusCode(from: successfulTransaction)
+        
+        // Extract redirect count
+        self.redirectCount = urlMetrics.redirectCount
+    }
+    
+    // MARK: - Private Calculation Methods
+    
+    /// Calculates DNS lookup duration
+    /// Formula: domainLookupEndDate - domainLookupStartDate
+    /// Represents: Time spent resolving domain name to IP address
+    private static func calculateDomainLookupDuration(from transaction: URLSessionTaskTransactionMetrics) -> TimeInterval? {
+        guard let start = transaction.domainLookupStartDate,
+              let end = transaction.domainLookupEndDate else { return nil }
+        return end.timeIntervalSince(start)
+    }
+    
+    /// Calculates TCP connection establishment duration
+    /// Formula: connectEndDate - connectStartDate
+    /// Represents: Time spent establishing TCP connection to server
+    private static func calculateConnectDuration(from transaction: URLSessionTaskTransactionMetrics) -> TimeInterval? {
+        guard let start = transaction.connectStartDate,
+              let end = transaction.connectEndDate else { return nil }
+        return end.timeIntervalSince(start)
+    }
+    
+    /// Calculates TLS/SSL handshake duration
+    /// Formula: secureConnectionEndDate - secureConnectionStartDate  
+    /// Represents: Time spent performing TLS/SSL handshake for HTTPS connections
+    private static func calculateSecureConnectionDuration(from transaction: URLSessionTaskTransactionMetrics) -> TimeInterval? {
+        guard let start = transaction.secureConnectionStartDate,
+              let end = transaction.secureConnectionEndDate else { return nil }
+        return end.timeIntervalSince(start)
+    }
+    
+    /// Calculates the image retrieval duration for a single transaction 
+    /// Formula: responseEndDate - requestStartDate
+    /// Represents: Time from sending HTTP request to receiving complete image response
+    private static func calculateRetrieveImageDuration(from transaction: URLSessionTaskTransactionMetrics) -> TimeInterval {
+        guard let start = transaction.requestStartDate,
+              let end = transaction.responseEndDate else { 
+            return 0 
+        }
+        return end.timeIntervalSince(start)
+    }
+    
+    /// Extracts HTTP status code from response
+    /// Returns: HTTP status code (200, 404, etc.) or nil for non-HTTP responses
+    private static func extractHTTPStatusCode(from transaction: URLSessionTaskTransactionMetrics) -> Int? {
+        return (transaction.response as? HTTPURLResponse)?.statusCode
+    }
+}

+ 14 - 0
Sources/Networking/SessionDataTask.swift

@@ -68,6 +68,14 @@ public class SessionDataTask: @unchecked Sendable {
 
     private var currentToken = 0
     private let lock = NSLock()
+    
+    private var _metrics: NetworkMetrics?
+    /// The network metrics collected during the download task.
+    public var metrics: NetworkMetrics? {
+        lock.lock()
+        defer { lock.unlock() }
+        return _metrics
+    }
 
     let onTaskDone = Delegate<(Result<(Data, URLResponse?), KingfisherError>, [TaskCallback]), Void>()
     let onCallbackCancelled = Delegate<(CancelToken, TaskCallback), Void>()
@@ -139,4 +147,10 @@ public class SessionDataTask: @unchecked Sendable {
         defer { lock.unlock() }
         _mutableData.append(data)
     }
+    
+    func didCollectMetrics(_ metrics: NetworkMetrics) {
+        lock.lock()
+        defer { lock.unlock() }
+        _metrics = metrics
+    }
 }

+ 9 - 0
Sources/Networking/SessionDelegate.swift

@@ -258,6 +258,15 @@ extension SessionDelegate: URLSessionDataDelegate {
             newRequest: request
         )
     }
+    
+    open func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
+        guard let sessionTask = self.task(for: task) else { return }
+        
+        // Collect network metrics for the completed task
+        if let networkMetrics = NetworkMetrics(from: metrics) {
+            sessionTask.didCollectMetrics(networkMetrics)
+        }
+    }
 
     private func onCompleted(task: URLSessionTask, result: Result<(Data, URLResponse?), KingfisherError>) {
         guard let sessionTask = self.task(for: task) else {

+ 1 - 1
Tests/KingfisherTests/ImageDownloaderTests.swift

@@ -591,7 +591,7 @@ class ImageDownloaderTests: XCTestCase {
             init(_ expectation:XCTestExpectation) {
                 exp = expectation
             }
-            func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
+            override func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
                 exp.fulfill()
             }
         }