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 }