apple_kit

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

RadrootsTelemetry.swift (12332B)


      1 import Foundation
      2 
      3 public enum RadrootsTelemetryError: Error, Equatable, Sendable {
      4     case invalidRequest
      5 }
      6 
      7 extension RadrootsTelemetryError: LocalizedError {
      8     public var errorDescription: String? { "The telemetry request is invalid." }
      9 }
     10 
     11 public enum RadrootsTelemetryLevel: String, Sendable, Equatable, Hashable, CaseIterable, Comparable {
     12     case trace
     13     case debug
     14     case info
     15     case notice
     16     case warning
     17     case error
     18     case critical
     19 
     20     public static func < (lhs: Self, rhs: Self) -> Bool {
     21         lhs.severity < rhs.severity
     22     }
     23 
     24     public var severity: Int {
     25         switch self {
     26         case .trace:
     27             0
     28         case .debug:
     29             1
     30         case .info:
     31             2
     32         case .notice:
     33             3
     34         case .warning:
     35             4
     36         case .error:
     37             5
     38         case .critical:
     39             6
     40         }
     41     }
     42 }
     43 
     44 public enum RadrootsTelemetryFieldValue: Sendable, Equatable, Hashable {
     45     case string(String)
     46     case integer(Int64)
     47     case double(Double)
     48     case bool(Bool)
     49     case stringList([String])
     50 
     51     public var renderedValue: String {
     52         switch self {
     53         case .string(let value):
     54             value
     55         case .integer(let value):
     56             String(value)
     57         case .double(let value):
     58             String(value)
     59         case .bool(let value):
     60             value ? "true" : "false"
     61         case .stringList(let value):
     62             value.joined(separator: ",")
     63         }
     64     }
     65 
     66     fileprivate func redacted(
     67         key: String,
     68         policy: RadrootsTelemetryRedactionPolicy
     69     ) -> RadrootsTelemetryFieldValue {
     70         switch self {
     71         case .string(let value):
     72             return .string(policy.redactedString(value, key: key))
     73         case .integer, .double, .bool:
     74             return policy.shouldRedactKey(key) ? .string(policy.replacement) : self
     75         case .stringList(let values):
     76             if policy.shouldRedactKey(key) {
     77                 return .string(policy.replacement)
     78             }
     79             return .stringList(values.map { policy.redactedString($0, key: key) })
     80         }
     81     }
     82 }
     83 
     84 public struct RadrootsTelemetryField: Sendable, Equatable, Hashable {
     85     public let key: String
     86     public let value: RadrootsTelemetryFieldValue
     87 
     88     public init(key: String, value: RadrootsTelemetryFieldValue) throws {
     89         let normalizedKey = try RadrootsTelemetryValidation.normalizedIdentifier(
     90             key,
     91             field: "telemetry field key",
     92             maximumLength: 80
     93         )
     94         try RadrootsTelemetryValidation.validate(value)
     95         self.key = normalizedKey
     96         self.value = value
     97     }
     98 
     99     public static func string(_ key: String, _ value: String) throws -> Self {
    100         try Self(key: key, value: .string(value))
    101     }
    102 
    103     public static func integer(_ key: String, _ value: Int) throws -> Self {
    104         try Self(key: key, value: .integer(Int64(value)))
    105     }
    106 
    107     public static func integer(_ key: String, _ value: Int64) throws -> Self {
    108         try Self(key: key, value: .integer(value))
    109     }
    110 
    111     public static func double(_ key: String, _ value: Double) throws -> Self {
    112         try Self(key: key, value: .double(value))
    113     }
    114 
    115     public static func bool(_ key: String, _ value: Bool) throws -> Self {
    116         try Self(key: key, value: .bool(value))
    117     }
    118 
    119     public static func stringList(_ key: String, _ value: [String]) throws -> Self {
    120         try Self(key: key, value: .stringList(value))
    121     }
    122 
    123     fileprivate init(validatedKey: String, value: RadrootsTelemetryFieldValue) {
    124         key = validatedKey
    125         self.value = value
    126     }
    127 
    128     fileprivate func redacted(policy: RadrootsTelemetryRedactionPolicy) -> Self {
    129         Self(validatedKey: key, value: value.redacted(key: key, policy: policy))
    130     }
    131 }
    132 
    133 public struct RadrootsTelemetryEvent: Sendable, Equatable, Hashable {
    134     public let name: String
    135     public let category: String
    136     public let level: RadrootsTelemetryLevel
    137     public let message: String?
    138     public let fields: [RadrootsTelemetryField]
    139     public let occurredAt: Date
    140 
    141     public init(
    142         name: String,
    143         category: String = "app",
    144         level: RadrootsTelemetryLevel = .info,
    145         message: String? = nil,
    146         fields: [RadrootsTelemetryField] = [],
    147         occurredAt: Date = Date()
    148     ) throws {
    149         let normalizedName = try RadrootsTelemetryValidation.normalizedIdentifier(
    150             name,
    151             field: "telemetry event name",
    152             maximumLength: 120
    153         )
    154         let normalizedCategory = try RadrootsTelemetryValidation.normalizedIdentifier(
    155             category,
    156             field: "telemetry event category",
    157             maximumLength: 80
    158         )
    159         let normalizedMessage = try RadrootsTelemetryValidation.normalizedMessage(message)
    160         guard occurredAt.timeIntervalSinceReferenceDate.isFinite else {
    161             throw RadrootsTelemetryError.invalidRequest
    162         }
    163         let duplicateFieldKeys = Set(fields.map(\.key)).count != fields.count
    164         guard !duplicateFieldKeys else {
    165             throw RadrootsTelemetryError.invalidRequest
    166         }
    167         self.name = normalizedName
    168         self.category = normalizedCategory
    169         self.level = level
    170         self.message = normalizedMessage
    171         self.fields = fields
    172         self.occurredAt = occurredAt
    173     }
    174 
    175     fileprivate init(
    176         validatedName: String,
    177         validatedCategory: String,
    178         level: RadrootsTelemetryLevel,
    179         message: String?,
    180         fields: [RadrootsTelemetryField],
    181         occurredAt: Date
    182     ) {
    183         name = validatedName
    184         category = validatedCategory
    185         self.level = level
    186         self.message = message
    187         self.fields = fields
    188         self.occurredAt = occurredAt
    189     }
    190 }
    191 
    192 public struct RadrootsTelemetryRedactionPolicy: Sendable, Equatable, Hashable {
    193     public let replacement: String
    194     public let maximumStringLength: Int
    195 
    196     public init(
    197         replacement: String = "[redacted]",
    198         maximumStringLength: Int = 160
    199     ) {
    200         let normalizedReplacement = replacement.trimmingCharacters(in: .whitespacesAndNewlines)
    201         self.replacement = normalizedReplacement.isEmpty ? "[redacted]" : normalizedReplacement
    202         self.maximumStringLength = max(32, maximumStringLength)
    203     }
    204 
    205     public static let `default` = RadrootsTelemetryRedactionPolicy()
    206 
    207     public func redacted(_ event: RadrootsTelemetryEvent) -> RadrootsTelemetryEvent {
    208         RadrootsTelemetryEvent(
    209             validatedName: redactedIdentifier(event.name, fallback: "redacted"),
    210             validatedCategory: redactedIdentifier(event.category, fallback: "redacted"),
    211             level: event.level,
    212             message: event.message.map { redactedString($0, key: "message") },
    213             fields: event.fields.map { $0.redacted(policy: self) },
    214             occurredAt: event.occurredAt
    215         )
    216     }
    217 
    218     public func redactedString(_ value: String, key: String? = nil) -> String {
    219         if let key, shouldRedactKey(key) {
    220             return replacement
    221         }
    222         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
    223         guard !trimmed.isEmpty else {
    224             return trimmed
    225         }
    226         guard !containsUnsafeValue(trimmed) else {
    227             return replacement
    228         }
    229         guard trimmed.count > maximumStringLength else {
    230             return trimmed
    231         }
    232         return String(trimmed.prefix(maximumStringLength))
    233     }
    234 
    235     public func shouldRedactKey(_ key: String) -> Bool {
    236         let normalized = key.lowercased()
    237         let unsafeFragments = [
    238             "absolute_path",
    239             "body",
    240             "content",
    241             "document",
    242             "file_name",
    243             "filename",
    244             "keychain",
    245             "nsec",
    246             "password",
    247             "path",
    248             "private",
    249             "secret",
    250             "selected_secret",
    251             "text",
    252             "token",
    253         ]
    254         return unsafeFragments.contains { normalized.contains($0) }
    255     }
    256 
    257     public func containsUnsafeValue(_ value: String) -> Bool {
    258         let normalized = value.lowercased()
    259         if normalized.contains("nsec") {
    260             return true
    261         }
    262         let unsafePathFragments = [
    263             "/users/",
    264             "/private/var/",
    265             "/var/mobile/containers/",
    266             "/var/folders/",
    267             "file:///",
    268         ]
    269         if unsafePathFragments.contains(where: { normalized.contains($0) }) {
    270             return true
    271         }
    272         return normalized.range(of: "[a-f0-9]{64}", options: .regularExpression) != nil
    273     }
    274 
    275     private func redactedIdentifier(_ value: String, fallback: String) -> String {
    276         let redacted = redactedString(value)
    277         return redacted == replacement ? fallback : redacted
    278     }
    279 }
    280 
    281 public protocol RadrootsTelemetry: Sendable {
    282     func record(_ event: RadrootsTelemetryEvent) async
    283 }
    284 
    285 public struct RadrootsNoopTelemetry: RadrootsTelemetry, Sendable {
    286     public init() {}
    287 
    288     public func record(_: RadrootsTelemetryEvent) async {}
    289 }
    290 
    291 public struct RadrootsRedactingTelemetry: RadrootsTelemetry, Sendable {
    292     private let sink: any RadrootsTelemetry
    293     private let policy: RadrootsTelemetryRedactionPolicy
    294 
    295     public init(
    296         sink: any RadrootsTelemetry,
    297         policy: RadrootsTelemetryRedactionPolicy = .default
    298     ) {
    299         self.sink = sink
    300         self.policy = policy
    301     }
    302 
    303     public func record(_ event: RadrootsTelemetryEvent) async {
    304         await sink.record(policy.redacted(event))
    305     }
    306 }
    307 
    308 public struct RadrootsMultiplexTelemetry: RadrootsTelemetry, Sendable {
    309     private let sinks: [any RadrootsTelemetry]
    310 
    311     public init(_ sinks: [any RadrootsTelemetry]) {
    312         self.sinks = sinks
    313     }
    314 
    315     public func record(_ event: RadrootsTelemetryEvent) async {
    316         for sink in sinks {
    317             await sink.record(event)
    318         }
    319     }
    320 }
    321 
    322 public enum RadrootsTelemetryValidation {
    323     public static func normalizedIdentifier(
    324         _ value: String,
    325         field: String,
    326         maximumLength: Int
    327     ) throws -> String {
    328         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
    329         guard !trimmed.isEmpty else {
    330             throw RadrootsTelemetryError.invalidRequest
    331         }
    332         guard trimmed.count <= maximumLength else {
    333             throw RadrootsTelemetryError.invalidRequest
    334         }
    335         guard
    336             trimmed.range(
    337             of: "^[a-z][a-z0-9._-]*$",
    338             options: .regularExpression
    339             ) != nil
    340         else {
    341             throw RadrootsTelemetryError.invalidRequest
    342         }
    343         return trimmed
    344     }
    345 
    346     public static func normalizedMessage(_ value: String?) throws -> String? {
    347         guard let value else {
    348             return nil
    349         }
    350         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
    351         guard !trimmed.isEmpty else {
    352             return nil
    353         }
    354         guard doesNotContainControlCharacters(trimmed) else {
    355             throw RadrootsTelemetryError.invalidRequest
    356         }
    357         guard trimmed.count <= 500 else {
    358             throw RadrootsTelemetryError.invalidRequest
    359         }
    360         return trimmed
    361     }
    362 
    363     public static func validate(_ value: RadrootsTelemetryFieldValue) throws {
    364         switch value {
    365         case .string(let string):
    366             try validateStringValue(string)
    367         case .integer:
    368             return
    369         case .double(let double):
    370             guard double.isFinite else {
    371                 throw RadrootsTelemetryError.invalidRequest
    372             }
    373         case .bool:
    374             return
    375         case .stringList(let values):
    376             guard values.count <= 24 else {
    377                 throw RadrootsTelemetryError.invalidRequest
    378             }
    379             for value in values {
    380                 try validateStringValue(value)
    381             }
    382         }
    383     }
    384 
    385     private static func validateStringValue(_ value: String) throws {
    386         guard doesNotContainControlCharacters(value) else {
    387             throw RadrootsTelemetryError.invalidRequest
    388         }
    389         guard value.count <= 500 else {
    390             throw RadrootsTelemetryError.invalidRequest
    391         }
    392     }
    393 
    394     private static func doesNotContainControlCharacters(_ value: String) -> Bool {
    395         value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) }
    396     }
    397 }