RadrootsAppleLoggerTelemetry.swift (5766B)
1 import Foundation 2 import OSLog 3 4 public struct RadrootsAppleTelemetryLogRecord: Sendable, Equatable { 5 public let subsystem: String 6 public let category: String 7 public let level: RadrootsTelemetryLevel 8 public let renderedMessage: String 9 10 public init( 11 subsystem: String, 12 category: String, 13 level: RadrootsTelemetryLevel, 14 renderedMessage: String 15 ) { 16 self.subsystem = subsystem 17 self.category = category 18 self.level = level 19 self.renderedMessage = renderedMessage 20 } 21 } 22 23 public struct RadrootsAppleLoggerTelemetryAdapters: Sendable { 24 public let emit: @Sendable (RadrootsAppleTelemetryLogRecord) -> Void 25 26 public init(emit: @escaping @Sendable (RadrootsAppleTelemetryLogRecord) -> Void) { 27 self.emit = emit 28 } 29 30 public static let live = Self { record in 31 let logger = Logger(subsystem: record.subsystem, category: record.category) 32 logger.log(level: record.osLogType, "\(record.renderedMessage, privacy: .public)") 33 } 34 } 35 36 public final class RadrootsAppleLoggerTelemetry: RadrootsTelemetry, Sendable { 37 private let subsystem: String 38 private let adapters: RadrootsAppleLoggerTelemetryAdapters 39 private let redactionPolicy: RadrootsTelemetryRedactionPolicy 40 private let maximumRenderedMessageLength: Int 41 42 public init( 43 subsystem: String, 44 adapters: RadrootsAppleLoggerTelemetryAdapters = .live, 45 redactionPolicy: RadrootsTelemetryRedactionPolicy = .default, 46 maximumRenderedMessageLength: Int = 1_000 47 ) { 48 self.subsystem = Self.normalizedSubsystem(subsystem) 49 self.adapters = adapters 50 self.redactionPolicy = redactionPolicy 51 self.maximumRenderedMessageLength = max(160, maximumRenderedMessageLength) 52 } 53 54 public func record(_ event: RadrootsTelemetryEvent) async { 55 let redactedEvent = redactionPolicy.redacted(event) 56 let renderedMessage = Self.renderedMessage( 57 for: redactedEvent, 58 maximumLength: maximumRenderedMessageLength 59 ) 60 adapters.emit( 61 RadrootsAppleTelemetryLogRecord( 62 subsystem: subsystem, 63 category: Self.normalizedCategory(redactedEvent.category), 64 level: redactedEvent.level, 65 renderedMessage: renderedMessage 66 ) 67 ) 68 } 69 70 public static func normalizedSubsystem(_ value: String) -> String { 71 normalizedLogIdentifier( 72 value, 73 fallback: "org.radroots.apple_kit", 74 maximumLength: 120 75 ) 76 } 77 78 public static func normalizedCategory(_ value: String) -> String { 79 normalizedLogIdentifier( 80 value, 81 fallback: "app", 82 maximumLength: 80 83 ) 84 } 85 86 public static func renderedMessage( 87 for event: RadrootsTelemetryEvent, 88 maximumLength: Int = 1_000 89 ) -> String { 90 let payload = RadrootsAppleTelemetryPayload( 91 category: event.category, 92 event: event.name, 93 fields: Dictionary(uniqueKeysWithValues: event.fields.map { field in 94 (field.key, field.value.renderedValue) 95 }), 96 level: event.level.rawValue, 97 message: event.message, 98 occurredAtUnixMilliseconds: Int64(event.occurredAt.timeIntervalSince1970 * 1_000) 99 ) 100 let rendered: String 101 do { 102 let encoder = JSONEncoder() 103 encoder.outputFormatting = [.sortedKeys] 104 let data = try encoder.encode(payload) 105 rendered = String(decoding: data, as: UTF8.self) 106 } catch { 107 rendered = "{\"event\":\"\(event.name)\",\"level\":\"\(event.level.rawValue)\"}" 108 } 109 let boundedLength = max(160, maximumLength) 110 guard rendered.count > boundedLength else { 111 return rendered 112 } 113 return String(rendered.prefix(boundedLength)) 114 } 115 116 private static func normalizedLogIdentifier( 117 _ value: String, 118 fallback: String, 119 maximumLength: Int 120 ) -> String { 121 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) 122 guard !trimmed.isEmpty else { 123 return fallback 124 } 125 var normalized = "" 126 for scalar in trimmed.unicodeScalars { 127 if CharacterSet.alphanumerics.contains(scalar) || scalar == "." || scalar == "_" || scalar == "-" { 128 normalized.append(String(scalar)) 129 } else { 130 normalized.append("_") 131 } 132 } 133 let collapsed = normalized.replacingOccurrences( 134 of: "_+", 135 with: "_", 136 options: .regularExpression 137 ) 138 let trimmedSeparators = collapsed.trimmingCharacters(in: CharacterSet(charactersIn: "._-")) 139 guard !trimmedSeparators.isEmpty else { 140 return fallback 141 } 142 return String(trimmedSeparators.prefix(maximumLength)) 143 } 144 } 145 146 private struct RadrootsAppleTelemetryPayload: Encodable { 147 let category: String 148 let event: String 149 let fields: [String: String] 150 let level: String 151 let message: String? 152 let occurredAtUnixMilliseconds: Int64 153 154 enum CodingKeys: String, CodingKey { 155 case category 156 case event 157 case fields 158 case level 159 case message 160 case occurredAtUnixMilliseconds = "occurred_at_unix_ms" 161 } 162 } 163 164 extension RadrootsAppleTelemetryLogRecord { 165 var osLogType: OSLogType { 166 switch level { 167 case .trace, .debug: 168 .debug 169 case .info: 170 .info 171 case .notice, .warning: 172 .default 173 case .error: 174 .error 175 case .critical: 176 .fault 177 } 178 } 179 }