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:
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"
+ }
+ }
}