|
1 | 1 | import Foundation |
| 2 | +import CryptoKit |
2 | 3 |
|
3 | 4 | public enum ErrorKit { |
4 | 5 | /// Provides enhanced, user-friendly, localized error descriptions for a wide range of system errors. |
@@ -55,4 +56,200 @@ public enum ErrorKit { |
55 | 56 | let nsError = error as NSError |
56 | 57 | return "[\(nsError.domain): \(nsError.code)] \(nsError.localizedDescription)" |
57 | 58 | } |
| 59 | + |
| 60 | + /// Generates a detailed, hierarchical description of an error chain for debugging purposes. |
| 61 | + /// |
| 62 | + /// This function provides a comprehensive view of nested errors, particularly useful when errors are wrapped through multiple layers |
| 63 | + /// of an application. While ``userFriendlyMessage(for:)`` is designed for end users, this function helps developers understand |
| 64 | + /// the complete error chain during debugging, similar to a stack trace. |
| 65 | + /// |
| 66 | + /// One key advantage of using typed throws with ``Catching`` is that it maintains the full error chain hierarchy, allowing you to trace |
| 67 | + /// exactly where in your application's call stack the error originated. Without this, errors caught from deep within system frameworks |
| 68 | + /// or different modules would lose their context, making it harder to identify the source. The error chain description preserves both |
| 69 | + /// the original error (as the leaf node) and the complete path of error wrapping, effectively reconstructing the error's journey |
| 70 | + /// through your application's layers. |
| 71 | + /// |
| 72 | + /// The combination of nested error types often creates a unique signature that helps pinpoint exactly where in your codebase |
| 73 | + /// the error occurred, without requiring symbolicated crash reports or complex debugging setups. For instance, if you see |
| 74 | + /// `ProfileError` wrapping `DatabaseError` wrapping `FileError`, this specific chain might only be possible in one code path |
| 75 | + /// in your application. |
| 76 | + /// |
| 77 | + /// The output includes: |
| 78 | + /// - The full type hierarchy of nested errors |
| 79 | + /// - Detailed enum case information including associated values |
| 80 | + /// - Type metadata ([Struct] or [Class] for non-enum types) |
| 81 | + /// - User-friendly message at the leaf level |
| 82 | + /// |
| 83 | + /// This is particularly valuable when: |
| 84 | + /// - Using typed throws in Swift 6 wrapping nested errors using ``Catching`` |
| 85 | + /// - Debugging complex error flows across multiple modules |
| 86 | + /// - Understanding where and how errors are being wrapped |
| 87 | + /// - Investigating error handling in modular applications |
| 88 | + /// |
| 89 | + /// The structured output format makes it ideal for error analytics and monitoring: |
| 90 | + /// - The entire chain description can be sent to analytics services |
| 91 | + /// - A hash of the string split by ":" and "(" can group similar errors which is provided in ``groupingID(for:)`` |
| 92 | + /// - Error patterns can be monitored and analyzed systematically across your user base |
| 93 | + /// |
| 94 | + /// ## Example Output: |
| 95 | + /// ```swift |
| 96 | + /// // For a deeply nested error chain: |
| 97 | + /// StateError |
| 98 | + /// └─ OperationError |
| 99 | + /// └─ DatabaseError |
| 100 | + /// └─ FileError |
| 101 | + /// └─ PermissionError.denied(permission: "~/Downloads/Profile.png") |
| 102 | + /// └─ userFriendlyMessage: "Access to ~/Downloads/Profile.png was declined." |
| 103 | + /// ``` |
| 104 | + /// |
| 105 | + /// ## Usage Example: |
| 106 | + /// ```swift |
| 107 | + /// struct ProfileManager { |
| 108 | + /// enum ProfileError: Throwable, Catching { |
| 109 | + /// case validationFailed |
| 110 | + /// case caught(Error) |
| 111 | + /// } |
| 112 | + /// |
| 113 | + /// func updateProfile() throws { |
| 114 | + /// do { |
| 115 | + /// try ProfileError.catch { |
| 116 | + /// try databaseOperation() |
| 117 | + /// } |
| 118 | + /// } catch { |
| 119 | + /// let chainDescription = ErrorKit.errorChainDescription(for: error) |
| 120 | + /// |
| 121 | + /// // Log the complete error chain for debugging |
| 122 | + /// Logger().error("Error updating profile:\n\(chainDescription)") |
| 123 | + /// // Output might show: |
| 124 | + /// // ProfileError |
| 125 | + /// // └─ DatabaseError.connectionFailed |
| 126 | + /// // └─ userFriendlyMessage: "Could not connect to the database." |
| 127 | + /// |
| 128 | + /// // Optional: Send to analytics |
| 129 | + /// Analytics.logError( |
| 130 | + /// identifier: chainDescription.hashValue, |
| 131 | + /// details: chainDescription |
| 132 | + /// ) |
| 133 | + /// |
| 134 | + /// // forward error to handle in caller |
| 135 | + /// throw error |
| 136 | + /// } |
| 137 | + /// } |
| 138 | + /// } |
| 139 | + /// ``` |
| 140 | + /// |
| 141 | + /// This output helps developers trace the error's path through the application: |
| 142 | + /// 1. Identifies the entry point (ProfileError) |
| 143 | + /// 2. Shows the underlying cause (DatabaseError.connectionFailed) |
| 144 | + /// 3. Provides the user-friendly message for context (users will report this) |
| 145 | + /// |
| 146 | + /// - Parameter error: The error to describe, potentially containing nested errors |
| 147 | + /// - Returns: A formatted string showing the complete error hierarchy with indentation |
| 148 | + public static func errorChainDescription(for error: Error) -> String { |
| 149 | + return Self.chainDescription(for: error, indent: "", enclosingType: type(of: error)) |
| 150 | + } |
| 151 | + |
| 152 | + /// Generates a stable identifier that groups similar errors based on their type structure. |
| 153 | + /// |
| 154 | + /// While ``errorChainDescription(for:)`` provides a detailed view of an error chain including all parameters and messages, |
| 155 | + /// this function creates a consistent hash that only considers the error type hierarchy. This allows grouping similar errors |
| 156 | + /// that differ only in their specific parameters or localized messages. |
| 157 | + /// |
| 158 | + /// This is particularly useful for: |
| 159 | + /// - Error analytics and aggregation |
| 160 | + /// - Identifying common error patterns across your user base |
| 161 | + /// - Grouping similar errors in logging systems |
| 162 | + /// - Creating stable identifiers for error monitoring |
| 163 | + /// |
| 164 | + /// For example, these two errors would generate the same grouping ID despite having different parameters: |
| 165 | + /// ```swift |
| 166 | + /// // Error 1: |
| 167 | + /// DatabaseError |
| 168 | + /// └─ FileError.notFound(path: "/Users/john/data.db") |
| 169 | + /// └─ userFriendlyMessage: "Could not find database file." |
| 170 | + /// // Grouping ID: "3f9d2a" |
| 171 | + /// |
| 172 | + /// // Error 2: |
| 173 | + /// DatabaseError |
| 174 | + /// └─ FileError.notFound(path: "/Users/jane/backup.db") |
| 175 | + /// └─ userFriendlyMessage: "Database file missing." |
| 176 | + /// // Grouping ID: "3f9d2a" |
| 177 | + /// ``` |
| 178 | + /// |
| 179 | + /// ## Usage Example: |
| 180 | + /// ```swift |
| 181 | + /// struct ErrorMonitor { |
| 182 | + /// static func track(_ error: Error) { |
| 183 | + /// // Get a stable ID that ignores specific parameters |
| 184 | + /// let groupID = ErrorKit.groupingID(for: error) // e.g. "3f9d2a" |
| 185 | + /// |
| 186 | + /// // Get the full description for detailed logging |
| 187 | + /// let details = ErrorKit.errorChainDescription(for: error) |
| 188 | + /// |
| 189 | + /// // Track error occurrence with analytics |
| 190 | + /// Analytics.logError( |
| 191 | + /// identifier: groupID, // Short, readable identifier |
| 192 | + /// occurrence: Date.now, |
| 193 | + /// details: details |
| 194 | + /// ) |
| 195 | + /// } |
| 196 | + /// } |
| 197 | + /// ``` |
| 198 | + /// |
| 199 | + /// The generated ID is a prefix of the SHA-256 hash of the error chain stripped of all parameters and messages, |
| 200 | + /// ensuring that only the structure of error types influences the grouping. The 6-character prefix provides |
| 201 | + /// enough uniqueness for practical error grouping while remaining readable in logs and analytics. |
| 202 | + /// |
| 203 | + /// - Parameter error: The error to generate a grouping ID for |
| 204 | + /// - Returns: A stable 6-character hexadecimal string that can be used to group similar errors |
| 205 | + public static func groupingID(for error: Error) -> String { |
| 206 | + let errorChainDescription = Self.errorChainDescription(for: error) |
| 207 | + |
| 208 | + // Split at first occurrence of "(" or ":" to remove specific parameters and user-friendly messages |
| 209 | + let descriptionWithoutDetails = errorChainDescription.components(separatedBy: CharacterSet(charactersIn: "(:")).first! |
| 210 | + |
| 211 | + let digest = SHA256.hash(data: Data(descriptionWithoutDetails.utf8)) |
| 212 | + let fullHash = digest.compactMap { String(format: "%02x", $0) }.joined() |
| 213 | + |
| 214 | + // Return first 6 characters for a shorter but still practically unique identifier |
| 215 | + return String(fullHash.prefix(6)) |
| 216 | + } |
| 217 | + |
| 218 | + private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String { |
| 219 | + let mirror = Mirror(reflecting: error) |
| 220 | + |
| 221 | + // Helper function to format the type name with optional metadata |
| 222 | + func typeDescription(_ error: Error, enclosingType: Any.Type?) -> String { |
| 223 | + let typeName = String(describing: type(of: error)) |
| 224 | + |
| 225 | + // For structs and classes (non-enums), append [Struct] or [Class] |
| 226 | + if mirror.displayStyle != .enum { |
| 227 | + let isClass = Swift.type(of: error) is AnyClass |
| 228 | + return "\(typeName) [\(isClass ? "Class" : "Struct")]" |
| 229 | + } else { |
| 230 | + // For enums, include the full case description with type name |
| 231 | + if let enclosingType { |
| 232 | + return "\(enclosingType).\(error)" |
| 233 | + } else { |
| 234 | + return String(describing: error) |
| 235 | + } |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + // Check if this is a nested error (conforms to Catching and has a caught case) |
| 240 | + if let caughtError = mirror.children.first(where: { $0.label == "caught" })?.value as? Error { |
| 241 | + let currentErrorType = type(of: error) |
| 242 | + let nextIndent = indent + " " |
| 243 | + return """ |
| 244 | + \(currentErrorType) |
| 245 | + \(indent)└─ \(Self.chainDescription(for: caughtError, indent: nextIndent, enclosingType: type(of: caughtError))) |
| 246 | + """ |
| 247 | + } else { |
| 248 | + // This is a leaf node |
| 249 | + return """ |
| 250 | + \(typeDescription(error, enclosingType: enclosingType)) |
| 251 | + \(indent)└─ userFriendlyMessage: \"\(Self.userFriendlyMessage(for: error))\" |
| 252 | + """ |
| 253 | + } |
| 254 | + } |
58 | 255 | } |
0 commit comments