diff --git a/CHANGELOG.md b/CHANGELOG.md index 700f23b7b87c..384fc52a67dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ MINOR ## X.Y.Z - changes pending release +### Identity +* [Improved] Improved StripeIdentity analytics with richer error details and screen/camera context to help debug verification flows. + ### All * [Removed] Removed UPI support across the SDK. diff --git a/StripeIdentity/StripeIdentity/Source/Analytics/IdentityAnalyticsClient.swift b/StripeIdentity/StripeIdentity/Source/Analytics/IdentityAnalyticsClient.swift index 7b846249ae5b..79976d444862 100644 --- a/StripeIdentity/StripeIdentity/Source/Analytics/IdentityAnalyticsClient.swift +++ b/StripeIdentity/StripeIdentity/Source/Analytics/IdentityAnalyticsClient.swift @@ -36,6 +36,11 @@ enum IdentityAnalyticsClientError: AnalyticLoggableErrorV2 { } } +private enum CameraPermissionError: String, AnalyticLoggableStringErrorV2 { + case denied = "cameraPermissionDenied" + case unknown = "cameraPermissionUnknown" +} + /// Wrapper for AnalyticsClient that formats Identity-specific analytics final class IdentityAnalyticsClient { @@ -85,6 +90,15 @@ final class IdentityAnalyticsClient { case selfie } + enum CameraSource: String { + case cameraSession = "camera_session" + case imagePicker = "image_picker" + } + enum CameraEventKind: String { + case permission = "permission" + case runtimeError = "runtime_error" + } + static let sharedAnalyticsClient = AnalyticsClientV2( clientId: "mobile-identity-sdk", origin: "stripe-identity-ios" @@ -143,6 +157,36 @@ final class IdentityAnalyticsClient { } } + private func cameraMetadata( + screenName: ScreenName, + cameraSource: CameraSource, + cameraEventKind: CameraEventKind + ) -> [String: Any] { + return [ + "screen_name": screenName.rawValue, + "camera_source": cameraSource.rawValue, + "camera_event_kind": cameraEventKind.rawValue, + ] + } + + private func cameraAccessState(isGranted: Bool?) -> String { + guard let isGranted = isGranted else { + return "unknown" + } + return isGranted ? "granted" : "denied" + } + + private func cameraPermissionError(isGranted: Bool?) -> CameraPermissionError { + guard let isGranted = isGranted else { + return .unknown + } + if isGranted { + assertionFailure("cameraPermissionError should not be created for granted camera access") + return .unknown + } + return .denied + } + private func logAnalytic( _ eventName: EventName, metadata: [String: Any], @@ -219,8 +263,8 @@ final class IdentityAnalyticsClient { } } - /// Helper to create metadata common to both failed, canceled, and succeed analytic events - private func failedCanceledSucceededCommonMetadataPayload( + /// Helper to create metadata common to flow outcome analytic events + private func flowOutcomeCommonMetadataPayload( sheetController: VerificationSheetControllerProtocol ) -> [String: Any] { var metadata: [String: Any] = [:] @@ -239,13 +283,28 @@ final class IdentityAnalyticsClient { return metadata } + private func addLastScreenNameIfAvailable( + to metadata: inout [String: Any], + sheetController: VerificationSheetControllerProtocol + ) { + if let lastScreenName = sheetController.flowController.analyticsLastScreen?.analyticsScreenName.rawValue { + metadata["last_screen_name"] = lastScreenName + } + } + /// Logs an event when the verification sheet is closed private func logSheetClosed(sessionResult: String, sheetController: VerificationSheetControllerProtocol) { + var metadata = flowOutcomeCommonMetadataPayload( + sheetController: sheetController + ) + metadata["session_result"] = sessionResult + addLastScreenNameIfAvailable( + to: &metadata, + sheetController: sheetController + ) logAnalytic( .sheetClosed, - metadata: [ - "session_result": sessionResult - ], + metadata: metadata, verificationPage: try? sheetController.verificationPageResponse?.get() ) } @@ -257,7 +316,11 @@ final class IdentityAnalyticsClient { filePath: StaticString, line: UInt ) { - var metadata = failedCanceledSucceededCommonMetadataPayload( + var metadata = flowOutcomeCommonMetadataPayload( + sheetController: sheetController + ) + addLastScreenNameIfAvailable( + to: &metadata, sheetController: sheetController ) metadata["error"] = AnalyticsClientV2.serialize( @@ -273,12 +336,13 @@ final class IdentityAnalyticsClient { private func logVerificationCanceled( sheetController: VerificationSheetControllerProtocol ) { - var metadata = failedCanceledSucceededCommonMetadataPayload( + var metadata = flowOutcomeCommonMetadataPayload( + sheetController: sheetController + ) + addLastScreenNameIfAvailable( + to: &metadata, sheetController: sheetController ) - if let lastScreen = sheetController.flowController.analyticsLastScreen { - metadata["last_screen_name"] = lastScreen.analyticsScreenName.rawValue - } logAnalytic(.verificationCanceled, metadata: metadata, verificationPage: try? sheetController.verificationPageResponse?.get()) } @@ -287,7 +351,7 @@ final class IdentityAnalyticsClient { func logVerificationSucceeded( sheetController: VerificationSheetControllerProtocol ) { - var metadata = failedCanceledSucceededCommonMetadataPayload( + var metadata = flowOutcomeCommonMetadataPayload( sheetController: sheetController ) @@ -317,11 +381,15 @@ final class IdentityAnalyticsClient { /// Logs an event when a screen is presented func logScreenAppeared( screenName: ScreenName, + previousScreenName: ScreenName? = nil, sheetController: VerificationSheetControllerProtocol ) { - let metadata: [String: Any] = [ + var metadata: [String: Any] = [ "screen_name": screenName.rawValue, ] + if let previousScreenName = previousScreenName { + metadata["previous_screen_name"] = previousScreenName.rawValue + } logAnalytic(.screenAppeared, metadata: metadata, verificationPage: try? sheetController.verificationPageResponse?.get()) } @@ -330,10 +398,16 @@ final class IdentityAnalyticsClient { func logCameraError( sheetController: VerificationSheetControllerProtocol, error: Error, + screenName: ScreenName, + cameraSource: CameraSource, filePath: StaticString = #filePath, line: UInt = #line ) { - var metadata: [String: Any] = [:] + var metadata = cameraMetadata( + screenName: screenName, + cameraSource: cameraSource, + cameraEventKind: .runtimeError + ) metadata["error"] = AnalyticsClientV2.serialize( error: error, filePath: filePath, @@ -342,15 +416,74 @@ final class IdentityAnalyticsClient { logAnalytic(.cameraError, metadata: metadata, verificationPage: try? sheetController.verificationPageResponse?.get()) } - /// Logs either a permission denied or granted event when the camera permissions are checked prior to starting a camera session + /// Logs a permission analytic when camera access is checked prior to starting a camera session func logCameraPermissionsChecked( sheetController: VerificationSheetControllerProtocol, - isGranted: Bool? + isGranted: Bool?, + screenName: ScreenName, + cameraSource: CameraSource, + filePath: StaticString = #filePath, + line: UInt = #line ) { - let eventName: EventName = - (isGranted == true) ? .cameraPermissionGranted : .cameraPermissionDenied + guard isGranted != true else { + var metadata = cameraMetadata( + screenName: screenName, + cameraSource: cameraSource, + cameraEventKind: .permission + ) + metadata["camera_access_state"] = cameraAccessState(isGranted: isGranted) + logAnalytic( + .cameraPermissionGranted, + metadata: metadata, + verificationPage: try? sheetController.verificationPageResponse?.get() + ) + return + } + logCameraPermissionDeniedOrUnknown( + sheetController: sheetController, + isGranted: isGranted, + screenName: screenName, + cameraSource: cameraSource, + filePath: filePath, + line: line + ) + } - logAnalytic(eventName, metadata: [:], verificationPage: try? sheetController.verificationPageResponse?.get()) + /// Logs a permission analytic only when camera access is denied or unknown + func logCameraPermissionDeniedOrUnknown( + sheetController: VerificationSheetControllerProtocol, + isGranted: Bool?, + screenName: ScreenName, + cameraSource: CameraSource, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + guard isGranted != true else { + return + } + var metadata = cameraMetadata( + screenName: screenName, + cameraSource: cameraSource, + cameraEventKind: .permission + ) + metadata["camera_access_state"] = cameraAccessState(isGranted: isGranted) + + logAnalytic( + .cameraPermissionDenied, + metadata: metadata, + verificationPage: try? sheetController.verificationPageResponse?.get() + ) + + metadata["error"] = AnalyticsClientV2.serialize( + error: cameraPermissionError(isGranted: isGranted), + filePath: filePath, + line: line + ) + logAnalytic( + .cameraError, + metadata: metadata, + verificationPage: try? sheetController.verificationPageResponse?.get() + ) } /// Logs an event when document capture times out @@ -515,20 +648,44 @@ final class IdentityAnalyticsClient { /// Logs when an error occurs. func logGenericError( error: Error, + additionalMetadata: [String: Any] = [:], filePath: StaticString = #filePath, line: UInt = #line, sheetController: VerificationSheetControllerProtocol ) { + var metadata = additionalMetadata + metadata["error_details"] = AnalyticsClientV2.serialize( + error: error, + filePath: filePath, + line: line + ) logAnalytic( .genericError, - metadata: [ - "error_details": AnalyticsClientV2.serialize( - error: error, - filePath: filePath, - line: line - ), - ], + metadata: metadata, verificationPage: try? sheetController.verificationPageResponse?.get() ) } + + static func logUnscopedGenericError( + _ error: Error, + context: String, + additionalMetadata: [String: Any] = [:], + filePath: StaticString = #filePath, + line: UInt = #line + ) { + var eventMetadata = additionalMetadata + eventMetadata["error_context"] = context + eventMetadata["error_details"] = AnalyticsClientV2.serialize( + error: error, + filePath: filePath, + line: line + ) + + sharedAnalyticsClient.log( + eventName: EventName.genericError.rawValue, + parameters: [ + "event_metadata": eventMetadata, + ] + ) + } } diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/ImageScanner/ImageScanningConcurrencyManager.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/ImageScanner/ImageScanningConcurrencyManager.swift index a46854464bca..7f4d1c54746c 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/ImageScanner/ImageScanningConcurrencyManager.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/ImageScanner/ImageScanningConcurrencyManager.swift @@ -61,13 +61,19 @@ final class ImageScanningConcurrencyManager: ImageScanningConcurrencyManagerProt private var futureQueue: DispatchQueue = DispatchQueue(label: "com.stripe.identity.concurrent-image-scanner.futures") private let analyticsClient: IdentityAnalyticsClient + private let scannerName: IdentityAnalyticsClient.ScannerName + private let screenName: IdentityAnalyticsClient.ScreenName private let sheetController: VerificationSheetControllerProtocol init( sheetController: VerificationSheetControllerProtocol, + scannerName: IdentityAnalyticsClient.ScannerName, + screenName: IdentityAnalyticsClient.ScreenName, maxConcurrentScans: Int = kConcurrentImageScannerDefaultMaxConcurrentScans ) { self.analyticsClient = sheetController.analyticsClient + self.scannerName = scannerName + self.screenName = screenName self.sheetController = sheetController self.semaphore = DispatchSemaphore(value: maxConcurrentScans) } @@ -142,7 +148,15 @@ final class ImageScanningConcurrencyManager: ImageScanningConcurrencyManagerProt case .success(let scannerOutput): wrappedCompletion(scannerOutput) case .failure(let error): - self.analyticsClient.logGenericError(error: error, sheetController: self.sheetController) + self.analyticsClient.logGenericError( + error: error, + additionalMetadata: [ + "error_context": "image_scan", + "scanner_name": self.scannerName.rawValue, + "screen_name": self.screenName.rawValue, + ], + sheetController: self.sheetController + ) } // Track when the scan ended diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/ImageUploaders/IdentityImageUploader.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/ImageUploaders/IdentityImageUploader.swift index 42098f337ad4..ff90b7fa8cbf 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/ImageUploaders/IdentityImageUploader.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/ImageUploaders/IdentityImageUploader.swift @@ -139,6 +139,7 @@ final class IdentityImageUploader { ) ) } catch { + logUploadError(error, stage: .highResCrop, fileName: fileName) return Promise(error: error) } } @@ -174,6 +175,7 @@ final class IdentityImageUploader { jpegCompressionQuality: jpegCompressionQuality ) } catch { + logUploadError(error, stage: .imageResize, fileName: fileName) return Promise(error: error) } } @@ -208,9 +210,41 @@ final class IdentityImageUploader { fileSizeBytes: metrics.fileSizeBytes, sheetController: self.sheetController ) + } else if let self = self, + case .failure(let error) = result + { + self.logUploadError(error, stage: .imageUpload, fileName: fileName) } } } return promise } } + +private extension IdentityImageUploader { + enum UploadErrorStage: String { + case highResCrop + case imageResize + case imageUpload + } + func logUploadError( + _ error: Error, + stage: UploadErrorStage, + fileName: String, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + analyticsClient.logGenericError( + error: error, + additionalMetadata: [ + "error_context": "image_upload", + "image_upload_stage": stage.rawValue, + "file_name": fileName, + "file_purpose": configuration.filePurpose, + ], + filePath: filePath, + line: line, + sheetController: sheetController + ) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift index d5ebd6352504..6e01f90f7939 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetController.swift @@ -119,6 +119,16 @@ protocol VerificationSheetControllerProtocol: AnyObject { func transitionToDocumentCapture() } +private enum VerificationSheetControllerError: String, AnalyticLoggableStringErrorV2 { + case missingVerificationPageResponseForFallbackUpdate + case missingVerificationPageResponseForCountryNotListedTransition + case missingVerificationPageResponseForIndividualTransition + case missingVerificationPageResponseForSelfieCaptureTransition + case missingVerificationPageResponseForDocumentCaptureTransition + case missingVerificationPageResponseForPageDataTransition + case missingVerificationPageResponseForClearDataCalculation +} + final class VerificationSheetController: VerificationSheetControllerProtocol { weak var delegate: VerificationSheetControllerDelegate? @@ -282,8 +292,10 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { if resultData.needsFallback() { // Checking the buffered VerificationPageResponse, update its missings with the new missings - guard let verificationPageResponse = try? self.verificationPageResponse?.get() else { - assertionFailure("Fail to get VerificationPageResponse is nil") + guard let verificationPageResponse = self.verificationPageOrLogError( + missingError: .missingVerificationPageResponseForFallbackUpdate, + assertionMessage: "Fail to get VerificationPageResponse is nil" + ) else { return } self.verificationPageResponse = .success(verificationPageResponse.copyWithNewMissings(newMissings: resultData.requirements.missing)) @@ -464,7 +476,7 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { } func sendCannotVerifyPhoneOtpAndTransition( - completion: @escaping() -> Void + completion: @escaping () -> Void ) { apiClient.cannotPhoneVerifyOtp().observe(on: .main) { [weak self] updatedDataResult in self?.transitionWithUpdatedDataResult(result: updatedDataResult) @@ -481,9 +493,10 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { // MARK: - Transition without save func transitionToCountryNotListed(missingType: IndividualFormElement.MissingType) { - - guard let verificationPageResponse = verificationPageResponse else { - assertionFailure("verificationPageResponse is nil") + guard let verificationPageResponse = verificationPageResponseOrLogMissing( + .missingVerificationPageResponseForCountryNotListedTransition, + assertionMessage: "verificationPageResponse is nil" + ) else { return } @@ -495,8 +508,10 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { } func transitionToIndividual() { - guard let verificationPageResponse = verificationPageResponse else { - assertionFailure("verificationPageResponse is nil") + guard let verificationPageResponse = verificationPageResponseOrLogMissing( + .missingVerificationPageResponseForIndividualTransition, + assertionMessage: "verificationPageResponse is nil" + ) else { return } @@ -507,8 +522,10 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { } func transitionToSelfieCapture() { - guard let verificationPageResponse = verificationPageResponse else { - assertionFailure("verificationPageResponse is nil") + guard let verificationPageResponse = verificationPageResponseOrLogMissing( + .missingVerificationPageResponseForSelfieCaptureTransition, + assertionMessage: "verificationPageResponse is nil" + ) else { return } @@ -519,8 +536,10 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { } func transitionToDocumentCapture() { - guard let verificationPageResponse = verificationPageResponse else { - assertionFailure("verificationPageResponse is nil") + guard let verificationPageResponse = verificationPageResponseOrLogMissing( + .missingVerificationPageResponseForDocumentCaptureTransition, + assertionMessage: "verificationPageResponse is nil" + ) else { return } @@ -537,9 +556,10 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { ) { // Only mutate properties on the main thread assert(Thread.isMainThread) - - guard let verificationPageResponse = verificationPageResponse else { - assertionFailure("verificationPageResponse is nil") + guard let verificationPageResponse = verificationPageResponseOrLogMissing( + .missingVerificationPageResponseForPageDataTransition, + assertionMessage: "verificationPageResponse is nil" + ) else { return } @@ -666,10 +686,31 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { ) -> StripeAPI.VerificationPageClearData { let initialMissings: Set - do { - initialMissings = try verificationPageResponse?.get().requirements.missing ?? Set() - } catch { + if let verificationPageResponse = verificationPageResponse { + do { + initialMissings = try verificationPageResponse.get().requirements.missing + } catch { + assertionFailure("verificationPageResponse could not be read, using StripeAPI.VerificationPageFieldType.allCases as initialMissings") + analyticsClient.logGenericError( + error: error, + additionalMetadata: [ + "error_context": "clear_data_calculation", + "screen_name": flowController.analyticsLastScreen?.analyticsScreenName.rawValue ?? "unknown", + ], + sheetController: self + ) + initialMissings = Set(StripeAPI.VerificationPageFieldType.allCases) + } + } else { assertionFailure("verificationPageResponse is nil, using StripeAPI.VerificationPageFieldType.allCases as initialMissings") + analyticsClient.logGenericError( + error: VerificationSheetControllerError.missingVerificationPageResponseForClearDataCalculation, + additionalMetadata: [ + "error_context": "clear_data_calculation", + "screen_name": flowController.analyticsLastScreen?.analyticsScreenName.rawValue ?? "unknown", + ], + sheetController: self + ) initialMissings = Set(StripeAPI.VerificationPageFieldType.allCases) } let ret = StripeAPI.VerificationPageClearData.init( @@ -679,7 +720,63 @@ final class VerificationSheetController: VerificationSheetControllerProtocol { ) return ret } +} +private extension VerificationSheetController { + func verificationPageResponseOrLogMissing( + _ error: VerificationSheetControllerError, + assertionMessage: String, + filePath: StaticString = #filePath, + line: UInt = #line + ) -> Result? { + guard let verificationPageResponse = verificationPageResponse else { + assertionFailure(assertionMessage) + analyticsClient.logGenericError( + error: error, + additionalMetadata: [ + "error_context": "verification_page_response_missing", + "screen_name": flowController.analyticsLastScreen?.analyticsScreenName.rawValue ?? "unknown", + ], + filePath: filePath, + line: line, + sheetController: self + ) + return nil + } + return verificationPageResponse + } + + func verificationPageOrLogError( + missingError: VerificationSheetControllerError, + assertionMessage: String, + filePath: StaticString = #filePath, + line: UInt = #line + ) -> StripeAPI.VerificationPage? { + guard let verificationPageResponse = verificationPageResponseOrLogMissing( + missingError, + assertionMessage: assertionMessage, + filePath: filePath, + line: line + ) else { + return nil + } + do { + return try verificationPageResponse.get() + } catch { + assertionFailure(assertionMessage) + analyticsClient.logGenericError( + error: error, + additionalMetadata: [ + "error_context": "verification_page_response_read", + "screen_name": flowController.analyticsLastScreen?.analyticsScreenName.rawValue ?? "unknown", + ], + filePath: filePath, + line: line, + sheetController: self + ) + return nil + } + } } // MARK: - VerificationSheetFlowControllerDelegate diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetFlowController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetFlowController.swift index 9c6c9267d512..ecf2d0f95775 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetFlowController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/Coordinators/VerificationSheetFlowController.swift @@ -649,7 +649,16 @@ extension VerificationSheetFlowController: VerificationSheetFlowControllerProtoc switch documentScannerResult { case .failure(let error): - sheetController.analyticsClient.logGenericError(error: error, sheetController: sheetController) + sheetController.analyticsClient.logGenericError( + error: error, + additionalMetadata: [ + "error_context": "document_scanner_load", + "fallback_screen": IdentityAnalyticsClient.ScreenName.documentFileUpload.rawValue, + "require_live_capture": staticContent.documentCapture.requireLiveCapture, + "screen_name": IdentityAnalyticsClient.ScreenName.documentCapture.rawValue, + ], + sheetController: sheetController + ) // Return document upload screen if we can't load models for auto-capture return DocumentFileUploadViewController( diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/ML/Helpers/MLModelLoader.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/ML/Helpers/MLModelLoader.swift index 9a545b9c2e5f..e5bfc9a6081c 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/ML/Helpers/MLModelLoader.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/ML/Helpers/MLModelLoader.swift @@ -53,6 +53,10 @@ final class MLModelLoader { try fileManager.moveItem(at: compiledModel, to: destinationURL) return destinationURL } catch { + Self.logModelLoadingError( + error, + stage: "cache_compiled_model" + ) return nil } } @@ -83,13 +87,21 @@ final class MLModelLoader { // Check if model is already cached to file system let cachedModel = self.getCachedLocation(forRemoteURL: remoteURL) - if let mlModel = try? MLModel(contentsOf: cachedModel) { + do { + let mlModel = try MLModel(contentsOf: cachedModel) return returnedPromise.resolve(with: mlModel) + } catch { + if FileManager.default.fileExists(atPath: cachedModel.path) { + Self.logModelLoadingError( + error, + stage: "load_cached_model" + ) + + // If the model failed to load because it was corrupted, delete the artifact + try? FileManager.default.removeItem(at: cachedModel) + } } - // If the model failed to load because it was corrupted, delete the artifact - try? FileManager.default.removeItem(at: cachedModel) - self.fileDownloader.downloadFileTemporarily(from: remoteURL).chained(on: loadPromiseCacheQueue) { [weak self] tmpFileURL -> Promise in let compilePromise = Promise() @@ -141,3 +153,22 @@ final class MLModelLoader { } } } + +private extension MLModelLoader { + static func logModelLoadingError( + _ error: Error, + stage: String, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + IdentityAnalyticsClient.logUnscopedGenericError( + error, + context: "ml_model_load", + additionalMetadata: [ + "ml_model_stage": stage, + ], + filePath: filePath, + line: line + ) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/ML/IdentityMLModelLoader.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/ML/IdentityMLModelLoader.swift index 35674cff6210..93d7336c7dff 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/ML/IdentityMLModelLoader.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/ML/IdentityMLModelLoader.swift @@ -108,6 +108,11 @@ final class IdentityMLModelLoader: IdentityMLModelLoaderProtocol { ) return cacheDirectory } catch { + Self.logModelLoadingError( + error, + modelType: "shared", + stage: "create_cache_directory" + ) // If creating the subdirectory fails, use temp directory directly return tempDirectory } @@ -125,10 +130,16 @@ final class IdentityMLModelLoader: IdentityMLModelLoaderProtocol { with sheetController: VerificationSheetControllerProtocol ) { guard let idDetectorURL = URL(string: capturePageConfig.models.idDetectorUrl) else { + let error = IdentityMLModelLoaderError.malformedURL( + capturePageConfig.models.idDetectorUrl + ) + Self.logModelLoadingError( + error, + modelType: "document", + stage: "url_validation" + ) documentMLModelsPromise.reject( - with: IdentityMLModelLoaderError.malformedURL( - capturePageConfig.models.idDetectorUrl - ) + with: error ) return } @@ -136,8 +147,8 @@ final class IdentityMLModelLoader: IdentityMLModelLoaderProtocol { mlModelLoader.loadVisionModel( fromRemote: idDetectorURL ).chained { idDetectorModel in - return Promise( - value: .init( + return Promise( + value: AnyDocumentScanner( DocumentScanner( idDetectorModel: idDetectorModel, configuration: .init(from: capturePageConfig), @@ -146,6 +157,13 @@ final class IdentityMLModelLoader: IdentityMLModelLoaderProtocol { ) ) }.observe { [weak self] result in + if case .failure(let error) = result { + Self.logModelLoadingError( + error, + modelType: "document", + stage: "load" + ) + } self?.documentMLModelsPromise.fullfill(with: result) } } @@ -156,10 +174,16 @@ final class IdentityMLModelLoader: IdentityMLModelLoaderProtocol { from selfiePageConfig: StripeAPI.VerificationPageStaticContentSelfiePage ) { guard let faceDetectorURL = URL(string: selfiePageConfig.models.faceDetectorUrl) else { + let error = IdentityMLModelLoaderError.malformedURL( + selfiePageConfig.models.faceDetectorUrl + ) + Self.logModelLoadingError( + error, + modelType: "face", + stage: "url_validation" + ) faceMLModelsPromise.reject( - with: IdentityMLModelLoaderError.malformedURL( - selfiePageConfig.models.faceDetectorUrl - ) + with: error ) return } @@ -167,8 +191,8 @@ final class IdentityMLModelLoader: IdentityMLModelLoaderProtocol { mlModelLoader.loadVisionModel( fromRemote: faceDetectorURL ).chained { faceDetectorModel in - return Promise( - value: .init( + return Promise( + value: AnyFaceScanner( FaceScanner( faceDetectorModel: faceDetectorModel, configuration: .init(from: selfiePageConfig) @@ -176,7 +200,35 @@ final class IdentityMLModelLoader: IdentityMLModelLoaderProtocol { ) ) }.observe { [weak self] result in + if case .failure(let error) = result { + Self.logModelLoadingError( + error, + modelType: "face", + stage: "load" + ) + } self?.faceMLModelsPromise.fullfill(with: result) } } } + +private extension IdentityMLModelLoader { + static func logModelLoadingError( + _ error: Error, + modelType: String, + stage: String, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + IdentityAnalyticsClient.logUnscopedGenericError( + error, + context: "ml_model_load", + additionalMetadata: [ + "ml_model_type": modelType, + "ml_model_stage": stage, + ], + filePath: filePath, + line: line + ) + } +} diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentCaptureViewController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentCaptureViewController.swift index 148fa016ffad..0edc2796d4ea 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentCaptureViewController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentCaptureViewController.swift @@ -321,7 +321,9 @@ final class DocumentCaptureViewController: IdentityFlowViewController { scanner: anyDocumentScanner, concurrencyManager: concurrencyManager ?? ImageScanningConcurrencyManager( - sheetController: sheetController + sheetController: sheetController, + scannerName: .document, + screenName: .documentCapture ), cameraPermissionsManager: cameraPermissionsManager, appSettingsHelper: appSettingsHelper @@ -482,7 +484,9 @@ extension DocumentCaptureViewController: ImageScanningSessionDelegate { } sheetController.analyticsClient.logCameraError( sheetController: sheetController, - error: error + error: error, + screenName: analyticsScreenName, + cameraSource: .cameraSession ) } @@ -495,7 +499,9 @@ extension DocumentCaptureViewController: ImageScanningSessionDelegate { } sheetController.analyticsClient.logCameraPermissionsChecked( sheetController: sheetController, - isGranted: isGranted + isGranted: isGranted, + screenName: analyticsScreenName, + cameraSource: .cameraSession ) } diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentFileUploadViewController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentFileUploadViewController.swift index 8fa5b811a1e4..cc9257d3d273 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentFileUploadViewController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/DocumentFileUploadViewController.swift @@ -34,6 +34,12 @@ final class DocumentFileUploadViewController: IdentityFlowViewController { static let uploadCompleteIcon = Image.iconCheckmark.makeImage(template: true) } + private enum UploadSource: String { + case camera + case documentPicker = "document_picker" + case photoLibrary = "photo_library" + } + // MARK: - Instance Properties let imageLoadingQueue = DispatchQueue(label: "com.stripe.identity.document-image-loading") @@ -338,13 +344,11 @@ final class DocumentFileUploadViewController: IdentityFlowViewController { func selectPhotoFromLibrary() { guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { - if let sheetController { - sheetController.analyticsClient.logGenericError( - error: DocumentFileUploadViewControllerError - .imagePickerSourcePhotoLibraryUnavailable, - sheetController: sheetController - ) - } + logFileUploadError( + DocumentFileUploadViewControllerError.imagePickerSourcePhotoLibraryUnavailable, + side: currentlySelectingSide, + uploadSource: .photoLibrary + ) return } @@ -360,18 +364,16 @@ final class DocumentFileUploadViewController: IdentityFlowViewController { cameraPermissionsManager.requestCameraAccess(completeOnQueue: .main) { [weak self] granted in guard let self = self else { return } + self.logDeniedCameraPermissionIfNeeded(granted) guard granted == true else { self.showCameraPermissionsAlert() return } guard UIImagePickerController.isSourceTypeAvailable(.camera) else { - if let sheetController { - sheetController.analyticsClient.logGenericError( - error: DocumentFileUploadViewControllerError.imagePickerSourceCameraUnavailable, - sheetController: sheetController - ) - } + self.logCameraError( + DocumentFileUploadViewControllerError.imagePickerSourceCameraUnavailable + ) return } @@ -409,12 +411,11 @@ final class DocumentFileUploadViewController: IdentityFlowViewController { method: StripeAPI.VerificationPageDataDocumentFileData.FileUploadMethod ) { guard let cgImage = image.cgImage else { - if let sheetController { - sheetController.analyticsClient.logGenericError( - error: DocumentFileUploadViewControllerError.cgImageUnretrievableFromUIImage, - sheetController: sheetController - ) - } + logFileUploadError( + DocumentFileUploadViewControllerError.cgImageUnretrievableFromUIImage, + side: side, + uploadMethod: method + ) return } @@ -464,6 +465,85 @@ final class DocumentFileUploadViewController: IdentityFlowViewController { present(alert, animated: true, completion: nil) } + private func logDeniedCameraPermissionIfNeeded(_ isGranted: Bool?) { + guard let sheetController else { + return + } + sheetController.analyticsClient.logCameraPermissionDeniedOrUnknown( + sheetController: sheetController, + isGranted: isGranted, + screenName: analyticsScreenName, + cameraSource: .imagePicker + ) + } + + private func uploadSource( + for pickerSourceType: UIImagePickerController.SourceType + ) -> UploadSource { + switch pickerSourceType { + case .camera: + return .camera + case .photoLibrary, + .savedPhotosAlbum: + return .photoLibrary + @unknown default: + return .photoLibrary + } + } + + private func logFileUploadError( + _ error: Error, + side: DocumentSide? = nil, + uploadSource: UploadSource? = nil, + uploadMethod: StripeAPI.VerificationPageDataDocumentFileData.FileUploadMethod? = nil, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + guard let sheetController else { + return + } + + var additionalMetadata: [String: Any] = [ + "screen_name": analyticsScreenName.rawValue, + ] + if let side { + additionalMetadata["side"] = side.rawValue + } + if let uploadSource { + additionalMetadata["upload_source"] = uploadSource.rawValue + } + if let uploadMethod { + additionalMetadata["upload_method"] = uploadMethod.rawValue + } + + sheetController.analyticsClient.logGenericError( + error: error, + additionalMetadata: additionalMetadata, + filePath: filePath, + line: line, + sheetController: sheetController + ) + } + + private func logCameraError( + _ error: Error, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + guard let sheetController else { + return + } + + sheetController.analyticsClient.logCameraError( + sheetController: sheetController, + error: error, + screenName: analyticsScreenName, + cameraSource: .imagePicker, + filePath: filePath, + line: line + ) + } + // MARK: - Continue button func didTapContinueButton() { @@ -517,23 +597,19 @@ extension DocumentFileUploadViewController: UIImagePickerControllerDelegate { } guard let side = currentlySelectingSide else { - if let sheetController { - sheetController.analyticsClient.logGenericError( - error: DocumentFileUploadViewControllerError.documentSideNotSelected, - sheetController: sheetController - ) - } + logFileUploadError( + DocumentFileUploadViewControllerError.documentSideNotSelected, + uploadSource: uploadSource(for: picker.sourceType) + ) return picker.dismiss(animated: true, completion: nil) } guard let image = info[.originalImage] as? UIImage else { - if let sheetController { - sheetController.analyticsClient.logGenericError( - error: DocumentFileUploadViewControllerError.imagePickerImageUnretrievable, - sheetController: sheetController - ) - - } + logFileUploadError( + DocumentFileUploadViewControllerError.imagePickerImageUnretrievable, + side: side, + uploadSource: uploadSource(for: picker.sourceType) + ) return picker.dismiss(animated: true, completion: nil) } @@ -562,22 +638,19 @@ extension DocumentFileUploadViewController: UIDocumentPickerDelegate { } guard let side = currentlySelectingSide else { - if let sheetController { - sheetController.analyticsClient.logGenericError( - error: DocumentFileUploadViewControllerError.documentSideNotSelected, - sheetController: sheetController - ) - } + logFileUploadError( + DocumentFileUploadViewControllerError.documentSideNotSelected, + uploadSource: .documentPicker + ) return } guard let url = urls.first else { - if let sheetController { - sheetController.analyticsClient.logGenericError( - error: DocumentFileUploadViewControllerError.documentPickerDidNotReturnURL, - sheetController: sheetController - ) - } + logFileUploadError( + DocumentFileUploadViewControllerError.documentPickerDidNotReturnURL, + side: side, + uploadSource: .documentPicker + ) return } @@ -588,21 +661,20 @@ extension DocumentFileUploadViewController: UIDocumentPickerDelegate { do { let data = try Data(contentsOf: url) guard let image = UIImage(data: data) else { - if let sheetController { - sheetController.analyticsClient.logGenericError( - error: DocumentFileUploadViewControllerError - .documentPickerDataNotFormattedAsImage, - sheetController: sheetController - ) - } + self.logFileUploadError( + DocumentFileUploadViewControllerError.documentPickerDataNotFormattedAsImage, + side: side, + uploadSource: .documentPicker + ) return } self.upload(image: image, for: side, method: .fileUpload) } catch { - - if let sheetController { - sheetController.analyticsClient.logGenericError(error: error, sheetController: sheetController) - } + self.logFileUploadError( + error, + side: side, + uploadSource: .documentPicker + ) } self.setIsLoadingImageFromFile(false, for: side) DispatchQueue.main.async { [weak self] in diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/ErrorViewController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/ErrorViewController.swift index 4673b7513a15..f34621b90683 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/ErrorViewController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/ErrorViewController.swift @@ -177,6 +177,10 @@ extension ErrorViewController { if let sheetController = sheetController { sheetController.analyticsClient.logGenericError( error: error, + additionalMetadata: [ + "error_context": "error_screen", + "screen_name": analyticsScreenName.rawValue, + ], filePath: filePath, line: line, sheetController: sheetController diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/IdentityFlowViewController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/IdentityFlowViewController.swift index 274c0c105e6a..1d499dba9032 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/IdentityFlowViewController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/IdentityFlowViewController.swift @@ -16,6 +16,7 @@ class IdentityFlowViewController: UIViewController { private let flowView = IdentityFlowView() private var navBarBackgroundColor: UIColor? + private var previousScreenNameForScreenAppearedAnalytic: IdentityAnalyticsClient.ScreenName? let analyticsScreenName: IdentityAnalyticsClient.ScreenName @@ -71,6 +72,9 @@ class IdentityFlowViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + previousScreenNameForScreenAppearedAnalytic = ( + transitionCoordinator?.viewController(forKey: .from) as? IdentityFlowViewController + )?.analyticsScreenName navigationController?.setNavigationBarBackgroundColor(with: navBarBackgroundColor) } @@ -79,12 +83,15 @@ class IdentityFlowViewController: UIViewController { guard let sheetController = sheetController else { return } + let previousScreenName = previousScreenNameForScreenAppearedAnalytic + previousScreenNameForScreenAppearedAnalytic = nil sheetController.analyticsClient.stopTrackingTimeToScreenAndLogIfNeeded( to: analyticsScreenName, sheetController: sheetController ) sheetController.analyticsClient.logScreenAppeared( screenName: analyticsScreenName, + previousScreenName: previousScreenName, sheetController: sheetController ) } @@ -100,7 +107,14 @@ class IdentityFlowViewController: UIViewController { try flowView.configure(with: viewModel) } catch { if let sheetController = sheetController { - sheetController.analyticsClient.logGenericError(error: error, sheetController: sheetController) + sheetController.analyticsClient.logGenericError( + error: error, + additionalMetadata: [ + "error_context": "flow_view_configure", + "screen_name": analyticsScreenName.rawValue, + ], + sheetController: sheetController + ) } } navBarBackgroundColor = viewModel.headerViewModel?.backgroundColor @@ -169,6 +183,10 @@ extension IdentityFlowViewController { if let sheetController = self.sheetController { sheetController.analyticsClient.logGenericError( error: BottomSheetError(loggableType: errorContent), + additionalMetadata: [ + "error_context": "bottomsheet", + "screen_name": analyticsScreenName.rawValue, + ], sheetController: sheetController ) } diff --git a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/SelfieCaptureViewController.swift b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/SelfieCaptureViewController.swift index 181a6fe791bc..a0cdd78d9fb0 100644 --- a/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/SelfieCaptureViewController.swift +++ b/StripeIdentity/StripeIdentity/Source/NativeComponents/ViewControllers/SelfieCaptureViewController.swift @@ -237,7 +237,9 @@ final class SelfieCaptureViewController: IdentityFlowViewController { scanner: anyFaceScanner, concurrencyManager: concurrencyManager ?? ImageScanningConcurrencyManager( - sheetController: sheetController + sheetController: sheetController, + scannerName: .selfie, + screenName: .selfieCapture ), cameraPermissionsManager: cameraPermissionsManager, appSettingsHelper: appSettingsHelper @@ -342,7 +344,9 @@ extension SelfieCaptureViewController: ImageScanningSessionDelegate { } sheetController.analyticsClient.logCameraError( sheetController: sheetController, - error: error + error: error, + screenName: analyticsScreenName, + cameraSource: .cameraSession ) } @@ -355,7 +359,9 @@ extension SelfieCaptureViewController: ImageScanningSessionDelegate { } sheetController.analyticsClient.logCameraPermissionsChecked( sheetController: sheetController, - isGranted: isGranted + isGranted: isGranted, + screenName: analyticsScreenName, + cameraSource: .cameraSession ) } diff --git a/StripeIdentity/StripeIdentity/Source/WebWrapper/VerificationFlowWebView.swift b/StripeIdentity/StripeIdentity/Source/WebWrapper/VerificationFlowWebView.swift index 5fe2b001bdc8..14173d3bad14 100644 --- a/StripeIdentity/StripeIdentity/Source/WebWrapper/VerificationFlowWebView.swift +++ b/StripeIdentity/StripeIdentity/Source/WebWrapper/VerificationFlowWebView.swift @@ -31,6 +31,38 @@ protocol VerificationFlowWebViewDelegate: AnyObject { /// - view: The view who's opening a URL. /// - url: The new URL that should be opened in a new target. func verificationFlowWebView(_ view: VerificationFlowWebView, didOpenURLInNewTarget url: URL) + + /// The view failed to load web content. + /// - Parameters: + /// - view: The view that failed to load content. + /// - error: The loading error. + /// - stage: The webview loading stage that failed. + func verificationFlowWebView( + _ view: VerificationFlowWebView, + didFailLoadingWith error: Error, + stage: VerificationFlowWebViewFailureStage + ) +} + +@available(iOS 14.3, *) +extension VerificationFlowWebViewDelegate { + func verificationFlowWebView( + _ view: VerificationFlowWebView, + didFailLoadingWith error: Error, + stage: VerificationFlowWebViewFailureStage + ) {} +} + +@available(iOS 14.3, *) +private enum VerificationFlowWebViewError: String, AnalyticLoggableStringErrorV2 { + case webContentProcessTerminated +} + +@available(iOS 14.3, *) +enum VerificationFlowWebViewFailureStage: String { + case navigation = "navigation" + case provisionalNavigation = "provisional_navigation" + case webContentProcessTerminated = "web_content_process_terminated" } /// Basic WebView that displays a spinner while the page is loading or an error message with a "Try Again" button @@ -225,6 +257,15 @@ extension VerificationFlowWebView { fileprivate func didTapTryAgainButton() { load() } + + fileprivate func handleLoadError(_ error: Error, stage: VerificationFlowWebViewFailureStage) { + delegate?.verificationFlowWebView( + self, + didFailLoadingWith: error, + stage: stage + ) + displayRetryMessage() + } } // MARK: - WKNavigationDelegate @@ -240,7 +281,7 @@ extension VerificationFlowWebView: WKNavigationDelegate { } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - displayRetryMessage() + handleLoadError(error, stage: .navigation) } func webView( @@ -248,7 +289,12 @@ extension VerificationFlowWebView: WKNavigationDelegate { didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error ) { - displayRetryMessage() + handleLoadError(error, stage: .provisionalNavigation) + } + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + let error = VerificationFlowWebViewError.webContentProcessTerminated + handleLoadError(error, stage: .webContentProcessTerminated) } } diff --git a/StripeIdentity/StripeIdentity/Source/WebWrapper/VerificationFlowWebViewController.swift b/StripeIdentity/StripeIdentity/Source/WebWrapper/VerificationFlowWebViewController.swift index e9601a79b0ab..123677d69631 100644 --- a/StripeIdentity/StripeIdentity/Source/WebWrapper/VerificationFlowWebViewController.swift +++ b/StripeIdentity/StripeIdentity/Source/WebWrapper/VerificationFlowWebViewController.swift @@ -179,6 +179,28 @@ extension VerificationFlowWebViewController { fileprivate func didTapCloseButton() { dismiss(animated: true, completion: nil) } + + fileprivate func logLoadingFailure( + _ error: Error, + stage: VerificationFlowWebViewFailureStage, + filePath: StaticString = #filePath, + line: UInt = #line + ) { + IdentityAnalyticsClient.sharedAnalyticsClient.log( + eventName: IdentityAnalyticsClient.EventName.genericError.rawValue, + parameters: [ + "event_metadata": [ + "error_context": "verification_webview", + "webview_failure_stage": stage.rawValue, + "error_details": AnalyticsClientV2.serialize( + error: error, + filePath: filePath, + line: line + ), + ], + ] + ) + } } // MARK: - VerificationFlowWebViewDelegate @@ -201,4 +223,12 @@ extension VerificationFlowWebViewController: VerificationFlowWebViewDelegate { func verificationFlowWebView(_ view: VerificationFlowWebView, didOpenURLInNewTarget url: URL) { UIApplication.shared.open(url) } + + func verificationFlowWebView( + _ view: VerificationFlowWebView, + didFailLoadingWith error: Error, + stage: VerificationFlowWebViewFailureStage + ) { + logLoadingFailure(error, stage: stage) + } } diff --git a/StripeIdentity/StripeIdentityTests/Unit/Analytics/IdentityAnalyticsClientTest.swift b/StripeIdentity/StripeIdentityTests/Unit/Analytics/IdentityAnalyticsClientTest.swift index 4379754a30d8..06a74f6da5f8 100644 --- a/StripeIdentity/StripeIdentityTests/Unit/Analytics/IdentityAnalyticsClientTest.swift +++ b/StripeIdentity/StripeIdentityTests/Unit/Analytics/IdentityAnalyticsClientTest.swift @@ -6,7 +6,6 @@ // @_spi(STP) import StripeCoreTestUtils -import StripeCoreTestUtils import XCTest @_spi(STP) @testable import StripeCore @@ -62,4 +61,18 @@ final class IdentityAnalyticsClientTest: XCTestCase { XCTAssertTrue(mockAnalyticsClient.loggedAnalyticsPayloads.contains(where: { $0["event_name"] as? String == IdentityAnalyticsClient.EventName.screenAppeared.rawValue })) } + func testLogScreenAppearedIncludesPreviousScreenNameIfProvided() throws { + sheetController.verificationPageResponse = .success(try VerificationPageMock.response200NoExp.make()) + + self.analyticsClient.logScreenAppeared( + screenName: .documentWarmup, + previousScreenName: .biometricConsent, + sheetController: sheetController + ) + + let analytic = mockAnalyticsClient.loggedAnalyticsPayloads.first + XCTAssert(analytic: analytic, hasMetadata: "screen_name", withValue: "document_warmup") + XCTAssert(analytic: analytic, hasMetadata: "previous_screen_name", withValue: "consent") + } + } diff --git a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentCaptureViewControllerTest.swift b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentCaptureViewControllerTest.swift index 69ba6e74b041..ce78a199d1e4 100644 --- a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentCaptureViewControllerTest.swift +++ b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentCaptureViewControllerTest.swift @@ -428,11 +428,36 @@ final class DocumentCaptureViewControllerTest: XCTestCase { expectedState: .noCameraAccess, expectedButtonState: .enabled ) + let analytic = mockAnalyticsClient.loggedAnalyticPayloads( + withEventName: "camera_permission_denied" + ).first + XCTAssert(analytic: analytic, hasMetadata: "screen_name", withValue: "live_capture") + XCTAssert( + analytic: analytic, + hasMetadata: "camera_source", + withValue: "camera_session" + ) + XCTAssert(analytic: analytic, hasMetadata: "camera_event_kind", withValue: "permission") + XCTAssert(analytic: analytic, hasMetadata: "camera_access_state", withValue: "denied") XCTAssertEqual( mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_permission_granted") .count, 0 ) + + let errorAnalytic = mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_error") + .first + XCTAssert(analytic: errorAnalytic, hasMetadata: "screen_name", withValue: "live_capture") + XCTAssert( + analytic: errorAnalytic, + hasMetadata: "camera_source", + withValue: "camera_session" + ) + XCTAssert(analytic: errorAnalytic, hasMetadata: "camera_event_kind", withValue: "permission") + XCTAssert(analytic: errorAnalytic, hasMetadata: "camera_access_state", withValue: "denied") + let error = (errorAnalytic?["event_metadata"] as? [String: Any])?["error"] as? [String: Any] + XCTAssertEqual(error?["type"] as? String, "cameraPermissionDenied") + XCTAssertEqual(error?["file"] as? String, "DocumentCaptureViewController.swift") } func testCameraSessionFailedConfigure() { @@ -464,9 +489,16 @@ final class DocumentCaptureViewControllerTest: XCTestCase { code: 100, fileName: "DocumentCaptureViewController.swift" ) + XCTAssert(analytic: analytic, hasMetadata: "screen_name", withValue: "live_capture") + XCTAssert( + analytic: analytic, + hasMetadata: "camera_source", + withValue: "camera_session" + ) + XCTAssert(analytic: analytic, hasMetadata: "camera_event_kind", withValue: "runtime_error") } - func testCameraAccessGrantedAnalytic() { + func testCameraAccessGrantedLogsExistingAnalytic() { // Mock collected data for analytics mockSheetController.collectedData = VerificationPageDataUpdateMock.default.collectedData! @@ -477,11 +509,20 @@ final class DocumentCaptureViewControllerTest: XCTestCase { grantCameraAccess(granted: true) // Verify analytics + XCTAssertEqual( + mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_permission_granted") + .count, + 1 + ) XCTAssertEqual( mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_permission_denied") .count, 0 ) + XCTAssertEqual( + mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_error").count, + 0 + ) } func testSettingsButton() { diff --git a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentFileUploadViewControllerTest.swift b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentFileUploadViewControllerTest.swift index d0866d8479a4..c7ccc56ca28c 100644 --- a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentFileUploadViewControllerTest.swift +++ b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/DocumentFileUploadViewControllerTest.swift @@ -9,6 +9,7 @@ import Foundation @_spi(STP) import StripeCameraCoreTestUtils @_spi(STP) import StripeCore +@_spi(STP) import StripeCoreTestUtils import UIKit import XCTest @@ -20,6 +21,7 @@ final class DocumentFileUploadViewControllerTest: XCTestCase { var mockCameraPermissionsManager: MockCameraPermissionsManager! var mockAppSettingsHelper: MockAppSettingsHelper! var mockSheetController: VerificationSheetControllerMock! + var mockAnalyticsClient: MockAnalyticsClientV2! let mockImage = CapturedImageMock.frontDriversLicense.image let mockImageURL = CapturedImageMock.frontDriversLicense.url @@ -31,7 +33,13 @@ final class DocumentFileUploadViewControllerTest: XCTestCase { mockCameraPermissionsManager = .init() mockAppSettingsHelper = .init() mockAppSettingsHelper.canOpenAppSettings = true - mockSheetController = .init() + mockAnalyticsClient = .init() + mockSheetController = .init( + analyticsClient: IdentityAnalyticsClient( + verificationSessionId: "", + analyticsClient: mockAnalyticsClient + ) + ) } func testIdCardFront() { @@ -115,6 +123,45 @@ final class DocumentFileUploadViewControllerTest: XCTestCase { return XCTFail("Expected UIAlertController") } XCTAssertEqual(alertController.actions.map { $0.title }, ["App Settings", "OK"]) + + let analytic = mockAnalyticsClient.loggedAnalyticPayloads( + withEventName: "camera_permission_denied" + ).first + XCTAssert(analytic: analytic, hasMetadata: "screen_name", withValue: "file_upload") + XCTAssert(analytic: analytic, hasMetadata: "camera_source", withValue: "image_picker") + XCTAssert(analytic: analytic, hasMetadata: "camera_event_kind", withValue: "permission") + XCTAssert(analytic: analytic, hasMetadata: "camera_access_state", withValue: "denied") + + let errorAnalytic = mockAnalyticsClient.loggedAnalyticPayloads( + withEventName: "camera_error" + ).first + XCTAssert(analytic: errorAnalytic, hasMetadata: "screen_name", withValue: "file_upload") + XCTAssert(analytic: errorAnalytic, hasMetadata: "camera_source", withValue: "image_picker") + XCTAssert(analytic: errorAnalytic, hasMetadata: "camera_event_kind", withValue: "permission") + XCTAssert(analytic: errorAnalytic, hasMetadata: "camera_access_state", withValue: "denied") + let error = (errorAnalytic?["event_metadata"] as? [String: Any])?["error"] as? [String: Any] + XCTAssertEqual(error?["type"] as? String, "cameraPermissionDenied") + XCTAssertEqual(error?["file"] as? String, "DocumentFileUploadViewController.swift") + } + + func testTakePhotoCameraPermissionsGrantedDoesNotLogAnalytics() { + let vc = makeViewController() + vc.didTapSelect(for: .front, from: UIButton()) + vc.takePhoto() + XCTAssertTrue(mockCameraPermissionsManager.didRequestCameraAccess) + + mockCameraPermissionsManager.respondToRequest(granted: true) + wait(for: [mockCameraPermissionsManager.didCompleteExpectation], timeout: 1) + XCTAssertEqual( + mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_permission_granted") + .count, + 0 + ) + XCTAssertEqual( + mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_permission_denied") + .count, + 0 + ) } func testSelectFileFromSystem() { diff --git a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/SelfieWarmupViewControllerTest.swift b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/SelfieWarmupViewControllerTest.swift index 0cdd880f2741..84e67e953ce6 100644 --- a/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/SelfieWarmupViewControllerTest.swift +++ b/StripeIdentity/StripeIdentityTests/Unit/NativeComponents/ViewControllers/SelfieWarmupViewControllerTest.swift @@ -6,8 +6,14 @@ // import Foundation +@_spi(STP) import StripeCameraCoreTestUtils +@_spi(STP) import StripeCore +@_spi(STP) import StripeCoreTestUtils import XCTest +// swift-format-ignore +@testable @_spi(STP) import StripeCameraCore + @testable import StripeIdentity final class SelfieWarmupViewControllerTest: XCTestCase { @@ -33,3 +39,147 @@ final class SelfieWarmupViewControllerTest: XCTestCase { } } + +final class SelfieCaptureViewControllerTest: XCTestCase { + private let mockCameraSession = MockTestCameraSession() + private let mockConcurrencyManager = ImageScanningConcurrencyManagerMock() + private let mockCameraPermissionsManager = MockCameraPermissionsManager() + private let mockAppSettingsHelper = MockAppSettingsHelper() + private let mockError = NSError(domain: "mock_error", code: 100) + + private var mockAnalyticsClient: MockAnalyticsClientV2! + private var mockSheetController: VerificationSheetControllerMock! + private var mockFaceScanner: FaceScannerMock! + private var mockSelfieUploader: SelfieUploaderProtocol! + + override func setUp() { + super.setUp() + + mockAnalyticsClient = .init() + mockSheetController = .init( + analyticsClient: IdentityAnalyticsClient( + verificationSessionId: "", + analyticsClient: mockAnalyticsClient + ) + ) + mockFaceScanner = .init() + mockSelfieUploader = SelfieUploaderMock() + } + + func testRequestCameraAccessDeniedLogsAnalytics() { + let vc = makeViewController() + + vc.viewWillAppear(false) + mockCameraPermissionsManager.respondToRequest(granted: false) + wait(for: [mockCameraPermissionsManager.didCompleteExpectation], timeout: 1) + + let analytic = mockAnalyticsClient.loggedAnalyticPayloads( + withEventName: "camera_permission_denied" + ).first + XCTAssert(analytic: analytic, hasMetadata: "screen_name", withValue: "selfie") + XCTAssert( + analytic: analytic, + hasMetadata: "camera_source", + withValue: "camera_session" + ) + XCTAssert(analytic: analytic, hasMetadata: "camera_event_kind", withValue: "permission") + XCTAssert(analytic: analytic, hasMetadata: "camera_access_state", withValue: "denied") + + let errorAnalytic = mockAnalyticsClient.loggedAnalyticPayloads( + withEventName: "camera_error" + ).first + XCTAssert(analytic: errorAnalytic, hasMetadata: "screen_name", withValue: "selfie") + XCTAssert( + analytic: errorAnalytic, + hasMetadata: "camera_source", + withValue: "camera_session" + ) + XCTAssert(analytic: errorAnalytic, hasMetadata: "camera_event_kind", withValue: "permission") + XCTAssert(analytic: errorAnalytic, hasMetadata: "camera_access_state", withValue: "denied") + let error = (errorAnalytic?["event_metadata"] as? [String: Any])?["error"] as? [String: Any] + XCTAssertEqual(error?["type"] as? String, "cameraPermissionDenied") + XCTAssertEqual(error?["file"] as? String, "SelfieCaptureViewController.swift") + } + + func testRequestCameraAccessGrantedLogsExistingAnalytic() { + let vc = makeViewController() + + vc.viewWillAppear(false) + mockCameraPermissionsManager.respondToRequest(granted: true) + wait(for: [mockCameraPermissionsManager.didCompleteExpectation], timeout: 1) + + XCTAssertEqual( + mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_permission_granted") + .count, + 1 + ) + XCTAssertEqual( + mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_permission_denied") + .count, + 0 + ) + XCTAssertEqual( + mockAnalyticsClient.loggedAnalyticPayloads(withEventName: "camera_error").count, + 0 + ) + } + + func testCameraSessionFailedConfigureLogsAnalytics() { + let vc = makeViewController() + + vc.viewWillAppear(false) + mockCameraPermissionsManager.respondToRequest(granted: true) + wait(for: [mockCameraPermissionsManager.didCompleteExpectation], timeout: 1) + + mockCameraSession.respondToConfigureSession(setupResult: .failed(error: mockError)) + wait(for: [mockCameraSession.configureSessionCompletionExp], timeout: 1) + + let analytic = mockAnalyticsClient.loggedAnalyticPayloads( + withEventName: "camera_error" + ).first + XCTAssert( + analytic: analytic, + hasMetadataError: "error", + withDomain: "mock_error", + code: 100, + fileName: "SelfieCaptureViewController.swift" + ) + XCTAssert(analytic: analytic, hasMetadata: "screen_name", withValue: "selfie") + XCTAssert( + analytic: analytic, + hasMetadata: "camera_source", + withValue: "camera_session" + ) + XCTAssert(analytic: analytic, hasMetadata: "camera_event_kind", withValue: "runtime_error") + } +} + +private extension SelfieCaptureViewControllerTest { + func makeViewController( + initialState: SelfieCaptureViewController.State = .initial + ) -> SelfieCaptureViewController { + return SelfieCaptureViewController( + initialState: initialState, + apiConfig: SelfieWarmupViewControllerTest.mockVerificationPage.selfie!, + sheetController: mockSheetController, + cameraSession: mockCameraSession, + selfieUploader: mockSelfieUploader, + anyFaceScanner: .init(mockFaceScanner), + concurrencyManager: mockConcurrencyManager, + cameraPermissionsManager: mockCameraPermissionsManager, + appSettingsHelper: mockAppSettingsHelper + ) + } +} + +private final class SelfieUploaderMock: SelfieUploaderProtocol { + var uploadFuture: Future? + + func uploadImages(_ capturedImages: FaceCaptureData) { + // no-op + } + + func reset() { + uploadFuture = nil + } +}