Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9530cf7
[#16044] Crashlytics Error Identity for Non-Fatals
yakovmanshin Apr 6, 2026
eae2646
`ErrorInspectorTests` Fix for CocoaPods
yakovmanshin Apr 7, 2026
cf013e7
Updated Identification of `NSError`s
yakovmanshin Apr 7, 2026
ce52129
`nullability.NullPassedToNonnull` Warning Fix
yakovmanshin Apr 7, 2026
47c07a0
Syntax Updates (Gemini Review)
yakovmanshin Apr 7, 2026
fe642df
Merge branch 'firebase:main' into crashlytics-error-identity
yakovmanshin Apr 8, 2026
17491ed
Merge branch 'main' into crashlytics-error-identity
yakovmanshin Apr 13, 2026
5865c9a
Clarified Documentation
yakovmanshin Apr 13, 2026
0b2d894
Extended Tests
yakovmanshin Apr 13, 2026
ed458f3
Only Report Swift Errors
yakovmanshin Apr 14, 2026
838b0d9
Code-Review Updates
yakovmanshin Apr 15, 2026
53c3711
Merge branch 'main' into crashlytics-error-identity
yakovmanshin Apr 28, 2026
de9f094
Merge branch 'main' into crashlytics-error-identity
yakovmanshin May 20, 2026
8fdb1d9
Merge branch 'main' into crashlytics-error-identity
yakovmanshin Jun 26, 2026
d023bdd
Merge branch 'main' into crashlytics-error-identity
yakovmanshin Jul 18, 2026
a8000e7
Merge branch 'main' into crashlytics-error-identity
yakovmanshin Jul 29, 2026
2beb054
Merge branch 'main' into crashlytics-error-identity
yakovmanshin Aug 28, 2026
d87ac53
Merge branch 'main' into crashlytics-error-identity
yakovmanshin Sep 9, 2026
e8978e0
Merge branch 'main' into crashlytics-error-identity
yakovmanshin Oct 7, 2026
a57a855
Merge branch 'main' into crashlytics-error-identity
yakovmanshin Oct 23, 2026
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
6 changes: 5 additions & 1 deletion Crashlytics/Crashlytics/FIRCrashlytics.m
Original file line number Diff line number Diff line change
Expand Up @@ -432,8 +432,12 @@ - (void)recordError:(NSError *)error userInfo:(NSDictionary<NSString *, id> *)us

NSString *rolloutsInfoJSON = [_remoteConfigManager getRolloutAssignmentsEncodedJsonString];

NSMutableDictionary *errorInfo = [userInfo mutableCopy] ?: [NSMutableDictionary dictionary];
NSString *swiftErrorIdentity = [FIRCLSErrorInspector getIdentityDescriptionForError:error];
if (swiftErrorIdentity) errorInfo[@"swift-error"] = swiftErrorIdentity;

FIRCLSNonFatalError *nonFatalError = [[FIRCLSNonFatalError alloc] initWithError:error
userInfo:userInfo
userInfo:errorInfo
rolloutsInfoJSON:rolloutsInfoJSON];

[self waitForContextInit:@"recordError"
Expand Down
73 changes: 73 additions & 0 deletions Crashlytics/Crashlytics/SwiftUtilities/ErrorInspector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// 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:
/// * **True `NSError`s** and subclasses: `nil`
/// (since we only describe the identity of errors declared as Swift types).
/// * **Errors conforming to `CustomNSError`**: error domain + error code
/// from protocol conformance (e.g. `CustomErrorDomain.123`).
/// * 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`).
@objc(getIdentityDescriptionForError:)
public static func identityDescription(for error: any Error) -> String? {
let nsError = error as NSError
// Swift errors bridged to `NSError` have the `__SwiftNativeNSError` underlying type:
guard NSStringFromClass(type(of: nsError)).contains("SwiftNative") else {
Comment thread
yakovmanshin marked this conversation as resolved.
// This is a true `NSError` (or its subclass), not a Swift error bridged to one.
// We only report the identity of errors declared as Swift types.
// See https://github.com/firebase/firebase-ios-sdk/pull/16045
return nil
}

// Always prioritize the custom domain and code if they’re available:
if let customNSError = error as? CustomNSError {
return "\(type(of: customNSError).errorDomain).\(customNSError.errorCode)"
}

// 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.
// `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.
Comment thread
yakovmanshin marked this conversation as resolved.
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)"
}
}
}
157 changes: 157 additions & 0 deletions Crashlytics/UnitTestsSwift/ErrorInspectorTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// 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"),
(.error5(1, "2", true), "TestErrorEnum.error5"),
] 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 == nil)
}

@Test func nsErrorSubclass() {
let error = TestNSError(
domain: "TEST_NSErrorSubclass",
code: 456,
userInfo: [NSLocalizedDescriptionKey: "TEST_LocDesc"]
)

let description = ErrorInspector.identityDescription(for: error)

#expect(description == nil)
}

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

// Error details from the `CustomNSError` conformance are always prioritized.
// Note that `NSError` doesn’t conform to `CustomNSError` out of the box.
@Test func nsErrorSubclassWithCustomNSErrorConformance() {
let error = TestNSErrorWithCustomNSError(
domain: "TEST_ErrorDomainFromInit",
code: 123,
userInfo: [NSLocalizedDescriptionKey: "TEST_LocDesc"]
)

let description = ErrorInspector.identityDescription(for: error)

#expect(description == nil)
}
}

enum TestErrorEnum: Error {
case error1
case error2(Int)
case error3(String)
case error4(Any)
case error5(Int, String, Bool)
}

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 TestNSError: NSError, @unchecked Sendable {}

// Having `SwiftNative` in the name is an edge case:
class TestSwiftNativeError: NSError, @unchecked Sendable {}

class TestNSErrorWithCustomNSError: NSError, CustomNSError, @unchecked Sendable {
static var errorDomain: String { "TEST_ErrorDomainFromProtocol" }
var errorCode: Int { 321 }
}
7 changes: 6 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,11 @@ func packageTargets() -> [Target] {
.target(
name: "FirebaseCrashlyticsSwift",
dependencies: ["FirebaseRemoteConfigInterop"],
path: "Crashlytics/Crashlytics/Rollouts"
path: "Crashlytics/Crashlytics",
sources: [
"Rollouts",
"SwiftUtilities",
]
),
.testTarget(
name: "FirebaseCrashlyticsSwiftUnit",
Expand Down Expand Up @@ -1416,6 +1420,7 @@ func firebaseCrashlyticsTarget() -> Target {
"CrashlyticsInputFiles.xcfilelist",
"third_party/libunwind/LICENSE",
"Crashlytics/Rollouts",
"Crashlytics/SwiftUtilities",
],
sources: [
"Crashlytics",
Expand Down