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