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 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 {