Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1e7d311
chore: FirebaseCrashlytics SPM 의존성 및 모듈 등록 - #286
clxxrlove May 11, 2026
46b9f2b
feat: Crashlytics dSYM 업로드 스크립트 추가 - #286
clxxrlove May 11, 2026
8784f3b
feat: Core/Crashlytics 모듈 추가 - #286
clxxrlove May 11, 2026
bfa4fbc
feat: Core 에러 타입에 CustomNSError 적용 - #286
clxxrlove May 11, 2026
ff82e3a
feat: Domain 에러 타입에 CustomNSError 적용 및 AuthClient 에러 wrapping - #286
clxxrlove May 11, 2026
3c74a77
chore: App 타겟에 Crashlytics 의존성 및 dSYM 스크립트 추가 - #286
clxxrlove May 11, 2026
21ba2c4
feat: AppDelegate에 Crashlytics 수집 설정 추가 - #286
clxxrlove May 11, 2026
4b5df9d
feat: AppCoordinator에 Crashlytics 유저 식별자 및 오류 추적 추가 - #286
clxxrlove May 11, 2026
c44b871
feat: Auth Feature에 Crashlytics 로그인 실패 추적 추가 - #286
clxxrlove May 11, 2026
e2fd891
feat: ProofPhoto Feature에 Crashlytics 오류 추적 추가 - #286
clxxrlove May 11, 2026
5483c32
feat: Onboarding Feature에 초대 코드 실패 토스트 및 Crashlytics 추적 추가 - #286
clxxrlove May 11, 2026
e734988
chore: 버전 1.1.2로 업데이트 - #286
clxxrlove May 11, 2026
ae2538d
fix: 컴파일 에러 수정 - #286
clxxrlove May 11, 2026
b4e5d0f
fix: CalendarNow 변수명 SwiftLint identifier_name 위반 수정 - #286
clxxrlove May 13, 2026
23b7d17
refactor: CrashlyticsClient를 화면별 이벤트 enum 패턴으로 전환 - #286
clxxrlove May 13, 2026
b515d0d
refactor: AuthLoginError.caseName 제거 - #286
clxxrlove May 13, 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
7 changes: 4 additions & 3 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ private let commonInfoPlist: [String: Plist.Value] = Project.Environment.InfoPli
"DEEPLINK_HOST": "$(DEEPLINK_HOST)",
"API_BASE_URL": "$(API_BASE_URL)",
"NSCameraUsageDescription": "UseCamera",
"CFBundleShortVersionString": "1.1.1"
"CFBundleShortVersionString": "1.1.2"
], uniquingKeysWith: { current, _ in current })

