From c5801757250dd073ebb901ee2bfd4721de494f7a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 13 Aug 2026 20:41:31 -0700 Subject: [PATCH 1/5] Add classified Periscope macro foundation --- Package.swift | 23 + Shared/CreditKit/AGENTS.md | 3 +- Shared/CreditKit/README.md | 2 + .../Tools/Tests/Fixtures/MacroPackage.swift | 25 + .../Tools/Tests/generate_attribution_test.rb | 18 + .../CreditKit/Tools/generate-attribution.rb | 6 +- Shared/Periscope/AGENTS.md | 1 + .../Sources/Events/ClassifiedLogField.swift | 308 +++++++++++++ .../Sources/Events/JSONValue.swift | 66 +++ .../Sources/Events/LogField.swift | 51 +++ .../Sources/Events/LogMacros.swift | 21 + .../Sources/Loggers/LogScopeDefinition.swift | 6 + .../PeriscopeCore/Tests/JSONValueTests.swift | 59 +++ .../PeriscopeCore/Tests/LogFieldTests.swift | 42 ++ Shared/Periscope/PeriscopeMacros/AGENTS.md | 25 + Shared/Periscope/PeriscopeMacros/README.md | 22 + .../PeriscopeMacros/Sources/Diagnostics.swift | 21 + .../Sources/LogEventMacro.swift | 431 ++++++++++++++++++ .../Sources/LogScopeMacro.swift | 174 +++++++ .../Sources/PeriscopePlugin.swift | 10 + .../Sources/SyntaxSupport.swift | 105 +++++ .../Tests/LogEventMacroTests.swift | 124 +++++ .../Tests/LogScopeMacroTests.swift | 23 + Shared/Periscope/README.md | 4 +- Where/Where/Resources/attribution.json | 10 + test | 71 ++- 26 files changed, 1635 insertions(+), 16 deletions(-) create mode 100644 Shared/CreditKit/Tools/Tests/Fixtures/MacroPackage.swift create mode 100644 Shared/CreditKit/Tools/Tests/generate_attribution_test.rb create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Events/ClassifiedLogField.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Events/JSONValue.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Events/LogField.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Events/LogMacros.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/JSONValueTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogFieldTests.swift create mode 100644 Shared/Periscope/PeriscopeMacros/AGENTS.md create mode 100644 Shared/Periscope/PeriscopeMacros/README.md create mode 100644 Shared/Periscope/PeriscopeMacros/Sources/Diagnostics.swift create mode 100644 Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift create mode 100644 Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift create mode 100644 Shared/Periscope/PeriscopeMacros/Sources/PeriscopePlugin.swift create mode 100644 Shared/Periscope/PeriscopeMacros/Sources/SyntaxSupport.swift create mode 100644 Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift create mode 100644 Shared/Periscope/PeriscopeMacros/Tests/LogScopeMacroTests.swift diff --git a/Package.swift b/Package.swift index 933ad2e17..222eeb40a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,5 @@ // swift-tools-version: 6.2 +import CompilerPluginSupport import PackageDescription let package = Package( @@ -43,8 +44,20 @@ let package = Package( .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.0"), .package(url: "https://github.com/cashapp/AccessibilitySnapshot", from: "0.12.0"), .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "7.0.0"), + .package(url: "https://github.com/swiftlang/swift-syntax", exact: "603.0.2"), ], targets: [ + .macro( + name: "PeriscopeMacros", + dependencies: [ + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftDiagnostics", package: "swift-syntax"), + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + ], + path: "Shared/Periscope/PeriscopeMacros/Sources", + ), .target( name: "CreditKit", path: "Shared/CreditKit/Sources", @@ -79,6 +92,7 @@ let package = Package( name: "PeriscopeCore", dependencies: [ .target(name: "JournalKit"), + .target(name: "PeriscopeMacros"), ], path: "Shared/Periscope/PeriscopeCore/Sources", ), @@ -218,5 +232,14 @@ let package = Package( ], path: "Shared/Broadway/BroadwayUI/Sources", ), + .testTarget( + name: "PeriscopeMacrosTests", + dependencies: [ + .target(name: "PeriscopeMacros"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ], + path: "Shared/Periscope/PeriscopeMacros/Tests", + ), ], ) diff --git a/Shared/CreditKit/AGENTS.md b/Shared/CreditKit/AGENTS.md index 8a1e992ec..974eb629b 100644 --- a/Shared/CreditKit/AGENTS.md +++ b/Shared/CreditKit/AGENTS.md @@ -23,9 +23,10 @@ CreditKit provides tools and types for working out what an app owes attribution - **`SoftwareCredit` is `Identifiable` by `name`.** A library's name is its repo basename. - **Read notices at the pinned revision.** Never read the default branch. HEAD's text may not govern the code in the binary. - **The generator keys off `.product(name:package:)`, not `dependencies:`.** -- **That keeps tooling-only packages (BumperBowling, swift-syntax) out of a report by construction.** +- **Include packages that a target links.** A macro-linked package is a development tool. - **`kind` is derived from reachability, not declared.** `shippedFrom` names the app's root package targets. - **Anything inside that closure is a `library`.** Any other linked package is a `developmentTool`. Linking is not shipping. +- **Stop shipping reachability at macro targets.** Macro implementations run on the build host. - **`shippedFrom` is the only hand-set part for SPM packages.** - **`agentSkills` and `developmentTools` declare `kind` in config** — both are development tools in Where today. diff --git a/Shared/CreditKit/README.md b/Shared/CreditKit/README.md index 9fc4f40c8..73d3eac34 100644 --- a/Shared/CreditKit/README.md +++ b/Shared/CreditKit/README.md @@ -85,6 +85,8 @@ The generator walks the manifest's target graph out from them. A package inside that closure is a `library`. Any other linked package is a `developmentTool`. Linking is not shipping. +A macro target stops shipping reachability because its implementation runs on the build host. +Packages that the macro links remain credited as development tools. A snapshot-testing engine linked by a test-support target is credited (the repo depends on it) but must not be described as being in the binary. `shippedFrom` is the only part set by hand. Adding a dependency cannot quietly land under the wrong kind. diff --git a/Shared/CreditKit/Tools/Tests/Fixtures/MacroPackage.swift b/Shared/CreditKit/Tools/Tests/Fixtures/MacroPackage.swift new file mode 100644 index 000000000..d54d920bd --- /dev/null +++ b/Shared/CreditKit/Tools/Tests/Fixtures/MacroPackage.swift @@ -0,0 +1,25 @@ +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "Fixture", + dependencies: [ + .package(url: "https://example.com/fake-runtime", exact: "1.0.0"), + .package(url: "https://example.com/fake-syntax", exact: "1.0.0"), + ], + targets: [ + .target( + name: "Shipping", + dependencies: [ + .target(name: "FixtureMacro"), + .product(name: "FakeRuntime", package: "fake-runtime"), + ], + ), + .macro( + name: "FixtureMacro", + dependencies: [ + .product(name: "FakeSyntax", package: "fake-syntax"), + ], + ), + ], +) diff --git a/Shared/CreditKit/Tools/Tests/generate_attribution_test.rb b/Shared/CreditKit/Tools/Tests/generate_attribution_test.rb new file mode 100644 index 000000000..d76279809 --- /dev/null +++ b/Shared/CreditKit/Tools/Tests/generate_attribution_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../generate-attribution" + +class GenerateAttributionTest < Minitest::Test + FIXTURE = "Shared/CreditKit/Tools/Tests/Fixtures/MacroPackage.swift" + + def test_macro_packages_are_linked_but_not_shipped + targets = package_targets(FIXTURE) + linked = targets.values.flat_map { |target| target["packages"] }.uniq + shipped = shipped_package_identities(targets, ["Shipping"]) + + assert_equal %w[fake-runtime fake-syntax], linked + assert_equal ["fake-runtime"], shipped + assert_equal "macro", targets.fetch("FixtureMacro").fetch("kind") + end +end diff --git a/Shared/CreditKit/Tools/generate-attribution.rb b/Shared/CreditKit/Tools/generate-attribution.rb index cf40fe862..c56417ef1 100755 --- a/Shared/CreditKit/Tools/generate-attribution.rb +++ b/Shared/CreditKit/Tools/generate-attribution.rb @@ -131,7 +131,7 @@ def read_json(relative_path, source_type) # A target declaration, as distinct from a `.target(name:)` *dependency* entry: # only the declaration puts `name:` on its own line. Keying off that rather than # indentation keeps the parse independent of how deeply the array is nested. -TARGET_DECLARATION = /\.(?:target|testTarget|executableTarget)\(\s*\n\s*name:\s*"([^"]+)"/ +TARGET_DECLARATION = /\.(target|testTarget|executableTarget|macro)\(\s*\n\s*name:\s*"([^"]+)"/ TARGET_DEPENDENCY = /\.target\(name:\s*"([^"]+)"/ PRODUCT_DEPENDENCY = /\.product\(\s*name:\s*"[^"]+",\s*package:\s*"([^"]+)"/ @@ -148,8 +148,9 @@ def package_targets(manifest_path, root: ROOT) # Everything up to the next declaration is this target's body. body = text[declaration.end(0)...(declarations[index + 1]&.begin(0) || text.length)] [ - declaration[1], + declaration[2], { + "kind" => declaration[1], "targets" => body.scan(TARGET_DEPENDENCY).flatten, "packages" => body.scan(PRODUCT_DEPENDENCY).flatten.map(&:downcase), }, @@ -172,6 +173,7 @@ def shipped_package_identities(targets, roots) visited << name target = targets[name] next unless target + next if target["kind"] == "macro" shipped.concat(target["packages"]) queue.concat(target["targets"]) end diff --git a/Shared/Periscope/AGENTS.md b/Shared/Periscope/AGENTS.md index f3eabb7ac..8b33ee67b 100644 --- a/Shared/Periscope/AGENTS.md +++ b/Shared/Periscope/AGENTS.md @@ -6,6 +6,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format ## Modules & dependencies +- **PeriscopeMacros** — compile-time event generation. Depends on SwiftSyntax and runs only on the build host. - **PeriscopeCore** — the model and machinery. No SwiftUI, no app code. - **PeriscopeUI** — SwiftUI integration. Depends on PeriscopeCore. - **PeriscopeTools** — developer surfaces. Depends on PeriscopeCore, PeriscopeUI, BroadwayCore/BroadwayUI, and SFSafeSymbols. diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/ClassifiedLogField.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/ClassifiedLogField.swift new file mode 100644 index 000000000..d0e87b520 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/ClassifiedLogField.swift @@ -0,0 +1,308 @@ +import Foundation + +/// Whether a classified field can enter baseline remote diagnostics. +public enum LogFieldExposure: Equatable, Sendable { + case shareable + case restricted +} + +/// The semantic role of a classified event field. +public enum LogFieldKind: Equatable, Sendable { + case boolean + case count + case limit + case duration + case category + case json + case pii + case identifier + case location + case userContent + case errorDetails + case dateTime + case pathOrURL + case arbitraryText + case domainValue + case technicalState +} + +/// The shareable subset of ``LogFieldKind``. +public enum ShareableLogFieldKind: Equatable, Sendable { + case boolean + case count + case limit + case duration + case category + case json +} + +/// A stable event-field key supplied as a source literal. +public struct LogFieldKey: Hashable, Sendable { + public let rawValue: String + + public init(_ rawValue: StaticString) { + self.rawValue = String(describing: rawValue) + } +} + +/// A provider-neutral representation of a baseline-shareable value. +public enum ShareableLogFieldValue: Equatable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case json(JSONValue) +} + +/// The safe field projection consumed by baseline remote sinks. +public enum ClassifiedLogField: Equatable, Sendable { + case shareable( + key: LogFieldKey, + kind: ShareableLogFieldKind, + value: ShareableLogFieldValue, + ) + case restricted(key: LogFieldKey, kind: LogFieldKind) +} + +/// Phantom types used by ``ClassifiedLogInput``. +public enum LogFieldPolicy { + public enum Shared {} + public enum Restricted {} + public enum Boolean {} + public enum Count {} + public enum Limit {} + public enum Duration {} + public enum Category {} + public enum JSON {} + public enum PII {} + public enum Identifier {} + public enum Location {} + public enum UserContent {} + public enum ErrorDetails {} + public enum DateTime {} + public enum PathOrURL {} + public enum ArbitraryText {} + public enum DomainValue {} + public enum TechnicalState {} +} + +/// A compiler-checked token for one semantic field kind. +public struct LogFieldKindToken: Sendable { + fileprivate init() {} +} + +extension LogFieldKindToken { + public static var boolean: LogFieldKindToken { + .init() + } + + public static var count: LogFieldKindToken { + .init() + } + + public static var limit: LogFieldKindToken { + .init() + } + + public static var duration: LogFieldKindToken { + .init() + } + + public static var category: LogFieldKindToken { + .init() + } + + public static var json: LogFieldKindToken { + .init() + } + + public static var pii: LogFieldKindToken { + .init() + } + + public static var identifier: LogFieldKindToken { + .init() + } + + public static var location: LogFieldKindToken { + .init() + } + + public static var userContent: LogFieldKindToken { + .init() + } + + public static var errorDetails: LogFieldKindToken { + .init() + } + + public static var dateTime: LogFieldKindToken { + .init() + } + + public static var pathOrURL: LogFieldKindToken { + .init() + } + + public static var arbitraryText: LogFieldKindToken { + .init() + } + + public static var domainValue: LogFieldKindToken { + .init() + } + + public static var technicalState: LogFieldKindToken { + .init() + } +} + +/// A field value whose exposure, semantic kind, and Swift type are checked by the compiler. +public struct ClassifiedLogInput: Sendable { + public let value: Value + + private init(value: Value) { + self.value = value + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Boolean, Value == Bool +{ + public static func shared( + _: LogFieldKindToken, + _ value: Bool, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Boolean, Value == Bool? +{ + public static func shared( + _: LogFieldKindToken, + _ value: Bool?, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Count, Value == Int +{ + public static func shared( + _: LogFieldKindToken, + _ value: Int, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Count, Value == Int? +{ + public static func shared( + _: LogFieldKindToken, + _ value: Int?, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Limit, Value == Int +{ + public static func shared( + _: LogFieldKindToken, + _ value: Int, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Limit, Value == Int? +{ + public static func shared( + _: LogFieldKindToken, + _ value: Int?, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Duration, Value == Duration +{ + public static func shared( + _: LogFieldKindToken, + _ value: Duration, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Duration, Value == Duration? +{ + public static func shared( + _: LogFieldKindToken, + _ value: Duration?, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Category, + Value: Codable & Sendable & CaseIterable & RawRepresentable, + Value.RawValue == String +{ + public static func shared( + _: LogFieldKindToken, + _ value: Value, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.JSON, Value == JSONValue +{ + public static func shared( + _: LogFieldKindToken, + _ value: JSONValue, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.JSON, Value == JSONValue? +{ + public static func shared( + _: LogFieldKindToken, + _ value: JSONValue?, + ) -> Self { + .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Restricted { + public static func restricted( + _: LogFieldKindToken, + _ value: Value, + ) -> Self { + .init(value: value) + } +} + +extension Duration { + /// The provider-neutral millisecond representation used by classified fields. + public var periscopeMilliseconds: Double { + let components = components + return Double(components.seconds) * 1000 + + Double(components.attoseconds) / 1_000_000_000_000_000 + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/JSONValue.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/JSONValue.swift new file mode 100644 index 000000000..a017ea724 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/JSONValue.swift @@ -0,0 +1,66 @@ +import Foundation + +/// A provider-neutral JSON value for explicitly classified structured fields. +public enum JSONValue: Equatable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) +} + +extension JSONValue: Codable { + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = try .object(container.decode([String: JSONValue].self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case let .bool(value): + try container.encode(value) + case let .int(value): + try container.encode(value) + case let .double(value): + guard value.isFinite else { + throw EncodingError.invalidValue( + value, + EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "JSON numbers must be finite", + ), + ) + } + try container.encode(value) + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + case let .object(value): + try container.encode(value) + } + } + + public static func encoding(_ value: some Encodable & Sendable) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(value)) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogField.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogField.swift new file mode 100644 index 000000000..1101c28e7 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogField.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Stores one event payload value without persisting its classification metadata. +@propertyWrapper +public struct LogField: Codable, Sendable { + private var storage: Value? + private var isInitialized: Bool + + public var wrappedValue: Value { + get { + precondition(isInitialized, "A LogField must be initialized before use") + return storage! + } + set { + storage = newValue + isInitialized = true + } + } + + public init( + _: StaticString, + exposure _: LogFieldExposure, + kind _: LogFieldKind, + ) { + storage = nil + isInitialized = false + } + + public init( + wrappedValue: Value, + _: StaticString, + exposure _: LogFieldExposure, + kind _: LogFieldKind, + ) { + storage = wrappedValue + isInitialized = true + } + + public init(from decoder: any Decoder) throws { + storage = try decoder.singleValueContainer().decode(Value.self) + isInitialized = true + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wrappedValue) + } +} + +extension LogField: Equatable where Value: Equatable {} +extension LogField: Hashable where Value: Hashable {} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogMacros.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogMacros.swift new file mode 100644 index 000000000..8ab1fc29f --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogMacros.swift @@ -0,0 +1,21 @@ +/// Defines a stable logging scope and generates typed event methods. +@attached(member, names: named(scopeName)) +@attached(extension, conformances: LogScopeDefinition) +@attached(peer, names: arbitrary) +public macro LogScope(_ id: String) = #externalMacro( + module: "PeriscopeMacros", + type: "LogScopeMacro", +) + +/// Defines a stable, classified event nested directly in a ``LogScope`` namespace. +@attached(member, names: arbitrary) +@attached(extension, conformances: LogEvent) +public macro LogEvent( + _ id: String, + level: LogLevel? = nil, + message: String? = nil, + version: Int = 1, +) = #externalMacro( + module: "PeriscopeMacros", + type: "LogEventMacro", +) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift new file mode 100644 index 000000000..03ad00f05 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift @@ -0,0 +1,6 @@ +/// A compile-time namespace for one stable logging scope. +public protocol LogScopeDefinition { + associatedtype SpanName: Hashable & Sendable = String + + static var scopeName: String { get } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/JSONValueTests.swift b/Shared/Periscope/PeriscopeCore/Tests/JSONValueTests.swift new file mode 100644 index 000000000..7a09a2725 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/JSONValueTests.swift @@ -0,0 +1,59 @@ +import Foundation +import PeriscopeCore +import Testing + +struct JSONValueTests { + @Test(arguments: [ + JSONValue.null, + .bool(true), + .int(42), + .double(4.25), + .string("value"), + .array([]), + .object([:]), + .array([.null, .object(["nested": .array([.int(1), .bool(false)])])]), + ]) + func roundTripsNaturalJSON(_ value: JSONValue) throws { + let data = try JSONEncoder().encode(value) + #expect(try JSONDecoder().decode(JSONValue.self, from: data) == value) + } + + @Test func preservesIntegerAndDoubleCases() throws { + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("1".utf8)) == .int(1)) + #expect(try JSONDecoder().decode(JSONValue.self, from: Data("1.5".utf8)) == .double(1.5)) + } + + @Test(arguments: [Double.infinity, -Double.infinity, Double.nan]) + func rejectsNonfiniteDoubles(_ value: Double) { + #expect(throws: EncodingError.self) { + try JSONEncoder().encode(JSONValue.double(value)) + } + } + + @Test func convertsArbitraryEncodableValues() throws { + struct Value: Encodable, Sendable { + let count: Int + let complete: Bool + } + + #expect(try JSONValue.encoding(Value(count: 3, complete: true)) == .object([ + "complete": .bool(true), + "count": .int(3), + ])) + } + + @Test func propagatesArbitraryEncodableFailure() { + struct Failing: Encodable, Sendable { + func encode(to _: any Encoder) throws { + throw Failure.expected + } + } + enum Failure: Error { + case expected + } + + #expect(throws: Failure.expected) { + try JSONValue.encoding(Failing()) + } + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogFieldTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogFieldTests.swift new file mode 100644 index 000000000..3b977f529 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogFieldTests.swift @@ -0,0 +1,42 @@ +import Foundation +import PeriscopeCore +import Testing + +private struct WrappedPayload: Codable, Equatable { + @LogField("stable_key", exposure: .restricted, kind: .identifier) + var renamedProperty: String + + init(renamedProperty: String) { + _renamedProperty = LogField( + wrappedValue: renamedProperty, + "stable_key", + exposure: .restricted, + kind: .identifier, + ) + } +} + +struct LogFieldTests { + @Test func wrapperEncodesOnlyItsRawValue() throws { + let payload = WrappedPayload(renamedProperty: "sample-123") + let data = try JSONEncoder().encode(payload) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: String]) + + #expect(object == ["renamedProperty": "sample-123"]) + #expect(try JSONDecoder().decode(WrappedPayload.self, from: data) == payload) + } + + @Test func classifiedInputsRetainTheirRawValues() { + let count: ClassifiedLogInput = + .shared(.count, 3) + let identifier: ClassifiedLogInput< + LogFieldPolicy.Restricted, + LogFieldPolicy.Identifier, + String + > = + .restricted(.identifier, "sample-123") + + #expect(count.value == 3) + #expect(identifier.value == "sample-123") + } +} diff --git a/Shared/Periscope/PeriscopeMacros/AGENTS.md b/Shared/Periscope/PeriscopeMacros/AGENTS.md new file mode 100644 index 000000000..650df486e --- /dev/null +++ b/Shared/Periscope/PeriscopeMacros/AGENTS.md @@ -0,0 +1,25 @@ +# PeriscopeMacros – Module Shape + +PeriscopeMacros implements the classified logging macros. +See [`README.md`](README.md) for the public behavior. +Read the root [`AGENTS.md`](../../../AGENTS.md) and the Periscope [`AGENTS.md`](../AGENTS.md) first. + +## Scope and dependencies + +- Import only the SwiftSyntax products that `Package.swift` lists. +- Do not import `PeriscopeCore`. +- Generate references to public `PeriscopeCore` types as source text. +- Keep diagnostics deterministic and attached to the smallest relevant syntax node. + +## Invariants + +- Accept stable identifiers only as plain literals. +- Generate wire names from explicit identifiers, never Swift type names. +- Generate classified method parameters from each `@LogField` declaration. +- Reject declarations that can create ambiguous generated code. +- Keep restricted field values out of `classifiedFields`. + +## Testing + +Run `./test PeriscopeMacrosTests` for macro expansions and diagnostics. +Use Swift Testing and `SwiftSyntaxMacrosTestSupport`. diff --git a/Shared/Periscope/PeriscopeMacros/README.md b/Shared/Periscope/PeriscopeMacros/README.md new file mode 100644 index 000000000..bd6fc4af2 --- /dev/null +++ b/Shared/Periscope/PeriscopeMacros/README.md @@ -0,0 +1,22 @@ +# PeriscopeMacros + +PeriscopeMacros generates classified event code for `PeriscopeCore`. +It validates stable scope, event, and field identifiers at compile time. +It also generates typed log methods that require classified inputs. + +## Public macros + +- `@LogScope` defines a namespace that conforms to `LogScopeDefinition`. +- `@LogEvent` defines a nested event that conforms to `LogEvent`. + +Application modules import `PeriscopeCore` to use both macros. +They do not import this implementation module. + +## Development + +The root `Package.swift` pins SwiftSyntax exactly. +Run the host tests with `./test PeriscopeMacrosTests`. +This command does not select an iOS simulator. + +Macro expansion tests use SwiftSyntax test support and Swift Testing. +The compiler tests generated constraints when application targets compile. diff --git a/Shared/Periscope/PeriscopeMacros/Sources/Diagnostics.swift b/Shared/Periscope/PeriscopeMacros/Sources/Diagnostics.swift new file mode 100644 index 000000000..378e31107 --- /dev/null +++ b/Shared/Periscope/PeriscopeMacros/Sources/Diagnostics.swift @@ -0,0 +1,21 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +struct PeriscopeMacroDiagnostic: DiagnosticMessage { + let message: String + let diagnosticID: MessageID + let severity: DiagnosticSeverity + + init(_ id: String, _ message: String, severity: DiagnosticSeverity = .error) { + self.message = message + diagnosticID = MessageID(domain: "PeriscopeMacros", id: id) + self.severity = severity + } +} + +extension MacroExpansionContext { + func diagnose(_ node: some SyntaxProtocol, id: String, message: String) { + diagnose(Diagnostic(node: Syntax(node), message: PeriscopeMacroDiagnostic(id, message))) + } +} diff --git a/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift b/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift new file mode 100644 index 000000000..d1a25d690 --- /dev/null +++ b/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift @@ -0,0 +1,431 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct LogEventMacro: MemberMacro, ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo _: [TypeSyntax], + in context: some MacroExpansionContext, + ) throws -> [DeclSyntax] { + guard let event = declaration.as(StructDeclSyntax.self) else { + context.diagnose( + declaration, + id: "event-not-struct", + message: "@LogEvent requires a struct", + ) + return [] + } + guard let arguments = argumentList(of: node), + let idExpression = arguments.first(where: { $0.label == nil })?.expression, + let eventID = plainString(from: idExpression), + !eventID.isEmpty + else { + context.diagnose( + node, + id: "event-id", + message: "@LogEvent requires a nonempty string-literal event ID", + ) + return [] + } + guard let scope = context.lexicalContext.compactMap({ $0.as(EnumDeclSyntax.self) }).last, + attribute(named: "LogScope", in: scope.attributes) != nil + else { + context.diagnose( + event, + id: "event-scope", + message: "@LogEvent must be nested directly in an @LogScope enum", + ) + return [] + } + + let parsed = parseArguments(arguments, on: node, in: context) + let fields = parseFields(event, in: context) + guard !parsed.hasError, !fields.hasError else { return [] } + let access = accessPrefix(event.modifiers) + let hasLevel = instanceProperty(named: "level", in: event) != nil + let hasMessage = instanceProperty(named: "message", in: event) != nil + let hasExternalID = instanceProperty(named: "externalID", in: event) != nil + let hasProtected = staticProperty(named: "isProtectedFromDropping", in: event) != nil + + if parsed.level != nil, hasLevel { + context.diagnose( + event, + id: "duplicate-level", + message: "an event cannot declare both a fixed and an instance level", + ) + return [] + } + if parsed.message != nil, hasMessage { + context.diagnose( + event, + id: "duplicate-message", + message: "an event cannot declare both a static and an instance message", + ) + return [] + } + if parsed.message == nil, !hasMessage { + context.diagnose( + event, + id: "missing-message", + message: "an event requires a static or instance message", + ) + return [] + } + + let scopeName = scope.name.text + var members: [DeclSyntax] = [ + "\(raw: access)static let eventName = \(raw: scopeName).scopeName + \".\(raw: escapedStringLiteral(eventID))\"", + "\(raw: access)static let eventVersion = \(raw: parsed.version)", + DeclSyntax(stringLiteral: codingKeys(access: access, fields: fields.values)), + DeclSyntax(stringLiteral: initializer(access: access, fields: fields.values)), + DeclSyntax(stringLiteral: classifiedFields(access: access, fields: fields.values)), + ] + if let level = parsed.level { + members.append("\(raw: access)var level: LogLevel { .\(raw: level) }") + } + if let message = parsed.message { + members + .append( + "\(raw: access)var message: String { \"\(raw: escapedStringLiteral(message))\" }", + ) + } + if !hasExternalID { + members.append("\(raw: access)var externalID: String? { nil }") + } + if !hasProtected { + members.append("\(raw: access)static var isProtectedFromDropping: Bool { false }") + } + return members + } + + public static func expansion( + of _: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo _: [TypeSyntax], + in context: some MacroExpansionContext, + ) throws -> [ExtensionDeclSyntax] { + guard declaration.is(StructDeclSyntax.self), + context.lexicalContext.compactMap({ $0.as(EnumDeclSyntax.self) }).last.map({ + attribute(named: "LogScope", in: $0.attributes) != nil + }) == true + else { + return [] + } + let extensionDecl: DeclSyntax = "extension \(type.trimmed): LogEvent, Codable, Sendable {}" + return [extensionDecl.cast(ExtensionDeclSyntax.self)] + } +} + +extension LogEventMacro { + fileprivate struct ParsedArguments { + var level: String? + var message: String? + var version = 1 + var hasError = false + } + + fileprivate struct ParsedFields { + var values: [EventField] = [] + var hasError = false + } + + fileprivate static func parseArguments( + _ arguments: LabeledExprListSyntax, + on node: AttributeSyntax, + in context: some MacroExpansionContext, + ) -> ParsedArguments { + var result = ParsedArguments() + for argument in arguments { + switch argument.label?.text { + case "level": + guard let level = memberName(from: argument.expression) else { + context.diagnose( + argument, + id: "event-level", + message: "level must be a LogLevel member", + ) + result.hasError = true + continue + } + result.level = level + case "message": + guard let message = plainString(from: argument.expression) else { + context.diagnose( + argument, + id: "event-message", + message: "message must be a plain string literal", + ) + result.hasError = true + continue + } + result.message = message + case "version": + guard let version = plainInteger(from: argument.expression), version > 0 else { + context.diagnose( + argument, + id: "event-version", + message: "version must be a positive integer literal", + ) + result.hasError = true + continue + } + result.version = version + case nil: + break + default: + context.diagnose( + node, + id: "event-argument", + message: "unsupported @LogEvent argument", + ) + result.hasError = true + } + } + return result + } + + fileprivate static func parseFields( + _ event: StructDeclSyntax, + in context: some MacroExpansionContext, + ) -> ParsedFields { + let metadataNames = ["message", "level", "externalID", "isProtectedFromDropping"] + let reservedNames = [ + "attachments", + "function", + "fileID", + "classifiedFields", + "eventName", + "eventVersion", + ] + let shareableKinds = ["boolean", "count", "limit", "duration", "category", "json"] + var result = ParsedFields() + var keys = Set() + + for member in event.memberBlock.members { + guard let variable = member.decl.as(VariableDeclSyntax.self), + !variable.modifiers + .contains(where: { ["static", "class"].contains($0.name.text) }) + else { + continue + } + guard variable.bindings.count == 1, let binding = variable.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self) + else { + context.diagnose( + variable, + id: "event-binding", + message: "event properties require one named binding", + ) + result.hasError = true + continue + } + let name = identifier.identifier.text + if binding.accessorBlock != nil { + if !metadataNames.contains(name) { + context.diagnose( + variable, + id: "event-accessor", + message: "only event metadata properties can have accessors", + ) + result.hasError = true + } + continue + } + if metadataNames.contains(name) || reservedNames.contains(name) { + context.diagnose( + variable, + id: "event-reserved", + message: "stored event property '\(name)' collides with generated metadata", + ) + result.hasError = true + continue + } + guard binding.initializer == nil, + let type = binding.typeAnnotation?.type.trimmedDescription + else { + context.diagnose( + variable, + id: "event-property", + message: "event fields require an explicit type and no initializer", + ) + result.hasError = true + continue + } + guard let fieldAttribute = attribute(named: "LogField", in: variable.attributes), + let arguments = argumentList(of: fieldAttribute), + let keyExpression = arguments.first(where: { $0.label == nil })?.expression, + let key = plainString(from: keyExpression), !key.isEmpty, + let exposureExpression = arguments.first(where: { $0.label?.text == "exposure" })? + .expression, + let exposure = memberName(from: exposureExpression), + let kindExpression = arguments.first(where: { $0.label?.text == "kind" })? + .expression, + let kind = memberName(from: kindExpression) + else { + context.diagnose( + variable, + id: "event-field", + message: "stored event properties require a complete @LogField classification", + ) + result.hasError = true + continue + } + if !keys.insert(key).inserted { + context.diagnose( + fieldAttribute, + id: "duplicate-key", + message: "event field key '\(key)' is duplicated", + ) + result.hasError = true + } + if reservedNames.contains(name) { + context.diagnose( + variable, + id: "reserved-label", + message: "event field label '\(name)' is reserved", + ) + result.hasError = true + } + if exposure == "shareable", !shareableKinds.contains(kind) { + context.diagnose( + fieldAttribute, + id: "shareable-kind", + message: "field kind '.\(kind)' cannot be shareable", + ) + result.hasError = true + } + if exposure != "shareable", exposure != "restricted" { + context.diagnose( + fieldAttribute, + id: "field-exposure", + message: "field exposure must be .shareable or .restricted", + ) + result.hasError = true + } + if shareableTypeMismatch(type: type, kind: kind, exposure: exposure) { + context.diagnose( + variable, + id: "shareable-type", + message: "shareable .\(kind) requires its classified Swift value type", + ) + result.hasError = true + } + result.values.append(EventField( + name: name, + type: type, + key: key, + exposure: exposure, + kind: kind, + isOptional: type.hasSuffix("?"), + )) + } + return result + } + + fileprivate static func shareableTypeMismatch( + type: String, + kind: String, + exposure: String, + ) -> Bool { + guard exposure == "shareable" else { return false } + let base = type.hasSuffix("?") ? String(type.dropLast()) : type + switch kind { + case "boolean": return base != "Bool" + case "count", "limit": return base != "Int" + case "duration": return base != "Duration" + case "json": return base != "JSONValue" + case "category": return false + default: return true + } + } + + fileprivate static func instanceProperty( + named name: String, + in event: StructDeclSyntax, + ) -> VariableDeclSyntax? { + event.memberBlock.members.compactMap { $0.decl.as(VariableDeclSyntax.self) } + .first { variable in + !variable.modifiers.contains(where: { ["static", "class"].contains($0.name.text) }) + && variable.bindings + .contains { + $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text == name + } + } + } + + fileprivate static func staticProperty( + named name: String, + in event: StructDeclSyntax, + ) -> VariableDeclSyntax? { + event.memberBlock.members.compactMap { $0.decl.as(VariableDeclSyntax.self) } + .first { variable in + variable.modifiers.contains(where: { ["static", "class"].contains($0.name.text) }) + && variable.bindings + .contains { + $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text == name + } + } + } + + fileprivate static func codingKeys(access _: String, fields: [EventField]) -> String { + let cases = fields.map { " case \($0.name) = \"\(escapedStringLiteral($0.key))\"" } + .joined(separator: "\n") + return """ + private enum CodingKeys: String, CodingKey { + \(cases) + } + """ + } + + fileprivate static func initializer(access: String, fields: [EventField]) -> String { + let parameters = fields.map { " \($0.name): \($0.parameterType)" } + .joined(separator: ",\n") + let assignments = fields.map { + " self._\($0.name) = LogField(wrappedValue: \($0.name).value, \"\(escapedStringLiteral($0.key))\", exposure: .\($0.exposure), kind: .\($0.kind))" + }.joined(separator: "\n") + if fields.isEmpty { + return "\(access)init() {}" + } + return """ + \(access)init( + \(parameters) + ) { + \(assignments) + } + """ + } + + fileprivate static func classifiedFields(access: String, fields: [EventField]) -> String { + let statements = fields.map { field -> String in + let key = "LogFieldKey(\"\(escapedStringLiteral(field.key))\")" + if field.exposure == "restricted" { + return " fields.append(.restricted(key: \(key), kind: .\(field.kind)))" + } + let rawName = field.isOptional ? "value" : field.name + let value = switch field.kind { + case "boolean": ".bool(\(rawName))" + case "count", "limit": ".int(\(rawName))" + case "duration": ".double(\(rawName).periscopeMilliseconds)" + case "category": ".string(\(rawName).rawValue)" + case "json": ".json(\(rawName))" + default: ".string(String(describing: \(rawName)))" + } + let append = "fields.append(.shareable(key: \(key), kind: .\(field.kind), value: \(value)))" + if field.isOptional { + return " if let value = \(field.name) { \(append) }" + } + return " \(append)" + }.joined(separator: "\n") + return """ + \(access)var classifiedFields: [ClassifiedLogField] { + var fields: [ClassifiedLogField] = [] + \(statements) + return fields + } + """ + } +} diff --git a/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift b/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift new file mode 100644 index 000000000..83a08f14a --- /dev/null +++ b/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift @@ -0,0 +1,174 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct LogScopeMacro: MemberMacro, ExtensionMacro, PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo _: [TypeSyntax], + in context: some MacroExpansionContext, + ) throws -> [DeclSyntax] { + guard let scope = declaration.as(EnumDeclSyntax.self) else { + context.diagnose( + declaration, + id: "scope-not-enum", + message: "@LogScope requires an enum namespace", + ) + return [] + } + guard let arguments = argumentList(of: node), + let expression = arguments.first?.expression, + let id = plainString(from: expression), !id.isEmpty + else { + context.diagnose( + node, + id: "scope-id", + message: "@LogScope requires a nonempty string-literal scope ID", + ) + return [] + } + if scope.memberBlock.members.contains(where: { $0.decl.is(EnumCaseDeclSyntax.self) }) { + context.diagnose( + scope, + id: "scope-cases", + message: "an @LogScope enum cannot declare cases", + ) + return [] + } + let access = accessPrefix(scope.modifiers) + return ["\(raw: access)static let scopeName = \"\(raw: escapedStringLiteral(id))\""] + } + + public static func expansion( + of _: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo _: [TypeSyntax], + in _: some MacroExpansionContext, + ) throws -> [ExtensionDeclSyntax] { + guard declaration.is(EnumDeclSyntax.self) else { return [] } + let extensionDecl: DeclSyntax = "extension \(type.trimmed): LogScopeDefinition {}" + return [extensionDecl.cast(ExtensionDeclSyntax.self)] + } + + public static func expansion( + of _: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext, + ) throws -> [DeclSyntax] { + guard let scope = declaration.as(EnumDeclSyntax.self) else { return [] } + let scopeAccess = accessPrefix(scope.modifiers) + var seenIDs = Set() + var seenMethods = Set() + var methods: [String] = [] + + for member in scope.memberBlock.members { + guard let event = member.decl.as(StructDeclSyntax.self), + let eventAttribute = attribute(named: "LogEvent", in: event.attributes), + let arguments = argumentList(of: eventAttribute), + let idExpression = arguments.first?.expression, + let eventID = plainString(from: idExpression) + else { + continue + } + if !seenIDs.insert(eventID).inserted { + context.diagnose( + event, + id: "duplicate-event-id", + message: "event ID '\(eventID)' is duplicated in this scope", + ) + continue + } + let methodName = lowerCamelCase(event.name.text) + if !seenMethods.insert(methodName).inserted { + context.diagnose( + event, + id: "duplicate-method", + message: "generated log method '\(methodName)' is duplicated", + ) + continue + } + let fields = eventFields(event) + let eventAccess = accessPrefix(event.modifiers) + let access = scopeAccess == "public " && eventAccess == "public " ? "public " : "" + methods.append(method( + access: access, + name: methodName, + scope: scope.name.text, + event: event.name.text, + fields: fields, + )) + } + guard !methods.isEmpty else { return [] } + return [DeclSyntax(stringLiteral: """ + extension Log where Scope == \(scope.name.text) { + \(methods.joined(separator: "\n\n")) + } + """)] + } +} + +extension LogScopeMacro { + fileprivate static func eventFields(_ event: StructDeclSyntax) -> [EventField] { + event.memberBlock.members.compactMap { member in + guard let variable = member.decl.as(VariableDeclSyntax.self), + let fieldAttribute = attribute(named: "LogField", in: variable.attributes), + let binding = variable.bindings.first, + let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text, + let type = binding.typeAnnotation?.type.trimmedDescription, + let arguments = argumentList(of: fieldAttribute), + let keyExpression = arguments.first?.expression, + let key = plainString(from: keyExpression), + let exposureExpression = arguments.first(where: { $0.label?.text == "exposure" })? + .expression, + let exposure = memberName(from: exposureExpression), + let kindExpression = arguments.first(where: { $0.label?.text == "kind" })? + .expression, + let kind = memberName(from: kindExpression) + else { + return nil + } + return EventField( + name: name, + type: type, + key: key, + exposure: exposure, + kind: kind, + isOptional: type.hasSuffix("?"), + ) + } + } + + fileprivate static func method( + access: String, + name: String, + scope: String, + event: String, + fields: [EventField], + ) -> String { + var parameters = fields.map { " \($0.name): \($0.parameterType)" } + parameters.append(" attachments: [LogAttachment] = []") + parameters.append(" function: StaticString = #function") + parameters.append(" fileID: StaticString = #fileID") + let arguments = fields.map { " \($0.name): \($0.name)" } + .joined(separator: ",\n") + let eventInit = fields.isEmpty ? "\(scope).\(event)()" : """ + \(scope).\(event)( + \(arguments) + ) + """ + return """ + \(access)func \(name)( + \(parameters.joined(separator: ",\n")) + ) { + record( + \(eventInit), + attachments: attachments, + function: function, + fileID: fileID + ) + } + """ + } +} diff --git a/Shared/Periscope/PeriscopeMacros/Sources/PeriscopePlugin.swift b/Shared/Periscope/PeriscopeMacros/Sources/PeriscopePlugin.swift new file mode 100644 index 000000000..770e57fb5 --- /dev/null +++ b/Shared/Periscope/PeriscopeMacros/Sources/PeriscopePlugin.swift @@ -0,0 +1,10 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct PeriscopePlugin: CompilerPlugin { + let providingMacros: [any Macro.Type] = [ + LogEventMacro.self, + LogScopeMacro.self, + ] +} diff --git a/Shared/Periscope/PeriscopeMacros/Sources/SyntaxSupport.swift b/Shared/Periscope/PeriscopeMacros/Sources/SyntaxSupport.swift new file mode 100644 index 000000000..0172099ec --- /dev/null +++ b/Shared/Periscope/PeriscopeMacros/Sources/SyntaxSupport.swift @@ -0,0 +1,105 @@ +import SwiftSyntax + +struct EventField { + let name: String + let type: String + let key: String + let exposure: String + let kind: String + let isOptional: Bool + + var policyType: String { + exposure == "shareable" ? "Shared" : "Restricted" + } + + var kindType: String { + switch kind { + case "boolean": "Boolean" + case "count": "Count" + case "limit": "Limit" + case "duration": "Duration" + case "category": "Category" + case "json": "JSON" + case "pii": "PII" + case "identifier": "Identifier" + case "location": "Location" + case "userContent": "UserContent" + case "errorDetails": "ErrorDetails" + case "dateTime": "DateTime" + case "pathOrURL": "PathOrURL" + case "arbitraryText": "ArbitraryText" + case "domainValue": "DomainValue" + case "technicalState": "TechnicalState" + default: "TechnicalState" + } + } + + var parameterType: String { + "ClassifiedLogInput" + } +} + +func argumentList(of attribute: AttributeSyntax) -> LabeledExprListSyntax? { + guard case let .argumentList(arguments) = attribute.arguments else { return nil } + return arguments +} + +func plainString(from expression: ExprSyntax) -> String? { + guard let literal = expression.as(StringLiteralExprSyntax.self), + literal.segments.count == 1, + let segment = literal.segments.first?.as(StringSegmentSyntax.self) + else { + return nil + } + return segment.content.text +} + +func plainInteger(from expression: ExprSyntax) -> Int? { + guard let literal = expression.as(IntegerLiteralExprSyntax.self) else { return nil } + return Int(literal.literal.text) +} + +func memberName(from expression: ExprSyntax) -> String? { + expression.as(MemberAccessExprSyntax.self)?.declName.baseName.text +} + +func attribute(named name: String, in attributes: AttributeListSyntax) -> AttributeSyntax? { + attributes.compactMap { element -> AttributeSyntax? in + guard case let .attribute(attribute) = element else { return nil } + let attributeName = attribute.attributeName.trimmedDescription + return attributeName == name || attributeName.hasSuffix(".\(name)") ? attribute : nil + }.first +} + +func accessPrefix(_ modifiers: DeclModifierListSyntax) -> String { + modifiers.contains { $0.name.tokenKind == .keyword(.public) } ? "public " : "" +} + +func lowerCamelCase(_ name: String) -> String { + guard let first = name.first else { return name } + let scalars = Array(name) + var uppercasePrefix = 0 + while uppercasePrefix < scalars.count, scalars[uppercasePrefix].isUppercase { + uppercasePrefix += 1 + } + if uppercasePrefix <= 1 { + return first.lowercased() + name.dropFirst() + } + let acronymEnd = uppercasePrefix == scalars.count ? uppercasePrefix : uppercasePrefix - 1 + return String(scalars[.. String { + var result = "" + for character in value { + switch character { + case "\\": result += "\\\\" + case "\"": result += "\\\"" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + default: result.append(character) + } + } + return result +} diff --git a/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift b/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift new file mode 100644 index 000000000..2f2be1cf3 --- /dev/null +++ b/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift @@ -0,0 +1,124 @@ +@testable import PeriscopeMacros +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing + +private let macros: [String: any Macro.Type] = [ + "LogEvent": LogEventMacro.self, + "LogScope": LogScopeMacro.self, +] + +@Test +func expandsClassifiedEventAndLogMethod() { + assertMacroExpansion( + """ + @LogScope("Sample") + enum SampleLog { + @LogEvent("counted", level: .notice, message: "Counted") + struct Counted { + @LogField("count", exposure: .shareable, kind: .count) + var count: Int + } + } + """, + expandedSource: """ + enum SampleLog { + struct Counted { + @LogField("count", exposure: .shareable, kind: .count) + var count: Int + + static let eventName = SampleLog.scopeName + ".counted" + + static let eventVersion = 1 + + private enum CodingKeys: String, CodingKey { + case count = "count" + } + + init( + count: ClassifiedLogInput + ) { + self._count = LogField(wrappedValue: count.value, "count", exposure: .shareable, kind: .count) + } + + var classifiedFields: [ClassifiedLogField] { + var fields: [ClassifiedLogField] = [] + fields.append(.shareable(key: LogFieldKey("count"), kind: .count, value: .int(count))) + return fields + } + + var level: LogLevel { + .notice + } + + var message: String { + "Counted" + } + + var externalID: String? { + nil + } + + static var isProtectedFromDropping: Bool { + false + } + } + + static let scopeName = "Sample" + } + + extension Log where Scope == SampleLog { + func counted( + count: ClassifiedLogInput, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID + ) { + record( + SampleLog.Counted( + count: count + ), + attachments: attachments, + function: function, + fileID: fileID + ) + } + } + + extension SampleLog.Counted: LogEvent, Codable, Sendable { + } + + extension SampleLog: LogScopeDefinition { + } + """, + macros: macros, + ) +} + +@Test +func eventRequiresStruct() { + assertMacroExpansion( + "@LogEvent(\"value\", message: \"Value\") enum Value {}", + expandedSource: "enum Value {}", + diagnostics: [ + DiagnosticSpec(message: "@LogEvent requires a struct", line: 1, column: 1), + ], + macros: macros, + ) +} + +@Test +func eventRequiresScope() { + assertMacroExpansion( + "@LogEvent(\"value\", message: \"Value\") struct Value {}", + expandedSource: "struct Value {}", + diagnostics: [ + DiagnosticSpec( + message: "@LogEvent must be nested directly in an @LogScope enum", + line: 1, + column: 1, + ), + ], + macros: macros, + ) +} diff --git a/Shared/Periscope/PeriscopeMacros/Tests/LogScopeMacroTests.swift b/Shared/Periscope/PeriscopeMacros/Tests/LogScopeMacroTests.swift new file mode 100644 index 000000000..51d89fa66 --- /dev/null +++ b/Shared/Periscope/PeriscopeMacros/Tests/LogScopeMacroTests.swift @@ -0,0 +1,23 @@ +@testable import PeriscopeMacros +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import Testing + +@Test +func scopeRequiresEnum() { + assertMacroExpansion( + "@LogScope(\"scope\") struct Scope {}", + expandedSource: "struct Scope {}", + diagnostics: [ + DiagnosticSpec(message: "@LogScope requires an enum namespace", line: 1, column: 1), + ], + macros: ["LogScope": LogScopeMacro.self], + ) +} + +@Test +func leadingAcronymBecomesOneWord() { + #expect(lowerCamelCase("URLLoadFailed") == "urlLoadFailed") + #expect(lowerCamelCase("GPS") == "gps") + #expect(lowerCamelCase("Loaded") == "loaded") +} diff --git a/Shared/Periscope/README.md b/Shared/Periscope/README.md index 993784882..dee06b7eb 100644 --- a/Shared/Periscope/README.md +++ b/Shared/Periscope/README.md @@ -9,6 +9,7 @@ This file is the map. ## Modules +- **PeriscopeMacros** ([PeriscopeMacros/](PeriscopeMacros/)) — the compile-time implementation for classified event authoring. It depends on SwiftSyntax and runs only on the build host. - **PeriscopeCore** ([PeriscopeCore/](PeriscopeCore/)) — the model and the machinery: events, levels, scopes, links, tags, spans, attachments, the sink pipeline (OSLog + SwiftData), ambient event sources, the crash journal, and the store. Foundation-level. No SwiftUI. - **PeriscopeUI** ([PeriscopeUI/](PeriscopeUI/)) — the SwiftUI integration: the `logContext` modifier and environment accessors that flow log scopes down a view hierarchy. Depends on PeriscopeCore. - **PeriscopeTools** ([PeriscopeTools/](PeriscopeTools/)) — the on-device developer surfaces: the latest-logs viewer, tracer, span views, scope-tree browser, debug toast, and inspect mode. Depends on PeriscopeCore, PeriscopeUI, and Broadway for styling. @@ -23,7 +24,8 @@ It ships in no target and no CI job. Libraries are declared in the root [`Package.swift`](../../Package.swift). Their hosted test bundles are in [`Project.swift`](../../Project.swift). -Run `./test PeriscopeCoreTests`, `./test PeriscopeUITests`, or `./test PeriscopeToolsTests`. +Run `./test PeriscopeMacrosTests` for the host macro suite. +Run `./test PeriscopeCoreTests`, `./test PeriscopeUITests`, or `./test PeriscopeToolsTests` for the hosted suites. ## Open work diff --git a/Where/Where/Resources/attribution.json b/Where/Where/Resources/attribution.json index eff0d0696..11840b493 100644 --- a/Where/Where/Resources/attribution.json +++ b/Where/Where/Resources/attribution.json @@ -50,6 +50,16 @@ "text": "MIT License\n\nCopyright (c) 2019 Point-Free, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n" } }, + { + "name": "swift-syntax", + "kind": "developmentTool", + "version": "603.0.2", + "homepageURL": "https://github.com/swiftlang/swift-syntax", + "license": { + "name": "Apache License 2.0", + "text": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n\n\n\n## Runtime Library Exception to the Apache 2.0 License: ##\n\n\n As an exception, if you use this Software to compile your source code and\n portions of this Software are embedded into the binary product as a result,\n you may redistribute such product without providing attribution as would\n otherwise be required by Sections 4(a), 4(b) and 4(d) of the License.\n" + } + }, { "name": "simple-english", "kind": "developmentTool", diff --git a/test b/test index 39b6169a4..2d15f8e10 100755 --- a/test +++ b/test @@ -82,6 +82,7 @@ SHARED=false RUN_ARCHITECTURE=true ARCHITECTURE_ONLY=false HAS_TEST_ARGUMENTS=false +HOST_MACROS=false usage() { cat <<'USAGE' @@ -97,9 +98,10 @@ Scope: (no arguments) Bundles affected by the diff against origin/main, including uncommitted and untracked files BundleName ... Named bundles only (e.g. ./test WhereCoreTests) - --all The full unit suite (Stuff-iOS-Tests) + PeriscopeMacrosTests runs on the host without a simulator + --all The full unit suite plus host macro tests --snapshots The image-snapshot suite (StuffSnapshotTests) - --everything Both suites — what CI runs + --everything Both suites plus host macro tests — what CI runs --only ID An xcodebuild test identifier, repeatable: Bundle, Bundle/Suite, or 'Bundle/Suite/test()' --only-file PATH Read newline-delimited Bundle/Suite identifiers from PATH @@ -268,6 +270,9 @@ if [ "$ARCHITECTURE_ONLY" = true ]; then exit 0 fi +echo "==> Testing attribution generator" +mise exec -- ruby Shared/CreditKit/Tools/Tests/generate_attribution_test.rb + WORKSPACE="Stuff.xcworkspace" UNIT_SCHEME="Stuff-iOS-Tests" SNAPSHOT_SCHEME="StuffSnapshotTests" @@ -356,9 +361,9 @@ add_scheme() { } case "$SCOPE" in - all) add_scheme "$UNIT_SCHEME" ;; + all) add_scheme "$UNIT_SCHEME"; HOST_MACROS=true ;; snapshots) add_scheme "$SNAPSHOT_SCHEME" ;; - everything) add_scheme "$UNIT_SCHEME"; add_scheme "$SNAPSHOT_SCHEME" ;; + everything) add_scheme "$UNIT_SCHEME"; add_scheme "$SNAPSHOT_SCHEME"; HOST_MACROS=true ;; bundles|changed) if [ "$SCOPE" = changed ]; then if git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then @@ -374,6 +379,10 @@ case "$SCOPE" in echo "==> No changes against $BASE_REF — nothing to test." exit 0 fi + if printf '%s\n' "$changed_files" | grep -Eq \ + '^(Package\.swift|Package\.resolved|test|Shared/Periscope/PeriscopeMacros/)'; then + HOST_MACROS=true + fi set +e selection="$(affected_bundles "$changed_files")" selection_status=$? @@ -389,16 +398,20 @@ case "$SCOPE" in done < No test bundle covers these changes:" printf '%s\n' "$changed_files" | sed 's/^/ /' exit 0 fi - echo "==> Affected bundles: ${BUNDLES[*]}" + if [ "${#BUNDLES[@]}" -gt 0 ]; then + echo "==> Affected bundles: ${BUNDLES[*]}" + fi fi image_bundles="$(snapshot_bundles)" for bundle in "${BUNDLES[@]}"; do - if printf '%s\n' "$image_bundles" | grep -qx "$bundle"; then + if [ "$bundle" = PeriscopeMacrosTests ]; then + HOST_MACROS=true + elif printf '%s\n' "$image_bundles" | grep -qx "$bundle"; then add_scheme "$SNAPSHOT_SCHEME" SNAPSHOT_FILTERS+=("-only-testing:$bundle") else @@ -438,7 +451,7 @@ if [ -n "$ONLY_FILE" ]; then fi fi -if [ "${#SCHEMES[@]}" -eq 0 ]; then +if [ "${#SCHEMES[@]}" -eq 0 ] && [ "$HOST_MACROS" = false ]; then echo "error: nothing to run (see ./test --help)" >&2 exit 1 fi @@ -456,6 +469,44 @@ for scheme in "${SCHEMES[@]}"; do break done +overall_status=0 +RESULTS=() +LOGS=() + +if [ "$HOST_MACROS" = true ]; then + echo "==> Building PeriscopeMacrosTests on the host" + macro_build_log="$WORKDIR/PeriscopeMacrosTests-build.log" + if ! mise exec -- swift build --target PeriscopeMacrosTests >"$macro_build_log" 2>&1; then + echo "error: build failed for PeriscopeMacrosTests. Tail of $macro_build_log:" >&2 + tail -n 30 "$macro_build_log" >&2 + exit 1 + fi + macro_bundle="$(find .build -path '*/Products/Debug/PeriscopeMacrosTests.xctest' \ + -type d -print -quit)" + if [ -z "$macro_bundle" ]; then + echo "error: SwiftPM did not produce PeriscopeMacrosTests.xctest" >&2 + exit 1 + fi + echo "==> Testing PeriscopeMacrosTests on macOS" + macro_log="$WORKDIR/PeriscopeMacrosTests.log" + set +e + xcrun xctest "$macro_bundle" 2>&1 | tee "$macro_log" + macro_status="${PIPESTATUS[0]}" + set -e + LOGS+=("$macro_log") + [ "$macro_status" -ne 0 ] && overall_status="$macro_status" +fi + +if [ "${#SCHEMES[@]}" -eq 0 ]; then + echo + if [ "$overall_status" -eq 0 ]; then + echo "==> Passed. Logs and result bundles: $WORKDIR" + else + echo "==> Failed (exit $overall_status). Logs and result bundles: $WORKDIR" + fi + exit "$overall_status" +fi + if [ "$DO_GENERATE" = true ]; then echo "==> Regenerating project (tuist generate --no-open)" started="$(phase_started)" @@ -537,10 +588,6 @@ elif [ -z "$BUILD_ARTIFACTS" ]; then "bundle lookups will fall back to the linker's placement" >&2 fi -overall_status=0 -RESULTS=() -LOGS=() - for scheme in "${SCHEMES[@]}"; do filters=() if [ "$scheme" = "$SNAPSHOT_SCHEME" ]; then From c388ef0235ea8004924ed77efa2d1e2ae3e0d5c5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 13 Aug 2026 21:07:32 -0700 Subject: [PATCH 2/5] Add scope-typed Periscope contexts --- .../Ambient/AccessibilityAmbientSource.swift | 13 +- .../Sources/Ambient/AmbientEvent.swift | 104 +++--- .../Ambient/AppLifecycleAmbientSource.swift | 7 +- .../Ambient/LowPowerModeAmbientSource.swift | 7 +- .../Ambient/MemoryWarningAmbientSource.swift | 8 +- .../Ambient/NetworkPathAmbientSource.swift | 9 +- .../Ambient/ThermalStateAmbientSource.swift | 7 +- .../Sources/Context/AmbientLogContext.swift | 4 +- .../Sources/Context/LogContext.swift | 122 +++++++ .../Sources/Context/LogContextProviding.swift | 10 +- .../Sources/Events/LogEvent.swift | 18 +- .../Sources/Events/LogMacros.swift | 3 +- .../Sources/Events/Message.swift | 45 ++- .../Sources/Events/PeriscopeInternalLog.swift | 32 ++ .../PeriscopeCore/Sources/Loggers/Log.swift | 61 +++- .../Sources/Loggers/LogScopeDefinition.swift | 13 + .../Sources/Pipeline/Periscope.swift | 36 +- .../PeriscopeCore/Sources/Spans/LogSpan.swift | 337 +++++++++--------- .../Sources/Store/PeriscopeStore.swift | 14 +- .../Store/PeriscopeStoreJournalIngest.swift | 5 +- .../Sources/Store/StoredLogEvent.swift | 31 -- .../Tests/AmbientEventSourceTests.swift | 4 +- .../Tests/AmbientEventTests.swift | 21 +- .../Tests/AmbientSnapshotTests.swift | 24 +- .../Tests/LogContextProvidingTests.swift | 2 +- .../Tests/LogJournalEntryTests.swift | 8 +- .../PeriscopeCore/Tests/LogJournalTests.swift | 4 +- .../PeriscopeCore/Tests/LogMacrosTests.swift | 26 ++ .../PeriscopeCore/Tests/LogSpanTests.swift | 16 +- .../PeriscopeCore/Tests/MessageTests.swift | 12 +- .../PeriscopeCore/Tests/OSLogSinkTests.swift | 19 +- .../Tests/PeriscopeCoreTestSupport.swift | 82 ++++- .../PeriscopeStoreJournalIngestTests.swift | 23 +- .../Tests/PeriscopeStoreTests.swift | 42 +-- .../PeriscopeCore/Tests/PeriscopeTests.swift | 32 +- .../Tests/StoredLogEventTests.swift | 5 +- .../Sources/LogEventMacro.swift | 15 +- .../Sources/LogScopeMacro.swift | 88 +++-- .../Tests/LogEventMacroTests.swift | 48 ++- .../PeriscopeViewerSnapshotTests.swift | 5 +- .../Sources/InspectMode/LogInspectable.swift | 2 +- .../LocalNotificationAlertHandlerTests.swift | 4 +- .../Tests/LogEventDetailViewTests.swift | 9 +- .../Tests/LogTraceModelTests.swift | 4 +- .../Tests/PeriscopeToolsTestSupport.swift | 76 +++- .../Tests/PeriscopeViewerModelTests.swift | 2 +- .../Tests/SpanHistoryModelTests.swift | 8 +- Shared/Periscope/PeriscopeUI/AGENTS.md | 3 +- .../Sources/LogContextEnvironment.swift | 18 +- .../Tests/LogContextEnvironmentTests.swift | 8 +- .../Developer/DeveloperLogInspection.swift | 2 +- .../WhereUI/Tests/LogHistoryPrunerTests.swift | 5 +- 52 files changed, 980 insertions(+), 523 deletions(-) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Context/LogContext.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Events/PeriscopeInternalLog.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogMacrosTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AccessibilityAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AccessibilityAmbientSource.swift index 17d4e5047..11db9cf0d 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AccessibilityAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AccessibilityAmbientSource.swift @@ -89,11 +89,16 @@ @MainActor private static func summaryEvent() -> AmbientEvent { AmbientEvent( - kind: .accessibility, - value: Dictionary( - uniqueKeysWithValues: settings - .map { ($0.name, AmbientValue.bool($0.isEnabled())) }, + kind: .restricted(.technicalState, .accessibility), + value: .restricted( + .domainValue, + Dictionary( + uniqueKeysWithValues: settings + .map { ($0.name, AmbientValue.bool($0.isEnabled())) }, + ), ), + level: .restricted(.technicalState, .info), + reporting: .restricted(.technicalState, .state), ) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift index 08cc7062b..223a40d35 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift @@ -111,51 +111,65 @@ extension [String: AmbientValue] { } } -/// The standard event ambient sources emit: environmental context — -/// backgrounding, memory pressure, connectivity, thermal state — that helps -/// diagnose what the system was doing around an error. -public struct AmbientEvent: LogEvent, Hashable { - public static let eventName = "ambient" - - /// Whether an event announces a lasting condition or a passing moment. - /// - /// Only `state` folds into the ``AmbientSnapshot`` every later record is - /// stamped with. A memory warning describes an instant, not a condition - /// the app stays in, so it must not stick to everything after it. - /// - /// The case names are the persisted wire values — renaming one rewrites - /// the format for stored rows. - public enum Reporting: String, Hashable, Sendable, Codable { - /// A lasting condition: the newest value replaces the previous one - /// and describes the app until it changes again. - case state - /// A momentary occurrence, meaningful only at its own timestamp. - case occurrence - } - - public var kind: AmbientKind - /// The state as named fields (`["level": "serious"]`, - /// `["voiceover": false]`) — a JSON object in the payload, not a - /// formatted sentence the tooling would have to parse back apart. - public var value: [String: AmbientValue] - public var level: LogLevel - /// Defaults to ``Reporting/state`` — "ambient" means a surrounding - /// condition, and a source that reports moments is the exception. - public var reporting: Reporting - - public var message: String { - "\(kind): \(value.ambientDescription)" - } +/// The built-in scope for environmental state and occurrence events. +@LogScope("ambient") +public enum AmbientLog { + /// The standard event ambient sources emit: environmental context — + /// backgrounding, memory pressure, connectivity, thermal state — that helps + /// diagnose what the system was doing around an error. + @LogEvent("event") + public struct Event: Hashable { + /// Whether an event announces a lasting condition or a passing moment. + /// + /// Only `state` folds into the ``AmbientSnapshot`` every later record is + /// stamped with. A memory warning describes an instant, not a condition + /// the app stays in, so it must not stick to everything after it. + /// + /// The case names are the persisted wire values — renaming one rewrites + /// the format for stored rows. + public enum Reporting: String, Hashable, Sendable, Codable { + /// A lasting condition: the newest value replaces the previous one + /// and describes the app until it changes again. + case state + /// A momentary occurrence, meaningful only at its own timestamp. + case occurrence + } - public init( - kind: AmbientKind, - value: [String: AmbientValue], - level: LogLevel = .info, - reporting: Reporting = .state, - ) { - self.kind = kind - self.value = value - self.level = level - self.reporting = reporting + @LogField( + "kind", + exposure: .restricted, + kind: .technicalState, + ) + public var kind: AmbientKind + + /// The state as named fields (`["level": "serious"]`, + /// `["voiceover": false]`) — a JSON object in the payload, not a + /// formatted sentence the tooling would have to parse back apart. + @LogField( + "value", + exposure: .restricted, + kind: .domainValue, + ) + public var value: [String: AmbientValue] + + @LogField( + "level", + exposure: .restricted, + kind: .technicalState, + ) + public var level: LogLevel + + @LogField( + "reporting", + exposure: .restricted, + kind: .technicalState, + ) + public var reporting: AmbientLog.Event.Reporting + + public var message: String { + "\(kind): \(value.ambientDescription)" + } } } + +public typealias AmbientEvent = AmbientLog.Event diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift index 55cbff420..b61df5900 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift @@ -18,7 +18,12 @@ override public func event(for notification: Notification) -> AmbientEvent? { Self.values[notification.name].map { - AmbientEvent(kind: .appLifecycle, value: ["phase": .string($0)]) + AmbientEvent( + kind: .restricted(.technicalState, .appLifecycle), + value: .restricted(.domainValue, ["phase": .string($0)]), + level: .restricted(.technicalState, .info), + reporting: .restricted(.technicalState, .state), + ) } } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift index 2d5495bc7..40ee90ec8 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift @@ -22,6 +22,11 @@ public final class LowPowerModeAmbientSource: NotificationAmbientSource { private static func currentEvent() -> AmbientEvent { let enabled = ProcessInfo.processInfo.isLowPowerModeEnabled - return AmbientEvent(kind: .powerMode, value: ["low-power": .bool(enabled)]) + return AmbientEvent( + kind: .restricted(.technicalState, .powerMode), + value: .restricted(.domainValue, ["low-power": .bool(enabled)]), + level: .restricted(.technicalState, .info), + reporting: .restricted(.technicalState, .state), + ) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift index 1954bd557..f752ae099 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift @@ -13,10 +13,10 @@ // `.occurrence`: the app isn't "in a memory warning" afterwards, // so this must not stick to every later record's snapshot. AmbientEvent( - kind: .memory, - value: ["pressure": .string("warning")], - level: .warning, - reporting: .occurrence, + kind: .restricted(.technicalState, .memory), + value: .restricted(.domainValue, ["pressure": .string("warning")]), + level: .restricted(.technicalState, .warning), + reporting: .restricted(.technicalState, .occurrence), ) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift index 05863434c..b3c07038a 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift @@ -69,7 +69,14 @@ public final class NetworkPathAmbientSource: AmbientEventSource { return true } guard changed else { return } - log { AmbientEvent(kind: .network, value: value) } + log { + AmbientEvent( + kind: .restricted(.technicalState, .network), + value: .restricted(.domainValue, value), + level: .restricted(.technicalState, .info), + reporting: .restricted(.technicalState, .state), + ) + } } private static func describe(_ path: NWPath) -> [String: AmbientValue] { diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift index 473161615..781945fb9 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift @@ -43,9 +43,10 @@ public final class ThermalStateAmbientSource: NotificationAmbientSource { logLevel = .info } return AmbientEvent( - kind: .thermalState, - value: ["level": .string(level)], - level: logLevel, + kind: .restricted(.technicalState, .thermalState), + value: .restricted(.domainValue, ["level": .string(level)]), + level: .restricted(.technicalState, logLevel), + reporting: .restricted(.technicalState, .state), ) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift index 1afd252ee..08234c3b9 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift @@ -15,11 +15,11 @@ enum AmbientLogContext { } extension Log { - /// The ambient logger, typed to `Event`: the context bound by the + /// The ambient logger, typed to `Scope`: the context bound by the /// nearest enclosing ``withContext(isolation:_:)``, or a root logger on /// ``Periscope/shared`` when none is bound. Freeform helpers use /// `Log.current`. - public static var current: Log { + public static var current: Log { guard let context = AmbientLogContext.current else { return Log() } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/LogContext.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContext.swift new file mode 100644 index 000000000..aeafa878d --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContext.swift @@ -0,0 +1,122 @@ +import Foundation + +/// A type-erased logging context that retains scopes, tags, and its recorder. +public struct LogContext: Sendable { + let scopes: [LogScope] + let tags: [LogTag] + let recorder: any LogRecorder + + public init(system: Periscope = .shared) { + let log = Log(system: system) + scopes = log.scopes + tags = log.tags + recorder = log.recorder + } + + init(scopes: [LogScope], tags: [LogTag], recorder: any LogRecorder) { + precondition(!scopes.isEmpty, "A LogContext must have at least one scope") + self.scopes = scopes + self.tags = tags + self.recorder = recorder + } + + /// Derives a typed child scope from this context. + public func callAsFunction(_: Scope.Type) -> Log { + Log(scopes: scopes, tags: tags, recorder: recorder)(Scope.self) + } + + /// Derives a legacy event scope and records one event in a single expression. + public func callAsFunction( + _ type: Event.Type, + function: StaticString = #function, + fileID: StaticString = #fileID, + _ event: () -> Event, + ) { + let child = callAsFunction(type) + child.record(event(), function: function, fileID: fileID) + } + + /// Links another context after this context while preserving this primary scope. + public func linked(with other: LogContext) -> LogContext { + var merged = scopes + for scope in other.scopes where !merged.contains(scope) { + merged.append(scope) + } + return LogContext(scopes: merged, tags: tags.merging(other.tags), recorder: recorder) + } + + public func log( + _ level: LogLevel, + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + erasedLog.log(level, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func debug( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.debug, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func info( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.info, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func notice( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.notice, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func warning( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.warning, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func error( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.error, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func fault( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.fault, text(), attachments: attachments, function: function, fileID: fileID) + } + + private var erasedLog: Log { + Log(scopes: scopes, tags: tags, recorder: recorder) + } +} + +extension Log { + /// Returns this logger's context without deriving another scope. + public var context: LogContext { + LogContext(scopes: scopes, tags: tags, recorder: recorder) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift index 5adf93e4a..6e9116440 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift @@ -12,7 +12,7 @@ import os /// /// ```swift /// final class PhotoController: LogContextProviding { -/// typealias LogEventType = PhotoLogs // omit for freeform-only logging +/// typealias LoggingScope = PhotoLogs // omit for freeform-only logging /// /// func refresh() { /// log.info("refreshing") // scoped to this instance @@ -24,9 +24,9 @@ import os /// Conformers log into ``Periscope/shared`` unless they override /// ``logSystem``. public protocol LogContextProviding: AnyObject { - /// The structured event type `log` emits. Defaults to ``Message`` for + /// The scope that `log` uses. Defaults to ``FreeformLogScope`` for /// freeform-only conformers. - associatedtype LogEventType: LogEvent = Message + associatedtype LoggingScope: LogScopeDefinition = FreeformLogScope /// The system this object logs into; defaults to ``Periscope/shared``. var logSystem: Periscope { get } @@ -38,7 +38,7 @@ extension LogContextProviding { } /// A logger scoped to this instance (type root scope → instance scope). - public var log: Log { + public var log: Log { logSystem.instanceLog(for: self) } } @@ -48,7 +48,7 @@ extension Periscope { /// Repeated calls for the same instance return the same scope. public func instanceLog( for object: Object, - ) -> Log { + ) -> Log { let scopes = instanceScopes.scopes(for: object) defineScope(scopes.type) return Log(scopes: [scopes.instance], tags: [], recorder: self) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift index 061d4757e..94a36a9af 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift @@ -17,19 +17,7 @@ import Foundation /// /// Events are emitted through a typed logger: `Log` can log /// only `PhotoUploaded` values (plus freeform ``Message`` conveniences). -public protocol LogEvent: Codable, Sendable { - /// The token type naming this event's spans — `log.measure(.saveEvent)` - /// resolves against it. Defaults to `String` for freeform span names; - /// declare a nested enum for typed tokens: - /// - /// ```swift - /// struct DatabaseLogs: LogEvent { - /// enum SpanName: Hashable, Sendable { case saveEvent, migration } - /// // ... - /// } - /// ``` - associatedtype SpanName: Hashable, Sendable = String - +public protocol LogEvent: LogScopeDefinition, Codable, Sendable { /// Stable name the event persists under; defaults to the type name. /// /// Persisted payloads are keyed by this name (plus ``eventVersion``), so @@ -72,6 +60,10 @@ public protocol LogEvent: Codable, Sendable { } extension LogEvent { + public static var scopeName: String { + eventName + } + public static var eventName: String { String(describing: Self.self) } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogMacros.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogMacros.swift index 8ab1fc29f..d12bd797f 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/LogMacros.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogMacros.swift @@ -1,7 +1,6 @@ /// Defines a stable logging scope and generates typed event methods. -@attached(member, names: named(scopeName)) +@attached(member, names: arbitrary) @attached(extension, conformances: LogScopeDefinition) -@attached(peer, names: arbitrary) public macro LogScope(_ id: String) = #externalMacro( module: "PeriscopeMacros", type: "LogScopeMacro", diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/Message.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/Message.swift index eb7937bd2..f02b76007 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/Message.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/Message.swift @@ -1,24 +1,33 @@ import Foundation -/// The built-in freeform log event: a rendered string plus a level. -/// -/// `Message` is what the level convenience methods on `Log` (`log.debug(_:)`, -/// `log.error(_:)`, …) emit, so every typed logger stays freeform-capable — -/// the generic `Event` constraint applies to custom structured events only. -public struct Message: LogEvent, Hashable { - public static let eventName = "message" +/// The built-in scope for freeform messages. +@LogScope("message") +public enum FreeformLogScope { + /// The built-in freeform log event: a rendered string plus a level. + /// + /// `Message` is what the level convenience methods on `Log` (`log.debug(_:)`, + /// `log.error(_:)`, …) emit, so every typed logger stays freeform-capable. + @LogEvent("message") + public struct Message: Hashable { + @LogField( + "level", + exposure: .restricted, + kind: .technicalState, + ) + public var level: LogLevel - public var level: LogLevel + /// The stored, already-rendered message text. + @LogField( + "text", + exposure: .restricted, + kind: .arbitraryText, + ) + public var text: String - /// The stored, already-rendered message text. - public var text: String - - public var message: String { - text - } - - public init(level: LogLevel, _ text: String) { - self.level = level - self.text = text + public var message: String { + text + } } } + +public typealias Message = FreeformLogScope.Message diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/PeriscopeInternalLog.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/PeriscopeInternalLog.swift new file mode 100644 index 000000000..feb4b16a7 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/PeriscopeInternalLog.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Events emitted by Periscope's own delivery and persistence machinery. +@LogScope("Periscope") +public enum PeriscopeInternalLog { + /// Reports records discarded by the bounded delivery queue. + @LogEvent("dropped-events", level: .warning) + public struct DroppedEvents { + @LogField("count", exposure: .shareable, kind: .count) + public var count: Int + + public var message: String { + "\(count) log event(s) dropped before delivery" + } + } + + /// Marks a failed, rolled-back store write in the durable history. + @LogEvent("store-write-failed", level: .warning) + public struct StoreWriteFailed { + @LogField("lost_record_count", exposure: .shareable, kind: .count) + public var lostRecordCount: Int + + @LogField("reason", exposure: .restricted, kind: .errorDetails) + public var reason: String + + public var message: String { + "\(lostRecordCount) record(s) failed to persist: \(reason)" + } + } +} + +public typealias StoreWriteFailed = PeriscopeInternalLog.StoreWriteFailed diff --git a/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift index 24782fac8..19e54f875 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift @@ -20,7 +20,8 @@ import Foundation /// /// Deriving the same path twice yields the same scope (see ``ScopeID``), so /// loggers can be rebuilt anywhere without coordination. -public struct Log: Sendable { +@dynamicMemberLookup +public struct Log: Sendable { /// The scopes events emitted here belong to: the primary scope first, /// then any linked scopes. public let scopes: [LogScope] @@ -30,14 +31,19 @@ public struct Log: Sendable { let recorder: any LogRecorder + /// Resolves a macro-generated event method for this scope. + public subscript(dynamicMember keyPath: KeyPath) -> Method { + Scope.makeLogMethods(self)[keyPath: keyPath] + } + /// The scope this logger derives children from. public var primaryScope: LogScope { scopes[0] } - /// A root logger whose scope is named after `Event`. + /// A root logger whose scope uses the definition's stable name. public init(recorder: any LogRecorder) { - self.init(scopes: [LogScope.root(named: Event.eventName)], tags: [], recorder: recorder) + self.init(scopes: [LogScope.root(named: Scope.scopeName)], tags: [], recorder: recorder) } init(scopes: [LogScope], tags: [LogTag], recorder: any LogRecorder) { @@ -51,17 +57,17 @@ public struct Log: Sendable { // MARK: Deriving children /// A child logger typed to `Child`, under a child scope named after it. - public func callAsFunction(_: Child.Type) -> Log { - deriving(childNamed: Child.eventName) + public func callAsFunction(_: Child.Type) -> Log { + deriving(childNamed: Child.scopeName) } /// A child logger for a specific entity, under a child scope named by /// `id` — e.g. one scope per album, per payment, per request. - public func callAsFunction(for id: some Hashable & Sendable) -> Log { + public func callAsFunction(for id: some Hashable & Sendable) -> Log { deriving(childNamed: String(describing: id)) } - private func deriving(childNamed name: String) -> Log { + private func deriving(childNamed name: String) -> Log { var scopes = scopes scopes[0] = primaryScope.child(named: name) return Log(scopes: scopes, tags: tags, recorder: recorder) @@ -73,12 +79,12 @@ public struct Log: Sendable { /// between two contexts, e.g. a model object's log and the UI's log. /// Duplicate scopes collapse and tags merge; the left side stays /// primary and wins tag-key conflicts. - public static func + (lhs: Log, rhs: Log) -> Log { + public static func + (lhs: Log, rhs: Log) -> Log { lhs.linked(with: rhs) } /// The spelled-out form of `+`. - public func linked(with other: Log) -> Log { + public func linked(with other: Log) -> Log { var merged = scopes for scope in other.scopes where !merged.contains(scope) { merged.append(scope) @@ -92,7 +98,7 @@ public struct Log: Sendable { /// different event type. No child scope is derived (unlike calling with /// an event type); adapters use this to carry a context across a typed /// boundary, e.g. the SwiftUI environment's freeform accessor. - public func retyped(to _: Other.Type) -> Log { + public func retyped(to _: Other.Type) -> Log { Log(scopes: scopes, tags: tags, recorder: recorder) } @@ -104,7 +110,7 @@ public struct Log: Sendable { /// event under it carries the tag, wherever it sits in the tree. /// Values are typed (see ``LogTagValue``); `String`, `Int`, `Double`, /// and `Bool` convert directly. Re-tagging a key replaces its value. - public func tagged(_ key: LogTagKey, _ value: some LogTagValueConvertible) -> Log { + public func tagged(_ key: LogTagKey, _ value: some LogTagValueConvertible) -> Log { var tags = tags tags.set(value.logTagValue, forKey: key) return Log(scopes: scopes, tags: tags, recorder: recorder) @@ -116,8 +122,8 @@ public struct Log: Sendable { public func callAsFunction( function: StaticString = #function, fileID: StaticString = #fileID, - _ event: () -> Event, - ) { + _ event: () -> Scope, + ) where Scope: LogEvent { emit(event(), callSite: LogCallSite(function: function, fileID: fileID)) } @@ -127,8 +133,8 @@ public struct Log: Sendable { attachments: [LogAttachment], function: StaticString = #function, fileID: StaticString = #fileID, - _ event: () -> Event, - ) { + _ event: () -> Scope, + ) where Scope: LogEvent { emit( event(), attachments: attachments, @@ -161,12 +167,26 @@ public struct Log: Sendable { for id: some Hashable & Sendable, function: StaticString = #function, fileID: StaticString = #fileID, - _ event: () -> Event, - ) { - let child: Log = callAsFunction(for: id) + _ event: () -> Scope, + ) where Scope: LogEvent { + let child: Log = callAsFunction(for: id) child.emit(event(), callSite: LogCallSite(function: function, fileID: fileID)) } + /// Records one event with this logger's scopes, tags, attachments, and call site. + public func record( + _ event: some LogEvent, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + emit( + event, + attachments: attachments, + callSite: LogCallSite(function: function, fileID: fileID), + ) + } + func emit( _ event: any LogEvent, attachments: [LogAttachment] = [], @@ -199,7 +219,10 @@ extension Log { ) { guard recorder.shouldRecord(level: level, scopes: scopes.map(\.id)) else { return } emit( - Message(level: level, text()), + Message( + level: .restricted(.technicalState, level), + text: .restricted(.arbitraryText, text()), + ), attachments: attachments, callSite: LogCallSite(function: function, fileID: fileID), ) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift index 03ad00f05..19517bbe5 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift @@ -1,6 +1,19 @@ /// A compile-time namespace for one stable logging scope. public protocol LogScopeDefinition { associatedtype SpanName: Hashable & Sendable = String + associatedtype LogMethods = EmptyLogMethods static var scopeName: String { get } + static func makeLogMethods(_ log: Log) -> LogMethods +} + +/// The method surface for a scope that declares no structured events. +public struct EmptyLogMethods: Sendable { + public init() {} +} + +extension LogScopeDefinition where LogMethods == EmptyLogMethods { + public static func makeLogMethods(_: Log) -> EmptyLogMethods { + EmptyLogMethods() + } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift index 52ded1135..55516bcbe 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift @@ -117,27 +117,7 @@ public final class Periscope: LogRecorder, Sendable { } /// The synthetic event reporting records dropped by the overflow policy. - public struct DroppedEvents: LogEvent { - public static let eventName = "dropped-events" - - public let count: Int - - public var level: LogLevel { - .warning - } - - public var message: String { - "\(count) log event(s) dropped before delivery" - } - - public var remoteFields: [RemoteLogField] { - [RemoteLogField(key: RemoteLogFieldKey("count"), value: .count(count))] - } - - public init(count: Int) { - self.count = count - } - } + public typealias DroppedEvents = PeriscopeInternalLog.DroppedEvents /// One entry in the ordered pending queue. A single queue keeps scope /// definitions strictly before the records that reference them. @@ -684,10 +664,14 @@ public final class Periscope: LogRecorder, Sendable { var closing = LogRecord( date: Date(), event: SpanEnded( - spanID: span.id, - name: span.name, - duration: now - span.start, - exit: .expired(budget: budget), + spanID: .restricted(.identifier, span.id), + name: .restricted(.technicalState, span.name), + duration: .shared(.duration, now - span.start), + exitMode: .shared(.category, .expired), + exitReason: .restricted( + .errorDetails, + SpanExit.expired(budget: budget).reason, + ), ), scopes: span.scopes, tags: span.tags, @@ -832,7 +816,7 @@ public final class Periscope: LogRecorder, Sendable { if state.droppedCount > 0 { var report = LogRecord( date: Date(), - event: DroppedEvents(count: state.droppedCount), + event: DroppedEvents(count: .shared(.count, state.droppedCount)), scopes: [systemScope.id], ) // Stamped here rather than in `buffer`: the report is diff --git a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift index e080446c7..89f1a0b0b 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift @@ -29,129 +29,145 @@ extension SpanID: Codable { } } -/// Marks the start of a timed span (`Log.measure` / `Log.begin(for:)`). -/// Carries the span's lifetime and relaunch policy so the watchdog and the -/// relaunch sweep can honor them from the persisted payload alone. -public struct SpanBegan: LogEvent { - public static let eventName = "span-began" - public static let eventVersion = 2 - /// Half of a span pair — see `LogEvent.isProtectedFromDropping`. - public static let isProtectedFromDropping = true - - public let spanID: SpanID - public let name: String - public let lifetime: SpanLifetime - public let relaunchPolicy: SpanRelaunchPolicy +/// The built-in scope for timed operation events. +@LogScope("span") +public enum SpanLog { + /// Marks the start of a timed span (`Log.measure` / `Log.begin(for:)`). + /// Carries the span's lifetime and relaunch policy so the watchdog and the + /// relaunch sweep can honor them from the persisted payload alone. + @LogEvent("began") + public struct Began { + public enum LifetimeMode: String, CaseIterable, Codable, Sendable { + case scoped + case bounded + case indefinite + } - public var message: String { - "▶ \(name)" - } + /// Half of a span pair — see `LogEvent.isProtectedFromDropping`. + public static let isProtectedFromDropping = true + + @LogField("span_id", exposure: .restricted, kind: .identifier) + public var spanID: SpanID + + @LogField("name", exposure: .restricted, kind: .technicalState) + public var name: String + + @LogField("lifetime", exposure: .restricted, kind: .technicalState) + public var lifetimeMode: SpanLog.Began.LifetimeMode + + @LogField("budget_ms", exposure: .shareable, kind: .duration) + public var budget: Duration? - public var remoteFields: [RemoteLogField] { - var fields = [RemoteLogField( - key: RemoteLogFieldKey("relaunch_policy"), - value: .category(RemoteLogCategory(relaunchPolicy)), - )] - if case let .bounded(budget) = lifetime { - fields.append(RemoteLogField( - key: RemoteLogFieldKey("budget_ms"), - value: .durationMilliseconds(budget.milliseconds), - )) + @LogField("relaunch_policy", exposure: .shareable, kind: .category) + public var relaunchPolicy: SpanRelaunchPolicy + + public var lifetime: SpanLifetime { + switch lifetimeMode { + case .scoped: .scoped + case .bounded: .bounded(budget: budget ?? .zero) + case .indefinite: .indefinite + } } - return fields - } - public init( - spanID: SpanID, - name: String, - lifetime: SpanLifetime, - relaunchPolicy: SpanRelaunchPolicy, - ) { - self.spanID = spanID - self.name = name - self.lifetime = lifetime - self.relaunchPolicy = relaunchPolicy + public var message: String { + "▶ \(name)" + } } -} -/// Marks the end of a timed span: the measured duration (monotonic — -/// `ContinuousClock`; `nil` when unknowable, i.e. orphaned across process -/// death) and how it ended. -/// -/// Abnormal exits (`superseded`, `expired`, `orphaned`, `failure`) log at -/// `.warning`; `success` and `cancelled` (a normal lifecycle outcome) stay -/// at `.info`. -public struct SpanEnded: LogEvent { - public static let eventName = "span-ended" - public static let eventVersion = 2 - /// Half of a span pair — see `LogEvent.isProtectedFromDropping`. - public static let isProtectedFromDropping = true - - public let spanID: SpanID - public let name: String - public let duration: Duration? - public let exit: SpanExit + /// Marks the end of a timed span: the measured duration and how it ended. + @LogEvent("ended") + public struct Ended { + /// Half of a span pair — see `LogEvent.isProtectedFromDropping`. + public static let isProtectedFromDropping = true + + @LogField("span_id", exposure: .restricted, kind: .identifier) + public var spanID: SpanID + + @LogField("name", exposure: .restricted, kind: .technicalState) + public var name: String + + @LogField("duration_ms", exposure: .shareable, kind: .duration) + public var duration: Duration? + + @LogField("exit", exposure: .shareable, kind: .category) + public var exitMode: SpanExit.Mode - public var level: LogLevel { - switch exit.mode { - case .success, .cancelled: .info - case .failure, .superseded, .expired, .orphaned: .warning + @LogField("exit_reason", exposure: .restricted, kind: .errorDetails) + public var exitReason: String? + + public var exit: SpanExit { + SpanExit(mode: exitMode, reason: exitReason) } - } - public var message: String { - var text = "◀ \(name) \(exit.mode.described)" - if let reason = exit.reason { - text += ": \(reason)" + public var level: LogLevel { + switch exitMode { + case .success, .cancelled: .info + case .failure, .superseded, .expired, .orphaned: .warning + } } - if let duration { - text += " (\(duration.formatted()))" + + public var message: String { + var text = "◀ \(name) \(exitMode.described)" + if let exitReason { + text += ": \(exitReason)" + } + if let duration { + text += " (\(duration.formatted()))" + } + return text } - return text - } - public var remoteFields: [RemoteLogField] { - var fields = [RemoteLogField( - key: RemoteLogFieldKey("exit"), - value: .category(RemoteLogCategory(exit.mode)), - )] - if let duration { - fields.append(RemoteLogField( - key: RemoteLogFieldKey("duration_ms"), - value: .durationMilliseconds(duration.milliseconds), - )) + /// Best-effort recovery of the span name from a rendered ``message``. + public static func nameRecovered( + fromMessage message: String, + exit mode: SpanExit.Mode?, + ) -> String { + var text = message.hasPrefix("◀ ") ? String(message.dropFirst(2)) : message + if let mode, let exitWord = text.range(of: " " + mode.described) { + text = String(text[.. String { - var text = message.hasPrefix("◀ ") ? String(message.dropFirst(2)) : message - if let mode, let exitWord = text.range(of: " " + mode.described) { - text = String(text[..(_ name: Event.SpanName, _ body: () throws -> R) rethrows -> R { + public func measure(_ name: Scope.SpanName, _ body: () throws -> R) rethrows -> R { try timedSpan(named: String(describing: name), budget: nil, body) } @@ -368,7 +345,7 @@ extension Log { /// span itself still ends normally with its derived exit. @discardableResult public func measure( - _ name: Event.SpanName, + _ name: Scope.SpanName, budget: Duration, _ body: () throws -> R, ) rethrows -> R { @@ -378,7 +355,7 @@ extension Log { /// The `async` form of `measure`; preserves the caller's isolation. @discardableResult public func measure( - _ name: Event.SpanName, + _ name: Scope.SpanName, isolation: isolated (any Actor)? = #isolation, _ body: () async throws -> R, ) async rethrows -> R { @@ -393,7 +370,7 @@ extension Log { /// The `async` form of the budgeted `measure`. @discardableResult public func measure( - _ name: Event.SpanName, + _ name: Scope.SpanName, budget: Duration, isolation: isolated (any Actor)? = #isolation, _ body: () async throws -> R, @@ -505,7 +482,11 @@ extension Log { guard !Task.isCancelled else { return } gate.withLock { ended in guard !ended else { return } - emit(SpanOverdue(spanID: span, name: name, budget: budget)) + emit(SpanOverdue( + spanID: .restricted(.identifier, span), + name: .restricted(.technicalState, name), + budget: .shared(.duration, budget), + )) } } } @@ -516,10 +497,11 @@ extension Log { /// floors hid. private func beginMeasuredSpan(_ span: SpanID, name: String) -> Bool { let began = SpanBegan( - spanID: span, - name: name, - lifetime: .scoped, - relaunchPolicy: .endsWithProcess, + spanID: .restricted(.identifier, span), + name: .restricted(.technicalState, name), + lifetimeMode: .restricted(.technicalState, .scoped), + budget: .shared(.duration, nil), + relaunchPolicy: .shared(.category, .endsWithProcess), ) let recorded = recorder.shouldRecord(level: began.level, scopes: scopes.map(\.id)) SpanSignposts.begin(span, name: name) @@ -539,7 +521,13 @@ extension Log { ) { SpanSignposts.end(span) guard recorded else { return } - let ended = SpanEnded(spanID: span, name: name, duration: duration, exit: exit) + let ended = SpanEnded( + spanID: .restricted(.identifier, span), + name: .restricted(.technicalState, name), + duration: .shared(.duration, duration), + exitMode: .shared(.category, exit.mode), + exitReason: .restricted(.errorDetails, exit.reason), + ) if let overdueGate { // MARK: - and-emit under the gate: after this, the sentinel stays @@ -575,10 +563,11 @@ extension Log { let name = String(describing: id) let key = SpanKey(scope: primaryScope.id, identifier: name) let began = SpanBegan( - spanID: SpanID(), - name: name, - lifetime: lifetime, - relaunchPolicy: relaunch, + spanID: .restricted(.identifier, SpanID()), + name: .restricted(.technicalState, name), + lifetimeMode: .restricted(.technicalState, lifetime.classifiedMode), + budget: .shared(.duration, lifetime.classifiedBudget), + relaunchPolicy: .shared(.category, relaunch), ) // The floor decision is made once, here, for the whole pair: a // recorded began always gets its end (even if floors rise @@ -617,10 +606,11 @@ extension Log { var closing = LogRecord( date: Date(), event: SpanEnded( - spanID: superseded.id, - name: superseded.name, - duration: span.start - superseded.start, - exit: .superseded, + spanID: .restricted(.identifier, superseded.id), + name: .restricted(.technicalState, superseded.name), + duration: .shared(.duration, span.start - superseded.start), + exitMode: .shared(.category, .superseded), + exitReason: .restricted(.errorDetails, nil), ), scopes: superseded.scopes, tags: superseded.tags, @@ -647,10 +637,11 @@ extension Log { guard open.beganRecorded else { return } emit( SpanEnded( - spanID: open.id, - name: open.name, - duration: ContinuousClock().now - open.start, - exit: exit, + spanID: .restricted(.identifier, open.id), + name: .restricted(.technicalState, open.name), + duration: .shared(.duration, ContinuousClock().now - open.start), + exitMode: .shared(.category, exit.mode), + exitReason: .restricted(.errorDetails, exit.reason), ), bypassingFloors: true, ) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift index 95e11b64e..8001a1a56 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift @@ -273,10 +273,11 @@ public actor PeriscopeStore: LogSink { orphansBySession[row.sessionID, default: []].append(LogRecord( date: Date(), event: SpanEnded( - spanID: SpanID(rawValue: spanID), - name: event?.name ?? row.message, - duration: nil, - exit: .orphaned, + spanID: .restricted(.identifier, SpanID(rawValue: spanID)), + name: .restricted(.technicalState, event?.name ?? row.message), + duration: .shared(.duration, nil), + exitMode: .shared(.category, .orphaned), + exitReason: .restricted(.errorDetails, nil), ), scopes: row.orderedScopeIDs.map(ScopeID.init(rawValue:)), tags: Self.tags(from: row), @@ -400,7 +401,10 @@ public actor PeriscopeStore: LogSink { private func persistWriteFailureMarker(lostRecordCount: Int, reason: String) { let marker = LogRecord( date: Date(), - event: StoreWriteFailed(lostRecordCount: lostRecordCount, reason: reason), + event: StoreWriteFailed( + lostRecordCount: .shared(.count, lostRecordCount), + reason: .restricted(.errorDetails, reason), + ), scopes: [], ) do { diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStoreJournalIngest.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStoreJournalIngest.swift index 51c90bd4e..66efe1b19 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStoreJournalIngest.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStoreJournalIngest.swift @@ -183,7 +183,10 @@ extension PeriscopeStore { if recovery.droppedOlderEntries { text += "; older entries were dropped by the journal's byte budget" } - let notice = Message(level: hasGaps ? .warning : .notice, text) + let notice = Message( + level: .restricted(.technicalState, hasGaps ? .warning : .notice), + text: .restricted(.arbitraryText, text), + ) let marker = try SDLogEvent( eventID: UUID(), date: Date(), diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift index 25e06a919..59c1bdaca 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift @@ -1,36 +1,5 @@ import Foundation -/// The synthetic event `PeriscopeStore` persists after a failed, -/// rolled-back write — the durable history's marker for its own gap. -/// The lost batch's contents are gone by definition; this records how -/// many records vanished and why. -public struct StoreWriteFailed: LogEvent { - public static let eventName = "store-write-failed" - - public let lostRecordCount: Int - public let reason: String - - public var level: LogLevel { - .warning - } - - public var message: String { - "\(lostRecordCount) record(s) failed to persist: \(reason)" - } - - public var remoteFields: [RemoteLogField] { - [RemoteLogField( - key: RemoteLogFieldKey("lost_record_count"), - value: .count(lostRecordCount), - )] - } - - public init(lostRecordCount: Int, reason: String) { - self.lostRecordCount = lostRecordCount - self.reason = reason - } -} - /// A persisted log event, as returned by `PeriscopeStore` queries — the /// value-type snapshot of a stored row. /// diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift index 21c558455..6685f16d3 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift @@ -6,7 +6,7 @@ import Testing /// A source that logs one event the moment it starts. private final class ImmediateSource: AmbientEventSource { func start(log: Log) { - log { AmbientEvent(kind: AmbientKind("test-kind"), value: ["phase": "started"]) } + log { makeAmbientEvent(kind: AmbientKind("test-kind"), value: ["phase": "started"]) } } func stop() {} @@ -28,7 +28,7 @@ private final class NotificationSource: NotificationAmbientSource { } override func event(for _: Notification) -> AmbientEvent? { - AmbientEvent(kind: AmbientKind("test-kind"), value: ["phase": "fired"]) + makeAmbientEvent(kind: AmbientKind("test-kind"), value: ["phase": "fired"]) } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventTests.swift index 5b24c7788..ac8b6cc74 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventTests.swift @@ -4,13 +4,13 @@ import Testing struct AmbientEventTests { @Test func messageCombinesKindAndSortedFields() { - let event = AmbientEvent(kind: .network, value: ["status": "unsatisfied"]) + let event = makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"]) #expect(event.message == "network: status=unsatisfied") #expect(event.level == .info) } @Test func messageOrdersFieldsDeterministically() { - let event = AmbientEvent( + let event = makeAmbientEvent( kind: .network, value: ["status": "satisfied", "interfaces": "wifi"], ) @@ -18,18 +18,22 @@ struct AmbientEventTests { } @Test func levelCanBeRaised() { - let event = AmbientEvent(kind: .memory, value: ["pressure": "warning"], level: .warning) + let event = makeAmbientEvent( + kind: .memory, + value: ["pressure": "warning"], + level: .warning, + ) #expect(event.level == .warning) } @Test func appsCanDefineTheirOwnKinds() { let custom = AmbientKind("push-token") - let event = AmbientEvent(kind: custom, value: ["state": "refreshed"]) + let event = makeAmbientEvent(kind: custom, value: ["state": "refreshed"]) #expect(event.message == "push-token: state=refreshed") } @Test func roundTripsThroughCodable() throws { - let event = AmbientEvent( + let event = makeAmbientEvent( kind: .thermalState, value: ["level": "serious", "throttled": true, "steps": 3, "factor": 1.5], level: .warning, @@ -43,7 +47,7 @@ struct AmbientEventTests { /// case-keyed wrapper a synthesized enum coding would emit — so a /// stored payload reads as data anywhere JSON is spoken. @Test func valueEncodesAsAPlainJSONObject() throws { - let event = AmbientEvent( + let event = makeAmbientEvent( kind: .accessibility, value: ["voiceover": false, "contrast": "high", "retries": 2], ) @@ -58,11 +62,12 @@ struct AmbientEventTests { } @Test func reportsLastingStateByDefault() { - #expect(AmbientEvent(kind: .network, value: ["status": "satisfied"]).reporting == .state) + #expect(makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) + .reporting == .state) } @Test func roundTripsMomentaryReporting() throws { - let event = AmbientEvent( + let event = makeAmbientEvent( kind: .memory, value: ["pressure": "warning"], level: .warning, diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientSnapshotTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientSnapshotTests.swift index 5e89de289..ec0625fb4 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/AmbientSnapshotTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientSnapshotTests.swift @@ -5,7 +5,7 @@ import Testing struct AmbientSnapshotTests { @Test func firstStateEventStartsASnapshot() { let snapshot = AmbientSnapshot.folding( - AmbientEvent(kind: .network, value: ["status": "satisfied"]), + makeAmbientEvent(kind: .network, value: ["status": "satisfied"]), into: nil, ) #expect(snapshot?[.network] == ["status": "satisfied"]) @@ -15,7 +15,11 @@ struct AmbientSnapshotTests { /// while carrying no values — `nil` says "not observed yet" honestly. @Test func momentaryEventCannotCreateAnEmptySnapshot() { let snapshot = AmbientSnapshot.folding( - AmbientEvent(kind: .memory, value: ["pressure": "warning"], reporting: .occurrence), + makeAmbientEvent( + kind: .memory, + value: ["pressure": "warning"], + reporting: .occurrence, + ), into: nil, ) #expect(snapshot == nil) @@ -24,7 +28,7 @@ struct AmbientSnapshotTests { @Test func changedValueTakesANewIdentity() { let first = AmbientSnapshot(id: UUID(), values: [.network: ["status": "satisfied"]]) let second = first.applying( - AmbientEvent(kind: .network, value: ["status": "unsatisfied"]), + makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"]), ) #expect(second[.network] == ["status": "unsatisfied"]) #expect(second.id != first.id) @@ -32,7 +36,10 @@ struct AmbientSnapshotTests { @Test func newKindJoinsTheExistingValues() { let first = AmbientSnapshot(id: UUID(), values: [.network: ["status": "satisfied"]]) - let second = first.applying(AmbientEvent(kind: .thermalState, value: ["level": "fair"])) + let second = first.applying(makeAmbientEvent( + kind: .thermalState, + value: ["level": "fair"], + )) #expect(second[.network] == ["status": "satisfied"]) #expect(second[.thermalState] == ["level": "fair"]) } @@ -42,14 +49,19 @@ struct AmbientSnapshotTests { @Test func unchangedValueKeepsTheSameIdentity() { let first = AmbientSnapshot(id: UUID(), values: [.network: ["status": "satisfied"]]) #expect( - first.applying(AmbientEvent(kind: .network, value: ["status": "satisfied"])) == first, + first.applying(makeAmbientEvent(kind: .network, value: ["status": "satisfied"])) + == first, ) } @Test func momentaryEventLeavesAnExistingSnapshotAlone() { let first = AmbientSnapshot(id: UUID(), values: [.network: ["status": "satisfied"]]) let second = first.applying( - AmbientEvent(kind: .memory, value: ["pressure": "warning"], reporting: .occurrence), + makeAmbientEvent( + kind: .memory, + value: ["pressure": "warning"], + reporting: .occurrence, + ), ) #expect(second == first) #expect(second[.memory] == nil) diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift index f3886837c..ae4b7d67b 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift @@ -15,7 +15,7 @@ private final class FreeformController: LogContextProviding { } private final class TypedController: LogContextProviding { - typealias LogEventType = PhotoLogs + typealias LoggingScope = PhotoLogs let system: Periscope diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogJournalEntryTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogJournalEntryTests.swift index a0cc30315..c7ac95355 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogJournalEntryTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogJournalEntryTests.swift @@ -56,7 +56,7 @@ struct LogJournalEntryTests { @Test func spanBegansCarryTheirRelaunchPolicy() throws { let record = LogRecord( date: Date(timeIntervalSinceReferenceDate: 5), - event: SpanBegan( + event: makeSpanBegan( spanID: SpanID(), name: "long-download", lifetime: .indefinite, @@ -76,7 +76,7 @@ struct LogJournalEntryTests { @Test func nonBeganRecordsCarryNoRelaunchPolicy() throws { let record = LogRecord( date: Date(timeIntervalSinceReferenceDate: 6), - event: SpanEnded( + event: makeSpanEnded( spanID: SpanID(), name: "long-download", duration: .seconds(1), @@ -92,7 +92,7 @@ struct LogJournalEntryTests { let big = Data(repeating: 0xAB, count: LogJournalRecord.maximumInlineAttachmentBytes + 1) let record = LogRecord( date: Date(timeIntervalSinceReferenceDate: 1), - event: Message(level: .info, "screenshotted"), + event: makeMessage("screenshotted"), scopes: [scope.id], attachments: [ LogAttachment(name: "small", contentType: .json, data: Data([1])), @@ -134,7 +134,7 @@ struct LogJournalEntryTests { @Test func entriesWithoutAmbientStateStillDecode() throws { let record = LogRecord( date: Date(timeIntervalSinceReferenceDate: 7), - event: Message(level: .info, "from an older build"), + event: makeMessage("from an older build"), scopes: [], ) let journaled = try LogJournalRecord(record: record, sequence: 1) diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogJournalTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogJournalTests.swift index 8b3fb534b..e6478068f 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogJournalTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogJournalTests.swift @@ -88,7 +88,7 @@ struct LogJournalTests { system.install(journal: journal) let ambient = Log(system: system) - ambient { AmbientEvent(kind: .network, value: ["status": "unsatisfied"]) } + ambient { makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"]) } Log(system: system).error("failed while offline") let records = try entries(in: directory).compactMap { entry -> LogJournalRecord? in @@ -115,7 +115,7 @@ struct LogJournalTests { return LogRecord( id: record.id, date: record.date, - event: Message(level: record.level, "[redacted]"), + event: makeMessage("[redacted]", level: record.level), scopes: record.scopes, ) }), diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogMacrosTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogMacrosTests.swift new file mode 100644 index 000000000..ca141fa16 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogMacrosTests.swift @@ -0,0 +1,26 @@ +import PeriscopeCore +import Testing + +@LogScope("macro-fixture") +private enum MacroFixtureLog { + @LogEvent("counted", message: "Counted") + struct Counted { + @LogField("count", exposure: .shareable, kind: .count) + var count: Int + } +} + +struct LogMacrosTests { + @Test func generatedMethodRecordsTheClassifiedEvent() throws { + let recorder = RecordingRecorder() + let log = Log(recorder: recorder) + + log.counted(count: .shared(.count, 3)) + + let event = try #require(recorder.records.first?.event as? MacroFixtureLog.Counted) + #expect(event.count == 3) + #expect(event.classifiedFields == [ + .shareable(key: LogFieldKey("count"), kind: .count, value: .int(3)), + ]) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift index e8c128413..651933597 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift @@ -123,7 +123,7 @@ struct LogSpanTests { } @Test func overdueEventsRoundTripThroughCodable() throws { - let overdue = SpanOverdue(spanID: SpanID(), name: "save", budget: .seconds(1)) + let overdue = makeSpanOverdue(spanID: SpanID(), name: "save", budget: .seconds(1)) let data = try JSONEncoder().encode(overdue) let decoded = try JSONDecoder().decode(SpanOverdue.self, from: data) #expect(decoded.spanID == overdue.spanID) @@ -235,7 +235,7 @@ struct LogSpanTests { @Test func spanEventsRoundTripThroughCodable() throws { let span = SpanID() - let ended = SpanEnded( + let ended = makeSpanEnded( spanID: span, name: "save", duration: .milliseconds(12), @@ -247,7 +247,7 @@ struct LogSpanTests { #expect(decoded.duration == .milliseconds(12)) #expect(decoded.exit == .failure("card declined")) - let began = SpanBegan( + let began = makeSpanBegan( spanID: span, name: "save", lifetime: .bounded(budget: .seconds(30)), @@ -261,7 +261,7 @@ struct LogSpanTests { @Test func endedMessagesDescribeTheExit() { let span = SpanID() - let failed = SpanEnded( + let failed = makeSpanEnded( spanID: span, name: "save", duration: .seconds(2), @@ -270,7 +270,7 @@ struct LogSpanTests { #expect(failed.message.contains("save failed: card declined")) #expect(failed.message.contains("(")) - let orphaned = SpanEnded(spanID: span, name: "save", duration: nil, exit: .orphaned) + let orphaned = makeSpanEnded(spanID: span, name: "save", duration: nil, exit: .orphaned) #expect(orphaned.message == "◀ save orphaned") } @@ -278,7 +278,7 @@ struct LogSpanTests { /// duration added, recovery gets the bare name back — that's what lets /// undecodable rows of one kind share one bucket instead of one per row. @Test func nameRecoversFromARenderedMessage() { - let ended = SpanEnded( + let ended = makeSpanEnded( spanID: SpanID(), name: "save", duration: .seconds(2), @@ -286,14 +286,14 @@ struct LogSpanTests { ) #expect(SpanEnded.nameRecovered(fromMessage: ended.message, exit: .failure) == "save") - let plain = SpanEnded(spanID: SpanID(), name: "save", duration: nil, exit: .orphaned) + let plain = makeSpanEnded(spanID: SpanID(), name: "save", duration: nil, exit: .orphaned) #expect(SpanEnded.nameRecovered(fromMessage: plain.message, exit: .orphaned) == "save") } /// Without a usable exit column the duration parenthetical — the part /// that varies per instance — still comes off. @Test func nameRecoveryWithoutAnExitStripsTheDuration() { - let ended = SpanEnded( + let ended = makeSpanEnded( spanID: SpanID(), name: "save", duration: .seconds(2), diff --git a/Shared/Periscope/PeriscopeCore/Tests/MessageTests.swift b/Shared/Periscope/PeriscopeCore/Tests/MessageTests.swift index 2330dbb90..4172284e7 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/MessageTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/MessageTests.swift @@ -4,17 +4,23 @@ import Testing struct MessageTests { @Test func rendersItsStoredText() { - let message = Message(level: .warning, "Falling back to cache") + let message = Message( + level: .restricted(.technicalState, .warning), + text: .restricted(.arbitraryText, "Falling back to cache"), + ) #expect(message.message == "Falling back to cache") #expect(message.level == .warning) } @Test func usesAStableEventName() { - #expect(Message.eventName == "message") + #expect(Message.eventName == "message.message") } @Test func roundTripsThroughCodable() throws { - let message = Message(level: .error, "Request failed") + let message = Message( + level: .restricted(.technicalState, .error), + text: .restricted(.arbitraryText, "Request failed"), + ) let data = try JSONEncoder().encode(message) let decoded = try JSONDecoder().decode(Message.self, from: data) #expect(decoded == message) diff --git a/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift index 3ee862add..00acb8953 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift @@ -6,7 +6,14 @@ struct OSLogSinkTests { let sink = OSLogSink(subsystem: "com.stuff.periscope.tests") private func record(primary: LogScope, message: String = "hello") -> LogRecord { - LogRecord(date: Date(), event: Message(level: .info, message), scopes: [primary.id]) + LogRecord( + date: Date(), + event: Message( + level: .restricted(.technicalState, .info), + text: .restricted(.arbitraryText, message), + ), + scopes: [primary.id], + ) } @Test func categoryIsTheRootScopeName() async { @@ -37,7 +44,10 @@ struct OSLogSinkTests { await sink.defineScopes([root]) let record = LogRecord( date: Date(), - event: Message(level: .info, "hello"), + event: Message( + level: .restricted(.technicalState, .info), + text: .restricted(.arbitraryText, "hello"), + ), scopes: [root.id], tags: [ LogTag(key: LogTagKey("b-key"), value: "2"), @@ -61,7 +71,10 @@ struct OSLogSinkTests { record(primary: root, message: "smoke"), LogRecord( date: Date(), - event: Message(level: .fault, "fault smoke"), + event: Message( + level: .restricted(.technicalState, .fault), + text: .restricted(.arbitraryText, "fault smoke"), + ), scopes: [root.id], ), ]) diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift index a111caffc..20f35fb6f 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -213,7 +213,87 @@ func makeRecord( date: Date, scopes: [ScopeID], ) -> LogRecord { - LogRecord(date: date, event: Message(level: level, text), scopes: scopes) + LogRecord( + date: date, + event: Message( + level: .restricted(.technicalState, level), + text: .restricted(.arbitraryText, text), + ), + scopes: scopes, + ) +} + +func makeMessage(_ text: String, level: LogLevel = .info) -> Message { + Message( + level: .restricted(.technicalState, level), + text: .restricted(.arbitraryText, text), + ) +} + +/// Builds an ambient event while keeping its complete restricted schema explicit. +func makeAmbientEvent( + kind: AmbientKind, + value: [String: AmbientValue], + level: LogLevel = .info, + reporting: AmbientEvent.Reporting = .state, +) -> AmbientEvent { + AmbientEvent( + kind: .restricted(.technicalState, kind), + value: .restricted(.domainValue, value), + level: .restricted(.technicalState, level), + reporting: .restricted(.technicalState, reporting), + ) +} + +func makeSpanBegan( + spanID: SpanID, + name: String, + lifetime: SpanLifetime, + relaunchPolicy: SpanRelaunchPolicy, +) -> SpanBegan { + let mode: SpanBegan.LifetimeMode + let budget: Duration? + switch lifetime { + case .scoped: + mode = .scoped + budget = nil + case let .bounded(value): + mode = .bounded + budget = value + case .indefinite: + mode = .indefinite + budget = nil + } + return SpanBegan( + spanID: .restricted(.identifier, spanID), + name: .restricted(.technicalState, name), + lifetimeMode: .restricted(.technicalState, mode), + budget: .shared(.duration, budget), + relaunchPolicy: .shared(.category, relaunchPolicy), + ) +} + +func makeSpanEnded( + spanID: SpanID, + name: String, + duration: Duration?, + exit: SpanExit, +) -> SpanEnded { + SpanEnded( + spanID: .restricted(.identifier, spanID), + name: .restricted(.technicalState, name), + duration: .shared(.duration, duration), + exitMode: .shared(.category, exit.mode), + exitReason: .restricted(.errorDetails, exit.reason), + ) +} + +func makeSpanOverdue(spanID: SpanID, name: String, budget: Duration) -> SpanOverdue { + SpanOverdue( + spanID: .restricted(.identifier, spanID), + name: .restricted(.technicalState, name), + budget: .shared(.duration, budget), + ) } /// Shared fixture events used across suites. diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreJournalIngestTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreJournalIngestTests.swift index 7fe6ed447..65fa97691 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreJournalIngestTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreJournalIngestTests.swift @@ -54,7 +54,10 @@ struct PeriscopeStoreJournalIngestTests { records: [ LogRecord( date: date(1), - event: Message(level: .error, "about to die"), + event: Message( + level: .restricted(.technicalState, .error), + text: .restricted(.arbitraryText, "about to die"), + ), scopes: [scope.id], tags: [LogTag(key: key, value: "pay_1")], callSite: LogCallSite(function: "buy()", fileID: "App/Checkout.swift"), @@ -103,12 +106,12 @@ struct PeriscopeStoreJournalIngestTests { records: [ LogRecord( date: date(1), - event: AmbientEvent(kind: .network, value: ["status": "unsatisfied"]), + event: makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"]), scopes: [scope.id], ), LogRecord( date: date(2), - event: Message(level: .error, "died while offline"), + event: makeMessage("died while offline", level: .error), scopes: [scope.id], ), ], @@ -139,7 +142,7 @@ struct PeriscopeStoreJournalIngestTests { ) let delivered = LogRecord( date: date(1), - event: Message(level: .info, "delivered"), + event: makeMessage("delivered"), scopes: [scope.id], ) await firstStore.defineScopes([scope]) @@ -149,7 +152,7 @@ struct PeriscopeStoreJournalIngestTests { journal.append( LogRecord( date: date(2), - event: Message(level: .info, "journal only"), + event: makeMessage("journal only"), scopes: [scope.id], ), sequence: 2, @@ -170,7 +173,7 @@ struct PeriscopeStoreJournalIngestTests { let root = try makeRoot() let crashed = LogSession.fixture(startedAt: date(0)) let scope = LogScope.root(named: "app") - let began = SpanBegan( + let began = makeSpanBegan( spanID: SpanID(), name: "checkout", lifetime: .indefinite, @@ -200,7 +203,7 @@ struct PeriscopeStoreJournalIngestTests { let root = try makeRoot() let crashed = LogSession.fixture(startedAt: date(0)) let scope = LogScope.root(named: "app") - let began = SpanBegan( + let began = makeSpanBegan( spanID: SpanID(), name: "long-download", lifetime: .indefinite, @@ -229,10 +232,10 @@ struct PeriscopeStoreJournalIngestTests { records: [ LogRecord( date: date(1), - event: Message(level: .info, "intact"), + event: makeMessage("intact"), scopes: [scope.id], ), - LogRecord(date: date(2), event: Message(level: .info, "torn"), scopes: [scope.id]), + LogRecord(date: date(2), event: makeMessage("torn"), scopes: [scope.id]), ], ) // Tear the final entry, as a crash mid-append would. @@ -279,7 +282,7 @@ struct PeriscopeStoreJournalIngestTests { for index in 0 ..< 40 { let record = LogRecord( date: date(TimeInterval(index)), - event: Message(level: .info, "storm-\(index)"), + event: makeMessage("storm-\(index)"), scopes: [scope.id], ) try journal.append( diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 9aba5294e..cdf2db14e 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -299,7 +299,7 @@ struct PeriscopeStoreTests { let second = LogAttachment(name: "b", contentType: .png, data: Data([2, 3])) let record = LogRecord( date: date(1), - event: Message(level: .error, "failed"), + event: makeMessage("failed", level: .error), scopes: [root.id], attachments: [first, second], ) @@ -327,7 +327,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: SpanBegan( + event: makeSpanBegan( spanID: span, name: "save", lifetime: .indefinite, @@ -338,7 +338,7 @@ struct PeriscopeStoreTests { makeRecord("unrelated", date: date(2), scopes: [root.id]), LogRecord( date: date(3), - event: SpanEnded( + event: makeSpanEnded( spanID: span, name: "save", duration: .seconds(2), @@ -364,7 +364,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: SpanEnded( + event: makeSpanEnded( spanID: failedSpan, name: "save", duration: .seconds(1), @@ -374,7 +374,7 @@ struct PeriscopeStoreTests { ), LogRecord( date: date(2), - event: SpanEnded( + event: makeSpanEnded( spanID: SpanID(), name: "sync", duration: .seconds(1), @@ -408,7 +408,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: SpanBegan( + event: makeSpanBegan( spanID: span, name: name, lifetime: .indefinite, @@ -556,7 +556,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(2), - event: SpanEnded( + event: makeSpanEnded( spanID: span, name: "save", duration: .seconds(1), @@ -579,13 +579,13 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: Message(level: .info, "for pay_1"), + event: makeMessage("for pay_1"), scopes: [root.id], tags: [LogTag(key: key, value: "pay_1")], ), LogRecord( date: date(2), - event: Message(level: .info, "for pay_2"), + event: makeMessage("for pay_2"), scopes: [root.id], tags: [LogTag(key: key, value: "pay_2")], ), @@ -644,7 +644,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: Message(level: .info, "located"), + event: makeMessage("located"), scopes: [root.id], callSite: LogCallSite(function: "uploadPhoto(_:)", fileID: "App/Uploader.swift"), ), @@ -665,7 +665,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: Message(level: .info, "both"), + event: makeMessage("both"), scopes: [root.id], tags: [ LogTag(key: payment, value: "pay_1"), @@ -674,13 +674,13 @@ struct PeriscopeStoreTests { ), LogRecord( date: date(2), - event: Message(level: .info, "payment only"), + event: makeMessage("payment only"), scopes: [root.id], tags: [LogTag(key: payment, value: "pay_1")], ), LogRecord( date: date(3), - event: Message(level: .info, "retry only"), + event: makeMessage("retry only"), scopes: [root.id], tags: [LogTag(key: retry, value: .int(2))], ), @@ -701,13 +701,13 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: Message(level: .info, "typed int"), + event: makeMessage("typed int"), scopes: [root.id], tags: [LogTag(key: key, value: .int(3))], ), LogRecord( date: date(2), - event: Message(level: .info, "stringly"), + event: makeMessage("stringly"), scopes: [root.id], tags: [LogTag(key: key, value: .string("3"))], ), @@ -779,7 +779,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: Message(level: .info, "old"), + event: makeMessage("old"), scopes: [album.id], tags: [LogTag(key: key, value: "pay_old")], ), @@ -791,7 +791,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(200), - event: Message(level: .info, "new"), + event: makeMessage("new"), scopes: [root.id], tags: [LogTag(key: key, value: "pay_new")], ), @@ -811,7 +811,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(300), - event: Message(level: .info, "old pair reused"), + event: makeMessage("old pair reused"), scopes: [root.id], tags: [LogTag(key: key, value: "pay_old")], ), @@ -898,7 +898,7 @@ struct PeriscopeStoreTests { system.add(sink: store) let ambient = Log(system: system) - ambient { AmbientEvent(kind: .powerMode, value: ["low-power": true]) } + ambient { makeAmbientEvent(kind: .powerMode, value: ["low-power": true]) } Log(system: system).error("slow while saving battery") await system.flush() @@ -995,7 +995,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: Message(level: .info, "poisoned"), + event: makeMessage("poisoned"), scopes: [scope.id], tags: [LogTag(key: key, value: "pay_1")], ), @@ -1006,7 +1006,7 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(2), - event: Message(level: .info, "healthy"), + event: makeMessage("healthy"), scopes: [scope.id], tags: [LogTag(key: key, value: "pay_1")], ), diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index f14282aad..19a45250c 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -523,7 +523,7 @@ struct PeriscopeTests { LogRecord( id: record.id, date: record.date, - event: Message(level: record.level, "[redacted]"), + event: makeMessage("[redacted]", level: record.level), scopes: record.scopes, ) }), @@ -1170,7 +1170,7 @@ struct PeriscopeTests { @Test func ambientStateStampsOntoEverySubsequentRecord() async throws { let system = makeSystem() let ambient = Log(system: system) - ambient { AmbientEvent(kind: .network, value: ["status": "satisfied"]) } + ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } Log(system: system).info("after") await system.flush() @@ -1183,8 +1183,8 @@ struct PeriscopeTests { @Test func anAmbientEventCarriesTheStateItAnnounces() async { let system = makeSystem() let ambient = Log(system: system) - ambient { AmbientEvent(kind: .thermalState, value: ["level": "nominal"]) } - ambient { AmbientEvent(kind: .thermalState, value: ["level": "serious"]) } + ambient { makeAmbientEvent(kind: .thermalState, value: ["level": "nominal"]) } + ambient { makeAmbientEvent(kind: .thermalState, value: ["level": "serious"]) } await system.flush() let changes = sink.records.filter { $0.eventName == AmbientEvent.eventName } @@ -1197,9 +1197,9 @@ struct PeriscopeTests { @Test func momentaryAmbientEventsDoNotStickToLaterRecords() async throws { let system = makeSystem() let ambient = Log(system: system) - ambient { AmbientEvent(kind: .network, value: ["status": "satisfied"]) } + ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } ambient { - AmbientEvent( + makeAmbientEvent( kind: .memory, value: ["pressure": "warning"], level: .warning, @@ -1220,9 +1220,9 @@ struct PeriscopeTests { let system = makeSystem() let ambient = Log(system: system) let log = Log(system: system) - ambient { AmbientEvent(kind: .network, value: ["status": "satisfied"]) } + ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } log.info("one") - ambient { AmbientEvent(kind: .network, value: ["status": "satisfied"]) } + ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } log.info("two") await system.flush() @@ -1234,7 +1234,7 @@ struct PeriscopeTests { @Test func spanRecordsCarryAmbientState() async { let system = makeSystem() let ambient = Log(system: system) - ambient { AmbientEvent(kind: .powerMode, value: ["low-power": true]) } + ambient { makeAmbientEvent(kind: .powerMode, value: ["low-power": true]) } Log(system: system).measure("work") {} await system.flush() @@ -1254,7 +1254,7 @@ struct PeriscopeTests { ) let log = Log(system: system) let ambient = Log(system: system) - ambient { AmbientEvent(kind: .network, value: ["status": "unsatisfied"]) } + ambient { makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"]) } log.info("r0") let drainBlocked = await waitUntil { gate.batchCount >= 1 } @@ -1274,7 +1274,7 @@ struct PeriscopeTests { @Test func liveObserversSeeTheStampedRecord() async throws { let system = makeSystem() let ambient = Log(system: system) - ambient { AmbientEvent(kind: .network, value: ["status": "satisfied"]) } + ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } let records = system.liveRecords() Log(system: system).info("live") @@ -1289,11 +1289,11 @@ struct PeriscopeTests { @Test func flooredAmbientEventsStillFoldIntoTheSnapshot() async throws { let system = makeSystem() let ambient = Log(system: system) - ambient { AmbientEvent(kind: .network, value: ["status": "satisfied"]) } + ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } system.minimumLevel = .warning // .info — floored. - ambient { AmbientEvent(kind: .network, value: ["status": "unsatisfied"]) } + ambient { makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"]) } Log(system: system).warning("after") await system.flush() @@ -1316,11 +1316,11 @@ struct PeriscopeTests { sinks: [sink], ) let ambient = Log(system: system) - ambient { AmbientEvent(kind: .network, value: ["ssid": "wifi-public"]) } - ambient { AmbientEvent(kind: .thermalState, value: ["level": "nominal"]) } + ambient { makeAmbientEvent(kind: .network, value: ["ssid": "wifi-public"]) } + ambient { makeAmbientEvent(kind: .thermalState, value: ["level": "nominal"]) } // Suppressed by the redaction hook. - ambient { AmbientEvent(kind: .network, value: ["ssid": "wifi-secret"]) } + ambient { makeAmbientEvent(kind: .network, value: ["ssid": "wifi-secret"]) } Log(system: system).info("after") await system.flush() diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift index 681159e2f..d607ab995 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -35,7 +35,10 @@ struct StoredLogEventTests { } @Test func decodeThrowsWhenTheShapeNoLongerMatches() throws { - let payload = try JSONEncoder().encode(Message(level: .info, "not a photo")) + let payload = try JSONEncoder().encode(Message( + level: .restricted(.technicalState, .info), + text: .restricted(.arbitraryText, "not a photo"), + )) let stored = makeStored(payload: payload) #expect(throws: (any Error).self) { diff --git a/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift b/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift index d1a25d690..1cb1b9dd5 100644 --- a/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift +++ b/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift @@ -115,7 +115,7 @@ public struct LogEventMacro: MemberMacro, ExtensionMacro { else { return [] } - let extensionDecl: DeclSyntax = "extension \(type.trimmed): LogEvent, Codable, Sendable {}" + let extensionDecl: DeclSyntax = "extension \(type.trimmed): LogEvent {}" return [extensionDecl.cast(ExtensionDeclSyntax.self)] } } @@ -193,6 +193,7 @@ extension LogEventMacro { in context: some MacroExpansionContext, ) -> ParsedFields { let metadataNames = ["message", "level", "externalID", "isProtectedFromDropping"] + let storedMetadataNames = ["message", "externalID", "isProtectedFromDropping"] let reservedNames = [ "attachments", "function", @@ -225,17 +226,11 @@ extension LogEventMacro { } let name = identifier.identifier.text if binding.accessorBlock != nil { - if !metadataNames.contains(name) { - context.diagnose( - variable, - id: "event-accessor", - message: "only event metadata properties can have accessors", - ) - result.hasError = true - } + // Computed projections are ordinary event API. They do not + // participate in the persisted payload or classification. continue } - if metadataNames.contains(name) || reservedNames.contains(name) { + if storedMetadataNames.contains(name) || reservedNames.contains(name) { context.diagnose( variable, id: "event-reserved", diff --git a/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift b/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift index 83a08f14a..4b722ed43 100644 --- a/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift +++ b/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift @@ -2,7 +2,7 @@ import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacros -public struct LogScopeMacro: MemberMacro, ExtensionMacro, PeerMacro { +public struct LogScopeMacro: MemberMacro, ExtensionMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, @@ -37,7 +37,31 @@ public struct LogScopeMacro: MemberMacro, ExtensionMacro, PeerMacro { return [] } let access = accessPrefix(scope.modifiers) - return ["\(raw: access)static let scopeName = \"\(raw: escapedStringLiteral(id))\""] + let events = eventMethods(in: scope, context: context) + var members: [DeclSyntax] = [ + "\(raw: access)static let scopeName = \"\(raw: escapedStringLiteral(id))\"", + ] + guard !events.isEmpty else { return members } + members.append(DeclSyntax(stringLiteral: methodsContainer( + access: access, + scope: scope.name.text, + events: events, + ))) + members.append(contentsOf: events.map { event in + DeclSyntax(stringLiteral: methodProxy( + access: event.access, + name: event.name, + scope: scope.name.text, + event: event.event, + fields: event.fields, + )) + }) + members.append(DeclSyntax(stringLiteral: """ + \(access)static func makeLogMethods(_ log: Log<\(scope.name.text)>) -> LogMethods { + LogMethods(log: log) + } + """)) + return members } public static func expansion( @@ -51,17 +75,24 @@ public struct LogScopeMacro: MemberMacro, ExtensionMacro, PeerMacro { let extensionDecl: DeclSyntax = "extension \(type.trimmed): LogScopeDefinition {}" return [extensionDecl.cast(ExtensionDeclSyntax.self)] } +} - public static func expansion( - of _: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext, - ) throws -> [DeclSyntax] { - guard let scope = declaration.as(EnumDeclSyntax.self) else { return [] } +extension LogScopeMacro { + fileprivate struct EventMethod { + let access: String + let name: String + let event: String + let fields: [EventField] + } + + fileprivate static func eventMethods( + in scope: EnumDeclSyntax, + context: some MacroExpansionContext, + ) -> [EventMethod] { let scopeAccess = accessPrefix(scope.modifiers) var seenIDs = Set() var seenMethods = Set() - var methods: [String] = [] + var methods: [EventMethod] = [] for member in scope.memberBlock.members { guard let event = member.decl.as(StructDeclSyntax.self), @@ -92,20 +123,14 @@ public struct LogScopeMacro: MemberMacro, ExtensionMacro, PeerMacro { let fields = eventFields(event) let eventAccess = accessPrefix(event.modifiers) let access = scopeAccess == "public " && eventAccess == "public " ? "public " : "" - methods.append(method( + methods.append(EventMethod( access: access, name: methodName, - scope: scope.name.text, event: event.name.text, fields: fields, )) } - guard !methods.isEmpty else { return [] } - return [DeclSyntax(stringLiteral: """ - extension Log where Scope == \(scope.name.text) { - \(methods.joined(separator: "\n\n")) - } - """)] + return methods } } @@ -140,9 +165,26 @@ extension LogScopeMacro { } } - fileprivate static func method( + fileprivate static func methodsContainer( + access: String, + scope: String, + events: [EventMethod], + ) -> String { + let properties = events.map { event in + " \(event.access)var \(event.name): \(event.event)LogMethod { \(event.event)LogMethod(log: log) }" + }.joined(separator: "\n") + return """ + \(access)struct LogMethods { + fileprivate let log: Log<\(scope)> + + \(properties) + } + """ + } + + fileprivate static func methodProxy( access: String, - name: String, + name _: String, scope: String, event: String, fields: [EventField], @@ -159,16 +201,20 @@ extension LogScopeMacro { ) """ return """ - \(access)func \(name)( + \(access)struct \(event)LogMethod { + fileprivate let log: Log<\(scope)> + + \(access)func callAsFunction( \(parameters.joined(separator: ",\n")) ) { - record( + log.record( \(eventInit), attachments: attachments, function: function, fileID: fileID ) } + } """ } } diff --git a/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift b/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift index 2f2be1cf3..82504117c 100644 --- a/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift +++ b/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift @@ -65,27 +65,41 @@ func expandsClassifiedEventAndLogMethod() { } static let scopeName = "Sample" - } - extension Log where Scope == SampleLog { - func counted( - count: ClassifiedLogInput, - attachments: [LogAttachment] = [], - function: StaticString = #function, - fileID: StaticString = #fileID - ) { - record( - SampleLog.Counted( - count: count - ), - attachments: attachments, - function: function, - fileID: fileID - ) + struct LogMethods { + fileprivate let log: Log + + var counted: CountedLogMethod { + CountedLogMethod(log: log) + } + } + + struct CountedLogMethod { + fileprivate let log: Log + + func callAsFunction( + count: ClassifiedLogInput, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID + ) { + log.record( + SampleLog.Counted( + count: count + ), + attachments: attachments, + function: function, + fileID: fileID + ) + } + } + + static func makeLogMethods(_ log: Log) -> LogMethods { + LogMethods(log: log) } } - extension SampleLog.Counted: LogEvent, Codable, Sendable { + extension SampleLog.Counted: LogEvent { } extension SampleLog: LogScopeDefinition { diff --git a/Shared/Periscope/PeriscopeTools/SnapshotTests/PeriscopeViewerSnapshotTests.swift b/Shared/Periscope/PeriscopeTools/SnapshotTests/PeriscopeViewerSnapshotTests.swift index af63bbaec..515f14d7c 100644 --- a/Shared/Periscope/PeriscopeTools/SnapshotTests/PeriscopeViewerSnapshotTests.swift +++ b/Shared/Periscope/PeriscopeTools/SnapshotTests/PeriscopeViewerSnapshotTests.swift @@ -74,7 +74,10 @@ struct PeriscopeViewerSnapshotTests { await store.write(lines.map { line in LogRecord( date: referenceNow.addingTimeInterval(-line.age), - event: Message(level: line.level, line.text), + event: Message( + level: .restricted(.technicalState, line.level), + text: .restricted(.arbitraryText, line.text), + ), scopes: [line.scope.id], tags: [], ) diff --git a/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift index 938fefd5a..e05912dec 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift @@ -9,7 +9,7 @@ extension View { /// subtrees — e.g. wrap a payment row and see everything associated /// with that payment. With no inspector or the mode off, the view /// renders unchanged. - public func logInspectable(_ log: Log, limit: Int = 500) -> some View { + public func logInspectable(_ log: Log, limit: Int = 500) -> some View { modifier(LogInspectableModifier(scopes: log.scopes.map(\.id), limit: limit)) } diff --git a/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift index 9f0c4855f..ce461f77c 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift @@ -58,7 +58,7 @@ struct LocalNotificationAlertHandlerTests { private func makeRecord(_ message: String) -> LogRecord { LogRecord( date: Date(), - event: Message(level: .error, message), + event: classifiedMessage(message, level: .error), scopes: [LogScope.root(named: "app").id], ) } @@ -66,7 +66,7 @@ struct LocalNotificationAlertHandlerTests { @Test func requestsCarryTheRecordsSeverityAndMessage() { let record = LogRecord( date: Date(), - event: Message(level: .error, "Upload failed"), + event: classifiedMessage("Upload failed", level: .error), scopes: [LogScope.root(named: "app").id], ) diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift index c64b6b335..b10b1f3f3 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift @@ -30,7 +30,7 @@ struct LogEventDetailViewTests { } @Test func exitReasonDecodesFromThePayload() throws { - let ended = SpanEnded( + let ended = classifiedSpanEnded( spanID: SpanID(), name: "checkout", duration: nil, @@ -42,7 +42,12 @@ struct LogEventDetailViewTests { } @Test func exitReasonIsNilWithoutAReason() throws { - let ended = SpanEnded(spanID: SpanID(), name: "checkout", duration: nil, exit: .success) + let ended = classifiedSpanEnded( + spanID: SpanID(), + name: "checkout", + duration: nil, + exit: .success, + ) let event = try stored(eventName: "span-ended", payload: JSONEncoder().encode(ended)) #expect(event.exitReason == nil) diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift index db8a742cf..96a3e0be0 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift @@ -67,7 +67,7 @@ struct LogTraceModelTests { await store.write([ LogRecord( date: date(1), - event: SpanBegan( + event: classifiedSpanBegan( spanID: span, name: "save", lifetime: .indefinite, @@ -77,7 +77,7 @@ struct LogTraceModelTests { ), LogRecord( date: date(2), - event: SpanEnded( + event: classifiedSpanEnded( spanID: span, name: "save", duration: .seconds(1), diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift index 8aedecb07..744e02015 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift @@ -57,12 +57,22 @@ func makeRecord( ) -> LogRecord { LogRecord( date: date, - event: Message(level: level, text), + event: Message( + level: .restricted(.technicalState, level), + text: .restricted(.arbitraryText, text), + ), scopes: scopes, tags: tags, ) } +func classifiedMessage(_ text: String, level: LogLevel = .info) -> Message { + Message( + level: .restricted(.technicalState, level), + text: .restricted(.arbitraryText, text), + ) +} + func date(_ offset: TimeInterval) -> Date { Date(timeIntervalSinceReferenceDate: offset) } @@ -77,10 +87,11 @@ func spanBegan( LogRecord( date: date, event: SpanBegan( - spanID: id, - name: name, - lifetime: .scoped, - relaunchPolicy: .endsWithProcess, + spanID: .restricted(.identifier, id), + name: .restricted(.technicalState, name), + lifetimeMode: .restricted(.technicalState, .scoped), + budget: .shared(.duration, nil), + relaunchPolicy: .shared(.category, .endsWithProcess), ), scopes: [scope], ) @@ -97,7 +108,7 @@ func spanEnded( ) -> LogRecord { LogRecord( date: date, - event: SpanEnded(spanID: id, name: name, duration: duration, exit: exit), + event: classifiedSpanEnded(spanID: id, name: name, duration: duration, exit: exit), scopes: [scope], ) } @@ -142,7 +153,13 @@ func storedSpanBegan(_ id: SpanID, name: String, at date: Date) throws -> Stored message: "▶ \(name)", at: date, payload: JSONEncoder().encode( - SpanBegan(spanID: id, name: name, lifetime: .scoped, relaunchPolicy: .endsWithProcess), + SpanBegan( + spanID: .restricted(.identifier, id), + name: .restricted(.technicalState, name), + lifetimeMode: .restricted(.technicalState, .scoped), + budget: .shared(.duration, nil), + relaunchPolicy: .shared(.category, .endsWithProcess), + ), ), ) } @@ -161,12 +178,55 @@ func storedSpanEnded( message: "◀ \(name)", at: date, payload: JSONEncoder().encode( - SpanEnded(spanID: id, name: name, duration: duration, exit: exit), + classifiedSpanEnded(spanID: id, name: name, duration: duration, exit: exit), ), exitMode: exit.mode, ) } +func classifiedSpanEnded( + spanID id: SpanID, + name: String, + duration: Duration?, + exit: SpanExit, +) -> SpanEnded { + SpanEnded( + spanID: .restricted(.identifier, id), + name: .restricted(.technicalState, name), + duration: .shared(.duration, duration), + exitMode: .shared(.category, exit.mode), + exitReason: .restricted(.errorDetails, exit.reason), + ) +} + +func classifiedSpanBegan( + spanID id: SpanID, + name: String, + lifetime: SpanLifetime, + relaunchPolicy: SpanRelaunchPolicy, +) -> SpanBegan { + let mode: SpanBegan.LifetimeMode + let budget: Duration? + switch lifetime { + case .scoped: + mode = .scoped + budget = nil + case let .bounded(value): + mode = .bounded + budget = value + case .indefinite: + mode = .indefinite + budget = nil + } + return SpanBegan( + spanID: .restricted(.identifier, id), + name: .restricted(.technicalState, name), + lifetimeMode: .restricted(.technicalState, mode), + budget: .shared(.duration, budget), + relaunchPolicy: .shared(.category, relaunchPolicy), + ) +} + /// Bytes that are not JSON — a persisted payload is `JSONEncoder` output, so /// these stand in for on-disk corruption rather than a shape change. let unreadablePayload = Data([0xFF, 0x00]) diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift index 84194abc1..e9859f1bb 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift @@ -83,7 +83,7 @@ struct PeriscopeViewerModelTests { await store.write([ LogRecord( date: date(1), - event: SpanEnded( + event: classifiedSpanEnded( spanID: SpanID(), name: "save", duration: .seconds(1), diff --git a/Shared/Periscope/PeriscopeTools/Tests/SpanHistoryModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/SpanHistoryModelTests.swift index e2f8d416d..d78a72268 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/SpanHistoryModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/SpanHistoryModelTests.swift @@ -69,7 +69,7 @@ struct SpanHistoryModelTests { await store.write([ LogRecord( date: date(0), - event: SpanEnded( + event: classifiedSpanEnded( spanID: orphan, name: "lost", duration: nil, @@ -99,7 +99,7 @@ struct SpanHistoryModelTests { spanEnded(SpanID(), name: "mixed", at: date(0), duration: .seconds(2), scope: root.id), LogRecord( date: date(1), - event: SpanEnded( + event: classifiedSpanEnded( spanID: SpanID(), name: "mixed", duration: nil, @@ -298,7 +298,7 @@ struct SpanHistoryModelTests { } let summaries = SpanHistoryModel.summaries(from: [ corrupt( - SpanEnded( + classifiedSpanEnded( spanID: SpanID(), name: "save", duration: .seconds(1), @@ -307,7 +307,7 @@ struct SpanHistoryModelTests { at: date(0), ), corrupt( - SpanEnded( + classifiedSpanEnded( spanID: SpanID(), name: "save", duration: .seconds(9), diff --git a/Shared/Periscope/PeriscopeUI/AGENTS.md b/Shared/Periscope/PeriscopeUI/AGENTS.md index 6fbdf29ad..cb8d7f61c 100644 --- a/Shared/Periscope/PeriscopeUI/AGENTS.md +++ b/Shared/Periscope/PeriscopeUI/AGENTS.md @@ -12,7 +12,8 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) first. That file owns the build ## Invariants - **Stacked `logContext` modifiers link, not replace.** A child's context is the union of every ancestor's scopes plus merged tags. The nearest modifier is primary (`Log.linked(with:)` semantics). Do not reimplement the merge here. -- **`\.logContext` always yields a usable logger.** Outside any modifier, it falls back to a root `Log` on `Periscope.shared`. That mirrors `Log.current`. +- **Keep the accumulated environment value optional.** A direct fallback would join a freeform scope into every explicit context. +- **`\.logContext` always yields a usable context.** Outside any modifier, it falls back to `LogContext()` on `Periscope.shared`. ## Testing diff --git a/Shared/Periscope/PeriscopeUI/Sources/LogContextEnvironment.swift b/Shared/Periscope/PeriscopeUI/Sources/LogContextEnvironment.swift index 1e7cfe612..0c05a59f0 100644 --- a/Shared/Periscope/PeriscopeUI/Sources/LogContextEnvironment.swift +++ b/Shared/Periscope/PeriscopeUI/Sources/LogContextEnvironment.swift @@ -4,7 +4,7 @@ import SwiftUI extension EnvironmentValues { /// The accumulated context, or `nil` above the first `logContext` /// modifier. Internal so the public accessor can supply the fallback. - @Entry var accumulatedLogContext: Log? + @Entry var accumulatedLogContext: LogContext? /// The accumulated log context: every scope and tag contributed by /// enclosing ``SwiftUICore/View/logContext(_:)-(Log<_>)`` modifiers, @@ -12,10 +12,10 @@ extension EnvironmentValues { /// (`log.info("tapped")`) or derive typed loggers /// (`log(PhotoLogs.self)`). /// - /// Outside any `logContext` modifier this falls back to a root logger + /// Outside any `logContext` modifier this falls back to a root context /// on `Periscope.shared`, mirroring `Log.current`. - public var logContext: Log { - accumulatedLogContext ?? Log() + public var logContext: LogContext { + accumulatedLogContext ?? LogContext() } } @@ -31,10 +31,14 @@ extension View { /// .logContext(model.photoLog) // model-layer context /// .logContext(screenLog) // this screen's context /// ``` - public func logContext(_ log: Log) -> some View { + public func logContext(_ log: Log) -> some View { + logContext(log.context) + } + + /// Contribute a type-erased context to this view hierarchy. + public func logContext(_ context: LogContext) -> some View { transformEnvironment(\.accumulatedLogContext) { current in - let contributed = log.retyped(to: Message.self) - current = current.map { contributed.linked(with: $0) } ?? contributed + current = current.map { context.linked(with: $0) } ?? context } } diff --git a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift index e7e012a56..502b7b311 100644 --- a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift +++ b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift @@ -1,5 +1,5 @@ import PeriscopeCore -import PeriscopeUI +@testable import PeriscopeUI import SwiftUI import TestHostSupport import Testing @@ -56,9 +56,9 @@ struct LogContextEnvironmentTests { } @Test func contextOutsideAnyModifierFallsBackToASharedRoot() { - let log = EnvironmentValues().logContext - #expect(log.primaryScope.name == Message.eventName) - #expect(log.primaryScope.parentID == nil) + let values = EnvironmentValues() + #expect(values.accumulatedLogContext.map { _ in false } ?? true) + let _: LogContext = values.logContext } @Test func modifierGivesDescendantsTheContext() throws { diff --git a/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift b/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift index 8acc17dff..d99bcba0c 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift @@ -14,7 +14,7 @@ extension View { /// that opens the newest events in `log`'s scope subtree — e.g. wrap an /// evidence row in `WhereLog.evidence` to see everything logged under it. @ViewBuilder - func debugLogInspectable(_ log: Log) -> some View { + func debugLogInspectable(_ log: Log) -> some View { #if DEBUG logInspectable(log) #else diff --git a/Where/WhereUI/Tests/LogHistoryPrunerTests.swift b/Where/WhereUI/Tests/LogHistoryPrunerTests.swift index 90650bc66..aafbae5ae 100644 --- a/Where/WhereUI/Tests/LogHistoryPrunerTests.swift +++ b/Where/WhereUI/Tests/LogHistoryPrunerTests.swift @@ -35,7 +35,10 @@ struct LogHistoryPrunerTests { await store.write((0 ..< count).map { daysAgo in LogRecord( date: Self.now.addingTimeInterval(-Self.days(Double(daysAgo))), - event: Message(level: .info, "day-\(daysAgo)"), + event: Message( + level: .restricted(.technicalState, .info), + text: .restricted(.arbitraryText, "day-\(daysAgo)"), + ), scopes: [root.id], ) }) From d2c78586e4fb3e7e9bdf2a8a65fa6cafc51d7606 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 14 Aug 2026 11:22:24 -0700 Subject: [PATCH 3/5] Complete classified event enforcement --- .../Sources/Events/ClassifiedLogField.swift | 37 +++- .../Sources/Loggers/LogScopeDefinition.swift | 2 +- .../PeriscopeCore/Tests/LogEventTests.swift | 207 +++++++++++++----- .../PeriscopeCore/Tests/LogFieldTests.swift | 55 +++-- Shared/Periscope/PeriscopeMacros/README.md | 10 + .../Sources/LogEventMacro.swift | 38 +++- .../Sources/LogScopeMacro.swift | 33 +++ .../Sources/SyntaxSupport.swift | 10 + .../Tests/LogEventMacroTests.swift | 191 ++++++++++++++++ .../Tests/LogScopeMacroTests.swift | 71 ++++++ test | 4 +- 11 files changed, 571 insertions(+), 87 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/ClassifiedLogField.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/ClassifiedLogField.swift index d0e87b520..65040a641 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/ClassifiedLogField.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/ClassifiedLogField.swift @@ -263,7 +263,26 @@ extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, _: LogFieldKindToken, _ value: Value, ) -> Self { - .init(value: value) + preconditionClosedCategory(value) + return .init(value: value) + } +} + +extension ClassifiedLogInput where Exposure == LogFieldPolicy.Shared, + Kind == LogFieldPolicy.Category +{ + public static func shared( + _: LogFieldKindToken, + _ value: Category?, + ) -> Self where + Value == Category?, + Category: Codable & Sendable & CaseIterable & RawRepresentable, + Category.RawValue == String + { + if let value { + preconditionClosedCategory(value) + } + return .init(value: value) } } @@ -306,3 +325,19 @@ extension Duration { + Double(components.attoseconds) / 1_000_000_000_000_000 } } + +@_spi(Testing) +public func isClosedLogCategory(_ value: Value) -> Bool + where Value.RawValue == String +{ + Value.allCases.contains { $0.rawValue == value.rawValue } +} + +private func preconditionClosedCategory(_ value: Value) + where Value.RawValue == String +{ + precondition( + isClosedLogCategory(value), + "Shareable log categories must be members of a closed CaseIterable set", + ) +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift index 19517bbe5..9fde9188c 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScopeDefinition.swift @@ -1,5 +1,5 @@ /// A compile-time namespace for one stable logging scope. -public protocol LogScopeDefinition { +public protocol LogScopeDefinition: Sendable { associatedtype SpanName: Hashable & Sendable = String associatedtype LogMethods = EmptyLogMethods diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogEventTests.swift index f43069f24..c6d97ba10 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogEventTests.swift @@ -2,87 +2,180 @@ import Foundation import PeriscopeCore import Testing -private struct PhotoUploaded: LogEvent { - var photoID: String - var byteCount: Int - var message: String { - "Uploaded photo \(photoID) (\(byteCount) bytes)" - } -} +@LogScope("Photos") +private enum PhotoLog { + @LogEvent("uploaded") + struct Uploaded { + @LogField("photo_id", exposure: .restricted, kind: .identifier) + var photoID: String -private enum UploadKind: String, CaseIterable { - case thumbnail -} + @LogField("byte_count", exposure: .restricted, kind: .count) + var byteCount: Int -private struct ApprovedUpload: LogEvent { - let byteCount: Int - var message: String { - "Uploaded" + var message: String { + "Uploaded photo \(photoID) (\(byteCount) bytes)" + } } - var remoteFields: [RemoteLogField] { - [ - RemoteLogField(key: RemoteLogFieldKey("byte_count"), value: .count(byteCount)), - RemoteLogField( - key: RemoteLogFieldKey("kind"), - value: .category(RemoteLogCategory(UploadKind.thumbnail)), - ), - ] - } -} + enum UploadKind: String, CaseIterable, Codable { case thumbnail } + + @LogEvent("approved-upload", message: "Uploaded") + struct ApprovedUpload { + @LogField("byte_count", exposure: .shareable, kind: .count) + var byteCount: Int -private struct DiskFull: LogEvent { - static let eventName = "disk-full" - static let eventVersion = 2 - var level: LogLevel { - .error + @LogField("kind", exposure: .shareable, kind: .category) + var kind: UploadKind } - var message: String { - "Disk full" + @LogEvent("disk-full", level: .error, message: "Disk full", version: 2) + struct DiskFull {} + + @LogEvent("all-fields", message: "All fields") + struct AllFields { + @LogField("boolean", exposure: .shareable, kind: .boolean) + var boolean: Bool + + @LogField("count", exposure: .shareable, kind: .count) + var count: Int + + @LogField("limit", exposure: .shareable, kind: .limit) + var limit: Int + + @LogField("duration", exposure: .shareable, kind: .duration) + var duration: Duration + + @LogField("category", exposure: .shareable, kind: .category) + var category: UploadKind + + @LogField("json", exposure: .shareable, kind: .json) + var json: JSONValue + + @LogField("optional_count", exposure: .shareable, kind: .count) + var optionalCount: Int? + + @LogField("pii", exposure: .restricted, kind: .pii) + var pii: String + + @LogField("identifier", exposure: .restricted, kind: .identifier) + var identifier: String? + + @LogField("location", exposure: .restricted, kind: .location) + var location: String + + @LogField("user_content", exposure: .restricted, kind: .userContent) + var userContent: String + + @LogField("error_details", exposure: .restricted, kind: .errorDetails) + var errorDetails: String + + @LogField("date_time", exposure: .restricted, kind: .dateTime) + var dateTime: Date + + @LogField("path", exposure: .restricted, kind: .pathOrURL) + var path: URL + + @LogField("arbitrary_text", exposure: .restricted, kind: .arbitraryText) + var arbitraryText: String + + @LogField("domain_value", exposure: .restricted, kind: .domainValue) + var domainValue: UploadKind + + @LogField("technical_state", exposure: .restricted, kind: .technicalState) + var technicalState: Bool } } struct LogEventTests { - @Test func eventNameDefaultsToTypeName() { - #expect(PhotoUploaded.eventName == "PhotoUploaded") - } - - @Test func eventNameCanBeOverridden() { - #expect(DiskFull.eventName == "disk-full") + @Test func eventNameCombinesStableScopeAndEventIDs() { + #expect(PhotoLog.Uploaded.eventName == "Photos.uploaded") + #expect(PhotoLog.DiskFull.eventName == "Photos.disk-full") } - @Test func eventVersionDefaultsToOne() { - #expect(PhotoUploaded.eventVersion == 1) - #expect(DiskFull.eventVersion == 2) + @Test func eventVersionsDefaultAndCanBeExplicit() { + #expect(PhotoLog.Uploaded.eventVersion == 1) + #expect(PhotoLog.DiskFull.eventVersion == 2) } - @Test func levelDefaultsToInfo() { - let event = PhotoUploaded(photoID: "p1", byteCount: 42) + @Test func levelsDefaultAndCanBeFixed() { + let event = PhotoLog.Uploaded( + photoID: .restricted(.identifier, "p1"), + byteCount: .restricted(.count, 42), + ) #expect(event.level == .info) - #expect(DiskFull().level == .error) + #expect(PhotoLog.DiskFull().level == .error) } - @Test func remoteFieldsDefaultToEmpty() { - #expect(PhotoUploaded(photoID: "private-id", byteCount: 42).remoteFields.isEmpty) - } - - @Test func remoteFieldsCarryOnlyApprovedTypedValues() { - #expect(ApprovedUpload(byteCount: 42).remoteFields == [ - RemoteLogField(key: RemoteLogFieldKey("byte_count"), value: .count(42)), - RemoteLogField( - key: RemoteLogFieldKey("kind"), - value: .category(RemoteLogCategory(UploadKind.thumbnail)), - ), + @Test func classifiedFieldsCarryOnlyApprovedValues() { + let event = PhotoLog.ApprovedUpload( + byteCount: .shared(.count, 42), + kind: .shared(.category, .thumbnail), + ) + #expect(event.classifiedFields == [ + .shareable(key: LogFieldKey("byte_count"), kind: .count, value: .int(42)), + .shareable(key: LogFieldKey("kind"), kind: .category, value: .string("thumbnail")), ]) } - @Test func payloadRoundTripsThroughCodable() throws { - let event = PhotoUploaded(photoID: "p1", byteCount: 42) + @Test func payloadRoundTripsWithoutWrapperMetadata() throws { + let event = PhotoLog.Uploaded( + photoID: .restricted(.identifier, "p1"), + byteCount: .restricted(.count, 42), + ) let data = try JSONEncoder().encode(event) - let decoded = try JSONDecoder().decode(PhotoUploaded.self, from: data) + #expect(String(decoding: data, as: UTF8.self).contains("exposure") == false) + let decoded = try JSONDecoder().decode(PhotoLog.Uploaded.self, from: data) #expect(decoded.photoID == "p1") #expect(decoded.byteCount == 42) #expect(decoded.message == event.message) } + + @Test func everyClassificationProjectsWithItsDeclaredPolicy() { + let event = PhotoLog.AllFields( + boolean: .shared(.boolean, true), + count: .shared(.count, 3), + limit: .shared(.limit, 10), + duration: .shared(.duration, .milliseconds(1500)), + category: .shared(.category, .thumbnail), + json: .shared(.json, .object(["complete": .bool(true)])), + optionalCount: .shared(.count, nil), + pii: .restricted(.pii, "private"), + identifier: .restricted(.identifier, nil), + location: .restricted(.location, "private"), + userContent: .restricted(.userContent, "private"), + errorDetails: .restricted(.errorDetails, "private"), + dateTime: .restricted(.dateTime, .distantPast), + path: .restricted(.pathOrURL, URL(filePath: "/private")), + arbitraryText: .restricted(.arbitraryText, "private"), + domainValue: .restricted(.domainValue, .thumbnail), + technicalState: .restricted(.technicalState, true), + ) + + #expect(event.classifiedFields == [ + .shareable(key: LogFieldKey("boolean"), kind: .boolean, value: .bool(true)), + .shareable(key: LogFieldKey("count"), kind: .count, value: .int(3)), + .shareable(key: LogFieldKey("limit"), kind: .limit, value: .int(10)), + .shareable(key: LogFieldKey("duration"), kind: .duration, value: .double(1500)), + .shareable( + key: LogFieldKey("category"), + kind: .category, + value: .string("thumbnail"), + ), + .shareable( + key: LogFieldKey("json"), + kind: .json, + value: .json(.object(["complete": .bool(true)])), + ), + .restricted(key: LogFieldKey("pii"), kind: .pii), + .restricted(key: LogFieldKey("identifier"), kind: .identifier), + .restricted(key: LogFieldKey("location"), kind: .location), + .restricted(key: LogFieldKey("user_content"), kind: .userContent), + .restricted(key: LogFieldKey("error_details"), kind: .errorDetails), + .restricted(key: LogFieldKey("date_time"), kind: .dateTime), + .restricted(key: LogFieldKey("path"), kind: .pathOrURL), + .restricted(key: LogFieldKey("arbitrary_text"), kind: .arbitraryText), + .restricted(key: LogFieldKey("domain_value"), kind: .domainValue), + .restricted(key: LogFieldKey("technical_state"), kind: .technicalState), + ]) + } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogFieldTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogFieldTests.swift index 3b977f529..29239eef8 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogFieldTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogFieldTests.swift @@ -1,29 +1,33 @@ import Foundation -import PeriscopeCore +@_spi(Testing) import PeriscopeCore import Testing -private struct WrappedPayload: Codable, Equatable { - @LogField("stable_key", exposure: .restricted, kind: .identifier) - var renamedProperty: String - - init(renamedProperty: String) { - _renamedProperty = LogField( - wrappedValue: renamedProperty, - "stable_key", - exposure: .restricted, - kind: .identifier, - ) +@LogScope("WrappedPayload") +private enum WrappedPayloadLog { + @LogEvent("payload", message: "Wrapped payload") + struct Payload: Equatable { + @LogField("stable_key", exposure: .restricted, kind: .identifier) + var renamedProperty: String } } +private struct ClosedCategory: Codable, CaseIterable, RawRepresentable { + static let allowed = Self(rawValue: "allowed") + static let allCases = [allowed] + + let rawValue: String +} + struct LogFieldTests { @Test func wrapperEncodesOnlyItsRawValue() throws { - let payload = WrappedPayload(renamedProperty: "sample-123") + let payload = WrappedPayloadLog.Payload( + renamedProperty: .restricted(.identifier, "sample-123"), + ) let data = try JSONEncoder().encode(payload) let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: String]) - #expect(object == ["renamedProperty": "sample-123"]) - #expect(try JSONDecoder().decode(WrappedPayload.self, from: data) == payload) + #expect(object == ["stable_key": "sample-123"]) + #expect(try JSONDecoder().decode(WrappedPayloadLog.Payload.self, from: data) == payload) } @Test func classifiedInputsRetainTheirRawValues() { @@ -39,4 +43,25 @@ struct LogFieldTests { #expect(count.value == 3) #expect(identifier.value == "sample-123") } + + @Test func closedCategoryValidationRejectsValuesOutsideAllCases() { + #expect(isClosedLogCategory(ClosedCategory.allowed)) + #expect(isClosedLogCategory(ClosedCategory(rawValue: "injected")) == false) + } + + @Test func sharedCategoryFactoriesAcceptMembersOfAllCases() { + let required: ClassifiedLogInput< + LogFieldPolicy.Shared, + LogFieldPolicy.Category, + ClosedCategory + > = .shared(.category, .allowed) + let optional: ClassifiedLogInput< + LogFieldPolicy.Shared, + LogFieldPolicy.Category, + ClosedCategory? + > = .shared(.category, .allowed) + + #expect(required.value.rawValue == "allowed") + #expect(optional.value?.rawValue == "allowed") + } } diff --git a/Shared/Periscope/PeriscopeMacros/README.md b/Shared/Periscope/PeriscopeMacros/README.md index bd6fc4af2..b36085398 100644 --- a/Shared/Periscope/PeriscopeMacros/README.md +++ b/Shared/Periscope/PeriscopeMacros/README.md @@ -12,6 +12,16 @@ It also generates typed log methods that require classified inputs. Application modules import `PeriscopeCore` to use both macros. They do not import this implementation module. +`@LogEvent` generates stable coding keys, classified initializers, safe field projections, and event metadata. +`@LogScope` generates the scope definition and compiler-checked event methods on `Log`. + +Generated parameters encode exposure, semantic kind, and Swift value type. +A call site uses inputs such as `.shared(.count, value)` or `.restricted(.identifier, value)`. + +Repository code must use these macros. The runtime protocols keep safe defaults for external manual conformances, but repository sources and tests cannot conform directly. + +Stable IDs are wire data. A macro accepts only plain string literals for scope, event, and field IDs. An incompatible event payload needs a positive new version. + ## Development The root `Package.swift` pins SwiftSyntax exactly. diff --git a/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift b/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift index 1cb1b9dd5..6af1b8f74 100644 --- a/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift +++ b/Shared/Periscope/PeriscopeMacros/Sources/LogEventMacro.swift @@ -30,7 +30,7 @@ public struct LogEventMacro: MemberMacro, ExtensionMacro { ) return [] } - guard let scope = context.lexicalContext.compactMap({ $0.as(EnumDeclSyntax.self) }).last, + guard let scope = context.lexicalContext.first?.as(EnumDeclSyntax.self), attribute(named: "LogScope", in: scope.attributes) != nil else { context.diagnose( @@ -79,10 +79,15 @@ public struct LogEventMacro: MemberMacro, ExtensionMacro { var members: [DeclSyntax] = [ "\(raw: access)static let eventName = \(raw: scopeName).scopeName + \".\(raw: escapedStringLiteral(eventID))\"", "\(raw: access)static let eventVersion = \(raw: parsed.version)", - DeclSyntax(stringLiteral: codingKeys(access: access, fields: fields.values)), DeclSyntax(stringLiteral: initializer(access: access, fields: fields.values)), DeclSyntax(stringLiteral: classifiedFields(access: access, fields: fields.values)), ] + if !fields.values.isEmpty { + members.insert( + DeclSyntax(stringLiteral: codingKeys(access: access, fields: fields.values)), + at: 2, + ) + } if let level = parsed.level { members.append("\(raw: access)var level: LogLevel { .\(raw: level) }") } @@ -109,7 +114,7 @@ public struct LogEventMacro: MemberMacro, ExtensionMacro { in context: some MacroExpansionContext, ) throws -> [ExtensionDeclSyntax] { guard declaration.is(StructDeclSyntax.self), - context.lexicalContext.compactMap({ $0.as(EnumDeclSyntax.self) }).last.map({ + context.lexicalContext.first?.as(EnumDeclSyntax.self).map({ attribute(named: "LogScope", in: $0.attributes) != nil }) == true else { @@ -192,16 +197,15 @@ extension LogEventMacro { _ event: StructDeclSyntax, in context: some MacroExpansionContext, ) -> ParsedFields { - let metadataNames = ["message", "level", "externalID", "isProtectedFromDropping"] - let storedMetadataNames = ["message", "externalID", "isProtectedFromDropping"] - let reservedNames = [ - "attachments", - "function", - "fileID", + let storedMetadataNames = [ + "message", + "externalID", "classifiedFields", "eventName", "eventVersion", + "isProtectedFromDropping", ] + let reservedLabels = ["attachments", "function", "fileID"] let shareableKinds = ["boolean", "count", "limit", "duration", "category", "json"] var result = ParsedFields() var keys = Set() @@ -225,12 +229,22 @@ extension LogEventMacro { continue } let name = identifier.identifier.text - if binding.accessorBlock != nil { + if let accessorBlock = binding.accessorBlock { + if attribute(named: "LogField", in: variable.attributes) != nil + || accessorBlock.hasObservers + { + context.diagnose( + variable, + id: "event-accessor", + message: "event fields cannot declare accessors or observers", + ) + result.hasError = true + } // Computed projections are ordinary event API. They do not // participate in the persisted payload or classification. continue } - if storedMetadataNames.contains(name) || reservedNames.contains(name) { + if storedMetadataNames.contains(name) { context.diagnose( variable, id: "event-reserved", @@ -277,7 +291,7 @@ extension LogEventMacro { ) result.hasError = true } - if reservedNames.contains(name) { + if reservedLabels.contains(name) { context.diagnose( variable, id: "reserved-label", diff --git a/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift b/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift index 4b722ed43..4ad4bf3a7 100644 --- a/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift +++ b/Shared/Periscope/PeriscopeMacros/Sources/LogScopeMacro.swift @@ -36,6 +36,31 @@ public struct LogScopeMacro: MemberMacro, ExtensionMacro { ) return [] } + if let spanName = scope.memberBlock.members.first(where: { member in + if let declaration = member.decl.as(EnumDeclSyntax.self) { + return declaration.name.text == "SpanName" + } + if let declaration = member.decl.as(StructDeclSyntax.self) { + return declaration.name.text == "SpanName" + } + if let declaration = member.decl.as(ClassDeclSyntax.self) { + return declaration.name.text == "SpanName" + } + if let declaration = member.decl.as(ActorDeclSyntax.self) { + return declaration.name.text == "SpanName" + } + if let declaration = member.decl.as(TypeAliasDeclSyntax.self) { + return declaration.name.text == "SpanName" + } + return false + }), !spanName.decl.is(EnumDeclSyntax.self) { + context.diagnose( + spanName.decl, + id: "scope-span-name", + message: "SpanName must be a Hashable and Sendable enum", + ) + return [] + } let access = accessPrefix(scope.modifiers) let events = eventMethods(in: scope, context: context) var members: [DeclSyntax] = [ @@ -112,6 +137,14 @@ extension LogScopeMacro { continue } let methodName = lowerCamelCase(event.name.text) + if methodName == "log" { + context.diagnose( + event, + id: "reserved-method", + message: "generated log method 'log' conflicts with a reserved LogMethods member", + ) + continue + } if !seenMethods.insert(methodName).inserted { context.diagnose( event, diff --git a/Shared/Periscope/PeriscopeMacros/Sources/SyntaxSupport.swift b/Shared/Periscope/PeriscopeMacros/Sources/SyntaxSupport.swift index 0172099ec..030991829 100644 --- a/Shared/Periscope/PeriscopeMacros/Sources/SyntaxSupport.swift +++ b/Shared/Periscope/PeriscopeMacros/Sources/SyntaxSupport.swift @@ -103,3 +103,13 @@ func escapedStringLiteral(_ value: String) -> String { } return result } + +extension AccessorBlockSyntax { + var hasObservers: Bool { + guard case let .accessors(accessors) = accessors else { return false } + return accessors.contains { accessor in + let name = accessor.accessorSpecifier.text + return name == "willSet" || name == "didSet" + } + } +} diff --git a/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift b/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift index 82504117c..ea1a51d6c 100644 --- a/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift +++ b/Shared/Periscope/PeriscopeMacros/Tests/LogEventMacroTests.swift @@ -136,3 +136,194 @@ func eventRequiresScope() { macros: macros, ) } + +@Test +func eventRequiresAMessage() { + assertMacroExpansion( + """ + @LogScope("Sample") + enum SampleLog { + @LogEvent("event") + struct Event {} + } + """, + expandedSource: """ + @LogScope("Sample") + enum SampleLog { + struct Event {} + } + + extension SampleLog.Event: LogEvent { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "an event requires a static or instance message", + line: 3, + column: 5, + ), + ], + macros: ["LogEvent": LogEventMacro.self], + ) +} + +@Test +func eventRejectsInvalidIdentifiersAndVersions() { + assertMacroExpansion( + """ + @LogScope("Sample") + enum SampleLog { + @LogEvent("", message: "Event") + struct Event {} + } + """, + expandedSource: """ + @LogScope("Sample") + enum SampleLog { + struct Event {} + } + + extension SampleLog.Event: LogEvent { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@LogEvent requires a nonempty string-literal event ID", + line: 3, + column: 5, + ), + ], + macros: ["LogEvent": LogEventMacro.self], + ) + + assertMacroExpansion( + """ + @LogScope("Sample") + enum SampleLog { + @LogEvent("event", message: "Event", version: 0) + struct Event {} + } + """, + expandedSource: """ + @LogScope("Sample") + enum SampleLog { + struct Event {} + } + + extension SampleLog.Event: LogEvent { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "version must be a positive integer literal", + line: 3, + column: 42, + ), + ], + macros: ["LogEvent": LogEventMacro.self], + ) +} + +@Test +func eventRejectsUnclassifiedAndInvalidShareableFields() { + assertMacroExpansion( + """ + @LogScope("Sample") + enum SampleLog { + @LogEvent("event", message: "Event") + struct Event { + var count: Int + } + } + """, + expandedSource: """ + @LogScope("Sample") + enum SampleLog { + struct Event { + var count: Int + } + } + + extension SampleLog.Event: LogEvent { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "stored event properties require a complete @LogField classification", + line: 5, + column: 9, + ), + ], + macros: ["LogEvent": LogEventMacro.self], + ) + + assertMacroExpansion( + """ + @LogScope("Sample") + enum SampleLog { + @LogEvent("event", message: "Event") + struct Event { + @LogField("value", exposure: .shareable, kind: .identifier) + var value: String + } + } + """, + expandedSource: """ + @LogScope("Sample") + enum SampleLog { + struct Event { + @LogField("value", exposure: .shareable, kind: .identifier) + var value: String + } + } + + extension SampleLog.Event: LogEvent { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "field kind '.identifier' cannot be shareable", + line: 5, + column: 9, + ), + DiagnosticSpec( + message: "shareable .identifier requires its classified Swift value type", + line: 5, + column: 9, + ), + ], + macros: ["LogEvent": LogEventMacro.self], + ) +} + +@Test +func scopeRejectsEventMethodNamesThatCollideWithGeneratedMembers() { + assertMacroExpansion( + """ + @LogScope("Sample") + enum SampleLog { + @LogEvent("log", message: "Log") + struct Log {} + } + """, + expandedSource: """ + enum SampleLog { + @LogEvent("log", message: "Log") + struct Log {} + + static let scopeName = "Sample" + } + + extension SampleLog: LogScopeDefinition { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "generated log method 'log' conflicts with a reserved LogMethods member", + line: 3, + column: 5, + ), + ], + macros: ["LogScope": LogScopeMacro.self], + ) +} diff --git a/Shared/Periscope/PeriscopeMacros/Tests/LogScopeMacroTests.swift b/Shared/Periscope/PeriscopeMacros/Tests/LogScopeMacroTests.swift index 51d89fa66..ebd5620bb 100644 --- a/Shared/Periscope/PeriscopeMacros/Tests/LogScopeMacroTests.swift +++ b/Shared/Periscope/PeriscopeMacros/Tests/LogScopeMacroTests.swift @@ -21,3 +21,74 @@ func leadingAcronymBecomesOneWord() { #expect(lowerCamelCase("GPS") == "gps") #expect(lowerCamelCase("Loaded") == "loaded") } + +@Test +func scopeRejectsInvalidIdentifiersCasesAndSpanNames() { + assertMacroExpansion( + "@LogScope(\"\") enum Scope {}", + expandedSource: """ + enum Scope {} + + extension Scope: LogScopeDefinition { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@LogScope requires a nonempty string-literal scope ID", + line: 1, + column: 1, + ), + ], + macros: ["LogScope": LogScopeMacro.self], + ) + + assertMacroExpansion( + """ + @LogScope("Scope") + enum Scope { + case invalid + } + """, + expandedSource: """ + enum Scope { + case invalid + } + + extension Scope: LogScopeDefinition { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "an @LogScope enum cannot declare cases", + line: 1, + column: 1, + ), + ], + macros: ["LogScope": LogScopeMacro.self], + ) + + assertMacroExpansion( + """ + @LogScope("Scope") + enum Scope { + struct SpanName {} + } + """, + expandedSource: """ + enum Scope { + struct SpanName {} + } + + extension Scope: LogScopeDefinition { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "SpanName must be a Hashable and Sendable enum", + line: 3, + column: 5, + ), + ], + macros: ["LogScope": LogScopeMacro.self], + ) +} diff --git a/test b/test index 2d15f8e10..5266ed67e 100755 --- a/test +++ b/test @@ -476,7 +476,9 @@ LOGS=() if [ "$HOST_MACROS" = true ]; then echo "==> Building PeriscopeMacrosTests on the host" macro_build_log="$WORKDIR/PeriscopeMacrosTests-build.log" - if ! mise exec -- swift build --target PeriscopeMacrosTests >"$macro_build_log" 2>&1; then + # A target build stops at the test module and does not link the .xctest bundle. + # The synthesized product links it without building the package's iOS-only targets. + if ! mise exec -- swift build --product PeriscopeMacrosTests >"$macro_build_log" 2>&1; then echo "error: build failed for PeriscopeMacrosTests. Tail of $macro_build_log:" >&2 tail -n 30 "$macro_build_log" >&2 exit 1 From cea01542f386cfd6c655b6659bcbb05475d20370 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 7 Sep 2026 13:04:47 -0700 Subject: [PATCH 4/5] Keep classified-event base independently testable --- .../periscopeViewer.PeriscopeViewer_iPhone.png | 4 ++-- .../periscopeViewer.PeriscopeViewer_iPhone_dark.png | 4 ++-- .../Tests/LocalNotificationAlertHandlerTests.swift | 2 +- .../Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift | 2 +- .../PeriscopeTools/Tests/PeriscopeViewerModelTests.swift | 2 +- test | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone.png b/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone.png index 582714263..456667fa1 100644 --- a/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone.png +++ b/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c11ebed4661340ac16d047178775d22c17be7e30a302e761ffe4d647e2d04946 -size 271566 +oid sha256:eb4cca15a697cb208e3b2be2dd20b732f26c86231a55d2b82ed2c7564baac2f2 +size 273754 diff --git a/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone_dark.png b/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone_dark.png index 8e6f34aa3..a10822a7a 100644 --- a/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone_dark.png +++ b/Shared/Periscope/PeriscopeTools/SnapshotTests/__Snapshots__/PeriscopeViewerSnapshotTests/periscopeViewer.PeriscopeViewer_iPhone_dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ccb3bdcdeeb266aa922890c425e57d38ea659b44a9eedb9d87f92858525bd716 -size 272950 +oid sha256:63cfcdb02291ee60240e9916237bf48da6f21eba745d17fa94d4aa1fda7b9d0e +size 275186 diff --git a/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift index ce461f77c..08b20b8ec 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift @@ -72,7 +72,7 @@ struct LocalNotificationAlertHandlerTests { let request = LocalNotificationAlertHandler.request(for: record) - #expect(request.content.title == "Error: message") + #expect(request.content.title == "Error: message.message") #expect(request.content.body == "Upload failed") #expect(request.identifier == "periscope-alert-\(record.id.uuidString)") #expect(request.trigger == nil) diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift index 96a3e0be0..f7a2b1c05 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift @@ -92,7 +92,7 @@ struct LogTraceModelTests { let model = LogTraceModel(store: store, origin: origin, limit: 500) await model.load() - #expect(model.trail.contains { $0.spanID == span && $0.eventName == "span-began" }) + #expect(model.trail.contains { $0.spanID == span && $0.eventName == "span.began" }) } @Test func sameMillisecondEventsAfterTheOriginAreExcluded() async throws { diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift index e9859f1bb..b1bdc33ce 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift @@ -142,7 +142,7 @@ struct PeriscopeViewerModelTests { let model = PeriscopeViewerModel(store: store) await model.load() - #expect(model.eventNames == ["PhotoLogs", "message"].sorted()) + #expect(model.eventNames == ["PhotoLogs", "message.message"].sorted()) #expect(model.sessions.count == 1) #expect(model.scopeChoices.map(\.path).contains("app / photos / album-1")) #expect(model.availableLevels == LogLevel.standardLevels) diff --git a/test b/test index 5266ed67e..7513f0403 100755 --- a/test +++ b/test @@ -456,7 +456,7 @@ if [ "${#SCHEMES[@]}" -eq 0 ] && [ "$HOST_MACROS" = false ]; then exit 1 fi -for scheme in "${SCHEMES[@]}"; do +for scheme in ${SCHEMES[@]+"${SCHEMES[@]}"}; do [ "$scheme" = "$SNAPSHOT_SCHEME" ] || continue expected_xcode_build="$(< .xcode-build-version)" From cfa36112caf50c505e844fd663bdb87b5dfb7bbb Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 7 Sep 2026 13:28:57 -0700 Subject: [PATCH 5/5] Fix host macro runner contract fixture --- Tools/Tests/test_xcode_command_contracts.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Tools/Tests/test_xcode_command_contracts.py b/Tools/Tests/test_xcode_command_contracts.py index c721aa3f6..4ab5f8e85 100644 --- a/Tools/Tests/test_xcode_command_contracts.py +++ b/Tools/Tests/test_xcode_command_contracts.py @@ -110,7 +110,11 @@ def _write_fake_tools(self) -> None: self.bin / "mise", """#!/bin/bash printf 'mise %s\\n' "$*" >>"$TOOL_LOG" -exit "${MISE_STATUS:-0}" +status="${MISE_STATUS:-0}" +if [ "$status" -eq 0 ] && [[ " $* " == *" swift build --product PeriscopeMacrosTests "* ]]; then + /bin/mkdir -p .build/fixture/Products/Debug/PeriscopeMacrosTests.xctest +fi +exit "$status" """, ) self._write_executable(