apple_kit

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

commit 475d2ee2c7079bade88595a5c6ed66f7e97bbf5d
parent 32d2f84aa73a2003307627d23972de177c2187a0
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 14:12:13 -0700

telemetry: add local telemetry contracts

- add typed telemetry levels, events, fields, and validation
- add reusable redaction, no-op, redacting, and multiplex sinks
- add deterministic recording telemetry for RadrootsKitTesting
- cover event validation, redaction, sink forwarding, and fake filtering

Diffstat:
ASources/RadrootsKit/RadrootsTelemetry.swift | 400+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKitTesting/RadrootsTelemetryTesting.swift | 39+++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTestingTests/RadrootsTelemetryTestingTests.swift | 25+++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsTelemetryTests.swift | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 586 insertions(+), 0 deletions(-)

diff --git a/Sources/RadrootsKit/RadrootsTelemetry.swift b/Sources/RadrootsKit/RadrootsTelemetry.swift @@ -0,0 +1,400 @@ +import Foundation + +public enum RadrootsTelemetryError: Error, Equatable, Sendable { + case invalidRequest(String) +} + +extension RadrootsTelemetryError: LocalizedError { + public var errorDescription: String? { + switch self { + case .invalidRequest(let message): + message + } + } +} + +public enum RadrootsTelemetryLevel: String, Sendable, Equatable, Hashable, CaseIterable, Comparable { + case trace + case debug + case info + case notice + case warning + case error + case critical + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.severity < rhs.severity + } + + public var severity: Int { + switch self { + case .trace: + 0 + case .debug: + 1 + case .info: + 2 + case .notice: + 3 + case .warning: + 4 + case .error: + 5 + case .critical: + 6 + } + } +} + +public enum RadrootsTelemetryFieldValue: Sendable, Equatable, Hashable { + case string(String) + case integer(Int64) + case double(Double) + case bool(Bool) + case stringList([String]) + + public var renderedValue: String { + switch self { + case .string(let value): + value + case .integer(let value): + String(value) + case .double(let value): + String(value) + case .bool(let value): + value ? "true" : "false" + case .stringList(let value): + value.joined(separator: ",") + } + } + + fileprivate func redacted( + key: String, + policy: RadrootsTelemetryRedactionPolicy + ) -> RadrootsTelemetryFieldValue { + switch self { + case .string(let value): + return .string(policy.redactedString(value, key: key)) + case .integer, .double, .bool: + return policy.shouldRedactKey(key) ? .string(policy.replacement) : self + case .stringList(let values): + if policy.shouldRedactKey(key) { + return .string(policy.replacement) + } + return .stringList(values.map { policy.redactedString($0, key: key) }) + } + } +} + +public struct RadrootsTelemetryField: Sendable, Equatable, Hashable { + public let key: String + public let value: RadrootsTelemetryFieldValue + + public init(key: String, value: RadrootsTelemetryFieldValue) throws { + let normalizedKey = try RadrootsTelemetryValidation.normalizedIdentifier( + key, + field: "telemetry field key", + maximumLength: 80 + ) + try RadrootsTelemetryValidation.validate(value) + self.key = normalizedKey + self.value = value + } + + public static func string(_ key: String, _ value: String) throws -> Self { + try Self(key: key, value: .string(value)) + } + + public static func integer(_ key: String, _ value: Int) throws -> Self { + try Self(key: key, value: .integer(Int64(value))) + } + + public static func integer(_ key: String, _ value: Int64) throws -> Self { + try Self(key: key, value: .integer(value)) + } + + public static func double(_ key: String, _ value: Double) throws -> Self { + try Self(key: key, value: .double(value)) + } + + public static func bool(_ key: String, _ value: Bool) throws -> Self { + try Self(key: key, value: .bool(value)) + } + + public static func stringList(_ key: String, _ value: [String]) throws -> Self { + try Self(key: key, value: .stringList(value)) + } + + fileprivate init(validatedKey: String, value: RadrootsTelemetryFieldValue) { + self.key = validatedKey + self.value = value + } + + fileprivate func redacted(policy: RadrootsTelemetryRedactionPolicy) -> Self { + Self(validatedKey: key, value: value.redacted(key: key, policy: policy)) + } +} + +public struct RadrootsTelemetryEvent: Sendable, Equatable, Hashable { + public let name: String + public let category: String + public let level: RadrootsTelemetryLevel + public let message: String? + public let fields: [RadrootsTelemetryField] + public let occurredAt: Date + + public init( + name: String, + category: String = "app", + level: RadrootsTelemetryLevel = .info, + message: String? = nil, + fields: [RadrootsTelemetryField] = [], + occurredAt: Date = Date() + ) throws { + let normalizedName = try RadrootsTelemetryValidation.normalizedIdentifier( + name, + field: "telemetry event name", + maximumLength: 120 + ) + let normalizedCategory = try RadrootsTelemetryValidation.normalizedIdentifier( + category, + field: "telemetry event category", + maximumLength: 80 + ) + let normalizedMessage = try RadrootsTelemetryValidation.normalizedMessage(message) + guard occurredAt.timeIntervalSinceReferenceDate.isFinite else { + throw RadrootsTelemetryError.invalidRequest("telemetry event timestamp must be finite") + } + let duplicateFieldKeys = Set(fields.map(\.key)).count != fields.count + guard !duplicateFieldKeys else { + throw RadrootsTelemetryError.invalidRequest("telemetry event field keys must be unique") + } + self.name = normalizedName + self.category = normalizedCategory + self.level = level + self.message = normalizedMessage + self.fields = fields + self.occurredAt = occurredAt + } + + fileprivate init( + validatedName: String, + validatedCategory: String, + level: RadrootsTelemetryLevel, + message: String?, + fields: [RadrootsTelemetryField], + occurredAt: Date + ) { + self.name = validatedName + self.category = validatedCategory + self.level = level + self.message = message + self.fields = fields + self.occurredAt = occurredAt + } +} + +public struct RadrootsTelemetryRedactionPolicy: Sendable, Equatable, Hashable { + public let replacement: String + public let maximumStringLength: Int + + public init( + replacement: String = "[redacted]", + maximumStringLength: Int = 160 + ) { + let normalizedReplacement = replacement.trimmingCharacters(in: .whitespacesAndNewlines) + self.replacement = normalizedReplacement.isEmpty ? "[redacted]" : normalizedReplacement + self.maximumStringLength = max(32, maximumStringLength) + } + + public static let `default` = RadrootsTelemetryRedactionPolicy() + + public func redacted(_ event: RadrootsTelemetryEvent) -> RadrootsTelemetryEvent { + RadrootsTelemetryEvent( + validatedName: redactedIdentifier(event.name, fallback: "redacted"), + validatedCategory: redactedIdentifier(event.category, fallback: "redacted"), + level: event.level, + message: event.message.map { redactedString($0, key: "message") }, + fields: event.fields.map { $0.redacted(policy: self) }, + occurredAt: event.occurredAt + ) + } + + public func redactedString(_ value: String, key: String? = nil) -> String { + if let key, shouldRedactKey(key) { + return replacement + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return trimmed + } + guard !containsUnsafeValue(trimmed) else { + return replacement + } + guard trimmed.count > maximumStringLength else { + return trimmed + } + return String(trimmed.prefix(maximumStringLength)) + } + + public func shouldRedactKey(_ key: String) -> Bool { + let normalized = key.lowercased() + let unsafeFragments = [ + "absolute_path", + "body", + "content", + "document", + "file_name", + "filename", + "keychain", + "nsec", + "password", + "path", + "private", + "secret", + "selected_secret", + "text", + "token" + ] + return unsafeFragments.contains { normalized.contains($0) } + } + + public func containsUnsafeValue(_ value: String) -> Bool { + let normalized = value.lowercased() + if normalized.contains("nsec") { + return true + } + let unsafePathFragments = [ + "/users/", + "/private/var/", + "/var/mobile/containers/", + "/var/folders/", + "file:///" + ] + if unsafePathFragments.contains(where: { normalized.contains($0) }) { + return true + } + return normalized.range(of: "[a-f0-9]{64}", options: .regularExpression) != nil + } + + private func redactedIdentifier(_ value: String, fallback: String) -> String { + let redacted = redactedString(value) + return redacted == replacement ? fallback : redacted + } +} + +public protocol RadrootsTelemetry: Sendable { + func record(_ event: RadrootsTelemetryEvent) async +} + +public struct RadrootsNoopTelemetry: RadrootsTelemetry, Sendable { + public init() {} + + public func record(_ event: RadrootsTelemetryEvent) async {} +} + +public struct RadrootsRedactingTelemetry: RadrootsTelemetry, Sendable { + private let sink: any RadrootsTelemetry + private let policy: RadrootsTelemetryRedactionPolicy + + public init( + sink: any RadrootsTelemetry, + policy: RadrootsTelemetryRedactionPolicy = .default + ) { + self.sink = sink + self.policy = policy + } + + public func record(_ event: RadrootsTelemetryEvent) async { + await sink.record(policy.redacted(event)) + } +} + +public struct RadrootsMultiplexTelemetry: RadrootsTelemetry, Sendable { + private let sinks: [any RadrootsTelemetry] + + public init(_ sinks: [any RadrootsTelemetry]) { + self.sinks = sinks + } + + public func record(_ event: RadrootsTelemetryEvent) async { + for sink in sinks { + await sink.record(event) + } + } +} + +public enum RadrootsTelemetryValidation { + public static func normalizedIdentifier( + _ value: String, + field: String, + maximumLength: Int + ) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw RadrootsTelemetryError.invalidRequest("\(field) must not be empty") + } + guard trimmed.count <= maximumLength else { + throw RadrootsTelemetryError.invalidRequest("\(field) is too long") + } + guard trimmed.range( + of: "^[a-z][a-z0-9._-]*$", + options: .regularExpression + ) != nil else { + throw RadrootsTelemetryError.invalidRequest("\(field) must use lowercase safe identifier characters") + } + return trimmed + } + + public static func normalizedMessage(_ value: String?) throws -> String? { + guard let value else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + guard doesNotContainControlCharacters(trimmed) else { + throw RadrootsTelemetryError.invalidRequest("telemetry event message cannot contain control characters") + } + guard trimmed.count <= 500 else { + throw RadrootsTelemetryError.invalidRequest("telemetry event message is too long") + } + return trimmed + } + + public static func validate(_ value: RadrootsTelemetryFieldValue) throws { + switch value { + case .string(let string): + try validateStringValue(string) + case .integer: + return + case .double(let double): + guard double.isFinite else { + throw RadrootsTelemetryError.invalidRequest("telemetry double field must be finite") + } + case .bool: + return + case .stringList(let values): + guard values.count <= 24 else { + throw RadrootsTelemetryError.invalidRequest("telemetry string list field is too long") + } + for value in values { + try validateStringValue(value) + } + } + } + + private static func validateStringValue(_ value: String) throws { + guard doesNotContainControlCharacters(value) else { + throw RadrootsTelemetryError.invalidRequest("telemetry string field cannot contain control characters") + } + guard value.count <= 500 else { + throw RadrootsTelemetryError.invalidRequest("telemetry string field is too long") + } + } + + private static func doesNotContainControlCharacters(_ value: String) -> Bool { + value.unicodeScalars.allSatisfy { !CharacterSet.controlCharacters.contains($0) } + } +} diff --git a/Sources/RadrootsKitTesting/RadrootsTelemetryTesting.swift b/Sources/RadrootsKitTesting/RadrootsTelemetryTesting.swift @@ -0,0 +1,39 @@ +import Foundation +import RadrootsKit + +public actor RadrootsRecordingTelemetry: RadrootsTelemetry { + private let minimumLevel: RadrootsTelemetryLevel + private var recordedEventsValue: [RadrootsTelemetryEvent] + + public init(minimumLevel: RadrootsTelemetryLevel = .trace) { + self.minimumLevel = minimumLevel + self.recordedEventsValue = [] + } + + public func record(_ event: RadrootsTelemetryEvent) async { + guard event.level >= minimumLevel else { + return + } + recordedEventsValue.append(event) + } + + public func reset() { + recordedEventsValue.removeAll() + } + + public var recordedEvents: [RadrootsTelemetryEvent] { + recordedEventsValue + } + + public var recordedEventCount: Int { + recordedEventsValue.count + } + + public var recordedEventNames: [String] { + recordedEventsValue.map(\.name) + } + + public func events(named name: String) -> [RadrootsTelemetryEvent] { + recordedEventsValue.filter { $0.name == name } + } +} diff --git a/Tests/RadrootsKitTestingTests/RadrootsTelemetryTestingTests.swift b/Tests/RadrootsKitTestingTests/RadrootsTelemetryTestingTests.swift @@ -0,0 +1,25 @@ +import Testing +import RadrootsKit +import RadrootsKitTesting + +@Test func recordingTelemetryStoresEventsInOrderAndFiltersByLevel() async throws { + let telemetry = RadrootsRecordingTelemetry(minimumLevel: .warning) + let debug = try RadrootsTelemetryEvent(name: "field_ios.startup.debug", level: .debug) + let warning = try RadrootsTelemetryEvent(name: "field_ios.relay.warning", level: .warning) + let critical = try RadrootsTelemetryEvent(name: "field_ios.identity.critical", level: .critical) + + await telemetry.record(debug) + await telemetry.record(warning) + await telemetry.record(critical) + + #expect(await telemetry.recordedEventCount == 2) + #expect(await telemetry.recordedEventNames == [ + "field_ios.relay.warning", + "field_ios.identity.critical" + ]) + #expect(await telemetry.events(named: "field_ios.relay.warning").count == 1) + + await telemetry.reset() + + #expect(await telemetry.recordedEventCount == 0) +} diff --git a/Tests/RadrootsKitTests/RadrootsTelemetryTests.swift b/Tests/RadrootsKitTests/RadrootsTelemetryTests.swift @@ -0,0 +1,122 @@ +import Foundation +import Testing +@testable import RadrootsKit + +@Test func telemetryEventNormalizesSafeIdentifiersAndFields() throws { + let event = try RadrootsTelemetryEvent( + name: "field_ios.startup.begin", + category: "field_ios", + level: .notice, + message: " Startup began ", + fields: [ + try .integer("configured_relay_count", 3), + try .bool("has_identity", true), + try .string("relay_light", "green") + ], + occurredAt: Date(timeIntervalSince1970: 42) + ) + + #expect(event.name == "field_ios.startup.begin") + #expect(event.category == "field_ios") + #expect(event.level == .notice) + #expect(event.message == "Startup began") + #expect(event.fields.map(\.key) == ["configured_relay_count", "has_identity", "relay_light"]) + #expect(event.occurredAt == Date(timeIntervalSince1970: 42)) +} + +@Test func telemetryEventRejectsUnsafeShape() throws { + #expect(throws: RadrootsTelemetryError.invalidRequest("telemetry event name must use lowercase safe identifier characters")) { + _ = try RadrootsTelemetryEvent(name: "FieldIos.Startup") + } + #expect(throws: RadrootsTelemetryError.invalidRequest("telemetry field key must use lowercase safe identifier characters")) { + _ = try RadrootsTelemetryField.string("Relay Light", "green") + } + #expect(throws: RadrootsTelemetryError.invalidRequest("telemetry double field must be finite")) { + _ = try RadrootsTelemetryField.double("elapsed_seconds", .infinity) + } + #expect(throws: RadrootsTelemetryError.invalidRequest("telemetry event field keys must be unique")) { + _ = try RadrootsTelemetryEvent( + name: "field_ios.relay.status", + fields: [ + try .integer("connected_count", 1), + try .integer("connected_count", 2) + ] + ) + } +} + +@Test func telemetryRedactionPolicyRedactsSecretsPathsAndUnsafeKeys() throws { + let policy = RadrootsTelemetryRedactionPolicy.default + let secretHex = String(repeating: "a", count: 64) + let event = try RadrootsTelemetryEvent( + name: "field_ios.nsec_startup", + category: "field_ios", + level: .error, + message: "failed with nsec1secretvalue", + fields: [ + try .string("relay_error", "path /Users/person/container"), + try .string("selected_secret_key_name", "field identity"), + try .string("public_reason", "event id \(secretHex)"), + try .integer("absolute_path_count", 1), + try .stringList("relay_urls", ["wss://radroots.org", "nsec1relay"]) + ] + ) + + let redacted = policy.redacted(event) + + #expect(redacted.name == "redacted") + #expect(redacted.category == "field_ios") + #expect(redacted.message == "[redacted]") + #expect(redacted.fields[0].value == .string("[redacted]")) + #expect(redacted.fields[1].value == .string("[redacted]")) + #expect(redacted.fields[2].value == .string("[redacted]")) + #expect(redacted.fields[3].value == .string("[redacted]")) + #expect(redacted.fields[4].value == .stringList(["wss://radroots.org", "[redacted]"])) +} + +@Test func redactingTelemetryRecordsOnlyRedactedEvents() async throws { + let recorder = RadrootsTelemetryProbe() + let telemetry = RadrootsRedactingTelemetry(sink: recorder) + let event = try RadrootsTelemetryEvent( + name: "field_ios.identity.import", + message: "imported nsec1secret", + fields: [ + try .string("identity_state", "imported") + ] + ) + + await telemetry.record(event) + + let events = await recorder.recordedEvents + #expect(events.count == 1) + #expect(events[0].message == "[redacted]") + #expect(events[0].fields[0].value == .string("imported")) +} + +@Test func multiplexTelemetryForwardsToAllSinks() async throws { + let first = RadrootsTelemetryProbe() + let second = RadrootsTelemetryProbe() + let telemetry = RadrootsMultiplexTelemetry([first, second]) + let event = try RadrootsTelemetryEvent(name: "field_ios.startup.success") + + await telemetry.record(event) + + #expect(await first.recordedEventNames == ["field_ios.startup.success"]) + #expect(await second.recordedEventNames == ["field_ios.startup.success"]) +} + +private actor RadrootsTelemetryProbe: RadrootsTelemetry { + private var eventsValue: [RadrootsTelemetryEvent] = [] + + func record(_ event: RadrootsTelemetryEvent) async { + eventsValue.append(event) + } + + var recordedEvents: [RadrootsTelemetryEvent] { + eventsValue + } + + var recordedEventNames: [String] { + eventsValue.map(\.name) + } +}