field_ios

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

commit 417d5882b824fd3e8e0ec9cd74a66a9b8da4d226
parent 0feaf1efb7b4c3ceccda83fdb6e3c3635d8bc32e
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 14:27:36 -0700

telemetry: emit structured app events

- add typed field telemetry event helpers
- record startup, relay, identity, user presence, capture, document, and external action outcomes
- keep telemetry fields to safe counts, booleans, and enum values
- suppress duplicate relay status telemetry between polling ticks

Diffstat:
MRadroots/App/AppState.swift | 246++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
MRadroots/Runtime/FieldTelemetry.swift | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 463 insertions(+), 53 deletions(-)

diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -85,6 +85,7 @@ public final class AppState: ObservableObject { private let locationCheckIn = FieldLocationCheckIn.configured() private let externalActions = FieldExternalActions.configured() private let userPresenceGate = FieldUserPresenceGate.configured() + private var lastTelemetryRelayStatus: FieldTelemetryRelayStatus? init(radroots: Radroots = Radroots(), telemetry: FieldTelemetry = .shared) { self.radroots = radroots @@ -98,6 +99,7 @@ public final class AppState: ObservableObject { public func start() async throws { guard bootstrapPhase == .idle || isFailed else { return } + telemetry.appStartupBegan() bootstrapPhase = .starting do { try await holdBootstrapSplashForUITestIfRequested() @@ -141,11 +143,17 @@ public final class AppState: ObservableObject { await refreshLocationCheckInStatus() await refreshCaptureIntakeState(using: captureIntake) bootstrapPhase = .ready + telemetry.appStartupSucceeded( + storedIdentityAvailable: storedIdentityAvailable, + runtimeIdentityReady: runtimeIdentityReady, + locked: isLocked + ) } catch { statusTask?.cancel() statusTask = nil let message = error.localizedDescription bootstrapPhase = .failed(message) + telemetry.appStartupFailed(error) throw error } } @@ -165,42 +173,61 @@ public final class AppState: ObservableObject { public func continueWithLocalIdentity() async throws { let service = try requireRuntimeService() - try await requireUserPresence(for: .unlockIdentity) - try await restoreStoredIdentity(using: service) - setLocked(false) - await refreshRuntimeState(using: service) - await refreshNostrProfileExternalActionCapability() - startConnectingAndPollingStatus(using: service) + do { + try await requireUserPresence(for: .unlockIdentity) + try await restoreStoredIdentity(using: service) + setLocked(false) + await refreshRuntimeState(using: service) + await refreshNostrProfileExternalActionCapability() + startConnectingAndPollingStatus(using: service) + telemetry.identityCustody(action: "unlock", outcome: "success") + } catch { + telemetry.identityCustody(action: "unlock", outcome: FieldTelemetry.userPresenceOutcome(for: error)) + throw error + } } public func createLocalIdentity() async throws { let service = try requireRuntimeService() - try await requireUserPresence(for: .saveIdentity) - try await createHostCustodyIdentity(using: service) - setLocked(false) - await refreshRuntimeState(using: service) - await refreshNostrProfileExternalActionCapability() - startConnectingAndPollingStatus(using: service) + do { + try await requireUserPresence(for: .saveIdentity) + try await createHostCustodyIdentity(using: service) + setLocked(false) + await refreshRuntimeState(using: service) + await refreshNostrProfileExternalActionCapability() + startConnectingAndPollingStatus(using: service) + telemetry.identityCustody(action: "create", outcome: "success") + } catch { + telemetry.identityCustody(action: "create", outcome: FieldTelemetry.userPresenceOutcome(for: error)) + throw error + } } public func importNostrSecret(_ secretKey: String) async throws { let trimmed = secretKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } let service = try requireRuntimeService() - try await requireUserPresence(for: .saveIdentity) - let record = try await secureIdentityStoreOrConfigured().importSecret( - trimmed, - label: "Imported Field Identity", - using: service - ) - try persistIdentity(record) - setLocked(false) - await refreshRuntimeState(using: service) - await refreshNostrProfileExternalActionCapability() - startConnectingAndPollingStatus(using: service) + do { + try await requireUserPresence(for: .saveIdentity) + let record = try await secureIdentityStoreOrConfigured().importSecret( + trimmed, + label: "Imported Field Identity", + using: service + ) + try persistIdentity(record) + setLocked(false) + await refreshRuntimeState(using: service) + await refreshNostrProfileExternalActionCapability() + startConnectingAndPollingStatus(using: service) + telemetry.identityCustody(action: "import", outcome: "success") + } catch { + telemetry.identityCustody(action: "import", outcome: FieldTelemetry.userPresenceOutcome(for: error)) + throw error + } } public func signOut() { + telemetry.identityCustody(action: "lock", outcome: "success") setLocked(true) statusTask?.cancel() statusTask = nil @@ -214,26 +241,32 @@ public final class AppState: ObservableObject { public func resetLocalIdentity() async throws { let service = try requireRuntimeService() - try await requireUserPresence(for: .deleteIdentity) - try secureIdentityStoreOrConfigured().deleteSelectedSecret() - try identityMetadataStoreOrConfigured().delete() - try await resetRuntimeIdentityState(using: service) - applyNoIdentity() - setLocked(false) - relayConnectedCount = 0 - relayConnectingCount = 0 - relayLight = .red - relayLastError = nil - canOpenNostrProfile = false - externalActionStatus = nil - await refreshRuntimeState(using: service) - try refreshFileAccessProbe( - bundleIdentifier: try bundleIdentifier(), - resetLocalStateRequested: false, - identityResetObserved: true - ) - statusTask?.cancel() - statusTask = nil + do { + try await requireUserPresence(for: .deleteIdentity) + try secureIdentityStoreOrConfigured().deleteSelectedSecret() + try identityMetadataStoreOrConfigured().delete() + try await resetRuntimeIdentityState(using: service) + applyNoIdentity() + setLocked(false) + relayConnectedCount = 0 + relayConnectingCount = 0 + relayLight = .red + relayLastError = nil + canOpenNostrProfile = false + externalActionStatus = nil + await refreshRuntimeState(using: service) + try refreshFileAccessProbe( + bundleIdentifier: try bundleIdentifier(), + resetLocalStateRequested: false, + identityResetObserved: true + ) + statusTask?.cancel() + statusTask = nil + telemetry.identityCustody(action: "delete", outcome: "success") + } catch { + telemetry.identityCustody(action: "delete", outcome: FieldTelemetry.userPresenceOutcome(for: error)) + throw error + } } public func requireRuntimeService() throws -> FieldRuntimeService { @@ -320,25 +353,67 @@ public final class AppState: ObservableObject { } func prepareDiagnosticsDocumentExport() throws -> RadrootsPreparedExportDocument { - try documentInterchange().prepareDiagnosticsExport( - infoJSONString: infoJSONString, - relays: RelaySettings.relays(), - connectedCount: relayConnectedCount, - connectingCount: relayConnectingCount, - lastError: relayLastError - ) + do { + let relays = try RelaySettings.relays() + let document = try documentInterchange().prepareDiagnosticsExport( + infoJSONString: infoJSONString, + relays: relays, + connectedCount: relayConnectedCount, + connectingCount: relayConnectingCount, + lastError: relayLastError + ) + telemetry.documentInterchange(operation: "diagnostics_export", outcome: "success", relayCount: relays.count) + return document + } catch { + telemetry.documentInterchange( + operation: "diagnostics_export", + outcome: FieldTelemetry.documentInterchangeOutcome(for: error) + ) + throw error + } } func prepareRelayConfigDocumentExport() throws -> RadrootsPreparedExportDocument { - try documentInterchange().prepareRelayConfigExport(relays: RelaySettings.relays()) + do { + let relays = try RelaySettings.relays() + let document = try documentInterchange().prepareRelayConfigExport(relays: relays) + telemetry.documentInterchange(operation: "relay_config_export", outcome: "success", relayCount: relays.count) + return document + } catch { + telemetry.documentInterchange( + operation: "relay_config_export", + outcome: FieldTelemetry.documentInterchangeOutcome(for: error) + ) + throw error + } } func importedRelayConfig(from importedDocument: RadrootsImportedDocument) throws -> [String] { - try documentInterchange().importedRelayConfig(from: importedDocument) + do { + let relays = try documentInterchange().importedRelayConfig(from: importedDocument) + telemetry.documentInterchange(operation: "relay_config_import", outcome: "success", relayCount: relays.count) + return relays + } catch { + telemetry.documentInterchange( + operation: "relay_config_import", + outcome: FieldTelemetry.documentInterchangeOutcome(for: error) + ) + throw error + } } func publicPostShareRequest(content: String) throws -> RadrootsShareRequest { - try documentInterchange().publicPostShareRequest(content: content) + do { + let request = try documentInterchange().publicPostShareRequest(content: content) + telemetry.documentInterchange(operation: "public_share_prepare", outcome: "success") + return request + } catch { + telemetry.documentInterchange( + operation: "public_share_prepare", + outcome: FieldTelemetry.documentInterchangeOutcome(for: error) + ) + throw error + } } func documentFileAccess() throws -> RadrootsAppleFileAccess { @@ -361,11 +436,21 @@ public final class AppState: ObservableObject { captureIntakeState.records = try captureIntake.loadRecords() captureIntakeState.support = try await captureIntake.support() captureIntakeState.operation = .idle + telemetry.captureSupportRefreshed( + support: captureIntakeState.support, + recordCount: captureIntakeState.records.count, + outcome: "success" + ) } catch { captureIntakeState.support = .unavailable captureIntakeState.operation = .idle captureIntakeState.lastError = error.localizedDescription captureIntakeState.recoveryAction = nil + telemetry.captureSupportRefreshed( + support: captureIntakeState.support, + recordCount: captureIntakeState.records.count, + outcome: FieldTelemetry.captureOutcome(for: error) + ) } } @@ -386,10 +471,22 @@ public final class AppState: ObservableObject { captureIntakeState.support = try await captureIntake.support() captureIntakeState.operation = .idle captureIntakeState.recoveryAction = nil + telemetry.captureOperation( + operation: operation, + outcome: "success", + recordCount: captureIntakeState.records.count, + recoveryAction: nil + ) } catch { captureIntakeState.operation = .idle captureIntakeState.lastError = error.localizedDescription captureIntakeState.recoveryAction = captureRecoveryAction(for: error) + telemetry.captureOperation( + operation: operation, + outcome: FieldTelemetry.captureOutcome(for: error), + recordCount: captureIntakeState.records.count, + recoveryAction: captureIntakeState.recoveryAction + ) } } @@ -490,6 +587,21 @@ public final class AppState: ObservableObject { case .red: relayLight = .red } + let telemetryStatus = FieldTelemetryRelayStatus( + connectedCount: relayConnectedCount, + connectingCount: relayConnectingCount, + configuredRelayCount: (try? RelaySettings.relays().count) ?? 0, + light: relayLight.telemetryValue + ) + if telemetryStatus != lastTelemetryRelayStatus { + lastTelemetryRelayStatus = telemetryStatus + telemetry.relayStatusChanged( + connectedCount: telemetryStatus.connectedCount, + connectingCount: telemetryStatus.connectingCount, + configuredRelayCount: telemetryStatus.configuredRelayCount, + light: telemetryStatus.light + ) + } } private func apply(identity snapshot: NostrIdentitySnapshot) { @@ -545,8 +657,10 @@ public final class AppState: ObservableObject { do { let record = try await userPresenceGate.requirePresence(for: action) userPresenceStatus = record.statusText + telemetry.userPresence(action: action, outcome: "success") } catch { userPresenceStatus = error.localizedDescription + telemetry.userPresence(action: action, outcome: FieldTelemetry.userPresenceOutcome(for: error)) throw error } } @@ -666,8 +780,14 @@ public final class AppState: ObservableObject { do { let record = try await action() externalActionStatus = record.statusText + telemetry.externalAction(operation: "open", kind: record.kind, outcome: "success") } catch { externalActionStatus = error.localizedDescription + telemetry.externalAction( + operation: "open", + kind: nil, + outcome: FieldTelemetry.externalActionOutcome(for: error) + ) } } @@ -697,3 +817,23 @@ public final class AppState: ObservableObject { return "\(value.prefix(12))...\(value.suffix(6))" } } + +private struct FieldTelemetryRelayStatus: Equatable { + let connectedCount: UInt32 + let connectingCount: UInt32 + let configuredRelayCount: Int + let light: String +} + +private extension AppState.RelayLight { + var telemetryValue: String { + switch self { + case .red: + "red" + case .yellow: + "yellow" + case .green: + "green" + } + } +} diff --git a/Radroots/Runtime/FieldTelemetry.swift b/Radroots/Runtime/FieldTelemetry.swift @@ -92,6 +92,135 @@ final class FieldTelemetry: @unchecked Sendable { ) } + func appStartupBegan() { + record(name: "field_ios.startup.begin", level: .notice) + } + + func appStartupSucceeded( + storedIdentityAvailable: Bool, + runtimeIdentityReady: Bool, + locked: Bool + ) { + record( + name: "field_ios.startup.success", + level: .notice, + fields: [ + try? .bool("stored_identity_available", storedIdentityAvailable), + try? .bool("runtime_identity_ready", runtimeIdentityReady), + try? .bool("identity_locked", locked) + ].compactMap { $0 } + ) + } + + func appStartupFailed(_ error: Error) { + record( + name: "field_ios.startup.failure", + level: .error, + fields: [ + try? .string("outcome", Self.outcome(for: error)) + ].compactMap { $0 } + ) + } + + func relayStatusChanged( + connectedCount: UInt32, + connectingCount: UInt32, + configuredRelayCount: Int, + light: String + ) { + record( + name: "field_ios.relay.status_changed", + level: light == "red" ? .warning : .info, + fields: [ + try? .integer("connected_count", Int64(connectedCount)), + try? .integer("connecting_count", Int64(connectingCount)), + try? .integer("configured_relay_count", configuredRelayCount), + try? .string("relay_light", light) + ].compactMap { $0 } + ) + } + + func identityCustody(action: String, outcome: String) { + record( + name: "field_ios.identity_custody.\(action)", + level: outcome == "success" ? .info : .warning, + fields: [ + try? .string("outcome", outcome) + ].compactMap { $0 } + ) + } + + func userPresence(action: FieldUserPresenceAction, outcome: String) { + record( + name: "field_ios.user_presence.\(action.telemetryName)", + level: outcome == "success" ? .info : .warning, + fields: [ + try? .string("outcome", outcome) + ].compactMap { $0 } + ) + } + + func captureSupportRefreshed( + support: FieldCaptureSupportState, + recordCount: Int, + outcome: String + ) { + record( + name: "field_ios.capture.support_refreshed", + level: outcome == "success" ? .info : .warning, + fields: [ + try? .string("outcome", outcome), + try? .bool("photo_import_available", support.photoImportAvailable), + try? .bool("camera_photo_available", support.cameraPhotoAvailable), + try? .bool("document_scanner_available", support.documentScannerAvailable), + try? .integer("record_count", recordCount) + ].compactMap { $0 } + ) + } + + func captureOperation( + operation: FieldCaptureIntakeOperation, + outcome: String, + recordCount: Int, + recoveryAction: FieldExternalActionRecovery? + ) { + record( + name: "field_ios.capture.\(operation.telemetryName)", + level: outcome == "success" ? .info : .warning, + fields: [ + try? .string("outcome", outcome), + try? .integer("record_count", recordCount), + recoveryAction.map { try? .string("recovery_action", $0.rawValue) } ?? nil + ].compactMap { $0 } + ) + } + + func documentInterchange(operation: String, outcome: String, relayCount: Int? = nil) { + record( + name: "field_ios.document_interchange.\(operation)", + level: outcome == "success" ? .info : .warning, + fields: [ + try? .string("outcome", outcome), + relayCount.map { try? .integer("relay_count", $0) } ?? nil + ].compactMap { $0 } + ) + } + + func externalAction( + operation: String, + kind: RadrootsExternalActionDestinationKind?, + outcome: String + ) { + record( + name: "field_ios.external_action.\(operation)", + level: outcome == "success" ? .info : .warning, + fields: [ + try? .string("outcome", outcome), + kind.map { try? .string("destination_kind", $0.rawValue) } ?? nil + ].compactMap { $0 } + ) + } + func recordedEventsForUITest() async -> [RadrootsTelemetryEvent] { guard let recordingTelemetry else { return [] @@ -134,4 +263,145 @@ final class FieldTelemetry: @unchecked Sendable { } return .info } + + private static func outcome(for error: Error) -> String { + if let error = error as? FieldAppRuntimeError { + switch error { + case .forcedStartupFailure: + return "forced_failure" + case .runtimeNotReady: + return "runtime_not_ready" + } + } + if let error = error as? RelaySettingsError { + switch error { + case .noRelaysConfigured: + return "relay_config_missing" + } + } + switch error { + case FieldUserPresenceGateError.notVerified: + return "unverified" + case let error as RadrootsUserPresenceError: + return userPresenceOutcome(for: error) + case let error as RadrootsCaptureIntakeError: + return captureOutcome(for: error) + case let error as RadrootsExternalActionError: + return externalActionOutcome(for: error) + case let error as FieldDocumentInterchangeError: + return documentInterchangeOutcome(for: error) + default: + return "failure" + } + } + + static func userPresenceOutcome(for error: Error) -> String { + if let error = error as? FieldUserPresenceGateError { + switch error { + case .notVerified: + return "unverified" + } + } + guard let error = error as? RadrootsUserPresenceError else { + return "failure" + } + switch error { + case .userCancelled: + return "cancelled" + case .permissionDenied: + return "denied" + case .unavailable: + return "unavailable" + case .timeout: + return "timeout" + case .transientFailure: + return "transient_failure" + case .permanentFailure: + return "permanent_failure" + case .invalidRequest: + return "invalid_request" + } + } + + static func captureOutcome(for error: Error) -> String { + guard let error = error as? RadrootsCaptureIntakeError else { + return "failure" + } + switch error { + case .userCancelled: + return "cancelled" + case .permissionDenied: + return "denied" + case .unavailable: + return "unavailable" + case .transientFailure: + return "transient_failure" + case .permanentFailure: + return "permanent_failure" + case .invalidRequest: + return "invalid_request" + } + } + + static func externalActionOutcome(for error: Error) -> String { + guard let error = error as? RadrootsExternalActionError else { + return "failure" + } + switch error { + case .invalidRequest: + return "invalid_request" + case .blockedByPolicy: + return "blocked_by_policy" + case .unavailable: + return "unavailable" + case .transientFailure: + return "transient_failure" + case .permanentFailure: + return "permanent_failure" + } + } + + static func documentInterchangeOutcome(for error: Error) -> String { + guard let error = error as? FieldDocumentInterchangeError else { + return "failure" + } + switch error { + case .emptyRelayConfig: + return "empty_relay_config" + case .invalidRelayURL: + return "invalid_relay_url" + case .invalidRelayConfigDocument: + return "invalid_relay_config_document" + } + } +} + +extension FieldUserPresenceAction { + var telemetryName: String { + switch self { + case .unlockIdentity: + return "unlock_identity" + case .saveIdentity: + return "save_identity" + case .deleteIdentity: + return "delete_identity" + } + } +} + +extension FieldCaptureIntakeOperation { + var telemetryName: String { + switch self { + case .idle: + return "idle" + case .refreshing: + return "support_refresh" + case .importingPhoto: + return "import_photo" + case .capturingPhoto: + return "capture_photo" + case .scanningDocument: + return "scan_document" + } + } }