apple_kit

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

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 }