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 }