From 0f8bd5e373075306dbc55d0be56a28c3ddb46f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Mon, 30 Dec 2024 10:19:37 +0100 Subject: [PATCH 1/6] First attempt at implementing errorChainDescription with lots of tests --- Sources/ErrorKit/ErrorKit.swift | 70 ++++++++ Tests/ErrorKitTests/ErrorKitTests.swift | 220 +++++++++++++++++++++--- 2 files changed, 267 insertions(+), 23 deletions(-) diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index a1dbcf7..e977f0e 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -55,4 +55,74 @@ public enum ErrorKit { let nsError = error as NSError return "[\(nsError.domain): \(nsError.code)] \(nsError.localizedDescription)" } + + // TODO: add documentation + public static func errorChainDescription(for error: Error) -> String { + return self.chainDescription(for: error, isRoot: true) + } + + private static func chainDescription(for error: Error, isRoot: Bool = false, prefix: String = "") -> String { + // Get type information + let typeName = String(describing: type(of: error)) + var output = "" + + // Handle root level format + if isRoot { + if isLeafNode(error) { + output += "─ " + typeNameWithKind(error) + } else { + output += typeName // No prefix for nested error chains + } + } else { + // For non-root nodes, we need to check if this is a 'caught' case or a regular error case + if let catchingError = error as? any Catching, + Mirror(reflecting: catchingError).children.first?.label == "caught" { + // Just show the type name for catching errors + output += prefix + "└─ \(typeName)" + } else { + // For regular error cases, show the full type name and case + let caseDescription = String(describing: error) + let errorTypeName = String(describing: type(of: error)) + output += prefix + "└─ \(errorTypeName).\(caseDescription)" + } + } + + // If this is a Catching error, check for nested errors + if let catchingError = error as? any Catching, + let mirror = Mirror(reflecting: catchingError).children.first, + mirror.label == "caught" { + // Recursively build trace for nested error + let nextPrefix = isRoot ? " " : prefix + " " + output += "\n" + self.chainDescription( + for: mirror.value as! Error, + prefix: nextPrefix + ) + } else { + // For leaf nodes or non-Catching errors, add userFriendlyMessage + let message = ErrorKit.userFriendlyMessage(for: error) + output += "\n" + prefix + " └─ userFriendlyMessage: \"\(message)\"" + } + + return output + } + + private static func isLeafNode(_ error: Error) -> Bool { + // Check if it's not a Catching error or if it doesn't have a caught error + if let catchingError = error as? any Catching, + let mirror = Mirror(reflecting: catchingError).children.first, + mirror.label == "caught" { + return false + } + return true + } + + private static func typeNameWithKind(_ error: Error) -> String { + let mirror = Mirror(reflecting: error) + if mirror.displayStyle == .struct { + return "\(String(describing: type(of: error))) [Struct]" + } else if mirror.displayStyle == .class { + return "\(String(describing: type(of: error))) [Class]" + } + return String(describing: type(of: error)) + } } diff --git a/Tests/ErrorKitTests/ErrorKitTests.swift b/Tests/ErrorKitTests/ErrorKitTests.swift index 052f5b9..0896c7b 100644 --- a/Tests/ErrorKitTests/ErrorKitTests.swift +++ b/Tests/ErrorKitTests/ErrorKitTests.swift @@ -2,31 +2,205 @@ import Foundation import Testing @testable import ErrorKit -struct SomeLocalizedError: LocalizedError { - let errorDescription: String? = "Something failed." - let failureReason: String? = "It failed because it wanted to." - let recoverySuggestion: String? = "Try again later." - let helpAnchor: String? = "https://github.com/apple/swift-error-kit#readme" -} +enum ErrorKitTests { + struct SomeLocalizedError: LocalizedError { + let errorDescription: String? = "Something failed." + let failureReason: String? = "It failed because it wanted to." + let recoverySuggestion: String? = "Try again later." + let helpAnchor: String? = "https://github.com/apple/swift-error-kit#readme" + } -@Test -func userFriendlyMessageLocalizedError() { - #expect(ErrorKit.userFriendlyMessage(for: SomeLocalizedError()) == "Something failed. It failed because it wanted to. Try again later.") -} + struct SomeThrowable: Throwable { + let userFriendlyMessage: String = "Something failed hard." + } -@Test -func userFriendlyMessageNSError() { - let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) - #expect(ErrorKit.userFriendlyMessage(for: nsError) == "[SOME: 1245] Something failed.") -} + enum UserFriendlyMessage { + @Test + static func localizedError() { + #expect(ErrorKit.userFriendlyMessage(for: SomeLocalizedError()) == "Something failed. It failed because it wanted to. Try again later.") + } -struct SomeThrowable: Throwable { - let userFriendlyMessage: String = "Something failed hard." -} + @Test + static func nsError() { + let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) + #expect(ErrorKit.userFriendlyMessage(for: nsError) == "[SOME: 1245] Something failed.") + } -@Test -func userFriendlyMessageThrowable() async throws { - #expect(ErrorKit.userFriendlyMessage(for: SomeThrowable()) == "Something failed hard.") -} + @Test + static func throwable() async throws { + #expect(ErrorKit.userFriendlyMessage(for: SomeThrowable()) == "Something failed hard.") + } + + @Test + static func nested() async throws { + let nestedError = DatabaseError.caught(FileError.caught(PermissionError.denied(permission: "~/Downloads/Profile.png"))) + #expect(ErrorKit.userFriendlyMessage(for: nestedError) == "Access to ~/Downloads/Profile.png was declined. To use this feature, please enable the permission in your device Settings.") + } + } + + enum ErrorChainDescription { + @Test + static func localizedError() { + #expect( + ErrorKit.errorChainDescription(for: SomeLocalizedError()) + == + """ + SomeLocalizedError [Struct] + └─ userFriendlyMessage: "Something failed. It failed because it wanted to. Try again later." + """ + ) + } + + @Test + static func nsError() { + let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) + #expect( + ErrorKit.errorChainDescription(for: nsError) + == + """ + NSError [Class] + └─ userFriendlyMessage: "[SOME: 1245] Something failed." + """ + ) + } -// TODO: add more tests for more specific errors such as CoreData, MapKit, etc. + @Test + static func throwableStruct() { + #expect( + ErrorKit.errorChainDescription(for: SomeThrowable()) + == + """ + SomeThrowable [Struct] + └─ userFriendlyMessage: "Something failed hard." + """ + ) + } + + @Test + static func throwableEnum() { + #expect( + ErrorKit.errorChainDescription(for: PermissionError.restricted(permission: "~/Downloads/Profile.png")) + == + """ + PermissionError.restricted(permission: "~/Downloads/Profile.png") + └─ userFriendlyMessage: "Access to ~/Downloads/Profile.png is currently restricted. This may be due to system settings or parental controls." + """ + ) + } + + @Test + static func shallowNested() { + let nestedError = DatabaseError.caught(FileError.fileNotFound(fileName: "App.sqlite")) + #expect( + ErrorKit.errorChainDescription(for: nestedError) + == + """ + DatabaseError + └─ FileError.fileNotFound(fileName: "App.sqlite") + └─ userFriendlyMessage: "The file App.sqlite could not be located. Please verify the file path and try again." + """ + ) + } + + @Test + static func deeplyNestedThrowablesWithEnumLeaf() { + let nestedError = StateError.caught( + OperationError.caught( + DatabaseError.caught( + FileError.caught( + PermissionError.denied(permission: "~/Downloads/Profile.png") + ) + ) + ) + ) + #expect( + ErrorKit.errorChainDescription(for: nestedError) + == + """ + StateError + └─ OperationError + └─ DatabaseError + └─ FileError + └─ PermissionError.denied(permission: "~/Downloads/Profile.png") + └─ userFriendlyMessage: "Access to ~/Downloads/Profile.png was declined. To use this feature, please enable the permission in your device Settings." + """ + ) + } + + @Test + static func shallowNestedThrowablesWithStructLeaf() { + let nestedError = StateError.caught( + OperationError.caught( + DatabaseError.caught( + FileError.caught( + SomeThrowable() + ) + ) + ) + ) + #expect( + ErrorKit.errorChainDescription(for: nestedError) + == + """ + StateError + └─ OperationError + └─ DatabaseError + └─ FileError + └─ SomeThrowable [Struct] + └─ userFriendlyMessage: "Something failed hard." + """ + ) + } + + @Test + static func shallowNestedThrowablesWithLocalizedErrorLeaf() { + let nestedError = StateError.caught( + OperationError.caught( + DatabaseError.caught( + FileError.caught( + SomeLocalizedError() + ) + ) + ) + ) + #expect( + ErrorKit.errorChainDescription(for: nestedError) + == + """ + StateError + └─ OperationError + └─ DatabaseError + └─ FileError + └─ SomeLocalizedError [Struct] + └─ userFriendlyMessage: "Something failed. It failed because it wanted to. Try again later." + """ + ) + } + + @Test + static func shallowNestedThrowablesWithNSErrorLeaf() { + let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) + let nestedError = StateError.caught( + OperationError.caught( + DatabaseError.caught( + FileError.caught(nsError) + ) + ) + ) + #expect( + ErrorKit.errorChainDescription(for: nestedError) + == + """ + StateError + └─ OperationError + └─ DatabaseError + └─ FileError + └─ NSError [Class] + └─ userFriendlyMessage: "[SOME: 1245] Something failed." + """ + ) + } + } + + // TODO: add more tests for more specific errors such as CoreData, MapKit – and also nested errors! +} From 0f8088e0dfb9aa33fb90e6fcc34f06785aa395b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Mon, 30 Dec 2024 11:14:59 +0100 Subject: [PATCH 2/6] Rework implementation of errorChainDescription to get all tests passed --- Sources/ErrorKit/ErrorKit.swift | 87 +++++++++---------------- Tests/ErrorKitTests/ErrorKitTests.swift | 76 +++++++++------------ 2 files changed, 60 insertions(+), 103 deletions(-) diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index e977f0e..16bfa0e 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -58,71 +58,44 @@ public enum ErrorKit { // TODO: add documentation public static func errorChainDescription(for error: Error) -> String { - return self.chainDescription(for: error, isRoot: true) + return Self.chainDescription(for: error, indent: "", enclosingType: type(of: error)) } - private static func chainDescription(for error: Error, isRoot: Bool = false, prefix: String = "") -> String { - // Get type information - let typeName = String(describing: type(of: error)) - var output = "" + private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String { + let mirror = Mirror(reflecting: error) - // Handle root level format - if isRoot { - if isLeafNode(error) { - output += "─ " + typeNameWithKind(error) - } else { - output += typeName // No prefix for nested error chains - } - } else { - // For non-root nodes, we need to check if this is a 'caught' case or a regular error case - if let catchingError = error as? any Catching, - Mirror(reflecting: catchingError).children.first?.label == "caught" { - // Just show the type name for catching errors - output += prefix + "└─ \(typeName)" + // Helper function to format the type name with optional metadata + func typeDescription(_ error: Error, enclosingType: Any.Type?) -> String { + let typeName = String(describing: type(of: error)) + + // For structs and classes (non-enums), append [Struct] or [Class] + if mirror.displayStyle != .enum { + let isClass = Swift.type(of: error) is AnyClass + return "\(typeName) [\(isClass ? "Class" : "Struct")]" } else { - // For regular error cases, show the full type name and case - let caseDescription = String(describing: error) - let errorTypeName = String(describing: type(of: error)) - output += prefix + "└─ \(errorTypeName).\(caseDescription)" + // For enums, include the full case description with type name + if let enclosingType { + return "\(enclosingType).\(error)" + } else { + return String(describing: error) + } } } - // If this is a Catching error, check for nested errors - if let catchingError = error as? any Catching, - let mirror = Mirror(reflecting: catchingError).children.first, - mirror.label == "caught" { - // Recursively build trace for nested error - let nextPrefix = isRoot ? " " : prefix + " " - output += "\n" + self.chainDescription( - for: mirror.value as! Error, - prefix: nextPrefix - ) + // Check if this is a nested error (conforms to Catching and has a caught case) + if let caughtError = mirror.children.first(where: { $0.label == "caught" })?.value as? Error { + let currentErrorType = type(of: error) + let nextIndent = indent + " " + return """ + \(currentErrorType) + \(indent)└─ \(Self.chainDescription(for: caughtError, indent: nextIndent, enclosingType: type(of: caughtError))) + """ } else { - // For leaf nodes or non-Catching errors, add userFriendlyMessage - let message = ErrorKit.userFriendlyMessage(for: error) - output += "\n" + prefix + " └─ userFriendlyMessage: \"\(message)\"" - } - - return output - } - - private static func isLeafNode(_ error: Error) -> Bool { - // Check if it's not a Catching error or if it doesn't have a caught error - if let catchingError = error as? any Catching, - let mirror = Mirror(reflecting: catchingError).children.first, - mirror.label == "caught" { - return false - } - return true - } - - private static func typeNameWithKind(_ error: Error) -> String { - let mirror = Mirror(reflecting: error) - if mirror.displayStyle == .struct { - return "\(String(describing: type(of: error))) [Struct]" - } else if mirror.displayStyle == .class { - return "\(String(describing: type(of: error))) [Class]" + // This is a leaf node + return """ + \(typeDescription(error, enclosingType: enclosingType)) + \(indent)└─ userFriendlyMessage: \"\(Self.userFriendlyMessage(for: error))\" + """ } - return String(describing: type(of: error)) } } diff --git a/Tests/ErrorKitTests/ErrorKitTests.swift b/Tests/ErrorKitTests/ErrorKitTests.swift index 0896c7b..59d03c3 100644 --- a/Tests/ErrorKitTests/ErrorKitTests.swift +++ b/Tests/ErrorKitTests/ErrorKitTests.swift @@ -54,52 +54,44 @@ enum ErrorKitTests { @Test static func nsError() { let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) - #expect( - ErrorKit.errorChainDescription(for: nsError) - == - """ + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: nsError) + let expectedErrorChainDescription = """ NSError [Class] └─ userFriendlyMessage: "[SOME: 1245] Something failed." """ - ) + #expect(generatedErrorChainDescription == expectedErrorChainDescription) } @Test static func throwableStruct() { - #expect( - ErrorKit.errorChainDescription(for: SomeThrowable()) - == - """ - SomeThrowable [Struct] - └─ userFriendlyMessage: "Something failed hard." - """ - ) + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: SomeThrowable()) + let expectedErrorChainDescription = """ + SomeThrowable [Struct] + └─ userFriendlyMessage: "Something failed hard." + """ + #expect(generatedErrorChainDescription == expectedErrorChainDescription) } @Test static func throwableEnum() { - #expect( - ErrorKit.errorChainDescription(for: PermissionError.restricted(permission: "~/Downloads/Profile.png")) - == - """ + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: PermissionError.restricted(permission: "~/Downloads/Profile.png")) + let expectedErrorChainDescription = """ PermissionError.restricted(permission: "~/Downloads/Profile.png") └─ userFriendlyMessage: "Access to ~/Downloads/Profile.png is currently restricted. This may be due to system settings or parental controls." """ - ) + #expect(generatedErrorChainDescription == expectedErrorChainDescription) } @Test static func shallowNested() { let nestedError = DatabaseError.caught(FileError.fileNotFound(fileName: "App.sqlite")) - #expect( - ErrorKit.errorChainDescription(for: nestedError) - == - """ + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: nestedError) + let expectedErrorChainDescription = """ DatabaseError └─ FileError.fileNotFound(fileName: "App.sqlite") └─ userFriendlyMessage: "The file App.sqlite could not be located. Please verify the file path and try again." """ - ) + #expect(generatedErrorChainDescription == expectedErrorChainDescription) } @Test @@ -113,10 +105,8 @@ enum ErrorKitTests { ) ) ) - #expect( - ErrorKit.errorChainDescription(for: nestedError) - == - """ + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: nestedError) + let expectedErrorChainDescription = """ StateError └─ OperationError └─ DatabaseError @@ -124,11 +114,11 @@ enum ErrorKitTests { └─ PermissionError.denied(permission: "~/Downloads/Profile.png") └─ userFriendlyMessage: "Access to ~/Downloads/Profile.png was declined. To use this feature, please enable the permission in your device Settings." """ - ) + #expect(generatedErrorChainDescription == expectedErrorChainDescription) } @Test - static func shallowNestedThrowablesWithStructLeaf() { + static func deeplyNestedThrowablesWithStructLeaf() { let nestedError = StateError.caught( OperationError.caught( DatabaseError.caught( @@ -138,10 +128,8 @@ enum ErrorKitTests { ) ) ) - #expect( - ErrorKit.errorChainDescription(for: nestedError) - == - """ + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: nestedError) + let expectedErrorChainDescription = """ StateError └─ OperationError └─ DatabaseError @@ -149,11 +137,11 @@ enum ErrorKitTests { └─ SomeThrowable [Struct] └─ userFriendlyMessage: "Something failed hard." """ - ) + #expect(generatedErrorChainDescription == expectedErrorChainDescription) } @Test - static func shallowNestedThrowablesWithLocalizedErrorLeaf() { + static func deeplyNestedThrowablesWithLocalizedErrorLeaf() { let nestedError = StateError.caught( OperationError.caught( DatabaseError.caught( @@ -163,10 +151,8 @@ enum ErrorKitTests { ) ) ) - #expect( - ErrorKit.errorChainDescription(for: nestedError) - == - """ + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: nestedError) + let expectedErrorChainDescription = """ StateError └─ OperationError └─ DatabaseError @@ -174,11 +160,11 @@ enum ErrorKitTests { └─ SomeLocalizedError [Struct] └─ userFriendlyMessage: "Something failed. It failed because it wanted to. Try again later." """ - ) + #expect(generatedErrorChainDescription == expectedErrorChainDescription) } @Test - static func shallowNestedThrowablesWithNSErrorLeaf() { + static func deeplyNestedThrowablesWithNSErrorLeaf() { let nsError = NSError(domain: "SOME", code: 1245, userInfo: [NSLocalizedDescriptionKey: "Something failed."]) let nestedError = StateError.caught( OperationError.caught( @@ -187,10 +173,8 @@ enum ErrorKitTests { ) ) ) - #expect( - ErrorKit.errorChainDescription(for: nestedError) - == - """ + let generatedErrorChainDescription = ErrorKit.errorChainDescription(for: nestedError) + let expectedErrorChainDescription = """ StateError └─ OperationError └─ DatabaseError @@ -198,7 +182,7 @@ enum ErrorKitTests { └─ NSError [Class] └─ userFriendlyMessage: "[SOME: 1245] Something failed." """ - ) + #expect(generatedErrorChainDescription == expectedErrorChainDescription) } } From 33c8e414b9aa1133cde6aadea7312152e57ddeae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Mon, 30 Dec 2024 12:17:59 +0100 Subject: [PATCH 3/6] Document errorChainDescription function in detail --- Sources/ErrorKit/ErrorKit.swift | 89 ++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index 16bfa0e..eef8625 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -56,7 +56,94 @@ public enum ErrorKit { return "[\(nsError.domain): \(nsError.code)] \(nsError.localizedDescription)" } - // TODO: add documentation + /// Generates a detailed, hierarchical description of an error chain for debugging purposes. + /// + /// This function provides a comprehensive view of nested errors, particularly useful when errors are wrapped through multiple layers + /// of an application. While ``userFriendlyMessage(for:)`` is designed for end users, this function helps developers understand + /// the complete error chain during debugging, similar to a stack trace. + /// + /// One key advantage of using typed throws with ``Catching`` is that it maintains the full error chain hierarchy, allowing you to trace + /// exactly where in your application's call stack the error originated. Without this, errors caught from deep within system frameworks + /// or different modules would lose their context, making it harder to identify the source. The error chain description preserves both + /// the original error (as the leaf node) and the complete path of error wrapping, effectively reconstructing the error's journey + /// through your application's layers. + /// + /// The combination of nested error types often creates a unique signature that helps pinpoint exactly where in your codebase + /// the error occurred, without requiring symbolicated crash reports or complex debugging setups. For instance, if you see + /// `ProfileError` wrapping `DatabaseError` wrapping `FileError`, this specific chain might only be possible in one code path + /// in your application. + /// + /// The output includes: + /// - The full type hierarchy of nested errors + /// - Detailed enum case information including associated values + /// - Type metadata ([Struct] or [Class] for non-enum types) + /// - User-friendly message at the leaf level + /// + /// This is particularly valuable when: + /// - Using typed throws in Swift 6 wrapping nested errors using ``Catching`` + /// - Debugging complex error flows across multiple modules + /// - Understanding where and how errors are being wrapped + /// - Investigating error handling in modular applications + /// + /// The structured output format makes it ideal for error analytics and monitoring: + /// - The entire chain description can be sent to analytics services + /// - A hash of the string split by ":" and "(" can group similar errors which is provided in ``groupingID(for:)`` + /// - Error patterns can be monitored and analyzed systematically across your user base + /// + /// ## Example Output: + /// ```swift + /// // For a deeply nested error chain: + /// StateError + /// └─ OperationError + /// └─ DatabaseError + /// └─ FileError + /// └─ PermissionError.denied(permission: "~/Downloads/Profile.png") + /// └─ userFriendlyMessage: "Access to ~/Downloads/Profile.png was declined." + /// ``` + /// + /// ## Usage Example: + /// ```swift + /// struct ProfileManager { + /// enum ProfileError: Throwable, Catching { + /// case validationFailed + /// case caught(Error) + /// } + /// + /// func updateProfile() throws { + /// do { + /// try ProfileError.catch { + /// try databaseOperation() + /// } + /// } catch { + /// let chainDescription = ErrorKit.errorChainDescription(for: error) + /// + /// // Log the complete error chain for debugging + /// Logger().error("Error updating profile:\n\(chainDescription)") + /// // Output might show: + /// // ProfileError + /// // └─ DatabaseError.connectionFailed + /// // └─ userFriendlyMessage: "Could not connect to the database." + /// + /// // Optional: Send to analytics + /// Analytics.logError( + /// identifier: chainDescription.hashValue, + /// details: chainDescription + /// ) + /// + /// // forward error to handle in caller + /// throw error + /// } + /// } + /// } + /// ``` + /// + /// This output helps developers trace the error's path through the application: + /// 1. Identifies the entry point (ProfileError) + /// 2. Shows the underlying cause (DatabaseError.connectionFailed) + /// 3. Provides the user-friendly message for context (users will report this) + /// + /// - Parameter error: The error to describe, potentially containing nested errors + /// - Returns: A formatted string showing the complete error hierarchy with indentation public static func errorChainDescription(for error: Error) -> String { return Self.chainDescription(for: error, indent: "", enclosingType: type(of: error)) } From 4982f28c938d114bd7acbfeacbe663b6d216d49d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Mon, 30 Dec 2024 12:18:22 +0100 Subject: [PATCH 4/6] Add groupingID helper for grouping errors + document extensively --- Sources/ErrorKit/ErrorKit.swift | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/Sources/ErrorKit/ErrorKit.swift b/Sources/ErrorKit/ErrorKit.swift index eef8625..99906c2 100644 --- a/Sources/ErrorKit/ErrorKit.swift +++ b/Sources/ErrorKit/ErrorKit.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit public enum ErrorKit { /// Provides enhanced, user-friendly, localized error descriptions for a wide range of system errors. @@ -148,6 +149,72 @@ public enum ErrorKit { return Self.chainDescription(for: error, indent: "", enclosingType: type(of: error)) } + /// Generates a stable identifier that groups similar errors based on their type structure. + /// + /// While ``errorChainDescription(for:)`` provides a detailed view of an error chain including all parameters and messages, + /// this function creates a consistent hash that only considers the error type hierarchy. This allows grouping similar errors + /// that differ only in their specific parameters or localized messages. + /// + /// This is particularly useful for: + /// - Error analytics and aggregation + /// - Identifying common error patterns across your user base + /// - Grouping similar errors in logging systems + /// - Creating stable identifiers for error monitoring + /// + /// For example, these two errors would generate the same grouping ID despite having different parameters: + /// ```swift + /// // Error 1: + /// DatabaseError + /// └─ FileError.notFound(path: "/Users/john/data.db") + /// └─ userFriendlyMessage: "Could not find database file." + /// // Grouping ID: "3f9d2a" + /// + /// // Error 2: + /// DatabaseError + /// └─ FileError.notFound(path: "/Users/jane/backup.db") + /// └─ userFriendlyMessage: "Database file missing." + /// // Grouping ID: "3f9d2a" + /// ``` + /// + /// ## Usage Example: + /// ```swift + /// struct ErrorMonitor { + /// static func track(_ error: Error) { + /// // Get a stable ID that ignores specific parameters + /// let groupID = ErrorKit.groupingID(for: error) // e.g. "3f9d2a" + /// + /// // Get the full description for detailed logging + /// let details = ErrorKit.errorChainDescription(for: error) + /// + /// // Track error occurrence with analytics + /// Analytics.logError( + /// identifier: groupID, // Short, readable identifier + /// occurrence: Date.now, + /// details: details + /// ) + /// } + /// } + /// ``` + /// + /// The generated ID is a prefix of the SHA-256 hash of the error chain stripped of all parameters and messages, + /// ensuring that only the structure of error types influences the grouping. The 6-character prefix provides + /// enough uniqueness for practical error grouping while remaining readable in logs and analytics. + /// + /// - Parameter error: The error to generate a grouping ID for + /// - Returns: A stable 6-character hexadecimal string that can be used to group similar errors + public static func groupingID(for error: Error) -> String { + let errorChainDescription = Self.errorChainDescription(for: error) + + // Split at first occurrence of "(" or ":" to remove specific parameters and user-friendly messages + let descriptionWithoutDetails = errorChainDescription.components(separatedBy: CharacterSet(charactersIn: "(:")).first! + + let digest = SHA256.hash(data: Data(descriptionWithoutDetails.utf8)) + let fullHash = digest.compactMap { String(format: "%02x", $0) }.joined() + + // Return first 6 characters for a shorter but still practically unique identifier + return String(fullHash.prefix(6)) + } + private static func chainDescription(for error: Error, indent: String, enclosingType: Any.Type?) -> String { let mirror = Mirror(reflecting: error) From 7de0bad0c81fccbcfcd26a7442a5cd75033f2100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Mon, 30 Dec 2024 12:19:31 +0100 Subject: [PATCH 5/6] Add new README section with motivation & usage chain description & group ID --- README.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/README.md b/README.md index 4258244..fed4dae 100644 --- a/README.md +++ b/README.md @@ -421,3 +421,109 @@ func appOperation() throws(AppError) { - Consider error recovery strategies at each level The `Catching` protocol makes error handling in Swift more intuitive and maintainable, especially in larger applications with complex error hierarchies. Combined with typed throws, it provides a powerful way to handle errors while keeping your code clean and maintainable. + + +## Enhanced Error Debugging with Error Chain Description + +One of the most challenging aspects of error handling in Swift is tracing where exactly an error originated, especially when using error wrapping across multiple layers of an application. ErrorKit solves this with powerful debugging tools that help you understand the complete error chain. + +### The Problem with Traditional Error Logging + +When logging errors in Swift, you typically lose context about how an error propagated through your application: + +```swift +} catch { + // 😕 Only shows the leaf error with no chain information + Logger().error("Error occurred: \(error)") + + // 😕 Shows a better message but still no error chain + Logger().error("Error: \(ErrorKit.userFriendlyMessage(for: error))") + // Output: "Could not find database file." +} +``` + +This makes it difficult to: +- Understand which module or layer originally threw the error +- Trace the error's path through your application +- Group similar errors for analysis +- Prioritize which errors to fix first + +### Solution: Error Chain Description + +ErrorKit's `errorChainDescription(for:)` function provides a comprehensive view of the entire error chain, showing you exactly how an error propagated through your application: + +```swift +do { + try await updateUserProfile() +} catch { + // 🎯 Always use this for debug logging + Logger().error("\(ErrorKit.errorChainDescription(for: error))") + + // Output shows the complete chain: + // ProfileError + // └─ DatabaseError + // └─ FileError.notFound(path: "/Users/data.db") + // └─ userFriendlyMessage: "Could not find database file." +} +``` + +This hierarchical view tells you: +1. Where the error originated (FileError) +2. How it was wrapped (DatabaseError → ProfileError) +3. What exactly went wrong (file not found) +4. The user-friendly message (reported to users) + +For errors conforming to the `Catching` protocol, you get the complete error wrapping chain. This is why it's important for your own error types and any Swift packages you develop to adopt both `Throwable` and `Catching` - it not only makes them work better with typed throws but also enables automatic extraction of the full error chain. + +Even for errors that don't conform to `Catching`, you still get valuable information since most Swift errors are enums. The error chain description will show you the exact enum case (e.g., `FileError.notFound`), making it easy to search your codebase for the error's origin. This is much better than the default cryptic message you get for enum cases when using `localizedDescription`. + +### Error Analytics with Grouping IDs + +To help prioritize which errors to fix, ErrorKit provides `groupingID(for:)` that generates stable identifiers for errors sharing the exact same type structure and enum cases: + +```swift +struct ErrorTracker { + static func log(_ error: Error) { + // Get a stable ID that ignores dynamic parameters + let groupID = ErrorKit.groupingID(for: error) // e.g. "3f9d2a" + + Analytics.track( + event: "error_occurred", + properties: [ + "error_group": groupID, + "error_details": ErrorKit.errorChainDescription(for: error) + ] + ) + } +} +``` + +The grouping ID generates the same identifier for errors that have identical: +- Error type hierarchy +- Enum cases in the chain + +But it ignores: +- Dynamic parameters (file paths, field names, etc.) +- User-friendly messages (which might be localized or dynamic) + +For example, these errors have the same grouping ID since they differ only in their dynamic path parameters: +```swift +// Both generate groupID: "3f9d2a" +ProfileError +└─ DatabaseError + └─ FileError.notFound(path: "/Users/john/data.db") + +ProfileError +└─ DatabaseError + └─ FileError.notFound(path: "/Users/jane/backup.db") +``` + +This precise grouping allows you to: +- Track true error frequencies in analytics without noise from dynamic data +- Create meaningful charts of most common error patterns +- Make data-driven decisions about which errors to fix first +- Monitor error trends over time + +### Summary + +ErrorKit's debugging tools transform error handling from a black box into a transparent system. By combining `errorChainDescription` for debugging with `groupingID` for analytics, you get deep insight into error flows while maintaining the ability to track and prioritize issues effectively. This is particularly powerful when combined with ErrorKit's `Catching` protocol, creating a comprehensive system for error handling, debugging, and monitoring. From 0cc4be3cb2651b6bb0a507d7f1ead814020b0376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihat=20Gu=CC=88ndu=CC=88z?= Date: Mon, 30 Dec 2024 12:29:34 +0100 Subject: [PATCH 6/6] Add missing userFriendlyMessage from README for more realistic example --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index fed4dae..77d79a5 100644 --- a/README.md +++ b/README.md @@ -512,10 +512,12 @@ For example, these errors have the same grouping ID since they differ only in th ProfileError └─ DatabaseError └─ FileError.notFound(path: "/Users/john/data.db") + └─ userFriendlyMessage: "Could not find database file." ProfileError └─ DatabaseError └─ FileError.notFound(path: "/Users/jane/backup.db") + └─ userFriendlyMessage: "Die Backup-Datenbank konnte nicht gefunden werden." ``` This precise grouping allows you to: