diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81c2114a..07c1cde2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,6 +59,8 @@ jobs: cache: true - name: Install SwiftLint + env: + HOMEBREW_REQUIRE_TAP_TRUST: "1" shell: bash run: | set -euo pipefail @@ -264,6 +266,8 @@ jobs: cache: true - name: Install SwiftLint + env: + HOMEBREW_REQUIRE_TAP_TRUST: "1" shell: bash run: | set -euo pipefail diff --git a/Application/DevLogApp/Project.swift b/Application/DevLogApp/Project.swift index 8e89fc3b..244f4c0a 100644 --- a/Application/DevLogApp/Project.swift +++ b/Application/DevLogApp/Project.swift @@ -51,9 +51,13 @@ let project = Project( ], debug: [ "APS_ENVIRONMENT": "development", + "DEBUG_INFORMATION_FORMAT": "dwarf", + "INFOPLIST_KEY_FirebaseCrashlyticsCollectionEnabled": "NO", ], release: [ "APS_ENVIRONMENT": "production", + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", + "INFOPLIST_KEY_FirebaseCrashlyticsCollectionEnabled": "YES", ] ) ), diff --git a/Application/DevLogInfra/Sources/Common/FirebaseCrashlyticsHelper.swift b/Application/DevLogInfra/Sources/Common/FirebaseCrashlyticsHelper.swift new file mode 100644 index 00000000..217b239e --- /dev/null +++ b/Application/DevLogInfra/Sources/Common/FirebaseCrashlyticsHelper.swift @@ -0,0 +1,60 @@ +// +// FirebaseCrashlyticsHelper.swift +// DevLogInfra +// +// Created by opfic on 6/16/26. +// + +import FirebaseCrashlytics +import Foundation + +enum FirebaseCrashlyticsHelper { + static func record( + _ error: Error, + domain: String, + code: Int, + metadata: [String: String] = [:], + logs: [String] = [] + ) { + let nsError = error as NSError + let report = NSError( + domain: domain, + code: code, + userInfo: userInfo(for: nsError, error: error, metadata: metadata) + ) + let crashlytics = Crashlytics.crashlytics() + + logs.forEach { + crashlytics.log($0) + } + + crashlytics.record(error: report) + } +} + +private extension FirebaseCrashlyticsHelper { + enum Key: String { + case underlyingType + case underlyingDomain + case underlyingCode + } + + static func userInfo( + for nsError: NSError, + error: Error, + metadata: [String: String] + ) -> [String: Any] { + var userInfo: [String: Any] = [ + NSUnderlyingErrorKey: nsError, + Key.underlyingType.rawValue: String(describing: type(of: error)), + Key.underlyingDomain.rawValue: nsError.domain, + Key.underlyingCode.rawValue: nsError.code + ] + + metadata.forEach { + userInfo[$0.key] = $0.value + } + + return userInfo + } +} diff --git a/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift b/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift index c8f97147..8103e66e 100644 --- a/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift @@ -13,6 +13,17 @@ import DevLogCore import DevLogData final class AuthServiceImpl: AuthService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.AuthServiceImpl" + + enum Code: Int { + case getProviderID = 1 + case deleteCurrentUser + case deleteMessagingToken + case signOut + } + } + private let store = Firestore.firestore() private let messaging = Messaging.messaging() private let logger = Logger(category: "AuthServiceImpl") @@ -87,6 +98,7 @@ final class AuthServiceImpl: AuthService { return providerID } catch { logger.error("Failed to fetch provider ID", error: error) + record(error, code: .getProviderID) throw error } } @@ -103,6 +115,7 @@ final class AuthServiceImpl: AuthService { try await currentUser.delete() } catch { logger.error("Failed to delete FirebaseAuth current user", error: error) + record(error, code: .deleteCurrentUser) throw error } } @@ -114,12 +127,14 @@ final class AuthServiceImpl: AuthService { try await messaging.deleteToken() } catch { logger.error("Failed to delete FCM token while clearing session", error: error) + record(error, code: .deleteMessagingToken) } do { try Auth.auth().signOut() } catch { logger.error("Failed to sign out while clearing session", error: error) + record(error, code: .signOut) throw error } } @@ -127,6 +142,18 @@ final class AuthServiceImpl: AuthService { } private extension AuthServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } + func handleAuthStateChange(_ user: User?) { let signedIn = user != nil logger.info("Firebase auth state changed. signedIn: \(signedIn)") diff --git a/Application/DevLogInfra/Sources/Service/FirebaseAppServiceImpl.swift b/Application/DevLogInfra/Sources/Service/FirebaseAppServiceImpl.swift index bdc7b6af..74af8043 100644 --- a/Application/DevLogInfra/Sources/Service/FirebaseAppServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/FirebaseAppServiceImpl.swift @@ -6,6 +6,7 @@ // import DevLogData +import FirebaseCrashlytics import FirebaseCore final class FirebaseAppServiceImpl: FirebaseAppService { @@ -15,6 +16,15 @@ final class FirebaseAppServiceImpl: FirebaseAppService { guard !Self.isConfigured else { return } FirebaseApp.configure() + enableCrashlyticsCollectionIfNeeded() Self.isConfigured = true } } + +private extension FirebaseAppServiceImpl { + func enableCrashlyticsCollectionIfNeeded() { + #if !DEBUG + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) + #endif + } +} diff --git a/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift b/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift index aa4776b0..74a74cf9 100644 --- a/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/ProfileImageDataServiceImpl.swift @@ -10,16 +10,33 @@ import Nexa import DevLogData final class ProfileImageDataServiceImpl: ProfileImageDataService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.ProfileImageDataServiceImpl" + + enum Code: Int { + case fetchImageData = 1 + } + } + func fetchImageData(from url: URL) async throws -> Data { - try await NXAPIClient( - configuration: NXClientConfiguration(baseURL: url) - ) - .get() - .timeout(10) - .intercept(ProfileImageDataCachePolicyInterceptor()) - .validate(.successStatusCode) - .raw() - .data + do { + return try await NXAPIClient( + configuration: NXClientConfiguration(baseURL: url) + ) + .get() + .timeout(10) + .intercept(ProfileImageDataCachePolicyInterceptor()) + .validate(.successStatusCode) + .raw() + .data + } catch { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(CrashlyticsError.Code.fetchImageData)", + code: CrashlyticsError.Code.fetchImageData.rawValue + ) + throw error + } } } diff --git a/Application/DevLogInfra/Sources/Service/PushMessagingServiceImpl.swift b/Application/DevLogInfra/Sources/Service/PushMessagingServiceImpl.swift index 0c1ff320..dd99ca00 100644 --- a/Application/DevLogInfra/Sources/Service/PushMessagingServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/PushMessagingServiceImpl.swift @@ -11,6 +11,14 @@ import FirebaseMessaging import UserNotifications final class PushMessagingServiceImpl: NSObject, PushMessagingService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.PushMessagingServiceImpl" + + enum Code: Int { + case fetchFCMToken = 1 + } + } + private weak var delegate: PushMessagingServiceDelegate? func setDelegate(_ delegate: PushMessagingServiceDelegate?) { @@ -42,6 +50,11 @@ final class PushMessagingServiceImpl: NSObject, PushMessagingService { if error.isMissingAPNSTokenForFCMToken { return nil } + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(CrashlyticsError.Code.fetchFCMToken)", + code: CrashlyticsError.Code.fetchFCMToken.rawValue + ) throw error } } diff --git a/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift index cd6652c5..ece2ad88 100644 --- a/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift @@ -13,6 +13,22 @@ import DevLogCore import DevLogData final class PushNotificationServiceImpl: PushNotificationService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.PushNotificationServiceImpl" + + enum Code: Int { + case fetchPushNotificationEnabled = 1 + case fetchPushNotificationTime + case updatePushNotificationSettings + case requestNotifications + case observeNotifications + case observeUnreadPushCount + case deleteNotification + case undoDeleteNotification + case toggleNotificationRead + } + } + private enum FunctionName: String { case requestPushNotificationDeletion case undoPushNotificationDeletion @@ -44,6 +60,7 @@ final class PushNotificationServiceImpl: PushNotificationService { throw FirestoreError.dataNotFound("allowPushNotification") } catch { logger.error("Failed to fetch push notification status", error: error) + record(error, code: .fetchPushNotificationEnabled) throw error } } @@ -72,6 +89,7 @@ final class PushNotificationServiceImpl: PushNotificationService { return DateComponents(hour: hour, minute: minute) } catch { logger.error("Failed to fetch push notification time", error: error) + record(error, code: .fetchPushNotificationTime) throw error } } @@ -102,6 +120,7 @@ final class PushNotificationServiceImpl: PushNotificationService { logger.info("Successfully updated push notification settings") } catch { logger.error("Failed to update push notification settings", error: error) + record(error, code: .updatePushNotificationSettings) throw error } } @@ -144,6 +163,7 @@ final class PushNotificationServiceImpl: PushNotificationService { return PushNotificationPageResponse(items: items, nextCursor: nextCursor) } catch { logger.error("Failed to request notifications", error: error) + record(error, code: .requestNotifications) throw error } } @@ -160,6 +180,7 @@ final class PushNotificationServiceImpl: PushNotificationService { .limit(to: pageLimit) .addSnapshotListener { [weak self] snapshot, error in if let error { + Self.record(error, code: .observeNotifications) subject.send(completion: .failure(error)) return } @@ -190,6 +211,7 @@ final class PushNotificationServiceImpl: PushNotificationService { .whereField(PushNotificationFieldKey.isDeleted.rawValue, isEqualTo: false) .addSnapshotListener { snapshot, error in if let error { + Self.record(error, code: .observeUnreadPushCount) subject.send(completion: .failure(error)) return } @@ -213,6 +235,7 @@ final class PushNotificationServiceImpl: PushNotificationService { _ = try await function.call(["notificationId": notificationID]) } catch { logger.error("Failed to request notification deletion", error: error) + record(error, code: .deleteNotification) throw error } } @@ -225,6 +248,7 @@ final class PushNotificationServiceImpl: PushNotificationService { _ = try await function.call(["notificationId": notificationID]) } catch { logger.error("Failed to undo notification deletion", error: error) + record(error, code: .undoDeleteNotification) throw error } } @@ -259,12 +283,25 @@ final class PushNotificationServiceImpl: PushNotificationService { logger.info("Successfully toggled notification read") } catch { logger.error("Failed to toggle notification read", error: error) + record(error, code: .toggleNotificationRead) throw error } } } private extension PushNotificationServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } + func makeQuery( uid: String, query: PushNotificationQuery diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 914d1b6c..366eba9b 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -16,6 +16,18 @@ import DevLogCore import DevLogData final class AppleAuthenticationServiceImpl: AuthenticationService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.AppleAuthenticationServiceImpl" + + enum Code: Int { + case signIn = 1 + case signOut + case deleteAuth + case link + case unlink + } + } + private enum FunctionName: String { case requestAppleCustomToken case refreshAppleAccessToken @@ -94,6 +106,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { return result.user.makeResponse(providerID: .apple) } catch { logger.error("Failed to sign in with Apple", error: error) + record(error, code: .signIn) throw error } } @@ -112,6 +125,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { try Auth.auth().signOut() } catch { logger.error("Failed to sign out with Apple", error: error) + record(error, code: .signOut) throw error } } @@ -123,6 +137,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { try await revokeAppleAccessToken(token: token) } catch { logger.error("Failed to delete Apple auth", error: error) + record(error, code: .deleteAuth) throw error } } @@ -157,6 +172,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { try await user?.link(with: appleCredential) } catch { logger.error("Failed to link Apple account", error: error) + record(error, code: .link) if error.isFirebaseCredentialAlreadyInUse { throw DataLayerError.linkCredentialAlreadyInUse } @@ -188,6 +204,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { _ = try await user?.unlink(fromProvider: providerID.rawValue) } catch { logger.error("Failed to unlink Apple account", error: error) + record(error, code: .unlink) throw error } } @@ -311,3 +328,17 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { _ = try await revokeFunction.call(["token": token]) } } + +private extension AppleAuthenticationServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } +} diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index e3b0a847..8617c95a 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -16,6 +16,18 @@ import DevLogCore import DevLogData final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.GithubAuthenticationServiceImpl" + + enum Code: Int { + case signIn = 1 + case signOut + case deleteAuth + case link + case unlink + } + } + private enum FunctionName: String { case requestGithubTokens case revokeGithubAccessToken @@ -79,6 +91,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { ) } catch { logger.error("Failed to sign in with GitHub", error: error) + record(error, code: .signIn) throw error } } @@ -97,6 +110,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { try Auth.auth().signOut() } catch { logger.error("Failed to sign out with GitHub", error: error) + record(error, code: .signOut) throw error } } @@ -106,6 +120,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { try await revokeAccessToken() } catch { logger.error("Failed to delete GitHub auth", error: error) + record(error, code: .deleteAuth) throw error } } @@ -140,6 +155,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { logger.info("Successfully linked GitHub account") } catch { logger.error("Failed to link GitHub account", error: error) + record(error, code: .link) if error.isFirebaseCredentialAlreadyInUse { throw DataLayerError.linkCredentialAlreadyInUse } @@ -161,6 +177,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { _ = try await user?.unlink(fromProvider: providerID.rawValue) } catch { logger.error("Failed to unlink GitHub account", error: error) + record(error, code: .unlink) throw error } } @@ -304,6 +321,18 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { } private extension GithubAuthenticationServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } + struct GitHubUser: Codable { let login: String let name: String? diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index 50d63525..ae36a4b8 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -14,6 +14,18 @@ import DevLogCore import DevLogData final class GoogleAuthenticationServiceImpl: AuthenticationService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.GoogleAuthenticationServiceImpl" + + enum Code: Int { + case signIn = 1 + case signOut + case deleteAuth + case link + case unlink + } + } + private let store = Firestore.firestore() private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } @@ -55,6 +67,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { return result.user.makeResponse(providerID: .google) } catch { logger.error("Failed to sign in with Google", error: error) + record(error, code: .signIn) throw error } } @@ -76,6 +89,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { try Auth.auth().signOut() } catch { logger.error("Failed to sign out with Google", error: error) + record(error, code: .signOut) throw error } } @@ -86,6 +100,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { try await GIDSignIn.sharedInstance.disconnect() } catch { logger.error("Failed to delete Google auth", error: error) + record(error, code: .deleteAuth) throw error } } @@ -121,6 +136,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { try await user?.link(with: credential) } catch { logger.error("Failed to link Google account", error: error) + record(error, code: .link) if error.isFirebaseCredentialAlreadyInUse { throw DataLayerError.linkCredentialAlreadyInUse } @@ -138,8 +154,22 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { _ = try await user?.unlink(fromProvider: AuthProviderID.google.rawValue) } catch { logger.error("Failed to unlink Google account", error: error) + record(error, code: .unlink) throw error } } +} +private extension GoogleAuthenticationServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } } diff --git a/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift b/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift index e058eb2b..3ee310c0 100644 --- a/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/TodoCategoryServiceImpl.swift @@ -11,6 +11,15 @@ import DevLogCore import DevLogData final class TodoCategoryServiceImpl: TodoCategoryService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.TodoCategoryServiceImpl" + + enum Code: Int { + case fetchCategoryPreferences = 1 + case updateCategoryPreferences + } + } + private enum Field: String { case items case kind @@ -57,6 +66,7 @@ final class TodoCategoryServiceImpl: TodoCategoryService { return preferences } catch { logger.error("Failed to fetch todo category preferences", error: error) + record(error, code: .fetchCategoryPreferences) throw error } } @@ -79,12 +89,25 @@ final class TodoCategoryServiceImpl: TodoCategoryService { logger.info("Successfully updated todo category preferences") } catch { logger.error("Failed to update todo category preferences", error: error) + record(error, code: .updateCategoryPreferences) throw error } } } private extension TodoCategoryServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } + func makePreference(_ items: [String: Any]) -> TodoCategoryPreferenceResponse? { guard let kindString = items[Field.kind.rawValue] as? String, diff --git a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift index 7327d8fd..12aae818 100644 --- a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift @@ -12,6 +12,19 @@ import DevLogCore import DevLogData final class TodoServiceImpl: TodoService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.TodoServiceImpl" + + enum Code: Int { + case fetchTodos = 1 + case upsertTodo + case deleteTodo + case undoDeleteTodo + case fetchTodo + case fetchReferences + } + } + private enum FunctionName: String { case requestTodoDeletion case undoTodoDeletion @@ -49,122 +62,128 @@ final class TodoServiceImpl: TodoService { ] logger.info("Fetching todo page: \(logComponents.compactMap { $0 }.joined(separator: ", "))") - var firestoreQuery = makeQuery(uid: uid, query: query) + do { + var firestoreQuery = makeQuery(uid: uid, query: query) - if let categoryId = query.categoryId { - firestoreQuery = firestoreQuery.whereField( - TodoFieldKey.category.rawValue, - isEqualTo: categoryId - ) - } + if let categoryId = query.categoryId { + firestoreQuery = firestoreQuery.whereField( + TodoFieldKey.category.rawValue, + isEqualTo: categoryId + ) + } - if query.isPinned { - firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: true) - } + if query.isPinned { + firestoreQuery = firestoreQuery.whereField("isPinned", isEqualTo: true) + } - if let isCompleted = query.completionFilter.isCompletedValue { - firestoreQuery = firestoreQuery.whereField("isCompleted", isEqualTo: isCompleted) - } + if let isCompleted = query.completionFilter.isCompletedValue { + firestoreQuery = firestoreQuery.whereField("isCompleted", isEqualTo: isCompleted) + } - switch query.dueDateFilter { - case .all: - break - case .withDueDate: - firestoreQuery = firestoreQuery.whereField( - "dueDate", - isGreaterThan: Timestamp(date: Date(timeIntervalSince1970: 0)) - ) - case .withoutDueDate: - firestoreQuery = firestoreQuery.whereField("dueDate", isEqualTo: NSNull()) - } + switch query.dueDateFilter { + case .all: + break + case .withDueDate: + firestoreQuery = firestoreQuery.whereField( + "dueDate", + isGreaterThan: Timestamp(date: Date(timeIntervalSince1970: 0)) + ) + case .withoutDueDate: + firestoreQuery = firestoreQuery.whereField("dueDate", isEqualTo: NSNull()) + } - if let sortDateFrom = query.sortDateFrom { - firestoreQuery = firestoreQuery.whereField( - query.sortTarget.fieldName, - isGreaterThanOrEqualTo: Timestamp(date: sortDateFrom) - ) - } + if let sortDateFrom = query.sortDateFrom { + firestoreQuery = firestoreQuery.whereField( + query.sortTarget.fieldName, + isGreaterThanOrEqualTo: Timestamp(date: sortDateFrom) + ) + } - if let sortDateTo = query.sortDateTo { - firestoreQuery = firestoreQuery.whereField( - query.sortTarget.fieldName, - isLessThan: Timestamp(date: sortDateTo) - ) - } + if let sortDateTo = query.sortDateTo { + firestoreQuery = firestoreQuery.whereField( + query.sortTarget.fieldName, + isLessThan: Timestamp(date: sortDateTo) + ) + } - if trimmedKeyword.isEmpty { - if query.fetchAllPages { - var allItems: [TodoResponse] = [] - var pageCursor = cursor - - while true { - var pageQuery = firestoreQuery - if let pageCursor { - guard let cursorValues = cursorValues( - for: query, - cursor: pageCursor - ) else { - logger.error("Failed to build cursor values for paginated todo fetch.") - break + if trimmedKeyword.isEmpty { + if query.fetchAllPages { + var allItems: [TodoResponse] = [] + var pageCursor = cursor + + while true { + var pageQuery = firestoreQuery + if let pageCursor { + guard let cursorValues = cursorValues( + for: query, + cursor: pageCursor + ) else { + logger.error("Failed to build cursor values for paginated todo fetch.") + break + } + pageQuery = pageQuery.start(after: cursorValues) } - pageQuery = pageQuery.start(after: cursorValues) - } - pageQuery = pageQuery.limit(to: query.pageSize) - let snapshot = try await pageQuery.getDocuments() - allItems.append(contentsOf: snapshot.documents.compactMap { makeResponse(from: $0) }) + pageQuery = pageQuery.limit(to: query.pageSize) + let snapshot = try await pageQuery.getDocuments() + allItems.append(contentsOf: snapshot.documents.compactMap { makeResponse(from: $0) }) - guard snapshot.documents.count == query.pageSize else { - break - } + guard snapshot.documents.count == query.pageSize else { + break + } - guard let lastDocument = snapshot.documents.last, - let nextCursor = makeCursor( - from: lastDocument, - query: query - ) else { - break + guard let lastDocument = snapshot.documents.last, + let nextCursor = makeCursor( + from: lastDocument, + query: query + ) else { + break + } + + pageCursor = nextCursor } - pageCursor = nextCursor + return TodoPageResponse(items: allItems, nextCursor: nil) } - return TodoPageResponse(items: allItems, nextCursor: nil) - } + if let cursor { + guard let cursorValues = cursorValues(for: query, cursor: cursor) else { + logger.error("Failed to build cursor values for todo fetch.") + return TodoPageResponse(items: [], nextCursor: nil) + } + firestoreQuery = firestoreQuery.start(after: cursorValues) + } - if let cursor { - guard let cursorValues = cursorValues(for: query, cursor: cursor) else { - logger.error("Failed to build cursor values for todo fetch.") - return TodoPageResponse(items: [], nextCursor: nil) + firestoreQuery = firestoreQuery.limit(to: query.pageSize) + let snapshot = try await firestoreQuery.getDocuments() + let items = snapshot.documents.compactMap { makeResponse(from: $0) } + let nextCursor = snapshot.documents.last.flatMap { + makeCursor(from: $0, query: query) } - firestoreQuery = firestoreQuery.start(after: cursorValues) - } - firestoreQuery = firestoreQuery.limit(to: query.pageSize) - let snapshot = try await firestoreQuery.getDocuments() - let items = snapshot.documents.compactMap { makeResponse(from: $0) } - let nextCursor = snapshot.documents.last.flatMap { - makeCursor(from: $0, query: query) + return TodoPageResponse(items: items, nextCursor: nextCursor) } - return TodoPageResponse(items: items, nextCursor: nextCursor) - } + let snapshot = try await firestoreQuery.getDocuments() + let todos = snapshot.documents.compactMap { makeResponse(from: $0) } - let snapshot = try await firestoreQuery.getDocuments() - let todos = snapshot.documents.compactMap { makeResponse(from: $0) } + let todoNumber = searchedTodoNumber(from: trimmedKeyword) + let filtered = todos.filter { todo in + if let todoNumber, todo.number == todoNumber { + return true + } - let todoNumber = searchedTodoNumber(from: trimmedKeyword) - let filtered = todos.filter { todo in - if let todoNumber, todo.number == todoNumber { - return true + return todo.title.localizedCaseInsensitiveContains(trimmedKeyword) + || todo.content.localizedCaseInsensitiveContains(trimmedKeyword) + || todo.tags.contains { $0.localizedCaseInsensitiveContains(trimmedKeyword) } } - return todo.title.localizedCaseInsensitiveContains(trimmedKeyword) - || todo.content.localizedCaseInsensitiveContains(trimmedKeyword) - || todo.tags.contains { $0.localizedCaseInsensitiveContains(trimmedKeyword) } + return TodoPageResponse(items: filtered, nextCursor: nil) + } catch { + logger.error("Failed to fetch todos", error: error) + record(error, code: .fetchTodos) + throw error } - - return TodoPageResponse(items: filtered, nextCursor: nil) } // swiftlint:enable function_body_length @@ -198,6 +217,7 @@ final class TodoServiceImpl: TodoService { logger.info("Successfully upserted todo") } catch { logger.error("Failed to upsert todo", error: error) + record(error, code: .upsertTodo) throw error } } @@ -214,6 +234,7 @@ final class TodoServiceImpl: TodoService { logger.info("Successfully requested todo deletion") } catch { logger.error("Failed to request todo deletion", error: error) + record(error, code: .deleteTodo) throw error } } @@ -230,6 +251,7 @@ final class TodoServiceImpl: TodoService { logger.info("Successfully undone todo deletion") } catch { logger.error("Failed to undo todo deletion", error: error) + record(error, code: .undoDeleteTodo) throw error } } @@ -253,6 +275,7 @@ final class TodoServiceImpl: TodoService { return todo } catch { logger.error("Failed to fetch todo", error: error) + record(error, code: .fetchTodo) throw error } } @@ -263,45 +286,63 @@ final class TodoServiceImpl: TodoService { let uniqueNumbers = Array(Set(numbers)).sorted() if uniqueNumbers.isEmpty { return [:] } - let collection = store.collection(FirestorePath.todos(uid)) - let snapshots = try await withThrowingTaskGroup(of: [QueryDocumentSnapshot].self) { group in - for chunk in uniqueNumbers.chunked(maxCount: 10) { - group.addTask { - let snapshot = try await collection - .whereField(TodoFieldKey.number.rawValue, in: chunk) - .whereField(TodoFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) - .getDocuments() - return snapshot.documents + do { + let collection = store.collection(FirestorePath.todos(uid)) + let snapshots = try await withThrowingTaskGroup(of: [QueryDocumentSnapshot].self) { group in + for chunk in uniqueNumbers.chunked(maxCount: 10) { + group.addTask { + let snapshot = try await collection + .whereField(TodoFieldKey.number.rawValue, in: chunk) + .whereField(TodoFieldKey.deletedAt.rawValue, isEqualTo: NSNull()) + .getDocuments() + return snapshot.documents + } } - } - var documents = [QueryDocumentSnapshot]() - for try await chunkDocuments in group { - documents.append(contentsOf: chunkDocuments) + var documents = [QueryDocumentSnapshot]() + for try await chunkDocuments in group { + documents.append(contentsOf: chunkDocuments) + } + return documents } - return documents - } - return snapshots.reduce(into: [Int: TodoReferenceResponse]()) { partialResult, document in - let data = document.data() - guard - data[TodoFieldKey.deletedAt.rawValue] is NSNull, - let response = makeResponse(from: document) - else { - return - } + return snapshots.reduce(into: [Int: TodoReferenceResponse]()) { partialResult, document in + let data = document.data() + guard + data[TodoFieldKey.deletedAt.rawValue] is NSNull, + let response = makeResponse(from: document) + else { + return + } - partialResult[response.number] = TodoReferenceResponse( - id: response.id, - number: response.number, - title: response.title, - category: response.category - ) + partialResult[response.number] = TodoReferenceResponse( + id: response.id, + number: response.number, + title: response.title, + category: response.category + ) + } + } catch { + logger.error("Failed to fetch todo references", error: error) + record(error, code: .fetchReferences) + throw error } } } private extension TodoServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } + func upsertTodoWithNumberOnCreate( _ data: [String: Any], for todoRef: DocumentReference, diff --git a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift index eefdceca..8e1c827a 100644 --- a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift @@ -11,6 +11,18 @@ import DevLogCore import DevLogData final class UserServiceImpl: UserService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.UserServiceImpl" + + enum Code: Int { + case upsertUser = 1 + case fetchUserProfile + case upsertStatusMessage + case updateFCMToken + case updateUserTimeZone + } + } + private let store = Firestore.firestore() private let logger = Logger(category: "UserServiceImpl") @@ -112,6 +124,7 @@ final class UserServiceImpl: UserService { logger.info("Successfully upserted user: \(user.uid)") } catch { logger.error("Failed to upsert user", error: error) + record(error, code: .upsertUser) throw error } } @@ -150,6 +163,7 @@ final class UserServiceImpl: UserService { ) } catch { logger.error("Failed to fetch user profile", error: error) + record(error, code: .fetchUserProfile) throw error } } @@ -168,6 +182,7 @@ final class UserServiceImpl: UserService { logger.info("Successfully upserted status message") } catch { logger.error("Failed to upsert status message", error: error) + record(error, code: .upsertStatusMessage) throw error } } @@ -186,6 +201,7 @@ final class UserServiceImpl: UserService { logger.info("Successfully updated FCM token") } catch { logger.error("Failed to update FCM token", error: error) + record(error, code: .updateFCMToken) throw error } } @@ -207,7 +223,22 @@ final class UserServiceImpl: UserService { logger.info("Successfully updated timeZone") } catch { logger.error("Failed to update timeZone", error: error) + record(error, code: .updateUserTimeZone) throw error } } } + +private extension UserServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } +} diff --git a/Application/DevLogInfra/Sources/Service/WebPageMetadataServiceImpl.swift b/Application/DevLogInfra/Sources/Service/WebPageMetadataServiceImpl.swift index 6526a1f8..68c6fe0c 100644 --- a/Application/DevLogInfra/Sources/Service/WebPageMetadataServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/WebPageMetadataServiceImpl.swift @@ -12,6 +12,16 @@ import DevLogCore import DevLogData final class WebPageMetadataServiceImpl: WebPageMetadataService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.WebPageMetadataServiceImpl" + + enum Code: Int { + case fetchMetadata = 1 + case removeCachedImage + case cachedImageURL + } + } + private let imageStore: WebPageImageStore private let logger = Logger(category: "WebPageMetadataServiceImpl") @@ -42,6 +52,7 @@ final class WebPageMetadataServiceImpl: WebPageMetadataService { ) } catch { logger.error("Failed to fetch metadata", error: error) + record(error, code: .fetchMetadata) throw error } } @@ -60,6 +71,7 @@ final class WebPageMetadataServiceImpl: WebPageMetadataService { } } catch { logger.error("Failed to remove cached image", error: error) + record(error, code: .removeCachedImage) } } @@ -68,7 +80,13 @@ final class WebPageMetadataServiceImpl: WebPageMetadataService { throw URLError(.badURL) } - return try await imageStore.cachedImageURL(for: url) + do { + return try await imageStore.cachedImageURL(for: url) + } catch { + logger.error("Failed to fetch cached image URL", error: error) + record(error, code: .cachedImageURL) + throw error + } } private func extractImageURL(from imageProvider: NSItemProvider?, url: URL) async throws -> URL? { @@ -97,3 +115,17 @@ final class WebPageMetadataServiceImpl: WebPageMetadataService { } } } + +private extension WebPageMetadataServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } +} diff --git a/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift b/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift index 728c58b7..955b3291 100644 --- a/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/WebPageServiceImpl.swift @@ -12,6 +12,17 @@ import DevLogCore import DevLogData final class WebPageServiceImpl: WebPageService { + private enum CrashlyticsError { + static let domain = "DevLogInfra.WebPageServiceImpl" + + enum Code: Int { + case fetchWebPages = 1 + case upsertWebPage + case deleteWebPage + case undoDeleteWebPage + } + } + private enum FunctionName: String { case requestWebPageDeletion case undoWebPageDeletion @@ -51,6 +62,7 @@ final class WebPageServiceImpl: WebPageService { return filtered } catch { logger.error("Failed to fetch web pages", error: error) + record(error, code: .fetchWebPages) throw error } } @@ -72,6 +84,7 @@ final class WebPageServiceImpl: WebPageService { logger.info("Successfully upserted web page") } catch { logger.error("Failed to upsert web page", error: error) + record(error, code: .upsertWebPage) throw error } } @@ -90,6 +103,7 @@ final class WebPageServiceImpl: WebPageService { logger.info("Successfully requested web page deletion") } catch { logger.error("Failed to request web page deletion", error: error) + record(error, code: .deleteWebPage) throw error } } @@ -108,6 +122,7 @@ final class WebPageServiceImpl: WebPageService { logger.info("Successfully undone web page deletion") } catch { logger.error("Failed to undo web page deletion", error: error) + record(error, code: .undoDeleteWebPage) throw error } } @@ -125,6 +140,18 @@ final class WebPageServiceImpl: WebPageService { } private extension WebPageServiceImpl { + private static func record(_ error: Error, code: CrashlyticsError.Code) { + FirebaseCrashlyticsHelper.record( + error, + domain: "\(CrashlyticsError.domain).\(code)", + code: code.rawValue + ) + } + + private func record(_ error: Error, code: CrashlyticsError.Code) { + Self.record(error, code: code) + } + func makeResponse(from snapshot: QueryDocumentSnapshot) -> WebPageResponse? { let data = snapshot.data() guard diff --git a/README.md b/README.md index 61e1f429..3975b15e 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,8 @@ Todo, 저장 링크, 오늘 할 일, 받은 알림, 누적 활동을 하나의 | Architecture | Tuist Modular based Clean Architecture | | UI | SwiftUI, MarkdownUI, WidgetKit, AppIntents | | State & Async | Observable, Combine, async/await, The Composable Architecture | -| Backend | Firebase Authentication, Firestore, Cloud Functions, Cloud Messaging, Analytics | +| Backend | Firebase Authentication, Firestore, Cloud Functions, Cloud Messaging | +| Monitoring | Firebase Analytics, Crashlytics | | Apple Frameworks | AuthenticationServices, UserNotifications, LinkPresentation, Network, CryptoKit, os.log | | External Packages | ComposableArchitecture, MarkdownUI, OrderedCollections, GoogleSignIn, Nexa | | Testing | swift-testing, TCA TestStore | diff --git a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift index 6c9bbdb1..dd115836 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Packages.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Packages.swift @@ -37,6 +37,7 @@ public enum DevLogPackages { .package(product: "FirebaseCore"), .package(product: "FirebaseFunctions"), .package(product: "FirebaseAuth"), + .package(product: "FirebaseCrashlytics"), .package(product: "FirebaseMessaging"), .package(product: "FirebaseFirestore"), .package(product: "GoogleSignIn"), diff --git a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift index f6a53760..e97e9ef0 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift @@ -30,6 +30,12 @@ public extension Project { versionXcconfigPath: versionXcconfigPath, base: [ "ENABLE_USER_SCRIPT_SANDBOXING": "NO", + ], + debug: [ + "DEBUG_INFORMATION_FORMAT": "dwarf", + ], + release: [ + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", ] ) ),