Skip to content
Merged
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
108 changes: 108 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,111 @@ 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")
└─ 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:
- 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.
197 changes: 197 additions & 0 deletions Sources/ErrorKit/ErrorKit.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -55,4 +56,200 @@ public enum ErrorKit {
let nsError = error as NSError
return "[\(nsError.domain): \(nsError.code)] \(nsError.localizedDescription)"
}

/// 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))
}

/// 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)

// 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 enums, include the full case description with type name
if let enclosingType {
return "\(enclosingType).\(error)"
} else {
return String(describing: error)
}
}
}

// 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 {
// This is a leaf node
return """
\(typeDescription(error, enclosingType: enclosingType))
\(indent)└─ userFriendlyMessage: \"\(Self.userFriendlyMessage(for: error))\"
"""
}
}
}
Loading