Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions Sources/Pulse/NetworkLogger/NetworkLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion Sources/PulseUI/Helpers/Formatters.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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=<domain> Code=<code> "<description>" 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[..<close]
return description.isEmpty || description == "(null)" ? nil : String(description)
}

/// Returns the first line of the description, truncated to fit in a list.
private static func shortened(_ description: String, limit: Int = 40) -> 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 {
Expand Down