-
Notifications
You must be signed in to change notification settings - Fork 1.8k
[#16044] Crashlytics Error Identity for Non-Fatals #16045
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
9530cf7
eae2646
cf013e7
ce52129
47c07a0
fe642df
17491ed
5865c9a
0b2d894
ed458f3
838b0d9
53c3711
de9f094
8fdb1d9
d023bdd
a8000e7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| // Copyright 2026 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| import Foundation | ||
|
|
||
| @objc(FIRCLSErrorInspector) | ||
| public final class ErrorInspector: NSObject { | ||
| override private init() { | ||
| super.init() | ||
| } | ||
|
|
||
| /// Returns a short description of the error’s identity. | ||
| /// | ||
| /// The error identity is described as follows: | ||
| /// * Errors declared as **Swift enums**: type name + case name | ||
| /// (e.g. `SomeErrorEnum.errorCase`); associated values are discarded. | ||
| /// * Errors declared as **other Swift types**: type name + error code | ||
| /// (e.g. `SomeErrorStruct.1`); types that conform to `CustomNSError` | ||
| /// can provide their own error codes. | ||
| /// * **True `NSError`s** and subclasses: error domain + error code | ||
| /// (e.g. `FIRFirestoreErrorDomain.16`). | ||
| @objc(getIdentityDescriptionForError:) | ||
| public static func identityDescription(for error: any Error) -> String { | ||
| // Always prioritize the custom domain and code if they’re available: | ||
| if let customNSError = error as? CustomNSError { | ||
| return "\(type(of: customNSError).errorDomain).\(customNSError.errorCode)" | ||
| } | ||
|
|
||
| let nsError = error as NSError | ||
| // Swift errors bridged to `NSError` have the `__SwiftNativeNSError` underlying type: | ||
| guard NSStringFromClass(type(of: nsError)).contains("SwiftNative") else { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please move this to be the first check. |
||
| // This is a true `NSError` (or its subclass). | ||
| return "\(nsError.domain).\(nsError.code)" | ||
| } | ||
|
|
||
| // This is a Swift error bridged to `NSError`. | ||
| let typeLabel = String(describing: type(of: error)) | ||
| let mirror = Mirror(reflecting: error) | ||
|
|
||
| guard mirror.displayStyle == .enum else { | ||
| // This error is not declared as an enum. We fall back onto the `NSError` bridge. | ||
| // Types that implement `CustomNSError` can provide custom error codes; | ||
| // otherwise it’s always 1. | ||
| // `typeLabel` is used instead of `nsError.domain` for consistency | ||
| // (we only want the type name, not its parent types or module). | ||
| return "\(typeLabel).\(nsError.code)" | ||
| } | ||
|
|
||
| // The error is declared as an enum; we need to extract the case name. | ||
| if let caseLabel = mirror.children.first?.label { | ||
| // This enum case has an associated value. We don’t want it to affect the error’s identity: | ||
| // `someError(123)` and `someError(456)` should produce the same result `someError`. | ||
| // For enum cases with associated values, the bare case name is stored as the first child. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: if you can link to a reference for this and other private-ish behaviors, that would be good (if such a reference exists).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TBH they mostly come empirically from experiments in a playground and prototype apps, though there must be a precise explanation for each, of course. For the enum behavior specifically, which seems to be the most questionable thing here, the direct reference I found is this:
(At least that’s what I could figure from this C++ code. Looks plausible, but C++ is not my domain.) |
||
| return "\(typeLabel).\(caseLabel)" | ||
| } else { | ||
| // For enum cases with no associated values, the `Mirror.children` array is empty; | ||
| // the case name can be retrieved using the synthesized `CustomStringConvertible` conformance. | ||
| return "\(typeLabel).\(error)" | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| // Copyright 2026 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| #if SWIFT_PACKAGE | ||
| @testable import FirebaseCrashlyticsSwift | ||
| #else | ||
| @testable import FirebaseCrashlytics | ||
| #endif | ||
|
|
||
| import Foundation | ||
| import Testing | ||
|
|
||
| @Suite struct ErrorInspectorTests { | ||
| @Test(arguments: [ | ||
| (.error1, "TestErrorEnum.error1"), | ||
| (.error2(999), "TestErrorEnum.error2"), | ||
| (.error3("TEST_AssociatedValue"), "TestErrorEnum.error3"), | ||
| (.error4(TestErrorClass()), "TestErrorEnum.error4"), | ||
| ] as [(TestErrorEnum, String)]) func swiftEnumError(error: TestErrorEnum, | ||
| expectedDescription: String) { | ||
| let description = ErrorInspector.identityDescription(for: error) | ||
|
|
||
| #expect(description == expectedDescription) | ||
| } | ||
|
|
||
| @Test(arguments: [ | ||
| (.error1, "TEST_CustomErrorEnumDomain.987"), | ||
| (.error2(999), "TEST_CustomErrorEnumDomain.654"), | ||
| ] as [(TestCustomErrorEnum, String)]) func swiftCustomEnumError(error: TestCustomErrorEnum, | ||
| expectedDescription: String) { | ||
| let description = ErrorInspector.identityDescription(for: error) | ||
|
|
||
| #expect(description == expectedDescription) | ||
| } | ||
|
|
||
| @Test func swiftStructError() { | ||
| let description = ErrorInspector.identityDescription( | ||
| for: TestErrorStruct(value: 999) | ||
| ) | ||
|
|
||
| #expect(description == "TestErrorStruct.1") | ||
| } | ||
|
|
||
| @Test func swiftClassError() { | ||
| let description = ErrorInspector.identityDescription(for: TestErrorClass()) | ||
|
|
||
| #expect(description == "TEST_ErrorClassDomain.789") | ||
| } | ||
|
|
||
| @Test func nsError() { | ||
| let error = NSError( | ||
| domain: "TEST_NSError", | ||
| code: 123, | ||
| userInfo: [NSLocalizedDescriptionKey: "TEST_LocDesc"] | ||
| ) | ||
|
|
||
| let description = ErrorInspector.identityDescription(for: error) | ||
|
|
||
| #expect(description == "TEST_NSError.123") | ||
| } | ||
|
|
||
| @Test func nsErrorSubclass() { | ||
| let error = SomeNSError( | ||
| domain: "TEST_NSErrorSubclass", | ||
| code: 456, | ||
| userInfo: [NSLocalizedDescriptionKey: "TEST_LocDesc"] | ||
| ) | ||
|
|
||
| let description = ErrorInspector.identityDescription(for: error) | ||
|
|
||
| #expect(description == "TEST_NSErrorSubclass.456") | ||
| } | ||
|
|
||
| // This is a known edge case. If an error inherits from `NSError` | ||
| // and at the same time uses `SwiftNative` in its class name, | ||
| // it will be treated as if it were a Swift `Error` bridged to `NSError`, | ||
| // i.e. the error’s identity will be based on its class name, not `domain` | ||
| // (but `domain` and all other properties remain intact and available). | ||
| @Test func nsErrorSubclassWithSpecialName() { | ||
| let error = TestSwiftNativeError( | ||
| domain: "TEST_NSErrorSubclass", | ||
| code: 789, | ||
| userInfo: [NSLocalizedDescriptionKey: "TEST_LocDesc"] | ||
| ) | ||
|
|
||
| let description = ErrorInspector.identityDescription(for: error) | ||
|
|
||
| #expect(description == "TestSwiftNativeError.789") | ||
| } | ||
| } | ||
|
|
||
| enum TestErrorEnum: Error { | ||
| case error1 | ||
| case error2(Int) | ||
| case error3(String) | ||
| case error4(Any) | ||
| } | ||
|
|
||
| enum TestCustomErrorEnum: CustomNSError { | ||
| case error1 | ||
| case error2(Int) | ||
|
|
||
| static var errorDomain: String { "TEST_CustomErrorEnumDomain" } | ||
|
|
||
| var errorCode: Int { | ||
| switch self { | ||
| case .error1: 987 | ||
| case .error2: 654 | ||
| } | ||
| } | ||
| } | ||
|
|
||
| struct TestErrorStruct: Error { | ||
| let value: Int | ||
| } | ||
|
|
||
| class TestErrorClass: CustomNSError { | ||
| static var errorDomain: String { "TEST_ErrorClassDomain" } | ||
| var errorCode: Int { 789 } | ||
| } | ||
|
|
||
| class SomeNSError: NSError, @unchecked Sendable {} | ||
| class TestSwiftNativeError: NSError, @unchecked Sendable {} |
Uh oh!
There was an error while loading. Please reload this page.