diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 876daf9..8f496fc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,4 +9,16 @@ ### ๐Ÿ” ์ƒ์„ธ -## ๐Ÿ“ธ ์˜์ƒ / ์ด๋ฏธ์ง€ (Optional) +## ๐Ÿ”„ ๋ณ€๊ฒฝ ํ๋ฆ„ + + + +```mermaid +flowchart LR + start[์‹œ์ž‘] --> shared[๊ณตํ†ต ์ฒ˜๋ฆฌ] --> current[ํ˜„์žฌ ๊ฒฐ๊ณผ] + start -. ์ œ๊ฑฐ๋œ ์—ฐ๊ฒฐ .-> current +``` diff --git a/README.ko.md b/README.ko.md index 65c59af..9406bcb 100644 --- a/README.ko.md +++ b/README.ko.md @@ -17,6 +17,7 @@ Nexa๋Š” `URLSession` ๊ธฐ๋ฐ˜์˜ SwiftUI ์Šคํƒ€์ผ ์„ ์–ธํ˜• ๋„คํŠธ์›Œํ‚น ๋ผ์ด - [Endpoint API](#endpoint-api) - [์š”์ฒญ ์ „ํ™˜](#์š”์ฒญ-์ „ํ™˜) - [์„ค์ •](#์„ค์ •) +- [์ „์†ก metrics](#์ „์†ก-metrics) - [์ธ์ฆ ํ† ํฐ ๊ฐฑ์‹ ](#์ธ์ฆ-ํ† ํฐ-๊ฐฑ์‹ ) - [์‘๋‹ต cache](#์‘๋‹ต-cache) - [์žฌ์‹œ๋„ ์ •์ฑ…](#์žฌ์‹œ๋„-์ •์ฑ…) @@ -37,6 +38,7 @@ Nexa๋Š” `URLSession` ๊ธฐ๋ฐ˜์˜ SwiftUI ์Šคํƒ€์ผ ์„ ์–ธํ˜• ๋„คํŠธ์›Œํ‚น ๋ผ์ด - [x] ์‘๋‹ต ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋ฐ ์„œ๋ฒ„ ์—๋Ÿฌ ๋””์ฝ”๋”ฉ - [x] ์„ฑ๊ณตํ•œ `GET` ์‘๋‹ต๊ณผ ์ง„ํ–‰ ์ค‘์ธ ๋™์ผ ์š”์ฒญ์„ ์œ„ํ•œ validator ์žฌ๊ฒ€์ฆ ์„ ํƒํ˜• memory response cache - [x] ๋กœ๊ฑฐ ํ›… ๋ฐ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ transport ์ถ”์ƒํ™” +- [x] `Sendable` URLSession task metrics observer ## ์š”๊ตฌ ์‚ฌํ•ญ @@ -89,6 +91,7 @@ dependencies: [ | `NXAuthTokenProvider` | `.authorized()` ์š”์ฒญ์— ํ† ํฐ ์กฐํšŒ ๋ฐ ๊ฐฑ์‹  ๊ธฐ๋Šฅ์ด ํ•„์š”ํ•  ๋•Œ | `authTokenProvider: yourAuthTokenProvider` | | `NXServerErrorDecoder` | ์‹คํŒจ ์‘๋‹ต์„ ๋„๋ฉ”์ธ ์—๋Ÿฌ๋กœ ๋””์ฝ”๋”ฉํ•  ๋•Œ | `serverErrorDecoder: yourServerErrorDecoder` | | `NXLogger` | ๊ตฌ์กฐํ™”๋œ ์š”์ฒญ ์ƒ๋ช…์ฃผ๊ธฐ ๋กœ๊น…์ด ํ•„์š”ํ•  ๋•Œ | `logger: yourLogger` | +| `NXNetworkMetricsObserver` | logger event ๋ณ€๊ฒฝ ์—†์ด URLSession task ์‹œ๊ฐ„ snapshot์ด ํ•„์š”ํ•  ๋•Œ | `NXURLSessionTransport(metricsObserver: yourObserver)` | | `NXRawResponse` | `Data`์™€ `HTTPURLResponse`๋ฅผ ์ง์ ‘ ๋‹ค๋ค„์•ผ ํ•  ๋•Œ | `let response = try await client.get("/users").send()` | | `NXError` | ํ˜ธ์ถœ ์ฝ”๋“œ์—์„œ Nexa ๊ณ ์œ  ์˜ค๋ฅ˜๋ฅผ ์ฒ˜๋ฆฌํ•  ๋•Œ | `catch let error as NXError` | | `NXHTTPMethod` | `NXEndpoint`์˜ ๋ฉ”์„œ๋“œ๋ฅผ ์ •์˜ํ•  ๋•Œ | `var method: NXHTTPMethod { .post }` | @@ -169,6 +172,7 @@ struct UserEndpoint: NXEndpoint { - `NXAuthTokenProvider`: bearer token ์ฃผ์ž… ๋ฐ ๊ฐฑ์‹  - `NXServerErrorDecoder`: ์„œ๋ฒ„ payload๋ฅผ ๋„๋ฉ”์ธ ์—๋Ÿฌ๋กœ ๋งคํ•‘ - `NXLogger`: ์š”์ฒญ ์ƒ๋ช…์ฃผ๊ธฐ ๋กœ๊น… ๋ฐ ๊ด€์ธก์„ฑ +- `NXNetworkMetricsObserver`: URLSession task ์‹œ๊ฐ„ snapshot ## ์š”์ฒญ ํ๋ฆ„ @@ -348,6 +352,27 @@ let client = NXAPIClient(configuration: configuration) - ํ† ํฐ ๊ฐฑ์‹  ๋ฐ ์žฌ์‹œ๋„ ์ฒ˜๋ฆฌ - ์Šคํ„ฐ๋น™ ๋ฐ ๊ฒฉ๋ฆฌ ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ์ปค์Šคํ…€ transport +## ์ „์†ก metrics + +`NXURLSessionTransport`๋Š” `URLSession` task๋งˆ๋‹ค `NXNetworkMetrics` snapshot ํ•˜๋‚˜๋ฅผ `NXNetworkMetricsObserver`์— ์ „๋‹ฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. snapshot์—๋Š” task duration, redirect ์ˆ˜, transaction ์ˆ˜, ์ˆ˜์ง‘ ์ˆœ์„œ๋ฅผ ๋ณด์กดํ•œ `NXNetworkTransactionMetrics` ๊ฐ’์ด ํฌํ•จ๋ฉ๋‹ˆ๋‹ค. + +๊ฐ transaction์€ Foundation timestamp์˜ ์‹œ์ž‘๊ณผ ๋์ด ๋ชจ๋‘ ์žˆ์„ ๋•Œ๋งŒ DNS, connection, TLS, request ์‹œ์ž‘๋ถ€ํ„ฐ first byte๊นŒ์ง€์˜ duration์„ ๋‚˜ํƒ€๋ƒ…๋‹ˆ๋‹ค. timestamp๊ฐ€ ์—†์œผ๋ฉด ํ•ด๋‹น ๊ฐ’์€ `nil`์ž…๋‹ˆ๋‹ค. ์žฌ์‚ฌ์šฉ connection์€ `isConnectionReused`๋กœ ๋‚˜ํƒ€๋‚ด๋ฉฐ DNS ๋˜๋Š” connection duration์ด `nil`์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +```swift +import Foundation +import Nexa + +actor MetricsObserver: NXNetworkMetricsObserver { + func record(_ metrics: NXNetworkMetrics) async { + print(metrics.taskDuration ?? 0) + } +} + +let transport = NXURLSessionTransport(metricsObserver: MetricsObserver()) +``` + +snapshot์€ `NXURLSessionTransport`๋งŒ ์ˆ˜์ง‘ํ•ฉ๋‹ˆ๋‹ค. ์ปค์Šคํ…€ `NXHTTPTransport`์™€ cache hit๋Š” metrics๋ฅผ ํ•ฉ์„ฑํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. observer ์ „๋‹ฌ์€ ์š”์ฒญ ์™„๋ฃŒ๋ฅผ ์ง€์—ฐํ•˜์ง€ ์•Š์œผ๋ฉฐ `NXLogger` event์™€์˜ ์ˆœ์„œ๋Š” ๋ณด์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + ## ์ธ์ฆ ํ† ํฐ ๊ฐฑ์‹  ํ•˜๋‚˜์˜ `NXAPIClient`์—์„œ ์ƒ์„ฑํ•œ `.authorized()` ์š”์ฒญ์ด ๋™์‹œ์— `401` ์‘๋‹ต์„ ๋ฐ›์œผ๋ฉด ์ง„ํ–‰ ์ค‘์ธ ํ† ํฐ ๊ฐฑ์‹  ๊ฒฐ๊ณผ ํ•˜๋‚˜๋ฅผ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค. ํ•ด๋‹น client์˜ ๊ฐ’ ๋ณต์‚ฌ๋ณธ๊ณผ ์—ฌ๊ธฐ์„œ ํŒŒ์ƒํ•œ builder๋„ ๊ฐ™์€ ๊ฐฑ์‹ ์„ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค. ๋ณ„๋„๋กœ ์ƒ์„ฑํ•œ `NXAPIClient`๋Š” ๋…๋ฆฝ๋œ ๊ฐฑ์‹  ์ˆ˜๋ช…์„ ๊ฐ€์ง‘๋‹ˆ๋‹ค. diff --git a/README.md b/README.md index 785151a..407ce05 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Nexa is a SwiftUI-inspired declarative networking library built on `URLSession`. - [Endpoint API](#endpoint-api) - [Request Migration](#request-migration) - [Configuration](#configuration) +- [Transport Metrics](#transport-metrics) - [Authentication Refresh](#authentication-refresh) - [Response Cache](#response-cache) - [Retry Policy](#retry-policy) @@ -37,6 +38,7 @@ Nexa is a SwiftUI-inspired declarative networking library built on `URLSession`. - [x] Response validation and server error decoding - [x] Memory response cache with optional validator revalidation for successful `GET` responses and in-flight identical requests - [x] Logger hooks and transport abstraction for testing +- [x] `Sendable` URLSession task metrics observer ## Requirements @@ -89,6 +91,7 @@ The rest of the public surface is made of extension points for auth, logging, te | `NXAuthTokenProvider` | When `.authorized()` requests need token lookup and refresh support | `authTokenProvider: yourAuthTokenProvider` | | `NXServerErrorDecoder` | When failed responses should decode into your own domain error | `serverErrorDecoder: yourServerErrorDecoder` | | `NXLogger` | When you want structured request lifecycle logging | `logger: yourLogger` | +| `NXNetworkMetricsObserver` | When you want URLSession task timing snapshots without changing logger events | `NXURLSessionTransport(metricsObserver: yourObserver)` | | `NXRawResponse` | When you need both `Data` and `HTTPURLResponse` directly | `let response = try await client.get("/users").send()` | | `NXError` | When handling Nexa-specific failures in calling code | `catch let error as NXError` | | `NXHTTPMethod` | When defining an `NXEndpoint` method | `var method: NXHTTPMethod { .post }` | @@ -169,6 +172,7 @@ Use the lower-level protocols only when the default behavior is not enough: - `NXAuthTokenProvider`: bearer token injection and refresh - `NXServerErrorDecoder`: server payload to domain error mapping - `NXLogger`: request lifecycle logging and observability +- `NXNetworkMetricsObserver`: URLSession task timing snapshots ## Request Flow @@ -348,6 +352,27 @@ Nexa currently supports: - Token refresh and retry handling - Custom transports for stubbing and isolated tests +## Transport Metrics + +`NXURLSessionTransport` can forward one `NXNetworkMetrics` snapshot to an `NXNetworkMetricsObserver` for each `URLSession` task. The snapshot contains task duration, redirect count, transaction count, and ordered `NXNetworkTransactionMetrics` values. + +Each transaction reports DNS, connection, TLS, and request-to-first-byte durations only when both Foundation timestamps exist. A missing timestamp remains `nil`. A reused connection is represented by `isConnectionReused` and can have `nil` DNS or connection durations. + +```swift +import Foundation +import Nexa + +actor MetricsObserver: NXNetworkMetricsObserver { + func record(_ metrics: NXNetworkMetrics) async { + print(metrics.taskDuration ?? 0) + } +} + +let transport = NXURLSessionTransport(metricsObserver: MetricsObserver()) +``` + +Only `NXURLSessionTransport` collects these snapshots. A custom `NXHTTPTransport` and a cache hit do not synthesize metrics. Observer delivery does not delay request completion, and its order relative to `NXLogger` events is not guaranteed. + ## Authentication Refresh Concurrent `401` responses from `.authorized()` requests created by one `NXAPIClient` share one in-progress token refresh. Copies of that client and builders derived from it share the same refresh. A separately initialized `NXAPIClient` has an independent refresh lifetime. diff --git a/Sources/Core/NXNetworkMetrics.swift b/Sources/Core/NXNetworkMetrics.swift new file mode 100644 index 0000000..797d8bd --- /dev/null +++ b/Sources/Core/NXNetworkMetrics.swift @@ -0,0 +1,126 @@ +// +// NXNetworkMetrics.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation + +/// Value snapshot of one `URLSession` task's network activity. +/// +/// `NXURLSessionTransport` creates this value before delivering it to an `NXNetworkMetricsObserver`. +/// Custom `NXHTTPTransport` implementations do not produce this value. +public struct NXNetworkMetrics: Sendable, Equatable { + /// Total duration of the URL loading task. + public let taskDuration: TimeInterval? + /// Number of redirects followed by the URL loading task. + public let redirectCount: Int + /// Number of transactions collected for the URL loading task. + public let transactionCount: Int + /// Transaction snapshots in the order reported by `URLSession`. + public let transactions: [NXNetworkTransactionMetrics] + + /// Creates a network metrics snapshot. + public init( + taskDuration: TimeInterval?, + redirectCount: Int, + transactionCount: Int, + transactions: [NXNetworkTransactionMetrics] + ) { + self.taskDuration = taskDuration + self.redirectCount = redirectCount + self.transactionCount = transactionCount + self.transactions = transactions + } +} + +/// Value snapshot of one URL loading transaction. +public struct NXNetworkTransactionMetrics: Sendable, Equatable { + /// Duration of DNS lookup when URLSession reports both timestamps. + public let domainLookupDuration: TimeInterval? + /// Duration from connection start through connection end when URLSession reports both timestamps. + public let connectionDuration: TimeInterval? + /// Duration of TLS negotiation when URLSession reports both timestamps. + public let secureConnectionDuration: TimeInterval? + /// Duration from request start until the first response byte when URLSession reports both timestamps. + public let timeToFirstByte: TimeInterval? + /// Whether URLSession reused an existing connection. + public let isConnectionReused: Bool + + /// Creates a transaction metrics snapshot. + public init( + domainLookupDuration: TimeInterval?, + connectionDuration: TimeInterval?, + secureConnectionDuration: TimeInterval?, + timeToFirstByte: TimeInterval?, + isConnectionReused: Bool + ) { + self.domainLookupDuration = domainLookupDuration + self.connectionDuration = connectionDuration + self.secureConnectionDuration = secureConnectionDuration + self.timeToFirstByte = timeToFirstByte + self.isConnectionReused = isConnectionReused + } +} + +protocol NXNetworkMetricsSource { + associatedtype Transaction: NXNetworkTransactionMetricsSource + + var taskInterval: DateInterval { get } + var redirectCount: Int { get } + var transactions: [Transaction] { get } +} + +protocol NXNetworkTransactionMetricsSource { + var domainLookupStartDate: Date? { get } + var domainLookupEndDate: Date? { get } + var connectStartDate: Date? { get } + var connectEndDate: Date? { get } + var secureConnectionStartDate: Date? { get } + var secureConnectionEndDate: Date? { get } + var requestStartDate: Date? { get } + var responseStartDate: Date? { get } + var isConnectionReused: Bool { get } +} + +extension NXNetworkMetrics { + init(source: Source) { + let transactions = source.transactions.map { transaction in + NXNetworkTransactionMetrics( + domainLookupDuration: Self.duration( + from: transaction.domainLookupStartDate, + to: transaction.domainLookupEndDate + ), + connectionDuration: Self.duration( + from: transaction.connectStartDate, + to: transaction.connectEndDate + ), + secureConnectionDuration: Self.duration( + from: transaction.secureConnectionStartDate, + to: transaction.secureConnectionEndDate + ), + timeToFirstByte: Self.duration( + from: transaction.requestStartDate, + to: transaction.responseStartDate + ), + isConnectionReused: transaction.isConnectionReused + ) + } + + self.init( + taskDuration: source.taskInterval.duration, + redirectCount: source.redirectCount, + transactionCount: transactions.count, + transactions: transactions + ) + } + + private static func duration(from startDate: Date?, to endDate: Date?) -> TimeInterval? { + guard let startDate, let endDate else { + return nil + } + + return endDate.timeIntervalSince(startDate) + } +} diff --git a/Sources/Core/NXNetworkMetricsObserver.swift b/Sources/Core/NXNetworkMetricsObserver.swift new file mode 100644 index 0000000..fd544c6 --- /dev/null +++ b/Sources/Core/NXNetworkMetricsObserver.swift @@ -0,0 +1,14 @@ +// +// NXNetworkMetricsObserver.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +/// Receives metrics snapshots collected by `NXURLSessionTransport`. +/// +/// Metrics delivery is independent of request completion and logger event order. +public protocol NXNetworkMetricsObserver: Sendable { + /// Records one network metrics snapshot. + func record(_ metrics: NXNetworkMetrics) async +} diff --git a/Sources/Nexa.docc/Nexa.md b/Sources/Nexa.docc/Nexa.md index f85384d..77070e5 100644 --- a/Sources/Nexa.docc/Nexa.md +++ b/Sources/Nexa.docc/Nexa.md @@ -10,7 +10,7 @@ Use ``NXAPIClient`` to create an ``NXRequestBuilder`` from a relative path. Call Adopt ``NXEndpoint`` when a request shape should be reusable and carry its response type with it. Its existing ``NXTypedRequestBuilder`` configuration contract remains available for endpoint compatibility. -Lower-level customization points such as ``NXHTTPTransport``, ``NXHTTPInterceptor``, ``NXAuthTokenProvider``, ``NXServerErrorDecoder``, and ``NXLogger`` let you replace or extend the default behavior without changing the higher-level request flow. +Lower-level customization points such as ``NXHTTPTransport``, ``NXHTTPInterceptor``, ``NXAuthTokenProvider``, ``NXServerErrorDecoder``, ``NXLogger``, and ``NXNetworkMetricsObserver`` let you replace or extend the default behavior without changing the higher-level request flow. ## Quick Start @@ -77,6 +77,24 @@ Each ``NXAPIClient`` initializer creates one in-memory cache and in-flight reque Nexa does not implement `Vary`, disk cache, full `Cache-Control` interpretation, or stale-if-error. Adding ``NXCache/revalidatingMemory(ttl:)`` adds an enum case, so consumers must update exhaustive `switch` statements over ``NXCache`` when recompiling. +## Transport Metrics + +``NXURLSessionTransport`` can forward a ``NXNetworkMetrics`` value to an ``NXNetworkMetricsObserver``. The value contains task duration, redirect count, transaction count, and one ``NXNetworkTransactionMetrics`` value for each transaction reported by `URLSession`. + +Each transaction records DNS, connection, TLS, and request-to-first-byte durations when both Foundation timestamps exist. A missing timestamp remains `nil`; a reused connection is represented by `isConnectionReused` and can therefore have `nil` DNS or connection durations. + +```swift +actor MetricsObserver: NXNetworkMetricsObserver { + func record(_ metrics: NXNetworkMetrics) async { + print(metrics.taskDuration ?? 0) + } +} + +let transport = NXURLSessionTransport(metricsObserver: MetricsObserver()) +``` + +Only ``NXURLSessionTransport`` collects this value. A custom ``NXHTTPTransport`` and a cache hit do not synthesize metrics. Observer delivery is independent of request completion, and its order relative to ``NXLogger`` events is not guaranteed. + ## Retry Policy ``NXRequestBuilder/retry(maxAttempts:backoff:retryableStatusCodes:allowing:maximumServerDelay:jitter:)`` retries `GET`, `HEAD`, `PUT`, `DELETE`, and `OPTIONS` by default when a configured status code or a retryable transport error occurs. It uses three attempts when `maxAttempts` is omitted. Add `POST` or `PATCH` through `allowing` only when the server can safely receive the same request more than once. @@ -146,6 +164,8 @@ let user = try await client.send(UserEndpoint(identifier: 42)) - ``NXRetryBackoff`` - ``NXRetryJitter`` - ``NXURLSessionTransport`` +- ``NXNetworkMetrics`` +- ``NXNetworkTransactionMetrics`` ### Extension Points @@ -157,6 +177,7 @@ let user = try await client.send(UserEndpoint(identifier: 42)) - ``NXDefaultServerErrorDecoder`` - ``NXLogger`` - ``NXNoopLogger`` +- ``NXNetworkMetricsObserver`` ### Logging diff --git a/Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift b/Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift new file mode 100644 index 0000000..23f26e9 --- /dev/null +++ b/Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift @@ -0,0 +1,65 @@ +// +// NXURLSessionTaskMetricsDelegate.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation + +final class NXURLSessionTaskMetricsDelegate: NSObject, URLSessionTaskDelegate { + private let metricsObserver: any NXNetworkMetricsObserver + + init(metricsObserver: any NXNetworkMetricsObserver) { + self.metricsObserver = metricsObserver + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didFinishCollecting metrics: URLSessionTaskMetrics + ) { + let source = NXURLSessionTaskMetricsSource(metrics: metrics) + let snapshot = NXNetworkMetrics(source: source) + + Task { [metricsObserver] in + await metricsObserver.record(snapshot) + } + } +} + +private struct NXURLSessionTaskMetricsSource: NXNetworkMetricsSource { + let taskInterval: DateInterval + let redirectCount: Int + let transactions: [NXURLSessionTaskTransactionMetricsSource] + + init(metrics: URLSessionTaskMetrics) { + taskInterval = metrics.taskInterval + redirectCount = metrics.redirectCount + transactions = metrics.transactionMetrics.map(NXURLSessionTaskTransactionMetricsSource.init) + } +} + +private struct NXURLSessionTaskTransactionMetricsSource: NXNetworkTransactionMetricsSource { + let domainLookupStartDate: Date? + let domainLookupEndDate: Date? + let connectStartDate: Date? + let connectEndDate: Date? + let secureConnectionStartDate: Date? + let secureConnectionEndDate: Date? + let requestStartDate: Date? + let responseStartDate: Date? + let isConnectionReused: Bool + + init(metrics: URLSessionTaskTransactionMetrics) { + domainLookupStartDate = metrics.domainLookupStartDate + domainLookupEndDate = metrics.domainLookupEndDate + connectStartDate = metrics.connectStartDate + connectEndDate = metrics.connectEndDate + secureConnectionStartDate = metrics.secureConnectionStartDate + secureConnectionEndDate = metrics.secureConnectionEndDate + requestStartDate = metrics.requestStartDate + responseStartDate = metrics.responseStartDate + isConnectionReused = metrics.isReusedConnection + } +} diff --git a/Sources/Runtime/NXURLSessionTransport.swift b/Sources/Runtime/NXURLSessionTransport.swift index f9f3b43..2ce07ef 100644 --- a/Sources/Runtime/NXURLSessionTransport.swift +++ b/Sources/Runtime/NXURLSessionTransport.swift @@ -10,12 +10,26 @@ import Foundation /// Default HTTP transport backed by `URLSession`. public struct NXURLSessionTransport: NXHTTPTransport, Sendable { let urlSession: URLSession + let metricsObserver: (any NXNetworkMetricsObserver)? - /// Creates a transport that sends requests through a `URLSession`. + /// Creates a transport that sends requests through a `URLSession` without collecting metrics. /// /// - Parameter urlSession: Session used to perform requests. public init(urlSession: URLSession = .shared) { self.urlSession = urlSession + metricsObserver = nil + } + + /// Creates a transport that sends requests through a `URLSession` and records metrics snapshots. + /// + /// - Parameter urlSession: Session used to perform requests. + /// - Parameter metricsObserver: Observer that receives URLSession metrics snapshots. + public init( + urlSession: URLSession = .shared, + metricsObserver: any NXNetworkMetricsObserver + ) { + self.urlSession = urlSession + self.metricsObserver = metricsObserver } /// Sends a prepared request through the underlying `URLSession`. @@ -23,7 +37,8 @@ public struct NXURLSessionTransport: NXHTTPTransport, Sendable { /// - Parameter request: Prepared request to execute. /// - Returns: Raw response data and HTTP metadata. public func send(_ request: URLRequest) async throws -> NXRawResponse { - let (data, response) = try await urlSession.data(for: request) + let metricsDelegate = metricsObserver.map(NXURLSessionTaskMetricsDelegate.init) + let (data, response) = try await urlSession.data(for: request, delegate: metricsDelegate) guard let httpURLResponse = response as? HTTPURLResponse else { throw NXError.invalidRequest("Non-HTTP response") diff --git a/Tests/NXNetworkMetricsTests.swift b/Tests/NXNetworkMetricsTests.swift new file mode 100644 index 0000000..67c9d1d --- /dev/null +++ b/Tests/NXNetworkMetricsTests.swift @@ -0,0 +1,121 @@ +// +// NXNetworkMetricsTests.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +@testable import Nexa +import Foundation +import Testing + +@Suite("์ „์†ก metrics snapshot ํ…Œ์ŠคํŠธ") +struct NXNetworkMetricsTests { + @Test("snapshot์€ transaction ์ˆœ์„œ์™€ ๊ตฌ๊ฐ„ ๊ฐ’์„ ๋ณด์กดํ•œ๋‹ค") + func snapshotPreservesTransactionValues() { + let metrics = NXNetworkMetrics(source: FixtureMetricsSource( + taskInterval: DateInterval(start: date(0), duration: 1.2), + redirectCount: 2, + transactions: [ + FixtureTransactionSource( + domainLookupStartDate: date(0), + domainLookupEndDate: date(0.1), + connectStartDate: date(0.1), + connectEndDate: date(0.3), + secureConnectionStartDate: date(0.3), + secureConnectionEndDate: date(0.6), + requestStartDate: date(0.6), + responseStartDate: date(1), + isConnectionReused: false + ), + FixtureTransactionSource( + domainLookupStartDate: date(2), + domainLookupEndDate: date(2.5), + connectStartDate: date(2.5), + connectEndDate: date(3.1), + secureConnectionStartDate: date(3.1), + secureConnectionEndDate: date(3.8), + requestStartDate: date(3.8), + responseStartDate: date(4.6), + isConnectionReused: false + ), + FixtureTransactionSource( + domainLookupStartDate: nil, + domainLookupEndDate: nil, + connectStartDate: nil, + connectEndDate: nil, + secureConnectionStartDate: nil, + secureConnectionEndDate: nil, + requestStartDate: date(5), + responseStartDate: date(5.2), + isConnectionReused: true + ) + ] + )) + + #expect(isApproximatelyEqual(metrics.taskDuration, to: 1.2)) + #expect(metrics.redirectCount == 2) + #expect(metrics.transactionCount == 3) + #expect(isApproximatelyEqual(metrics.transactions[0].domainLookupDuration, to: 0.1)) + #expect(isApproximatelyEqual(metrics.transactions[1].timeToFirstByte, to: 0.8)) + #expect(metrics.transactions[2].isConnectionReused) + } + + @Test("๋ถˆ์™„์ „ํ•œ ๋‚ ์งœ ๊ตฌ๊ฐ„์€ ํ•ด๋‹น duration๋งŒ nil๋กœ ํ‘œํ˜„ํ•œ๋‹ค") + func incompleteSegmentsRemainNil() { + let metrics = NXNetworkMetrics(source: FixtureMetricsSource( + taskInterval: DateInterval(start: date(0), duration: 0.25), + redirectCount: 0, + transactions: [ + FixtureTransactionSource( + domainLookupStartDate: date(0), + domainLookupEndDate: nil, + connectStartDate: date(0), + connectEndDate: nil, + secureConnectionStartDate: date(0.1), + secureConnectionEndDate: date(0.15), + requestStartDate: date(0.15), + responseStartDate: date(0.25), + isConnectionReused: true + ) + ] + )) + let transaction = metrics.transactions[0] + + #expect(transaction.domainLookupDuration == nil) + #expect(transaction.connectionDuration == nil) + #expect(isApproximatelyEqual(transaction.secureConnectionDuration, to: 0.05)) + #expect(isApproximatelyEqual(transaction.timeToFirstByte, to: 0.1)) + #expect(transaction.isConnectionReused) + } + + private func date(_ seconds: TimeInterval) -> Date { + Date(timeIntervalSinceReferenceDate: seconds) + } + + private func isApproximatelyEqual(_ value: TimeInterval?, to expected: TimeInterval) -> Bool { + guard let value else { + return false + } + + return abs(value - expected) < 0.000_000_001 + } +} + +private struct FixtureMetricsSource: NXNetworkMetricsSource { + let taskInterval: DateInterval + let redirectCount: Int + let transactions: [FixtureTransactionSource] +} + +private struct FixtureTransactionSource: NXNetworkTransactionMetricsSource { + let domainLookupStartDate: Date? + let domainLookupEndDate: Date? + let connectStartDate: Date? + let connectEndDate: Date? + let secureConnectionStartDate: Date? + let secureConnectionEndDate: Date? + let requestStartDate: Date? + let responseStartDate: Date? + let isConnectionReused: Bool +} diff --git a/Tests/NXTransportMetricsCompatibilityTests.swift b/Tests/NXTransportMetricsCompatibilityTests.swift new file mode 100644 index 0000000..5081e3e --- /dev/null +++ b/Tests/NXTransportMetricsCompatibilityTests.swift @@ -0,0 +1,82 @@ +// +// NXTransportMetricsCompatibilityTests.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +import Foundation +import Testing +@testable import Nexa + +@Suite("์ „์†ก metrics ํ˜ธํ™˜์„ฑ ํ…Œ์ŠคํŠธ") +struct NXTransportMetricsCompatibilityTests { + @Test("custom transport๊ณผ cache hit๋Š” ๊ธฐ์กด ์‘๋‹ต๊ณผ logger ์ˆœ์„œ๋ฅผ ์œ ์ง€ํ•œ๋‹ค") + func customTransportAndCacheHitPreserveExistingContracts() async throws { + let counter = AttemptCounter() + let logger = MemoryLogger() + let client = NXAPIClient( + configuration: NXClientConfiguration( + baseURL: URL(string: "https://example.com")!, + transport: ClosureTransport { _ in + _ = await counter.increment() + return makeRawResponse(statusCode: 200, body: "{}", path: "/metrics") + }, + logger: logger, + cache: .memory(ttl: 10) + ) + ) + + let firstResponse: NXRawResponse = try await client.get("/metrics").send() + let secondResponse: NXRawResponse = try await client.get("/metrics").send() + + #expect(firstResponse.response.statusCode == 200) + #expect(secondResponse.response.statusCode == 200) + #expect(firstResponse.data == secondResponse.data) + #expect(await counter.value() == 1) + #expect(await logEvents(from: logger) == [.requestStart, .requestEnd, .requestStart, .requestEnd]) + } + + @Test("URLSession initializer member์™€ metrics initializer๋ฅผ ํ•จ๊ป˜ ์ œ๊ณตํ•œ๋‹ค") + func transportInitializersPreserveSourceCompatibility() { + let factory: (URLSession) -> NXURLSessionTransport = NXURLSessionTransport.init(urlSession:) + let session = URLSession(configuration: .ephemeral) + let observer = MetricsObserverStub() + let factoryTransport = factory(session) + let directTransport = NXURLSessionTransport(urlSession: session) + let metricsTransport = NXURLSessionTransport(metricsObserver: observer) + + #expect(factoryTransport.urlSession === session) + #expect(directTransport.urlSession === session) + #expect(metricsTransport.metricsObserver != nil) + } + + private func logEvents(from logger: MemoryLogger) async -> [LogEvent] { + await logger.allEvents().compactMap { event in + switch event { + case .requestStart: + return .requestStart + case .requestEnd: + return .requestEnd + case .requestFailure: + return .requestFailure + case .retry: + return .retry + case .authRefresh: + return .authRefresh + } + } + } +} + +private enum LogEvent: Equatable { + case requestStart + case requestEnd + case requestFailure + case retry + case authRefresh +} + +private actor MetricsObserverStub: NXNetworkMetricsObserver { + func record(_ metrics: NXNetworkMetrics) async {} +} diff --git a/Tests/NXURLSessionTransportMetricsTests.swift b/Tests/NXURLSessionTransportMetricsTests.swift new file mode 100644 index 0000000..8aa82ce --- /dev/null +++ b/Tests/NXURLSessionTransportMetricsTests.swift @@ -0,0 +1,210 @@ +// +// NXURLSessionTransportMetricsTests.swift +// Nexa +// +// Created by opfic on 8/23/26. +// + +@testable import Nexa +import Foundation +import Testing + +@Suite("URLSession transport metrics ํ…Œ์ŠคํŠธ") +struct NXURLSessionTransportMetricsTests { + @Test("URLSession transport๋Š” metrics callback์„ observer์— ์ „๋‹ฌํ•œ๋‹ค") + func transportForwardsMetricsToObserver() async throws { + let observer = MetricsObserverSpy() + let session = makeSession(protocolClass: SuccessfulURLProtocol.self) + let transport = NXURLSessionTransport(urlSession: session, metricsObserver: observer) + let request = URLRequest(url: URL(string: "https://example.com/metrics")!) + + defer { session.invalidateAndCancel() } + + let response = try await transport.send(request) + let metrics = await observer.nextMetrics() + + #expect(response.response.statusCode == 200) + #expect(response.data == Data("{}".utf8)) + #expect(0 <= metrics.transactionCount) + #expect(await observer.recordedCount() == 1) + } + + @Test("observer ์œ ๋ฌด๊ฐ€ URLSession transport ์˜ค๋ฅ˜๋ฅผ ๋ฐ”๊พธ์ง€ ์•Š๋Š”๋‹ค") + func observerDoesNotChangeTransportFailure() async { + let observer = MetricsObserverSpy() + let session = makeSession(protocolClass: FailingURLProtocol.self) + let transport = NXURLSessionTransport(urlSession: session, metricsObserver: observer) + let request = URLRequest(url: URL(string: "https://example.com/failure")!) + + defer { session.invalidateAndCancel() } + + await #expect { + _ = try await transport.send(request) + } throws: { error in + (error as? URLError)?.code == .cannotConnectToHost + } + } + + @Test("๋А๋ฆฐ observer๊ฐ€ ์š”์ฒญ ์™„๋ฃŒ๋ฅผ ์ง€์—ฐํ•˜์ง€ ์•Š๋Š”๋‹ค") + func slowObserverDoesNotDelayResponse() async throws { + let observer = BlockingMetricsObserver() + let session = makeSession(protocolClass: SuccessfulURLProtocol.self) + let transport = NXURLSessionTransport(urlSession: session, metricsObserver: observer) + let request = URLRequest(url: URL(string: "https://example.com/slow-observer")!) + + defer { + session.invalidateAndCancel() + Task { await observer.release() } + } + + let response = try await responseBeforeTimeout(from: transport, request: request) + + #expect(response.response.statusCode == 200) + await observer.waitUntilRecording() + } + + private func makeSession(protocolClass: URLProtocol.Type) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [protocolClass] + return URLSession(configuration: configuration) + } + + private func responseBeforeTimeout( + from transport: NXURLSessionTransport, + request: URLRequest + ) async throws -> NXRawResponse { + try await withThrowingTaskGroup(of: NXRawResponse.self) { group in + group.addTask { + try await transport.send(request) + } + group.addTask { + try await Task.sleep(nanoseconds: 1_000_000_000) + throw MetricsTimeoutError.elapsed + } + + guard let response = try await group.next() else { + throw MetricsTimeoutError.elapsed + } + + group.cancelAll() + return response + } + } +} + +private actor MetricsObserverSpy: NXNetworkMetricsObserver { + private var metrics: [NXNetworkMetrics] = [] + private var continuation: CheckedContinuation? + private var count = 0 + + func record(_ metrics: NXNetworkMetrics) async { + count += 1 + + if let continuation { + self.continuation = nil + continuation.resume(returning: metrics) + return + } + + self.metrics.append(metrics) + } + + func nextMetrics() async -> NXNetworkMetrics { + if let metrics = metrics.first { + self.metrics.removeFirst() + return metrics + } + + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func recordedCount() -> Int { + count + } +} + +private actor BlockingMetricsObserver: NXNetworkMetricsObserver { + private var isRecording = false + private var isReleased = false + private var startContinuation: CheckedContinuation? + private var releaseContinuation: CheckedContinuation? + + func record(_ metrics: NXNetworkMetrics) async { + isRecording = true + startContinuation?.resume() + startContinuation = nil + + if isReleased == false { + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + } + + func waitUntilRecording() async { + if isRecording { + return + } + + await withCheckedContinuation { continuation in + startContinuation = continuation + } + } + + func release() { + isReleased = true + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private enum MetricsTimeoutError: Error { + case elapsed +} + +private class SuccessfulURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let client, + let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + ) else { + return + } + + client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client.urlProtocol(self, didLoad: Data("{}".utf8)) + client.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +private class FailingURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) + } + + override func stopLoading() {} +}