apple_kit

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

RadrootsTelemetry.swift (12881B)


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