From 76188aadb7d2f035c279147c951b11a3984db5d9 Mon Sep 17 00:00:00 2001 From: Florian Fittschen Date: Fri, 25 Sep 2026 17:17:48 +0200 Subject: [PATCH 1/2] feat(network-logger): log tasks without a URLSessionTask Add NetworkLogger.logTaskCreated(taskId:taskType:request:label:taskDescription:) and NetworkLogger.logTaskCompleted(taskId:taskType:request:response:error: requestBody:responseBody:metrics:label:taskDescription:) for tasks that don't go through URLSession, such as gRPC calls made over SwiftNIO. The caller owns the task ID, timing and bodies; NetworkLogger keeps no new state. Both methods build the same networkTaskCreated and networkTaskCompleted events as the URLSessionTask-based methods and go through send(_:), so include/exclude filters, header, query item and data field redaction, and willHandleEvent all apply. The task shows as pending until it completes. The label defaults to Configuration.label. Document the methods in the network logging article. --- .../Pulse/NetworkLogger/NetworkLogger.swift | 75 +++++++++++++++++++ .../Articles/NetworkLogging-Article.md | 26 +++++++ 2 files changed, 101 insertions(+) diff --git a/Sources/Pulse/NetworkLogger/NetworkLogger.swift b/Sources/Pulse/NetworkLogger/NetworkLogger.swift index e772a949f..903393e80 100644 --- a/Sources/Pulse/NetworkLogger/NetworkLogger.swift +++ b/Sources/Pulse/NetworkLogger/NetworkLogger.swift @@ -258,6 +258,81 @@ public final class NetworkLogger: @unchecked Sendable { ))) } + // MARK: Logging (Without URLSessionTask) + + /// Logs the creation of a task that isn't backed by a `URLSessionTask`, + /// such as a gRPC call made over SwiftNIO. + /// + /// The task is displayed as pending until you call + /// ``logTaskCompleted(taskId:taskType:request:response:error:requestBody:responseBody:metrics:label:taskDescription:)`` + /// with the same `taskId`. + /// + /// - parameters: + /// - taskId: A unique identifier of the task. + /// - taskType: The type of the task. + /// - request: The original request. + /// - label: A custom label. If `nil`, uses ``Configuration/label``. + /// - taskDescription: A custom task description. + public func logTaskCreated( + taskId: UUID, + taskType: TaskType = .dataTask, + request: URLRequest, + label: String? = nil, + taskDescription: String? = nil + ) { + send(.networkTaskCreated(LoggerStore.Event.NetworkTaskCreated( + taskId: taskId, + taskType: taskType, + createdAt: Date(), + originalRequest: Request(request), + currentRequest: Request(request), + label: label ?? configuration.label, + taskDescription: taskDescription + ))) + } + + /// Logs the completion of a task previously registered using + /// ``logTaskCreated(taskId:taskType:request:label:taskDescription:)``. + /// + /// - parameters: + /// - taskId: The identifier passed to `logTaskCreated`. + /// - taskType: The type of the task. + /// - request: The original request. + /// - response: The response, if any. + /// - error: The error, if the task failed. + /// - requestBody: The request body. + /// - responseBody: The response body. + /// - metrics: The task metrics. Use it to record the task duration. + /// - label: A custom label. If `nil`, uses ``Configuration/label``. + /// - taskDescription: A custom task description. + public func logTaskCompleted( + taskId: UUID, + taskType: TaskType = .dataTask, + request: URLRequest, + response: URLResponse?, + error: Error?, + requestBody: Data?, + responseBody: Data?, + metrics: Metrics?, + label: String? = nil, + taskDescription: String? = nil + ) { + send(.networkTaskCompleted(LoggerStore.Event.NetworkTaskCompleted( + taskId: taskId, + taskType: taskType, + createdAt: Date(), + originalRequest: Request(request), + currentRequest: Request(request), + response: response.map(Response.init), + error: error.map(ResponseError.init), + requestBody: requestBody, + responseBody: responseBody, + metrics: metrics, + label: label ?? configuration.label, + taskDescription: taskDescription + ))) + } + private func send(_ event: LoggerStore.Event) { guard !isFilteringNeeded || filter(event) else { return diff --git a/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md b/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md index c376756cb..7b30bf4dc 100644 --- a/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md +++ b/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md @@ -90,6 +90,32 @@ Alternatively, if you don't have access to `URLSessionTask`, you can store the r LoggerStore.shared.storeRequest(urlRequest, response: urlResponse, ...) ``` +If the requests don't go through `URLSession` at all, for example, gRPC calls made over SwiftNIO, log the start and the end of each task using an identifier you generate. The task is displayed as pending until it completes, and it goes through the logger's filters and redaction: + +```swift +let taskId = UUID() +let startDate = Date() // When the call starts +NetworkLogger.shared.logTaskCreated(taskId: taskId, request: urlRequest) + +// Later, when the call finishes: +let metrics = NetworkLogger.Metrics( + taskInterval: DateInterval(start: startDate, end: Date()), + redirectCount: 0, + transactions: [] // There are no URLSession transactions to report +) +NetworkLogger.shared.logTaskCompleted( + taskId: taskId, + request: urlRequest, + response: urlResponse, + error: error, + requestBody: requestBody, + responseBody: responseBody, + metrics: metrics +) +``` + +The metrics record when the task started and how long it took. `transactions` stays empty because only `URLSession` produces transaction metrics, so the console shows the task's duration but no timing breakdown. + ## Configure Logging ### Record Decoding Errors From ab5816150bbc9505e767adeffe08408a4011b994 Mon Sep 17 00:00:00 2001 From: Florian Fittschen Date: Fri, 25 Sep 2026 17:22:55 +0200 Subject: [PATCH 2/2] feat(console): show the description of errors from custom domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The console showed "Error" for every error outside of NSURLErrorDomain and the decoding error domain, so failures from other stacks, such as gRPC, all looked the same in the list. For these errors, show the error's localized description, taken from the stored NSError debug description (Error Domain=… Code=… "" UserInfo=…). Only the first line is shown, truncated to 40 characters. Errors without a localized description, and Swift errors whose debug description isn't an NSError one, still show "Error". --- Sources/PulseUI/Helpers/Formatters.swift | 27 +++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Sources/PulseUI/Helpers/Formatters.swift b/Sources/PulseUI/Helpers/Formatters.swift index 7426e9c80..7f327c51e 100644 --- a/Sources/PulseUI/Helpers/Formatters.swift +++ b/Sources/PulseUI/Helpers/Formatters.swift @@ -176,12 +176,37 @@ package enum ErrorFormatter { } else if task.errorDomain == NetworkLogger.DecodingError.domain { return "Decoding Failed" } else { - return "Error" + return localizedDescription(fromDebugDescription: task.errorDebugDescription) + .flatMap { shortened($0) } ?? "Error" } } else { return StatusCodeFormatter.string(for: Int(task.statusCode)) } } + + /// Extracts the localized description from an `NSError` debug description: + /// `Error Domain= Code= "" UserInfo={…}`. + private static func localizedDescription(fromDebugDescription string: String?) -> String? { + guard let string, string.hasPrefix("Error Domain="), + let open = string.firstIndex(of: "\"") else { + return nil + } + let rest = string[string.index(after: open)...] + guard let close = rest.range(of: "\" UserInfo=")?.lowerBound ?? rest.lastIndex(of: "\"") else { + return nil + } + let description = rest[.. String? { + let line = description.prefix(while: { !$0.isNewline }).trimmingCharacters(in: .whitespaces) + guard !line.isEmpty else { + return nil + } + return line.count > limit ? String(line.prefix(limit - 1)) + "…" : line + } } extension ByteCountFormatter {