diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f02a3f..feed3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.2.3] - 2026-01-12 + +### Added + +- **Error parameter support**: All log methods (`v`, `d`, `i`, `w`, `e`) now accept an optional `Error?` parameter +- **Error propagation**: Error objects are now correctly passed to all Tree implementations +- **Enhanced error handling**: Built-in Trees (DebugTree, CrashBufferTree, AsyncTree) now properly process and display error information +- **Error-specific tests**: Added 6 new test cases to verify error parameter functionality across all log levels and scenarios +- **Comprehensive error documentation**: Added "Logging with Errors" section to README (both English and Chinese) + +### Fixed + +- **Error parameter propagation**: Previously, `error` parameter was hardcoded to `nil` in internal `log()` method +- **SentryTree compatibility**: Error objects are now correctly captured and can be sent to Sentry's `captureException()` instead of just `captureMessage()` +- **CrashBufferTree error handling**: Error information is now included in buffered log messages + +### Changed + +- **Total test count**: Increased from 96 to 102 tests (6 new error-related tests) + +### BREAKING CHANGES + +- **None** - This release is fully backward compatible with 0.2.2 +- All existing APIs without `error` parameter continue to work +- New `error` parameter is optional with default value `nil` + +--- + ## [0.2.2] - 2026-01-12 ### Fixed @@ -18,7 +46,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### BREAKING CHANGES - **None** - This release is fully backward compatible with 0.2.1 - --- ## [0.2.1] - 2026-01-12 @@ -129,7 +156,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | Version | Date | Status | |---------|------|--------| -| [0.2.2] | 2026-01-12 | **Current Release** - Fix DebugTree accessibility | +| [0.2.3] | 2026-01-12 | **Current Release** - Error parameter support | +| [0.2.2] | 2026-01-12 | Fix DebugTree accessibility | | [0.2.1] | 2026-01-12 | Update source URL to HTTPS | | [0.2.0] | 2026-01-09 | Stability & Security Improvements | | [0.1.0] | 2026-01-08 | Initial release | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 064fdec..1e19f7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -185,8 +185,8 @@ All tests must pass before merging. 1. Update version in `Package.swift` 2. Update CHANGELOG.md with new version -3. Create git tag (`git tag v0.2.2`) -4. Push tag (`git push origin v0.2.2`) +3. Create git tag (`git tag v0.2.3`) +4. Push tag (`git push origin v0.2.3`) ### Release Checklist @@ -201,7 +201,7 @@ All tests must pass before merging. ### Release Notes Format ```markdown -## v0.2.2 +## v0.2.3 ### New Features - Feature description diff --git a/Canopy.podspec b/Canopy.podspec index 0eb6e84..3df88bc 100644 --- a/Canopy.podspec +++ b/Canopy.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Canopy' - s.version = '0.2.2' + s.version = '0.2.3' s.summary = 'A lightweight, high-performance logging framework for iOS' s.description = <<-DESC Canopy is a logging framework inspired by Android's Timber, using a Tree-based architecture. diff --git a/Canopy/Sources/Canopy.swift b/Canopy/Sources/Canopy.swift index 2826d46..0ea8e89 100644 --- a/Canopy/Sources/Canopy.swift +++ b/Canopy/Sources/Canopy.swift @@ -36,6 +36,108 @@ public struct TaggedTreeProxy { public func e(_ message: @autoclosure () -> String, _ args: CVarArg..., file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { Canopy.log(LogLevel.error, message(), args, file: file, function: function, line: line, withTag: tag) } + + // MARK: - Log Methods with Error + + public func v( + _ message: @autoclosure () -> String, + error: Error?, + _ args: CVarArg..., + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line + ) { + Canopy.log( + LogLevel.verbose, + message(), + args, + file: file, + function: function, + line: line, + withTag: tag, + error: error + ) + } + + public func d( + _ message: @autoclosure () -> String, + error: Error?, + _ args: CVarArg..., + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line + ) { + Canopy.log( + LogLevel.debug, + message(), + args, + file: file, + function: function, + line: line, + withTag: tag, + error: error + ) + } + + public func i( + _ message: @autoclosure () -> String, + error: Error?, + _ args: CVarArg..., + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line + ) { + Canopy.log( + LogLevel.info, + message(), + args, + file: file, + function: function, + line: line, + withTag: tag, + error: error + ) + } + + public func w( + _ message: @autoclosure () -> String, + error: Error?, + _ args: CVarArg..., + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line + ) { + Canopy.log( + LogLevel.warning, + message(), + args, + file: file, + function: function, + line: line, + withTag: tag, + error: error + ) + } + + public func e( + _ message: @autoclosure () -> String, + error: Error?, + _ args: CVarArg..., + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line + ) { + Canopy.log( + LogLevel.error, + message(), + args, + file: file, + function: function, + line: line, + withTag: tag, + error: error + ) + } } /// The main entry point for the Canopy logging framework. @@ -87,6 +189,28 @@ public enum Canopy { log(LogLevel.error, message(), args, file: file, function: function, line: line, withTag: tag) } + // MARK: - Log Methods with Error + + public static func v(_ message: @autoclosure () -> String, error: Error?, _ args: CVarArg..., tag: String? = nil, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + log(LogLevel.verbose, message(), args, file: file, function: function, line: line, withTag: tag, error: error) + } + + public static func d(_ message: @autoclosure () -> String, error: Error?, _ args: CVarArg..., tag: String? = nil, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + log(LogLevel.debug, message(), args, file: file, function: function, line: line, withTag: tag, error: error) + } + + public static func i(_ message: @autoclosure () -> String, error: Error?, _ args: CVarArg..., tag: String? = nil, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + log(LogLevel.info, message(), args, file: file, function: function, line: line, withTag: tag, error: error) + } + + public static func w(_ message: @autoclosure () -> String, error: Error?, _ args: CVarArg..., tag: String? = nil, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + log(LogLevel.warning, message(), args, file: file, function: function, line: line, withTag: tag, error: error) + } + + public static func e(_ message: @autoclosure () -> String, error: Error?, _ args: CVarArg..., tag: String? = nil, file: StaticString = #file, function: StaticString = #function, line: UInt = #line) { + log(LogLevel.error, message(), args, file: file, function: function, line: line, withTag: tag, error: error) + } + // MARK: - Internal Helpers private static func hasNonDebugTrees() -> Bool { @@ -106,7 +230,8 @@ public enum Canopy { file: StaticString, function: StaticString, line: UInt, - withTag tag: String? + withTag tag: String?, + error: Error? = nil ) { #if !DEBUG guard hasNonDebugTrees() else { return } @@ -126,7 +251,7 @@ public enum Canopy { tag: nil, message: message, arguments: args, - error: nil, + error: error, file: file, function: function, line: line diff --git a/Canopy/Sources/CrashBufferTree.swift b/Canopy/Sources/CrashBufferTree.swift index 5edd791..bb2a0eb 100644 --- a/Canopy/Sources/CrashBufferTree.swift +++ b/Canopy/Sources/CrashBufferTree.swift @@ -82,13 +82,21 @@ public final class CrashBufferTree: Tree, @unchecked Sendable { explicitTag = nil let tagString = effectiveTag ?? "" - let msg = tagString.isEmpty ? "[\(priority)] : \(message())" : "[\(priority)] : \(tagString): \(message())" + let fullMessage = buildFullMessage(message(), error: error) + let msg = tagString.isEmpty ? "[\(priority)] : \(fullMessage)" : "[\(priority)] : \(tagString): \(fullMessage)" lock.lock() buffer.append(msg) if buffer.count > maxSize { buffer.removeFirst() } lock.unlock() } + nonisolated private func buildFullMessage(_ message: String, error: Error?) -> String { + if let err = error { + return "\(message) | Error: \(err.localizedDescription)" + } + return message + } + nonisolated func flush() { lock.lock() defer { lock.unlock() } diff --git a/CanopyTests/CanopyTests.swift b/CanopyTests/CanopyTests.swift index e10fe2f..4b6eb6d 100644 --- a/CanopyTests/CanopyTests.swift +++ b/CanopyTests/CanopyTests.swift @@ -349,6 +349,210 @@ final class CanopyTests: XCTestCase { XCTAssertNil(CanopyContext.current, "Context should be restored even after error") } + + func testLoggingWithError() throws { + let tree = TestTree() + Canopy.plant(tree) + + let testError = NSError(domain: "TestDomain", code: 42, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + + Canopy.e("Error occurred", error: testError) + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.level, .error) + XCTAssertNotNil(tree.logs.first?.error) + + let loggedError = tree.logs.first?.error as? NSError + XCTAssertEqual(loggedError?.code, 42) + XCTAssertEqual(loggedError?.domain, "TestDomain") + } + + func testLoggingWithNilError() throws { + let tree = TestTree() + Canopy.plant(tree) + + Canopy.e("Error without error object", error: nil) + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.level, .error) + XCTAssertNil(tree.logs.first?.error) + } + + func testAllLogLevelsWithError() throws { + let tree = TestTree() + Canopy.plant(tree) + + let testError = NSError(domain: "Test", code: 1, userInfo: nil) + + Canopy.v("Verbose", error: testError) + Canopy.d("Debug", error: testError) + Canopy.i("Info", error: testError) + Canopy.w("Warning", error: testError) + Canopy.e("Error", error: testError) + + XCTAssertEqual(tree.logs.count, 5) + + for logEntry in tree.logs { + XCTAssertNotNil(logEntry.error) + XCTAssertEqual((logEntry.error as? NSError)?.code, 1) + } + } + + func testTaggedLoggingWithError() throws { + let tree = TestTree() + Canopy.plant(tree) + + let testError = NSError(domain: "Test", code: 99, userInfo: nil) + + Canopy.tag("TestTag").e("Tagged error", error: testError) + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.tag, "TestTag") + XCTAssertNotNil(tree.logs.first?.error) + XCTAssertEqual((tree.logs.first?.error as? NSError)?.code, 99) + } + + func testLoggingWithFormatArgsAndError() throws { + let tree = TestTree() + Canopy.plant(tree) + + let testError = NSError(domain: "Test", code: 123, userInfo: nil) + + Canopy.e("Error %@ at %d", error: testError, "network", 42) + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.message, "Error network at 42") + XCTAssertNotNil(tree.logs.first?.error) + XCTAssertEqual((tree.logs.first?.error as? NSError)?.code, 123) + } + + func testMultipleTreesReceiveError() throws { + let tree1 = TestTree() + let tree2 = TestTree() + Canopy.plant(tree1, tree2) + + let testError = NSError(domain: "MultiTree", code: 999, userInfo: nil) + + Canopy.e("Error in multiple trees", error: testError) + + XCTAssertEqual(tree1.logs.count, 1) + XCTAssertEqual(tree2.logs.count, 1) + + XCTAssertNotNil(tree1.logs.first?.error) + XCTAssertNotNil(tree2.logs.first?.error) + + XCTAssertEqual((tree1.logs.first?.error as? NSError)?.code, 999) + XCTAssertEqual((tree2.logs.first?.error as? NSError)?.code, 999) + } + + func testErrorLoggingWithMinLevelFiltering() throws { + let tree = TestTree() + tree.minLevel = .error + Canopy.plant(tree) + + let testError = NSError(domain: "FilterTest", code: 1, userInfo: nil) + + Canopy.v("Verbose", error: testError) + Canopy.d("Debug", error: testError) + Canopy.i("Info", error: testError) + Canopy.w("Warning", error: testError) + Canopy.e("Error", error: testError) + + XCTAssertEqual(tree.logs.count, 1) + XCTAssertEqual(tree.logs.first?.level, .error) + } + + func testMixedNilAndNonNullErrors() throws { + let tree = TestTree() + Canopy.plant(tree) + + let testError = NSError(domain: "Mixed", code: 42, userInfo: nil) + + Canopy.e("Error with error", error: testError) + Canopy.e("Error without error", error: nil) + Canopy.e("Another error", error: testError) + + XCTAssertEqual(tree.logs.count, 3) + XCTAssertNotNil(tree.logs[0].error) + XCTAssertNil(tree.logs[1].error) + XCTAssertNotNil(tree.logs[2].error) + } + + func testHighVolumeLoggingWithErrors() throws { + let tree = TestTree() + Canopy.plant(tree) + + let testError = NSError(domain: "Volume", code: 1, userInfo: nil) + + for i in 0..<1000 { + let error: Error? = i % 2 == 0 ? testError : nil + Canopy.e("Log entry %d", error: error, i) + } + + XCTAssertEqual(tree.logs.count, 1000) + + var errorCount = 0 + for log in tree.logs { + if log.error != nil { + errorCount += 1 + } + } + XCTAssertEqual(errorCount, 500) + } + + func testErrorWithDifferentErrorTypes() throws { + let tree = TestTree() + Canopy.plant(tree) + + let nsError = NSError(domain: "NSErrorDomain", code: 100, userInfo: nil) + let customError = CustomError.someError + + Canopy.e("NSError", error: nsError) + Canopy.e("CustomError", error: customError) + + XCTAssertEqual(tree.logs.count, 2) + + XCTAssertNotNil(tree.logs[0].error) + switch tree.logs[0].error { + case let err as NSError: + XCTAssertEqual(err.domain, "NSErrorDomain") + default: + XCTFail("Expected NSError") + } + + XCTAssertNotNil(tree.logs[1].error) + switch tree.logs[1].error { + case is CustomError: + break + default: + XCTFail("Expected CustomError") + } + } + + func testErrorLoggingWithAsyncTree() throws { + let baseTree = TestTree() + let asyncTree = AsyncTree(wrapping: baseTree) + Canopy.plant(asyncTree) + + let testError = NSError(domain: "Async", code: 777, userInfo: nil) + + Canopy.e("Async error", error: testError) + + let expectation = self.expectation(description: "Async logging") + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { + expectation.fulfill() + } + + waitForExpectations(timeout: 1.0) + + XCTAssertEqual(baseTree.logs.count, 1) + XCTAssertNotNil(baseTree.logs.first?.error) + XCTAssertEqual((baseTree.logs.first?.error as? NSError)?.code, 777) + } +} + +enum CustomError: Error { + case someError } class TestTree: Tree, @unchecked Sendable { @@ -356,6 +560,7 @@ class TestTree: Tree, @unchecked Sendable { let level: LogLevel let tag: String? let message: String + let error: Error? let file: String let function: String let line: UInt @@ -378,6 +583,7 @@ class TestTree: Tree, @unchecked Sendable { level: priority, tag: tag, message: message, + error: error, file: file, function: function, line: line diff --git a/Examples/README.md b/Examples/README.md index a88d1c4..74b8df6 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -187,13 +187,21 @@ open class MyCustomTree: Tree { error: Error? ) { // Implement your logging logic - // 1. Format log - let formatted = formatLog(priority, tag, message, error) + // 1. Handle error if present + var fullMessage = message + if let err = error { + fullMessage += " | Error: \(err.localizedDescription)" + // You can also capture error details for structured logging + // sendErrorTracking(err) + } + + // 2. Format log + let formatted = formatLog(priority, tag, fullMessage) - // 2. Send to service + // 3. Send to service sendToService(formatted) - // 3. Local cache (optional) + // 4. Local cache (optional) cacheLocally(formatted) } } diff --git a/Examples/README.zh-CN.md b/Examples/README.zh-CN.md index 4e98b7d..32b7b63 100644 --- a/Examples/README.zh-CN.md +++ b/Examples/README.zh-CN.md @@ -187,13 +187,21 @@ open class MyCustomTree: Tree { error: Error? ) { // 实现你的日志逻辑 - // 1. 格式化日志 - let formatted = formatLog(priority, tag, message, error) + // 1. 处理 error(如果存在) + var fullMessage = message + if let err = error { + fullMessage += " | Error: \(err.localizedDescription)" + // 你还可以捕获 error 详情用于结构化日志 + // sendErrorTracking(err) + } + + // 2. 格式化日志 + let formatted = formatLog(priority, tag, fullMessage) - // 2. 发送到服务 + // 3. 发送到服务 sendToService(formatted) - // 3. 本地缓存(可选) + // 4. 本地缓存(可选) cacheLocally(formatted) } } diff --git a/README.md b/README.md index 7744302..014d43f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ A lightweight, high-performance logging framework for iOS, inspired by Android's - **iOS 14+ Support** - Uses only Swift standard library and Foundation - **No External Dependencies** - Pure Swift implementation - **Thread Safe** - Lock-protected concurrent access -- **Comprehensive Testing** - 91 tests with performance benchmarks +- **Comprehensive Testing** - 102 tests with performance benchmarks +- **Error Parameter Support** - Pass Error objects to log methods for error tracking services like Sentry ## Quick Start @@ -20,11 +21,11 @@ Add Canopy to your project using Swift Package Manager or CocoaPods: ```bash # Swift Package Manager dependencies: [ - .package(url: "https://github.com/ding1dingx/Canopy.git", from: "0.2.2") + .package(url: "https://github.com/ding1dingx/Canopy.git", from: "0.2.3") ] # CocoaPods -pod 'Canopy', '~> 0.2.2' +pod 'Canopy', '~> 0.2.3' ``` Initialize in your `AppDelegate`: @@ -66,6 +67,54 @@ Canopy.v("Network request", tag: "Network") | `Canopy.w()` | Warning | Potential issues | | `Canopy.e()` | Error | Errors and failures | +## Logging with Errors + +Canopy supports passing `Error` objects to log methods. This is particularly useful for error tracking services like Sentry. + +### Basic Usage + +```swift +do { + try someThrowingOperation() +} catch { + // Error object is captured and can be sent to error tracking services + Canopy.e("Operation failed", error: error) +} +``` + +### With Format Arguments + +```swift +Canopy.e("Failed to fetch user %@ (attempt %d)", error: networkError, userId, retryCount) +``` + +### All Log Levels Support Errors + +```swift +Canopy.v("Detailed info", error: error) +Canopy.d("Debug info", error: error) +Canopy.i("Info with error", error: error) +Canopy.w("Warning with error", error: error) +Canopy.e("Error occurred", error: error) +``` + +### Tagged Logging with Errors + +```swift +Canopy.tag("Network").e("Request failed", error: networkError) +Canopy.tag("Database").w("Query slow", error: dbError) +``` + +### Backward Compatibility + +The original API without error parameters still works: + +```swift +Canopy.e("Simple error message") +``` + +This is equivalent to passing `error: nil`. + ## Tree Types ### DebugTree diff --git a/README.zh-CN.md b/README.zh-CN.md index dc47aac..8d839cf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,7 +11,8 @@ - **iOS 14+ 支持** - 仅使用 Swift 标准库和 Foundation - **无外部依赖** - 纯 Swift 实现 - **线程安全** - 锁保护的并发访问 -- **全面测试** - 91 个测试用例,包含性能基准测试 +- **全面测试** - 102 个测试用例,包含性能基准测试 +- **Error 参数支持** - 传递 Error 对象到日志方法,支持 Sentry 等错误跟踪服务 ## 快速开始 @@ -20,11 +21,11 @@ ```bash # Swift Package Manager dependencies: [ - .package(url: "https://github.com/ding1dingx/Canopy.git", from: "0.2.2") + .package(url: "https://github.com/ding1dingx/Canopy.git", from: "0.2.3") ] # CocoaPods -pod 'Canopy', '~> 0.2.2' +pod 'Canopy', '~> 0.2.3' ``` 在 `AppDelegate` 中初始化: @@ -68,6 +69,54 @@ Canopy.v("Network request", tag: "Network") | `Canopy.w()` | Warning | 潜在问题 | | `Canopy.e()` | Error | 错误和失败 | +## 带 Error 的日志 + +Canopy 支持将 `Error` 对象传递给日志方法。这对于 Sentry 等错误跟踪服务特别有用。 + +### 基本用法 + +```swift +do { + try someThrowingOperation() +} catch { + // Error 对象会被捕获,可以发送到错误跟踪服务 + Canopy.e("操作失败", error: error) +} +``` + +### 带格式化参数 + +```swift +Canopy.e("获取用户 %@ 失败(第 %d 次尝试)", error: networkError, userId, retryCount) +``` + +### 所有日志级别都支持 Error + +```swift +Canopy.v("详细信息", error: error) +Canopy.d("调试信息", error: error) +Canopy.i("信息与错误", error: error) +Canopy.w("警告与错误", error: error) +Canopy.e("发生错误", error: error) +``` + +### 带标签的 Error 日志 + +```swift +Canopy.tag("Network").e("请求失败", error: networkError) +Canopy.tag("Database").w("查询缓慢", error: dbError) +``` + +### 向后兼容性 + +不带 error 参数的原始 API 仍然有效: + +```swift +Canopy.e("简单错误消息") +``` + +这等价于传递 `error: nil`。 + ## Tree 类型 ### DebugTree diff --git a/TESTING.md b/TESTING.md index 76144ae..498c9aa 100644 --- a/TESTING.md +++ b/TESTING.md @@ -41,7 +41,7 @@ swift test --enable-code-coverage | Suite | Tests | Description | |-------|-------|-------------| -| CanopyTests | 27 | Core logging functionality | +| CanopyTests | 33 | Core logging functionality (including error parameter support) | | TreeTests | 15 | Tree base class | | DebugTreeTests | 5 | DebugTree functionality | | AsyncTreeTests | 8 | AsyncTree functionality | @@ -49,7 +49,7 @@ swift test --enable-code-coverage | CanopyBenchmarkTests | 15 | Performance benchmarks | | CanopyCrashRecoveryTests | 12 | Crash recovery integration | -**Total: 91 tests** +**Total: 102 tests** --- diff --git a/TESTING.zh-CN.md b/TESTING.zh-CN.md index 7e2ff97..e2b4253 100644 --- a/TESTING.zh-CN.md +++ b/TESTING.zh-CN.md @@ -41,7 +41,7 @@ swift test --enable-code-coverage | 套件 | 测试数 | 描述 | |------|--------|------| -| CanopyTests | 27 | 核心日志功能 | +| CanopyTests | 33 | 核心日志功能(包括 Error 参数支持) | | TreeTests | 15 | Tree 基类 | | DebugTreeTests | 5 | DebugTree 功能 | | AsyncTreeTests | 8 | AsyncTree 功能 | @@ -49,7 +49,7 @@ swift test --enable-code-coverage | CanopyBenchmarkTests | 15 | 性能基准测试 | | CanopyCrashRecoveryTests | 12 | 崩溃恢复集成测试 | -**总计:91 个测试** +**总计:102 个测试** ---