Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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] = [:]
Expand All @@ -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()
)
}
Expand All @@ -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(
Expand All @@ -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())
}
Expand All @@ -287,7 +351,7 @@ final class IdentityAnalyticsClient {
func logVerificationSucceeded(
sheetController: VerificationSheetControllerProtocol
) {
var metadata = failedCanceledSucceededCommonMetadataPayload(
var metadata = flowOutcomeCommonMetadataPayload(
sheetController: sheetController
)

Expand Down Expand Up @@ -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())
}
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
]
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading