field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

FieldTelemetry.swift (16567B)


      1 import Foundation
      2 import RadrootsKit
      3 
      4 final class FieldTelemetry: @unchecked Sendable {
      5     static let shared = FieldTelemetry.configured()
      6 
      7     private let sink: any RadrootsTelemetry
      8     private let minimumLevel: RadrootsTelemetryLevel
      9     private let recordedEventsProvider: (@Sendable () async -> [RadrootsTelemetryEvent])?
     10 
     11     init(
     12         sink: any RadrootsTelemetry,
     13         minimumLevel: RadrootsTelemetryLevel = .info,
     14         recordedEventsProvider: (@Sendable () async -> [RadrootsTelemetryEvent])? = nil
     15     ) {
     16         self.sink = sink
     17         self.minimumLevel = minimumLevel
     18         self.recordedEventsProvider = recordedEventsProvider
     19     }
     20 
     21     static func configured(
     22         bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "dev.local.radroots",
     23         loggingSettings: LoggingSettings = .load()
     24     ) -> FieldTelemetry {
     25         let minimumLevel = telemetryMinimumLevel(from: loggingSettings.level)
     26         let appleTelemetry = RadrootsAppleLoggerTelemetry(subsystem: bundleIdentifier)
     27         #if DEBUG
     28         if FieldUITestHarness.isRequested {
     29             let recorder = FieldUITestRecordingTelemetry()
     30             return FieldTelemetry(
     31                 sink: RadrootsMultiplexTelemetry([appleTelemetry, recorder]),
     32                 minimumLevel: minimumLevel,
     33                 recordedEventsProvider: { await recorder.recordedEvents }
     34             )
     35         }
     36         #endif
     37         return FieldTelemetry(sink: appleTelemetry, minimumLevel: minimumLevel)
     38     }
     39 
     40     func record(
     41         name: String,
     42         category: String = "field_ios",
     43         level: RadrootsTelemetryLevel = .info,
     44         message: String? = nil,
     45         fields: [RadrootsTelemetryField] = []
     46     ) {
     47         Task {
     48             await recordAsync(
     49                 name: name,
     50                 category: category,
     51                 level: level,
     52                 message: message,
     53                 fields: fields
     54             )
     55         }
     56     }
     57 
     58     func recordAsync(
     59         name: String,
     60         category: String = "field_ios",
     61         level: RadrootsTelemetryLevel = .info,
     62         message: String? = nil,
     63         fields: [RadrootsTelemetryField] = []
     64     ) async {
     65         guard level >= minimumLevel else {
     66             return
     67         }
     68         guard let event = try? RadrootsTelemetryEvent(
     69             name: name,
     70             category: category,
     71             level: level,
     72             message: message,
     73             fields: fields
     74         ) else {
     75             return
     76         }
     77         await sink.record(event)
     78     }
     79 
     80     func runtimeLoggingInitialized(settings: LoggingSettings) {
     81         record(
     82             name: "field_ios.runtime.logging_initialized",
     83             level: .info,
     84             fields: [
     85                 try? .bool("stdout_enabled", settings.stdout),
     86                 try? .bool("file_enabled", settings.fileEnabled),
     87                 try? .string("logging_filter", settings.level ?? "unset"),
     88             ].compactMap { $0 }
     89         )
     90     }
     91 
     92     func appStartupBegan() {
     93         record(name: "field_ios.startup.begin", level: .notice)
     94     }
     95 
     96     func appStartupSucceeded(
     97         storedIdentityAvailable: Bool,
     98         runtimeIdentityReady: Bool,
     99         locked: Bool
    100     ) {
    101         record(
    102             name: "field_ios.startup.success",
    103             level: .notice,
    104             fields: [
    105                 try? .bool("stored_identity_available", storedIdentityAvailable),
    106                 try? .bool("runtime_identity_ready", runtimeIdentityReady),
    107                 try? .bool("identity_locked", locked)
    108             ].compactMap { $0 }
    109         )
    110     }
    111 
    112     func appStartupFailed(_ error: Error) {
    113         record(
    114             name: "field_ios.startup.failure",
    115             level: .error,
    116             fields: [
    117                 try? .string("outcome", Self.outcome(for: error))
    118             ].compactMap { $0 }
    119         )
    120     }
    121 
    122     func relayStatusChanged(
    123         connectedCount: UInt32,
    124         connectingCount: UInt32,
    125         configuredRelayCount: Int,
    126         light: String
    127     ) {
    128         record(
    129             name: "field_ios.relay.status_changed",
    130             level: light == "red" ? .warning : .info,
    131             fields: [
    132                 try? .integer("connected_count", Int64(connectedCount)),
    133                 try? .integer("connecting_count", Int64(connectingCount)),
    134                 try? .integer("configured_relay_count", configuredRelayCount),
    135                 try? .string("relay_light", light)
    136             ].compactMap { $0 }
    137         )
    138     }
    139 
    140     func identityCustody(action: String, outcome: String) {
    141         record(
    142             name: "field_ios.identity_custody.\(action)",
    143             level: outcome == "success" ? .info : .warning,
    144             fields: [
    145                 try? .string("outcome", outcome)
    146             ].compactMap { $0 }
    147         )
    148     }
    149 
    150     func userPresence(action: FieldUserPresenceAction, outcome: String) {
    151         record(
    152             name: "field_ios.user_presence.\(action.telemetryName)",
    153             level: outcome == "success" ? .info : .warning,
    154             fields: [
    155                 try? .string("outcome", outcome)
    156             ].compactMap { $0 }
    157         )
    158     }
    159 
    160     func captureSupportRefreshed(
    161         support: FieldCaptureSupportState,
    162         recordCount: Int,
    163         outcome: String
    164     ) {
    165         record(
    166             name: "field_ios.capture.support_refreshed",
    167             level: outcome == "success" ? .info : .warning,
    168             fields: [
    169                 try? .string("outcome", outcome),
    170                 try? .bool("photo_import_available", support.photoImportAvailable),
    171                 try? .bool("camera_photo_available", support.cameraPhotoAvailable),
    172                 try? .bool("document_scanner_available", support.documentScannerAvailable),
    173                 try? .integer("record_count", recordCount)
    174             ].compactMap { $0 }
    175         )
    176     }
    177 
    178     func captureOperation(
    179         operation: FieldCaptureIntakeOperation,
    180         outcome: String,
    181         recordCount: Int,
    182         recoveryAction: FieldExternalActionRecovery?
    183     ) {
    184         record(
    185             name: "field_ios.capture.\(operation.telemetryName)",
    186             level: outcome == "success" ? .info : .warning,
    187             fields: [
    188                 try? .string("outcome", outcome),
    189                 try? .integer("record_count", recordCount),
    190                 recoveryAction.map { try? .string("recovery_action", $0.rawValue) } ?? nil
    191             ].compactMap { $0 }
    192         )
    193     }
    194 
    195     func documentInterchange(operation: String, outcome: String, relayCount: Int? = nil) {
    196         record(
    197             name: "field_ios.document_interchange.\(operation)",
    198             level: outcome == "success" ? .info : .warning,
    199             fields: [
    200                 try? .string("outcome", outcome),
    201                 relayCount.map { try? .integer("relay_count", $0) } ?? nil
    202             ].compactMap { $0 }
    203         )
    204     }
    205 
    206     func externalAction(
    207         operation: String,
    208         kind: RadrootsExternalActionDestinationKind?,
    209         outcome: String
    210     ) {
    211         record(
    212             name: "field_ios.external_action.\(operation)",
    213             level: outcome == "success" ? .info : .warning,
    214             fields: [
    215                 try? .string("outcome", outcome),
    216                 kind.map { try? .string("destination_kind", $0.rawValue) } ?? nil
    217             ].compactMap { $0 }
    218         )
    219     }
    220 
    221     func backgroundExecution(
    222         operation: String,
    223         outcome: String,
    224         taskCount: Int? = nil,
    225         stagedBlobCount: Int? = nil,
    226         transferCount: Int? = nil,
    227         relayConnectedCount: UInt32? = nil,
    228         relayConnectingCount: UInt32? = nil,
    229         identityUnlocked: Bool? = nil,
    230         reason: String? = nil
    231     ) {
    232         let expectedOutcome = outcome == "success" || outcome.hasPrefix("skipped")
    233         var fields: [RadrootsTelemetryField] = []
    234         if let field = try? RadrootsTelemetryField.string("outcome", outcome) {
    235             fields.append(field)
    236         }
    237         if let taskCount,
    238            let field = try? RadrootsTelemetryField.integer("task_count", taskCount) {
    239             fields.append(field)
    240         }
    241         if let stagedBlobCount,
    242            let field = try? RadrootsTelemetryField.integer("staged_blob_count", stagedBlobCount) {
    243             fields.append(field)
    244         }
    245         if let transferCount,
    246            let field = try? RadrootsTelemetryField.integer("transfer_count", transferCount) {
    247             fields.append(field)
    248         }
    249         if let relayConnectedCount,
    250            let field = try? RadrootsTelemetryField.integer("relay_connected_count", Int64(relayConnectedCount)) {
    251             fields.append(field)
    252         }
    253         if let relayConnectingCount,
    254            let field = try? RadrootsTelemetryField.integer("relay_connecting_count", Int64(relayConnectingCount)) {
    255             fields.append(field)
    256         }
    257         if let identityUnlocked,
    258            let field = try? RadrootsTelemetryField.bool("identity_unlocked", identityUnlocked) {
    259             fields.append(field)
    260         }
    261         if let reason,
    262            let field = try? RadrootsTelemetryField.string("reason", reason) {
    263             fields.append(field)
    264         }
    265         record(
    266             name: "field_ios.background_execution.\(operation)",
    267             level: expectedOutcome ? .info : .warning,
    268             fields: fields
    269         )
    270     }
    271 
    272     func recordedEventsForUITest() async -> [RadrootsTelemetryEvent] {
    273         guard let recordedEventsProvider else {
    274             return []
    275         }
    276         return await recordedEventsProvider()
    277     }
    278 
    279     private static func telemetryMinimumLevel(from filter: String?) -> RadrootsTelemetryLevel {
    280         guard let filter else {
    281             return .info
    282         }
    283         let lowered = filter.lowercased()
    284         if lowered.contains("trace") {
    285             return .trace
    286         }
    287         if lowered.contains("debug") {
    288             return .debug
    289         }
    290         if lowered.contains("info") {
    291             return .info
    292         }
    293         if lowered.contains("notice") {
    294             return .notice
    295         }
    296         if lowered.contains("warn") {
    297             return .warning
    298         }
    299         if lowered.contains("error") {
    300             return .error
    301         }
    302         if lowered.contains("critical") || lowered.contains("fault") {
    303             return .critical
    304         }
    305         return .info
    306     }
    307 
    308     private static func outcome(for error: Error) -> String {
    309         if let error = error as? FieldAppRuntimeError {
    310             switch error {
    311             case .forcedStartupFailure:
    312                 return "forced_failure"
    313             case .runtimeNotReady:
    314                 return "runtime_not_ready"
    315             }
    316         }
    317         if let error = error as? RelaySettingsError {
    318             switch error {
    319             case .noRelaysConfigured:
    320                 return "relay_config_missing"
    321             case .invalidRelayURL:
    322                 return "invalid_relay_url"
    323             case .invalidStoredRelaySettings:
    324                 return "invalid_relay_settings"
    325             }
    326         }
    327         switch error {
    328         case FieldUserPresenceGateError.notVerified:
    329             return "unverified"
    330         case is FieldRuntimeLoggingError:
    331             return "logging_initialization_failed"
    332         case let error as RadrootsUserPresenceError:
    333             return userPresenceOutcome(for: error)
    334         case let error as RadrootsCaptureIntakeError:
    335             return captureOutcome(for: error)
    336         case let error as RadrootsExternalActionError:
    337             return externalActionOutcome(for: error)
    338         case let error as FieldDocumentInterchangeError:
    339             return documentInterchangeOutcome(for: error)
    340         default:
    341             return "failure"
    342         }
    343     }
    344 
    345     static func userPresenceOutcome(for error: Error) -> String {
    346         if let error = error as? FieldUserPresenceGateError {
    347             switch error {
    348             case .notVerified:
    349                 return "unverified"
    350             }
    351         }
    352         guard let error = error as? RadrootsUserPresenceError else {
    353             return "failure"
    354         }
    355         switch error {
    356         case .userCancelled:
    357             return "cancelled"
    358         case .permissionDenied:
    359             return "denied"
    360         case .unavailable:
    361             return "unavailable"
    362         case .timeout:
    363             return "timeout"
    364         case .transientFailure:
    365             return "transient_failure"
    366         case .permanentFailure:
    367             return "permanent_failure"
    368         case .invalidRequest:
    369             return "invalid_request"
    370         }
    371     }
    372 
    373     static func captureOutcome(for error: Error) -> String {
    374         guard let error = error as? RadrootsCaptureIntakeError else {
    375             return "failure"
    376         }
    377         switch error {
    378         case .userCancelled:
    379             return "cancelled"
    380         case .permissionDenied:
    381             return "denied"
    382         case .unavailable:
    383             return "unavailable"
    384         case .transientFailure:
    385             return "transient_failure"
    386         case .permanentFailure:
    387             return "permanent_failure"
    388         case .invalidRequest:
    389             return "invalid_request"
    390         }
    391     }
    392 
    393     static func backgroundExecutionOutcome(for error: Error) -> String {
    394         switch error {
    395         case let error as RadrootsBackgroundTaskError:
    396             switch error {
    397             case .invalidRequest:
    398                 return "invalid_request"
    399             case .unavailable:
    400                 return "unavailable"
    401             case .schedulerFailure:
    402                 return "scheduler_failure"
    403             }
    404         case let error as RadrootsBackgroundTransferError:
    405             switch error {
    406             case .invalidRequest:
    407                 return "invalid_request"
    408             case .unavailable:
    409                 return "unavailable"
    410             case .transferFailure:
    411                 return "transfer_failure"
    412             case .persistenceFailure:
    413                 return "persistence_failure"
    414             }
    415         case let error as RadrootsAppleFileError:
    416             switch error {
    417             case .invalidRequest:
    418                 return "invalid_request"
    419             case .notFound:
    420                 return "not_found"
    421             case .permissionDenied:
    422                 return "permission_denied"
    423             case .transientFailure:
    424                 return "transient_failure"
    425             case .permanentFailure:
    426                 return "permanent_failure"
    427             }
    428         case let error as RelaySettingsError:
    429             switch error {
    430             case .noRelaysConfigured:
    431                 return "relay_config_missing"
    432             case .invalidRelayURL:
    433                 return "invalid_relay_url"
    434             case .invalidStoredRelaySettings:
    435                 return "invalid_relay_settings"
    436             }
    437         default:
    438             return "failure"
    439         }
    440     }
    441 
    442     static func externalActionOutcome(for error: Error) -> String {
    443         guard let error = error as? RadrootsExternalActionError else {
    444             return "failure"
    445         }
    446         switch error {
    447         case .invalidRequest:
    448             return "invalid_request"
    449         case .blockedByPolicy:
    450             return "blocked_by_policy"
    451         case .unavailable:
    452             return "unavailable"
    453         case .transientFailure:
    454             return "transient_failure"
    455         case .permanentFailure:
    456             return "permanent_failure"
    457         }
    458     }
    459 
    460     static func documentInterchangeOutcome(for error: Error) -> String {
    461         guard let error = error as? FieldDocumentInterchangeError else {
    462             return "failure"
    463         }
    464         switch error {
    465         case .emptyRelayConfig:
    466             return "empty_relay_config"
    467         case .invalidRelayURL:
    468             return "invalid_relay_url"
    469         case .invalidRelayConfigDocument:
    470             return "invalid_relay_config_document"
    471         }
    472     }
    473 }
    474 
    475 extension FieldUserPresenceAction {
    476     var telemetryName: String {
    477         switch self {
    478         case .unlockIdentity:
    479             return "unlock_identity"
    480         case .saveIdentity:
    481             return "save_identity"
    482         case .deleteIdentity:
    483             return "delete_identity"
    484         }
    485     }
    486 }
    487 
    488 extension FieldCaptureIntakeOperation {
    489     var telemetryName: String {
    490         switch self {
    491         case .idle:
    492             return "idle"
    493         case .refreshing:
    494             return "support_refresh"
    495         case .importingPhoto:
    496             return "import_photo"
    497         case .capturingPhoto:
    498             return "capture_photo"
    499         case .scanningDocument:
    500             return "scan_document"
    501         }
    502     }
    503 }