diff --git a/Sources/Pulse/LoggerStore/LoggerStore.swift b/Sources/Pulse/LoggerStore/LoggerStore.swift index 84c286235..3d5b889d4 100644 --- a/Sources/Pulse/LoggerStore/LoggerStore.swift +++ b/Sources/Pulse/LoggerStore/LoggerStore.swift @@ -301,35 +301,6 @@ extension LoggerStore { ))) } - /// Stores the network request. - /// - /// - note: If you want to store incremental updates to the task, use - /// `NetworkLogger` instead. - public func storeRequest( - _ request: URLRequest, - response: URLResponse?, - error: Swift.Error?, - data: Data?, - metrics: URLSessionTaskMetrics? = nil, - label: String? = nil, - taskDescription: String? = nil - ) { - handle(.networkTaskCompleted(.init( - taskId: UUID(), - taskType: .dataTask, - createdAt: makeCurrentDate(), - originalRequest: NetworkLogger.Request(request), - currentRequest: NetworkLogger.Request(request), - response: response.map(NetworkLogger.Response.init), - error: error.map(NetworkLogger.ResponseError.init), - requestBody: request.httpBody ?? request.httpBodyStreamData(), - responseBody: data, - metrics: metrics.map(NetworkLogger.Metrics.init), - label: label, - taskDescription: taskDescription - ))) - } - /// Handles event created by the current store and dispatches it to observers. func handle(_ event: Event) { guard let event = configuration.willHandleEvent(event) else { diff --git a/Sources/Pulse/NetworkLogger/NetworkLogger.swift b/Sources/Pulse/NetworkLogger/NetworkLogger.swift index 91c423cf4..2ddb86eab 100644 --- a/Sources/Pulse/NetworkLogger/NetworkLogger.swift +++ b/Sources/Pulse/NetworkLogger/NetworkLogger.swift @@ -13,16 +13,8 @@ public final class NetworkLogger: @unchecked Sendable { private var store: LoggerStore { _store ?? .shared } private let _store: LoggerStore? - private var includedHosts: [Regex] = [] - private var includedURLs: [Regex] = [] - private var excludedHosts: [Regex] = [] - private var excludedURLs: [Regex] = [] + private let patterns: Redacted.Patterns - private var sensitiveHeaders: [Regex] = [] - private var sensitiveQueryItems: Set = [] - private var sensitiveDataFields: Set = [] - - private var isFilteringNeeded = false private let lock = NSLock() /// A shared network logger. @@ -53,49 +45,7 @@ public final class NetworkLogger: @unchecked Sendable { /// If the request itself fails, the task completes immediately. public var isWaitingForDecoding = false - /// Store logs only for the included hosts. - /// - /// - note: Supports wildcards, e.g. `*.example.com`, and full regex - /// when ``isRegexEnabled`` option is enabled. - public var includedHosts: Set = [] - - /// Store logs only for the included URLs. - /// - /// - note: Supports wildcards, e.g. `*.example.com`, and full regex - /// when ``isRegexEnabled`` option is enabled. - public var includedURLs: Set = [] - - /// Exclude the given hosts from the logs. - /// - /// - note: Supports wildcards, e.g. `*.example.com`, and full regex - /// when ``isRegexEnabled`` option is enabled. - public var excludedHosts: Set = [] - - /// Exclude the given URLs from the logs. - /// - /// - note: Supports wildcards, e.g. `*.example.com`, and full regex - /// when ``isRegexEnabled`` option is enabled. - public var excludedURLs: Set = [] - - /// Redact the given HTTP headers from the logged requests and responses. - /// - /// - note: Supports wildcards, e.g. `X-*`, and full regex - /// when ``isRegexEnabled`` option is enabled. - public var sensitiveHeaders: Set = [] - - /// Redact the given query items from the URLs. - /// - /// - note: Supports only plain strings. Case-sensitive. - public var sensitiveQueryItems: Set = [] - - /// Redact the given JSON fields from the logged requests and responses bodies. - /// - /// - note: Supports only plain strings. Case-sensitive. - public var sensitiveDataFields: Set = [] - - /// If enabled, processes `include` and `exclude` patterns using regex. - /// By default, patterns support only basic wildcard syntax: `*.example.com`. - public var isRegexEnabled = false + public var redacted = Redacted() /// Gets called when the logger receives an event. You can use it to /// modify the event before it is stored in order, for example, filter @@ -115,7 +65,7 @@ public final class NetworkLogger: @unchecked Sendable { public init(store: LoggerStore? = nil, configuration: Configuration = .init()) { self._store = store self.configuration = configuration - self.processPatterns() + self.patterns = configuration.redacted.patterns() } /// Initializes and configures the network logger. @@ -125,36 +75,6 @@ public final class NetworkLogger: @unchecked Sendable { self.init(store: store, configuration: configuration) } - // MARK: Patterns - - private func processPatterns() { - func process(_ pattern: String) -> Regex? { - process(pattern, options: []) - } - - func process(_ pattern: String, options: [Regex.Options]) -> Regex? { - do { - let pattern = configuration.isRegexEnabled ? pattern : expandingWildcards(pattern) - return try Regex(pattern) - } catch { - debugPrint("Failed to parse pattern: \(pattern) \(error)") - return nil - } - } - - self.includedHosts = configuration.includedHosts.compactMap(process) - self.includedURLs = configuration.includedURLs.compactMap(process) - self.excludedHosts = configuration.excludedHosts.compactMap(process) - self.excludedURLs = configuration.excludedURLs.compactMap(process) - self.sensitiveHeaders = configuration.sensitiveHeaders.compactMap { - process($0, options: [.caseInsensitive]) - } - self.sensitiveQueryItems = configuration.sensitiveQueryItems - self.sensitiveDataFields = configuration.sensitiveDataFields - - self.isFilteringNeeded = !includedHosts.isEmpty || !excludedHosts.isEmpty || !includedURLs.isEmpty || !excludedURLs.isEmpty - } - // MARK: Logging /// Logs the task creation (optional). @@ -255,47 +175,15 @@ public final class NetworkLogger: @unchecked Sendable { } private func send(_ event: LoggerStore.Event) { - guard !isFilteringNeeded || filter(event) else { + guard !patterns.isFilteringNeeded || patterns.filter(event) else { return } - guard let event = configuration.willHandleEvent(preprocess(event)) else { + guard let event = configuration.willHandleEvent(patterns.preprocess(event)) else { return } store.handle(event) } - /// Check if the events can be stored (included and not excluded). - private func filter(_ event: LoggerStore.Event) -> Bool { - guard let url = event.url else { - return false // Should never happen - } - var host = url.host ?? "" - if url.scheme == nil, let url = URL(string: "https://" + url.absoluteString) { - host = url.host ?? "" // URL(string: "example.com")?.host with not scheme returns host: "" - } - let absoluteString = url.absoluteString - if !includedHosts.isEmpty || !includedURLs.isEmpty { - guard includedHosts.contains(where: { $0.isMatch(host) }) || - includedURLs.contains(where: { $0.isMatch(absoluteString) }) else { - return false - } - } - if !excludedHosts.isEmpty && excludedHosts.contains(where: { $0.isMatch(host) }) { - return false - } - if !excludedURLs.isEmpty && excludedURLs.contains(where: { $0.isMatch(absoluteString) }) { - return false - } - return true - } - - private func preprocess(_ event: LoggerStore.Event) -> LoggerStore.Event { - event - .redactingSensitiveHeaders(sensitiveHeaders) - .redactingSensitiveQueryItems(sensitiveQueryItems) - .redactingSensitiveResponseDataFields(sensitiveDataFields) - } - // MARK: - Private private var tasks: [TaskKey: TaskContext] = [:] diff --git a/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md b/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md index 811d86630..0fec31531 100644 --- a/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md +++ b/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md @@ -84,10 +84,10 @@ struct NetworkLoggerEventMonitor: EventMonitor { } ``` -Alternatively, if you don't have access to `URLSessionTask`, you can store the request/response directly in ``LoggerStore``: +Alternatively, if you don't have access to `URLSessionTask`, you can store the request/response directly using ``RequestsLogger``: ```swift -LoggerStore.shared.storeRequest(urlRequest, response: urlResponse, ...) +RequestsLogger.shared.storeRequest(urlRequest, response: urlResponse, ...) ``` ## Configure Logging @@ -113,36 +113,44 @@ logger.logTask(task, didFinishDecodingWithError: decodingError) ``NetworkLogger`` captures data safely in a local database, and it never leaves your device. Logs are never written to the system's logging system. But, of course, logs are meant to be viewed and shared, which is why PulseUI provides sharing options. In case the logs do leave your device, it's best to redact any sensitive information.  -``NetworkLogger/Configuration`` has a set of convenience APIs for managing what information is included or excluded from the logs. +It's possible to manage that information using ``Redacted`` component. The same type is used in ``NetworkLogger.Configuration`` and ``RequestsLogger.Configuration``. So you can either use different ones or the same one in both. ```swift -var configuration = NetworkLogger.Configuration() +var redacted = Redacted() // Includes only requests with the given domain. -configuration.includedHosts = ["*.example.com"] +redacted.includedHosts = ["*.example.com"] // Exclude some subdomains. -configuration.excludedHosts = ["logging.example.com"] +redacted.excludedHosts = ["logging.example.com"] // Exclude specific URLs. -configuration.excludedURLs = ["*/log/event"] +redacted.excludedURLs = ["*/log/event"] // Replaces values for the given HTTP headers with "" -configuration.sensitiveHeaders = ["Authorization", "Access-Token"] +redacted.sensitiveHeaders = ["Authorization", "Access-Token"] // Redacts sensitive query items. -configuration.sensitiveQueryItems = ["password"] +redacted.sensitiveQueryItems = ["password"] // Replaces values for the given response and request JSON fields with "" -configuration.sensitiveDataFields = ["password"] - -let logger = NetworkLogger(configuration: configuration) +redacted.sensitiveDataFields = ["password"] ``` You can then replace the default decoder with your custom instance: ```swift -NetworkLogger.shared = logger +NetworkLogger.shared = NetworkLogger { + $0.redacted = redacted +} +``` + +And do the same with ``RequestsLogger`` + +```swift +RequestsLogger.shared = RequestsLogger { + $0.redacted = redacted +} ``` > Tip: "Include" and "exclude" patterns support basic wildcards (`*`), but you can also turn them into full-featured regex patterns using ``NetworkLogger/Configuration/isRegexEnabled``.  diff --git a/Sources/Pulse/Pulse.docc/Extensions/LoggerStore-Extension.md b/Sources/Pulse/Pulse.docc/Extensions/LoggerStore-Extension.md index 517ffc4de..ca2d6b8c0 100644 --- a/Sources/Pulse/Pulse.docc/Extensions/LoggerStore-Extension.md +++ b/Sources/Pulse/Pulse.docc/Extensions/LoggerStore-Extension.md @@ -12,7 +12,6 @@ ### Storing Logs - ``storeMessage(createdAt:label:level:message:metadata:file:function:line:)`` -- ``storeRequest(_:response:error:data:metrics:label:taskDescription:)`` ### Accessing Logs diff --git a/Sources/Pulse/Pulse.docc/Pulse.md b/Sources/Pulse/Pulse.docc/Pulse.md index 776342dac..0ef0e972e 100644 --- a/Sources/Pulse/Pulse.docc/Pulse.md +++ b/Sources/Pulse/Pulse.docc/Pulse.md @@ -13,6 +13,7 @@ Logger and network inspector for Apple platforms. ### Network Logging & Debugging - +- ``RequestsLogger`` - ``NetworkLogger`` - ``URLSessionProxy`` - ``URLSessionProtocol`` diff --git a/Sources/Pulse/NetworkLogger/NetworkLogger+Redacting.swift b/Sources/Pulse/Redacted/Redacted+Patterns.swift similarity index 75% rename from Sources/Pulse/NetworkLogger/NetworkLogger+Redacting.swift rename to Sources/Pulse/Redacted/Redacted+Patterns.swift index 562aa253a..f6ab39b14 100644 --- a/Sources/Pulse/NetworkLogger/NetworkLogger+Redacting.swift +++ b/Sources/Pulse/Redacted/Redacted+Patterns.swift @@ -4,6 +4,73 @@ import Foundation +extension Redacted { + struct Patterns { + let includedHosts: [Regex] + let includedURLs: [Regex] + let excludedHosts: [Regex] + let excludedURLs: [Regex] + + let sensitiveHeaders: [Regex] + let sensitiveQueryItems: Set + let sensitiveDataFields: Set + + let isFilteringNeeded: Bool + + init( + includedHosts: [Regex] = [], + includedURLs: [Regex] = [], + excludedHosts: [Regex] = [], + excludedURLs: [Regex] = [], + sensitiveHeaders: [Regex] = [], + sensitiveQueryItems: Set = [], + sensitiveDataFields: Set = [], + isFilteringNeeded: Bool = false + ) { + self.includedHosts = includedHosts + self.includedURLs = includedURLs + self.excludedHosts = excludedHosts + self.excludedURLs = excludedURLs + self.sensitiveHeaders = sensitiveHeaders + self.sensitiveQueryItems = sensitiveQueryItems + self.sensitiveDataFields = sensitiveDataFields + self.isFilteringNeeded = isFilteringNeeded + } + + /// Check if the events can be stored (included and not excluded). + func filter(_ event: LoggerStore.Event) -> Bool { + guard let url = event.url else { + return false // Should never happen + } + var host = url.host ?? "" + if url.scheme == nil, let url = URL(string: "https://" + url.absoluteString) { + host = url.host ?? "" // URL(string: "example.com")?.host with not scheme returns host: "" + } + let absoluteString = url.absoluteString + if !includedHosts.isEmpty || !includedURLs.isEmpty { + guard includedHosts.contains(where: { $0.isMatch(host) }) || + includedURLs.contains(where: { $0.isMatch(absoluteString) }) else { + return false + } + } + if !excludedHosts.isEmpty && excludedHosts.contains(where: { $0.isMatch(host) }) { + return false + } + if !excludedURLs.isEmpty && excludedURLs.contains(where: { $0.isMatch(absoluteString) }) { + return false + } + return true + } + + func preprocess(_ event: LoggerStore.Event) -> LoggerStore.Event { + event + .redactingSensitiveHeaders(sensitiveHeaders) + .redactingSensitiveQueryItems(sensitiveQueryItems) + .redactingSensitiveResponseDataFields(sensitiveDataFields) + } + } +} + // MARK: - Redacting Sensitive Headers extension LoggerStore.Event { diff --git a/Sources/Pulse/Redacted/Redacted.swift b/Sources/Pulse/Redacted/Redacted.swift new file mode 100644 index 000000000..59e27a3d9 --- /dev/null +++ b/Sources/Pulse/Redacted/Redacted.swift @@ -0,0 +1,89 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +public struct Redacted: Sendable { + /// Store logs only for the included hosts. + /// + /// - note: Supports wildcards, e.g. `*.example.com`, and full regex + /// when ``isRegexEnabled`` option is enabled. + public var includedHosts: Set = [] + + /// Store logs only for the included URLs. + /// + /// - note: Supports wildcards, e.g. `*.example.com`, and full regex + /// when ``isRegexEnabled`` option is enabled. + public var includedURLs: Set = [] + + /// Exclude the given hosts from the logs. + /// + /// - note: Supports wildcards, e.g. `*.example.com`, and full regex + /// when ``isRegexEnabled`` option is enabled. + public var excludedHosts: Set = [] + + /// Exclude the given URLs from the logs. + /// + /// - note: Supports wildcards, e.g. `*.example.com`, and full regex + /// when ``isRegexEnabled`` option is enabled. + public var excludedURLs: Set = [] + + /// Redact the given HTTP headers from the logged requests and responses. + /// + /// - note: Supports wildcards, e.g. `X-*`, and full regex + /// when ``isRegexEnabled`` option is enabled. + public var sensitiveHeaders: Set = [] + + /// Redact the given query items from the URLs. + /// + /// - note: Supports only plain strings. Case-sensitive. + public var sensitiveQueryItems: Set = [] + + /// Redact the given JSON fields from the logged requests and responses bodies. + /// + /// - note: Supports only plain strings. Case-sensitive. + public var sensitiveDataFields: Set = [] + + /// If enabled, processes `include` and `exclude` patterns using regex. + /// By default, patterns support only basic wildcard syntax: `*.example.com`. + public var isRegexEnabled = false + + public init() {} + + func patterns() -> Patterns { + func process(_ pattern: String) -> Regex? { + process(pattern, options: []) + } + + func process(_ pattern: String, options: [Regex.Options]) -> Regex? { + do { + let pattern = isRegexEnabled ? pattern : expandingWildcards(pattern) + return try Regex(pattern) + } catch { + debugPrint("Failed to parse pattern: \(pattern) \(error)") + return nil + } + } + + func expandingWildcards(_ pattern: String) -> String { + let pattern = NSRegularExpression.escapedPattern(for: pattern) + .replacingOccurrences(of: "\\?", with: ".") + .replacingOccurrences(of: "\\*", with: "[^\\s]*") + return "^\(pattern)$" + } + + return Patterns( + includedHosts: includedHosts.compactMap(process), + includedURLs: includedURLs.compactMap(process), + excludedHosts: excludedHosts.compactMap(process), + excludedURLs: excludedURLs.compactMap(process), + sensitiveHeaders: sensitiveHeaders.compactMap { + process($0, options: [.caseInsensitive]) + }, + sensitiveQueryItems: sensitiveQueryItems, + sensitiveDataFields: sensitiveDataFields, + isFilteringNeeded: !includedHosts.isEmpty || !excludedHosts.isEmpty || !includedURLs.isEmpty || !excludedURLs.isEmpty + ) + } +} diff --git a/Sources/Pulse/RequestsLogger/RequestsLogger.swift b/Sources/Pulse/RequestsLogger/RequestsLogger.swift new file mode 100644 index 000000000..f36ed571e --- /dev/null +++ b/Sources/Pulse/RequestsLogger/RequestsLogger.swift @@ -0,0 +1,101 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +public final class RequestsLogger: @unchecked Sendable { + public struct Configuration { + public var redacted: Redacted = Redacted() + + /// Gets called when the logger receives an event. You can use it to + /// modify the event before it is stored in order, for example, filter + /// out some sensitive information. If you return `nil`, the event + /// is ignored completely. + public var willHandleEvent: @Sendable (LoggerStore.Event) -> LoggerStore.Event? = { $0 } + + /// Initializes the default configuration. + public init() {} + } + + private var store: LoggerStore { _store ?? .shared } + private let _store: LoggerStore? + + private let configuration: Configuration + private let patterns: Redacted.Patterns + + /// A shared requests logger. + /// + /// You can configure a logger by creating a new instance and setting it as + /// a shared logger: + /// + /// ```swift + /// RequestsLogger.shared = RequestsLogger { + /// $0.excludedHosts = ["github.com"] + /// } + /// ``` + /// + /// The best place to do it is at the app launch. + public static var shared: RequestsLogger { + get { _shared.value } + set { _shared.value = newValue } + } + private static let _shared = Mutex(RequestsLogger()) + + /// Initializes the requests logger. + /// + /// - parameters: + /// - store: The target store for network requests. + /// - configuration: The store configuration. + public init(store: LoggerStore? = nil, configuration: Configuration = .init()) { + self._store = store + self.configuration = configuration + self.patterns = configuration.redacted.patterns() + } + + /// Initializes and configures the requests logger. + public convenience init(store: LoggerStore? = nil, _ configure: (inout Configuration) -> Void) { + var configuration = Configuration() + configure(&configuration) + self.init(store: store, configuration: configuration) + } + + /// Stores the network request. + /// + /// - note: If you want to store incremental updates to the task, use + /// `NetworkLogger` instead. + public func storeRequest( + _ request: URLRequest, + response: URLResponse?, + error: Swift.Error?, + data: Data?, + metrics: URLSessionTaskMetrics? = nil, + label: String? = nil, + taskDescription: String? = nil + ) { + send(.networkTaskCompleted(.init( + taskId: UUID(), + taskType: .dataTask, + createdAt: Date(), + originalRequest: NetworkLogger.Request(request), + currentRequest: NetworkLogger.Request(request), + response: response.map(NetworkLogger.Response.init), + error: error.map(NetworkLogger.ResponseError.init), + requestBody: request.httpBody ?? request.httpBodyStreamData(), + responseBody: data, + metrics: metrics.map(NetworkLogger.Metrics.init), + label: label, + taskDescription: taskDescription + ))) + } + + private func send(_ event: LoggerStore.Event) { + guard !patterns.isFilteringNeeded || patterns.filter(event) else { + return + } + guard let event = configuration.willHandleEvent(patterns.preprocess(event)) else { + return + } + store.handle(event) + } +}