Skip to content

Commit 5c09ef4

Browse files
authored
Merge pull request #22 from FlineDev/wip/handle-errors
Provide human-readable error chain description & error grouping APIs
2 parents 6dc521d + 0cc4be3 commit 5c09ef4

File tree

3 files changed

+486
-23
lines changed

3 files changed

+486
-23
lines changed

README.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,3 +421,111 @@ func appOperation() throws(AppError) {
421421
- Consider error recovery strategies at each level
422422

423423
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.
424+
425+
426+
## Enhanced Error Debugging with Error Chain Description
427+
428+
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.
429+
430+
### The Problem with Traditional Error Logging
431+
432+
When logging errors in Swift, you typically lose context about how an error propagated through your application:
433+
434+
```swift
435+
} catch {
436+
// 😕 Only shows the leaf error with no chain information
437+
Logger().error("Error occurred: \(error)")
438+
439+
// 😕 Shows a better message but still no error chain
440+
Logger().error("Error: \(ErrorKit.userFriendlyMessage(for: error))")
441+
// Output: "Could not find database file."
442+
}
443+
```
444+
445+
This makes it difficult to:
446+
- Understand which module or layer originally threw the error
447+
- Trace the error's path through your application
448+
- Group similar errors for analysis
449+
- Prioritize which errors to fix first
450+
451+
### Solution: Error Chain Description
452+
453+
ErrorKit's `errorChainDescription(for:)` function provides a comprehensive view of the entire error chain, showing you exactly how an error propagated through your application:
454+
455+
```swift
456+
do {
457+
try await updateUserProfile()
458+
} catch {
459+
// 🎯 Always use this for debug logging
460+
Logger().error("\(ErrorKit.errorChainDescription(for: error))")
461+
462+
// Output shows the complete chain:
463+
// ProfileError
464+
// └─ DatabaseError
465+
// └─ FileError.notFound(path: "/Users/data.db")
466+
// └─ userFriendlyMessage: "Could not find database file."
467+
}
468+
```
469+
470+
This hierarchical view tells you:
471+
1. Where the error originated (FileError)
472+
2. How it was wrapped (DatabaseError → ProfileError)
473+
3. What exactly went wrong (file not found)
474+
4. The user-friendly message (reported to users)
475+
476+
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.
477+
478+
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`.
479+
480+
### Error Analytics with Grouping IDs
481+
482+
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:
483+
484+
```swift
485+
struct ErrorTracker {
486+
static func log(_ error: Error) {
487+
// Get a stable ID that ignores dynamic parameters
488+
let groupID = ErrorKit.groupingID(for: error) // e.g. "3f9d2a"
489+
490+
Analytics.track(
491+
event: "error_occurred",
492+
properties: [
493+
"error_group": groupID,
494+
"error_details": ErrorKit.errorChainDescription(for: error)
495+
]
496+
)
497+
}
498+
}
499+
```
500+
501+
The grouping ID generates the same identifier for errors that have identical:
502+
- Error type hierarchy
503+
- Enum cases in the chain
504+
505+
But it ignores:
506+
- Dynamic parameters (file paths, field names, etc.)
507+
- User-friendly messages (which might be localized or dynamic)
508+
509+
For example, these errors have the same grouping ID since they differ only in their dynamic path parameters:
510+
```swift
511+
// Both generate groupID: "3f9d2a"
512+
ProfileError
513+
└─ DatabaseError
514+
└─ FileError.notFound(path: "/Users/john/data.db")
515+
└─ userFriendlyMessage: "Could not find database file."
516+
517+
ProfileError
518+
└─ DatabaseError
519+
└─ FileError.notFound(path: "/Users/jane/backup.db")
520+
└─ userFriendlyMessage: "Die Backup-Datenbank konnte nicht gefunden werden."
521+
```
522+
523+
This precise grouping allows you to:
524+
- Track true error frequencies in analytics without noise from dynamic data
525+
- Create meaningful charts of most common error patterns
526+
- Make data-driven decisions about which errors to fix first
527+
- Monitor error trends over time
528+
529+
### Summary
530+
531+
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.

Sources/ErrorKit/ErrorKit.swift

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import CryptoKit
23

34
public enum ErrorKit {
45
/// Provides enhanced, user-friendly, localized error descriptions for a wide range of system errors.
@@ -55,4 +56,200 @@ public enum ErrorKit {
5556
let nsError = error as NSError
5657
return "[\(nsError.domain): \(nsError.code)] \(nsError.localizedDescription)"
5758
}
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+
}
58255
}

0 commit comments

Comments
 (0)