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 }