Skip to content

Commit 99382b8

Browse files
KM-17457: Introduce IAP processing KPI metrics
1 parent a1f2bb6 commit 99382b8

5 files changed

Lines changed: 378 additions & 7 deletions

File tree

LocalPackages/PIALibrary/Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ let package = Package(
6969
name: "PIALibraryTests",
7070
dependencies: [
7171
"PIALibrary",
72+
.product(name: "PIAKPI", package: "PIAKPI"),
7273
.product(
7374
name: "TunnelKitOpenVPN",
7475
package: "mobile-ios-openvpn",

LocalPackages/PIALibrary/Sources/PIALibrary/Account/DefaultAccountProvider.swift

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -494,8 +494,14 @@ public final class DefaultAccountProvider: AccountProvider, ConfigurationAccess,
494494

495495
accessedDatabase.plain.lastSignupEmail = request.email
496496

497+
// A new IAP transaction starts processing (Kape verification path).
498+
ServiceQualityManager.shared.iapProcessingPurchaseEvent(origin: .signup)
499+
500+
var verificationSucceeded = false
497501
do {
498502
let credentials = try await webServices.signup(with: signup)
503+
verificationSucceeded = true
504+
ServiceQualityManager.shared.iapProcessingSuccessEvent(origin: .signup)
499505

500506
if let transaction = request.transaction {
501507
accessedStore.finishTransaction(transaction, success: true)
@@ -518,14 +524,19 @@ public final class DefaultAccountProvider: AccountProvider, ConfigurationAccess,
518524
} catch let error as ClientError where error == .badReceipt {
519525
// If signup failed with badReceipt (HTTP 400), try login-with-receipt.
520526
// This handles returning users (e.g. "Duplicate purchase" from API).
527+
// The fallback reports the final verification outcome, so no event here.
521528
await attemptLoginWithReceiptFallback(transaction: request.transaction, callback: callback)
522529
} catch {
523-
if let urlError = error as? URLError, (urlError.code == .notConnectedToInternet) {
524-
DispatchQueue.main.async { callback?(nil, ClientError.internetUnreachable) }
525-
return
530+
let isOffline = (error as? URLError)?.code == .notConnectedToInternet
531+
let reportedError: Error = isOffline ? ClientError.internetUnreachable : error
532+
533+
// Report a verification failure only if success was not already reported
534+
// (later account-setup calls failing is not a verification failure).
535+
if !verificationSucceeded {
536+
ServiceQualityManager.shared.iapProcessingFailureEvent(origin: .signup, error: reportedError)
526537
}
527538

528-
DispatchQueue.main.async { callback?(nil, error) }
539+
DispatchQueue.main.async { callback?(nil, reportedError) }
529540
}
530541
}
531542

@@ -538,16 +549,20 @@ public final class DefaultAccountProvider: AccountProvider, ConfigurationAccess,
538549
}
539550

540551
guard let jws else {
552+
ServiceQualityManager.shared.iapProcessingFailureEvent(origin: .signup, error: ClientError.badReceipt)
541553
DispatchQueue.main.async { callback?(nil, ClientError.badReceipt) }
542554
return
543555
}
544556

545557
do {
546558
try await webServices.token(receipt: jws)
547559
} catch {
560+
ServiceQualityManager.shared.iapProcessingFailureEvent(origin: .signup, error: error)
548561
DispatchQueue.main.async { callback?(nil, ClientError.badReceipt) }
549562
return
550563
}
564+
// Receipt verified via the login-with-receipt fallback.
565+
ServiceQualityManager.shared.iapProcessingSuccessEvent(origin: .signup)
551566

552567
if let transaction = transaction {
553568
accessedStore.finishTransaction(transaction, success: true)
@@ -636,8 +651,14 @@ public final class DefaultAccountProvider: AccountProvider, ConfigurationAccess,
636651
return
637652
}
638653

654+
// A renewal IAP transaction starts processing (Kape verification path).
655+
ServiceQualityManager.shared.iapProcessingPurchaseEvent(origin: .renewal)
656+
657+
var verificationSucceeded = false
639658
do {
640659
try await webServices.processPayment(credentials: user.credentials, request: payment)
660+
verificationSucceeded = true
661+
ServiceQualityManager.shared.iapProcessingSuccessEvent(origin: .renewal)
641662

642663
if let transaction = request.transaction {
643664
accessedStore.finishTransaction(transaction, success: true)
@@ -651,6 +672,9 @@ public final class DefaultAccountProvider: AccountProvider, ConfigurationAccess,
651672
Macros.postNotification(.PIAAccountDidRefresh, [.user: user])
652673
DispatchQueue.main.async { callback?(user, nil) }
653674
} catch {
675+
if !verificationSucceeded {
676+
ServiceQualityManager.shared.iapProcessingFailureEvent(origin: .renewal, error: error)
677+
}
654678
DispatchQueue.main.async { callback?(nil, error) }
655679
}
656680
}

LocalPackages/PIALibrary/Sources/PIALibrary/ServiceQuality/ServiceQualityManager.swift

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ private let log = PIALogger.logger(for: ServiceQualityManager.self)
2727
public 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([])
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//
2+
// MockKPIAPI.swift
3+
// PIALibraryTests
4+
//
5+
// Copyright © 2026 Private Internet Access, Inc.
6+
//
7+
// This file is part of the Private Internet Access iOS Client.
8+
//
9+
// The Private Internet Access iOS Client is free software: you can redistribute it and/or
10+
// modify it under the terms of the GNU General Public License as published by the Free
11+
// Software Foundation, either version 3 of the License, or (at your option) any later version.
12+
//
13+
// The Private Internet Access iOS Client is distributed in the hope that it will be useful,
14+
// but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
15+
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
16+
// details.
17+
//
18+
// You should have received a copy of the GNU General Public License along with the Private
19+
// Internet Access iOS Client. If not, see <https://www.gnu.org/licenses/>.
20+
//
21+
22+
import Foundation
23+
import PIAKPI
24+
import XCTest
25+
26+
/// Test double for `KPIAPI` that captures submitted events. Events are submitted from a
27+
/// detached `Task` inside `ServiceQualityManager`, so tests fulfill `submitExpectation`
28+
/// to await the submission before asserting.
29+
final class MockKPIAPI: KPIAPI, @unchecked Sendable {
30+
31+
private let lock = NSLock()
32+
private var storedEvents: [KPIClientEvent] = []
33+
private var submitExpectation: XCTestExpectation?
34+
35+
var submittedEvents: [KPIClientEvent] {
36+
lock.lock()
37+
defer { lock.unlock() }
38+
return storedEvents
39+
}
40+
41+
/// Fulfilled once for every `submit(event:)` call.
42+
func expectSubmission(_ expectation: XCTestExpectation) {
43+
lock.lock()
44+
defer { lock.unlock() }
45+
submitExpectation = expectation
46+
}
47+
48+
func start() async {}
49+
func stop() async throws {}
50+
func flush() async throws {}
51+
func recentEvents() async -> [String] { [] }
52+
53+
func submit(event: KPIClientEvent) async throws {
54+
lock.lock()
55+
storedEvents.append(event)
56+
let expectation = submitExpectation
57+
lock.unlock()
58+
expectation?.fulfill()
59+
}
60+
}

0 commit comments

Comments
 (0)