private let commonDependencies: [TargetDependency] = [
Expand All @@ -45,7 +45,8 @@ private let commonDependencies: [TargetDependency] = [
.external(dependency: .GoogleSignIn),
.external(dependency: .FirebaseCore),
.external(dependency: .FirebaseMessaging),
.external(dependency: .FirebaseRemoteConfig)
.external(dependency: .FirebaseRemoteConfig),
.core(implements: .crashlytics)
]

private let commonBuildSettings: SettingsDictionary = [
Expand Down Expand Up @@ -75,7 +76,7 @@ let project = Project(
config: .init(
infoPlist: .extendingDefault(with: commonInfoPlist),
entitlements: .file(path: "Support/Twix.entitlements"),
scripts: [.swiftLint],
scripts: [.swiftLint, .crashlyticsUploadSymbols],
dependencies: commonDependencies + [
.core(implements: .analytics)
],
Expand Down
7 changes: 7 additions & 0 deletions Projects/App/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import CoreLogging
import FirebaseCore
import FirebaseCrashlytics
import FirebaseMessaging
import UIKit
import UserNotifications
Expand All @@ -21,6 +22,12 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
// Firebase 초기화
FirebaseApp.configure()

#if DEBUG
Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(false)
#else
Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true)
#endif

// 푸시 알림 delegate 설정
UNUserNotificationCenter.current().delegate = self
Messaging.messaging().delegate = self
Expand Down
33 changes: 33 additions & 0 deletions Projects/App/Sources/Crashlytics/AppCrashlyticsEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// AppCrashlyticsEvent.swift
// Twix
//

import CoreCrashlyticsInterface
import Foundation

enum AppCrashlyticsLogEvent: CrashlyticsLogEvent {
case sessionExpiredAtOnboardingStatusCheck

var message: String {
switch self {
case .sessionExpiredAtOnboardingStatusCheck:
"session expired at onboarding status check"
}
}
}

enum AppCrashlyticsRecordEvent: CrashlyticsRecordEvent {
case appStartupFailed
case onboardingStatusCheckFailed

var customKeys: [String: String] {
switch self {
case .appStartupFailed:
[CrashlyticsKey.screen: "app_startup"]

case .onboardingStatusCheckFailed:
[CrashlyticsKey.screen: "startup_onboarding_check"]
}
}
}
18 changes: 13 additions & 5 deletions Projects/App/Sources/Reducer/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import ComposableArchitecture
import CoreAnalytics
import CoreAnalyticsInterface
import CoreCrashlyticsInterface
import CoreLogging
import CoreNetworkInterface
import CorePushInterface
Expand Down Expand Up @@ -35,7 +36,11 @@ struct AppCoordinator {
@Dependency(\.notificationClient)
var notificationClient

@Dependency(\.analyticsClient) var analyticsClient
@Dependency(\.analyticsClient)
var analyticsClient

@Dependency(\.crashlyticsClient)
var crashlytics

private let authReducer: AuthReducer
private let onboardingCoordinator: OnboardingCoordinator
Expand Down Expand Up @@ -149,9 +154,10 @@ struct AppCoordinator {
}
return .none

case .checkAuthResult(.failure):
case .checkAuthResult(.failure(let error)):
state.isCheckingAuth = false
state.route = .auth(AuthReducer.State())
crashlytics.record(error, AppCrashlyticsRecordEvent.appStartupFailed)
return .none

case let .checkOnboardingStatusResult(.success(status)):
Expand Down Expand Up @@ -198,10 +204,11 @@ struct AppCoordinator {
state.isCheckingAuth = false
if let networkError = error as? NetworkError,
case .authorizationError = networkError {
crashlytics.log(AppCrashlyticsLogEvent.sessionExpiredAtOnboardingStatusCheck)
state.route = .auth(AuthReducer.State())
return .none
}

crashlytics.record(error, AppCrashlyticsRecordEvent.onboardingStatusCheckFailed)
state.route = .onboarding(OnboardingCoordinator.State(
pendingReceivedCode: state.pendingInviteCode
))
Expand All @@ -225,8 +232,9 @@ struct AppCoordinator {

state.pendingNotificationDeepLink = nil
return .send(.route(.mainTab(.notificationDeepLinkReceived(deepLink))))

case .route(.auth(.delegate(.loginSucceeded))):

case let .route(.auth(.delegate(.loginSucceeded(authResult)))):
crashlytics.setUserIdentifier("\(authResult.userId)")
return .merge(
// 1. 온보딩 상태 체크
.run { [onboardingClient] send in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,18 @@ public enum CaptureSessionError: Error {
case photoDataUnavailable
case deviceInputNotCreated
}

// MARK: - CustomNSError

extension CaptureSessionError: CustomNSError {
public static var errorDomain: String { "org.yapp.twix.capture" }

public var errorCode: Int {
switch self {
case .sessionDeallocated: return 1
case .sessionNotConfigured: return 2
case .photoDataUnavailable: return 3
case .deviceInputNotCreated: return 4
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// CrashlyticsClient.swift
// CoreCrashlyticsInterface
//

import ComposableArchitecture
import Foundation

/// Crashlytics non-fatal 오류 추적 클라이언트입니다.
///
/// 화면/Feature별로 정의된 이벤트 타입을 통해 메시지·커스텀 키를 일관되게 전달합니다.
///
/// ## 사용 예시
/// ```swift
/// @Dependency(\.crashlyticsClient) var crashlytics
///
/// // non-fatal 오류 기록
/// crashlytics.record(
/// error,
/// ProofPhotoCrashlyticsRecordEvent.uploadFailed(step: "fetchURL", goalId: 1)
/// )
///
/// // 브레드크럼 로그
/// crashlytics.log(ProofPhotoCrashlyticsLogEvent.uploadStep(.fetchURL, goalId: 1))
///
/// // 유저 식별자 설정 (로그인 성공 시 1회)
/// crashlytics.setUserIdentifier(userId)
/// ```
public struct CrashlyticsClient: Sendable {
public var record: @Sendable (Error, any CrashlyticsRecordEvent) -> Void
public var log: @Sendable (any CrashlyticsLogEvent) -> Void
public var setUserIdentifier: @Sendable (String) -> Void

public init(
record: @escaping @Sendable (Error, any CrashlyticsRecordEvent) -> Void,
log: @escaping @Sendable (any CrashlyticsLogEvent) -> Void,
setUserIdentifier: @escaping @Sendable (String) -> Void
) {
self.record = record
self.log = log
self.setUserIdentifier = setUserIdentifier
}
}

// MARK: - TestDependencyKey

extension CrashlyticsClient: TestDependencyKey {
public static let testValue = Self(
record: { _, _ in },
log: { _ in },
setUserIdentifier: { _ in }
)

public static let previewValue = Self(
record: { _, _ in },
log: { _ in },
setUserIdentifier: { _ in }
)
}

// MARK: - DependencyValues

public extension DependencyValues {
var crashlyticsClient: CrashlyticsClient {
get { self[CrashlyticsClient.self] }
set { self[CrashlyticsClient.self] = newValue }
}
}
50 changes: 50 additions & 0 deletions Projects/Core/Crashlytics/Interface/Sources/CrashlyticsEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// CrashlyticsEvent.swift
// CoreCrashlyticsInterface
//

import Foundation

/// Crashlytics 브레드크럼 로그 이벤트가 따라야 하는 공통 인터페이스입니다.
///
/// ## 사용 예시
/// ```swift
/// enum ProofPhotoCrashlyticsLogEvent: CrashlyticsLogEvent {
/// case uploadStep(UploadStep, goalId: Int64)
///
/// var message: String {
/// switch self {
/// case let .uploadStep(step, goalId):
/// "upload_step: \(step.rawValue), goalId=\(goalId)"
/// }
/// }
/// }
/// ```
public protocol CrashlyticsLogEvent: Sendable {
var message: String { get }
}

/// Crashlytics non-fatal 오류 기록 이벤트가 따라야 하는 공통 인터페이스입니다.
///
/// `customKeys`에 화면 식별자(`CrashlyticsKey.screen`) 등 컨텍스트 정보를 담아 전달합니다.
///
/// ## 사용 예시
/// ```swift
/// enum ProofPhotoCrashlyticsRecordEvent: CrashlyticsRecordEvent {
/// case uploadFailed(step: String, goalId: Int64)
///
/// var customKeys: [String: String] {
/// switch self {
/// case let .uploadFailed(step, goalId):
/// [
/// CrashlyticsKey.screen: "proof_photo_upload",
/// CrashlyticsKey.uploadStep: step,
/// CrashlyticsKey.goalId: "\(goalId)"
/// ]
/// }
/// }
/// }
/// ```
public protocol CrashlyticsRecordEvent: Sendable {
var customKeys: [String: String] { get }
}
44 changes: 44 additions & 0 deletions Projects/Core/Crashlytics/Interface/Sources/CrashlyticsKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// CrashlyticsKey.swift
// CoreCrashlyticsInterface
//

/// Crashlytics custom key 상수 모음입니다.
///
/// `crashlyticsClient.record(error, [CrashlyticsKey.screen: "home"])` 형태로 사용합니다.
public enum CrashlyticsKey {

// MARK: - 사용자 컨텍스트
public static let userId = "user_id"
public static let goalId = "goal_id"

// MARK: - 화면 컨텍스트
public static let screen = "screen"

// MARK: - 네트워크
public static let networkEndpoint = "network_endpoint"
public static let networkMethod = "network_method"
public static let httpStatusCode = "http_status_code"
public static let networkErrorType = "network_error_type"
public static let retryCount = "retry_count"

// MARK: - 이미지 업로드
// fetchURL | uploadS3 | createLog
public static let uploadStep = "upload_step"
public static let originalImageBytes = "original_image_bytes"
public static let optimizedImageBytes = "optimized_image_bytes"
// 이미지 최적화 실패로 원본을 그대로 사용한 경우 "true"
public static let optimizationFallback = "optimization_fallback"

// MARK: - 카메라
public static let captureErrorType = "capture_error_type"

// MARK: - 인증
public static let authProvider = "auth_provider"
public static let authErrorType = "auth_error_type"

// MARK: - 키체인
// save | load | delete
public static let keychainOperation = "keychain_operation"
public static let keychainOsStatus = "keychain_os_status"
}
26 changes: 26 additions & 0 deletions Projects/Core/Crashlytics/Project.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.makeModule(
name: Module.Core.name + Module.Core.crashlytics.rawValue,
targets: [
.core(
interface: .crashlytics,
config: .init(
dependencies: [
.external(dependency: .ComposableArchitecture)
]
)
),
.core(
implements: .crashlytics,
config: .init(
dependencies: [
.core(interface: .crashlytics),
.external(dependency: .ComposableArchitecture),
.external(dependency: .FirebaseCrashlytics)
]
)
)
]
)
24 changes: 24 additions & 0 deletions Projects/Core/Crashlytics/Sources/CrashlyticsClient+Live.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// CrashlyticsClient+Live.swift
// CoreCrashlytics
//

import ComposableArchitecture
import CoreCrashlyticsInterface
import FirebaseCrashlytics

extension CrashlyticsClient: DependencyKey {
public static let liveValue = Self(
record: { error, event in
let instance = Crashlytics.crashlytics()
event.customKeys.forEach { instance.setCustomValue($0.value, forKey: $0.key) }
instance.record(error: error)
},
log: { event in
Crashlytics.crashlytics().log(event.message)
},
setUserIdentifier: { userId in
Crashlytics.crashlytics().setUserID(userId)
}
)
}
Loading
Loading