apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

commit 6633fca85507c17bd68103a1d39730b593a5a147
parent 475d2ee2c7079bade88595a5c6ed66f7e97bbf5d
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 14:14:42 -0700

telemetry: add Apple logger sink

- add an OSLog-backed telemetry sink behind RadrootsKit
- expose an injectable emitter for deterministic sink tests
- sanitize log identifiers and bound rendered payloads
- cover redaction, level mapping, category cleanup, and payload rendering

Diffstat:
ASources/RadrootsKit/RadrootsAppleLoggerTelemetry.swift | 179+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsAppleLoggerTelemetryTests.swift | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 294 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsAppleLoggerTelemetry.swift b/Sources/RadrootsKit/RadrootsAppleLoggerTelemetry.swift @@ -0,0 +1,179 @@ +import Foundation +import OSLog + +public struct RadrootsAppleTelemetryLogRecord: Sendable, Equatable { + public let subsystem: String + public let category: String + public let level: RadrootsTelemetryLevel + public let renderedMessage: String + + public init( + subsystem: String, + category: String, + level: RadrootsTelemetryLevel, + renderedMessage: String + ) { + self.subsystem = subsystem + self.category = category + self.level = level + self.renderedMessage = renderedMessage + } +} + +public struct RadrootsAppleLoggerTelemetryAdapters: Sendable { + public let emit: @Sendable (RadrootsAppleTelemetryLogRecord) -> Void + + public init(emit: @escaping @Sendable (RadrootsAppleTelemetryLogRecord) -> Void) { + self.emit = emit + } + + public static let live = Self { record in + let logger = Logger(subsystem: record.subsystem, category: record.category) + logger.log(level: record.osLogType, "\(record.renderedMessage, privacy: .public)") + } +} + +public final class RadrootsAppleLoggerTelemetry: RadrootsTelemetry, Sendable { + private let subsystem: String + private let adapters: RadrootsAppleLoggerTelemetryAdapters + private let redactionPolicy: RadrootsTelemetryRedactionPolicy + private let maximumRenderedMessageLength: Int + + public init( + subsystem: String, + adapters: RadrootsAppleLoggerTelemetryAdapters = .live, + redactionPolicy: RadrootsTelemetryRedactionPolicy = .default, + maximumRenderedMessageLength: Int = 1_000 + ) { + self.subsystem = Self.normalizedSubsystem(subsystem) + self.adapters = adapters + self.redactionPolicy = redactionPolicy + self.maximumRenderedMessageLength = max(160, maximumRenderedMessageLength) + } + + public func record(_ event: RadrootsTelemetryEvent) async { + let redactedEvent = redactionPolicy.redacted(event) + let renderedMessage = Self.renderedMessage( + for: redactedEvent, + maximumLength: maximumRenderedMessageLength + ) + adapters.emit( + RadrootsAppleTelemetryLogRecord( + subsystem: subsystem, + category: Self.normalizedCategory(redactedEvent.category), + level: redactedEvent.level, + renderedMessage: renderedMessage + ) + ) + } + + public static func normalizedSubsystem(_ value: String) -> String { + normalizedLogIdentifier( + value, + fallback: "org.radroots.apple_kit", + maximumLength: 120 + ) + } + + public static func normalizedCategory(_ value: String) -> String { + normalizedLogIdentifier( + value, + fallback: "app", + maximumLength: 80 + ) + } + + public static func renderedMessage( + for event: RadrootsTelemetryEvent, + maximumLength: Int = 1_000 + ) -> String { + let payload = RadrootsAppleTelemetryPayload( + category: event.category, + event: event.name, + fields: Dictionary(uniqueKeysWithValues: event.fields.map { field in + (field.key, field.value.renderedValue) + }), + level: event.level.rawValue, + message: event.message, + occurredAtUnixMilliseconds: Int64(event.occurredAt.timeIntervalSince1970 * 1_000) + ) + let rendered: String + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(payload) + rendered = String(decoding: data, as: UTF8.self) + } catch { + rendered = "{\"event\":\"\(event.name)\",\"level\":\"\(event.level.rawValue)\"}" + } + let boundedLength = max(160, maximumLength) + guard rendered.count > boundedLength else { + return rendered + } + return String(rendered.prefix(boundedLength)) + } + + private static func normalizedLogIdentifier( + _ value: String, + fallback: String, + maximumLength: Int + ) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return fallback + } + var normalized = "" + for scalar in trimmed.unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) || scalar == "." || scalar == "_" || scalar == "-" { + normalized.append(String(scalar)) + } else { + normalized.append("_") + } + } + let collapsed = normalized.replacingOccurrences( + of: "_+", + with: "_", + options: .regularExpression + ) + let trimmedSeparators = collapsed.trimmingCharacters(in: CharacterSet(charactersIn: "._-")) + guard !trimmedSeparators.isEmpty else { + return fallback + } + return String(trimmedSeparators.prefix(maximumLength)) + } +} + +private struct RadrootsAppleTelemetryPayload: Encodable { + let category: String + let event: String + let fields: [String: String] + let level: String + let message: String? + let occurredAtUnixMilliseconds: Int64 + + enum CodingKeys: String, CodingKey { + case category + case event + case fields + case level + case message + case occurredAtUnixMilliseconds = "occurred_at_unix_ms" + } +} + +extension RadrootsAppleTelemetryLogRecord { + var osLogType: OSLogType { + switch level { + case .trace, .debug: + .debug + case .info: + .info + case .notice, .warning: + .default + case .error: + .error + case .critical: + .fault + } + } +} diff --git a/Tests/RadrootsKitTests/RadrootsAppleLoggerTelemetryTests.swift b/Tests/RadrootsKitTests/RadrootsAppleLoggerTelemetryTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func appleLoggerTelemetryEmitsRedactedBoundedRecords() async throws { + let probe = RadrootsAppleLoggerTelemetryProbe() + let telemetry = RadrootsAppleLoggerTelemetry( + subsystem: " org.radroots.field ios ", + adapters: RadrootsAppleLoggerTelemetryAdapters(emit: probe.emit), + maximumRenderedMessageLength: 220 + ) + let event = try RadrootsTelemetryEvent( + name: "field_ios.identity.import", + category: "field_ios", + level: .error, + message: "imported nsec1secret", + fields: [ + try .string("relay_light", "red"), + try .string("selected_secret_key_name", "field identity") + ], + occurredAt: Date(timeIntervalSince1970: 10) + ) + + await telemetry.record(event) + + let record = try #require(probe.records.first) + #expect(record.subsystem == "org.radroots.field_ios") + #expect(record.category == "field_ios") + #expect(record.level == .error) + #expect(record.renderedMessage.contains("\"event\":\"field_ios.identity.import\"")) + #expect(record.renderedMessage.contains("\"message\":\"[redacted]\"")) + #expect(record.renderedMessage.contains("\"selected_secret_key_name\":\"[redacted]\"")) + #expect(!record.renderedMessage.contains("nsec1secret")) + #expect(!record.renderedMessage.contains("field identity")) + #expect(record.renderedMessage.count <= 220) +} + +@Test func appleLoggerTelemetrySanitizesSubsystemAndCategory() async throws { + #expect(RadrootsAppleLoggerTelemetry.normalizedSubsystem(" Field iOS / Local ") == "Field_iOS_Local") + #expect(RadrootsAppleLoggerTelemetry.normalizedSubsystem(" ") == "org.radroots.apple_kit") + #expect(RadrootsAppleLoggerTelemetry.normalizedCategory(" relay/status ") == "relay_status") + #expect(RadrootsAppleLoggerTelemetry.normalizedCategory(" -- ") == "app") +} + +@Test func appleLoggerTelemetryMapsLevelsToOsLogTypes() { + #expect(RadrootsAppleTelemetryLogRecord( + subsystem: "org.radroots.tests", + category: "tests", + level: .trace, + renderedMessage: "{}" + ).osLogType == .debug) + #expect(RadrootsAppleTelemetryLogRecord( + subsystem: "org.radroots.tests", + category: "tests", + level: .info, + renderedMessage: "{}" + ).osLogType == .info) + #expect(RadrootsAppleTelemetryLogRecord( + subsystem: "org.radroots.tests", + category: "tests", + level: .warning, + renderedMessage: "{}" + ).osLogType == .default) + #expect(RadrootsAppleTelemetryLogRecord( + subsystem: "org.radroots.tests", + category: "tests", + level: .error, + renderedMessage: "{}" + ).osLogType == .error) + #expect(RadrootsAppleTelemetryLogRecord( + subsystem: "org.radroots.tests", + category: "tests", + level: .critical, + renderedMessage: "{}" + ).osLogType == .fault) +} + +@Test func appleLoggerTelemetryRendersSortedPayloads() throws { + let event = try RadrootsTelemetryEvent( + name: "field_ios.relay.status", + category: "field_ios", + level: .notice, + fields: [ + try .integer("connecting_count", 1), + try .integer("connected_count", 2) + ], + occurredAt: Date(timeIntervalSince1970: 1) + ) + + let rendered = RadrootsAppleLoggerTelemetry.renderedMessage(for: event) + + #expect(rendered.contains("\"category\":\"field_ios\"")) + #expect(rendered.contains("\"connected_count\":\"2\"")) + #expect(rendered.contains("\"connecting_count\":\"1\"")) + #expect(rendered.contains("\"event\":\"field_ios.relay.status\"")) + #expect(rendered.contains("\"level\":\"notice\"")) + #expect(rendered.contains("\"occurred_at_unix_ms\":1000")) +} + +private final class RadrootsAppleLoggerTelemetryProbe: @unchecked Sendable { + private let lock = NSLock() + private var recordsValue: [RadrootsAppleTelemetryLogRecord] = [] + + func emit(_ record: RadrootsAppleTelemetryLogRecord) { + lock.withLock { + recordsValue.append(record) + } + } + + var records: [RadrootsAppleTelemetryLogRecord] { + lock.withLock { + recordsValue + } + } +}