@@ -27,7 +27,7 @@ private let log = PIALogger.logger(for: ServiceQualityManager.self)
2727public final class ServiceQualityManager : NSObject {
2828
2929 public static let shared = ServiceQualityManager ( )
30- private let kpiPreferenceName = " PIA_KPI_PREFERENCE_NAME "
30+ private static let kpiPreferenceName = " PIA_KPI_PREFERENCE_NAME "
3131 private var kpiManager : KPIAPI ?
3232 private var isAppActive = true
3333
@@ -68,12 +68,60 @@ public final class ServiceQualityManager: NSObject {
6868 case timeToConnect = " time_to_connect "
6969 }
7070
71+ /**
72+ * Enum defining the in-app-purchase processing events.
73+ * These track the KapeClientSDK receipt-verification funnel.
74+ */
75+ private enum KPIIapEvent : String {
76+ case iapProcessingPurchase = " iap_processing_purchase "
77+ case iapProcessingSuccess = " iap_processing_success "
78+ case iapProcessingRetry = " iap_processing_retry "
79+ case iapProcessingFailure = " iap_processing_failure "
80+ }
81+
82+ /**
83+ * Property keys for the IAP processing events. Their raw values are camelCase
84+ * on purpose: they are the cross-platform (XV) wire contract, unlike the
85+ * lower_snake_case keys used by the connection events above.
86+ */
87+ private enum KPIIapPropertyKey : String {
88+ case origin
89+ case environment
90+ case retryCount
91+ case error
92+ case csi
93+ case internalError
94+ case rawError
95+ }
96+
97+ /**
98+ * The user-facing flow a purchase originated from, reported as the `origin`
99+ * property. NOTE: the XV spec lists `origin` but does not enumerate its values;
100+ * these map to the two purchase-crediting flows and can be adjusted if XV
101+ * expects a fixed literal (e.g. a store name).
102+ */
103+ public enum KPIIapOrigin : String {
104+ case signup
105+ case renewal
106+ }
107+
71108 public override init ( ) {
72109 super. init ( )
110+ kpiManager = ServiceQualityManager . makeKPIManager ( )
111+ registerAppStateObservers ( )
112+ }
73113
114+ /// Injectable initializer used by unit tests to supply a mock `KPIAPI`.
115+ init ( kpiManager: KPIAPI ? ) {
116+ super. init ( )
117+ self . kpiManager = kpiManager
118+ registerAppStateObservers ( )
119+ }
120+
121+ private static func makeKPIManager( ) -> KPIAPI ? {
74122 do {
75123 let provider : KPIClientStateProvider = Client . environment == . staging ? PIAKPIStagingClientStateProvider ( ) : PIAKPIClientStateProvider ( )
76- kpiManager = try KPIBuilder ( )
124+ return try KPIBuilder ( )
77125 . setFlushEventMode ( . perBatch)
78126 . setKPIClientStateProvider ( provider)
79127 . setEventTimeRoundGranularity ( . hours)
@@ -84,8 +132,11 @@ public final class ServiceQualityManager: NSObject {
84132 . build ( )
85133 } catch {
86134 log. error ( " KPI manager build failed: \( error) " )
135+ return nil
87136 }
137+ }
88138
139+ private func registerAppStateObservers( ) {
89140 NotificationCenter . default. addObserver (
90141 self ,
91142 selector: #selector( appChangedState ( with: ) ) ,
@@ -96,7 +147,6 @@ public final class ServiceQualityManager: NSObject {
96147 selector: #selector( appChangedState ( with: ) ) ,
97148 name: UIApplication . didBecomeActiveNotification,
98149 object: nil )
99-
100150 }
101151
102152 deinit {
@@ -219,6 +269,103 @@ public final class ServiceQualityManager: NSObject {
219269 }
220270 }
221271
272+ // MARK: IAP processing events
273+
274+ /// A new IAP transaction starts processing on the KapeClientSDK path.
275+ public func iapProcessingPurchaseEvent( origin: KPIIapOrigin ) {
276+ submitIapEvent ( . iapProcessingPurchase, properties: baseIapProperties ( origin: origin) )
277+ }
278+
279+ /// The purchase succeeded verification using KapeClientSDK.
280+ public func iapProcessingSuccessEvent( origin: KPIIapOrigin , retryCount: Int = 0 ) {
281+ var properties = baseIapProperties ( origin: origin)
282+ properties [ KPIIapPropertyKey . retryCount. rawValue] = String ( retryCount)
283+ submitIapEvent ( . iapProcessingSuccess, properties: properties)
284+ }
285+
286+ /// A verification attempt failed and is being retried on the KapeClientSDK path.
287+ /// NOTE: not wired yet — client-side retry logic is delivered by the separate
288+ /// retry/XV ticket, which will call this with the incremented `retryCount`.
289+ public func iapProcessingRetryEvent( origin: KPIIapOrigin , error: Error , retryCount: Int ) {
290+ var properties = baseIapProperties ( origin: origin)
291+ properties [ KPIIapPropertyKey . retryCount. rawValue] = String ( retryCount)
292+ properties [ KPIIapPropertyKey . error. rawValue] = iapErrorCode ( for: error)
293+ submitIapEvent ( . iapProcessingRetry, properties: properties)
294+ }
295+
296+ /// The purchase failed verification using KapeClientSDK.
297+ public func iapProcessingFailureEvent( origin: KPIIapOrigin , error: Error , retryCount: Int ? = nil ) {
298+ var properties = baseIapProperties ( origin: origin)
299+ properties [ KPIIapPropertyKey . error. rawValue] = iapErrorCode ( for: error)
300+ if let retryCount {
301+ properties [ KPIIapPropertyKey . retryCount. rawValue] = String ( retryCount)
302+ }
303+ properties. merge ( iapErrorDetails ( for: error) ) { _, new in new }
304+ submitIapEvent ( . iapProcessingFailure, properties: properties)
305+ }
306+
307+ private func baseIapProperties( origin: KPIIapOrigin ) -> [ String : String ] {
308+ [
309+ KPIIapPropertyKey . origin. rawValue: origin. rawValue,
310+ KPIIapPropertyKey . environment. rawValue: Client . environment. rawValue
311+ ]
312+ }
313+
314+ /// Short, stable identifier for the primary `error` property.
315+ private func iapErrorCode( for error: Error ) -> String {
316+ switch error {
317+ case let clientError as ClientError :
318+ switch clientError {
319+ case . unknown( let code, _) : return " unknown_ \( code) "
320+ case . throttled( let retryAfter) : return " throttled_ \( retryAfter) "
321+ case . libraryError: return " library_error "
322+ default : return String ( describing: clientError)
323+ }
324+ default :
325+ let nsError = error as NSError
326+ return " \( nsError. domain) _ \( nsError. code) "
327+ }
328+ }
329+
330+ /// Optional diagnostic detail for failure events (`internalError`, `rawError`).
331+ /// `csi` has no client-side source and is intentionally omitted.
332+ private func iapErrorDetails( for error: Error ) -> [ String : String ] {
333+ var details : [ String : String ] = [
334+ KPIIapPropertyKey . rawError. rawValue: String ( describing: error)
335+ ]
336+ if let clientError = error as? ClientError {
337+ switch clientError {
338+ case . unknown( _, let message) , . libraryError( let message) :
339+ if let message {
340+ details [ KPIIapPropertyKey . internalError. rawValue] = message
341+ }
342+ default :
343+ break
344+ }
345+ }
346+ return details
347+ }
348+
349+ private func submitIapEvent( _ event: KPIIapEvent , properties: [ String : String ] ) {
350+ guard Client . preferences. shareServiceQualityData, let kpiManager else { return }
351+
352+ let clientEvent = KPIClientEvent (
353+ eventCountry: nil ,
354+ eventName: event. rawValue,
355+ eventProperties: properties,
356+ eventInstant: Date ( )
357+ )
358+
359+ Task {
360+ do {
361+ try await kpiManager. submit ( event: clientEvent)
362+ log. debug ( " KPI event submitted \( clientEvent) " )
363+ } catch {
364+ log. error ( " \( error) " )
365+ }
366+ }
367+ }
368+
222369 public func availableData( completion: @escaping ( ( [ String ] ) -> Void ) ) {
223370 guard let kpiManager else {
224371 completion ( [ ] )
0 commit comments