field_ios

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

commit 0feaf1efb7b4c3ceccda83fdb6e3c3635d8bc32e
parent 3353c52c82cff9c50c6a44fdfc01f3131d107130
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 14:19:06 -0700

telemetry: route app logging through AppleKit

- add a FieldTelemetry runtime facade backed by RadrootsKit telemetry
- configure UI-test recording telemetry and logging-filter level mapping
- keep Rust runtime logging initialization in LoggingSettings
- remove direct app OSLog and generated Rust helper logging calls

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 20++++----------------
MRadroots/App/AppState.swift | 6++++--
ARadroots/Runtime/FieldTelemetry.swift | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Runtime/LoggingSettings.swift | 19-------------------
MRadroots/Runtime/Radroots.swift | 9++++++---
DRadroots/Shared/Logging/DebugDump.swift | 43-------------------------------------------
DRadroots/Shared/Logging/Logger.swift | 35-----------------------------------
7 files changed, 151 insertions(+), 118 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -23,7 +23,6 @@ 3A7FA9E5BCC7590B2EAC5349 /* RelaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FBB081610305940C7849C7C /* RelaySettings.swift */; }; 3B6020E24A2DAD8ADFC2F155 /* BuildConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71EFADBC7D54AF5B9314773 /* BuildConfig.swift */; }; 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */; }; - 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */; }; 4B44B723FF06ECC363A486BA /* TradeListingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */; }; 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */; }; 5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */; }; @@ -37,11 +36,11 @@ 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE0EB327C10171444553378 /* PostFeedView.swift */; }; 8F6D0970610DF68816DE1A98 /* Radroots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F0F21496E7A8490EB14AC5B /* Radroots.swift */; }; 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C7DE4207398DE242519F9C /* CopyButton.swift */; }; + 9346DA48630668A65D37E14A /* FieldTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */; }; A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA8F5611075F60F214A00 /* SectionWideButton.swift */; }; B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */; }; B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */; }; C22DB0F3EB2E69A34DF941E0 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2818363B157125491FB84A1E /* App.swift */; }; - C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FE790CA1CD31208947913B9 /* Logger.swift */; }; C8AB3389F7430A5C79AD7DF8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */; }; D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */; }; D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41A4289F43625DD65E6C4B25 /* CopyRow.swift */; }; @@ -70,7 +69,7 @@ 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListingDetailView.swift; sourceTree = "<group>"; }; 2818363B157125491FB84A1E /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; }; 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldLocationCheckIn.swift; sourceTree = "<group>"; }; - 2FE790CA1CD31208947913B9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; }; + 3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldTelemetry.swift; sourceTree = "<group>"; }; 3E6187FA7C4786EC662718B2 /* FieldExternalActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldExternalActions.swift; sourceTree = "<group>"; }; 41A4289F43625DD65E6C4B25 /* CopyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyRow.swift; sourceTree = "<group>"; }; 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeOrderRequestView.swift; sourceTree = "<group>"; }; @@ -110,7 +109,6 @@ DC881872F750120A184F45E6 /* RadrootsKitBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadrootsKitBindings.swift; sourceTree = "<group>"; }; E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; E4F2BDC19EF9435162BFF5EC /* FieldDocumentInterchange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldDocumentInterchange.swift; sourceTree = "<group>"; }; - E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugDump.swift; sourceTree = "<group>"; }; E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldRuntimeService.swift; sourceTree = "<group>"; }; EF05B944D348DAA4EF28B463 /* FieldDocumentInterchangeUITestProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldDocumentInterchangeUITestProbe.swift; sourceTree = "<group>"; }; F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedViewModel.swift; sourceTree = "<group>"; }; @@ -202,6 +200,7 @@ 2F3541389124A31F5D701A45 /* FieldLocationCheckIn.swift */, E883FDB7004A210C9D7BE27A /* FieldRuntimeService.swift */, 8246B707FA9D218414EC4038 /* FieldSecureIdentityStore.swift */, + 3B4E53FD4C4AADF63855888A /* FieldTelemetry.swift */, D65835E1C633C7B946C64D11 /* FieldUserPresenceGate.swift */, D4DE3DD8C3BB2F63676F463E /* LoggingSettings.swift */, 63189EB90A86A9929BECD9ED /* Nostr.swift */, @@ -227,7 +226,6 @@ D46F444AD1818932F03AC6B6 /* Components */, 9D22575D1FAD99FE8B6FCE6C /* Extensions */, 65EC1C4AF7DC676E78603D52 /* Localisation */, - F16A19713274742D956C3A4D /* Logging */, C5BAA3C6E2D410F0C8475D89 /* Modifiers */, ); path = Shared; @@ -323,15 +321,6 @@ path = Resources; sourceTree = "<group>"; }; - F16A19713274742D956C3A4D /* Logging */ = { - isa = PBXGroup; - children = ( - E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */, - 2FE790CA1CD31208947913B9 /* Logger.swift */, - ); - path = Logging; - sourceTree = "<group>"; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -436,7 +425,6 @@ E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */, 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, - 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */, 299A6111507F657670856F36 /* FieldCaptureIntake.swift in Sources */, 3FC570AC038C3DC575E5A3E7 /* FieldDocumentInterchange.swift in Sources */, E432FD39ECC8F03764EEED81 /* FieldDocumentInterchangeUITestProbe.swift in Sources */, @@ -447,9 +435,9 @@ 1C6EA551530A46CA77BD9E1C /* FieldLocationCheckIn.swift in Sources */, D25C1E1DC99F5CF8E99AE970 /* FieldRuntimeService.swift in Sources */, D9BF5BE7E4AB5EACBF342539 /* FieldSecureIdentityStore.swift in Sources */, + 9346DA48630668A65D37E14A /* FieldTelemetry.swift in Sources */, 25654E50F9519809A237759D /* FieldUserPresenceGate.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, - C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, B8A3BBDE3A1FC0248512BF76 /* LoggingSettings.swift in Sources */, 33A800AA701C354099623B24 /* MarketView.swift in Sources */, 657BEA5AAFF129E10177FE63 /* Nostr.swift in Sources */, diff --git a/Radroots/App/AppState.swift b/Radroots/App/AppState.swift @@ -71,6 +71,7 @@ public final class AppState: ObservableObject { } public let radroots: Radroots + private let telemetry: FieldTelemetry public var runtimeService: FieldRuntimeService? { radroots.runtimeService @@ -85,8 +86,9 @@ public final class AppState: ObservableObject { private let externalActions = FieldExternalActions.configured() private let userPresenceGate = FieldUserPresenceGate.configured() - public init(radroots: Radroots = Radroots()) { + init(radroots: Radroots = Radroots(), telemetry: FieldTelemetry = .shared) { self.radroots = radroots + self.telemetry = telemetry self.isLocked = UserDefaults.standard.bool(forKey: lockKey) } @@ -102,7 +104,7 @@ public final class AppState: ObservableObject { if startupFailureWasRequested { throw FieldAppRuntimeError.forcedStartupFailure } - let service = try radroots.start() + let service = try radroots.start(telemetry: telemetry) let secureStore = try FieldSecureIdentityStore.configured() let metadataStore = try FieldIdentityPublicMetadataStore.configured() let appBundleIdentifier = try bundleIdentifier() diff --git a/Radroots/Runtime/FieldTelemetry.swift b/Radroots/Runtime/FieldTelemetry.swift @@ -0,0 +1,137 @@ +import Foundation +import RadrootsKit +import RadrootsKitTesting + +final class FieldTelemetry: @unchecked Sendable { + static let shared = FieldTelemetry.configured() + + private let sink: any RadrootsTelemetry + private let minimumLevel: RadrootsTelemetryLevel + private let recordingTelemetry: RadrootsRecordingTelemetry? + + init( + sink: any RadrootsTelemetry, + minimumLevel: RadrootsTelemetryLevel = .info, + recordingTelemetry: RadrootsRecordingTelemetry? = nil + ) { + self.sink = sink + self.minimumLevel = minimumLevel + self.recordingTelemetry = recordingTelemetry + } + + static func configured( + bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "dev.local.radroots", + loggingSettings: LoggingSettings = .load() + ) -> FieldTelemetry { + let minimumLevel = telemetryMinimumLevel(from: loggingSettings.level) + let appleTelemetry = RadrootsAppleLoggerTelemetry(subsystem: bundleIdentifier) + if uiTestWasRequested { + let recorder = RadrootsRecordingTelemetry() + return FieldTelemetry( + sink: RadrootsMultiplexTelemetry([appleTelemetry, recorder]), + minimumLevel: minimumLevel, + recordingTelemetry: recorder + ) + } + return FieldTelemetry(sink: appleTelemetry, minimumLevel: minimumLevel) + } + + func record( + name: String, + category: String = "field_ios", + level: RadrootsTelemetryLevel = .info, + message: String? = nil, + fields: [RadrootsTelemetryField] = [] + ) { + Task { + await recordAsync( + name: name, + category: category, + level: level, + message: message, + fields: fields + ) + } + } + + func recordAsync( + name: String, + category: String = "field_ios", + level: RadrootsTelemetryLevel = .info, + message: String? = nil, + fields: [RadrootsTelemetryField] = [] + ) async { + guard level >= minimumLevel else { + return + } + guard let event = try? RadrootsTelemetryEvent( + name: name, + category: category, + level: level, + message: message, + fields: fields + ) else { + return + } + await sink.record(event) + } + + func runtimeLoggingInitialized( + settings: LoggingSettings, + fallbackUsed: Bool + ) { + record( + name: "field_ios.runtime.logging_initialized", + level: fallbackUsed ? .warning : .info, + fields: [ + try? .bool("stdout_enabled", settings.stdout), + try? .bool("file_enabled", settings.fileEnabled), + try? .string("logging_filter", settings.level ?? "unset"), + try? .bool("fallback_used", fallbackUsed) + ].compactMap { $0 } + ) + } + + func recordedEventsForUITest() async -> [RadrootsTelemetryEvent] { + guard let recordingTelemetry else { + return [] + } + return await recordingTelemetry.recordedEvents + } + + private static var uiTestWasRequested: Bool { + let environment = ProcessInfo.processInfo.environment + let arguments = ProcessInfo.processInfo.arguments + return environment["RADROOTS_FIELD_IOS_UI_TEST"] == "true" || + arguments.contains("--radroots-field-ios-ui-test") + } + + private static func telemetryMinimumLevel(from filter: String?) -> RadrootsTelemetryLevel { + guard let filter else { + return .info + } + let lowered = filter.lowercased() + if lowered.contains("trace") { + return .trace + } + if lowered.contains("debug") { + return .debug + } + if lowered.contains("info") { + return .info + } + if lowered.contains("notice") { + return .notice + } + if lowered.contains("warn") { + return .warning + } + if lowered.contains("error") { + return .error + } + if lowered.contains("critical") || lowered.contains("fault") { + return .critical + } + return .info + } +} diff --git a/Radroots/Runtime/LoggingSettings.swift b/Radroots/Runtime/LoggingSettings.swift @@ -29,23 +29,4 @@ struct LoggingSettings: Equatable { try initLogging(dir: nil, fileName: fileName, isStdout: stdout) } } - - func logEffectiveConfigs() { - let keys: [BuildConfigKey] = [ - .envFile, - .runtimeMode, - .loggingStdout, - .loggingFilter, - .loggingFileEnabled, - .loggingFileName, - .nostrRelayUrls, - .keychainServicePrefix, - .resetLocalState, - .tradeRhiPubkey, - ] - let dict = BuildConfig.effectiveDictionary(keys: keys) - let json = (try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys])) ?? Data() - let text = String(data: json, encoding: .utf8) ?? String(describing: dict) - try? logInfo(msg: "radroots.config \(text)") - } } diff --git a/Radroots/Runtime/Radroots.swift b/Radroots/Runtime/Radroots.swift @@ -7,19 +7,22 @@ public final class Radroots: ObservableObject { public init() {} - public func start( + func start( bundleId: String = Bundle.main.bundleIdentifier ?? "unknown", version: String = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0", build: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "0", - buildSha: String? = nil + buildSha: String? = nil, + telemetry: FieldTelemetry = .shared ) throws -> FieldRuntimeService { let settings = LoggingSettings.load() + var loggingFallbackUsed = false do { try settings.apply(bundleIdentifier: bundleId) } catch { try? initLoggingStdout() + loggingFallbackUsed = true } - settings.logEffectiveConfigs() + telemetry.runtimeLoggingInitialized(settings: settings, fallbackUsed: loggingFallbackUsed) let rt = try RadrootsRuntime() let resolvedSha = buildSha ?? (Bundle.main.object(forInfoDictionaryKey: "GIT_SHA") as? String) diff --git a/Radroots/Shared/Logging/DebugDump.swift b/Radroots/Shared/Logging/DebugDump.swift @@ -1,43 +0,0 @@ -import Foundation - -enum DebugDump { - static func posts(_ items: [NostrPostEventMetadata], label: String = "PostFeed.kind1") { - let mapped = items.map { - DumpPost(id: $0.id, author: $0.author, publishedAt: $0.publishedAt, content: $0.post.content) - } - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let jsonString = (try? encoder.encode(mapped)).flatMap { String(data: $0, encoding: .utf8) } - let dumpString: String = jsonString ?? { - var s = "" - dump(mapped, to: &s, maxDepth: Int.max, maxItems: Int.max) - return s - }() - RadrootsLogger.debug("\(label) count=\(items.count)") - let chunks = dumpString.chunked(into: 900) - for (i, chunk) in chunks.enumerated() { - RadrootsLogger.debug("\(label) \(i + 1)/\(chunks.count):\n\(chunk)") - } - } - - private struct DumpPost: Codable { - let id: String - let author: String - let publishedAt: UInt64 - let content: String - } -} - -private extension String { - func chunked(into size: Int) -> [String] { - guard size > 0, !isEmpty else { return [self] } - var result: [String] = [] - var idx = startIndex - while idx < endIndex { - let end = index(idx, offsetBy: size, limitedBy: endIndex) ?? endIndex - result.append(String(self[idx..<end])) - idx = end - } - return result - } -} diff --git a/Radroots/Shared/Logging/Logger.swift b/Radroots/Shared/Logging/Logger.swift @@ -1,35 +0,0 @@ -import Foundation -import os - -private let oslog = os.Logger(subsystem: Bundle.main.bundleIdentifier ?? "Radroots", category: "App") - -enum RadrootsLogger { - static func info(_ message: String) { - oslog.info("\(message, privacy: .public)") - do { - try logInfo(msg: message) - } catch { - oslog.error("logInfo failed: \(error.localizedDescription, privacy: .public)") - } - } - - static func error(_ message: String) { - oslog.error("\(message, privacy: .public)") - do { - try logError(msg: message) - } catch { - oslog.error("logError failed: \(error.localizedDescription, privacy: .public)") - } - } - - static func debug(_ message: String) { - #if DEBUG - oslog.debug("\(message, privacy: .public)") - do { - try logDebug(msg: message) - } catch { - oslog.error("logDebug failed: \(error.localizedDescription, privacy: .public)") - } - #endif - } -}