diff --git a/TapTap/Projects/AnalyticsKit/Project.swift b/TapTap/Projects/AnalyticsKit/Project.swift index ff008c1b..d83105e2 100644 --- a/TapTap/Projects/AnalyticsKit/Project.swift +++ b/TapTap/Projects/AnalyticsKit/Project.swift @@ -18,7 +18,8 @@ let project = Project.project( dependencies: [ .TCA(), .FirebaseAnalytics(), - .Amplitude() + .Amplitude(), + .Mixpanel() ] ), Target.target( diff --git a/TapTap/Projects/AnalyticsKit/Sources/AnalyticsConfiguration.swift b/TapTap/Projects/AnalyticsKit/Sources/AnalyticsConfiguration.swift index a8364af4..9a9b9807 100644 --- a/TapTap/Projects/AnalyticsKit/Sources/AnalyticsConfiguration.swift +++ b/TapTap/Projects/AnalyticsKit/Sources/AnalyticsConfiguration.swift @@ -9,16 +9,19 @@ import Foundation public struct AnalyticsConfiguration: Sendable { public let amplitudeAPIKey: String? + public let mixpanelToken: String? public let hasFirebaseConfigFile: Bool public let isDebugLoggingEnabled: Bool public init( amplitudeAPIKey: String?, + mixpanelToken: String?, hasFirebaseConfigFile: Bool, isDebugLoggingEnabled: Bool ) { self.amplitudeAPIKey = amplitudeAPIKey + self.mixpanelToken = mixpanelToken self.hasFirebaseConfigFile = hasFirebaseConfigFile self.isDebugLoggingEnabled = isDebugLoggingEnabled } @@ -32,12 +35,14 @@ public struct AnalyticsConfiguration: Sendable { return AnalyticsConfiguration( amplitudeAPIKey: bundle.nonEmptyString(forInfoDictionaryKey: Self.amplitudeKeyName), + mixpanelToken: bundle.nonEmptyString(forInfoDictionaryKey: Self.mixpanelKeyName), hasFirebaseConfigFile: bundle.path(forResource: "GoogleService-Info", ofType: "plist") != nil, isDebugLoggingEnabled: isDebug ) } static let amplitudeKeyName = "AMPLITUDE_API_KEY" + static let mixpanelKeyName = "MIXPANEL_TOKEN" } private extension Bundle { diff --git a/TapTap/Projects/AnalyticsKit/Sources/AnalyticsService.swift b/TapTap/Projects/AnalyticsKit/Sources/AnalyticsService.swift index 87e69665..542eb1ef 100644 --- a/TapTap/Projects/AnalyticsKit/Sources/AnalyticsService.swift +++ b/TapTap/Projects/AnalyticsKit/Sources/AnalyticsService.swift @@ -33,6 +33,14 @@ public final class AnalyticsService: Sendable { ) ) } + if configuration.mixpanelToken != nil { + providers.append( + MixpanelAnalyticsProvider( + token: configuration.mixpanelToken, + isVerboseLoggingEnabled: configuration.isDebugLoggingEnabled + ) + ) + } self.providers = providers } diff --git a/TapTap/Projects/AnalyticsKit/Sources/Provider/MixpanelAnalyticsProvider.swift b/TapTap/Projects/AnalyticsKit/Sources/Provider/MixpanelAnalyticsProvider.swift new file mode 100644 index 00000000..348eaf41 --- /dev/null +++ b/TapTap/Projects/AnalyticsKit/Sources/Provider/MixpanelAnalyticsProvider.swift @@ -0,0 +1,97 @@ +// +// MixpanelAnalyticsProvider.swift +// AnalyticsKit +// +// Created by 홍 on 9/10/26. +// + +import Foundation +import os + +import Mixpanel + +public final class MixpanelAnalyticsProvider: AnalyticsProviding { + public let identifier = "Mixpanel" + + private let logger = Logger(subsystem: "TapTap", category: "AnalyticsKit.Mixpanel") + + private let client: MixpanelInstance? + + public init(token: String?, isVerboseLoggingEnabled: Bool = false) { + guard let token else { + client = nil + return + } + + // trackAutomaticEvents: Amplitude의 [.sessions, .appLifecycles]와 같은 자리다. + // 세션·앱 생명주기만 자동으로 수집하고 화면 뷰는 포함하지 않는다 — screen_view는 + // 리듀서에서 직접 심는다(뷰 자동수집은 SwiftUI에서 화면 구분이 뭉개진다). + let instance = Mixpanel.initialize(token: token, trackAutomaticEvents: true) + instance.loggingEnabled = isVerboseLoggingEnabled + client = instance + } + + @discardableResult + public func start() -> Bool { + guard let client else { + logger.notice("MIXPANEL_TOKEN이 비어 있어 Mixpanel을 건너뛴다.") + return false + } + + // identify를 한 번도 부르지 않으면 유저 속성이 영원히 전송되지 않는다. + // `People.addPeopleRecordToQueueWithAction`이 distinctId가 nil일 때 레코드를 + // 미식별 플래그로 저장하고, `loadEntitiesInBatch(type: .people)`은 flag: false인 + // 행만 읽어가서 flush 대상에서 통째로 빠진다 — 큐에는 쌓이는데 나가지는 않는다. + // 탭탭은 로그인이 없어 setUserID가 불릴 일이 없으므로, SDK가 이미 만들어 둔 + // 익명 distinct_id(`$device:`)로 스스로 식별해 그 상태를 푼다. + // (identify가 `identifyPeople`로 기존 미식별 행의 플래그까지 뒤집는다) + if !client.distinctId.isEmpty { + client.identify(distinctId: client.distinctId) + } + return true + } + + public func track(_ event: AnalyticsEvent) { + guard let client else { return } + client.track(event: event.name, properties: event.parameters.mapValues(\.mixpanelValue)) + } + + public func setUserProperty(_ property: AnalyticsUserProperty) { + guard let client else { return } + client.people.set(properties: [property.name: property.value.mixpanelValue]) + } + + public func setUserID(_ userID: String?) { + guard let client else { return } + // Mixpanel의 identify는 nil을 받지 않는다. 사용자를 지우는 것은 reset이고, + // 그래야 다음 이벤트가 새 distinct_id로 나간다. + if let userID { + client.identify(distinctId: userID) + } else { + client.reset() + } + } + + public func setCollectionEnabled(_ isEnabled: Bool) { + guard let client else { return } + if isEnabled { + client.optInTracking() + } else { + client.optOutTracking() + } + } +} + +/// `AnalyticsValue`가 SDK를 모르게 두려고 여기에 둔다 — +/// `firebaseValue`·`amplitudeValue`는 `Any`라 SDK 타입이 필요 없지만 +/// Mixpanel은 `MixpanelType` 프로토콜을 요구해서 import가 따라붙는다. +private extension AnalyticsValue { + var mixpanelValue: MixpanelType { + switch self { + case .string(let value): return value + case .int(let value): return value + case .double(let value): return value + case .bool(let value): return value // Amplitude와 같이 native boolean. GA4만 문자열이다 + } + } +} diff --git a/TapTap/Projects/AnalyticsKit/Tests/AnalyticsServiceTests.swift b/TapTap/Projects/AnalyticsKit/Tests/AnalyticsServiceTests.swift index b982b682..3b082797 100644 --- a/TapTap/Projects/AnalyticsKit/Tests/AnalyticsServiceTests.swift +++ b/TapTap/Projects/AnalyticsKit/Tests/AnalyticsServiceTests.swift @@ -32,6 +32,7 @@ final class AnalyticsServiceTests: XCTestCase { func test_키가_없으면_프로바이더가_붙지_않는다() { let configuration = AnalyticsConfiguration( amplitudeAPIKey: nil, + mixpanelToken: nil, hasFirebaseConfigFile: false, isDebugLoggingEnabled: false ) @@ -45,6 +46,10 @@ final class AnalyticsServiceTests: XCTestCase { XCTAssertFalse(AmplitudeAnalyticsProvider(apiKey: nil).start()) } + func test_Mixpanel은_토큰이_없으면_시작하지_않는다() { + XCTAssertFalse(MixpanelAnalyticsProvider(token: nil).start()) + } + func test_유저프로퍼티_이름과_구간() { let provider = RecordingProvider(identifier: "p") let service = AnalyticsService(providers: [provider]) diff --git a/TapTap/Projects/App/Project.swift b/TapTap/Projects/App/Project.swift index 65fe44af..78989c88 100644 --- a/TapTap/Projects/App/Project.swift +++ b/TapTap/Projects/App/Project.swift @@ -23,6 +23,7 @@ let appTarget = Target.target( "ITSAppUsesNonExemptEncryption": false, "AMPLITUDE_API_KEY": "$(AMPLITUDE_API_KEY)", + "MIXPANEL_TOKEN": "$(MIXPANEL_TOKEN)", "FirebaseAutomaticScreenReportingEnabled": false, diff --git a/TapTap/Tuist/Package.resolved b/TapTap/Tuist/Package.resolved index 39dfd116..01242c66 100644 --- a/TapTap/Tuist/Package.resolved +++ b/TapTap/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8c3cbc3dbc1cc4b976c8525da4e5b841dcbd241bd5b7bb82d0e3c30c929a4a6b", + "originHash" : "8ed989b5ef8d9a6878d3ec7443c02bc42844629fc026fa04565587c324cf41ca", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -145,6 +145,15 @@ "version" : "4.5.2" } }, + { + "identity" : "mixpanel-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mixpanel/mixpanel-swift.git", + "state" : { + "revision" : "5f3020832a9594d98c129bf010a2bcc9dc4fa959", + "version" : "5.2.0" + } + }, { "identity" : "nanopb", "kind" : "remoteSourceControl", diff --git a/TapTap/Tuist/Package.swift b/TapTap/Tuist/Package.swift index 5dcfeb1e..94336388 100644 --- a/TapTap/Tuist/Package.swift +++ b/TapTap/Tuist/Package.swift @@ -8,7 +8,8 @@ let packageSettings = PackageSettings( productTypes: [ "ComposableArchitecture": .framework, "Lottie": .staticFramework, - "AmplitudeSwift": .staticFramework + "AmplitudeSwift": .staticFramework, + "Mixpanel": .staticFramework ] ) #endif @@ -31,6 +32,10 @@ let package = Package( .package( url: "https://github.com/amplitude/Amplitude-Swift.git", from: "1.18.8" + ), + .package( + url: "https://github.com/mixpanel/mixpanel-swift.git", + from: "5.0.0" ) ] ) diff --git a/TapTap/Tuist/ProjectDescriptionHelpers/PackageName.swift b/TapTap/Tuist/ProjectDescriptionHelpers/PackageName.swift index e547508d..ebbbd3fd 100644 --- a/TapTap/Tuist/ProjectDescriptionHelpers/PackageName.swift +++ b/TapTap/Tuist/ProjectDescriptionHelpers/PackageName.swift @@ -15,4 +15,5 @@ public extension Package { public extension Package { static let firebaseAnalytics = "FirebaseAnalytics" static let amplitude = "AmplitudeSwift" + static let mixpanel = "Mixpanel" } diff --git a/TapTap/Tuist/ProjectDescriptionHelpers/TargetDependency+Library.swift b/TapTap/Tuist/ProjectDescriptionHelpers/TargetDependency+Library.swift index 01560e36..902236f8 100644 --- a/TapTap/Tuist/ProjectDescriptionHelpers/TargetDependency+Library.swift +++ b/TapTap/Tuist/ProjectDescriptionHelpers/TargetDependency+Library.swift @@ -25,4 +25,8 @@ extension TargetDependency { public static func Amplitude() -> TargetDependency { .external(name: Package.amplitude) } + + public static func Mixpanel() -> TargetDependency { + .external(name: Package.mixpanel) + } }