From fd6a31ce3940224432d2ece19de8c052bec3c3d2 Mon Sep 17 00:00:00 2001 From: Philipp Hofmann Date: Wed, 3 May 2023 12:15:39 +0200 Subject: [PATCH] feat: Swift error names (#2960) Call into Swift to get the error description for Swift errors to get meaningful error names instead of only the error enum code. Fixes GH-2958 --- CHANGELOG.md | 21 +++++ .../iOS-Swift/Tools/RandomErrors.swift | 17 ---- Sources/Sentry/SentryClient.m | 14 ++++ Sources/Swift/SwiftDescriptor.swift | 5 ++ Tests/SentryTests/SentryClientTests.swift | 84 ++++++++++++++++++- Tests/SentryTests/SwiftDescriptorTests.swift | 41 +++++++++ 6 files changed, 162 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75bcc0447db..d79c6771529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## Unreleased +- Swift Error Names (#2960) + +```Swift +enum LoginError: Error { + case wrongUser(id: String) + case wrongPassword +} + +SentrySDK.capture(error: LoginError.wrongUser("12345678")) +``` + +For the Swift error above Sentry displays: + +| sentry-cocoa SDK | Title | Description | +| ----------- | ----------- | ----------- | +| Since 8.7.0 | `LoginError` | `wrongUser(id: "12345678") (Code: 1)` | +| Before 8.7.0 | `LoginError` | `Code: 1` | + +[Customized error descriptions](https://docs.sentry.io/platforms/apple/usage/#customizing-error-descriptions) have precedence over this feature. +This change has no impact on grouping of the issues in Sentry. + ### Fixes - Propagate span when copying scope (#2952) diff --git a/Samples/iOS-Swift/iOS-Swift/Tools/RandomErrors.swift b/Samples/iOS-Swift/iOS-Swift/Tools/RandomErrors.swift index 80e97000914..7321b615a81 100644 --- a/Samples/iOS-Swift/iOS-Swift/Tools/RandomErrors.swift +++ b/Samples/iOS-Swift/iOS-Swift/Tools/RandomErrors.swift @@ -6,23 +6,6 @@ enum SampleError: Error { case awesomeCentaur } -extension SampleError: CustomNSError { - var errorUserInfo: [String: Any] { - func getDebugDescription() -> String { - switch self { - case SampleError.bestDeveloper: - return "bestDeveloper" - case .happyCustomer: - return "happyCustomer" - case .awesomeCentaur: - return "awesomeCentaur" - } - } - - return [NSDebugDescriptionErrorKey: getDebugDescription()] - } -} - class RandomErrorGenerator { static func generate() throws { diff --git a/Sources/Sentry/SentryClient.m b/Sources/Sentry/SentryClient.m index d4914541210..b0954b6a198 100644 --- a/Sources/Sentry/SentryClient.m +++ b/Sources/Sentry/SentryClient.m @@ -35,6 +35,7 @@ #import "SentrySDK+Private.h" #import "SentryScope+Private.h" #import "SentryStacktraceBuilder.h" +#import "SentrySwift.h" #import "SentryThreadInspector.h" #import "SentryTraceContext.h" #import "SentryTracer.h" @@ -248,9 +249,22 @@ - (SentryEvent *)buildErrorEvent:(NSError *)error // If the error has a debug description, use that. NSString *customExceptionValue = [[error userInfo] valueForKey:NSDebugDescriptionErrorKey]; + + NSString *swiftErrorDescription = nil; + // SwiftNativeNSError is the subclass of NSError used to represent bridged native Swift errors, + // see + // https://github.com/apple/swift/blob/067e4ec50147728f2cb990dbc7617d66692c1554/stdlib/public/runtime/ErrorObject.mm#L63-L73 + NSString *errorClass = NSStringFromClass(error.class); + if ([errorClass containsString:@"SwiftNativeNSError"]) { + swiftErrorDescription = [SwiftDescriptor getSwiftErrorDescription:error]; + } + if (customExceptionValue != nil) { exceptionValue = [NSString stringWithFormat:@"%@ (Code: %ld)", customExceptionValue, (long)error.code]; + } else if (swiftErrorDescription != nil) { + exceptionValue = + [NSString stringWithFormat:@"%@ (Code: %ld)", swiftErrorDescription, (long)error.code]; } else { exceptionValue = [NSString stringWithFormat:@"Code: %ld", (long)error.code]; } diff --git a/Sources/Swift/SwiftDescriptor.swift b/Sources/Swift/SwiftDescriptor.swift index 9d330c92807..32b440bf8fb 100644 --- a/Sources/Swift/SwiftDescriptor.swift +++ b/Sources/Swift/SwiftDescriptor.swift @@ -8,4 +8,9 @@ public class SwiftDescriptor: NSObject { return String(describing: type(of: object)) } + @objc + public static func getSwiftErrorDescription(_ error: Error) -> String? { + return String(describing: error) + } + } diff --git a/Tests/SentryTests/SentryClientTests.swift b/Tests/SentryTests/SentryClientTests.swift index a07f052e43f..bbfcfadb3bc 100644 --- a/Tests/SentryTests/SentryClientTests.swift +++ b/Tests/SentryTests/SentryClientTests.swift @@ -415,7 +415,7 @@ class SentryClientTest: XCTestCase { eventId.assertIsNotEmpty() let error = TestError.invalidTest as NSError assertLastSentEvent { actual in - assertValidErrorEvent(actual, error) + assertValidErrorEvent(actual, error, exceptionValue: "invalidTest (Code: 0)") } } @@ -456,6 +456,62 @@ class SentryClientTest: XCTestCase { } } } + + func testCaptureSwiftError_UsesSwiftStringDescription() { + let eventId = fixture.getSut().capture(error: SentryClientError.someError) + + eventId.assertIsNotEmpty() + assertLastSentEvent { actual in + do { + let exceptions = try XCTUnwrap(actual.exceptions) + XCTAssertEqual("someError (Code: 1)", try XCTUnwrap(exceptions.first).value) + } catch { + XCTFail("Exception expected but was nil") + } + } + } + + func testCaptureSwiftErrorStruct_UsesSwiftStringDescription() { + let eventId = fixture.getSut().capture(error: XMLParsingError(line: 10, column: 12, kind: .internalError)) + + eventId.assertIsNotEmpty() + assertLastSentEvent { actual in + do { + let exceptions = try XCTUnwrap(actual.exceptions) + XCTAssertEqual("XMLParsingError(line: 10, column: 12, kind: SentryTests.XMLParsingError.ErrorKind.internalError) (Code: 1)", try XCTUnwrap(exceptions.first).value) + } catch { + XCTFail("Exception expected but was nil") + } + } + } + + func testCaptureSwiftErrorWithData_UsesSwiftStringDescription() { + let eventId = fixture.getSut().capture(error: SentryClientError.invalidInput("hello")) + + eventId.assertIsNotEmpty() + assertLastSentEvent { actual in + do { + let exceptions = try XCTUnwrap(actual.exceptions) + XCTAssertEqual("invalidInput(\"hello\") (Code: 0)", try XCTUnwrap(exceptions.first).value) + } catch { + XCTFail("Exception expected but was nil") + } + } + } + + func testCaptureSwiftErrorWithDebugDescription_UsesDebugDescription() { + let eventId = fixture.getSut().capture(error: SentryClientErrorWithDebugDescription.someError) + + eventId.assertIsNotEmpty() + assertLastSentEvent { actual in + do { + let exceptions = try XCTUnwrap(actual.exceptions) + XCTAssertEqual("anotherError (Code: 0)", try XCTUnwrap(exceptions.first).value) + } catch { + XCTFail("Exception expected but was nil") + } + } + } func testCaptureErrorWithComplexUserInfo() { let url = URL(string: "https://github.com/getsentry")! @@ -1458,7 +1514,7 @@ class SentryClientTest: XCTestCase { } } - private func assertValidErrorEvent(_ event: Event, _ error: NSError) { + private func assertValidErrorEvent(_ event: Event, _ error: NSError, exceptionValue: String? = nil) { XCTAssertEqual(SentryLevel.error, event.level) XCTAssertEqual(error, event.error as NSError?) @@ -1469,7 +1525,7 @@ class SentryClientTest: XCTestCase { let exception = exceptions[0] XCTAssertEqual(error.domain, exception.type) - XCTAssertEqual("Code: \(error.code)", exception.value) + XCTAssertEqual(exceptionValue ?? "Code: \(error.code)", exception.value) XCTAssertNil(exception.threadId) XCTAssertNil(exception.stacktrace) @@ -1559,4 +1615,26 @@ class SentryClientTest: XCTestCase { } +enum SentryClientError: Error { + case someError + case invalidInput(String) +} + +enum SentryClientErrorWithDebugDescription: Error { + case someError +} + +extension SentryClientErrorWithDebugDescription: CustomNSError { + var errorUserInfo: [String: Any] { + func getDebugDescription() -> String { + switch self { + case .someError: + return "anotherError" + } + } + + return [NSDebugDescriptionErrorKey: getDebugDescription()] + } +} + // swiftlint:enable file_length diff --git a/Tests/SentryTests/SwiftDescriptorTests.swift b/Tests/SentryTests/SwiftDescriptorTests.swift index bcf77661e06..244db6a95bf 100644 --- a/Tests/SentryTests/SwiftDescriptorTests.swift +++ b/Tests/SentryTests/SwiftDescriptorTests.swift @@ -28,7 +28,48 @@ class SwiftDescriptorTests: XCTestCase { XCTAssertEqual(name, "InnerClass") } + func testGetSwiftErrorDescription_EnumValue() { + let actual = SwiftDescriptor.getSwiftErrorDescription(LoginError.wrongPassword) + XCTAssertEqual("wrongPassword", actual) + } + + func testGetSwiftErrorDescription_EnumValueWithData() { + let actual = SwiftDescriptor.getSwiftErrorDescription(LoginError.wrongUser(name: "Max")) + XCTAssertEqual("wrongUser(name: \"Max\")", actual) + } + + func testGetSwiftErrorDescription_StructWithData() { + let actual = SwiftDescriptor.getSwiftErrorDescription(XMLParsingError(line: 10, column: 12, kind: .internalError)) + XCTAssertEqual("XMLParsingError(line: 10, column: 12, kind: SentryTests.XMLParsingError.ErrorKind.internalError)", actual) + } + + func testGetSwiftErrorDescription_StructWithOneParam() { + let actual = SwiftDescriptor.getSwiftErrorDescription(StructWithOneParam(line: 10)) + XCTAssertEqual("StructWithOneParam(line: 10)", actual) + } + private func sanitize(_ name: AnyObject) -> String { return SwiftDescriptor.getObjectClassName(name) } } + +enum LoginError: Error { + case wrongUser(name: String) + case wrongPassword +} + +struct XMLParsingError: Error { + enum ErrorKind { + case invalidCharacter + case mismatchedTag + case internalError + } + + let line: Int + let column: Int + let kind: ErrorKind +} + +struct StructWithOneParam: Error { + let line: Int +}