Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
7 changes: 6 additions & 1 deletion Crashlytics/Crashlytics/FIRCrashlytics.m
Original file line number Diff line number Diff line change
Expand Up @@ -426,9 +426,14 @@ - (void)recordError:(NSError *)error {

- (void)recordError:(NSError *)error userInfo:(NSDictionary<NSString *, id> *)userInfo {
NSString *rolloutsInfoJSON = [_remoteConfigManager getRolloutAssignmentsEncodedJsonString];
Comment thread
yakovmanshin marked this conversation as resolved.
NSMutableDictionary *errorInfo = [userInfo mutableCopy] ?: [NSMutableDictionary dictionary];
if (error) {
errorInfo[@"crashlytics_error_identity"] =
[FIRCLSErrorInspector getIdentityDescriptionForError:error];
}
[self waitForContextInit:@"recordError"
callback:^{
FIRCLSUserLoggingRecordError(error, userInfo, rolloutsInfoJSON);
FIRCLSUserLoggingRecordError(error, errorInfo, rolloutsInfoJSON);
}];
}

Expand Down
72 changes: 72 additions & 0 deletions Crashlytics/Crashlytics/SwiftUtilities/ErrorInspector.swift
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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).

@yakovmanshin yakovmanshin Apr 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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:

  1. The runtime reflection mechanism uses EnumImpl, a ReflectionMirrorImpl subclass;
  2. To get the number of elements in the resulting Mirror.children array, EnumImpl’s count calls getInfo;
  3. getInfo retrieves the index (tag) of the enum case and calls getFieldAt with the type (the enum itself) and the case index;
  4. From getFieldAt’s return, getInfo extracts payloadType (which represents the case’s associated value) and assigns the pointer to the payloadTypePtr in-out parameter;
  5. count checks this pointer and returns 1 if the enum case has a payload (associated value) and 0 if not.

(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)"
}
}
}
134 changes: 134 additions & 0 deletions Crashlytics/UnitTestsSwift/ErrorInspectorTests.swift
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 {}
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ let package = Package(
path: "Crashlytics",
sources: [
"Crashlytics/Rollouts/",
"Crashlytics/SwiftUtilities/",
]
),
.testTarget(
Expand Down Expand Up @@ -1402,6 +1403,7 @@ func firebaseCrashlyticsTarget() -> Target {
"CrashlyticsInputFiles.xcfilelist",
"third_party/libunwind/LICENSE",
"Crashlytics/Rollouts/",
"Crashlytics/SwiftUtilities",
],
sources: [
"Crashlytics/",
Expand Down
Loading