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
24 changes: 24 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,20 @@ let package = Package(
defaultLocalization: "en",
platforms: [.macOS(.v13), .iOS(.v16), .tvOS(.v16), .watchOS(.v9), .macCatalyst(.v16)],
products: [.library(name: "ErrorKit", targets: ["ErrorKit"])],
dependencies: [
// CryptoKit is not available on Linux, so we need Swift Crypto
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.11.0"),
],
targets: [
.target(
name: "ErrorKit",
dependencies: [
.product(
name: "Crypto",
package: "swift-crypto",
condition: .when(platforms: [.android, .linux, .openbsd, .wasi, .windows])
),
],
resources: [.process("Resources/Localizable.xcstrings")]
),
.testTarget(name: "ErrorKitTests", dependencies: ["ErrorKit"]),
Expand Down
120 changes: 120 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ ErrorKit makes error handling in Swift more intuitive. It reduces boilerplate co
- [Typed Throws for System Functions](#typed-throws-for-system-functions)
- [Error Nesting with Catching](#error-nesting-with-catching)
- [Error Chain Debugging](#error-chain-debugging)
- [User Feedback with Error Logs](#user-feedback-with-error-logs)

## The Problem with Swift's Error Protocol

Expand Down Expand Up @@ -544,3 +545,122 @@ This precise grouping allows you to:
### 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.


## User Feedback with Error Logs

When users encounter issues in your app, getting enough context to diagnose the problem can be challenging. Users rarely know what information you need, and reproducing issues without logs is often impossible. 😕

ErrorKit makes it simple to add diagnostic log collection to your app, providing crucial context for bug reports and support requests.

### The Power of System Logs

ErrorKit leverages Apple's unified logging system (`OSLog`/`Logger`) to collect valuable diagnostic information. If you're not already using structured logging, here's a quick primer:

```swift
import OSLog

// Log at appropriate levels
Logger().debug("Detailed connection info: \(details)") // Development debugging
Logger().info("User tapped on \(button)") // General information
Logger().notice("Successfully loaded user profile") // Important events
Logger().error("Failed to parse server response") // Errors that should be fixed
Logger().fault("Database corruption detected") // Critical system failures
```

ErrorKit can collect these logs based on level, giving you control over how much detail to include in reports. 3rd-party frameworks that also use Apple's unified logging system will be included so you get a full picture of what happened in your app, not just what you logged yourself.

### Creating a Feedback Button with Automatic Log Collection

The easiest way to implement a support system is using the `.mailComposer` SwiftUI modifier combined with `logAttachment`:

```swift
struct ContentView: View {
@State private var showMailComposer = false

var body: some View {
Form {
// Your app content here

Button("Report a Problem") {
showMailComposer = true
}
.mailComposer(
isPresented: $showMailComposer,
recipient: "support@yourapp.com",
subject: "<AppName> Bug Report",
messageBody: """
Please describe what happened:



----------------------------------
[Please do not remove the information below]

App version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown")
Build: \(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown")
Device: \(UIDevice.current.model)
iOS: \(UIDevice.current.systemVersion)
""",
attachments: [
try? ErrorKit.logAttachment(ofLast: .minutes(30), minLevel: .notice)
]
)
}
}
}
```

This creates a simple "Report a Problem" button that:
1. Opens a pre-filled email composer
2. Includes useful device and app information
3. Automatically attaches recent system logs
4. Provides space for the user to describe the issue

The above is just an example, feel free to adjust it to your needs and include any additional info needed.

### Alternative Methods for More Control

If you need more control over log handling, ErrorKit offers two additional approaches:

#### 1. Getting Log Data Directly

For sending logs to your own backend or processing them in-app:

```swift
let logData = try ErrorKit.loggedData(
ofLast: .minutes(10),
minLevel: .notice
)

// Use the data with your custom reporting system
analyticsService.sendLogs(data: logData)
```

#### 2. Exporting to a Temporary File

For sharing logs via other mechanisms:

```swift
let logFileURL = try ErrorKit.exportLogFile(
ofLast: .hours(1),
minLevel: .error
)

// Share the log file
let activityVC = UIActivityViewController(
activityItems: [logFileURL],
applicationActivities: nil
)
present(activityVC, animated: true)
```

### Benefits of Automatic Log Collection

- **Better bug reports**: Get the context you need without asking users for technical details
- **Faster issue resolution**: See exactly what happened leading up to the problem
- **Lower support burden**: Reduce back-and-forth communications with users
- **User satisfaction**: Demonstrate that you take their problems seriously
- **Developer sanity**: Stop trying to reproduce issues with insufficient information

By implementing a feedback button with automatic log collection, you transform the error reporting experience for both users and developers. Users can report issues with a single tap, and you get the diagnostic information you need to fix problems quickly.
28 changes: 12 additions & 16 deletions Sources/ErrorKit/BuiltInErrors/DatabaseError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,29 +136,25 @@ public enum DatabaseError: Throwable, Catching {
public var userFriendlyMessage: String {
switch self {
case .connectionFailed:
return String(
localized: "BuiltInErrors.DatabaseError.connectionFailed",
defaultValue: "Unable to establish a connection to the database. Check your network settings and try again.",
bundle: .module
return String.localized(
key: "BuiltInErrors.DatabaseError.connectionFailed",
defaultValue: "Unable to establish a connection to the database. Check your network settings and try again."
)
case .operationFailed(let context):
return String(
localized: "BuiltInErrors.DatabaseError.operationFailed",
defaultValue: "The database operation for \(context) could not be completed. Please retry the action.",
bundle: .module
return String.localized(
key: "BuiltInErrors.DatabaseError.operationFailed",
defaultValue: "The database operation for \(context) could not be completed. Please retry the action."
)
case .recordNotFound(let entity, let identifier):
if let identifier {
return String(
localized: "BuiltInErrors.DatabaseError.recordNotFoundWithID",
defaultValue: "The \(entity) record with ID \(identifier) was not found in the database. Verify the details and try again.",
bundle: .module
return String.localized(
key: "BuiltInErrors.DatabaseError.recordNotFoundWithID",
defaultValue: "The \(entity) record with ID \(identifier) was not found in the database. Verify the details and try again."
)
} else {
return String(
localized: "BuiltInErrors.DatabaseError.recordNotFound",
defaultValue: "The \(entity) record was not found in the database. Verify the details and try again.",
bundle: .module
return String.localized(
key: "BuiltInErrors.DatabaseError.recordNotFound",
defaultValue: "The \(entity) record was not found in the database. Verify the details and try again."
)
}
case .generic(let userFriendlyMessage):
Expand Down
21 changes: 9 additions & 12 deletions Sources/ErrorKit/BuiltInErrors/FileError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,22 +128,19 @@ public enum FileError: Throwable, Catching {
public var userFriendlyMessage: String {
switch self {
case .fileNotFound(let fileName):
return String(
localized: "BuiltInErrors.FileError.fileNotFound",
defaultValue: "The file \(fileName) could not be located. Please verify the file path and try again.",
bundle: .module
return String.localized(
key: "BuiltInErrors.FileError.fileNotFound",
defaultValue: "The file \(fileName) could not be located. Please verify the file path and try again."
)
case .readFailed(let fileName):
return String(
localized: "BuiltInErrors.FileError.readError",
defaultValue: "An error occurred while attempting to read the file \(fileName). Please check file permissions and try again.",
bundle: .module
return String.localized(
key: "BuiltInErrors.FileError.readError",
defaultValue: "An error occurred while attempting to read the file \(fileName). Please check file permissions and try again."
)
case .writeFailed(let fileName):
return String(
localized: "BuiltInErrors.FileError.writeError",
defaultValue: "Unable to write to the file \(fileName). Ensure you have the necessary permissions and try again.",
bundle: .module
return String.localized(
key: "BuiltInErrors.FileError.writeError",
defaultValue: "Unable to write to the file \(fileName). Ensure you have the necessary permissions and try again."
)
case .generic(let userFriendlyMessage):
return userFriendlyMessage
Expand Down
41 changes: 20 additions & 21 deletions Sources/ErrorKit/BuiltInErrors/NetworkError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,39 +178,38 @@ public enum NetworkError: Throwable, Catching {
public var userFriendlyMessage: String {
switch self {
case .noInternet:
return String(
localized: "BuiltInErrors.NetworkError.noInternet",
defaultValue: "Unable to connect to the internet. Please check your network settings and try again.",
bundle: .module
return String.localized(
key: "BuiltInErrors.NetworkError.noInternet",
defaultValue: "Unable to connect to the internet. Please check your network settings and try again."
)
case .timeout:
return String(
localized: "BuiltInErrors.NetworkError.timeout",
defaultValue: "The network request took too long to complete. Please check your connection and try again.",
bundle: .module
return String.localized(
key: "BuiltInErrors.NetworkError.timeout",
defaultValue: "The network request took too long to complete. Please check your connection and try again."
)
case .badRequest(let code, let message):
return String(
localized: "BuiltInErrors.NetworkError.badRequest",
defaultValue: "There was an issue with the request (Code: \(code)). \(message). Please review and retry.",
bundle: .module
return String.localized(
key: "BuiltInErrors.NetworkError.badRequest",
defaultValue: "There was an issue with the request (Code: \(code)). \(message). Please review and retry."
)
case .serverError(let code, let message):
let defaultMessage = String(
localized: "BuiltInErrors.NetworkError.serverError",
defaultValue: "The server encountered an error (Code: \(code)). ",
bundle: .module
let defaultMessage = String.localized(
key: "BuiltInErrors.NetworkError.serverError",
defaultValue: "The server encountered an error (Code: \(code)). "
)

if let message = message {
return defaultMessage + message
} else {
return defaultMessage + "Please try again later."
return defaultMessage + String.localized(
key: "Common.Message.tryAgainLater",
defaultValue: "Please try again later."
)
}
case .decodingFailure:
return String(
localized: "BuiltInErrors.NetworkError.decodingFailure",
defaultValue: "Unable to process the server's response. Please try again or contact support if the issue persists.",
bundle: .module
return String.localized(
key: "BuiltInErrors.NetworkError.decodingFailure",
defaultValue: "Unable to process the server's response. Please try again or contact support if the issue persists."
)
case .generic(let userFriendlyMessage):
return userFriendlyMessage
Expand Down
14 changes: 6 additions & 8 deletions Sources/ErrorKit/BuiltInErrors/OperationError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,14 @@ public enum OperationError: Throwable, Catching {
public var userFriendlyMessage: String {
switch self {
case .dependencyFailed(let dependency):
return String(
localized: "BuiltInErrors.OperationError.dependencyFailed",
defaultValue: "The operation could not be started because a required component failed to initialize: \(dependency). Please restart the application or contact support.",
bundle: .module
return String.localized(
key: "BuiltInErrors.OperationError.dependencyFailed",
defaultValue: "The operation could not be started because a required component failed to initialize: \(dependency). Please restart the application or contact support."
)
case .canceled:
return String(
localized: "BuiltInErrors.OperationError.canceled",
defaultValue: "The operation was canceled at your request. You can retry the action if needed.",
bundle: .module
return String.localized(
key: "BuiltInErrors.OperationError.canceled",
defaultValue: "The operation was canceled at your request. You can retry the action if needed."
)
case .generic(let userFriendlyMessage):
return userFriendlyMessage
Expand Down
14 changes: 6 additions & 8 deletions Sources/ErrorKit/BuiltInErrors/ParsingError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,16 +108,14 @@ public enum ParsingError: Throwable, Catching {
public var userFriendlyMessage: String {
switch self {
case .invalidInput(let input):
return String(
localized: "BuiltInErrors.ParsingError.invalidInput",
defaultValue: "The provided input could not be processed correctly: \(input). Please review the input and ensure it matches the expected format.",
bundle: .module
return String.localized(
key: "BuiltInErrors.ParsingError.invalidInput",
defaultValue: "The provided input could not be processed correctly: \(input). Please review the input and ensure it matches the expected format."
)
case .missingField(let field):
return String(
localized: "BuiltInErrors.ParsingError.missingField",
defaultValue: "The required information is incomplete. The \(field) field is missing and must be provided to continue.",
bundle: .module
return String.localized(
key: "BuiltInErrors.ParsingError.missingField",
defaultValue: "The required information is incomplete. The \(field) field is missing and must be provided to continue."
)
case .generic(let userFriendlyMessage):
return userFriendlyMessage
Expand Down
21 changes: 9 additions & 12 deletions Sources/ErrorKit/BuiltInErrors/PermissionError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,22 +129,19 @@ public enum PermissionError: Throwable, Catching {
public var userFriendlyMessage: String {
switch self {
case .denied(let permission):
return String(
localized: "BuiltInErrors.PermissionError.denied",
defaultValue: "Access to \(permission) was declined. To use this feature, please enable the permission in your device Settings.",
bundle: .module
return String.localized(
key: "BuiltInErrors.PermissionError.denied",
defaultValue: "Access to \(permission) was declined. To use this feature, please enable the permission in your device Settings."
)
case .restricted(let permission):
return String(
localized: "BuiltInErrors.PermissionError.restricted",
defaultValue: "Access to \(permission) is currently restricted. This may be due to system settings or parental controls.",
bundle: .module
return String.localized(
key: "BuiltInErrors.PermissionError.restricted",
defaultValue: "Access to \(permission) is currently restricted. This may be due to system settings or parental controls."
)
case .notDetermined(let permission):
return String(
localized: "BuiltInErrors.PermissionError.notDetermined",
defaultValue: "Permission for \(permission) has not been confirmed. Please review and grant access in your device Settings.",
bundle: .module
return String.localized(
key: "BuiltInErrors.PermissionError.notDetermined",
defaultValue: "Permission for \(permission) has not been confirmed. Please review and grant access in your device Settings."
)
case .generic(let userFriendlyMessage):
return userFriendlyMessage
Expand Down
Loading