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:
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)
+ }
+}