From 3d8d228cee55495e413f3cdefc22195d51852283 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:39:11 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EC=A0=84=EC=86=A1=20metrics=20snap?= =?UTF-8?q?shot=20=EA=B2=BD=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Core/NXNetworkMetrics.swift | 65 +++++++++++++++++++ Sources/Core/NXNetworkMetricsObserver.swift | 14 +++++ Tests/NXNetworkMetricsTests.swift | 70 +++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 Sources/Core/NXNetworkMetrics.swift create mode 100644 Sources/Core/NXNetworkMetricsObserver.swift create mode 100644 Tests/NXNetworkMetricsTests.swift diff --git a/Sources/Core/NXNetworkMetrics.swift b/Sources/Core/NXNetworkMetrics.swift new file mode 100644 index 0000000..6930a26 --- /dev/null +++ b/Sources/Core/NXNetworkMetrics.swift @@ -0,0 +1,65 @@ +// +// 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 + } +} 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/Tests/NXNetworkMetricsTests.swift b/Tests/NXNetworkMetricsTests.swift new file mode 100644 index 0000000..3108c91 --- /dev/null +++ b/Tests/NXNetworkMetricsTests.swift @@ -0,0 +1,70 @@ +// +// 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 transactions = [ + NXNetworkTransactionMetrics( + domainLookupDuration: 0.1, + connectionDuration: 0.2, + secureConnectionDuration: 0.3, + timeToFirstByte: 0.4, + isConnectionReused: false + ), + NXNetworkTransactionMetrics( + domainLookupDuration: 0.5, + connectionDuration: 0.6, + secureConnectionDuration: 0.7, + timeToFirstByte: 0.8, + isConnectionReused: false + ), + NXNetworkTransactionMetrics( + domainLookupDuration: nil, + connectionDuration: nil, + secureConnectionDuration: nil, + timeToFirstByte: 0.2, + isConnectionReused: true + ) + ] + let metrics = NXNetworkMetrics( + taskDuration: 1.2, + redirectCount: 2, + transactionCount: transactions.count, + transactions: transactions + ) + + #expect(metrics.taskDuration == 1.2) + #expect(metrics.redirectCount == 2) + #expect(metrics.transactionCount == 3) + #expect(metrics.transactions[0].domainLookupDuration == 0.1) + #expect(metrics.transactions[1].timeToFirstByte == 0.8) + #expect(metrics.transactions[2].isConnectionReused) + } + + @Test("불완전한 날짜 구간은 해당 duration만 nil로 표현한다") + func incompleteSegmentsRemainNil() { + let transaction = NXNetworkTransactionMetrics( + domainLookupDuration: nil, + connectionDuration: nil, + secureConnectionDuration: 0.05, + timeToFirstByte: 0.1, + isConnectionReused: true + ) + + #expect(transaction.domainLookupDuration == nil) + #expect(transaction.connectionDuration == nil) + #expect(transaction.secureConnectionDuration == 0.05) + #expect(transaction.timeToFirstByte == 0.1) + #expect(transaction.isConnectionReused) + } +} From e0e40cbcdca2f590e243711fac69c93f3daf3a3a Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:40:46 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20URLSession=20=EC=A0=84=EC=86=A1=20m?= =?UTF-8?q?etrics=20=EC=A0=84=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NXURLSessionTaskMetricsDelegate.swift | 69 ++++++++++ Sources/Runtime/NXURLSessionTransport.swift | 11 +- Tests/NXURLSessionTransportMetricsTests.swift | 123 ++++++++++++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift create mode 100644 Tests/NXURLSessionTransportMetricsTests.swift diff --git a/Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift b/Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift new file mode 100644 index 0000000..7a15fcd --- /dev/null +++ b/Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift @@ -0,0 +1,69 @@ +// +// 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 snapshot = NXNetworkMetrics(metrics: metrics) + + Task { [metricsObserver] in + await metricsObserver.record(snapshot) + } + } +} + +private extension NXNetworkMetrics { + init(metrics: URLSessionTaskMetrics) { + let transactions = metrics.transactionMetrics.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.isReusedConnection + ) + } + + self.init( + taskDuration: metrics.taskInterval.duration, + redirectCount: metrics.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/Runtime/NXURLSessionTransport.swift b/Sources/Runtime/NXURLSessionTransport.swift index f9f3b43..5fccb4e 100644 --- a/Sources/Runtime/NXURLSessionTransport.swift +++ b/Sources/Runtime/NXURLSessionTransport.swift @@ -10,12 +10,18 @@ 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`. /// /// - Parameter urlSession: Session used to perform requests. - public init(urlSession: URLSession = .shared) { + /// - Parameter metricsObserver: Observer that receives URLSession metrics snapshots. + public init( + urlSession: URLSession = .shared, + metricsObserver: (any NXNetworkMetricsObserver)? = nil + ) { self.urlSession = urlSession + self.metricsObserver = metricsObserver } /// Sends a prepared request through the underlying `URLSession`. @@ -23,7 +29,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/NXURLSessionTransportMetricsTests.swift b/Tests/NXURLSessionTransportMetricsTests.swift new file mode 100644 index 0000000..c231627 --- /dev/null +++ b/Tests/NXURLSessionTransportMetricsTests.swift @@ -0,0 +1,123 @@ +// +// 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) + } + + @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 + } + } + + private func makeSession(protocolClass: URLProtocol.Type) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [protocolClass] + return URLSession(configuration: configuration) + } +} + +private actor MetricsObserverSpy: NXNetworkMetricsObserver { + private var metrics: [NXNetworkMetrics] = [] + private var continuation: CheckedContinuation? + + func record(_ metrics: NXNetworkMetrics) async { + 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 + } + } +} + +private final 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 final 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() {} +} From 5313a8649264293680ba170f5f251c865ab773a3 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:41:22 +0900 Subject: [PATCH 3/8] =?UTF-8?q?test:=20=EC=A0=84=EC=86=A1=20metrics=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...NXTransportMetricsCompatibilityTests.swift | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 Tests/NXTransportMetricsCompatibilityTests.swift diff --git a/Tests/NXTransportMetricsCompatibilityTests.swift b/Tests/NXTransportMetricsCompatibilityTests.swift new file mode 100644 index 0000000..b41fbb9 --- /dev/null +++ b/Tests/NXTransportMetricsCompatibilityTests.swift @@ -0,0 +1,64 @@ +// +// 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]) + } + + 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 +} From 94447fe3dec9877486d4b1d9856b9c5c68cba2e7 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:44:06 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20URLProtocol=20test=20lint=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/NXURLSessionTransportMetricsTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/NXURLSessionTransportMetricsTests.swift b/Tests/NXURLSessionTransportMetricsTests.swift index c231627..55d863c 100644 --- a/Tests/NXURLSessionTransportMetricsTests.swift +++ b/Tests/NXURLSessionTransportMetricsTests.swift @@ -77,7 +77,7 @@ private actor MetricsObserverSpy: NXNetworkMetricsObserver { } } -private final class SuccessfulURLProtocol: URLProtocol { +private class SuccessfulURLProtocol: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { true } @@ -106,7 +106,7 @@ private final class SuccessfulURLProtocol: URLProtocol { override func stopLoading() {} } -private final class FailingURLProtocol: URLProtocol { +private class FailingURLProtocol: URLProtocol { override class func canInit(with request: URLRequest) -> Bool { true } From 2cc05c8155dcb7756ea84bd9b2093ec8a9f4cb22 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:51:38 +0900 Subject: [PATCH 5/8] =?UTF-8?q?test:=20metrics=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=EA=B3=BC=20observer=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Core/NXNetworkMetrics.swift | 61 ++++++++ .../NXURLSessionTaskMetricsDelegate.swift | 66 +++++---- Tests/NXNetworkMetricsTests.swift | 131 ++++++++++++------ Tests/NXURLSessionTransportMetricsTests.swift | 87 ++++++++++++ 4 files changed, 270 insertions(+), 75 deletions(-) diff --git a/Sources/Core/NXNetworkMetrics.swift b/Sources/Core/NXNetworkMetrics.swift index 6930a26..797d8bd 100644 --- a/Sources/Core/NXNetworkMetrics.swift +++ b/Sources/Core/NXNetworkMetrics.swift @@ -63,3 +63,64 @@ public struct NXNetworkTransactionMetrics: Sendable, Equatable { 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/Runtime/NXURLSessionTaskMetricsDelegate.swift b/Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift index 7a15fcd..23f26e9 100644 --- a/Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift +++ b/Sources/Runtime/NXURLSessionTaskMetricsDelegate.swift @@ -19,7 +19,8 @@ final class NXURLSessionTaskMetricsDelegate: NSObject, URLSessionTaskDelegate { task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics ) { - let snapshot = NXNetworkMetrics(metrics: metrics) + let source = NXURLSessionTaskMetricsSource(metrics: metrics) + let snapshot = NXNetworkMetrics(source: source) Task { [metricsObserver] in await metricsObserver.record(snapshot) @@ -27,43 +28,38 @@ final class NXURLSessionTaskMetricsDelegate: NSObject, URLSessionTaskDelegate { } } -private extension NXNetworkMetrics { - init(metrics: URLSessionTaskMetrics) { - let transactions = metrics.transactionMetrics.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.isReusedConnection - ) - } +private struct NXURLSessionTaskMetricsSource: NXNetworkMetricsSource { + let taskInterval: DateInterval + let redirectCount: Int + let transactions: [NXURLSessionTaskTransactionMetricsSource] - self.init( - taskDuration: metrics.taskInterval.duration, - redirectCount: metrics.redirectCount, - transactionCount: transactions.count, - transactions: transactions - ) + init(metrics: URLSessionTaskMetrics) { + taskInterval = metrics.taskInterval + redirectCount = metrics.redirectCount + transactions = metrics.transactionMetrics.map(NXURLSessionTaskTransactionMetricsSource.init) } +} - private static func duration(from startDate: Date?, to endDate: Date?) -> TimeInterval? { - guard let startDate, let endDate else { - return nil - } +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 - return endDate.timeIntervalSince(startDate) + 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/Tests/NXNetworkMetricsTests.swift b/Tests/NXNetworkMetricsTests.swift index 3108c91..67c9d1d 100644 --- a/Tests/NXNetworkMetricsTests.swift +++ b/Tests/NXNetworkMetricsTests.swift @@ -13,58 +13,109 @@ import Testing struct NXNetworkMetricsTests { @Test("snapshot은 transaction 순서와 구간 값을 보존한다") func snapshotPreservesTransactionValues() { - let transactions = [ - NXNetworkTransactionMetrics( - domainLookupDuration: 0.1, - connectionDuration: 0.2, - secureConnectionDuration: 0.3, - timeToFirstByte: 0.4, - isConnectionReused: false - ), - NXNetworkTransactionMetrics( - domainLookupDuration: 0.5, - connectionDuration: 0.6, - secureConnectionDuration: 0.7, - timeToFirstByte: 0.8, - isConnectionReused: false - ), - NXNetworkTransactionMetrics( - domainLookupDuration: nil, - connectionDuration: nil, - secureConnectionDuration: nil, - timeToFirstByte: 0.2, - isConnectionReused: true - ) - ] - let metrics = NXNetworkMetrics( - taskDuration: 1.2, + let metrics = NXNetworkMetrics(source: FixtureMetricsSource( + taskInterval: DateInterval(start: date(0), duration: 1.2), redirectCount: 2, - transactionCount: transactions.count, - transactions: transactions - ) + 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(metrics.taskDuration == 1.2) + #expect(isApproximatelyEqual(metrics.taskDuration, to: 1.2)) #expect(metrics.redirectCount == 2) #expect(metrics.transactionCount == 3) - #expect(metrics.transactions[0].domainLookupDuration == 0.1) - #expect(metrics.transactions[1].timeToFirstByte == 0.8) + #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 transaction = NXNetworkTransactionMetrics( - domainLookupDuration: nil, - connectionDuration: nil, - secureConnectionDuration: 0.05, - timeToFirstByte: 0.1, - isConnectionReused: true - ) + 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(transaction.secureConnectionDuration == 0.05) - #expect(transaction.timeToFirstByte == 0.1) + #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/NXURLSessionTransportMetricsTests.swift b/Tests/NXURLSessionTransportMetricsTests.swift index 55d863c..8aa82ce 100644 --- a/Tests/NXURLSessionTransportMetricsTests.swift +++ b/Tests/NXURLSessionTransportMetricsTests.swift @@ -26,6 +26,7 @@ struct NXURLSessionTransportMetricsTests { #expect(response.response.statusCode == 200) #expect(response.data == Data("{}".utf8)) #expect(0 <= metrics.transactionCount) + #expect(await observer.recordedCount() == 1) } @Test("observer 유무가 URLSession transport 오류를 바꾸지 않는다") @@ -44,18 +45,61 @@ struct NXURLSessionTransportMetricsTests { } } + @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) @@ -75,6 +119,49 @@ private actor MetricsObserverSpy: NXNetworkMetricsObserver { 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 { From 6c528fe448429eeaabc9094b44cdebf258a32a4f Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:51:45 +0900 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20=EC=A0=84=EC=86=A1=20metrics=20obse?= =?UTF-8?q?rver=20=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.ko.md | 25 +++++++++++++++++++++++++ README.md | 25 +++++++++++++++++++++++++ Sources/Nexa.docc/Nexa.md | 23 ++++++++++++++++++++++- 3 files changed, 72 insertions(+), 1 deletion(-) 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/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 From 6dbc6a23f96dee2cf4c74a62bd33a7d93f2f5f22 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:03:35 +0900 Subject: [PATCH 7/8] =?UTF-8?q?docs:=20pr=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 +``` From 619e31d105d62e5880c577c0b4f1422516dea601 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:26:33 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20URLSession=20transport=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=ED=98=B8=ED=99=98=EC=84=B1=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Runtime/NXURLSessionTransport.swift | 12 ++++++++++-- .../NXTransportMetricsCompatibilityTests.swift | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/Runtime/NXURLSessionTransport.swift b/Sources/Runtime/NXURLSessionTransport.swift index 5fccb4e..2ce07ef 100644 --- a/Sources/Runtime/NXURLSessionTransport.swift +++ b/Sources/Runtime/NXURLSessionTransport.swift @@ -12,13 +12,21 @@ 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)? = nil + metricsObserver: any NXNetworkMetricsObserver ) { self.urlSession = urlSession self.metricsObserver = metricsObserver diff --git a/Tests/NXTransportMetricsCompatibilityTests.swift b/Tests/NXTransportMetricsCompatibilityTests.swift index b41fbb9..5081e3e 100644 --- a/Tests/NXTransportMetricsCompatibilityTests.swift +++ b/Tests/NXTransportMetricsCompatibilityTests.swift @@ -37,6 +37,20 @@ struct NXTransportMetricsCompatibilityTests { #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 { @@ -62,3 +76,7 @@ private enum LogEvent: Equatable { case retry case authRefresh } + +private actor MetricsObserverStub: NXNetworkMetricsObserver { + func record(_ metrics: NXNetworkMetrics) async {} +}