diff --git a/.bumper/RULES.md b/.bumper/RULES.md index 21b3d5633..408644245 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -1,8 +1,16 @@ # Where Architecture Rules -`BumperBowling.swift` turns the module boundaries already documented in -`Where/**/AGENTS.md` into source-level checks. It scans production sources only; -tests and generated files are outside the architecture graph. +`BumperBowling.swift` turns documented module boundaries and classified-event authoring rules into source-level checks. The Where architecture rules scan production sources. The Periscope authoring rules also scan affected test targets. + +## Classified Periscope events + +Repository scopes use `@LogScope`. Their direct nested event structs use `@LogEvent`, and `@LogField` appears only in those event structs. + +The rules reject manual `LogEvent` and `LogScopeDefinition` conformances. They also reject the removed remote-field API names. + +Macro expansion strings are ordinary test data, not source declarations. The typed syntax rules inspect declarations and identifiers, so they need no path exceptions for macro tests. + +Repair a violation with the macro authoring API. Do not add a file exception. Mutation tests in `PeriscopeAuthoringRulesTests` cover each rule. ## Layer boundaries diff --git a/.bumper/Sources/PeriscopeAuthoringRules.swift b/.bumper/Sources/PeriscopeAuthoringRules.swift new file mode 100644 index 000000000..6435d4aae --- /dev/null +++ b/.bumper/Sources/PeriscopeAuthoringRules.swift @@ -0,0 +1,219 @@ +import BumperBowlingCore +import SwiftSyntax + +let periscopeAuthoringRules = RuleSet { + eventMacroRule + scopeMacroRule + manualEventConformanceRule + manualScopeConformanceRule + legacyRemoteAPIRule + logFieldPlacementRule +} + +private let eventMacroRule = Rules.files( + "periscope.structured_events_use_macro", + severity: .error, + summary: "Every event nested in a Periscope scope uses @LogEvent.", +) { file in + SyntaxQuery() + .filter { match in + guard let parent = nearestNominalParent(of: match.node)?.as(EnumDeclSyntax.self) else { + return false + } + return hasAttribute("LogScope", in: parent.attributes) + && !hasAttribute("LogEvent", in: match.node.attributes) + } + .matches(in: file) + .map { match in + match.failure( + message: "A structured Periscope event does not use @LogEvent.", + evidence: ViolationEvidence( + observed: match.node.name.text, + expectation: "a direct @LogEvent struct in its @LogScope namespace", + ), + ) + } +} + +private let scopeMacroRule = Rules.files( + "periscope.event_namespaces_use_macro", + severity: .error, + summary: "Every namespace containing @LogEvent declarations uses @LogScope.", +) { file in + SyntaxQuery() + .filter { match in + !hasAttribute("LogScope", in: match.node.attributes) + && match.node.memberBlock.members.contains { member in + guard let event = member.decl.as(StructDeclSyntax.self) else { return false } + return hasAttribute("LogEvent", in: event.attributes) + } + } + .matches(in: file) + .map { match in + match.failure( + message: "A Periscope event namespace does not use @LogScope.", + evidence: ViolationEvidence( + observed: match.node.name.text, + expectation: "an @LogScope namespace enum", + ), + ) + } +} + +private let manualEventConformanceRule = manualConformanceRule( + protocolName: "LogEvent", + id: "periscope.manual_event_conformance", + summary: "Repository event declarations do not conform to LogEvent manually.", +) + +private let manualScopeConformanceRule = manualConformanceRule( + protocolName: "LogScopeDefinition", + id: "periscope.manual_scope_conformance", + summary: "Repository scope declarations do not conform to LogScopeDefinition manually.", +) + +private let legacyRemoteIdentifiers: Set = [ + "remoteMessage", + "remoteFields", + "RemoteLogField", + "RemoteLogFieldKey", + "RemoteLogFieldValue", + "RemoteLogCategory", +] + +private let legacyRemoteAPIRule = Rules.files( + "periscope.legacy_remote_api", + severity: .error, + summary: "Legacy Periscope remote-field APIs stay removed.", +) { file in + SyntaxQuery() + .filter { legacyRemoteIdentifiers.contains($0.node.text) } + .matches(in: file) + .map { match in + match.failure( + message: "Repository code uses a removed Periscope remote API.", + evidence: ViolationEvidence( + observed: match.node.text, + expectation: "@LogField classification and classifiedFields", + ), + ) + } +} + +private let logFieldPlacementRule = Rules.files( + "periscope.log_field_placement", + severity: .error, + summary: "@LogField appears only on properties of direct @LogEvent structs.", +) { file in + SyntaxQuery() + .filter { match in + guard attributeBaseName(match.node) == "LogField" else { return false } + guard let event = nearestAncestor(of: match.node, as: StructDeclSyntax.self), + hasAttribute("LogEvent", in: event.attributes), + nearestNominalParent(of: event)?.is(EnumDeclSyntax.self) == true + else { + return true + } + return false + } + .matches(in: file) + .map { match in + match.failure( + message: "@LogField is outside a direct @LogEvent struct.", + evidence: ViolationEvidence( + observed: match.node.trimmedDescription, + expectation: "a stored property in a direct @LogEvent struct", + ), + ) + } +} + +private func manualConformanceRule( + protocolName: String, + id: String, + summary: String, +) -> SyntaxRule { + Rules.files(id, severity: .error, summary: summary) { file in + SyntaxQuery() + .filter { match in + match.node.type.trimmedDescription == protocolName + && inheritanceDecl(of: match.node) != nil + } + .matches(in: file) + .map { match in + match.failure( + message: "Repository code conforms to \(protocolName) manually.", + evidence: ViolationEvidence( + observed: match.node.trimmedDescription, + expectation: protocolName == "LogEvent" ? "@LogEvent" : "@LogScope", + ), + ) + } + } +} + +private func inheritanceDecl(of node: InheritedTypeSyntax) -> DeclSyntax? { + var ancestor = Syntax(node).parent + while let current = ancestor { + if current.is(AssociatedTypeDeclSyntax.self) + || current.is(TypeAliasDeclSyntax.self) + || current.is(FunctionDeclSyntax.self) + || current.is(VariableDeclSyntax.self) + { + return nil + } + if current.is(StructDeclSyntax.self) + || current.is(EnumDeclSyntax.self) + || current.is(ClassDeclSyntax.self) + || current.is(ActorDeclSyntax.self) + || current.is(ProtocolDeclSyntax.self) + || current.is(ExtensionDeclSyntax.self) + { + return current.as(DeclSyntax.self) + } + ancestor = current.parent + } + return nil +} + +private func hasAttribute(_ name: String, in attributes: AttributeListSyntax) -> Bool { + attributes.contains { element in + guard let attribute = element.as(AttributeSyntax.self) else { return false } + return attributeBaseName(attribute) == name + } +} + +private func attributeBaseName(_ attribute: AttributeSyntax) -> String { + attribute.attributeName.trimmedDescription.split(separator: ".").last.map(String.init) ?? "" +} + +private func nearestNominalParent(of node: some SyntaxProtocol) -> DeclSyntax? { + var ancestor = Syntax(node).parent + while let current = ancestor { + if current.is(StructDeclSyntax.self) + || current.is(EnumDeclSyntax.self) + || current.is(ClassDeclSyntax.self) + || current.is(ActorDeclSyntax.self) + || current.is(ProtocolDeclSyntax.self) + || current.is(ExtensionDeclSyntax.self) + { + return current.as(DeclSyntax.self) + } + ancestor = current.parent + } + return nil +} + +private func nearestAncestor( + of node: some SyntaxProtocol, + as _: Node.Type, +) -> Node? { + var ancestor = Syntax(node).parent + while let current = ancestor { + if let match = current.as(Node.self) { + return match + } + ancestor = current.parent + } + return nil +} diff --git a/.bumper/Sources/WhereProjectRules.swift b/.bumper/Sources/WhereProjectRules.swift index 0e047ad3b..fab2b463e 100644 --- a/.bumper/Sources/WhereProjectRules.swift +++ b/.bumper/Sources/WhereProjectRules.swift @@ -4,17 +4,18 @@ import SwiftSyntax let whereProjectRules = RuleSet { Rules.constructionOwnership( "WhereServices", - allowed: whereServicesConstructionScope, + allowed: whereServicesConstructionScope.union(nonWhereProductionScope), id: "where.services_composition_ownership", ) Rules.constructionOwnership( "CoreLocationSource", - allowed: .files(["Where/WhereUI/Sources/Launch/WhereLaunch.swift"]), + allowed: RuleScope.files(["Where/WhereUI/Sources/Launch/WhereLaunch.swift"]) + .union(nonWhereProductionScope), id: "where.live_location_source_ownership", ) Rules.singleNominalSpelling( suffix: "Log", - owner: whereLoggingScope, + owner: whereLoggingScope.union(nonWhereProductionScope), id: "where.logging_type_ownership", ) productionStoreOpeningRule @@ -26,6 +27,14 @@ let whereProjectRules = RuleSet { previewCoverageRule } +private let whereProductionScope = RuleScope { file in + file.path.rawValue.hasPrefix("Where/") && file.path.rawValue.contains("/Sources/") +} + +private let nonWhereProductionScope = RuleScope { file in + !whereProductionScope.includes(file) +} + private let whereServicesConstructionScope = RuleScope .component(WhereComponent.whereCore) .union(.files(["Where/WhereUI/Sources/Preview/PreviewSupport.swift"])) @@ -48,6 +57,7 @@ private let productionStoreOpeningRule = Rules.files( "where.production_store_opening", severity: .error, summary: "Production SwiftData stores open only at the app and share-extension composition roots.", + scope: whereProductionScope, ) { file in functionCalls() .filter { match in @@ -78,6 +88,7 @@ private let checkedConcurrencyBoundaryRule = Rules.files( "where.checked_concurrency_boundaries", severity: .error, summary: "Unchecked concurrency escape hatches stay inside documented lifecycle boundaries.", + scope: whereProductionScope, ) { file in let preconcurrencyFailures = SyntaxQuery() .filter { match in @@ -118,6 +129,7 @@ private let gregorianCalendarRule = Rules.files( "where.gregorian_calendar", severity: .error, summary: "Where day and year calculations do not use the device's potentially non-Gregorian current calendar.", + scope: whereProductionScope, ) { file in SyntaxQuery() .filter { match in @@ -153,6 +165,7 @@ private let storeTransactionBoundaryRule = Rules.files( "where.store_transaction_boundary", severity: .error, summary: "WhereStore mutations occur inside a transaction helper on store.", + scope: whereProductionScope, ) { file in functionCalls() .filter { match in @@ -197,6 +210,7 @@ private let appShortcutsProviderOwnershipRule = Rules.files( "where.app_shortcuts_provider_ownership", severity: .error, summary: "AppShortcutsProvider conformances live in the Where app target.", + scope: whereProductionScope, ) { file in guard file.component.rawValue != WhereComponent.app.rawValue else { return [] } return SyntaxQuery() @@ -217,6 +231,7 @@ private let loggingFacadeRule = Rules.files( "where.logging_facade", severity: .error, summary: "Where production logging goes through its typed Periscope facades.", + scope: whereProductionScope, ) { file in let rawLoggingImports = SyntaxQuery() .filter { $0.node.path.trimmedDescription == "OSLog" } diff --git a/.bumper/Tests/PeriscopeAuthoringRulesTests.swift b/.bumper/Tests/PeriscopeAuthoringRulesTests.swift new file mode 100644 index 000000000..5770c2bb7 --- /dev/null +++ b/.bumper/Tests/PeriscopeAuthoringRulesTests.swift @@ -0,0 +1,102 @@ +import BumperBowlingCore +import BumperBowlingTestSupport +import Testing + +struct PeriscopeAuthoringRulesTests { + @Test + func `macro-authored scope and event pass`() throws { + let report = try evaluate( + """ + @LogScope("Worker") + enum WorkerLog { + @LogEvent("finished", message: "Finished") + struct Finished { + @LogField("count", exposure: .shareable, kind: .count) + var count: Int + } + } + """, + ) + + #expect(report.violations.isEmpty) + } + + @Test + func `event nested in a scope requires LogEvent`() throws { + let report = try evaluate( + """ + @LogScope("Worker") + enum WorkerLog { + struct Finished {} + } + """, + ) + + #expect(report.violations.map(\.rule.id) == ["periscope.structured_events_use_macro"]) + } + + @Test + func `event namespace requires LogScope`() throws { + let report = try evaluate( + """ + enum WorkerLog { + @LogEvent("finished", message: "Finished") + struct Finished {} + } + """, + ) + + #expect(report.violations.map(\.rule.id) == ["periscope.event_namespaces_use_macro"]) + } + + @Test(arguments: ["LogEvent", "LogScopeDefinition"]) + func `manual logging conformances fail`(_ protocolName: String) throws { + let report = try evaluate("struct Manual: \(protocolName) {}") + + let expected = protocolName == "LogEvent" + ? "periscope.manual_event_conformance" + : "periscope.manual_scope_conformance" + #expect(report.violations.count == 1) + #expect(report.violations.first?.rule.id.rawValue == expected) + } + + @Test(arguments: [ + "remoteMessage", + "remoteFields", + "RemoteLogField", + "RemoteLogFieldKey", + "RemoteLogFieldValue", + "RemoteLogCategory", + ]) + func `legacy remote APIs fail`(_ name: String) throws { + let report = try evaluate("let value = \(name)") + + #expect(report.violations.map(\.rule.id) == ["periscope.legacy_remote_api"]) + } + + @Test + func `LogField outside an event fails`() throws { + let report = try evaluate( + """ + struct Payload { + @LogField("count", exposure: .shareable, kind: .count) + var count: Int + } + """, + ) + + #expect(report.violations.map(\.rule.id) == ["periscope.log_field_placement"]) + } + + private func evaluate(_ source: String) throws -> RuleReport { + try RuleTestHarness(periscopeAuthoringRules).evaluate( + VirtualRepository { + VirtualSourceFile.swift( + "Shared/Periscope/Fixture.swift", + component: "periscope", + source: source, + ) + }, + ) + } +} diff --git a/AGENTS.md b/AGENTS.md index b448cee1c..80aa94454 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -515,6 +515,8 @@ cannot exercise them. Record skipped checks in the commit or PR validation. Semantic changes to configuration, scripts, generator inputs, executable examples, or app-rendered copy are not documentation-only. +`./test PeriscopeMacrosTests` runs the host-side macro suite without selecting a simulator. `./test --all` and `./test --everything` include it. `./test --snapshots` alone does not. + Load the [`running-tests`](.agents/skills/running-tests/SKILL.md) skill for test tiers, snapshot opt-in, why not `tuist test`, and per-checkout simulator management (`./simulator` resolves a UDID — never pass a device name to diff --git a/BumperBowling.swift b/BumperBowling.swift index 52e813a48..12ef6cc71 100644 --- a/BumperBowling.swift +++ b/BumperBowling.swift @@ -1,6 +1,9 @@ import BumperBowlingCore enum WhereComponent: String, ComponentKey { + case periscope + case ledgerCore + case loggingTests case regionKit case whereCore case whereUI @@ -13,11 +16,26 @@ enum WhereComponent: String, ComponentKey { let bumper = BumperProject { Included { + "Shared/Periscope/PeriscopeCore/Sources" + "Shared/Periscope/PeriscopeMacros/Sources" + "Shared/Periscope/PeriscopeTools/Sources" + "Shared/Periscope/PeriscopeUI/Sources" + "Shared/Periscope/PeriscopeCore/Tests" + "Shared/Periscope/PeriscopeMacros/Tests" + "Shared/Periscope/PeriscopeTools/Tests" + "Shared/Periscope/PeriscopeUI/Tests" + "Ledger/LedgerCore/Sources" + "Ledger/LedgerCore/Tests" "Where/RegionKit/Sources" + "Where/RegionKit/Tests" "Where/WhereCore/Sources" + "Where/WhereCore/Tests" "Where/WhereUI/Sources" + "Where/WhereUI/Tests" "Where/WhereIntents/Sources" + "Where/WhereIntents/Tests" "Where/Where/Sources" + "Where/Where/Tests" "Where/WhereWidgets/Sources" "Where/WhereShareExtension/Sources" "Where/RegionViewer/Sources" @@ -30,9 +48,49 @@ let bumper = BumperProject { } Architecture(WhereComponent.self) { + Component(.periscope) { + Owns("Shared/Periscope/PeriscopeCore/Sources") + Owns("Shared/Periscope/PeriscopeMacros/Sources") + Owns("Shared/Periscope/PeriscopeTools/Sources") + Owns("Shared/Periscope/PeriscopeUI/Sources") + Modules("PeriscopeCore", "PeriscopeMacros", "PeriscopeTools", "PeriscopeUI") + } + + Component(.ledgerCore) { + Owns("Ledger/LedgerCore/Sources") + Modules("LedgerCore") + MayDependOn(.periscope) + } + + Component(.loggingTests) { + Owns("Shared/Periscope/PeriscopeCore/Tests") + Owns("Shared/Periscope/PeriscopeMacros/Tests") + Owns("Shared/Periscope/PeriscopeTools/Tests") + Owns("Shared/Periscope/PeriscopeUI/Tests") + Owns("Ledger/LedgerCore/Tests") + Owns("Where/RegionKit/Tests") + Owns("Where/WhereCore/Tests") + Owns("Where/WhereUI/Tests") + Owns("Where/WhereIntents/Tests") + Owns("Where/Where/Tests") + MayDependOn( + .periscope, + .ledgerCore, + .regionKit, + .whereCore, + .whereUI, + .whereIntents, + .app, + .widgets, + .shareExtension, + .regionViewer, + ) + } + Component(.regionKit) { Owns("Where/RegionKit/Sources") Modules("RegionKit") + MayDependOn(.periscope) Applies(.whereFoundationLayer) DoesNotUse("CoreLocation") } @@ -40,21 +98,21 @@ let bumper = BumperProject { Component(.whereCore) { Owns("Where/WhereCore/Sources") Modules("WhereCore") - MayDependOn(.regionKit) + MayDependOn(.periscope, .regionKit) Applies(.whereDomainLayer) } Component(.whereUI) { Owns("Where/WhereUI/Sources") Modules("WhereUI") - MayDependOn(.regionKit, .whereCore) + MayDependOn(.periscope, .regionKit, .whereCore) Applies(.wherePresentationLayer) } Component(.whereIntents) { Owns("Where/WhereIntents/Sources") Modules("WhereIntents") - MayDependOn(.regionKit, .whereCore, .whereUI) + MayDependOn(.periscope, .regionKit, .whereCore, .whereUI) Applies(.whereAdapterLayer) DoesNotUse("CoreLocation") DoesNotUse("BroadwayCore", "BroadwayUI") @@ -63,14 +121,14 @@ let bumper = BumperProject { Component(.app) { Owns("Where/Where/Sources") Modules("Where") - MayDependOn(.regionKit, .whereCore, .whereUI, .whereIntents) + MayDependOn(.periscope, .regionKit, .whereCore, .whereUI, .whereIntents) Applies(.whereHostLayer) } Component(.widgets) { Owns("Where/WhereWidgets/Sources") Modules("WhereWidgets") - MayDependOn(.regionKit, .whereCore, .whereUI) + MayDependOn(.periscope, .regionKit, .whereCore, .whereUI) Applies(.whereAdapterLayer) DoesNotUse("BroadwayCore", "BroadwayUI") } @@ -78,14 +136,14 @@ let bumper = BumperProject { Component(.shareExtension) { Owns("Where/WhereShareExtension/Sources") Modules("WhereShareExtension") - MayDependOn(.whereCore, .whereUI) + MayDependOn(.periscope, .whereCore, .whereUI) Applies(.whereAdapterLayer) } Component(.regionViewer) { Owns("Where/RegionViewer/Sources") Modules("RegionViewer") - MayDependOn(.regionKit, .whereCore, .whereUI) + MayDependOn(.periscope, .regionKit, .whereCore, .whereUI) Applies(.whereHostLayer) } } @@ -93,5 +151,6 @@ let bumper = BumperProject { Rules { ApplyAssertions(.whereArchitecture) whereProjectRules + periscopeAuthoringRules } } diff --git a/Ledger/LedgerCore/Sources/LedgerLog.swift b/Ledger/LedgerCore/Sources/LedgerLog.swift index 96c4b72dd..f45dc769f 100644 --- a/Ledger/LedgerCore/Sources/LedgerLog.swift +++ b/Ledger/LedgerCore/Sources/LedgerLog.swift @@ -1,16 +1,8 @@ import PeriscopeCore -/// Phantom root event naming Ledger's log scope tree. It is never emitted — its -/// only job is to give ``LedgerLog``'s root `Log` the scope name `"Ledger"`, so -/// every event sits under one filterable subtree in the process-wide -/// `Periscope.shared` system. -public struct LedgerRoot: LogEvent { - public static let eventName = "Ledger" - - public var message: String { - "" - } -} +/// Root namespace for Ledger's log scope tree. +@LogScope("Ledger") +public enum LedgerRoot {} /// Central logging facade for the Ledger menu bar app. /// diff --git a/README.md b/README.md index 9a3c8121c..0b5dfa298 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,10 @@ It streams progress while tests run: ```bash ./test # just what your change affects ./test WhereCoreTests # one bundle +./test PeriscopeMacrosTests # the host-side macro suite, without a simulator ./test --all # the whole unit suite ./test --snapshots # the image-snapshot suite -./test --everything # both CI suites in one local run +./test --everything # unit, snapshot, and host macro suites ``` See `./test --help` for the rest, including `--timings` and `--review` for reading a snapshot run. diff --git a/Shared/Periscope/AGENTS.md b/Shared/Periscope/AGENTS.md index 8b33ee67b..61a60beec 100644 --- a/Shared/Periscope/AGENTS.md +++ b/Shared/Periscope/AGENTS.md @@ -21,7 +21,7 @@ Durability sits below the stack in [`JournalKit`](../JournalKit). It is payload- - **A consumer owns its own root scope. Periscope owns the system.** - **An app declares a facade over a root `Log` scope** (Where has `WhereLog`, RegionKit `RegionLog`). -- **Emit typed `LogEvent`s through it.** Never emit a raw string. Never add a second logging system. +- **Declare repository events with `@LogScope`, `@LogEvent`, and `@LogField`.** Emit them through generated classified methods. Never write a direct `LogEvent` conformance. - **Those separate roots all record into the one process-wide `Periscope.shared`.** - **Then a single store sink and a single viewer see every scope subtree.** - **Attaching the store is the host app's job, once.** `PeriscopeStore.make` is `async`. @@ -33,7 +33,7 @@ Durability sits below the stack in [`JournalKit`](../JournalKit). It is payload- - **A bundle that was not stamped contributes no attributes rather than a build called `unknown`.** - **Where fills them from `BuildInfo.logSessionAttributes`.** - **Tests never touch `Periscope.shared`.** Build a fresh system with an in-memory store per test. Pass it explicitly. -- **`Log()` defaults to `.shared`.** An omitted `system:` silently joins the process-wide one. +- **`Log()` defaults to `.shared`.** An omitted `system:` silently joins the process-wide one. ## Testing diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index 318e86871..e87877c95 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -1,6 +1,6 @@ # PeriscopeCore – Module Shape -PeriscopeCore is the core of the **Periscope** observability framework. It provides typed `Codable` log events, the `Log` scope hierarchy, tags, spans, the sink pipeline, ambient event sources, and the SwiftData store. See [`README.md`](README.md) for the narrative and API. +PeriscopeCore is the core of the **Periscope** observability framework. It provides classified `Codable` log events, the `Log` hierarchy, tags, spans, the sink pipeline, ambient event sources, and the SwiftData store. See [`README.md`](README.md) for the narrative and API. Read the root [`AGENTS.md`](../../../AGENTS.md) first. That file owns the build system, formatting, and global conventions. @@ -38,8 +38,9 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) first. That file owns the build - **`remove(_:)` is `async` because it settles the sink first.** Await the in-flight drain and flush the sink. - **Then a removed sink is owed nothing and hears nothing more.** - **Removing a `PeriscopeStore` also uninstalls that store's journal.** Guard: `PeriscopeTests.removalDeliversAndFlushesWhatTheSinkWasOwed`. -- **Make remote export an explicit opt-in for each event.** Safe sinks use `remoteMessage` and `remoteFields`. -- **Never infer remote data from payloads, tags, dynamic scopes, ambient state, external IDs, or attachments.** +- **Repository events use the macros.** Do not add a direct `LogEvent` or `LogScopeDefinition` conformance. +- **Approve shareable fields twice.** Use `.shareable` in `@LogField` and `.shared` at emission. Classification is author approval, not content inspection. +- **Baseline sinks use `eventName` and `classifiedFields` only.** Never infer remote data from payloads, rendered messages, tags, dynamic scopes, ambient state, external IDs, or attachments. - **Never use attachment bytes as remote-export input, including in Debug full-metadata mode.** - **Use closed `CaseIterable` values for category fields.** Reject values outside `allCases`. - **Sink failures never propagate or vanish.** Log them to OSLog. Count them. @@ -78,4 +79,4 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) first. That file owns the build ## Testing -Swift Testing lives in [`Tests/`](Tests), hosted in `StuffTestHost` (`PeriscopeCoreTests`). Use in-memory stores and fresh `Periscope` systems per test (never the shared singleton). Use injected clocks. `Log()` defaults to `.shared` — a deliberate ergonomics exception to the no-Core-defaults rule — so tests must always pass `system:` explicitly. +Swift Testing lives in [`Tests/`](Tests), hosted in `StuffTestHost` (`PeriscopeCoreTests`). Use in-memory stores and fresh `Periscope` systems per test (never the shared singleton). Use injected clocks. `Log()` defaults to `.shared` — a deliberate ergonomics exception to the no-Core-defaults rule — so tests must always pass `system:` explicitly. diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index ca2377de6..afb97f300 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -2,7 +2,7 @@ The core of **Periscope**, a typed, hierarchical observability framework. Periscope logs **structured `Codable` events** (alongside freeform messages) -through **typed loggers** (`Log`) arranged in a **scope tree**, stamps +through **scope-typed loggers** (`Log`) arranged in a **scope tree**, stamps them with **tags**, times work with **spans**, and persists everything — hierarchy included — to **SwiftData** so days or weeks of history stay queryable on device. @@ -36,23 +36,36 @@ inspect mode live in [`PeriscopeTools`](../PeriscopeTools). ## Quick start -Define events, derive loggers, log: +Define a scope and its events. Classify every payload field at declaration and emission: ```swift import PeriscopeCore -struct PhotoLogs: LogEvent { - var photoID: String - var message: String { "Uploaded \(photoID)" } +@LogScope("Photos") +enum PhotoLog { + enum SpanName: Hashable { case upload } + + @LogEvent("uploaded") + struct Uploaded { + @LogField("photo_id", exposure: .restricted, kind: .identifier) + var photoID: String + + @LogField("byte_count", exposure: .shareable, kind: .count) + var byteCount: Int + + var message: String { "Uploaded \(photoID)" } + } } -let root = Log() // records into Periscope.shared -let photos = root(PhotoLogs.self) // typed child scope -let album = photos(for: album.id) // child scope keyed by an entity +let root = Log() // records into Periscope.shared +let photos = root(PhotoLog.self) // typed child scope +let album = photos(for: album.id) // child scope keyed by an entity -album { PhotoLogs(photoID: photo.id) } // structured event -album.warning("thumbnail cache miss") // freeform, any Log can -photos(for: album.id) { PhotoLogs(photoID: photo.id) } // derive + emit in one call +album.uploaded( + photoID: .restricted(.identifier, photo.id), + byteCount: .shared(.count, data.count), +) +album.warning("thumbnail cache miss") // freeform text is restricted let joined = album + screenLog // link model + UI contexts let tagged = joined.tagged(.paymentID, payment.id) // stamps every event @@ -72,24 +85,22 @@ Periscope.shared.startDefaultAmbientSources() ## Public API -- **Events** — `LogEvent` (`Codable & Sendable`; `eventName`, `eventVersion`, - `level`, `message`, PII-free `remoteMessage`, approved `remoteFields`), the built-in freeform `Message`, and the extensible +- **Events** — `@LogScope`, `@LogEvent`, and `@LogField` generate repository event code. `LogEvent` supplies the `Codable` and `Sendable` runtime contract. It includes identity, version, level, message, external ID, and classified fields. The module also provides freeform `Message` and the extensible `LogLevel` struct (`name` + `severity`; standard ladder `debug…fault`, custom levels slot between). -- **Loggers** — `Log`: derive typed children (`log(PhotoLogs.self)`), +- **Loggers** — `Log` derives typed children (`log(PhotoLog.self)`), entity children (`log(for: id)`), link contexts (`+` / `linked(with:)`), - tag (`tagged(_:_:)`), and emit (trailing closure, level conveniences, - `attachments:`). Scope IDs are deterministic (parent + name), so the same + tag (`tagged(_:_:)`), and emit through generated methods or freeform helpers. Generated methods accept attachments and source-location arguments. Scope IDs are deterministic (parent + name), so the same path is the same scope in any process or launch. - **Propagation** — `log.withContext { … }` binds the context to a `@TaskLocal`. `Log.current` reads it anywhere in the async call tree. - `LogContextProviding` gives classes a derived per-instance `.log`. + `LogContextProviding` gives classes a derived per-instance `.log`. `LogContext` carries scopes, tags, and the recorder without a scope generic. - **Spans** — `log.measure(.token) { … }` (sync/async) emits paired `SpanBegan`/`SpanEnded` events with the exit derived automatically (return → `.success`, throw → `.failure`, `CancellationError` → `.cancelled`), and an optional `budget:` fires a `SpanOverdue` warning - while the closure hangs past it. Names resolve against `Event.SpanName` - (defaults to `String`). Declare a `SpanName` enum on the event type for + while the closure hangs past it. Names resolve against `Scope.SpanName` + (defaults to `String`). Declare a `SpanName` enum on the scope namespace for compiler-checked tokens — the recommended style for structured events. Open-ended flows use `begin(for:lifetime:relaunch:)`/`end(for:exit:)`. Every span provably ends: bounded spans expire past @@ -100,13 +111,8 @@ Periscope.shared.startDefaultAmbientSources() - **Attachments** — `LogAttachment` (+ `.error`, `.json`, `.image` conveniences) rides along with any event. Blobs persist externally and load on demand. -- **Remote approval** — `remoteMessage` defaults to the stable event name and - `remoteFields` defaults empty. Fields are restricted to booleans, counts, - durations, and closed `RawRepresentable & CaseIterable` categories whose - selected value must be one of `allCases`; safe sinks never infer from the - Codable payload, tags, dynamic scopes, ambient state, external IDs, or attachments. - Debug full-metadata mode may add that context plus attachment names/MIME - types, but attachment bytes are never a remote-export input. +- **Remote approval** — a shareable field needs `.shareable` in `@LogField` and `.shared` at its call site. This is author approval. It does not inspect strings or JSON for personal data. Baseline sinks use the stable event name and `classifiedFields`. They exclude messages, restricted values, payloads, tags, dynamic scopes, ambient state, external IDs, and attachments. Debug-full sinks can encode the complete payload and context after user opt-in. They can include attachment names and MIME types. They never include attachment bytes. +- **Structured values** — `JSONValue` represents natural provider-neutral JSON. Construct it directly. For an existing `Encodable` value, use the throwing `JSONValue.encoding(_:)` helper. A shareable JSON field accepts `JSONValue` only. - **System** — `Periscope`: the recorder and `LogSink` pipeline (OSLog sink built in; `add(sink:)` returns a `SinkToken` that `remove(_:)` detaches — see [Detaching a sink](#detaching-a-sink)), level floors (`minimumLevel`, @@ -159,6 +165,10 @@ the gap (scope definitions and span began/ended pairs are exempt). Event payload so old rows outlive their Swift types — `StoredLogEvent.decode(_:)` recovers the type, and tooling degrades to raw JSON when it can't. +The classified-event migration changed the old enum payload shapes and event names. It has no decode fallback or store migration. +Historical rows remain available as raw records. Delete the pre-release development store before validating new span pairing. +Old `span-began` names do not match the new event names. + Ambient state is stamped at buffer time, not resolved at read time: the pipeline keeps the current `AmbientSnapshot` and hands each record the one in force when it was emitted. A snapshot keeps its identity until a `.state` @@ -206,6 +216,7 @@ their own sessions and leave recovery to the app's next launch. ## Contracts & limitations +- Repository code must use the macros. External clients can use direct conformances when they need the safe runtime defaults. - Messages mirror to OSLog as `.public` — keep PII out of messages, or scrub via the redaction hook. The hook may transform any record but cannot suppress span began/ended records (a stripped copy records instead — diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift index 168ad730f..bb23f46eb 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift @@ -16,7 +16,7 @@ public protocol AmbientEventSource: AnyObject, Sendable { /// filter no-op updates against its own last state before emitting /// (so a signal that re-fires without changing doesn't flood the log). /// A restart must replace the prior observation, not double it. - func start(log: Log) + func start(log: Log) /// End the observation: remove notification observers, cancel /// monitors. Nothing may keep logging (or retaining the logger's @@ -29,7 +29,7 @@ extension Periscope { /// ambient scope. public func startAmbientSource(_ source: some AmbientEventSource) { retainAmbientSource(source) - source.start(log: Log(recorder: self)) + source.start(log: Log(recorder: self)) } /// Stop and release every ambient source started on this system — the diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift index b3c07038a..d9fc5a4b8 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift @@ -27,7 +27,7 @@ public final class NetworkPathAmbientSource: AmbientEventSource { public init() {} - public func start(log: Log) { + public func start(log: Log) { let started = NWPathMonitor() started.pathUpdateHandler = { [weak self] path in self?.emit(Self.describe(path), to: log) @@ -62,21 +62,19 @@ public final class NetworkPathAmbientSource: AmbientEventSource { /// flooding the log. Exposed for tests via `@_spi(Testing)` so the /// coalescing is covered without a live monitor (an `NWPath` can't be /// constructed in a test). - @_spi(Testing) public func emit(_ value: [String: AmbientValue], to log: Log) { + @_spi(Testing) public func emit(_ value: [String: AmbientValue], to log: Log) { let changed = state.withLockUnchecked { state -> Bool in guard state.lastValue != value else { return false } state.lastValue = value return true } guard changed else { return } - log { - AmbientEvent( - kind: .restricted(.technicalState, .network), - value: .restricted(.domainValue, value), - level: .restricted(.technicalState, .info), - reporting: .restricted(.technicalState, .state), - ) - } + log.event( + 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/NotificationAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NotificationAmbientSource.swift index 777a4bf6e..99045dfe6 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/NotificationAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NotificationAmbientSource.swift @@ -21,7 +21,7 @@ import os open class NotificationAmbientSource: NSObject, AmbientEventSource, @unchecked Sendable { /// The logger handed in at `start`, read on the notification delivery /// thread; `nil` before `start`/after `stop`, which makes `emit` a no-op. - private let activeLog = OSAllocatedUnfairLock?>(uncheckedState: nil) + private let activeLog = OSAllocatedUnfairLock?>(uncheckedState: nil) override public init() { super.init() @@ -32,7 +32,7 @@ open class NotificationAmbientSource: NSObject, AmbientEventSource, @unchecked S [] } - public func start(log: Log) { + public func start(log: Log) { activeLog.withLockUnchecked { $0 = log } let center = NotificationCenter.default // Blanket-remove first so a restart re-adds rather than doubles. @@ -78,7 +78,7 @@ open class NotificationAmbientSource: NSObject, AmbientEventSource, @unchecked S /// Log `event` when started (a no-op after `stop`). public func emit(_ event: AmbientEvent) { guard let log = activeLog.withLockUnchecked({ $0 }) else { return } - log { event } + log.record(event) } @objc private func notify(_ notification: Notification) { diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift index 08234c3b9..03f924514 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift @@ -18,7 +18,7 @@ extension Log { /// 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`. + /// `Log.current`. 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 index aeafa878d..54c1a33a3 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Context/LogContext.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContext.swift @@ -25,17 +25,6 @@ public struct LogContext: Sendable { 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift index 94a36a9af..4443d1672 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift @@ -7,21 +7,11 @@ import Foundation /// and display. Each event also renders a human-readable `message` line and /// carries a `level`. /// -/// ```swift -/// struct PhotoUploaded: LogEvent { -/// var photoID: String -/// var byteCount: Int -/// var message: String { "Uploaded photo \(photoID) (\(byteCount) bytes)" } -/// } -/// ``` -/// -/// Events are emitted through a typed logger: `Log` can log -/// only `PhotoUploaded` values (plus freeform ``Message`` conveniences). -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 - /// renaming a type without overriding `eventName` orphans its history. +/// Repository events use ``LogEvent(_:level:message:version:)`` inside a +/// ``LogScope(_:)`` namespace. External clients can conform manually; the +/// defaults keep manual events safe by exporting no classified values. +public protocol LogEvent: Codable, Sendable { + /// Stable name the event persists under. static var eventName: String { get } /// Version of the payload shape, persisted alongside ``eventName`` so @@ -35,10 +25,6 @@ public protocol LogEvent: LogScopeDefinition, Codable, Sendable { /// Human-readable rendering, shown in Console.app and the log viewer. var message: String { get } - /// A deliberately PII-free rendering for approved remote export. The safe - /// default is the stable event name; events may opt into richer static copy. - var remoteMessage: String { get } - /// An identifier linking this event to the object it's about — a /// photo's URI in the local store, a Core Data managed object ID's /// URI representation — so tooling can find every event about an @@ -46,9 +32,8 @@ public protocol LogEvent: LogScopeDefinition, Codable, Sendable { /// event. Defaults to `nil`; the format is the app's to choose. var externalID: String? { get } - /// Operational fields this event explicitly approves for redacted remote - /// export. Arbitrary payload properties are never inferred or copied. - var remoteFields: [RemoteLogField] { get } + /// The compiler-checked projection approved for remote export. + var classifiedFields: [ClassifiedLogField] { get } /// Whether the overflow drop policy must keep records of this event /// under queue pressure (see @@ -60,14 +45,6 @@ public protocol LogEvent: LogScopeDefinition, Codable, Sendable { } extension LogEvent { - public static var scopeName: String { - eventName - } - - public static var eventName: String { - String(describing: Self.self) - } - public static var eventVersion: Int { 1 } @@ -84,11 +61,7 @@ extension LogEvent { nil } - public var remoteMessage: String { - Self.eventName - } - - public var remoteFields: [RemoteLogField] { + public var classifiedFields: [ClassifiedLogField] { [] } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/RemoteLogField.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/RemoteLogField.swift deleted file mode 100644 index 447ef5657..000000000 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/RemoteLogField.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -/// A named event field explicitly approved for remote diagnostic export. -public struct RemoteLogField: Equatable, Sendable { - public let key: RemoteLogFieldKey - public let value: RemoteLogFieldValue - - public init(key: RemoteLogFieldKey, value: RemoteLogFieldValue) { - self.key = key - self.value = value - } - - /// The standard closed category identifying one case of an event enum. - public static func eventKind(_ value: Value) -> Self - where Value: RawRepresentable & CaseIterable & Sendable, Value.RawValue == String - { - Self( - key: RemoteLogFieldKey("kind"), - value: .category(RemoteLogCategory(value)), - ) - } -} - -/// A structured remote-field key. Unlike a dictionary key, this keeps event -/// declarations at typed call sites and makes accidental payload export visible. -public struct RemoteLogFieldKey: Hashable, Sendable { - public let rawValue: String - - public init(_ rawValue: StaticString) { - self.rawValue = String(describing: rawValue) - } -} - -/// Values deliberately restricted to operational data that cannot carry free-form text. -public enum RemoteLogFieldValue: Equatable, Sendable { - case boolean(Bool) - case count(Int) - case durationMilliseconds(Double) - case category(RemoteLogCategory) -} - -/// A closed enum value approved by an event for remote export. -public struct RemoteLogCategory: Equatable, Sendable { - public let rawValue: String - - public init(_ value: Value) - where Value: RawRepresentable & CaseIterable & Sendable, Value.RawValue == String - { - precondition( - Value.allCases.contains { $0.rawValue == value.rawValue }, - "Remote log categories must be members of a closed CaseIterable set", - ) - rawValue = value.rawValue - } -} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift index 19e54f875..72eb98960 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift @@ -1,8 +1,8 @@ import Foundation /// A typed, hierarchical logger: a pure value that captures *where in the -/// system* events come from, and can only emit `Event` values (plus freeform -/// ``Message`` conveniences). +/// system* events come from. Macro-generated methods emit its classified +/// events. Every logger also provides freeform ``Message`` conveniences. /// /// Loggers form a tree of scopes. Calling a log with an event type derives a /// child logger typed to it; calling with an identifier derives a child scope @@ -10,10 +10,13 @@ import Foundation /// contexts: /// /// ```swift -/// let root = Log(recorder: recorder) -/// let photos = root(PhotoLogs.self) // child scope, typed PhotoLogs +/// let root = Log(recorder: recorder) +/// let photos = root(PhotoLog.self) // child scope, typed PhotoLog /// let album = photos(for: album.id) // child scope keyed by id -/// album { PhotoLogs.uploaded(photo.id) } // emits with full context +/// album.uploaded( +/// photoID: .restricted(.identifier, photo.id), +/// byteCount: .shared(.count, data.count) +/// ) /// /// let joined = album + uiLog // events reference both scopes /// ``` @@ -95,8 +98,8 @@ public struct Log: Sendable { // MARK: Retyping /// This same context — scopes, tags, recorder — retyped to emit a - /// different event type. No child scope is derived (unlike calling with - /// an event type); adapters use this to carry a context across a typed + /// a different scope type. No child scope is derived. 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 { Log(scopes: scopes, tags: tags, recorder: recorder) @@ -118,61 +121,6 @@ public struct Log: Sendable { // MARK: Emitting - /// Log a structured event with this logger's full context. - public func callAsFunction( - function: StaticString = #function, - fileID: StaticString = #fileID, - _ event: () -> Scope, - ) where Scope: LogEvent { - emit(event(), callSite: LogCallSite(function: function, fileID: fileID)) - } - - /// Log a structured event with attached data — errors, payloads, - /// screenshots (see ``LogAttachment``). - public func callAsFunction( - attachments: [LogAttachment], - function: StaticString = #function, - fileID: StaticString = #fileID, - _ event: () -> Scope, - ) where Scope: LogEvent { - emit( - event(), - attachments: attachments, - callSite: LogCallSite(function: function, fileID: fileID), - ) - } - - /// Derive the typed child scope and log one event into it, in a single - /// expression: `log(PhotoLogs.self) { PhotoLogs(photoID: id) }`. - /// - /// This overload exists because Swift resolves a *value* call's - /// arguments and trailing closure as one `callAsFunction` application — - /// unlike *type* callees (SwiftUI's Layouts), which get an implicit - /// init-then-call split. Without it, the spelling above fails to - /// compile and must be written as two statements. - public func callAsFunction( - _ type: Child.Type, - function: StaticString = #function, - fileID: StaticString = #fileID, - _ event: () -> Child, - ) { - let child: Log = callAsFunction(type) - child.emit(event(), callSite: LogCallSite(function: function, fileID: fileID)) - } - - /// Derive the entity-keyed child scope and log one event into it, in a - /// single expression: `album(for: photo.id) { .uploaded }`. Exists for - /// the same trailing-closure reason as the typed variant above. - public func callAsFunction( - for id: some Hashable & Sendable, - function: StaticString = #function, - fileID: StaticString = #fileID, - _ 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, @@ -207,8 +155,7 @@ public struct Log: Sendable { } /// Freeform logging: every `Log` can emit ``Message`` events at any level, -/// regardless of its `Event` type — the generic constraint applies to custom -/// structured events only. +/// regardless of its scope type. extension Log { public func log( _ level: LogLevel, diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift index 6685f16d3..2047613c7 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift @@ -5,8 +5,8 @@ import Testing /// A source that logs one event the moment it starts. private final class ImmediateSource: AmbientEventSource { - func start(log: Log) { - log { makeAmbientEvent(kind: AmbientKind("test-kind"), value: ["phase": "started"]) } + func start(log: Log) { + log.record(makeAmbientEvent(kind: AmbientKind("test-kind"), value: ["phase": "started"])) } func stop() {} @@ -48,7 +48,7 @@ struct AmbientEventSourceTests { #expect(sink.records.first?.message == "test-kind: phase=started") let scope = sink.records.first?.scopes.first - #expect(scope.flatMap { system.scope(for: $0) }?.name == AmbientEvent.eventName) + #expect(scope.flatMap { system.scope(for: $0) }?.name == AmbientLog.scopeName) } @Test func defaultSourcesStartAndStopWithoutIncident() async { @@ -81,7 +81,7 @@ struct AmbientEventSourceTests { @Test func restartingASourceReplacesItsObservationInsteadOfDoubling() async { let name = Notification.Name("periscope-test-\(UUID().uuidString)") let source = NotificationSource(name: name) - let log = Log(system: system) + let log = Log(system: system) source.start(log: log) source.start(log: log) diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift index 6191ff761..9c336c9d0 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift @@ -20,7 +20,7 @@ struct AmbientLogContextTests { let log = Log(system: system) await log.withContext { - Log.current.info("deep") + Log.current.info("deep") } await system.flush() @@ -32,7 +32,7 @@ struct AmbientLogContextTests { let log = Log(system: system) await log.withContext { - Log.current { PhotoLogs(photoID: "p1") } + Log.current.event(photoID: .restricted(.identifier, "p1")) } await system.flush() @@ -46,7 +46,7 @@ struct AmbientLogContextTests { await model.withContext { await ui.withContext { - Log.current.info("both") + Log.current.info("both") } } await system.flush() @@ -60,7 +60,7 @@ struct AmbientLogContextTests { await log.withContext { await log.withContext { - Log.current.info("once") + Log.current.info("once") } } await system.flush() @@ -74,7 +74,7 @@ struct AmbientLogContextTests { await log.withContext { await withTaskGroup(of: Void.self) { group in group.addTask { - Log.current.info("from child task") + Log.current.info("from child task") } await group.waitForAll() } @@ -89,7 +89,7 @@ struct AmbientLogContextTests { let log = Log(system: system) log.withContext { - Log.current.info("sync") + Log.current.info("sync") } await system.flush() @@ -102,7 +102,7 @@ struct AmbientLogContextTests { let log = Log(system: system).tagged(key, "pay_123") await log.withContext { - Log.current.info("tagged") + Log.current.info("tagged") } await system.flush() @@ -118,7 +118,7 @@ struct AmbientLogContextTests { await outer.withContext { await inner.withContext { - Log.current.info("both") + Log.current.info("both") } } await system.flush() diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift index df21842c7..9a822ccc8 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift @@ -47,9 +47,9 @@ struct LogAttachmentTests { } @Test func jsonAttachmentRoundTripsEncodableValues() throws { - let attachment = try LogAttachment.json(PhotoLogs(photoID: "p1"), name: "photo") + let attachment = try LogAttachment.json(makePhotoEvent("p1"), name: "photo") #expect(attachment.contentType == .json) - let decoded = try JSONDecoder().decode(PhotoLogs.self, from: attachment.data) + let decoded = try JSONDecoder().decode(PhotoLogs.Event.self, from: attachment.data) #expect(decoded.photoID == "p1") } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift index ae4b7d67b..f809d7f91 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift @@ -71,7 +71,7 @@ struct LogContextProvidingTests { @Test func typedConformersGetATypedLogger() async { let controller = TypedController(system: system) - controller.log { PhotoLogs(photoID: "p1") } + controller.log.event(photoID: .restricted(.identifier, "p1")) await system.flush() #expect(sink.records.map(\.message) == ["photo p1"]) diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogJournalEntryTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogJournalEntryTests.swift index c7ac95355..018cb5eae 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogJournalEntryTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogJournalEntryTests.swift @@ -21,7 +21,7 @@ struct LogJournalEntryTests { let scope = LogScope.root(named: "app") let record = LogRecord( date: Date(timeIntervalSinceReferenceDate: 123), - event: PhotoLogs(photoID: "p1"), + event: makePhotoEvent("p1"), scopes: [scope.id], tags: [LogTag(key: key, value: .int(3))], attachments: [ @@ -41,12 +41,12 @@ struct LogJournalEntryTests { #expect(back == journaled) #expect(back.sequence == 42) #expect(back.ambient?[.network] == ["status": "satisfied"]) - #expect(back.eventName == PhotoLogs.eventName) + #expect(back.eventName == PhotoLogs.Event.eventName) #expect(back.scopes == [scope.id.rawValue]) #expect(back.tags[key] == .int(3)) #expect(back.callFunction == "upload(_:)") // The payload decodes back to the typed event. - let event = try JSONDecoder().decode(PhotoLogs.self, from: back.payload) + let event = try JSONDecoder().decode(PhotoLogs.Event.self, from: back.payload) #expect(event.photoID == "p1") } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogJournalTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogJournalTests.swift index e6478068f..69ae80495 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogJournalTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogJournalTests.swift @@ -87,8 +87,8 @@ struct LogJournalTests { let journal = try LogJournal(directory: directory, session: .fixture()) system.install(journal: journal) - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"]) } + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"])) Log(system: system).error("failed while offline") let records = try entries(in: directory).compactMap { entry -> LogJournalRecord? in diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogRecordTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogRecordTests.swift index 2574fb533..ea90fc2cf 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogRecordTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogRecordTests.swift @@ -7,12 +7,12 @@ struct LogRecordTests { let scope = LogScope.root(named: "photos") let record = LogRecord( date: Date(timeIntervalSince1970: 100), - event: PhotoLogs(photoID: "p1"), + event: makePhotoEvent("p1"), scopes: [scope.id], ) #expect(record.level == .notice) #expect(record.message == "photo p1") - #expect(record.eventName == "PhotoLogs") + #expect(record.eventName == "PhotoLogs.event") #expect(record.eventVersion == 1) #expect(record.scopes == [scope.id]) } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift index 651933597..3277dafdb 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift @@ -2,15 +2,12 @@ import Foundation import PeriscopeCore import Testing -private struct DatabaseLogs: LogEvent { +@LogScope("DatabaseLogs") +private enum DatabaseLogs { enum SpanName: Hashable { case saveEvent case migration } - - var message: String { - "db" - } } private struct MeasureError: Error {} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift index 66eb5aa99..59e8d7205 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -38,7 +38,7 @@ struct LogTests { let root = Log(recorder: recorder) let photos = root(PhotoLogs.self) - photos { PhotoLogs(photoID: "p1") } + photos.event(photoID: .restricted(.identifier, "p1")) let record = try #require(recorder.records.first) #expect(record.scopes == photos.scopes.map(\.id)) @@ -55,7 +55,7 @@ struct LogTests { #expect(joined.primaryScope == model.primaryScope) #expect(joined.scopes == model.scopes + ui.scopes) - joined { PhotoLogs(photoID: "p9") } + joined.event(photoID: .restricted(.identifier, "p9")) #expect(recorder.records.last?.scopes == (model.scopes + ui.scopes).map(\.id)) } @@ -82,7 +82,7 @@ struct LogTests { @Test func typedDeriveAndEmitWorksAsOneExpression() throws { let root = Log(recorder: recorder) - root(PhotoLogs.self) { PhotoLogs(photoID: "p1") } + root(PhotoLogs.self).event(photoID: .restricted(.identifier, "p1")) let record = try #require(recorder.records.first) #expect(record.message == "photo p1") @@ -92,7 +92,7 @@ struct LogTests { @Test func keyedDeriveAndEmitWorksAsOneExpression() throws { let photos = Log(recorder: recorder)(PhotoLogs.self) - photos(for: "album-1") { PhotoLogs(photoID: "p2") } + photos(for: "album-1").event(photoID: .restricted(.identifier, "p2")) let record = try #require(recorder.records.first) #expect(record.message == "photo p2") @@ -103,7 +103,7 @@ struct LogTests { let photos = Log(recorder: recorder)(PhotoLogs.self) .tagged(LogTagKey("payment-id"), "pay_1") - let retyped = photos.retyped(to: Message.self) + let retyped = photos.retyped(to: FreeformLogScope.self) #expect(retyped.scopes == photos.scopes) #expect(retyped.tags == photos.tags) @@ -120,7 +120,10 @@ struct LogTests { data: Data([9]), ) - log(attachments: [attachment]) { PhotoLogs(photoID: "p1") } + log.event( + photoID: .restricted(.identifier, "p1"), + attachments: [attachment], + ) log.error("boom", attachments: [attachment]) log.info("bare") @@ -135,7 +138,7 @@ struct LogTests { let tagged = root.tagged(LogTagKey("payment-id"), "pay_123") tagged.info("charged") - tagged { AppLogs() } + tagged.event() #expect(recorder.records.count == 2) #expect(recorder.records.allSatisfy { $0.tags == [LogTag( @@ -167,7 +170,7 @@ struct LogTests { @Test func emitsCaptureTheirCallSite() { let log = Log(recorder: recorder) log.info("freeform") - log { AppLogs() } + log.event() for record in recorder.records { #expect(record.callSite?.function == "emitsCaptureTheirCallSite()") diff --git a/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift index 5dca03448..ca44210df 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift @@ -19,7 +19,7 @@ struct NetworkPathAmbientSourceTests { let sink = CapturingSink() let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) let source = NetworkPathAmbientSource() - let log = Log(recorder: system) + let log = Log(recorder: system) source.start(log: log) source.start(log: log) // replaces (and cancels) the first monitor @@ -34,7 +34,7 @@ struct NetworkPathAmbientSourceTests { let sink = CapturingSink() let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) let source = NetworkPathAmbientSource() - let log = Log(recorder: system) + let log = Log(recorder: system) source.emit(wifi, to: log) source.emit(wifi, to: log) // NWPathMonitor churn: dropped @@ -52,7 +52,7 @@ struct NetworkPathAmbientSourceTests { let sink = CapturingSink() let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) let source = NetworkPathAmbientSource() - let log = Log(recorder: system) + let log = Log(recorder: system) source.emit(wifi, to: log) source.emit(cellular, to: log) @@ -70,7 +70,7 @@ struct NetworkPathAmbientSourceTests { let sink = CapturingSink() let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) let source = NetworkPathAmbientSource() - let log = Log(recorder: system) + let log = Log(recorder: system) source.emit(wifi, to: log) source.stop() // clears the last-value filter diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift index 20f35fb6f..0c51b0dfe 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -297,19 +297,25 @@ func makeSpanOverdue(spanID: SpanID, name: String, budget: Duration) -> SpanOver } /// Shared fixture events used across suites. -struct AppLogs: LogEvent { - var message: String { - "app" - } +@LogScope("AppLogs") +enum AppLogs { + @LogEvent("event", message: "app") + struct Event {} } -struct PhotoLogs: LogEvent { - var photoID: String - var level: LogLevel { - .notice - } +@LogScope("PhotoLogs") +enum PhotoLogs { + @LogEvent("event", level: .notice) + struct Event { + @LogField("photo_id", exposure: .restricted, kind: .identifier) + var photoID: String - var message: String { - "photo \(photoID)" + var message: String { + "photo \(photoID)" + } } } + +func makePhotoEvent(_ photoID: String) -> PhotoLogs.Event { + PhotoLogs.Event(photoID: .restricted(.identifier, photoID)) +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index cdf2db14e..cb69868eb 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -4,6 +4,19 @@ import Testing private struct InjectedSaveFailure: Error {} +@LogScope("StoreExternalIDTest") +private enum StoreExternalIDLog { + @LogEvent("photo-uploaded", message: "uploaded") + struct PhotoUploaded { + @LogField("photo_uri", exposure: .restricted, kind: .identifier) + var photoURI: String + + var externalID: String? { + photoURI + } + } +} + struct PeriscopeStoreTests { /// A store with a small defined hierarchy: app → photos → album-1. private func makeStore() async throws -> ( @@ -53,13 +66,13 @@ struct PeriscopeStoreTests { @Test func payloadsDecodeBackToTheirEventTypes() async throws { let (store, root, _, _) = try await makeStore() await store.write([ - LogRecord(date: date(1), event: PhotoLogs(photoID: "p1"), scopes: [root.id]), + LogRecord(date: date(1), event: makePhotoEvent("p1"), scopes: [root.id]), ]) let event = try #require(try await store.events(matching: LogQuery()).first) - #expect(event.eventName == "PhotoLogs") + #expect(event.eventName == "PhotoLogs.event") #expect(event.eventVersion == 1) - #expect(try event.decode(PhotoLogs.self).photoID == "p1") + #expect(try event.decode(PhotoLogs.Event.self).photoID == "p1") } @Test func minimumLevelFiltersBySeverity() async throws { @@ -186,11 +199,11 @@ struct PeriscopeStoreTests { let (store, root, _, _) = try await makeStore() await store.write([ makeRecord("plain", date: date(1), scopes: [root.id]), - LogRecord(date: date(2), event: PhotoLogs(photoID: "p1"), scopes: [root.id]), + LogRecord(date: date(2), event: makePhotoEvent("p1"), scopes: [root.id]), ]) var query = LogQuery() - query.eventName = PhotoLogs.eventName + query.eventName = PhotoLogs.Event.eventName let events = try await store.events(matching: query) #expect(events.map(\.message) == ["photo p1"]) } @@ -603,27 +616,20 @@ struct PeriscopeStoreTests { } @Test func externalIDsPersistAndFilter() async throws { - struct PhotoUploaded: LogEvent { - var photoURI: String - var message: String { - "uploaded" - } - - var externalID: String? { - photoURI - } - } - let (store, root, _, _) = try await makeStore() await store.write([ LogRecord( date: date(1), - event: PhotoUploaded(photoURI: "photos://p1"), + event: StoreExternalIDLog.PhotoUploaded( + photoURI: .restricted(.identifier, "photos://p1"), + ), scopes: [root.id], ), LogRecord( date: date(2), - event: PhotoUploaded(photoURI: "photos://p2"), + event: StoreExternalIDLog.PhotoUploaded( + photoURI: .restricted(.identifier, "photos://p2"), + ), scopes: [root.id], ), makeRecord("no object", date: date(3), scopes: [root.id]), @@ -897,8 +903,8 @@ struct PeriscopeStoreTests { let system = Periscope(configuration: Periscope.Configuration(), sinks: []) system.add(sink: store) - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .powerMode, value: ["low-power": true]) } + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .powerMode, value: ["low-power": true])) Log(system: system).error("slow while saving battery") await system.flush() @@ -1074,7 +1080,7 @@ struct PeriscopeStoreTests { let system = Periscope(configuration: Periscope.Configuration(), sinks: [store]) let photos = Log(system: system)(PhotoLogs.self) - photos { PhotoLogs(photoID: "p1") } + photos.event(photoID: .restricted(.identifier, "p1")) photos.warning("degraded") await system.flush() diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 19a45250c..0d6fd3dbc 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -3,6 +3,14 @@ import os @_spi(Testing) import PeriscopeCore import Testing +@LogScope("AuditTest") +private enum AuditTestLog { + @LogEvent("entry", message: "audit-entry") + struct Entry { + static let isProtectedFromDropping = true + } +} + struct PeriscopeTests { let sink = CapturingSink() @@ -345,7 +353,7 @@ struct PeriscopeTests { log.debug("quiet") log.info("quiet") log.warning("loud") - log { AppLogs() } // AppLogs is .info — below the floor. + log.event() // AppLogs is .info — below the floor. await system.flush() #expect(sink.records.map(\.message) == ["loud"]) @@ -551,7 +559,7 @@ struct PeriscopeTests { let log = Log(system: system) log.debug("filtered freeform") - log { AppLogs() } // .info structured event — filtered in record() + log.event() // .info structured event — filtered in record() log.warning("admitted") await system.flush() @@ -930,13 +938,6 @@ struct PeriscopeTests { } @Test func customEventsCanOptIntoDropProtection() async throws { - struct AuditEvent: LogEvent { - static let isProtectedFromDropping = true - var message: String { - "audit-entry" - } - } - let gate = GateSink() let system = Periscope( configuration: Periscope.Configuration(pendingBufferCapacity: 3), @@ -948,7 +949,7 @@ struct PeriscopeTests { let drainBlocked = await waitUntil { gate.batchCount >= 1 } try #require(drainBlocked) - log(AuditEvent.self) { AuditEvent() } + log(AuditTestLog.self).entry() for index in 1 ... 5 { log.info("r\(index)") } @@ -1169,8 +1170,8 @@ struct PeriscopeTests { @Test func ambientStateStampsOntoEverySubsequentRecord() async throws { let system = makeSystem() - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .network, value: ["status": "satisfied"])) Log(system: system).info("after") await system.flush() @@ -1182,9 +1183,9 @@ struct PeriscopeTests { /// replaced — otherwise the event and its own snapshot disagree. @Test func anAmbientEventCarriesTheStateItAnnounces() async { let system = makeSystem() - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .thermalState, value: ["level": "nominal"]) } - ambient { makeAmbientEvent(kind: .thermalState, value: ["level": "serious"]) } + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .thermalState, value: ["level": "nominal"])) + ambient.record(makeAmbientEvent(kind: .thermalState, value: ["level": "serious"])) await system.flush() let changes = sink.records.filter { $0.eventName == AmbientEvent.eventName } @@ -1196,16 +1197,16 @@ struct PeriscopeTests { @Test func momentaryAmbientEventsDoNotStickToLaterRecords() async throws { let system = makeSystem() - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } - ambient { + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .network, value: ["status": "satisfied"])) + ambient.record( makeAmbientEvent( kind: .memory, value: ["pressure": "warning"], level: .warning, reporting: .occurrence, - ) - } + ), + ) Log(system: system).info("after") await system.flush() @@ -1218,11 +1219,11 @@ struct PeriscopeTests { /// or the store would write a row per repeat instead of per state. @Test func unchangedAmbientStateReusesOneSnapshotIdentity() async { let system = makeSystem() - let ambient = Log(system: system) + let ambient = Log(system: system) let log = Log(system: system) - ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } + ambient.record(makeAmbientEvent(kind: .network, value: ["status": "satisfied"])) log.info("one") - ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } + ambient.record(makeAmbientEvent(kind: .network, value: ["status": "satisfied"])) log.info("two") await system.flush() @@ -1233,8 +1234,8 @@ struct PeriscopeTests { @Test func spanRecordsCarryAmbientState() async { let system = makeSystem() - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .powerMode, value: ["low-power": true]) } + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .powerMode, value: ["low-power": true])) Log(system: system).measure("work") {} await system.flush() @@ -1253,8 +1254,8 @@ struct PeriscopeTests { sinks: [gate, sink], ) let log = Log(system: system) - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"]) } + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"])) log.info("r0") let drainBlocked = await waitUntil { gate.batchCount >= 1 } @@ -1273,8 +1274,8 @@ struct PeriscopeTests { @Test func liveObserversSeeTheStampedRecord() async throws { let system = makeSystem() - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .network, value: ["status": "satisfied"])) let records = system.liveRecords() Log(system: system).info("live") @@ -1288,12 +1289,12 @@ struct PeriscopeTests { /// carry the state the discarded event replaced. @Test func flooredAmbientEventsStillFoldIntoTheSnapshot() async throws { let system = makeSystem() - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .network, value: ["status": "satisfied"]) } + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .network, value: ["status": "satisfied"])) system.minimumLevel = .warning // .info — floored. - ambient { makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"]) } + ambient.record(makeAmbientEvent(kind: .network, value: ["status": "unsatisfied"])) Log(system: system).warning("after") await system.flush() @@ -1315,12 +1316,12 @@ struct PeriscopeTests { }), sinks: [sink], ) - let ambient = Log(system: system) - ambient { makeAmbientEvent(kind: .network, value: ["ssid": "wifi-public"]) } - ambient { makeAmbientEvent(kind: .thermalState, value: ["level": "nominal"]) } + let ambient = Log(system: system) + ambient.record(makeAmbientEvent(kind: .network, value: ["ssid": "wifi-public"])) + ambient.record(makeAmbientEvent(kind: .thermalState, value: ["level": "nominal"])) // Suppressed by the redaction hook. - ambient { makeAmbientEvent(kind: .network, value: ["ssid": "wifi-secret"]) } + ambient.record(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 d607ab995..c06ea5c48 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -10,8 +10,8 @@ struct StoredLogEventTests { date: Date(timeIntervalSinceReferenceDate: 100), sequence: 0, level: .notice, - eventName: PhotoLogs.eventName, - eventVersion: PhotoLogs.eventVersion, + eventName: PhotoLogs.Event.eventName, + eventVersion: PhotoLogs.Event.eventVersion, message: "photo p1", payload: payload, scopes: [scope.id], @@ -27,10 +27,10 @@ struct StoredLogEventTests { } @Test func decodeRecoversTheOriginalEvent() throws { - let payload = try JSONEncoder().encode(PhotoLogs(photoID: "p1")) + let payload = try JSONEncoder().encode(makePhotoEvent("p1")) let stored = makeStored(payload: payload) - let decoded = try stored.decode(PhotoLogs.self) + let decoded = try stored.decode(PhotoLogs.Event.self) #expect(decoded.photoID == "p1") } @@ -42,7 +42,7 @@ struct StoredLogEventTests { let stored = makeStored(payload: payload) #expect(throws: (any Error).self) { - try stored.decode(PhotoLogs.self) + try stored.decode(PhotoLogs.Event.self) } } diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md index e2e8c1de6..83d686ebc 100644 --- a/Shared/Periscope/PeriscopeTools/README.md +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -44,7 +44,7 @@ Toggle("Log View Mode", isOn: $inspector.isEnabled) - **Logs** — newest-first list over a `PeriscopeStore`, searchable, filterable by level / event type / scope subtree / session / span exit, paged, with exit-mode chips on span rows, per-event detail (exit + reason, payload JSON, tags, attachments, and the **ambient state** the event was stamped with), NDJSON export (ambient state included, headed by one `"record": "session"` line per referenced session carrying its build attributes), and a comfortable/compact **row-density** picker (persisted). The session filter names each session by its build — commit and optimization level when the session recorded them, so weeks-old logs can be tied to the code that produced them. - **Hierarchy** — the scope tree (see `LogHierarchyView`). -- **`LogHierarchyView(store:)`** — the scope-tree browser: the store's `LogScope` hierarchy (the tree the `Log` API builds in code) as an expandable outline with per-scope subtree counts. +- **`LogHierarchyView(store:)`** — the scope-tree browser: the store's `LogScope` hierarchy (the tree the `Log` API builds in code) as an expandable outline with per-scope subtree counts. Tapping a scope drills into its subtree's events with rows indented to mirror the nesting. Shown as the viewer's Hierarchy surface, and usable standalone. - **`LogTraceView(store:origin:)`** — the tracer: from one event (typically an error), shows the trail that led up to it — earlier events in the subtrees of all its (linked) scopes, events logged at ancestor scopes on the way up the tree (never siblings), and its span pair — newest first. 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 68061f4f5..045d68a09 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:d05ab6bf4f3ab4d3cf9b285dff26cb31a4916ca65e8dd524872948a85006cd3b -size 271747 +oid sha256:83329d7241a85c000f3063db32786b34abc4d9976693ae7f047bb738219cd8af +size 273927 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 395d954da..3868c03d2 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:d538f5bb9468470cd2102bae0bdc34651f55854817b714867a9a59397fbe5810 -size 275214 +oid sha256:7e558a416c7f278904d2226d10d7a2a0af9c8f3515342a36e0fbf37bf83578a6 +size 277318 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/NDJSONExporterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift index 72eefdf4d..a26fd88e2 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift @@ -107,7 +107,7 @@ struct NDJSONExporterTests { } @Test func linesCarryTheEventFields() throws { - let payload = try JSONEncoder().encode(PhotoLogs(photoID: "p1")) + let payload = try JSONEncoder().encode(makePhotoEvent("p1")) let line = NDJSONExporter.line( for: stored( message: "hello", @@ -128,7 +128,7 @@ struct NDJSONExporterTests { #expect(object["scopePath"] as? String == "app/photos") #expect(object["session"] as? String == sessionID.uuidString) #expect((object["tags"] as? [String: String])?["payment-id"] == "pay_1") - #expect((object["payload"] as? [String: Any])?["photoID"] as? String == "p1") + #expect((object["payload"] as? [String: Any])?["photo_id"] as? String == "p1") } @Test func linesCarryTheSpanExitWhenPresent() throws { diff --git a/Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift b/Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift index 1e0bf97cb..c0d887f34 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift @@ -6,11 +6,8 @@ import TestHostSupport import Testing import UIKit -private struct AppLogs: LogEvent { - var message: String { - "app" - } -} +@LogScope("AppLogs") +private enum AppLogs {} @MainActor struct OpenSpansViewHostingTests { diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift index e99226d56..a4cb0ac76 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift @@ -13,11 +13,8 @@ private final class CapturingAlertHandler: PeriscopeAlertHandler { } /// A fixture event for alerter routing. -private struct AppLogs: LogEvent { - var message: String { - "app" - } -} +@LogScope("AppLogs") +private enum AppLogs {} @MainActor struct PeriscopeAlerterTests { diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift index 744e02015..8592f1e1f 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift @@ -4,13 +4,22 @@ import TestHostSupport import UIKit /// Shared fixture event for the tools suites. -struct PhotoLogs: LogEvent { - var photoID: String - var message: String { - "photo \(photoID)" +@LogScope("PhotoLogs") +enum PhotoLogs { + @LogEvent("event") + struct Event { + @LogField("photo_id", exposure: .restricted, kind: .identifier) + var photoID: String + var message: String { + "photo \(photoID)" + } } } +func makePhotoEvent(_ photoID: String) -> PhotoLogs.Event { + PhotoLogs.Event(photoID: .restricted(.identifier, photoID)) +} + /// A deterministic session for store-backed tests. func makeSession( id: UUID = UUID(), diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift index e9859f1bb..4cf78e64f 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift @@ -136,13 +136,13 @@ struct PeriscopeViewerModelTests { let (store, root, _, _) = try await makeSeededStore() await store.write([ makeRecord("plain", date: date(1), scopes: [root.id]), - LogRecord(date: date(2), event: PhotoLogs(photoID: "p1"), scopes: [root.id]), + LogRecord(date: date(2), event: makePhotoEvent("p1"), scopes: [root.id]), ]) let model = PeriscopeViewerModel(store: store) await model.load() - #expect(model.eventNames == ["PhotoLogs", "message"].sorted()) + #expect(model.eventNames == ["PhotoLogs.event", "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/Shared/Periscope/PeriscopeUI/README.md b/Shared/Periscope/PeriscopeUI/README.md index 9ff870262..a0361f444 100644 --- a/Shared/Periscope/PeriscopeUI/README.md +++ b/Shared/Periscope/PeriscopeUI/README.md @@ -24,13 +24,13 @@ PhotoDetailView() .logContext(screenLog) // or any Log value struct PhotoDetailView: View { - @Environment(\.logContext) private var log + @Environment(\.logContext) private var logContext var body: some View { Button("Save") { - log.info("save tapped") // freeform, full context - let photos = log(PhotoLogs.self) // or derive typed loggers - photos { PhotoLogs.saved } + logContext.info("save tapped") + let photos = logContext(PhotoLogs.self) + photos.saved(photoID: .restricted(.identifier, photo.id)) } } } @@ -38,16 +38,16 @@ struct PhotoDetailView: View { ## Public API -- `View.logContext(_ log: Log)` — contribute a logger's scopes and tags to descendants. +- `View.logContext(_ log: Log)` — contribute a logger's scopes and tags to descendants. - `View.logContext(_ provider: some LogContextProviding)` — contribute a model object's instance context directly. -- `EnvironmentValues.logContext: Log` — the accumulated context. - Falls back to a root logger on `Periscope.shared` outside any modifier. +- `EnvironmentValues.logContext: LogContext` — the type-erased accumulated context. + It falls back to a freeform context on `Periscope.shared` outside any modifier. ## How it works Each `logContext` modifier **links** its context onto whatever enclosing modifiers already contributed (`Log.linked(with:)` semantics). Stacking modifiers unions scopes and merges tags, with the nearest modifier primary. -The environment value is a plain `Log`. +The stored accumulator is optional. This prevents the fallback freeform scope from linking into an explicit context. Deriving typed loggers or emitting events goes through the normal PeriscopeCore API, so nothing here duplicates logging behavior. ## Testing diff --git a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift index 502b7b311..0290570aa 100644 --- a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift +++ b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift @@ -23,7 +23,7 @@ private struct TypedProbe: View { var body: some View { Color.clear.onAppear { - log(PhotoLogs.self) { PhotoLogs(photoID: "p1") } + log(PhotoLogs.self).event(photoID: .restricted(.identifier, "p1")) } } } diff --git a/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUITestSupport.swift b/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUITestSupport.swift index e9900947e..6b7bce0bc 100644 --- a/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUITestSupport.swift +++ b/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUITestSupport.swift @@ -1,16 +1,21 @@ import PeriscopeCore /// Shared fixture events for the UI suites. -struct AppLogs: LogEvent { - var message: String { - "app" - } +@LogScope("AppLogs") +enum AppLogs { + @LogEvent("event", message: "app") + struct Event {} } -struct PhotoLogs: LogEvent { - var photoID: String - var message: String { - "photo \(photoID)" +@LogScope("PhotoLogs") +enum PhotoLogs { + @LogEvent("event") + struct Event { + @LogField("photo_id", exposure: .restricted, kind: .identifier) + var photoID: String + var message: String { + "photo \(photoID)" + } } } diff --git a/Shared/Periscope/README.md b/Shared/Periscope/README.md index dee06b7eb..965015f31 100644 --- a/Shared/Periscope/README.md +++ b/Shared/Periscope/README.md @@ -1,7 +1,7 @@ # Periscope Periscope is a typed, hierarchical observability stack. -It provides structured `Codable` log events emitted through typed loggers (`Log`) arranged in a scope tree. +It provides classified `Codable` events through scope-typed loggers (`Log`). Events are timed with spans, persisted to SwiftData so days of history stay queryable on device, and browsable from inside the app. Each module has its own `README.md` with the narrative and API. diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 3fcedb78e..a2fc33aa5 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -29,14 +29,16 @@ here. - refactor(PeriscopeTools) [quick-win]: `ScopeEventsView` (`:38`), `LogInspectorView` (`LogInspectable.swift:89`), `SpanTreeView` (`:25`), and `SpanHistoryView` (`:32`) seed density from `.load(from: .standard)` directly, bypassing the injectable `defaults` only `PeriscopeViewer` threads through (`PeriscopeViewer.swift:17`, `:58-63`) — so those surfaces can't be pointed at an ephemeral test suite and always touch the shared standard domain. It also means a drill-in *overrides* the density the viewer already seeded rather than inheriting it. Thread `defaults` through, or read the density from the environment. (pr#107 review; was nested under the `SpanTreeRow` density no-op, closed 2026-07-28) - perf(PeriscopeTools) [needs-design]: The incremental *fetch* is bounded per commit, but `SpanTreeModel.load` (`SpanTreeModel.swift:153`) / `LogHierarchyModel.load` (`LogHierarchyModel.swift:75`) still rebuild the whole tree/forest from all accumulated events on every `changes()` ping — O(total spans) per commit for a long-lived viewer over a busy store. Rebuild incrementally or throttle rebuilds. (pr#107 review) - perf(PeriscopeTools) [needs-design]: `LogInspectorModel` didn't get even the bounded fetch — it re-runs its full subtree query on every `changes()` ping with no `afterSequence` cursor (`LogInspectorModel.swift:42-48`, `:51-64`), so an open inspect sheet over a busy store re-reads everything per commit. Give it the same cursor, or debounce it. (audit 2026-07-26) -- refactor(PeriscopeCore) [needs-design]: Reconsider the `callAsFunction` scope-derivation API. `log(SomeLog.self)` / `log(for: id)` derivation reads as an opaque function call at declaration sites; a named form (`log.scope(SomeLog.self)` / `log.subcatalog(for: id)` / `log.child(_:)`) would read clearer. Constraint: the one-expression derive-and-emit (`log(PhotoLogs.self) { … }`) exists *because* `callAsFunction` lets Swift resolve the type arg + trailing closure as one application — a named method splits it, so the emit ergonomics need a paired design (a method that also takes the trailing closure) before renaming. Affects every derivation call site + all Periscope consumers. (pr#94 review) -- feat(PeriscopeCore) [quick-win]: Add non-closure emit overloads alongside the `{}` form. Today emit is only `log { .event }` / `log(attachments:) { .event }`; the closure is nice for multi-line payload builds but heavy for a bare event. Add a value form — either `log.emit(.event)` (named, no overload ambiguity) or a `log(.event)` value overload — keeping `{}` for multi-line. Additive; pairs with the derivation-naming item above. (pr#94 review) +- refactor(PeriscopeCore) [needs-design]: Reconsider the `callAsFunction` scope-derivation API. `log(SomeLog.self)` / `log(for: id)` derivation reads as an opaque function call at declaration sites; a named form (`log.scope(SomeLog.self)` / `log.subcatalog(for: id)` / `log.child(_:)`) would read clearer. Generated methods now own event emission, so emission no longer constrains this choice. Affects every derivation call site and all Periscope consumers. (pr#94 review; updated by classified-event migration) - feat(PeriscopeTools) [needs-design]: Inspect-by-object is scope-granular, not instance-granular. `.logInspectable(_:)` keys the badge/inspector to a `Log`'s *scope*, so tagging a list row (Where tags `EvidenceRow` with `WhereLog.evidence`, `LocationStatusRow` with `WhereLog.session`) surfaces the whole scope's recent events, not that one row's. Events already carry `externalID` for object correlation, but the inspector can't filter by it — a per-instance child scope (blocked on the `LogContextProviding` parent-hierarchy P0) or an `externalID`-scoped inspect entry would make true row-/object-level inspection work. (pr#94 review) - design(PeriscopeCore) [needs-design]: No eager store handle — `PeriscopeStore.make` being `async` forces an "optional store, observe until it lands" dance on consumers. Where exposes an `Optional` on `WhereModel` that stays `nil` until the bootstrap `Task` completes, and `RootView` has to watch the transition (`.onChange` of the store identity) to wire the viewer/inspector/alerter. A synchronous pending-store handle (usable immediately, resolves in the background) or an `await`-readiness accessor would remove the optional-and-observe boilerplate every app repeats. (agent) - test(PeriscopeTools) [needs-design]: broken-snapshots — replace the hosting smoke tests with image snapshots. **Twenty** tests across **ten** files assert nothing but "the hosted view reached a window" (re-counted 2026-08-09; filed as eighteen across nine, and it has *grown* rather than shrunk — PR #152 added a tenth file): `#expect(await waitUntil { host.view.window != nil })` in `LogEventListTests.swift:30`, `:41`, `LogHierarchyViewHostingTests.swift:23`, `:34`, `PeriscopeViewerHostingTests.swift:29`, `:42`, `ScopeEventsViewHostingTests.swift:25`, `:38`, `SpanHistoryViewHostingTests.swift:23`, `:34`, `SpanTreeViewHostingTests.swift:26`, `:37`, `LogEventDetailViewHostingTests.swift:30`, `:43`, and the `try waitFor { host.view.window != nil }` spelling in `LogInspectableHostingTests.swift:25`, `:38`, `:50`, `LogTraceViewHostingTests.swift:23`, `OpenSpansViewHostingTests.swift:27`, `:38`. The predicate restates what `show`/`showHosted` already guarantee, so each test proves only that construction didn't crash — never what rendered, which is the part the elaborate seeding sets up (`LogHierarchyView`'s outline, the comfortable density `PeriscopeViewerHostingTests` injects, the "No Events" state `ScopeEventsViewHostingTests` documents at `:29`). The repo convention is now that an image bundle, not a hosting smoke test, owns "does this screen render" (see [`Where/WhereUI/AGENTS.md`](../../Where/WhereUI/AGENTS.md#testing) and the WhereUI suite that replaced its own smoke tests). Convert them to image snapshots over the same seeded stores, keeping any assertion that isn't the window check and deleting the files left empty. **The plumbing is already in place**: [`SnapshotTests/`](PeriscopeTools/SnapshotTests) exists and `PeriscopeViewerSnapshotTests` is *still* the only file in it (2026-08-09), so none of the conversion has happened; the bundle and its `SnapshotKitTesting` link are wired at `Project.swift:619-623` — add a file per view beside it, and it compiles into the module's own `PeriscopeToolsSnapshotTests` bundle (one image bundle per module, gathered into the shared `StuffSnapshotTests` scheme — root [`AGENTS.md`](../../AGENTS.md#targets)) while recording references here. The remaining work is per-view authoring, not wiring: each view needs a deterministic fixture (a frozen store, as `PeriscopeViewerSnapshotTests` does) and ideally a `SnapshotProviding` conformance in its own source file — which needs a `SnapshotKit` dependency on PeriscopeTools, since the module has no `#Preview`s at all today. `OpenSpansView` is the one view with a genuine determinism problem: its `TimelineView(.periodic(from: .now, by: 1))` ticking ages (`OpenSpansView.swift:19`) need the `\.isCapturingSnapshot` treatment. (Note the two `window != nil` checks in `Shared/LifecycleKit/Tests/` are *not* in scope: they assert the hosting helper's own lifecycle contract, which is the one place the check is the point. Inspector carries the same hosting-smoke debt — see [`Shared/Inspector/TODOs.md`](../Inspector/TODOs.md).) (pr#101 review) # Completed issues +## Classified event authoring +- feat(PeriscopeCore): Generated event methods replace closure-only emission. Each `@LogEvent` emits through a named method with compiler-checked classified inputs. This supersedes the non-closure emit-overload item. (classified-event migration) + ## Ambient state snapshots, build-attributed sessions, span-tooling honesty (2026-07-28) - design(PeriscopeCore): Ambient state snapshots — every record is now stamped with an `AmbientSnapshot`, the latest value of every stateful `AmbientKind` as of that moment, so any event joins to the connectivity/thermal/power/lifecycle state at the time. Three parts made it work: `AmbientEvent.reporting` (`.state` vs `.occurrence`) so a momentary signal like a memory warning can't become a lasting claim; the pipeline folding `.state` events into one snapshot whose identity only changes when a value actually moves; and a `SDAmbientSnapshot` row per *distinct* state, referenced by ID, so the storage cost is the number of states rather than the number of events. Carried through the crash journal (`LogJournalRecord.ambient`) and surfaced in event detail plus the NDJSON export. Thermal state and low-power mode now also report at `started()`, so their state isn't unknown until it next changes; `AppLifecycleAmbientSource` deliberately doesn't, and a test says so. (was the P0 above) - docs(PeriscopeCore) [quick-win]: The pre-attach gap is now stated in `PeriscopeCore/README.md`'s crash-durability section — the journal opens with the store, so records emitted before `add(sink:)` reach neither. (Was nested under the "never drop the pre-store-attach window" P0, which stays open; only the doc claim is fixed.) diff --git a/Where/AGENTS.md b/Where/AGENTS.md index 0d0c8a29c..13364cae0 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -49,8 +49,7 @@ Rules the code enforces and agents must preserve: refresh inline. Launch is a typed [`LifecycleKit`](../Shared/LifecycleKit) `LaunchPlan` (`WhereLaunch` in WhereUI). It renders in [`LifecycleKitUI`](../Shared/LifecycleKitUI)'s container in `RootView`. -- **All logging goes through [Periscope](../Shared/Periscope).** Use typed - `LogEvent`s off the `WhereLog` facade. Never use a raw string. Each module +- **All logging goes through [Periscope](../Shared/Periscope).** Define `@LogScope` namespaces and nested `@LogEvent` structs. Classify each payload property with `@LogField`. Emit it with a matching `.shared` or `.restricted` input. Use the `WhereLog` facade. Never write a direct `LogEvent` conformance or use a raw string. Each module keeps its `*Log.swift` event types in its `Sources/Logging/` folder. Not re-derivable from source: events log `.public`, so **keep PII out**. `info` = important success. `warning` = degraded-but-handled. `error`/`fault` = diff --git a/Where/RegionKit/Sources/Logging/RegionAttributorLog.swift b/Where/RegionKit/Sources/Logging/RegionAttributorLog.swift index 42b194d28..8bae573b2 100644 --- a/Where/RegionKit/Sources/Logging/RegionAttributorLog.swift +++ b/Where/RegionKit/Sources/Logging/RegionAttributorLog.swift @@ -1,24 +1,10 @@ import PeriscopeCore -/// Structured events for `RegionAttributor`'s per-region geometry load. Missing -/// or corrupt bundled geometry is a programmer error, so those cases log at -/// `.fault` (paired with a debug `assertionFailure`); the region id rides on -/// `externalID` so the tooling can pull every event about one region. -enum RegionAttributorLog: LogEvent { - /// Names the loader's timed spans. Building an attributor parses one GeoJSON - /// file per region, which is the most expensive thing RegionKit does — and it - /// happens on the launch's critical path (and again whenever the tracked set - /// changes), so both the whole load and each region's share of it are timed. - /// - /// `description` is spelled out because ``loadRegion(_:)`` carries a region: - /// reflection would render it `loadRegion(RegionKit.Region(rawValue: "us-CA"))`, - /// which is both unreadable and a Swift-internal shape in a name the tools - /// group timings by. +/// Structured events and spans for `RegionAttributor`. +@LogScope("RegionAttributor") +enum RegionAttributorLog { enum SpanName: Hashable, CustomStringConvertible { - /// Loading every region an attributor was built for. case loadPolygons - /// One region's GeoJSON read + decode, so a slow load attributes to the - /// region whose geometry is heavy rather than to the set. case loadRegion(Region) var description: String { @@ -29,56 +15,53 @@ enum RegionAttributorLog: LogEvent { } } - /// The manifest names a geometry file the bundle doesn't contain. - case missingGeometry(region: Region) - /// The region's GeoJSON decoded to zero polygons. - case emptyPolygons(region: Region) - /// The region's GeoJSON failed to decode. - case decodeFailed(region: Region, description: String) - /// Finished loading polygons for `regionCount` regions. - case loaded(regionCount: Int) - - static let eventName = "RegionAttributor" + @LogEvent("missing-geometry", level: .fault) + struct MissingGeometry { + @LogField("region", exposure: .restricted, kind: .location) + var region: Region + var message: String { + "Missing bundled GeoJSON for region \(region.rawValue)" + } - var level: LogLevel { - switch self { - case .missingGeometry, .emptyPolygons, .decodeFailed: .fault - case .loaded: .info + var externalID: String? { + region.regionURL.absoluteString } } - var message: String { - switch self { - case let .missingGeometry(region): - "Missing bundled GeoJSON for region \(region.rawValue)" - case let .emptyPolygons(region): - "Region \(region.rawValue) decoded no polygons" - case let .decodeFailed(region, description): - "Failed to decode bundled GeoJSON for region \(region.rawValue): \(description)" - case let .loaded(regionCount): - "Loaded region polygons for \(regionCount) region(s)" + @LogEvent("empty-polygons", level: .fault) + struct EmptyPolygons { + @LogField("region", exposure: .restricted, kind: .location) + var region: Region + var message: String { + "Region \(region.rawValue) decoded no polygons" + } + + var externalID: String? { + region.regionURL.absoluteString } } - var externalID: String? { - switch self { - case let .missingGeometry(region), let .emptyPolygons(region), - let .decodeFailed(region, _): - region.regionURL.absoluteString - case .loaded: - nil + @LogEvent("decode-failed", level: .fault) + struct DecodeFailed { + @LogField("region", exposure: .restricted, kind: .location) + var region: Region + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to decode bundled GeoJSON for region \(region.rawValue): \(description)" + } + + var externalID: String? { + region.regionURL.absoluteString } } - var remoteFields: [RemoteLogField] { - switch self { - case let .loaded(regionCount): - [RemoteLogField( - key: RemoteLogFieldKey("region_count"), - value: .count(regionCount), - )] - case .missingGeometry, .emptyPolygons, .decodeFailed: - [] + @LogEvent("loaded") + struct Loaded { + @LogField("region_count", exposure: .shareable, kind: .count) + var regionCount: Int + var message: String { + "Loaded region polygons for \(regionCount) region(s)" } } } diff --git a/Where/RegionKit/Sources/Logging/RegionCatalogLog.swift b/Where/RegionKit/Sources/Logging/RegionCatalogLog.swift index ee66e964b..1f1f2ce00 100644 --- a/Where/RegionKit/Sources/Logging/RegionCatalogLog.swift +++ b/Where/RegionKit/Sources/Logging/RegionCatalogLog.swift @@ -1,53 +1,36 @@ import PeriscopeCore -/// Structured events for `RegionCatalog`'s bundled-manifest load. A missing or -/// unparseable `regions.json` is a programmer error (corrupt bundled resource), -/// so those cases log at `.fault` to match the paired `assertionFailure`. -enum RegionCatalogLog: LogEvent { - /// Names the catalog's timed span. +/// Structured events for `RegionCatalog`'s bundled-manifest load. +@LogScope("RegionCatalog") +enum RegionCatalogLog { enum SpanName: Hashable { - /// Reading and decoding the bundled manifest. Happens once per process, - /// lazily, on whichever thread first touches `RegionCatalog.shared` — - /// usually the launch — so it's worth knowing what it costs there. case loadManifest } - /// The bundled `regions.json` manifest is absent from the bundle. - case missingManifest - /// The manifest decoded successfully into `regionCount` entries. - case loaded(regionCount: Int) - /// The manifest was present but could not be decoded. - case decodeFailed(description: String) + @LogEvent( + "missing-manifest", + level: .fault, + message: "Missing required bundled regions.json manifest", + ) + struct MissingManifest {} - static let eventName = "RegionCatalog" + @LogEvent("loaded", level: .info) + struct Loaded { + @LogField("region_count", exposure: .shareable, kind: .count) + var regionCount: Int - var level: LogLevel { - switch self { - case .missingManifest, .decodeFailed: .fault - case .loaded: .info + var message: String { + "Loaded region catalog with \(regionCount) region(s)" } } - var message: String { - switch self { - case .missingManifest: - "Missing required bundled regions.json manifest" - case let .loaded(regionCount): - "Loaded region catalog with \(regionCount) region(s)" - case let .decodeFailed(description): - "Failed to decode bundled regions.json: \(description)" - } - } + @LogEvent("decode-failed", level: .fault) + struct DecodeFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - var remoteFields: [RemoteLogField] { - switch self { - case let .loaded(regionCount): - [RemoteLogField( - key: RemoteLogFieldKey("region_count"), - value: .count(regionCount), - )] - case .missingManifest, .decodeFailed: - [] + var message: String { + "Failed to decode bundled regions.json: \(description)" } } } diff --git a/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift b/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift index 4d9ae7f64..2a8f28e96 100644 --- a/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift +++ b/Where/RegionKit/Sources/Logging/RegionGeometryCatalogLog.swift @@ -1,23 +1,10 @@ import PeriscopeCore -/// Structured events for drawable geometry loads. A failed developer-viewer -/// load is degraded-but-handled and logs at `.warning`; a missing production -/// artwork resource is a bundled-data invariant and logs at `.fault`. Public -/// because the UI consumers live above RegionKit and emit through -/// ``RegionLog/geometryCatalog``. -public enum RegionGeometryCatalogLog: LogEvent { - /// Names the catalog's timed span. - /// `Sendable` is spelled out because this is a `public` nested type — unlike - /// the internal `SpanName`s elsewhere, it gets no inferred conformance, and - /// `LogEvent.SpanName` requires one. +/// Structured events for drawable geometry loads. +@LogScope("RegionGeometryCatalog") +public enum RegionGeometryCatalogLog { public enum SpanName: Hashable, Sendable, CustomStringConvertible { - /// The `.source` build: decoding *every* catalog region's GeoJSON at full - /// authored fidelity, which is far heavier than attribution's tracked - /// subset. Runs once per process behind the cache actor, so this span is - /// what the viewer's first toggle to source actually costs. case buildSourceOutlines - /// The first request for one region's drawable outlines. Later requests - /// reuse the per-region cache. case loadRegionOutlines(Region) public var description: String { @@ -29,36 +16,33 @@ public enum RegionGeometryCatalogLog: LogEvent { } } - /// Loading the outlines for a `RegionGeometryKind` failed. - case loadFailed(kind: String, description: String) - /// Loading the bundled outlines used by region-specific artwork failed. - /// Bundled geometry is a programmer-owned invariant, so this is a fault. - case regionLoadFailed(region: Region, description: String) + @LogEvent("load-failed", level: .warning) + public struct LoadFailed { + @LogField("kind", exposure: .restricted, kind: .technicalState) + public var kind: String - public static let eventName = "RegionGeometryCatalog" + @LogField("description", exposure: .restricted, kind: .errorDetails) + public var description: String - public var level: LogLevel { - switch self { - case .loadFailed: .warning - case .regionLoadFailed: .fault + public var message: String { + "Region map viewer failed to load \(kind) geometry: \(description)" } } - public var message: String { - switch self { - case let .loadFailed(kind, description): - "Region map viewer failed to load \(kind) geometry: \(description)" - case let .regionLoadFailed(region, description): - "Failed to load drawable outlines for \(region.rawValue): \(description)" + @LogEvent("region-load-failed", level: .fault) + public struct RegionLoadFailed { + @LogField("region", exposure: .restricted, kind: .location) + public var region: Region + + @LogField("description", exposure: .restricted, kind: .errorDetails) + public var description: String + + public var message: String { + "Failed to load drawable outlines for \(region.rawValue): \(description)" } - } - public var externalID: String? { - switch self { - case .loadFailed: - nil - case let .regionLoadFailed(region, _): - region.regionURL.absoluteString + public var externalID: String? { + region.regionURL.absoluteString } } } diff --git a/Where/RegionKit/Sources/Logging/RegionLog.swift b/Where/RegionKit/Sources/Logging/RegionLog.swift index 8ead506ac..27214510f 100644 --- a/Where/RegionKit/Sources/Logging/RegionLog.swift +++ b/Where/RegionKit/Sources/Logging/RegionLog.swift @@ -1,14 +1,8 @@ import PeriscopeCore -/// Phantom root event naming RegionKit's log scope tree. It is never emitted — -/// its only job is to give ``RegionLog``'s root `Log` the scope name -/// `"RegionKit"`, so every RegionKit event sits under one filterable subtree. -struct RegionKitRoot: LogEvent { - static let eventName = "RegionKit" - var message: String { - "" - } -} +/// Root namespace for RegionKit's log scope tree. +@LogScope("RegionKit") +enum RegionKitRoot {} /// Logging facade for `RegionKit`. Every logger site derives from one root /// `Log` scoped `"RegionKit"`, so RegionKit's events form a single subtree diff --git a/Where/RegionKit/Sources/RegionAttributor.swift b/Where/RegionKit/Sources/RegionAttributor.swift index 777c98e66..a9d10aab5 100644 --- a/Where/RegionKit/Sources/RegionAttributor.swift +++ b/Where/RegionKit/Sources/RegionAttributor.swift @@ -109,7 +109,7 @@ public struct RegionAttributor: RegionAttributing { for region in regions { guard region != .other else { continue } guard let url = RegionCatalog.shared.geometryURL(for: region) else { - logger { .missingGeometry(region: region) } + logger.missingGeometry(region: .restricted(.location, region)) assertionFailure("Missing bundled GeoJSON for region \(region.rawValue)") continue } @@ -118,25 +118,24 @@ public struct RegionAttributor: RegionAttributing { try GeoJSON.polygons(at: url) } guard !polygons.isEmpty else { - logger { .emptyPolygons(region: region) } + logger.emptyPolygons(region: .restricted(.location, region)) assertionFailure("Region \(region.rawValue) decoded no polygons") continue } entries.append(RegionPolygons(region: region, polygons: polygons)) } catch { - logger(attachments: [.error(error, name: "decode-error")]) { - .decodeFailed( - region: region, - description: error.localizedDescription, - ) - } + logger.decodeFailed( + region: .restricted(.location, region), + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "decode-error")], + ) assertionFailure( "Failed to decode bundled GeoJSON for region \(region.rawValue): \(error)", ) } } } - logger { .loaded(regionCount: entries.count) } + logger.loaded(regionCount: .shared(.count, entries.count)) return entries } } diff --git a/Where/RegionKit/Sources/RegionCatalog.swift b/Where/RegionKit/Sources/RegionCatalog.swift index 0d893ff34..7af73a3fa 100644 --- a/Where/RegionKit/Sources/RegionCatalog.swift +++ b/Where/RegionKit/Sources/RegionCatalog.swift @@ -96,7 +96,7 @@ extension RegionCatalog { private static func loadFromBundle() -> RegionCatalog { guard let url = Bundle.module.url(forResource: "regions", withExtension: "json") else { - logger { .missingManifest } + logger.missingManifest() assertionFailure("Missing bundled regions.json") return RegionCatalog(entries: []) } @@ -113,12 +113,13 @@ extension RegionCatalog { ) } } - logger { .loaded(regionCount: entries.count) } + logger.loaded(regionCount: .shared(.count, entries.count)) return RegionCatalog(entries: entries) } catch { - logger(attachments: [.error(error, name: "decode-error")]) { - .decodeFailed(description: error.localizedDescription) - } + logger.decodeFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "decode-error")], + ) assertionFailure("Failed to decode bundled regions.json: \(error)") return RegionCatalog(entries: []) } diff --git a/Where/RegionKit/Sources/RegionGeometryCatalog.swift b/Where/RegionKit/Sources/RegionGeometryCatalog.swift index 55d345619..b73f1f176 100644 --- a/Where/RegionKit/Sources/RegionGeometryCatalog.swift +++ b/Where/RegionKit/Sources/RegionGeometryCatalog.swift @@ -86,9 +86,11 @@ public enum RegionGeometryCatalog { do { return try await RegionCache.shared.outlines(for: region) } catch { - RegionLog.geometryCatalog(attachments: [.error(error, name: "geometry-error")]) { - .regionLoadFailed(region: region, description: error.localizedDescription) - } + RegionLog.geometryCatalog.regionLoadFailed( + region: .restricted(.location, region), + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "geometry-error")], + ) assertionFailure( "Failed to load drawable outlines for \(region.rawValue): \(error.localizedDescription)", ) diff --git a/Where/RegionKit/Tests/Logging/RegionLogTests.swift b/Where/RegionKit/Tests/Logging/RegionLogTests.swift index 8ba789b64..eaa6f4bd3 100644 --- a/Where/RegionKit/Tests/Logging/RegionLogTests.swift +++ b/Where/RegionKit/Tests/Logging/RegionLogTests.swift @@ -24,10 +24,14 @@ struct RegionLogTests { // MARK: - RegionCatalogLog @Test func catalogEventsRenderAndLevel() { - #expect(RegionCatalogLog.missingManifest.level == .fault) - #expect(RegionCatalogLog.decodeFailed(description: "boom").level == .fault) - #expect(RegionCatalogLog.loaded(regionCount: 4).level == .info) - #expect(RegionCatalogLog.loaded(regionCount: 4).message.contains("4 region")) + #expect(RegionCatalogLog.MissingManifest().level == .fault) + #expect(RegionCatalogLog.DecodeFailed( + description: .restricted(.errorDetails, "boom"), + ).level == .fault) + #expect(RegionCatalogLog.Loaded(regionCount: .shared(.count, 4)).level == .info) + #expect(RegionCatalogLog.Loaded( + regionCount: .shared(.count, 4), + ).message.contains("4 region")) } // MARK: - RegionAttributorLog @@ -36,33 +40,50 @@ struct RegionLogTests { // The region rides on externalID as its region:// identity (see // RegionURLTests for the exact string). #expect( - RegionAttributorLog.missingGeometry(region: .california) + RegionAttributorLog.MissingGeometry(region: .restricted(.location, .california)) .externalID == Region.california.regionURL.absoluteString, ) #expect( - RegionAttributorLog.emptyPolygons(region: .canada) + RegionAttributorLog.EmptyPolygons(region: .restricted(.location, .canada)) .externalID == Region.canada.regionURL.absoluteString, ) #expect( - RegionAttributorLog.decodeFailed(region: .newYork, description: "x") - .externalID == Region.newYork.regionURL.absoluteString, + RegionAttributorLog.DecodeFailed( + region: .restricted(.location, .newYork), + description: .restricted(.errorDetails, "x"), + ) + .externalID == Region.newYork.regionURL.absoluteString, ) - #expect(RegionAttributorLog.loaded(regionCount: 2).externalID == nil) + #expect(RegionAttributorLog.Loaded( + regionCount: .shared(.count, 2), + ).externalID == nil) } @Test func attributorFaultsAndInfo() { - #expect(RegionAttributorLog.missingGeometry(region: .california).level == .fault) - #expect(RegionAttributorLog.emptyPolygons(region: .california).level == .fault) + #expect(RegionAttributorLog.MissingGeometry( + region: .restricted(.location, .california), + ).level == .fault) + #expect(RegionAttributorLog.EmptyPolygons( + region: .restricted(.location, .california), + ).level == .fault) #expect( - RegionAttributorLog.decodeFailed(region: .california, description: "x").level == .fault, + RegionAttributorLog.DecodeFailed( + region: .restricted(.location, .california), + description: .restricted(.errorDetails, "x"), + ).level == .fault, ) - #expect(RegionAttributorLog.loaded(regionCount: 2).level == .info) + #expect(RegionAttributorLog.Loaded( + regionCount: .shared(.count, 2), + ).level == .info) } // MARK: - RegionGeometryCatalogLog @Test func geometryCatalogFailureIsWarning() { - let event = RegionGeometryCatalogLog.loadFailed(kind: "source", description: "nope") + let event = RegionGeometryCatalogLog.LoadFailed( + kind: .restricted(.technicalState, "source"), + description: .restricted(.errorDetails, "nope"), + ) #expect(event.level == .warning) #expect(event.message.contains("source")) } diff --git a/Where/Where/README.md b/Where/Where/README.md index fd7704996..e12510a82 100644 --- a/Where/Where/README.md +++ b/Where/Where/README.md @@ -45,6 +45,11 @@ An all-Off launch never starts the SDK. Performance tracing is not enabled by this setup. If the provider does not start, the app records a typed local error event. The regular runtime also shows the error in Privacy & Diagnostics. +Baseline forwarding sends stable event metadata and shareable classified fields only. +Debug-full forwarding can send the complete payload and context after user opt-in. +Neither mode sends attachment bytes. If payload or JSON encoding fails, the sink skips that record. +It counts the failure and reports it through OSLog to prevent Periscope recursion. + The Inspector runtime returns its standalone `InspectorView` and starts none of the model, launch, CoreLocation, notification, Periscope pipeline, App Intents, or Spotlight systems. It opens Where and Periscope containers only through their schema adapters for inspection. Each source's containment root is derived from the adapter's exact store URL, since SwiftData may place the Periscope database in the app-group container. diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index ebeb7ef7a..5a3cb915e 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -91,9 +91,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate { } private static func recordDiagnosticProviderFailure(_ error: any Error) { - logger(attachments: [.error(error, name: "provider-startup-error")]) { - .diagnosticProviderStartupFailed - } + logger.diagnosticProviderStartupFailed( + attachments: [.error(error, name: "provider-startup-error")], + ) } init(runtime: any WhereApplicationRuntime) { diff --git a/Where/Where/Sources/DiagnosticReportingController.swift b/Where/Where/Sources/DiagnosticReportingController.swift index dac70e96d..87ad46a5b 100644 --- a/Where/Where/Sources/DiagnosticReportingController.swift +++ b/Where/Where/Sources/DiagnosticReportingController.swift @@ -1,4 +1,5 @@ import Foundation +import os import PeriscopeCore import WhereCore import WhereCrashReporting @@ -137,10 +138,16 @@ extension DiagnosticReportingConfiguration { /// Actor-isolated mapping from Periscope's live records to Bitdrift fields. actor BitdriftRemoteLogSink: LogSink { + private static let encodingLogger = Logger( + subsystem: "com.stuff.Where", + category: "DiagnosticReportingEncoding", + ) + private var configuration: RemoteLoggingConfiguration private var effectiveFrom: Date private let writer: any BitdriftLogWriting private var scopes: [ScopeID: LogScope] = [:] + private(set) var encodingFailureCount = 0 init( configuration: RemoteLoggingConfiguration, @@ -169,7 +176,14 @@ actor BitdriftRemoteLogSink: LogSink { for record in records where record.date >= effectiveFrom && record.level >= minimumLevel.periscopeLevel { - await writer.write(entry(for: record, metadataPolicy: metadataPolicy)) + do { + try await writer.write(entry(for: record, metadataPolicy: metadataPolicy)) + } catch { + encodingFailureCount += 1 + Self.encodingLogger.error( + "Skipped remote log record after encoding failure: \(String(describing: error), privacy: .public)", + ) + } } } @@ -178,7 +192,7 @@ actor BitdriftRemoteLogSink: LogSink { private func entry( for record: LogRecord, metadataPolicy: RemoteLogMetadataPolicy, - ) -> BitdriftLogEntry { + ) throws -> BitdriftLogEntry { var fields: [String: BitdriftLogValue] = [ "event.name": .string(record.eventName), "event.version": .integer(record.eventVersion), @@ -193,19 +207,20 @@ actor BitdriftRemoteLogSink: LogSink { fields["source.file"] = .string(callSite.fileID) fields["source.function"] = .string(callSite.function) } - for field in record.event.remoteFields { - fields["event.\(field.key.rawValue)"] = field.value.bitdriftValue + for field in record.event.classifiedFields { + guard case let .shareable(key, _, value) = field else { continue } + fields["event.\(key.rawValue)"] = try value.bitdriftValue() } #if DEBUG if metadataPolicy == .allMetadataExcludingAttachmentData { - addFullMetadata(from: record, to: &fields) + try addFullMetadata(from: record, to: &fields) } #endif return BitdriftLogEntry( level: record.level.bitdriftLevel, - message: record.event.remoteMessage, + message: record.eventName, fields: fields, file: record.callSite?.fileID, function: record.callSite?.function, @@ -216,15 +231,15 @@ actor BitdriftRemoteLogSink: LogSink { private func addFullMetadata( from record: LogRecord, to fields: inout [String: BitdriftLogValue], - ) { - fields["event.payload"] = encodedString(record.event) - fields["context.tags"] = encodedString(record.tags) + ) throws { + fields["event.payload"] = try encodedString(record.event) + fields["context.tags"] = try encodedString(record.tags) fields["context.scopes"] = .string(record.scopes.compactMap { id in let path = LogScope.ancestry(of: id, resolve: { scopes[$0] }) return path.isEmpty ? nil : path.map(\.name).joined(separator: "/") }.joined(separator: ",")) if let ambient = record.ambient { - fields["context.ambient"] = encodedString(ambient) + fields["context.ambient"] = try encodedString(ambient) } if let externalID = record.externalID { fields["context.external_id"] = .string(externalID) @@ -236,15 +251,10 @@ actor BitdriftRemoteLogSink: LogSink { } } - private func encodedString(_ value: some Encodable) -> BitdriftLogValue { - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - return try .string(String(decoding: encoder.encode(value), as: UTF8.self)) - } catch { - assertionFailure("Could not encode full diagnostic metadata: \(error)") - return .string("{}") - } + private func encodedString(_ value: some Encodable) throws -> BitdriftLogValue { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try .string(String(decoding: encoder.encode(value), as: UTF8.self)) } #endif } @@ -261,13 +271,23 @@ extension LogLevel { } } -extension RemoteLogFieldValue { - fileprivate var bitdriftValue: BitdriftLogValue { +extension ShareableLogFieldValue { + fileprivate func bitdriftValue() throws -> BitdriftLogValue { switch self { - case let .boolean(value): .boolean(value) - case let .count(value): .integer(value) - case let .durationMilliseconds(value): .double(value) - case let .category(value): .string(value.rawValue) + case let .string(value): .string(value) + case let .int(value): .integer(value) + case let .double(value): .double(value) + case let .bool(value): .boolean(value) + case let .json(value): + try .string(value.canonicalJSONString()) } } } + +extension JSONValue { + fileprivate func canonicalJSONString() throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try String(decoding: encoder.encode(self), as: UTF8.self) + } +} diff --git a/Where/Where/Sources/Logging/WhereAppLog.swift b/Where/Where/Sources/Logging/WhereAppLog.swift index 193a82963..1382c6b39 100644 --- a/Where/Where/Sources/Logging/WhereAppLog.swift +++ b/Where/Where/Sources/Logging/WhereAppLog.swift @@ -1,17 +1,12 @@ import PeriscopeCore /// Process-level events from the Where application host. -enum WhereAppLog: LogEvent { - case diagnosticProviderStartupFailed - - var level: LogLevel { - .error - } - - var message: String { - switch self { - case .diagnosticProviderStartupFailed: - "The diagnostic reporting provider did not start." - } - } +@LogScope("WhereApp") +enum WhereAppLog { + @LogEvent( + "diagnostic-provider-startup-failed", + level: .error, + message: "The diagnostic reporting provider did not start.", + ) + struct DiagnosticProviderStartupFailed {} } diff --git a/Where/Where/Tests/DiagnosticReportingControllerTests.swift b/Where/Where/Tests/DiagnosticReportingControllerTests.swift index 49174024f..608d83b23 100644 --- a/Where/Where/Tests/DiagnosticReportingControllerTests.swift +++ b/Where/Where/Tests/DiagnosticReportingControllerTests.swift @@ -46,12 +46,12 @@ struct DiagnosticReportingControllerTests { let configuration = DiagnosticReportingConfiguration.defaults(isDebugBuild: true) let fixture = Fixture(configuration: configuration) fixture.controller.start() - let log = Log(recorder: fixture.logSystem) + let log = Log(recorder: fixture.logSystem) - log { RemoteTestEvent(level: .debug) } - log { RemoteTestEvent(level: .warning) } - log { RemoteTestEvent(level: .error) } - log { RemoteTestEvent(level: .fault) } + emit(.debug, to: log) + emit(.warning, to: log) + emit(.error, to: log) + emit(.fault, to: log) await fixture.logSystem.flush() let entries = await fixture.writer.entries @@ -66,10 +66,10 @@ struct DiagnosticReportingControllerTests { ) let fixture = Fixture(configuration: configuration) fixture.controller.start() - let log = Log(recorder: fixture.logSystem) + let log = Log(recorder: fixture.logSystem) for level in LogLevel.standardLevels { - log { RemoteTestEvent(level: level) } + emit(level, to: log) } await fixture.logSystem.flush() @@ -98,12 +98,12 @@ struct DiagnosticReportingControllerTests { await sink.write([ LogRecord( date: effectiveFrom.addingTimeInterval(-0.001), - event: RemoteTestEvent(level: .warning), + event: remoteTestEvent(level: .warning), scopes: [], ), LogRecord( date: effectiveFrom, - event: RemoteTestEvent(level: .warning), + event: remoteTestEvent(level: .warning), scopes: [], ), ]) @@ -115,21 +115,23 @@ struct DiagnosticReportingControllerTests { let configuration = DiagnosticReportingConfiguration.defaults(isDebugBuild: true) let fixture = Fixture(configuration: configuration) fixture.controller.start() - let tagged = Log(recorder: fixture.logSystem) + let tagged = Log(recorder: fixture.logSystem) .tagged(LogTagKey("private-tag"), "private-value") let log = tagged(for: "private-scope") - log( + log.event( + level: .restricted(.technicalState, .warning), + count: .shared(.count, 7), attachments: [LogAttachment( name: "private-name", contentType: .plainText, data: Data("private-bytes".utf8), )], - ) { RemoteTestEvent(level: .warning) } + ) await fixture.logSystem.flush() let entry = try #require(await fixture.writer.entries.first) - #expect(entry.message == "PII-free test event") + #expect(entry.message == "RemoteTest.event") #expect(entry.fields["event.count"] == .integer(7)) #expect(entry.fields["event.payload"] == nil) #expect(entry.fields["context.tags"] == nil) @@ -140,6 +142,59 @@ struct DiagnosticReportingControllerTests { #expect(entry.fields.values.contains(.string("private-scope")) == false) } + @Test func jsonExportsCanonicallyAsOneField() async throws { + let writer = RecordingBitdriftWriter() + let sink = BitdriftRemoteLogSink( + configuration: .enabled(minimumLevel: .debug, metadataPolicy: .approvedFields), + effectiveFrom: .distantPast, + writer: writer, + ) + let event = RemoteTestLog.InvalidJSON(json: .shared( + .json, + .object(["z": .array([.int(1), .bool(true)]), "a": .string("value")]), + )) + + await sink.write([LogRecord(date: .now, event: event, scopes: [])]) + + let entry = try #require(await writer.entries.first) + #expect(entry.fields["event.json"] == .string(#"{"a":"value","z":[1,true]}"#)) + #expect(entry.fields.keys.contains("event.json.a") == false) + } + + @Test func jsonEncodingFailureSkipsTheCompleteRecordAndIncrementsTheCounter() async { + let writer = RecordingBitdriftWriter() + let sink = BitdriftRemoteLogSink( + configuration: .enabled(minimumLevel: .debug, metadataPolicy: .approvedFields), + effectiveFrom: .distantPast, + writer: writer, + ) + let event = RemoteTestLog.InvalidJSON(json: .shared(.json, .double(.nan))) + + await sink.write([LogRecord(date: .now, event: event, scopes: [])]) + + #expect(await writer.entries.isEmpty) + #expect(await sink.encodingFailureCount == 1) + } + + @Test func freeformTextIsExcludedFromBaselineExport() async throws { + let writer = RecordingBitdriftWriter() + let sink = BitdriftRemoteLogSink( + configuration: .enabled(minimumLevel: .debug, metadataPolicy: .approvedFields), + effectiveFrom: .distantPast, + writer: writer, + ) + let event = Message( + level: .restricted(.technicalState, .info), + text: .restricted(.arbitraryText, "private freeform text"), + ) + + await sink.write([LogRecord(date: .now, event: event, scopes: [])]) + + let entry = try #require(await writer.entries.first) + #expect(entry.message == "message.message") + #expect(entry.fields.values.contains(.string("private freeform text")) == false) + } + #if DEBUG @Test func fullMetadataIncludesContextButNeverAttachmentBytes() async throws { let configuration = DiagnosticReportingConfiguration( @@ -152,15 +207,19 @@ struct DiagnosticReportingControllerTests { ) let fixture = Fixture(configuration: configuration) fixture.controller.start() - let tagged = Log(recorder: fixture.logSystem) + let tagged = Log(recorder: fixture.logSystem) .tagged(LogTagKey("private-tag"), "private-value") let log = tagged(for: "private-scope") - log(attachments: [LogAttachment( - name: "diagnostic.txt", - contentType: .plainText, - data: Data("never-transmit-these-bytes".utf8), - )]) { RemoteTestEvent(level: .warning) } + log.event( + level: .restricted(.technicalState, .warning), + count: .shared(.count, 7), + attachments: [LogAttachment( + name: "diagnostic.txt", + contentType: .plainText, + data: Data("never-transmit-these-bytes".utf8), + )], + ) await fixture.logSystem.flush() let entry = try #require(await fixture.writer.entries.first) @@ -171,6 +230,26 @@ struct DiagnosticReportingControllerTests { #expect(entry.fields["attachments.metadata"] == .string("diagnostic.txt:text/plain")) #expect(entry.fields.values.contains(.string("never-transmit-these-bytes")) == false) } + + @Test func fullMetadataEncodingFailureNeverSubstitutesAnEmptyObject() async { + let writer = RecordingBitdriftWriter() + let sink = BitdriftRemoteLogSink( + configuration: .enabled( + minimumLevel: .debug, + metadataPolicy: .allMetadataExcludingAttachmentData, + ), + effectiveFrom: .distantPast, + writer: writer, + ) + let event = RemoteTestLog.InvalidDebug( + value: .restricted(.technicalState, .nan), + ) + + await sink.write([LogRecord(date: .now, event: event, scopes: [])]) + + #expect(await writer.entries.isEmpty) + #expect(await sink.encodingFailureCount == 1) + } #endif @Test func detachDrainsThenSleepsWhenLaunchChannelsAreOff() async throws { @@ -185,8 +264,8 @@ struct DiagnosticReportingControllerTests { .enabled(minimumLevel: .info, metadataPolicy: .approvedFields), revision: 1, ) - let log = Log(recorder: fixture.logSystem) - log { RemoteTestEvent(level: .info) } + let log = Log(recorder: fixture.logSystem) + emit(.info, to: log) try await fixture.controller.applyRemoteLogging(.off, revision: 2) #expect(await fixture.writer.entries.count == 1) @@ -239,8 +318,8 @@ struct DiagnosticReportingControllerTests { revision: 1, ) } - let log = Log(recorder: fixture.logSystem) - log { RemoteTestEvent(level: .warning) } + let log = Log(recorder: fixture.logSystem) + emit(.warning, to: log) await fixture.logSystem.flush() #expect(await fixture.writer.entries.isEmpty) @@ -251,34 +330,60 @@ struct DiagnosticReportingControllerTests { fixture.controller.start() await fixture.controller.providerDidFail() - let log = Log(recorder: fixture.logSystem) - log { RemoteTestEvent(level: .warning) } + let log = Log(recorder: fixture.logSystem) + emit(.warning, to: log) await fixture.logSystem.flush() #expect(await fixture.writer.entries.isEmpty) } } -private struct RemoteTestEvent: LogEvent { - let level: LogLevel - let count = 7 - var message: String { - "PII-free test event" - } +@LogScope("RemoteTest") +private enum RemoteTestLog { + @LogEvent("event") + struct Event { + @LogField("level", exposure: .restricted, kind: .technicalState) + var level: LogLevel + + @LogField("count", exposure: .shareable, kind: .count) + var count: Int + + var message: String { + "PII-free test event" + } - var remoteMessage: String { - message + var externalID: String? { + "private-external-id" + } } - var externalID: String? { - "private-external-id" + @LogEvent("invalid-json", message: "Invalid JSON") + struct InvalidJSON { + @LogField("json", exposure: .shareable, kind: .json) + var json: JSONValue } - var remoteFields: [RemoteLogField] { - [RemoteLogField(key: RemoteLogFieldKey("count"), value: .count(count))] + @LogEvent("invalid-debug", message: "Invalid debug payload") + struct InvalidDebug { + @LogField("value", exposure: .restricted, kind: .technicalState) + var value: Double } } +private func remoteTestEvent(level: LogLevel) -> RemoteTestLog.Event { + RemoteTestLog.Event( + level: .restricted(.technicalState, level), + count: .shared(.count, 7), + ) +} + +private func emit(_ level: LogLevel, to log: Log) { + log.event( + level: .restricted(.technicalState, level), + count: .shared(.count, 7), + ) +} + @MainActor private struct Fixture { let writer = RecordingBitdriftWriter() diff --git a/Where/Where/Tests/Logging/WhereAppLogTests.swift b/Where/Where/Tests/Logging/WhereAppLogTests.swift index 5885a092d..0eecd0d84 100644 --- a/Where/Where/Tests/Logging/WhereAppLogTests.swift +++ b/Where/Where/Tests/Logging/WhereAppLogTests.swift @@ -4,9 +4,14 @@ import Testing struct WhereAppLogTests { @Test func diagnosticProviderStartupFailureIsAnError() { - let event = WhereAppLog.diagnosticProviderStartupFailed + let event = WhereAppLog.DiagnosticProviderStartupFailed() + #expect( + WhereAppLog.DiagnosticProviderStartupFailed.eventName + == "WhereApp.diagnostic-provider-startup-failed", + ) #expect(event.level == .error) #expect(event.message == "The diagnostic reporting provider did not start.") + #expect(event.classifiedFields.isEmpty) } } diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 577dac1d9..561233312 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -196,7 +196,7 @@ one it belongs to rather than to a god-object: [`RegionKit`](../RegionKit/README.md)'s.) - **`WhereLog`** — the Periscope logging facade: a `"Where"` root scope with grouping scopes (`location`, `reminders`, `backup`, `widgets`, `reporting`, …) - and a typed `LogEvent` per collaborator, emitted into `Periscope.shared`. Each + and an `@LogScope` namespace per collaborator. Nested `@LogEvent` structs emit through generated classified methods into `Periscope.shared`. Each collaborator's expensive work is also timed against a declared budget through its `*Log`'s `SpanName` cases, so slow reads, commits, and reconciles show up in Periscope's span history rather than only as a slow screen. diff --git a/Where/WhereCore/Sources/About/AppAttribution.swift b/Where/WhereCore/Sources/About/AppAttribution.swift index 486d2b365..a552d3a3a 100644 --- a/Where/WhereCore/Sources/About/AppAttribution.swift +++ b/Where/WhereCore/Sources/About/AppAttribution.swift @@ -39,15 +39,16 @@ public enum AppAttribution { public static func current(bundle: Bundle) -> AttributionManifest? { do { let manifest = try AttributionManifest.load(from: bundle, resource: resource) - logger { .loaded(creditCount: manifest.credits.count) } + logger.loaded(creditCount: .shared(.count, manifest.credits.count)) return manifest } catch AttributionError.reportMissing { - logger { .noReport } + logger.noReport() return nil } catch { - logger(attachments: [.error(error, name: "decode-error")]) { - .decodeFailed(description: error.localizedDescription) - } + logger.decodeFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "decode-error")], + ) assertionFailure("Failed to decode the bundled attribution report: \(error)") return nil } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index 976033687..fc59037cd 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -249,7 +249,9 @@ public actor BackupCoordinator { do { try FileManager.default.removeItem(at: previous) } catch { - Self.logger { .removePreviousExportFailed(description: error.localizedDescription) } + Self.logger.removePreviousExportFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) } } diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 686a8f27e..6ff315289 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -158,15 +158,13 @@ public struct BackupService: Sendable { compressionMethod: .deflate, ) } - Self.logger { - .wroteBackup( - sampleCount: samples.count, - evidenceCount: evidence.count, - manualDayCount: manualDays.count, - dismissedIssueCount: dismissedIssues.count, - trackedRegionCount: trackedRegions.count, - ) - } + Self.logger.wroteBackup( + sampleCount: .shared(.count, samples.count), + evidenceCount: .shared(.count, evidence.count), + manualDayCount: .shared(.count, manualDays.count), + dismissedIssueCount: .shared(.count, dismissedIssues.count), + trackedRegionCount: .shared(.count, trackedRegions.count), + ) return zipURL } @@ -228,7 +226,9 @@ public struct BackupService: Sendable { do { blobs[entry.evidenceId] = try Data(contentsOf: assetURL) } catch { - Self.logger { .assetMissing(evidenceID: entry.evidenceId.uuidString) } + Self.logger.assetMissing( + evidenceID: .restricted(.identifier, entry.evidenceId.uuidString), + ) throw error } } diff --git a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift index 42af8ac09..bfe58d0c0 100644 --- a/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift +++ b/Where/WhereCore/Sources/Devices/DeviceRecordingController.swift @@ -292,9 +292,10 @@ public actor DeviceRecordingController { needsReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) - Self.logger(attachments: [.error(error, name: "import-recovery-error")]) { - .importRecoveryFailed(description: error.localizedDescription) - } + Self.logger.importRecoveryFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "import-recovery-error")], + ) } endExclusive() } @@ -350,9 +351,10 @@ public actor DeviceRecordingController { needsReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) - Self.logger(attachments: [.error(error, name: "rollback-recovery-error")]) { - .rollbackRecoveryFailed(description: error.localizedDescription) - } + Self.logger.rollbackRecoveryFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "rollback-recovery-error")], + ) } } @@ -560,9 +562,10 @@ public actor DeviceRecordingController { needsReconciliation = true await ingestor.revokeRecordingAuthorization() publishRuntimeState(.unavailable) - Self.logger(attachments: [.error(error, name: "policy-observation-error")]) { - .policyObservationFailed(description: error.localizedDescription) - } + Self.logger.policyObservationFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "policy-observation-error")], + ) } endExclusive() } diff --git a/Where/WhereCore/Sources/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index 9dff51334..0f9b610a5 100644 --- a/Where/WhereCore/Sources/Journal/DayJournal.swift +++ b/Where/WhereCore/Sources/Journal/DayJournal.swift @@ -128,7 +128,10 @@ public actor DayJournal { try await store.setManualDay(presence) } await reconcileAfterDayDataChange() - Self.logger { .addedManualDay(day: String(describing: day), regionCount: regions.count) } + Self.logger.addedManualDay( + day: .restricted(.dateTime, String(describing: day)), + regionCount: .shared(.count, regions.count), + ) } /// Authoritatively set the regions for a single calendar day, *replacing* @@ -147,7 +150,10 @@ public actor DayJournal { try await store.setManualDay(presence) } await reconcileAfterDayDataChange() - Self.logger { .overrodeDay(day: String(describing: day), regionCount: regions.count) } + Self.logger.overrodeDay( + day: .restricted(.dateTime, String(describing: day)), + regionCount: .shared(.count, regions.count), + ) } /// Drop the manual overlay for a single calendar day, restoring the @@ -160,7 +166,9 @@ public actor DayJournal { try await store.clearManualDay(day) } await reconcileAfterDayDataChange() - Self.logger { .clearedManualDay(day: String(describing: day)) } + Self.logger.clearedManualDay( + day: .restricted(.dateTime, String(describing: day)), + ) } /// Drop the manual overlays for several calendar days (the logged-days @@ -183,7 +191,7 @@ public actor DayJournal { } } await reconcileAfterDayDataChange() - Self.logger { .clearedManualDays(dayCount: days.count) } + Self.logger.clearedManualDays(dayCount: .shared(.count, days.count)) } /// Assert `regions` for every calendar day in the inclusive range @@ -217,9 +225,10 @@ public actor DayJournal { } } await reconcileAfterDayDataChange() - Self.logger { - .backfilledManualDays(dayCount: days.count, regionCount: regions.count) - } + Self.logger.backfilledManualDays( + dayCount: .shared(.count, days.count), + regionCount: .shared(.count, regions.count), + ) } // MARK: - Clearing @@ -233,7 +242,7 @@ public actor DayJournal { } } await reconcileAfterDayDataChange() - Self.logger { .clearedYear(year: year) } + Self.logger.clearedYear(year: .restricted(.domainValue, year)) } /// Erase every sample, manual day, and piece of evidence in the store, then @@ -263,7 +272,7 @@ public actor DayJournal { } } await reconcileAfterDayDataChange() - Self.logger { .erasedAllData } + Self.logger.erasedAllData() } // MARK: - Evidence @@ -272,9 +281,10 @@ public actor DayJournal { try await store.performInCurrentGeneration { try await store.write(evidence: evidence, blob: blob) } - Self.logger { - .wroteEvidence(id: String(describing: evidence.id), hasBlob: blob != nil) - } + Self.logger.wroteEvidence( + id: .restricted(.identifier, String(describing: evidence.id)), + hasBlob: .shared(.boolean, blob != nil), + ) } public func evidence(for year: Int) async throws -> [Evidence] { diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index 9556344b0..0e8213688 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -143,7 +143,7 @@ public actor LocationIngestor { isMonitoring = true await locationSource.start() guard isMonitoring else { return } - Self.logger { .monitoringStarted } + Self.logger.monitoringStarted() installIngestTaskIfNeeded() } @@ -207,7 +207,7 @@ public actor LocationIngestor { guard !didLoadDurableBacklog else { return } let restored = try await outbox.load() if !restored.isEmpty { - Self.logger { .restoredBacklog(count: restored.count) } + Self.logger.restoredBacklog(count: .shared(.count, restored.count)) } // Rows written before device provenance existed intentionally remain unstamped and // legacy-visible. Re-attributing them to this installation would make them depend on @@ -266,7 +266,7 @@ public actor LocationIngestor { if isMonitoring { isMonitoring = false await locationSource.stop() - Self.logger { .monitoringStopped } + Self.logger.monitoringStopped() } captureTask?.cancel() } @@ -290,7 +290,7 @@ public actor LocationIngestor { guard isMonitoring else { return } isMonitoring = false await locationSource.stop() - Self.logger { .monitoringStopped } + Self.logger.monitoringStopped() } /// Permanently discard samples awaiting persistence. The durable outbox is @@ -315,7 +315,7 @@ public actor LocationIngestor { public func quiesce() async throws { await pause() try await discardRetryBacklog() - Self.logger { .quiesced } + Self.logger.quiesced() } /// Whether GPS monitoring is currently active. Exposed so the view-model can @@ -371,7 +371,7 @@ public actor LocationIngestor { private func performTodayCapture(now: Date) async { let startOfDay = calendar.startOfDay(for: now) guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { - Self.logger { .todayIntervalUnavailable } + Self.logger.todayIntervalUnavailable() return } let interval = DateInterval(start: startOfDay, end: endOfDay) @@ -385,7 +385,9 @@ public actor LocationIngestor { } catch { // Fail closed: if today's samples can't be read we skip rather than // risk logging a duplicate fix. Surfaced, not silently swallowed. - Self.logger { .foregroundCaptureReadFailed(description: error.localizedDescription) } + Self.logger.foregroundCaptureReadFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) return } let fix = await Self.logger.measure(.acquireFix, budget: .seconds(10)) { @@ -398,7 +400,7 @@ public actor LocationIngestor { // `pause()` either sees `acceptsSamples == false` here (we skip) or sees // the handle already set (it awaits us) — never neither. guard !Task.isCancelled, accepts(sample) else { return } - Self.logger { .capturedForegroundFix } + Self.logger.capturedForegroundFix() // Persist via `processIngestedSample` on the capture's own handle rather // than `ingest(_:)`, so it never shares the stream loop's single // `inFlightIngest` slot. `pause()` awaits this handle independently. @@ -474,19 +476,19 @@ public actor LocationIngestor { // via `os.Logger` rather than silently dropped. The stream keeps // running so a transient error doesn't stop tracking, and the sample // is queued for retry on the next save attempt. - Self.logger(attachments: [.error(error, name: "persist-error")]) { - .persistFailed( - sampleID: String(describing: sample.id), - description: error.localizedDescription, - ) - } + Self.logger.persistFailed( + sampleID: .restricted(.identifier, String(describing: sample.id)), + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "persist-error")], + ) enqueueForRetry(LocationOutboxEntry(sample: sample, dataGenerationID: dataGenerationID)) do { try await outbox.save(retryQueue) } catch { - Self.logger(attachments: [.error(error, name: "outbox-persist-error")]) { - .retryBacklogPersistenceFailed(description: error.localizedDescription) - } + Self.logger.retryBacklogPersistenceFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "outbox-persist-error")], + ) // Continuing to accept locations would make the in-memory queue the only copy; // fail closed until reconciliation can reopen recording with durable storage. await closeRecordingAuthority(ifAuthorizedFor: dataGenerationID) @@ -496,7 +498,7 @@ public actor LocationIngestor { private func enqueueForRetry(_ entry: LocationOutboxEntry) { if retryQueue.count >= retryQueueCapacity { - Self.logger { .retryQueueAtCapacity(capacity: retryQueueCapacity) } + Self.logger.retryQueueAtCapacity(capacity: .shared(.count, retryQueueCapacity)) retryQueue.removeFirst() } retryQueue.append(entry) @@ -536,23 +538,20 @@ public actor LocationIngestor { generationChanged = true break } catch { - Self.logger(attachments: [.error(error, name: "retry-error")]) { - .retryStillFailing( - sampleID: String(describing: sample.id), - description: error.localizedDescription, - ) - } + Self.logger.retryStillFailing( + sampleID: .restricted(.identifier, String(describing: sample.id)), + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "retry-error")], + ) enqueueForRetry(entry) } } } if !persistedDays.isEmpty { - Self.logger { - .drainedBacklog( - sampleCount: persistedSampleCount, - dayCount: persistedDays.count, - ) - } + Self.logger.drainedBacklog( + sampleCount: .shared(.count, persistedSampleCount), + dayCount: .shared(.count, persistedDays.count), + ) } try await outbox.save(retryQueue) if generationChanged { diff --git a/Where/WhereCore/Sources/Location/LocationOutbox.swift b/Where/WhereCore/Sources/Location/LocationOutbox.swift index a45fe5321..21c1b423c 100644 --- a/Where/WhereCore/Sources/Location/LocationOutbox.swift +++ b/Where/WhereCore/Sources/Location/LocationOutbox.swift @@ -121,7 +121,7 @@ public actor FileLocationOutbox: LocationOutbox { appropriateFor: nil, create: true, ) else { - logger { .noApplicationSupport } + logger.noApplicationSupport() return NoOpLocationOutbox() } let fileURL = directory @@ -141,7 +141,7 @@ public actor FileLocationOutbox: LocationOutbox { try secureDirectoryIfPresent() let recovered = try JournalRecovery.recover(directory: directoryURL) if recovered.foundTornEntry { - Self.logger { .recoveredTornJournal } + Self.logger.recoveredTornJournal() } if let payload = recovered.payloads.last { return try Self.decodeEntries(from: payload) @@ -151,9 +151,10 @@ public actor FileLocationOutbox: LocationOutbox { } return try migrateLegacyJSONIfNeeded() } catch { - Self.logger(attachments: [.error(error, name: "read-error")]) { - .readBacklogFailed(description: error.localizedDescription) - } + Self.logger.readBacklogFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "read-error")], + ) throw error } } @@ -167,9 +168,10 @@ public actor FileLocationOutbox: LocationOutbox { let data = try JSONEncoder().encode(entries) try openJournal().append(data, sync: .processDeath) } catch { - Self.logger(attachments: [.error(error, name: "persist-error")]) { - .persistBacklogFailed(description: error.localizedDescription) - } + Self.logger.persistBacklogFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "persist-error")], + ) throw error } } @@ -190,9 +192,10 @@ public actor FileLocationOutbox: LocationOutbox { try FileManager.default.removeItem(at: legacyFileURL) } } catch { - Self.logger(attachments: [.error(error, name: "clear-error")]) { - .persistBacklogFailed(description: error.localizedDescription) - } + Self.logger.persistBacklogFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "clear-error")], + ) throw error } } @@ -217,9 +220,10 @@ public actor FileLocationOutbox: LocationOutbox { do { try excludeFromBackup(directoryURL) } catch { - Self.logger(attachments: [.error(error, name: "backup-exclusion-error")]) { - .excludeFromBackupFailed(description: error.localizedDescription) - } + Self.logger.excludeFromBackupFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "backup-exclusion-error")], + ) journal?.close() journal = nil Self.discardInsecureDirectory(at: directoryURL) @@ -245,9 +249,10 @@ public actor FileLocationOutbox: LocationOutbox { do { entries = try Self.decodeEntries(from: data) } catch { - Self.logger(attachments: [.error(error, name: "decode-error")]) { - .droppedUnreadableBacklog(description: error.localizedDescription) - } + Self.logger.droppedUnreadableBacklog( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "decode-error")], + ) Self.discardInsecureFile(at: fileURL) throw error } @@ -278,9 +283,10 @@ public actor FileLocationOutbox: LocationOutbox { do { try excludeFromBackup(directoryURL) } catch { - logger(attachments: [.error(error, name: "backup-exclusion-error")]) { - .excludeFromBackupFailed(description: error.localizedDescription) - } + logger.excludeFromBackupFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "backup-exclusion-error")], + ) discardInsecureDirectory(at: directoryURL) return } @@ -293,9 +299,10 @@ public actor FileLocationOutbox: LocationOutbox { try excludeFromBackup(url) return true } catch { - logger(attachments: [.error(error, name: "backup-exclusion-error")]) { - .excludeFromBackupFailed(description: error.localizedDescription) - } + logger.excludeFromBackupFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "backup-exclusion-error")], + ) discardInsecureFile(at: url) return false } @@ -309,17 +316,19 @@ public actor FileLocationOutbox: LocationOutbox { } catch { // Both copies are already excluded, so a transient file-protection failure may retry // next launch without sacrificing the newer pending snapshot. - logger(attachments: [.error(error, name: "pending-read-error")]) { - .readBacklogFailed(description: error.localizedDescription) - } + logger.readBacklogFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "pending-read-error")], + ) return } do { _ = try decodeEntries(from: pendingData) } catch { - logger(attachments: [.error(error, name: "pending-decode-error")]) { - .droppedUnreadableBacklog(description: error.localizedDescription) - } + logger.droppedUnreadableBacklog( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "pending-decode-error")], + ) discardInsecureFile(at: pendingURL) return } @@ -336,18 +345,20 @@ public actor FileLocationOutbox: LocationOutbox { try fileManager.moveItem(at: pendingURL, to: fileURL) } } catch { - logger(attachments: [.error(error, name: "pending-promotion-error")]) { - .persistBacklogFailed(description: error.localizedDescription) - } + logger.persistBacklogFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "pending-promotion-error")], + ) return } do { try excludeFromBackup(fileURL) } catch { - logger(attachments: [.error(error, name: "backup-exclusion-error")]) { - .excludeFromBackupFailed(description: error.localizedDescription) - } + logger.excludeFromBackupFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "backup-exclusion-error")], + ) discardInsecureFile(at: fileURL) discardInsecureFile(at: pendingURL) } @@ -373,9 +384,10 @@ public actor FileLocationOutbox: LocationOutbox { try excludeFromBackup(fileURL) } } catch { - logger(attachments: [.error(error, name: "legacy-migration-error")]) { - .persistBacklogFailed(description: error.localizedDescription) - } + logger.persistBacklogFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "legacy-migration-error")], + ) secureExistingFile(at: legacyURL) } } @@ -387,9 +399,10 @@ public actor FileLocationOutbox: LocationOutbox { do { try excludeFromBackup(fileURL) } catch { - logger(attachments: [.error(error, name: "backup-exclusion-error")]) { - .excludeFromBackupFailed(description: error.localizedDescription) - } + logger.excludeFromBackupFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "backup-exclusion-error")], + ) discardInsecureFile(at: fileURL) } } @@ -431,9 +444,10 @@ public actor FileLocationOutbox: LocationOutbox { do { try FileManager.default.removeItem(at: fileURL) } catch { - logger(attachments: [.error(error, name: "insecure-discard-error")]) { - .discardInsecureBacklogFailed(description: error.localizedDescription) - } + logger.discardInsecureBacklogFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "insecure-discard-error")], + ) } } @@ -445,9 +459,10 @@ public actor FileLocationOutbox: LocationOutbox { do { try FileManager.default.removeItem(at: directoryURL) } catch { - logger(attachments: [.error(error, name: "insecure-discard-error")]) { - .discardInsecureBacklogFailed(description: error.localizedDescription) - } + logger.discardInsecureBacklogFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "insecure-discard-error")], + ) } } } diff --git a/Where/WhereCore/Sources/Logging/AppAttributionLog.swift b/Where/WhereCore/Sources/Logging/AppAttributionLog.swift index de99153c2..fa3e4c2ca 100644 --- a/Where/WhereCore/Sources/Logging/AppAttributionLog.swift +++ b/Where/WhereCore/Sources/Logging/AppAttributionLog.swift @@ -1,51 +1,28 @@ import PeriscopeCore /// Structured events for loading the app's bundled attribution report. -/// -/// The two failure cases are deliberately not the same severity. A bundle -/// without a report is a *legitimate* state — only the app target ships one, so -/// the RegionViewer, `StuffTestHost`, and the extensions have none — and says so -/// at `.info`. A report that is present but won't decode is a corrupt bundled -/// resource, so it logs at `.fault` to match the paired `assertionFailure`, and -/// it matters beyond a blank screen: the report is how the app discharges its -/// attribution obligations. -enum AppAttributionLog: LogEvent { - /// The bundle carries no report. Expected outside the app target. - case noReport - /// The report decoded successfully into `creditCount` entries. - case loaded(creditCount: Int) - /// The report was present but could not be decoded. - case decodeFailed(description: String) +@LogScope("AppAttribution") +enum AppAttributionLog { + @LogEvent("no-report", level: .info, message: "Bundle carries no attribution report") + struct NoReport {} - static let eventName = "AppAttribution" + @LogEvent("loaded", level: .info) + struct Loaded { + @LogField("credit_count", exposure: .shareable, kind: .count) + var creditCount: Int - var level: LogLevel { - switch self { - case .decodeFailed: .fault - case .noReport, .loaded: .info + var message: String { + "Loaded attribution report with \(creditCount) credit(s)" } } - var message: String { - switch self { - case .noReport: - "Bundle carries no attribution report" - case let .loaded(creditCount): - "Loaded attribution report with \(creditCount) credit(s)" - case let .decodeFailed(description): - "Failed to decode bundled attribution report: \(description)" - } - } + @LogEvent("decode-failed", level: .fault) + struct DecodeFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - var remoteFields: [RemoteLogField] { - switch self { - case let .loaded(creditCount): - [RemoteLogField( - key: RemoteLogFieldKey("credit_count"), - value: .count(creditCount), - )] - case .noReport, .decodeFailed: - [] + var message: String { + "Failed to decode bundled attribution report: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/BackupCoordinatorLog.swift b/Where/WhereCore/Sources/Logging/BackupCoordinatorLog.swift index 6543d8767..697910e5b 100644 --- a/Where/WhereCore/Sources/Logging/BackupCoordinatorLog.swift +++ b/Where/WhereCore/Sources/Logging/BackupCoordinatorLog.swift @@ -1,44 +1,23 @@ import PeriscopeCore -/// Structured events for `BackupCoordinator`. Failing to clear a previous export -/// staging directory is degraded-but-handled housekeeping, so it logs at -/// `.warning`. -enum BackupCoordinatorLog: LogEvent { - /// Names the coordinator's timed spans. Export and import are the longest - /// operations in the app — whole-table reads and per-blob file I/O — and the - /// only ones that show the user a progress bar, so they're decomposed far - /// enough to say *which* leg the bar is stuck on. `BackupService` spans the - /// zip/unzip and manifest legs that sit inside these. - /// - /// Unbudgeted, all of them: the runtime is proportional to the library, so - /// any threshold that didn't warn on a small library would be silent on a - /// large one. The percentiles in the span history are the yardstick here. +/// Structured events and spans for `BackupCoordinator`. +@LogScope("BackupCoordinator") +enum BackupCoordinatorLog { enum SpanName: Hashable { - /// A whole export: reads, blob load, archive write. case exportBackup - /// The four whole-table reads plus the primary-region set. case exportReads - /// Loading every evidence blob out of external storage — the leg the - /// progress bar tracks. case exportBlobLoad - /// A whole import: read the archive, then write it in one transaction. case importBackup - /// The single transaction an import commits, row by row. case importWrite } - case removePreviousExportFailed(description: String) + @LogEvent("remove-previous-export-failed", level: .warning) + struct RemovePreviousExportFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - static let eventName = "BackupCoordinator" - - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .removePreviousExportFailed(description): - "Failed to remove previous backup export directory: \(description)" + var message: String { + "Failed to remove previous backup export directory: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/BackupServiceLog.swift b/Where/WhereCore/Sources/Logging/BackupServiceLog.swift index 4d7515590..9dd830fd4 100644 --- a/Where/WhereCore/Sources/Logging/BackupServiceLog.swift +++ b/Where/WhereCore/Sources/Logging/BackupServiceLog.swift @@ -1,99 +1,47 @@ import PeriscopeCore -/// Structured events for `BackupService`'s archive read/write. Evidence ids ride -/// on `externalID` so a skipped blob traces back to its evidence row. -enum BackupServiceLog: LogEvent { - /// Names the service's timed spans — the file-I/O legs inside - /// `BackupCoordinator`'s export/import spans, in the order they run. +/// Structured events and spans for `BackupService`. +@LogScope("BackupService") +enum BackupServiceLog { enum SpanName: Hashable { - /// Writing every evidence blob into the staging directory. case stageAssets - /// Encoding `manifest.json` and writing it. Whole-library JSON, so it's - /// the leg that scales with sample count rather than attachment size. case encodeManifest - /// Zipping the staging directory into the archive file. case writeArchive - /// Unzipping a backup file into a scratch directory. case readArchive - /// Decoding `manifest.json` back into a `BackupArchive`. case decodeManifest - /// Reading the unzipped evidence blobs back into memory. case loadAssets } - case wroteBackup( - sampleCount: Int, - evidenceCount: Int, - manualDayCount: Int, - dismissedIssueCount: Int, - trackedRegionCount: Int, - ) - case assetMissing(evidenceID: String) + @LogEvent("wrote-backup") + struct WroteBackup { + @LogField("sample_count", exposure: .shareable, kind: .count) + var sampleCount: Int + @LogField("evidence_count", exposure: .shareable, kind: .count) + var evidenceCount: Int + @LogField("manual_day_count", exposure: .shareable, kind: .count) + var manualDayCount: Int + @LogField("dismissed_issue_count", exposure: .shareable, kind: .count) + var dismissedIssueCount: Int + @LogField("tracked_region_count", exposure: .shareable, kind: .count) + var trackedRegionCount: Int - static let eventName = "BackupService" - - var level: LogLevel { - switch self { - case .wroteBackup: .info - case .assetMissing: .warning + var message: String { + "Wrote backup with \(sampleCount) samples, \(evidenceCount) evidence, " + + "\(manualDayCount) manual days, \(dismissedIssueCount) dismissals, " + + "\(trackedRegionCount) tracked regions" } } - var message: String { - switch self { - case let .wroteBackup( - sampleCount, - evidenceCount, - manualDayCount, - dismissedIssueCount, - trackedRegionCount, - ): - "Wrote backup with \(sampleCount) samples, \(evidenceCount) evidence, \(manualDayCount) manual days, \(dismissedIssueCount) dismissals, \(trackedRegionCount) tracked regions" - case let .assetMissing(evidenceID): - "Backup asset missing for evidence \(evidenceID); skipping blob" + @LogEvent("asset-missing", level: .warning) + struct AssetMissing { + @LogField("evidence_id", exposure: .restricted, kind: .identifier) + var evidenceID: String + var message: String { + "Backup asset missing for evidence \(evidenceID); skipping blob" } - } - - var externalID: String? { - switch self { - case let .assetMissing(evidenceID): WhereStoreID.evidence(evidenceID) - case .wroteBackup: nil - } - } - var remoteFields: [RemoteLogField] { - switch self { - case let .wroteBackup( - sampleCount, - evidenceCount, - manualDayCount, - dismissedIssueCount, - trackedRegionCount, - ): - [ - RemoteLogField( - key: RemoteLogFieldKey("sample_count"), - value: .count(sampleCount), - ), - RemoteLogField( - key: RemoteLogFieldKey("evidence_count"), - value: .count(evidenceCount), - ), - RemoteLogField( - key: RemoteLogFieldKey("manual_day_count"), - value: .count(manualDayCount), - ), - RemoteLogField( - key: RemoteLogFieldKey("dismissed_issue_count"), - value: .count(dismissedIssueCount), - ), - RemoteLogField( - key: RemoteLogFieldKey("tracked_region_count"), - value: .count(trackedRegionCount), - ), - ] - case .assetMissing: - [] + var externalID: String? { + WhereStoreID.evidence(evidenceID) } } } diff --git a/Where/WhereCore/Sources/Logging/DailySummaryReconcilerLog.swift b/Where/WhereCore/Sources/Logging/DailySummaryReconcilerLog.swift index ed87b3302..9eb4d24f1 100644 --- a/Where/WhereCore/Sources/Logging/DailySummaryReconcilerLog.swift +++ b/Where/WhereCore/Sources/Logging/DailySummaryReconcilerLog.swift @@ -1,26 +1,16 @@ import PeriscopeCore /// Structured events for `DailySummaryReconciler`. -enum DailySummaryReconcilerLog: LogEvent { - /// Names the reconciler's timed span. - enum SpanName: Hashable { - /// One recap reconcile: the year report, the ranked body, and the - /// scheduler round-trip. - case reconcile - } - - case reconcileFailed(description: String) - - static let eventName = "DailySummaryReconciler" - - var level: LogLevel { - .error - } +@LogScope("DailySummaryReconciler") +enum DailySummaryReconcilerLog { + enum SpanName: Hashable { case reconcile } - var message: String { - switch self { - case let .reconcileFailed(description): - "Failed to reconcile daily summary: \(description)" + @LogEvent("reconcile-failed", level: .error) + struct ReconcileFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to reconcile daily summary: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/DailySummarySchedulerLog.swift b/Where/WhereCore/Sources/Logging/DailySummarySchedulerLog.swift index c866de5e5..4657c7ab5 100644 --- a/Where/WhereCore/Sources/Logging/DailySummarySchedulerLog.swift +++ b/Where/WhereCore/Sources/Logging/DailySummarySchedulerLog.swift @@ -1,39 +1,49 @@ import PeriscopeCore -/// Structured events for `DailySummaryScheduler` — authorization outcomes and -/// the scheduling of the daily recap notification. -enum DailySummarySchedulerLog: LogEvent { - case authorizationRequestFailed(description: String) - case authorizationNotGranted - case authorizationUnknown - case scheduled(time: String) - case scheduleFailed(description: String) - - static let eventName = "DailySummaryScheduler" - - var level: LogLevel { - switch self { - case .authorizationRequestFailed, .scheduleFailed: - .error - case .authorizationNotGranted, .authorizationUnknown: - .warning - case .scheduled: - .info +/// Structured events for `DailySummaryScheduler`. +@LogScope("DailySummaryScheduler") +enum DailySummarySchedulerLog { + @LogEvent("authorization-request-failed", level: .error) + struct AuthorizationRequestFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Notification authorization request failed: \(description)" + } + } + + @LogEvent( + "authorization-not-granted", + level: .warning, + message: "Daily summary enabled but notification authorization not granted; summary disabled", + ) + struct AuthorizationNotGranted {} + + @LogEvent( + "authorization-unknown", + level: .warning, + message: "Daily summary enabled but notification authorization status is unknown; summary disabled", + ) + struct AuthorizationUnknown {} + + @LogEvent("scheduled", level: .info) + struct Scheduled { + @LogField("time", exposure: .restricted, kind: .dateTime) + var time: String + + var message: String { + "Scheduled daily summary at \(time)" } } - var message: String { - switch self { - case let .authorizationRequestFailed(description): - "Notification authorization request failed: \(description)" - case .authorizationNotGranted: - "Daily summary enabled but notification authorization not granted; summary disabled" - case .authorizationUnknown: - "Daily summary enabled but notification authorization status is unknown; summary disabled" - case let .scheduled(time): - "Scheduled daily summary at \(time)" - case let .scheduleFailed(description): - "Failed to schedule daily summary: \(description)" + @LogEvent("schedule-failed", level: .error) + struct ScheduleFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to schedule daily summary: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/DataIssueAlertReconcilerLog.swift b/Where/WhereCore/Sources/Logging/DataIssueAlertReconcilerLog.swift index 641034137..d5ea945fe 100644 --- a/Where/WhereCore/Sources/Logging/DataIssueAlertReconcilerLog.swift +++ b/Where/WhereCore/Sources/Logging/DataIssueAlertReconcilerLog.swift @@ -1,26 +1,15 @@ import PeriscopeCore -/// Structured events for `DataIssueAlertReconciler`. -enum DataIssueAlertReconcilerLog: LogEvent { - /// Names the reconciler's timed span. - enum SpanName: Hashable { - /// One alert reconcile: the unresolved-issue count (a scan, unless the - /// scanner's cache is warm) and the scheduler round-trip. - case reconcile - } - - case reconcileFailed(description: String) - - static let eventName = "DataIssueAlertReconciler" - - var level: LogLevel { - .error - } - - var message: String { - switch self { - case let .reconcileFailed(description): - "Failed to reconcile issue alerts: \(description)" +@LogScope("DataIssueAlertReconciler") +enum DataIssueAlertReconcilerLog { + enum SpanName: Hashable { case reconcile } + + @LogEvent("reconcile-failed", level: .error) + struct ReconcileFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to reconcile issue alerts: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/DataIssueAlertSchedulerLog.swift b/Where/WhereCore/Sources/Logging/DataIssueAlertSchedulerLog.swift index 2cb6e4d68..4227ad112 100644 --- a/Where/WhereCore/Sources/Logging/DataIssueAlertSchedulerLog.swift +++ b/Where/WhereCore/Sources/Logging/DataIssueAlertSchedulerLog.swift @@ -1,39 +1,49 @@ import PeriscopeCore -/// Structured events for `DataIssueAlertScheduler` — authorization outcomes and -/// the scheduling of the "issues to resolve" notification. -enum DataIssueAlertSchedulerLog: LogEvent { - case authorizationRequestFailed(description: String) - case authorizationNotGranted - case authorizationUnknown - case scheduled(time: String) - case scheduleFailed(description: String) - - static let eventName = "DataIssueAlertScheduler" - - var level: LogLevel { - switch self { - case .authorizationRequestFailed, .scheduleFailed: - .error - case .authorizationNotGranted, .authorizationUnknown: - .warning - case .scheduled: - .info +/// Structured events for `DataIssueAlertScheduler`. +@LogScope("DataIssueAlertScheduler") +enum DataIssueAlertSchedulerLog { + @LogEvent("authorization-request-failed", level: .error) + struct AuthorizationRequestFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Notification authorization request failed: \(description)" + } + } + + @LogEvent( + "authorization-not-granted", + level: .warning, + message: "Issue alerts enabled but notification authorization not granted; alert disabled", + ) + struct AuthorizationNotGranted {} + + @LogEvent( + "authorization-unknown", + level: .warning, + message: "Issue alerts enabled but notification authorization status is unknown; alert disabled", + ) + struct AuthorizationUnknown {} + + @LogEvent("scheduled", level: .info) + struct Scheduled { + @LogField("time", exposure: .restricted, kind: .dateTime) + var time: String + + var message: String { + "Scheduled issue alert at \(time)" } } - var message: String { - switch self { - case let .authorizationRequestFailed(description): - "Notification authorization request failed: \(description)" - case .authorizationNotGranted: - "Issue alerts enabled but notification authorization not granted; alert disabled" - case .authorizationUnknown: - "Issue alerts enabled but notification authorization status is unknown; alert disabled" - case let .scheduled(time): - "Scheduled issue alert at \(time)" - case let .scheduleFailed(description): - "Failed to schedule issue alert: \(description)" + @LogEvent("schedule-failed", level: .error) + struct ScheduleFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to schedule issue alert: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/DataIssueScannerLog.swift b/Where/WhereCore/Sources/Logging/DataIssueScannerLog.swift index 4c65baeaa..2cc0d314f 100644 --- a/Where/WhereCore/Sources/Logging/DataIssueScannerLog.swift +++ b/Where/WhereCore/Sources/Logging/DataIssueScannerLog.swift @@ -3,7 +3,8 @@ import PeriscopeCore /// Names `DataIssueScanner`'s timed spans, and nothing else — the scanner throws /// its read failures to the caller, so what's worth recording about it is what a /// scan costs. A span-only facade, like ``PresenceCalendarLog``. -struct DataIssueScannerLog: LogEvent { +@LogScope("DataIssueScanner") +enum DataIssueScannerLog { /// Names the scan spans (`log.measure(.scan) { … }`). /// /// `description` is written out because ``detect(_:)`` carries the detector's @@ -24,14 +25,6 @@ struct DataIssueScannerLog: LogEvent { } } } - - static let eventName = "DataIssueScanner" - - var message: String { - "" - } - - private init() {} } extension DataIssueCategory { diff --git a/Where/WhereCore/Sources/Logging/DayJournalLog.swift b/Where/WhereCore/Sources/Logging/DayJournalLog.swift index 39d7bd165..14dda9466 100644 --- a/Where/WhereCore/Sources/Logging/DayJournalLog.swift +++ b/Where/WhereCore/Sources/Logging/DayJournalLog.swift @@ -1,103 +1,98 @@ import PeriscopeCore -/// Structured events for `DayJournal`'s committed writes. The affected calendar -/// day (or year) rides on `externalID` so the tooling can pull every event -/// about one day. All are successful-operation `.info` events. -enum DayJournalLog: LogEvent { - /// Names the journal's timed spans: the writes whose cost scales with how - /// much the user asked for, plus the reconcile fan-out every write pays. - /// - /// Single-row writes (one manual day, one override, one evidence record, one - /// dismissal) aren't here — each is a `SwiftDataStore` commit followed by one - /// of the fan-outs below, and both are already spanned, so a span of their - /// own would only sum its two children. +/// Structured events and spans for `DayJournal`. +@LogScope("DayJournal") +enum DayJournalLog { enum SpanName: Hashable { - /// A bulk sample load in one transaction (fixtures, future imports). case ingestBatch - /// A date-range manual-day backfill in one transaction. case backfillDays - /// A multi-day overlay clear in one transaction. case clearManualDays - /// Deleting a whole year of samples and overlays. case clearYear - /// Emptying the store — the write half of the app's reset. case eraseAllData - /// The reconcile every committed write pays: invalidate the issue - /// scanner, then recount the badge and the issue notification. case reconcileIssueState - /// ``reconcileIssueState`` plus the widget republish, for writes that - /// changed persisted day data. Nests the former, so the difference between the two - /// spans is what WidgetKit cost. case reconcileAfterDayDataChange } - case addedManualDay(day: String, regionCount: Int) - case overrodeDay(day: String, regionCount: Int) - case clearedManualDay(day: String) - case clearedManualDays(dayCount: Int) - case backfilledManualDays(dayCount: Int, regionCount: Int) - case clearedYear(year: Int) - case erasedAllData - case wroteEvidence(id: String, hasBlob: Bool) + @LogEvent("added-manual-day") + struct AddedManualDay { + @LogField("day", exposure: .restricted, kind: .dateTime) var day: String + @LogField("region_count", exposure: .shareable, kind: .count) var regionCount: Int + var message: String { + "Added manual day \(day) with \(regionCount) region(s)" + } + + var externalID: String? { + WhereStoreID.day(day) + } + } - static let eventName = "DayJournal" + @LogEvent("overrode-day") + struct OverrodeDay { + @LogField("day", exposure: .restricted, kind: .dateTime) var day: String + @LogField("region_count", exposure: .shareable, kind: .count) var regionCount: Int + var message: String { + "Overrode day \(day) with \(regionCount) region(s)" + } - var message: String { - switch self { - case let .addedManualDay(day, regionCount): - "Added manual day \(day) with \(regionCount) region(s)" - case let .overrodeDay(day, regionCount): - "Overrode day \(day) with \(regionCount) region(s)" - case let .clearedManualDay(day): - "Cleared manual overlay for day \(day)" - case let .clearedManualDays(dayCount): - "Cleared manual overlays for \(dayCount) day(s)" - case let .backfilledManualDays(dayCount, regionCount): - "Backfilled \(dayCount) manual day(s) with \(regionCount) region(s)" - case let .clearedYear(year): - "Cleared year \(year)" - case .erasedAllData: - "Erased all store data" - case let .wroteEvidence(id, hasBlob): - "Wrote evidence \(id) (blob: \(hasBlob))" + var externalID: String? { + WhereStoreID.day(day) } } - var externalID: String? { - switch self { - case let .addedManualDay(day, _), let .overrodeDay(day, _), - let .clearedManualDay(day): - WhereStoreID.day(day) - case let .clearedYear(year): - WhereStoreID.year(year) - case let .wroteEvidence(id, _): - WhereStoreID.evidence(id) - case .clearedManualDays, .backfilledManualDays, .erasedAllData: - nil + @LogEvent("cleared-manual-day") + struct ClearedManualDay { + @LogField("day", exposure: .restricted, kind: .dateTime) var day: String + var message: String { + "Cleared manual overlay for day \(day)" + } + + var externalID: String? { + WhereStoreID.day(day) } } - var remoteFields: [RemoteLogField] { - switch self { - case let .addedManualDay(_, regionCount), let .overrodeDay(_, regionCount): - [RemoteLogField( - key: RemoteLogFieldKey("region_count"), - value: .count(regionCount), - )] - case let .clearedManualDays(dayCount): - [RemoteLogField(key: RemoteLogFieldKey("day_count"), value: .count(dayCount))] - case let .backfilledManualDays(dayCount, regionCount): - [ - RemoteLogField(key: RemoteLogFieldKey("day_count"), value: .count(dayCount)), - RemoteLogField( - key: RemoteLogFieldKey("region_count"), - value: .count(regionCount), - ), - ] - case let .wroteEvidence(_, hasBlob): - [RemoteLogField(key: RemoteLogFieldKey("has_blob"), value: .boolean(hasBlob))] - case .clearedManualDay, .clearedYear, .erasedAllData: - [] + @LogEvent("cleared-manual-days") + struct ClearedManualDays { + @LogField("day_count", exposure: .shareable, kind: .count) var dayCount: Int + var message: String { + "Cleared manual overlays for \(dayCount) day(s)" + } + } + + @LogEvent("backfilled-manual-days") + struct BackfilledManualDays { + @LogField("day_count", exposure: .shareable, kind: .count) var dayCount: Int + @LogField("region_count", exposure: .shareable, kind: .count) var regionCount: Int + var message: String { + "Backfilled \(dayCount) manual day(s) with \(regionCount) region(s)" + } + } + + @LogEvent("cleared-year") + struct ClearedYear { + @LogField("year", exposure: .restricted, kind: .domainValue) var year: Int + var message: String { + "Cleared year \(year)" + } + + var externalID: String? { + WhereStoreID.year(year) + } + } + + @LogEvent("erased-all-data", message: "Erased all store data") + struct ErasedAllData {} + + @LogEvent("wrote-evidence") + struct WroteEvidence { + @LogField("id", exposure: .restricted, kind: .identifier) var id: String + @LogField("has_blob", exposure: .shareable, kind: .boolean) var hasBlob: Bool + var message: String { + "Wrote evidence \(id) (blob: \(hasBlob))" + } + + var externalID: String? { + WhereStoreID.evidence(id) } } } diff --git a/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift index 4ab5c2518..37a202d81 100644 --- a/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift +++ b/Where/WhereCore/Sources/Logging/DeviceRecordingControllerLog.swift @@ -1,25 +1,32 @@ import PeriscopeCore /// Structured failures from local recording and synced-removal reconciliation. -enum DeviceRecordingControllerLog: LogEvent { - case policyObservationFailed(description: String) - case rollbackRecoveryFailed(description: String) - case importRecoveryFailed(description: String) - - static let eventName = "DeviceRecordingController" +@LogScope("DeviceRecordingController") +enum DeviceRecordingControllerLog { + @LogEvent("policy-observation-failed", level: .error) + struct PolicyObservationFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to reconcile recording state; recording was stopped: \(description)" + } + } - var level: LogLevel { - .error + @LogEvent("rollback-recovery-failed", level: .error) + struct RollbackRecoveryFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to restore recording after an operation rolled back: \(description)" + } } - var message: String { - switch self { - case let .policyObservationFailed(description): - "Failed to reconcile recording state; recording was stopped: \(description)" - case let .rollbackRecoveryFailed(description): - "Failed to restore recording after an operation rolled back: \(description)" - case let .importRecoveryFailed(description): - "Backup committed, but recording could not be restored and was stopped: \(description)" + @LogEvent("import-recovery-failed", level: .error) + struct ImportRecoveryFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Backup committed, but recording could not be restored and was stopped: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift b/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift index 32a4dbdfe..ad60737af 100644 --- a/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift +++ b/Where/WhereCore/Sources/Logging/LocationIngestorLog.swift @@ -1,156 +1,120 @@ import PeriscopeCore -/// Structured events for `LocationIngestor`'s GPS lifecycle, one-shot capture, -/// and the durable retry queue. Persist failures carry the offending sample id -/// on `externalID` so the tooling can trace one sample across retries. -enum LocationIngestorLog: LogEvent { - private enum RemoteKind: String, CaseIterable { - case monitoringStarted = "monitoring-started" - case monitoringStopped = "monitoring-stopped" - case restoredBacklog = "restored-backlog" - case quiesced - case todayIntervalUnavailable = "today-interval-unavailable" - case foregroundCaptureReadFailed = "foreground-capture-read-failed" - case capturedForegroundFix = "captured-foreground-fix" - case persistFailed = "persist-failed" - case retryBacklogPersistenceFailed = "retry-backlog-persistence-failed" - case retryQueueAtCapacity = "retry-queue-at-capacity" - case retryStillFailing = "retry-still-failing" - case drainedBacklog = "drained-backlog" - } - - /// Names the ingestor's timed spans. - /// - /// The single-sample commit isn't here — `SwiftDataStore` already spans every - /// transaction, and a second span around the same `perform` would only - /// restate it. What's spanned instead is everything *around* the write that - /// nothing else measures: waiting on CoreLocation, working through a - /// backlog, and the reconcile fan-out a persisted sample triggers. +/// Structured events for `LocationIngestor`'s GPS lifecycle and durable retry queue. +@LogScope("LocationIngestor") +enum LocationIngestorLog { enum SpanName: Hashable { - /// Waiting on the one-shot GPS fix behind `captureTodayIfNeeded(now:)`. - /// The slowest thing the ingestor does by an order of magnitude, and it - /// runs on a launch step's tail, so it's budgeted at CoreLocation's own - /// rough ceiling. case acquireFix - /// Re-persisting a retry backlog, one transaction per queued sample. - /// Only a non-empty drain is spanned. case drainBacklog - /// The post-persist reconcile fan-out (badge/reminders, issue alerts, - /// widget snapshot). Runs on the hot GPS path, so its cost is the - /// ingestor's, not the caller's. case postPersist } - case monitoringStarted - case monitoringStopped - case restoredBacklog(count: Int) - case quiesced - case todayIntervalUnavailable - case foregroundCaptureReadFailed(description: String) - case capturedForegroundFix - case persistFailed(sampleID: String, description: String) - case retryBacklogPersistenceFailed(description: String) - case retryQueueAtCapacity(capacity: Int) - case retryStillFailing(sampleID: String, description: String) - case drainedBacklog(sampleCount: Int, dayCount: Int) - - static let eventName = "LocationIngestor" - - var level: LogLevel { - switch self { - case .monitoringStarted, .monitoringStopped, .restoredBacklog, .quiesced, - .capturedForegroundFix, .drainedBacklog: - .info - case .todayIntervalUnavailable, .foregroundCaptureReadFailed, .retryQueueAtCapacity: - .warning - case .persistFailed, .retryBacklogPersistenceFailed, .retryStillFailing: - .error + @LogEvent("monitoring-started", message: "GPS monitoring started") + struct MonitoringStarted {} + + @LogEvent("monitoring-stopped", message: "GPS monitoring stopped") + struct MonitoringStopped {} + + @LogEvent("restored-backlog") + struct RestoredBacklog { + @LogField("backlog_count", exposure: .shareable, kind: .count) + var count: Int + + var message: String { + "Restored \(count) sample(s) from durable retry backlog" } } - var message: String { - switch self { - case .monitoringStarted: - "GPS monitoring started" - case .monitoringStopped: - "GPS monitoring stopped" - case let .restoredBacklog(count): - "Restored \(count) sample(s) from durable retry backlog" - case .quiesced: - "GPS ingestion quiesced; retry backlog cleared" - case .todayIntervalUnavailable: - "Could not compute today's interval for foreground capture" - case let .foregroundCaptureReadFailed(description): - "Skipping foreground capture; could not read today's samples: \(description)" - case .capturedForegroundFix: - "Captured one-shot foreground location for today" - case let .persistFailed(sampleID, description): - "Failed to persist GPS sample \(sampleID): \(description)" - case let .retryBacklogPersistenceFailed(description): - "Failed to durably persist the GPS retry backlog; stopping recording: \(description)" - case let .retryQueueAtCapacity(capacity): - "Retry queue at capacity (\(capacity)); dropping oldest queued GPS sample" - case let .retryStillFailing(sampleID, description): - "Retry still failing for GPS sample \(sampleID): \(description)" - case let .drainedBacklog(sampleCount, dayCount): - "Drained retry backlog: persisted \(sampleCount) sample(s) across \(dayCount) day(s)" + @LogEvent("quiesced", message: "GPS ingestion quiesced; retry backlog cleared") + struct Quiesced {} + + @LogEvent( + "today-interval-unavailable", + level: .warning, + message: "Could not compute today's interval for foreground capture", + ) + struct TodayIntervalUnavailable {} + + @LogEvent("foreground-capture-read-failed", level: .warning) + struct ForegroundCaptureReadFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Skipping foreground capture; could not read today's samples: \(description)" } } - var externalID: String? { - switch self { - case let .persistFailed(sampleID, _), let .retryStillFailing(sampleID, _): - WhereStoreID.sample(sampleID) - case .monitoringStarted, .monitoringStopped, .restoredBacklog, .quiesced, - .todayIntervalUnavailable, .foregroundCaptureReadFailed, .capturedForegroundFix, - .retryBacklogPersistenceFailed, .retryQueueAtCapacity, .drainedBacklog: - nil + @LogEvent( + "captured-foreground-fix", + message: "Captured one-shot foreground location for today", + ) + struct CapturedForegroundFix {} + + @LogEvent("persist-failed", level: .error) + struct PersistFailed { + @LogField("sample_id", exposure: .restricted, kind: .identifier) + var sampleID: String + + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to persist GPS sample \(sampleID): \(description)" + } + + var externalID: String? { + WhereStoreID.sample(sampleID) } } - var remoteFields: [RemoteLogField] { - var fields = [RemoteLogField.eventKind(remoteKind)] - switch self { - case let .restoredBacklog(count): - fields.append(RemoteLogField( - key: RemoteLogFieldKey("backlog_count"), - value: .count(count), - )) - case let .retryQueueAtCapacity(capacity): - fields.append(RemoteLogField( - key: RemoteLogFieldKey("capacity"), - value: .count(capacity), - )) - case let .drainedBacklog(sampleCount, dayCount): - fields.append(contentsOf: [ - RemoteLogField( - key: RemoteLogFieldKey("sample_count"), - value: .count(sampleCount), - ), - RemoteLogField(key: RemoteLogFieldKey("day_count"), value: .count(dayCount)), - ]) - case .monitoringStarted, .monitoringStopped, .quiesced, .todayIntervalUnavailable, - .foregroundCaptureReadFailed, .capturedForegroundFix, .persistFailed, - .retryBacklogPersistenceFailed, .retryStillFailing: - break + @LogEvent("retry-backlog-persistence-failed", level: .error) + struct RetryBacklogPersistenceFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to durably persist the GPS retry backlog; stopping recording: \(description)" } - return fields } - private var remoteKind: RemoteKind { - switch self { - case .monitoringStarted: .monitoringStarted - case .monitoringStopped: .monitoringStopped - case .restoredBacklog: .restoredBacklog - case .quiesced: .quiesced - case .todayIntervalUnavailable: .todayIntervalUnavailable - case .foregroundCaptureReadFailed: .foregroundCaptureReadFailed - case .capturedForegroundFix: .capturedForegroundFix - case .persistFailed: .persistFailed - case .retryBacklogPersistenceFailed: .retryBacklogPersistenceFailed - case .retryQueueAtCapacity: .retryQueueAtCapacity - case .retryStillFailing: .retryStillFailing - case .drainedBacklog: .drainedBacklog + @LogEvent("retry-queue-at-capacity", level: .warning) + struct RetryQueueAtCapacity { + @LogField("capacity", exposure: .shareable, kind: .count) + var capacity: Int + + var message: String { + "Retry queue at capacity (\(capacity)); dropping oldest queued GPS sample" + } + } + + @LogEvent("retry-still-failing", level: .error) + struct RetryStillFailing { + @LogField("sample_id", exposure: .restricted, kind: .identifier) + var sampleID: String + + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Retry still failing for GPS sample \(sampleID): \(description)" + } + + var externalID: String? { + WhereStoreID.sample(sampleID) + } + } + + @LogEvent("drained-backlog") + struct DrainedBacklog { + @LogField("sample_count", exposure: .shareable, kind: .count) + var sampleCount: Int + + @LogField("day_count", exposure: .shareable, kind: .count) + var dayCount: Int + + var message: String { + "Drained retry backlog: persisted \(sampleCount) sample(s) across \(dayCount) day(s)" } } } diff --git a/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift b/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift index 9c0a48a0d..36df77b53 100644 --- a/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift +++ b/Where/WhereCore/Sources/Logging/LocationOutboxLog.swift @@ -1,47 +1,69 @@ import PeriscopeCore -/// Structured events for `FileLocationOutbox`, the durable mirror of the GPS -/// retry queue. A missing Application Support directory is degraded-but-handled -/// (`.warning`); read/write and backup-exclusion failures are surfaced as -/// `.error`. -enum LocationOutboxLog: LogEvent { - case noApplicationSupport - case droppedUnreadableBacklog(description: String) - case readBacklogFailed(description: String) - case recoveredTornJournal - case persistBacklogFailed(description: String) - case excludeFromBackupFailed(description: String) - case discardInsecureBacklogFailed(description: String) - - static let eventName = "LocationOutbox" - - var level: LogLevel { - switch self { - case .noApplicationSupport, .recoveredTornJournal: .warning - case .droppedUnreadableBacklog, - .readBacklogFailed, - .persistBacklogFailed, - .excludeFromBackupFailed, - .discardInsecureBacklogFailed: .error +/// Structured events for `FileLocationOutbox`. +@LogScope("LocationOutbox") +enum LocationOutboxLog { + @LogEvent( + "no-application-support", + level: .warning, + message: "No Application Support directory; using in-memory retry queue (backlog won't survive relaunch)", + ) + struct NoApplicationSupport {} + + @LogEvent("dropped-unreadable-backlog", level: .error) + struct DroppedUnreadableBacklog { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Dropping unreadable location retry backlog: \(description)" + } + } + + @LogEvent("read-backlog-failed", level: .error) + struct ReadBacklogFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to read location retry backlog; preserving it for retry: \(description)" + } + } + + @LogEvent( + "recovered-torn-journal", + level: .warning, + message: "Recovered the last intact location retry snapshot after a torn journal entry", + ) + struct RecoveredTornJournal {} + + @LogEvent("persist-backlog-failed", level: .error) + struct PersistBacklogFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to persist location retry backlog: \(description)" + } + } + + @LogEvent("exclude-from-backup-failed", level: .error) + struct ExcludeFromBackupFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to exclude location retry backlog from device backup: \(description)" } } - var message: String { - switch self { - case .noApplicationSupport: - "No Application Support directory; using in-memory retry queue (backlog won't survive relaunch)" - case let .droppedUnreadableBacklog(description): - "Dropping unreadable location retry backlog: \(description)" - case let .readBacklogFailed(description): - "Failed to read location retry backlog; preserving it for retry: \(description)" - case .recoveredTornJournal: - "Recovered the last intact location retry snapshot after a torn journal entry" - case let .persistBacklogFailed(description): - "Failed to persist location retry backlog: \(description)" - case let .excludeFromBackupFailed(description): - "Failed to exclude location retry backlog from device backup: \(description)" - case let .discardInsecureBacklogFailed(description): - "Failed to discard a backup-eligible location retry backlog: \(description)" + @LogEvent("discard-insecure-backlog-failed", level: .error) + struct DiscardInsecureBacklogFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to discard a backup-eligible location retry backlog: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/LoggingReminderSchedulerLog.swift b/Where/WhereCore/Sources/Logging/LoggingReminderSchedulerLog.swift index 58c1bc53e..bee8cf6ad 100644 --- a/Where/WhereCore/Sources/Logging/LoggingReminderSchedulerLog.swift +++ b/Where/WhereCore/Sources/Logging/LoggingReminderSchedulerLog.swift @@ -1,68 +1,66 @@ import PeriscopeCore -/// Structured events for `LoggingReminderScheduler` — authorization outcomes and -/// the reconcile of scheduled/removed reminders + badge. -enum LoggingReminderSchedulerLog: LogEvent { - /// Names the scheduler's timed span. The sibling summary/issue-alert - /// schedulers stay unspanned: each is a single add-or-remove, whereas this - /// one walks the pending *and* delivered sets and can add a week of - /// requests — several `UNUserNotificationCenter` round-trips, all of them - /// cross-process. +/// Structured events and spans for `LoggingReminderScheduler`. +@LogScope("LoggingReminderScheduler") +enum LoggingReminderSchedulerLog { enum SpanName: Hashable { case reconcileNotifications } - case authorizationRequestFailed(description: String) - case authorizationNotGranted - case authorizationUnknown - case reconciled(scheduled: Int, removed: Int, badge: Int) - case scheduleFailed(identifier: String, description: String) - case badgeUpdateFailed(description: String) + @LogEvent("authorization-request-failed", level: .error) + struct AuthorizationRequestFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Notification authorization request failed: \(description)" + } + } + + @LogEvent( + "authorization-not-granted", + level: .warning, + message: "Logging reminders enabled but notification authorization not granted; reminders disabled", + ) + struct AuthorizationNotGranted {} + + @LogEvent( + "authorization-unknown", + level: .warning, + message: "Logging reminders enabled but notification authorization status is unknown; reminders disabled", + ) + struct AuthorizationUnknown {} - static let eventName = "LoggingReminderScheduler" + @LogEvent("reconciled", level: .info) + struct Reconciled { + @LogField("scheduled_count", exposure: .shareable, kind: .count) + var scheduled: Int + @LogField("removed_count", exposure: .shareable, kind: .count) + var removed: Int + @LogField("badge_count", exposure: .shareable, kind: .count) + var badge: Int - var level: LogLevel { - switch self { - case .authorizationRequestFailed, .scheduleFailed, .badgeUpdateFailed: - .error - case .authorizationNotGranted, .authorizationUnknown: - .warning - case .reconciled: - .info + var message: String { + "Reconciled logging reminders (scheduled \(scheduled), removed \(removed); badge: \(badge))" } } - var message: String { - switch self { - case let .authorizationRequestFailed(description): - "Notification authorization request failed: \(description)" - case .authorizationNotGranted: - "Logging reminders enabled but notification authorization not granted; reminders disabled" - case .authorizationUnknown: - "Logging reminders enabled but notification authorization status is unknown; reminders disabled" - case let .reconciled(scheduled, removed, badge): - "Reconciled logging reminders (scheduled \(scheduled), removed \(removed); badge: \(badge))" - case let .scheduleFailed(identifier, description): - "Failed to schedule reminder \(identifier): \(description)" - case let .badgeUpdateFailed(description): - "Failed to set badge count: \(description)" + @LogEvent("schedule-failed", level: .error) + struct ScheduleFailed { + @LogField("identifier", exposure: .restricted, kind: .identifier) + var identifier: String + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to schedule reminder \(identifier): \(description)" } } - var remoteFields: [RemoteLogField] { - switch self { - case let .reconciled(scheduled, removed, badge): - [ - RemoteLogField( - key: RemoteLogFieldKey("scheduled_count"), - value: .count(scheduled), - ), - RemoteLogField(key: RemoteLogFieldKey("removed_count"), value: .count(removed)), - RemoteLogField(key: RemoteLogFieldKey("badge_count"), value: .count(badge)), - ] - case .authorizationRequestFailed, .authorizationNotGranted, .authorizationUnknown, - .scheduleFailed, .badgeUpdateFailed: - [] + @LogEvent("badge-update-failed", level: .error) + struct BadgeUpdateFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to set badge count: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/PresenceCalendarLog.swift b/Where/WhereCore/Sources/Logging/PresenceCalendarLog.swift index ba80ea0d7..992a02ee0 100644 --- a/Where/WhereCore/Sources/Logging/PresenceCalendarLog.swift +++ b/Where/WhereCore/Sources/Logging/PresenceCalendarLog.swift @@ -8,7 +8,8 @@ import PeriscopeCore /// "emit a `PresenceCalendarLog`" is unspellable and the type can only ever name /// a scope and its spans. (It can't be an uninhabited enum — `LogEvent` is /// `Codable`, which the compiler won't synthesize for one.) -struct PresenceCalendarLog: LogEvent { +@LogScope("PresenceCalendar") +enum PresenceCalendarLog { /// Names the layout spans (`log.measure(.layoutYear) { … }`). enum SpanName: Hashable { /// Laying out a whole year's month grids. Pure CPU on an already-loaded @@ -18,12 +19,4 @@ struct PresenceCalendarLog: LogEvent { /// record than they'd explain. case layoutYear } - - static let eventName = "PresenceCalendar" - - var message: String { - "" - } - - private init() {} } diff --git a/Where/WhereCore/Sources/Logging/RegionAttributionLog.swift b/Where/WhereCore/Sources/Logging/RegionAttributionLog.swift index fc81cd112..43b845e67 100644 --- a/Where/WhereCore/Sources/Logging/RegionAttributionLog.swift +++ b/Where/WhereCore/Sources/Logging/RegionAttributionLog.swift @@ -1,28 +1,15 @@ import PeriscopeCore -/// Structured events for `RegionAttribution`, the live attributor rebuild that -/// tracks the user's tracked-region set. A failed read leaves the prior -/// attributor in place, so it's degraded-but-handled (`.warning`). -enum RegionAttributionLog: LogEvent { - /// Names the rebuild span. Only an actual change is spanned — reconciling an - /// unchanged tracked set is a fetch and a set compare, and it runs on every - /// store commit, so spanning it would bury the rebuilds it exists to show. - enum SpanName: Hashable { - case rebuild - } - - case trackedRegionsReadFailed(description: String) - - static let eventName = "RegionAttribution" - - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .trackedRegionsReadFailed(description): - "Failed to read tracked regions for attributor rebuild: \(description)" +@LogScope("RegionAttribution") +enum RegionAttributionLog { + enum SpanName: Hashable { case rebuild } + + @LogEvent("tracked-regions-read-failed", level: .warning) + struct TrackedRegionsReadFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to read tracked regions for attributor rebuild: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/ReminderReconcilerLog.swift b/Where/WhereCore/Sources/Logging/ReminderReconcilerLog.swift index 59d0d6798..2c381084d 100644 --- a/Where/WhereCore/Sources/Logging/ReminderReconcilerLog.swift +++ b/Where/WhereCore/Sources/Logging/ReminderReconcilerLog.swift @@ -1,37 +1,29 @@ import PeriscopeCore -/// Structured events for `ReminderReconciler`. Failing to reconcile the schedule -/// is an outright failure (`.error`); failing only the badge scan is -/// degraded-but-handled (`.warning`). -enum ReminderReconcilerLog: LogEvent { - /// Names the reconciler's timed span. +/// Structured events for `ReminderReconciler`. +@LogScope("ReminderReconciler") +enum ReminderReconcilerLog { enum SpanName: Hashable { - /// One badge + schedule reconcile: the year report, the issue scan - /// folded into the badge, and the scheduler round-trip. `reconcile()` is - /// the app's most-run derived-state refresh (every launch, foreground, - /// write, and settings change), so its span is the honest measure of - /// "what a write costs after it commits". case reconcile } - case reconcileFailed(description: String) - case badgeScanFailed(description: String) + @LogEvent("reconcile-failed", level: .error) + struct ReconcileFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - static let eventName = "ReminderReconciler" - - var level: LogLevel { - switch self { - case .reconcileFailed: .error - case .badgeScanFailed: .warning + var message: String { + "Failed to reconcile logging reminders: \(description)" } } - var message: String { - switch self { - case let .reconcileFailed(description): - "Failed to reconcile logging reminders: \(description)" - case let .badgeScanFailed(description): - "Failed to scan data issues for badge: \(description)" + @LogEvent("badge-scan-failed", level: .warning) + struct BadgeScanFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to scan data issues for badge: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/ReportReaderLog.swift b/Where/WhereCore/Sources/Logging/ReportReaderLog.swift index 4913096f4..471099471 100644 --- a/Where/WhereCore/Sources/Logging/ReportReaderLog.swift +++ b/Where/WhereCore/Sources/Logging/ReportReaderLog.swift @@ -7,7 +7,8 @@ import PeriscopeCore /// the read path its own log scope and a compiler-checked set of span names. /// A span-only facade, like ``PresenceCalendarLog``: `private init()` leaves no /// way to construct one, so it can never be emitted as an event. -struct ReportReaderLog: LogEvent { +@LogScope("ReportReader") +enum ReportReaderLog { /// Names the read spans (`log.measure(.yearReport) { … }`). Each is one store /// fetch plus the aggregation over it; the fetch itself is spanned separately /// by `SwiftDataStore`, so the difference between the two is compute. @@ -25,12 +26,4 @@ struct ReportReaderLog: LogEvent { /// One representative coordinate per region for a year. case representativeCoordinates } - - static let eventName = "ReportReader" - - var message: String { - "" - } - - private init() {} } diff --git a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift index 22d85316d..14584b864 100644 --- a/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift +++ b/Where/WhereCore/Sources/Logging/SwiftDataStoreLog.swift @@ -1,103 +1,81 @@ import PeriscopeCore -/// Structured events for `SwiftDataStore` — store open (with the resolved -/// on-disk path / App Group state) and the data-integrity guards. A dropped -/// corrupt record is a programmer error, so it logs at `.fault`. -enum SwiftDataStoreLog: LogEvent { - /// Names the store's timed spans (`log.measure(.open) { … }`). - /// - /// Only the reads a normal session leans on are spanned, and only where the - /// row count grows with the user's history: the small fixed-size fetches - /// (tracked/primary regions, dismissals) would add records to every store - /// change without ever explaining a slow screen, and the whole-table reads - /// belong to the backup export that spans itself. Leaves like these carry no - /// budget — the operation that asked for them does. +/// Structured events and spans for `SwiftDataStore`. +@LogScope("SwiftDataStore") +enum SwiftDataStoreLog { enum SpanName: Hashable { case open - /// A windowed GPS-sample fetch — the query behind every year report. case fetchSamples - /// A day-range manual-day fetch. case fetchManualDays - /// A windowed evidence fetch. case fetchEvidence - /// One evidence blob, read out of external storage. case fetchEvidenceBlob - /// The outermost `perform` of a write transaction, up to and including - /// the save — so every batched mutation lands in one measured commit. case commit } - /// Opened an in-memory store (tests/previews). - case openedInMemory(mode: String) - /// Opened the on-disk store, reporting whether the App Group container - /// resolved and the resolved database URL. - case openedOnDisk(mode: String, appGroupResolved: Bool, url: String) - /// Ignored tracked-region ids the current catalog doesn't know (a store - /// written by a newer catalog version). The ids persist as a structured - /// list; formatting is done at display time (see `message`). - case ignoredUnknownTrackedRegions(ids: [String]) - /// Ignored primary-region ids the current catalog doesn't know (a store - /// written by a newer catalog version). - case ignoredUnknownPrimaryRegions(ids: [String]) - /// Dropped a record that failed to materialize into a domain value. - case droppedCorruptRecord(type: String) - /// Chose a deterministic value when CloudKit delivered conflicting rows for an immutable id. - case resolvedConflictingImmutableRecords(type: String, id: String, count: Int) - /// Persistent history could not distinguish a local save from an external import; the - /// observer fails open and performs the remote reconciliation rather than miss new data. - case remoteChangeClassificationFailed(description: String) + @LogEvent("opened-in-memory") + struct OpenedInMemory { + @LogField("mode", exposure: .restricted, kind: .technicalState) var mode: String + var message: String { + "Opened SwiftData store (mode: \(mode))" + } + } + + @LogEvent("opened-on-disk") + struct OpenedOnDisk { + @LogField("mode", exposure: .restricted, kind: .technicalState) var mode: String + @LogField("app_group_resolved", exposure: .shareable, kind: .boolean) + var appGroupResolved: Bool + @LogField("url", exposure: .restricted, kind: .pathOrURL) var url: String + var message: String { + "Opened SwiftData store (mode: \(mode), appGroupResolved: " + + "\(appGroupResolved), url: \(url))" + } + } - static let eventName = "SwiftDataStore" + @LogEvent("ignored-unknown-tracked-regions", level: .warning) + struct IgnoredUnknownTrackedRegions { + @LogField("ids", exposure: .restricted, kind: .location) var ids: [String] + @LogField("unknown_region_count", exposure: .shareable, kind: .count) + var unknownRegionCount: Int + var message: String { + "Ignored \(ids.count) unknown tracked-region id(s): \(ids.joined(separator: ", "))" + } + } + + @LogEvent("ignored-unknown-primary-regions", level: .warning) + struct IgnoredUnknownPrimaryRegions { + @LogField("ids", exposure: .restricted, kind: .location) var ids: [String] + @LogField("unknown_region_count", exposure: .shareable, kind: .count) + var unknownRegionCount: Int + var message: String { + "Ignored \(ids.count) unknown primary-region id(s): \(ids.joined(separator: ", "))" + } + } - var level: LogLevel { - switch self { - case .openedInMemory, .openedOnDisk: .info - case .ignoredUnknownTrackedRegions, - .ignoredUnknownPrimaryRegions, - .remoteChangeClassificationFailed: - .warning - case .droppedCorruptRecord, .resolvedConflictingImmutableRecords: .fault + @LogEvent("dropped-corrupt-record", level: .fault) + struct DroppedCorruptRecord { + @LogField("type", exposure: .restricted, kind: .technicalState) var type: String + var message: String { + "Dropped corrupt SwiftData record of type \(type)" } } - var message: String { - switch self { - case let .openedInMemory(mode): - "Opened SwiftData store (mode: \(mode))" - case let .openedOnDisk(mode, appGroupResolved, url): - "Opened SwiftData store (mode: \(mode), appGroupResolved: \(appGroupResolved), url: \(url))" - case let .ignoredUnknownTrackedRegions(ids): - "Ignored \(ids.count) unknown tracked-region id(s): \(ids.joined(separator: ", "))" - case let .ignoredUnknownPrimaryRegions(ids): - "Ignored \(ids.count) unknown primary-region id(s): \(ids.joined(separator: ", "))" - case let .droppedCorruptRecord(type): - "Dropped corrupt SwiftData record of type \(type)" - case let .resolvedConflictingImmutableRecords(type, id, count): - "Resolved \(count) conflicting immutable \(type) records for id \(id)" - case let .remoteChangeClassificationFailed(description): - "Could not classify persistent-store change; reconciling defensively: \(description)" + @LogEvent("resolved-conflicting-immutable-records", level: .fault) + struct ResolvedConflictingImmutableRecords { + @LogField("type", exposure: .restricted, kind: .technicalState) var type: String + @LogField("id", exposure: .restricted, kind: .identifier) var id: String + @LogField("conflict_count", exposure: .shareable, kind: .count) var count: Int + var message: String { + "Resolved \(count) conflicting immutable \(type) records for id \(id)" } } - var remoteFields: [RemoteLogField] { - switch self { - case let .openedOnDisk(_, appGroupResolved, _): - [RemoteLogField( - key: RemoteLogFieldKey("app_group_resolved"), - value: .boolean(appGroupResolved), - )] - case let .ignoredUnknownTrackedRegions(ids), let .ignoredUnknownPrimaryRegions(ids): - [RemoteLogField( - key: RemoteLogFieldKey("unknown_region_count"), - value: .count(ids.count), - )] - case let .resolvedConflictingImmutableRecords(_, _, count): - [RemoteLogField( - key: RemoteLogFieldKey("conflict_count"), - value: .count(count), - )] - case .openedInMemory, .droppedCorruptRecord, .remoteChangeClassificationFailed: - [] + @LogEvent("remote-change-classification-failed", level: .warning) + struct RemoteChangeClassificationFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Could not classify persistent-store change; reconciling defensively: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/WhereLog.swift b/Where/WhereCore/Sources/Logging/WhereLog.swift index dc9dddb02..3cc9f92f1 100644 --- a/Where/WhereCore/Sources/Logging/WhereLog.swift +++ b/Where/WhereCore/Sources/Logging/WhereLog.swift @@ -1,15 +1,8 @@ import PeriscopeCore -/// Phantom root event naming the Where app's log scope tree. It is never -/// emitted — its only job is to give ``WhereLog``'s root `Log` the scope name -/// `"Where"`, so every app event sits under one filterable subtree in the -/// process-wide `Periscope.shared` system. -public struct WhereRoot: LogEvent { - public static let eventName = "Where" - public var message: String { - "" - } -} +/// Root namespace for the Where app's log scope tree. +@LogScope("Where") +public enum WhereRoot {} /// Central logging facade for the Where app and its modules. /// diff --git a/Where/WhereCore/Sources/Logging/WidgetPresentationPublisherLog.swift b/Where/WhereCore/Sources/Logging/WidgetPresentationPublisherLog.swift index 070869286..2453e9200 100644 --- a/Where/WhereCore/Sources/Logging/WidgetPresentationPublisherLog.swift +++ b/Where/WhereCore/Sources/Logging/WidgetPresentationPublisherLog.swift @@ -1,25 +1,23 @@ import PeriscopeCore /// Structured events for publishing the widget presentation theme. -enum WidgetPresentationPublisherLog: LogEvent { - case published(theme: String) - case publishFailed(description: String) - - static let eventName = "WidgetPresentationPublisher" - - var level: LogLevel { - switch self { - case .published: .info - case .publishFailed: .error +@LogScope("WidgetPresentationPublisher") +enum WidgetPresentationPublisherLog { + @LogEvent("published") + struct Published { + @LogField("theme", exposure: .restricted, kind: .technicalState) + var theme: String + var message: String { + "Published widget presentation theme \(theme)" } } - var message: String { - switch self { - case let .published(theme): - "Published widget presentation theme \(theme)" - case let .publishFailed(description): - "Failed to publish widget presentation: \(description)" + @LogEvent("publish-failed", level: .error) + struct PublishFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to publish widget presentation: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/WidgetPresentationStoreLog.swift b/Where/WhereCore/Sources/Logging/WidgetPresentationStoreLog.swift index bfc037555..60cfedc6c 100644 --- a/Where/WhereCore/Sources/Logging/WidgetPresentationStoreLog.swift +++ b/Where/WhereCore/Sources/Logging/WidgetPresentationStoreLog.swift @@ -1,19 +1,15 @@ import PeriscopeCore /// Structured events for an unreadable widget presentation file. -enum WidgetPresentationStoreLog: LogEvent { - case unreadablePresentation(description: String) - - static let eventName = "WidgetPresentationStore" - - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .unreadablePresentation(description): - "Discarded unreadable widget presentation: \(description)" +@LogScope("WidgetPresentationStore") +enum WidgetPresentationStoreLog { + @LogEvent("unreadable-presentation", level: .warning) + struct UnreadablePresentation { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Discarded unreadable widget presentation: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/WidgetSnapshotPublisherLog.swift b/Where/WhereCore/Sources/Logging/WidgetSnapshotPublisherLog.swift index cd67ba6fd..b786c8bf5 100644 --- a/Where/WhereCore/Sources/Logging/WidgetSnapshotPublisherLog.swift +++ b/Where/WhereCore/Sources/Logging/WidgetSnapshotPublisherLog.swift @@ -1,57 +1,36 @@ import PeriscopeCore -/// Structured events for `WidgetSnapshotPublisher`. A publish records the day -/// and region count; a build failure surfaces as `.error`. -enum WidgetSnapshotPublisherLog: LogEvent { - /// Names the publisher's timed spans. +/// Structured events and spans for `WidgetSnapshotPublisher`. +@LogScope("WidgetSnapshotPublisher") +enum WidgetSnapshotPublisherLog { enum SpanName: Hashable { - /// Rebuilding the snapshot from the store and handing it to WidgetKit. - /// Spanned here rather than in `WidgetDataReader` because the reader is - /// only ever driven from this actor, and the WidgetKit reload the - /// publish ends with is part of what a caller waits for. The skip paths - /// (`refreshIfStale`, `publishAfterIngest`) are deliberately outside, so - /// the span history counts rebuilds rather than the far more numerous - /// times a rebuild was avoided. case publish } - case published(day: String, regionCount: Int) - case buildFailed(description: String) + @LogEvent("published", level: .info) + struct Published { + @LogField("day", exposure: .restricted, kind: .dateTime) + var day: String - static let eventName = "WidgetSnapshotPublisher" + @LogField("region_count", exposure: .shareable, kind: .count) + var regionCount: Int - var level: LogLevel { - switch self { - case .published: .info - case .buildFailed: .error + var message: String { + "Published widget snapshot for \(day) (\(regionCount) region(s))" } - } - var message: String { - switch self { - case let .published(day, regionCount): - "Published widget snapshot for \(day) (\(regionCount) region(s))" - case let .buildFailed(description): - "Failed to build widget snapshot: \(description)" + var externalID: String? { + WhereStoreID.day(day) } } - var externalID: String? { - switch self { - case let .published(day, _): WhereStoreID.day(day) - case .buildFailed: nil - } - } + @LogEvent("build-failed", level: .error) + struct BuildFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - var remoteFields: [RemoteLogField] { - switch self { - case let .published(_, regionCount): - [RemoteLogField( - key: RemoteLogFieldKey("region_count"), - value: .count(regionCount), - )] - case .buildFailed: - [] + var message: String { + "Failed to build widget snapshot: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/WidgetSnapshotStoreLog.swift b/Where/WhereCore/Sources/Logging/WidgetSnapshotStoreLog.swift index 10257ac4d..2528a8f0e 100644 --- a/Where/WhereCore/Sources/Logging/WidgetSnapshotStoreLog.swift +++ b/Where/WhereCore/Sources/Logging/WidgetSnapshotStoreLog.swift @@ -1,27 +1,15 @@ import PeriscopeCore /// Structured events for `WidgetSnapshotStore`'s read path. -/// -/// `read()` answers `nil` for two very different situations — nothing published -/// yet (a fresh install, and the widget's normal placeholder cue) and a file that -/// exists but won't decode (a truncated write, a stale format). Only the second -/// is a failure, so only the second logs: a warning, because the widget still has -/// an honest empty state to render and the next publish overwrites the bad file. -enum WidgetSnapshotStoreLog: LogEvent { - /// The snapshot file exists but couldn't be decoded, so the widget renders - /// its empty state as though nothing had been published. - case unreadableSnapshot(description: String) - - static let eventName = "WidgetSnapshotStore" - - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .unreadableSnapshot(description): - "Discarded an unreadable widget snapshot file: \(description)" +@LogScope("WidgetSnapshotStore") +enum WidgetSnapshotStoreLog { + @LogEvent("unreadable-snapshot", level: .warning) + struct UnreadableSnapshot { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Discarded an unreadable widget snapshot file: \(description)" } } } diff --git a/Where/WhereCore/Sources/Logging/WidgetTimelineRefresherLog.swift b/Where/WhereCore/Sources/Logging/WidgetTimelineRefresherLog.swift index 0ff0f2a01..8831e3131 100644 --- a/Where/WhereCore/Sources/Logging/WidgetTimelineRefresherLog.swift +++ b/Where/WhereCore/Sources/Logging/WidgetTimelineRefresherLog.swift @@ -1,26 +1,16 @@ import PeriscopeCore -/// Structured events for `WidgetCenterTimelineRefresher`, which writes the -/// snapshot to the App Group and reloads WidgetKit timelines. -enum WidgetTimelineRefresherLog: LogEvent { - case wroteSnapshot - case publishFailed(description: String) +@LogScope("WidgetRefresher") +enum WidgetTimelineRefresherLog { + @LogEvent("wrote-snapshot", message: "Wrote widget snapshot to App Group; reloading timelines") + struct WroteSnapshot {} - static let eventName = "WidgetRefresher" - - var level: LogLevel { - switch self { - case .wroteSnapshot: .info - case .publishFailed: .error - } - } - - var message: String { - switch self { - case .wroteSnapshot: - "Wrote widget snapshot to App Group; reloading timelines" - case let .publishFailed(description): - "Failed to publish widget snapshot: \(description)" + @LogEvent("publish-failed", level: .error) + struct PublishFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to publish widget snapshot: \(description)" } } } diff --git a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift index 307a1e3a7..480a7206b 100644 --- a/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift +++ b/Where/WhereCore/Sources/Persistence/StoreRemoteChangeSource.swift @@ -127,9 +127,10 @@ final class PersistentStoreRemoteChangeSource: NSObject, StoreRemoteChangeSource // Fail open: a missed remote refresh is less honest than a // duplicate rebuild. Log the classification failure so the // degraded behavior is observable. - Self.logger(attachments: [.error(error, name: "history-error")]) { - .remoteChangeClassificationFailed(description: error.localizedDescription) - } + Self.logger.remoteChangeClassificationFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "history-error")], + ) continuation.yield() } } diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index 71cd599a8..09d75996b 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -479,7 +479,9 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { public static func make(storage: Storage) throws -> SwiftDataStore { let container = try logger.measure(.open) { try makeContainer(storage: storage) } if storage == .inMemory { - logger { .openedInMemory(mode: String(describing: storage)) } + logger.openedInMemory( + mode: .restricted(.technicalState, String(describing: storage)), + ) } else { // Log the resolved on-disk path and whether the App Group container // is actually reachable at runtime. If the App Group capability isn't @@ -490,13 +492,11 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let groupResolved = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) != nil let url = container.configurations.first?.url.path(percentEncoded: false) ?? "unknown" - logger { - .openedOnDisk( - mode: String(describing: storage), - appGroupResolved: groupResolved, - url: url, - ) - } + logger.openedOnDisk( + mode: .restricted(.technicalState, String(describing: storage)), + appGroupResolved: .shared(.boolean, groupResolved), + url: .restricted(.pathOrURL, url), + ) } let store = SwiftDataStore(modelContainer: container) // On-disk stores live in a shared App Group container, so another process @@ -1834,9 +1834,10 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } if !unknown.isEmpty { - Self.logger { - .ignoredUnknownTrackedRegions(ids: unknown.sorted()) - } + Self.logger.ignoredUnknownTrackedRegions( + ids: .restricted(.location, unknown.sorted()), + unknownRegionCount: .shared(.count, unknown.count), + ) } return resolved } @@ -1915,9 +1916,10 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { )) } if !unknown.isEmpty { - Self.logger { - .ignoredUnknownPrimaryRegions(ids: unknown.sorted()) - } + Self.logger.ignoredUnknownPrimaryRegions( + ids: .restricted(.location, unknown.sorted()), + unknownRegionCount: .shared(.count, unknown.count), + ) } return resolved } @@ -1960,11 +1962,17 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } private static func logFault(forCorrupt _: Record) { - logger { .droppedCorruptRecord(type: String(describing: Record.self)) } + logger.droppedCorruptRecord( + type: .restricted(.technicalState, String(describing: Record.self)), + ) } private static func logImmutableConflict(type: String, id: String, count: Int) { - logger { .resolvedConflictingImmutableRecords(type: type, id: id, count: count) } + logger.resolvedConflictingImmutableRecords( + type: .restricted(.technicalState, type), + id: .restricted(.identifier, id), + count: .shared(.count, count), + ) } } diff --git a/Where/WhereCore/Sources/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift index 01f929093..8034c02d1 100644 --- a/Where/WhereCore/Sources/RegionAttribution.swift +++ b/Where/WhereCore/Sources/RegionAttribution.swift @@ -114,7 +114,9 @@ final class RegionAttribution: RegionAttributing { // Degraded-but-handled: keep the last-good attributor rather than // silently freezing on an empty/stale set, and surface the failure so // a persistent read error is observable instead of invisible. - Self.logger { .trackedRegionsReadFailed(description: String(describing: error)) } + Self.logger.trackedRegionsReadFailed( + description: .restricted(.errorDetails, String(describing: error)), + ) return } let ids = Set(tracked.map(\.rawValue)) diff --git a/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift b/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift index 701d9e628..99d98dfa0 100644 --- a/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift +++ b/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift @@ -69,9 +69,10 @@ public actor DailySummaryReconciler { body: summaryBody(for: report), ) } catch { - Self.logger(attachments: [.error(error, name: "reconcile-error")]) { - .reconcileFailed(description: error.localizedDescription) - } + Self.logger.reconcileFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "reconcile-error")], + ) } } } diff --git a/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift b/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift index d9f322dfd..71c2b1097 100644 --- a/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift @@ -78,7 +78,9 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling do { return try await center.requestAuthorization(options: [.alert, .sound, .badge]) } catch { - Self.logger { .authorizationRequestFailed(description: error.localizedDescription) } + Self.logger.authorizationRequestFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) return false } } @@ -104,11 +106,11 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling case .authorized, .provisional, .ephemeral: break case .notDetermined, .denied: - Self.logger { .authorizationNotGranted } + Self.logger.authorizationNotGranted() await removeAllOwned() return @unknown default: - Self.logger { .authorizationUnknown } + Self.logger.authorizationUnknown() await removeAllOwned() return } @@ -150,13 +152,17 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling ) do { try await center.add(request) - Self.logger { - .scheduled(time: String(format: "%02d:%02d", time.hour, time.minute)) - } + Self.logger.scheduled( + time: .restricted( + .dateTime, + String(format: "%02d:%02d", time.hour, time.minute), + ), + ) } catch { - Self.logger(attachments: [.error(error, name: "schedule-error")]) { - .scheduleFailed(description: error.localizedDescription) - } + Self.logger.scheduleFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "schedule-error")], + ) } } diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift index 5b0335be6..07eb5fa88 100644 --- a/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift @@ -73,9 +73,10 @@ public actor DataIssueAlertReconciler { body: Self.body(count: count), ) } catch { - Self.logger(attachments: [.error(error, name: "reconcile-error")]) { - .reconcileFailed(description: error.localizedDescription) - } + Self.logger.reconcileFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "reconcile-error")], + ) } } } diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift index 05811edf6..356890b80 100644 --- a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift @@ -80,7 +80,9 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu do { return try await center.requestAuthorization(options: [.alert, .sound, .badge]) } catch { - Self.logger { .authorizationRequestFailed(description: error.localizedDescription) } + Self.logger.authorizationRequestFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) return false } } @@ -112,11 +114,11 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu case .authorized, .provisional, .ephemeral: break case .notDetermined, .denied: - Self.logger { .authorizationNotGranted } + Self.logger.authorizationNotGranted() await removeAllOwned() return @unknown default: - Self.logger { .authorizationUnknown } + Self.logger.authorizationUnknown() await removeAllOwned() return } @@ -158,13 +160,17 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu ) do { try await center.add(request) - Self.logger { - .scheduled(time: String(format: "%02d:%02d", time.hour, time.minute)) - } + Self.logger.scheduled( + time: .restricted( + .dateTime, + String(format: "%02d:%02d", time.hour, time.minute), + ), + ) } catch { - Self.logger(attachments: [.error(error, name: "schedule-error")]) { - .scheduleFailed(description: error.localizedDescription) - } + Self.logger.scheduleFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "schedule-error")], + ) } } diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index d17c1a1fa..5a1d883a5 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -127,7 +127,9 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, do { return try await center.requestAuthorization(options: [.alert, .sound, .badge]) } catch { - Self.logger { .authorizationRequestFailed(description: error.localizedDescription) } + Self.logger.authorizationRequestFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) return false } } @@ -181,12 +183,12 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, case .authorized, .provisional, .ephemeral: break case .notDetermined, .denied: - Self.logger { .authorizationNotGranted } + Self.logger.authorizationNotGranted() await removeAllOwnedReminders() await setBadge(0) return @unknown default: - Self.logger { .authorizationUnknown } + Self.logger.authorizationUnknown() await removeAllOwnedReminders() await setBadge(0) return @@ -235,13 +237,11 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, // every launch/foreground and after every user write, so a no-op // reconcile (the common case) stays quiet. if !pendingToRemove.isEmpty || !staleDelivered.isEmpty || !toSchedule.isEmpty { - Self.logger { - .reconciled( - scheduled: toSchedule.count, - removed: pendingToRemove.count, - badge: badgeCount, - ) - } + Self.logger.reconciled( + scheduled: .shared(.count, toSchedule.count), + removed: .shared(.count, pendingToRemove.count), + badge: .shared(.count, badgeCount), + ) } } @@ -264,9 +264,11 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, do { try await center.add(request) } catch { - Self.logger(attachments: [.error(error, name: "schedule-error")]) { - .scheduleFailed(identifier: identifier, description: error.localizedDescription) - } + Self.logger.scheduleFailed( + identifier: .restricted(.identifier, identifier), + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "schedule-error")], + ) } } @@ -298,7 +300,9 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, do { try await center.setBadgeCount(max(0, count)) } catch { - Self.logger { .badgeUpdateFailed(description: error.localizedDescription) } + Self.logger.badgeUpdateFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) } } diff --git a/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift b/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift index 12f908b03..31cf3ba51 100644 --- a/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift +++ b/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift @@ -174,9 +174,10 @@ public actor ReminderReconciler { ? today : nil } catch { - Self.logger(attachments: [.error(error, name: "reconcile-error")]) { - .reconcileFailed(description: error.localizedDescription) - } + Self.logger.reconcileFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "reconcile-error")], + ) } } @@ -199,7 +200,9 @@ public actor ReminderReconciler { driftThresholdMeters: config.driftThresholdMeters, ) } catch { - Self.logger { .badgeScanFailed(description: error.localizedDescription) } + Self.logger.badgeScanFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) return 0 } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetPresentationPublisher.swift b/Where/WhereCore/Sources/Widgets/WidgetPresentationPublisher.swift index ea777f041..f621d8601 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetPresentationPublisher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetPresentationPublisher.swift @@ -31,11 +31,14 @@ public actor WidgetPresentationPublisher { try makeStore().write(theme: theme) lastPublishedTheme = theme reloadTimelines() - Self.logger { .published(theme: theme.rawValue) } + Self.logger.published( + theme: .restricted(.technicalState, theme.rawValue), + ) } catch { - Self.logger(attachments: [.error(error, name: "publish-error")]) { - .publishFailed(description: error.localizedDescription) - } + Self.logger.publishFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "publish-error")], + ) } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetPresentationStore.swift b/Where/WhereCore/Sources/Widgets/WidgetPresentationStore.swift index 09a252726..c9ff80977 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetPresentationStore.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetPresentationStore.swift @@ -44,9 +44,10 @@ public struct WidgetPresentationStore: Sendable { do { return try JSONDecoder().decode(WhereTheme.self, from: data) } catch { - Self.logger(attachments: [.error(error, name: "decode-error")]) { - .unreadablePresentation(description: error.localizedDescription) - } + Self.logger.unreadablePresentation( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "decode-error")], + ) return .standard } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift index 25e0c9e3c..dacd6e777 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift @@ -81,12 +81,10 @@ public actor WidgetSnapshotPublisher { let snapshot = try await widgetReader.snapshot(asOf: now()) await widgetRefresher.publish(snapshot) lastPublished = PublishedWidgetSnapshot(snapshot: snapshot, publishedAt: now()) - Self.logger { - .published( - day: dayLogLabel(snapshot.day), - regionCount: snapshot.dayRegions.count, - ) - } + Self.logger.published( + day: .restricted(.dateTime, dayLogLabel(snapshot.day)), + regionCount: .shared(.count, snapshot.dayRegions.count), + ) } catch let error as RecordingPersistenceError { // Generation/policy gaps mean a destructive CloudKit change may already be known // even @@ -102,9 +100,13 @@ public actor WidgetSnapshotPublisher { ) await widgetRefresher.publish(snapshot) lastPublished = PublishedWidgetSnapshot(snapshot: snapshot, publishedAt: date) - Self.logger { .buildFailed(description: error.localizedDescription) } + Self.logger.buildFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) } catch { - Self.logger { .buildFailed(description: error.localizedDescription) } + Self.logger.buildFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) } } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift index 5c27d4bc5..e8a4673dc 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotStore.swift @@ -73,9 +73,10 @@ public struct WidgetSnapshotStore: Sendable { do { return try JSONDecoder().decode(WidgetSnapshot.self, from: data) } catch { - Self.logger(attachments: [.error(error, name: "decode-error")]) { - .unreadableSnapshot(description: error.localizedDescription) - } + Self.logger.unreadableSnapshot( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "decode-error")], + ) return nil } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift index 6e8542212..1a400b6fd 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift @@ -33,9 +33,11 @@ public struct WidgetCenterTimelineRefresher: WidgetTimelineRefreshing { public func publish(_ snapshot: WidgetSnapshot) async { do { try WidgetSnapshotStore.shared().write(snapshot) - Self.logger { .wroteSnapshot } + Self.logger.wroteSnapshot() } catch { - Self.logger { .publishFailed(description: error.localizedDescription) } + Self.logger.publishFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) } WidgetCenter.shared.reloadAllTimelines() } diff --git a/Where/WhereCore/Tests/Logging/LocationIngestorLogTests.swift b/Where/WhereCore/Tests/Logging/LocationIngestorLogTests.swift index 4fbfe85e6..791c811d6 100644 --- a/Where/WhereCore/Tests/Logging/LocationIngestorLogTests.swift +++ b/Where/WhereCore/Tests/Logging/LocationIngestorLogTests.swift @@ -3,34 +3,43 @@ import Testing @testable import WhereCore struct LocationIngestorLogTests { - @Test func everyEventCaseExportsADistinctSafeKind() { - let events: [LocationIngestorLog] = [ - .monitoringStarted, - .monitoringStopped, - .restoredBacklog(count: 2), - .quiesced, - .todayIntervalUnavailable, - .foregroundCaptureReadFailed(description: "private error"), - .capturedForegroundFix, - .persistFailed(sampleID: "private id", description: "private error"), - .retryBacklogPersistenceFailed(description: "private error"), - .retryQueueAtCapacity(capacity: 20), - .retryStillFailing(sampleID: "private id", description: "private error"), - .drainedBacklog(sampleCount: 3, dayCount: 2), + @Test func everyEventHasAStableDistinctName() { + let names = [ + LocationIngestorLog.MonitoringStarted.eventName, + LocationIngestorLog.MonitoringStopped.eventName, + LocationIngestorLog.RestoredBacklog.eventName, + LocationIngestorLog.Quiesced.eventName, + LocationIngestorLog.TodayIntervalUnavailable.eventName, + LocationIngestorLog.ForegroundCaptureReadFailed.eventName, + LocationIngestorLog.CapturedForegroundFix.eventName, + LocationIngestorLog.PersistFailed.eventName, + LocationIngestorLog.RetryBacklogPersistenceFailed.eventName, + LocationIngestorLog.RetryQueueAtCapacity.eventName, + LocationIngestorLog.RetryStillFailing.eventName, + LocationIngestorLog.DrainedBacklog.eventName, ] - let kinds = events.compactMap(remoteKind) - #expect(kinds.count == events.count) - #expect(Set(kinds).count == events.count) - #expect(kinds.contains("private id") == false) - #expect(kinds.contains("private error") == false) + #expect(Set(names).count == names.count) + #expect(names.allSatisfy { $0.hasPrefix("LocationIngestor.") }) } - private func remoteKind(_ event: LocationIngestorLog) -> String? { - guard let field = event.remoteFields.first, - field.key == RemoteLogFieldKey("kind"), - case let .category(category) = field.value - else { return nil } - return category.rawValue + @Test func projectionPreservesTheExistingRemoteBoundary() { + let event = LocationIngestorLog.PersistFailed( + sampleID: .restricted(.identifier, "private id"), + description: .restricted(.errorDetails, "private error"), + ) + #expect(event.classifiedFields == [ + .restricted(key: LogFieldKey("sample_id"), kind: .identifier), + .restricted(key: LogFieldKey("description"), kind: .errorDetails), + ]) + + let drained = LocationIngestorLog.DrainedBacklog( + sampleCount: .shared(.count, 3), + dayCount: .shared(.count, 2), + ) + #expect(drained.classifiedFields == [ + .shareable(key: LogFieldKey("sample_count"), kind: .count, value: .int(3)), + .shareable(key: LogFieldKey("day_count"), kind: .count, value: .int(2)), + ]) } } diff --git a/Where/WhereCore/Tests/Logging/WhereLogTests.swift b/Where/WhereCore/Tests/Logging/WhereLogTests.swift index 625b3587e..5532ec729 100644 --- a/Where/WhereCore/Tests/Logging/WhereLogTests.swift +++ b/Where/WhereCore/Tests/Logging/WhereLogTests.swift @@ -90,48 +90,81 @@ struct WhereLogEventTests { @Test func dayJournalStampsTheAffectedDayAsExternalID() { // externalIDs are the canonical store:// identities (see WhereStoreIDTests // for the exact URL strings), so inspect-by-object shares the store's keys. - #expect(DayJournalLog.addedManualDay(day: "2026-06-05", regionCount: 2) - .externalID == WhereStoreID.day("2026-06-05")) - #expect(DayJournalLog.clearedYear(year: 2025).externalID == WhereStoreID.year(2025)) - #expect(DayJournalLog.wroteEvidence(id: "abc", hasBlob: true) - .externalID == WhereStoreID.evidence("abc")) - #expect(DayJournalLog.erasedAllData.externalID == nil) - #expect(DayJournalLog.addedManualDay(day: "d", regionCount: 2).level == .info) + #expect(DayJournalLog.AddedManualDay( + day: .restricted(.dateTime, "2026-06-05"), + regionCount: .shared(.count, 2), + ) + .externalID == WhereStoreID.day("2026-06-05")) + #expect(DayJournalLog.ClearedYear( + year: .restricted(.domainValue, 2025), + ).externalID == WhereStoreID.year(2025)) + #expect(DayJournalLog.WroteEvidence( + id: .restricted(.identifier, "abc"), + hasBlob: .shared(.boolean, true), + ) + .externalID == WhereStoreID.evidence("abc")) + #expect(DayJournalLog.ErasedAllData().externalID == nil) + #expect(DayJournalLog.AddedManualDay( + day: .restricted(.dateTime, "d"), + regionCount: .shared(.count, 2), + ).level == .info) } @Test func swiftDataStoreCorruptionIsAFault() { - #expect(SwiftDataStoreLog.droppedCorruptRecord(type: "SDEvidence").level == .fault) - #expect(SwiftDataStoreLog.openedInMemory(mode: "inMemory").level == .info) + #expect(SwiftDataStoreLog.DroppedCorruptRecord( + type: .restricted(.technicalState, "SDEvidence"), + ).level == .fault) + #expect(SwiftDataStoreLog.OpenedInMemory( + mode: .restricted(.technicalState, "inMemory"), + ).level == .info) #expect( - SwiftDataStoreLog.ignoredUnknownTrackedRegions(ids: ["zz"]).level == .warning, + SwiftDataStoreLog.IgnoredUnknownTrackedRegions( + ids: .restricted(.location, ["zz"]), + unknownRegionCount: .shared(.count, 1), + ).level == .warning, ) #expect( - SwiftDataStoreLog.ignoredUnknownTrackedRegions(ids: ["us-CA", "us-NY"]) - .message.contains("us-CA, us-NY"), + SwiftDataStoreLog.IgnoredUnknownTrackedRegions( + ids: .restricted(.location, ["us-CA", "us-NY"]), + unknownRegionCount: .shared(.count, 2), + ) + .message.contains("us-CA, us-NY"), ) } @Test func locationIngestorTracesSampleFailuresByID() { #expect( - LocationIngestorLog.persistFailed(sampleID: "abc", description: "x") - .externalID == WhereStoreID.sample("abc"), + LocationIngestorLog.PersistFailed( + sampleID: .restricted(.identifier, "abc"), + description: .restricted(.errorDetails, "x"), + ) + .externalID == WhereStoreID.sample("abc"), ) - #expect(LocationIngestorLog.persistFailed(sampleID: "abc", description: "x") - .level == .error) - #expect(LocationIngestorLog.monitoringStarted.externalID == nil) + #expect(LocationIngestorLog.PersistFailed( + sampleID: .restricted(.identifier, "abc"), + description: .restricted(.errorDetails, "x"), + ) + .level == .error) + #expect(LocationIngestorLog.MonitoringStarted().externalID == nil) } @Test func schedulerAuthorizationOutcomesUseHonestLevels() { - #expect(LoggingReminderSchedulerLog.authorizationNotGranted.level == .warning) + #expect(LoggingReminderSchedulerLog.AuthorizationNotGranted().level == .warning) #expect( - LoggingReminderSchedulerLog.authorizationRequestFailed(description: "x") - .level == .error, + LoggingReminderSchedulerLog.AuthorizationRequestFailed( + description: .restricted(.errorDetails, "x"), + ) + .level == .error, + ) + #expect(LoggingReminderSchedulerLog.Reconciled( + scheduled: .shared(.count, 1), + removed: .shared(.count, 0), + badge: .shared(.count, 2), ) - #expect(LoggingReminderSchedulerLog.reconciled(scheduled: 1, removed: 0, badge: 2) - .level == .info) + .level == .info) } @Test func widgetRefresherKeepsItsHistoricalEventName() { - #expect(WidgetTimelineRefresherLog.eventName == "WidgetRefresher") + #expect(WidgetTimelineRefresherLog.scopeName == "WidgetRefresher") } } diff --git a/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift b/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift index 2c828c2c8..6a1a5f8f4 100644 --- a/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift +++ b/Where/WhereIntents/Sources/Logging/WhereIntentsLog.swift @@ -1,24 +1,11 @@ import PeriscopeCore import WhereCore -/// Structured events for the Where App Intents surface. These run in the -/// app/intents process, which keeps `Periscope.shared` OSLog-only (no -/// persistent store of its own). -enum WhereIntentsLog: LogEvent { - /// Names the intents surface's timed spans. - /// - /// `description` is spelled out because ``perform(_:)`` carries an intent - /// token: reflection would render it `perform(WhereIntents.IntentName.logDay)`, - /// a Swift-internal shape in a name the span tools group timings by. +/// Structured events and spans for the Where App Intents surface. +@LogScope("WhereIntents") +enum WhereIntentsLog { enum SpanName: Hashable, CustomStringConvertible { - /// One intent's work — the read or write it delegates to WhereCore. - /// Excludes the ``awaitServices`` wait, so a cold-start park doesn't - /// read as a slow intent. case perform(IntentName) - /// An intent parked in `IntentServices.current()` waiting for the launch - /// to install the services stack. Only the *parking* path is spanned — - /// once installed, resolution is a property read, and timing it on every - /// invocation would bury the interesting case. case awaitServices var description: String { @@ -29,10 +16,6 @@ enum WhereIntentsLog: LogEvent { } } - /// The intents whose work is timed, and how long each may take before its - /// span raises an overdue warning. Budgets live here (not at the call site) - /// so an intent's name and its expectation can't drift apart, mirroring - /// `BudgetedLaunchStep` on the launch side. enum IntentName: String, Hashable, CaseIterable { case daysInRegion = "days-in-region" case daysInRegionSnippet = "days-in-region-snippet" @@ -41,10 +24,6 @@ enum WhereIntentsLog: LogEvent { case regionOnDate = "region-on-date" case todayRegions = "today-regions" - /// Siri and Shortcuts hold the user waiting on `perform()`, so these are - /// tight: a single year's aggregated read or a one-day write should be - /// well under a second. A trip earns more slack because it backfills a - /// range in one transaction. var budget: Duration { switch self { case .daysInRegion, .daysInRegionSnippet, .logDay, .regionOnDate, .todayRegions: @@ -55,36 +34,25 @@ enum WhereIntentsLog: LogEvent { } } - /// The tracked regions were indexed into Spotlight. - case spotlightIndexed(regionCount: Int) - /// Indexing the tracked regions into Spotlight failed - /// (degraded-but-handled: search integration is a nicety). - case spotlightIndexFailed(description: String) - static let eventName = "WhereIntents" - - var level: LogLevel { - switch self { - case .spotlightIndexed: - .info - case .spotlightIndexFailed: - .warning + @LogEvent("spotlight-indexed") + struct SpotlightIndexed { + @LogField("region_count", exposure: .restricted, kind: .count) + var regionCount: Int + var message: String { + "Indexed \(regionCount) region(s) for Spotlight" } } - var message: String { - switch self { - case let .spotlightIndexed(regionCount): - "Indexed \(regionCount) region(s) for Spotlight" - case let .spotlightIndexFailed(description): - "Failed to index regions for Spotlight: \(description)" + @LogEvent("spotlight-index-failed", level: .warning) + struct SpotlightIndexFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to index regions for Spotlight: \(description)" } } } extension WhereIntentsLog { - /// The one logger the whole intents surface emits through. Derived once here - /// rather than per type: every intent's span, the services wait, and the - /// Spotlight events share this scope, and eight copies of the same - /// derivation is eight chances for one of them to drift. static let logger = WhereLog.root(WhereIntentsLog.self) } diff --git a/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift b/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift index 9b6dc94eb..eacf22f2c 100644 --- a/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift +++ b/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift @@ -22,13 +22,14 @@ public enum RegionSpotlightIndexer { do { let entities = try await RegionEntity.tracked(from: intentServices.current()) try await CSSearchableIndex.default().indexAppEntities(entities) - logger { .spotlightIndexed(regionCount: entities.count) } + logger.spotlightIndexed(regionCount: .restricted(.count, entities.count)) } catch { // Degraded-but-handled: search integration is a nicety, so a failure // is logged and swallowed rather than surfaced to the user. - logger(attachments: [.error(error, name: "index-error")]) { - .spotlightIndexFailed(description: String(describing: error)) - } + logger.spotlightIndexFailed( + description: .restricted(.errorDetails, String(describing: error)), + attachments: [.error(error, name: "index-error")], + ) } } } diff --git a/Where/WhereShareExtension/Sources/Logging/ShareExtensionLog.swift b/Where/WhereShareExtension/Sources/Logging/ShareExtensionLog.swift index 4b48a1a90..ea9607d97 100644 --- a/Where/WhereShareExtension/Sources/Logging/ShareExtensionLog.swift +++ b/Where/WhereShareExtension/Sources/Logging/ShareExtensionLog.swift @@ -1,57 +1,62 @@ import PeriscopeCore -/// Structured events for the Where share extension — a short-lived, separate -/// process, so `Periscope.shared` stays OSLog-only (no persistent store). -enum ShareExtensionLog: LogEvent { - /// Names the extension's timed span. The save path isn't here: opening the - /// App Group store and committing the write are both spanned by - /// `SwiftDataStore` itself. +/// Structured events and spans for the Where share extension. +@LogScope("ShareExtension") +enum ShareExtensionLog { enum SpanName: Hashable { - /// Pulling the bytes out of every shared item provider — the wait between - /// tapping Share and the compose form appearing, and the only part of the - /// extension's work that scales with what the user shared (a multi-page - /// PDF, twenty photos). case loadAttachments } - /// The extension was invoked with the given number of shared items. - case opened(itemCount: Int) - /// A shared item provider yielded no bytes for its offered type. `reason` is - /// the error it reported, absent when it simply handed back nothing. - case attachmentLoadFailed(typeIdentifier: String, reason: String?) - /// A shared URL provider produced nothing readable. - case urlUnreadable(reason: String?) - /// Shared evidence records were persisted to the App Group store. - case saved(evidenceCount: Int) - /// Persisting the shared evidence failed; the form stays open. - case saveFailed(description: String) - - static let eventName = "ShareExtension" - - var level: LogLevel { - switch self { - case .opened, .saved: - .info - case .attachmentLoadFailed, .urlUnreadable: - .warning - case .saveFailed: - .error + @LogEvent("opened", level: .info) + struct Opened { + @LogField("item_count", exposure: .restricted, kind: .count) + var itemCount: Int + + var message: String { + "Share extension opened with \(itemCount) item(s)" + } + } + + @LogEvent("attachment-load-failed", level: .warning) + struct AttachmentLoadFailed { + @LogField("type_identifier", exposure: .restricted, kind: .identifier) + var typeIdentifier: String + + @LogField("reason", exposure: .restricted, kind: .errorDetails) + var reason: String? + + var message: String { + "Failed to load shared \(typeIdentifier): \(reason ?? "provider returned nothing")" } } - var message: String { - switch self { - case let .opened(itemCount): - "Share extension opened with \(itemCount) item(s)" - case let .attachmentLoadFailed(typeIdentifier, reason): - "Failed to load shared \(typeIdentifier): \(reason ?? "provider returned nothing")" - case let .urlUnreadable(reason): - "Shared URL provider yielded no readable URL:" - + " \(reason ?? "provider returned nothing")" - case let .saved(evidenceCount): - "Saved \(evidenceCount) shared evidence record(s)" - case let .saveFailed(description): - "Failed to save shared evidence: \(description)" + @LogEvent("url-unreadable", level: .warning) + struct URLUnreadable { + @LogField("reason", exposure: .restricted, kind: .errorDetails) + var reason: String? + + var message: String { + "Shared URL provider yielded no readable URL: \(reason ?? "provider returned nothing")" + } + } + + @LogEvent("saved", level: .info) + struct Saved { + @LogField("evidence_count", exposure: .restricted, kind: .count) + var evidenceCount: Int + + var message: String { + "Saved \(evidenceCount) shared evidence record(s)" + } + } + + @LogEvent("save-failed", level: .error) + struct SaveFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to save shared evidence: \(description)" } } } diff --git a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift index 67c9657b2..e163e27ed 100644 --- a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift +++ b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift @@ -99,13 +99,14 @@ final class ShareEvidenceModel { try await store.write(evidence: item.evidence, blob: item.blob) } } - Self.logger { .saved(evidenceCount: pending.count) } + Self.logger.saved(evidenceCount: .restricted(.count, pending.count)) return true } catch { phase = .failed(error.localizedDescription) - Self.logger(attachments: [.error(error, name: "save-error")]) { - .saveFailed(description: error.localizedDescription) - } + Self.logger.saveFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "save-error")], + ) return false } } diff --git a/Where/WhereShareExtension/Sources/ShareViewController.swift b/Where/WhereShareExtension/Sources/ShareViewController.swift index 77db519e0..7f144c839 100644 --- a/Where/WhereShareExtension/Sources/ShareViewController.swift +++ b/Where/WhereShareExtension/Sources/ShareViewController.swift @@ -17,7 +17,7 @@ final class ShareViewController: UIViewController { super.viewDidLoad() let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? [] - Self.logger { .opened(itemCount: items.count) } + Self.logger.opened(itemCount: .restricted(.count, items.count)) let model = ShareEvidenceModel(items: items) let root = ShareEvidenceView( diff --git a/Where/WhereShareExtension/Sources/SharedItemLoader.swift b/Where/WhereShareExtension/Sources/SharedItemLoader.swift index 3c9426d33..d842a2449 100644 --- a/Where/WhereShareExtension/Sources/SharedItemLoader.swift +++ b/Where/WhereShareExtension/Sources/SharedItemLoader.swift @@ -112,9 +112,10 @@ enum SharedItemLoader { filename: provider.suggestedName, ) case let .missing(reason): - logger { - .attachmentLoadFailed(typeIdentifier: type.identifier, reason: reason) - } + logger.attachmentLoadFailed( + typeIdentifier: .restricted(.identifier, type.identifier), + reason: .restricted(.errorDetails, reason), + ) return nil } } @@ -145,7 +146,7 @@ enum SharedItemLoader { filename: provider.suggestedName, ) case let .missing(reason): - logger { .urlUnreadable(reason: reason) } + logger.urlUnreadable(reason: .restricted(.errorDetails, reason)) return nil } } diff --git a/Where/WhereUI/Sources/Developer/RegionMapView.swift b/Where/WhereUI/Sources/Developer/RegionMapView.swift index f69123bb2..b51783db8 100644 --- a/Where/WhereUI/Sources/Developer/RegionMapView.swift +++ b/Where/WhereUI/Sources/Developer/RegionMapView.swift @@ -118,9 +118,10 @@ public struct RegionMapView: View { // Keep the failure observable in both the UI (the `.failure` // state renders an error) and the logs, rather than silently // showing an empty map. - RegionLog.geometryCatalog { - .loadFailed(kind: kind.rawValue, description: String(describing: error)) - } + RegionLog.geometryCatalog.loadFailed( + kind: .restricted(.technicalState, kind.rawValue), + description: .restricted(.errorDetails, String(describing: error)), + ) outlines = .failure(error) } } diff --git a/Where/WhereUI/Sources/Launch/DetachedFailureReporter.swift b/Where/WhereUI/Sources/Launch/DetachedFailureReporter.swift index b8e344969..172565725 100644 --- a/Where/WhereUI/Sources/Launch/DetachedFailureReporter.swift +++ b/Where/WhereUI/Sources/Launch/DetachedFailureReporter.swift @@ -65,12 +65,11 @@ final class DetachedFailureReporter { reportedCount = failures.count reportedTotal += new.count for failure in new { - Self.logger(attachments: [.error(failure.error, name: "detached-error")]) { - .detachedStepFailed( - stepID: String(describing: failure.stepID), - description: failure.error.localizedDescription, - ) - } + Self.logger.detachedStepFailed( + stepID: .restricted(.identifier, String(describing: failure.stepID)), + description: .restricted(.errorDetails, failure.error.localizedDescription), + attachments: [.error(failure.error, name: "detached-error")], + ) } return new } diff --git a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift index 01bd851ca..2a422a590 100644 --- a/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift +++ b/Where/WhereUI/Sources/Launch/InstallationRecordingContextStore.swift @@ -554,7 +554,7 @@ public final class FileInstallationRecordingContextStore: // An atomic write either produced a complete current value or unusable bytes. // Keep an older authoritative context when one exists, but never retry a // permanently malformed pending replacement on every launch. - Self.logger { .discardedCorruptInstallationContextPending } + Self.logger.discardedCorruptInstallationContextPending() try fileManager.removeItem(at: pendingURL) return try loadAuthoritativeContext( from: fileURL, @@ -688,12 +688,13 @@ public final class FileInstallationRecordingContextStore: do { try fileManager.removeItem(at: directoryURL) } catch { - logger { - .installationContextSecurityCleanupFailed( - exclusionDescription: exclusionError.localizedDescription, - cleanupDescription: error.localizedDescription, - ) - } + logger.installationContextSecurityCleanupFailed( + exclusionDescription: .restricted( + .errorDetails, + exclusionError.localizedDescription, + ), + cleanupDescription: .restricted(.errorDetails, error.localizedDescription), + ) throw SecurityCleanupError() } throw exclusionError diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index e880233ad..0425c5b45 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -116,7 +116,9 @@ public enum WhereLaunch { reason: LifecycleReason, onServicesReady: @escaping @MainActor (WhereServices) async -> Void = { _ in }, ) -> LifecycleRunner { - logger { .runnerCreated(reason: String(describing: reason)) } + logger.runnerCreated( + reason: .restricted(.technicalState, String(describing: reason)), + ) let runner = LifecycleRunner( reason: reason, initializePrerequisites: { @@ -313,12 +315,13 @@ public final class WhereBootstrap: WhereScopeAssembling { locationOutbox: locationOutbox, importRecoveryPersistence: installationContextStore, ) - Self.logger { .servicesAssembled } + Self.logger.servicesAssembled() return services } catch { - Self.logger(attachments: [.error(error, name: "assemble-error")]) { - .servicesAssemblyFailed(description: error.localizedDescription) - } + Self.logger.servicesAssemblyFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "assemble-error")], + ) throw error } } diff --git a/Where/WhereUI/Sources/Logging/AddEvidenceModelLog.swift b/Where/WhereUI/Sources/Logging/AddEvidenceModelLog.swift index 7a3fc0ac2..b28d8ac10 100644 --- a/Where/WhereUI/Sources/Logging/AddEvidenceModelLog.swift +++ b/Where/WhereUI/Sources/Logging/AddEvidenceModelLog.swift @@ -1,38 +1,40 @@ import PeriscopeCore import WhereCore -/// Structured events for `AddEvidenceModel`, the compose form. A save records -/// the evidence id (`externalID`); attachment-pick and save failures leave the -/// form open with an honest error, so they log at `.warning`. -enum AddEvidenceModelLog: LogEvent { - case attachmentPickFailed(description: String) - case saved(evidenceID: String) - case saveFailed(description: String) +/// Structured events for `AddEvidenceModel`, the compose form. +@LogScope("AddEvidenceModel") +enum AddEvidenceModelLog { + @LogEvent("attachment-pick-failed", level: .warning) + struct AttachmentPickFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - static let eventName = "AddEvidenceModel" - - var level: LogLevel { - switch self { - case .saved: .info - case .attachmentPickFailed, .saveFailed: .warning + var message: String { + "Evidence attachment pick failed: \(description)" } } - var message: String { - switch self { - case let .attachmentPickFailed(description): - "Evidence attachment pick failed: \(description)" - case let .saved(evidenceID): - "Saved evidence \(evidenceID) from compose form" - case let .saveFailed(description): - "Failed to save evidence: \(description)" + @LogEvent("saved", level: .info) + struct Saved { + @LogField("evidence_id", exposure: .restricted, kind: .identifier) + var evidenceID: String + + var message: String { + "Saved evidence \(evidenceID) from compose form" + } + + var externalID: String? { + WhereStoreID.evidence(evidenceID) } } - var externalID: String? { - switch self { - case let .saved(evidenceID): WhereStoreID.evidence(evidenceID) - case .attachmentPickFailed, .saveFailed: nil + @LogEvent("save-failed", level: .warning) + struct SaveFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to save evidence: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/AppIconCatalogLog.swift b/Where/WhereUI/Sources/Logging/AppIconCatalogLog.swift index 68446956b..8e7e35b3a 100644 --- a/Where/WhereUI/Sources/Logging/AppIconCatalogLog.swift +++ b/Where/WhereUI/Sources/Logging/AppIconCatalogLog.swift @@ -1,26 +1,13 @@ import PeriscopeCore -/// Structured events for the app-icon manifest load. -/// -/// A bundled `AppIcons.json` that's absent or won't decode is a packaging error -/// — the `./icons` script writes it and the build embeds it, so no user action -/// produces this — hence `.fault`, paired with a debug `assertionFailure` at the -/// call site. -enum AppIconCatalogLog: LogEvent { - /// The bundled manifest couldn't be read, so the icon picker lists nothing - /// and the surfaces that render the selected icon fall back to Classic art. - case manifestUnreadable(description: String) - - static let eventName = "AppIconCatalog" - - var level: LogLevel { - .fault - } - - var message: String { - switch self { - case let .manifestUnreadable(description): - "Failed to load the bundled app-icon manifest: \(description)" +@LogScope("AppIconCatalog") +enum AppIconCatalogLog { + @LogEvent("manifest-unreadable", level: .fault) + struct ManifestUnreadable { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to load the bundled app-icon manifest: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/BackupModelLog.swift b/Where/WhereUI/Sources/Logging/BackupModelLog.swift index 1c51059a2..fb70aa41c 100644 --- a/Where/WhereUI/Sources/Logging/BackupModelLog.swift +++ b/Where/WhereUI/Sources/Logging/BackupModelLog.swift @@ -1,83 +1,55 @@ import PeriscopeCore -/// Structured events for `BackupModel`, the export/import view model. Failures -/// leave the UI in an honest error state, so they log at `.warning`. -enum BackupModelLog: LogEvent { - case exported - case exportFailed(description: String) - case imported( - sampleCount: Int, - evidenceCount: Int, - manualDayCount: Int, - dismissedIssueCount: Int, - trackedRegionCount: Int, - ) - case importFailed(description: String) - case importCleanupFailed(description: String) +/// Structured events for `BackupModel`. +@LogScope("Backup") +enum BackupModelLog { + @LogEvent("exported", message: "Exported backup archive") + struct Exported {} - static let eventName = "Backup" + @LogEvent("export-failed", level: .warning) + struct ExportFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Backup export failed: \(description)" + } + } + + @LogEvent("imported") + struct Imported { + @LogField("sample_count", exposure: .shareable, kind: .count) + var sampleCount: Int + @LogField("evidence_count", exposure: .shareable, kind: .count) + var evidenceCount: Int + @LogField("manual_day_count", exposure: .shareable, kind: .count) + var manualDayCount: Int + @LogField("dismissed_issue_count", exposure: .shareable, kind: .count) + var dismissedIssueCount: Int + @LogField("tracked_region_count", exposure: .shareable, kind: .count) + var trackedRegionCount: Int - var level: LogLevel { - switch self { - case .exported, .imported: .info - case .exportFailed, .importFailed, .importCleanupFailed: .warning + var message: String { + "Imported backup (\(sampleCount) samples, \(evidenceCount) evidence, " + + "\(manualDayCount) manual days, \(dismissedIssueCount) dismissals, " + + "\(trackedRegionCount) tracked regions)" } } - var message: String { - switch self { - case .exported: - "Exported backup archive" - case let .exportFailed(description): - "Backup export failed: \(description)" - case let .imported( - sampleCount, - evidenceCount, - manualDayCount, - dismissedIssueCount, - trackedRegionCount, - ): - "Imported backup (\(sampleCount) samples, \(evidenceCount) evidence, \(manualDayCount) manual days, \(dismissedIssueCount) dismissals, \(trackedRegionCount) tracked regions)" - case let .importFailed(description): - "Backup import failed: \(description)" - case let .importCleanupFailed(description): - "Backup import committed but recording cleanup failed: \(description)" + @LogEvent("import-failed", level: .warning) + struct ImportFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Backup import failed: \(description)" } } - var remoteFields: [RemoteLogField] { - switch self { - case let .imported( - sampleCount, - evidenceCount, - manualDayCount, - dismissedIssueCount, - trackedRegionCount, - ): - [ - RemoteLogField( - key: RemoteLogFieldKey("sample_count"), - value: .count(sampleCount), - ), - RemoteLogField( - key: RemoteLogFieldKey("evidence_count"), - value: .count(evidenceCount), - ), - RemoteLogField( - key: RemoteLogFieldKey("manual_day_count"), - value: .count(manualDayCount), - ), - RemoteLogField( - key: RemoteLogFieldKey("dismissed_issue_count"), - value: .count(dismissedIssueCount), - ), - RemoteLogField( - key: RemoteLogFieldKey("tracked_region_count"), - value: .count(trackedRegionCount), - ), - ] - case .exported, .exportFailed, .importFailed, .importCleanupFailed: - [] + @LogEvent("import-cleanup-failed", level: .warning) + struct ImportCleanupFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Backup import committed but recording cleanup failed: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/CalendarViewLog.swift b/Where/WhereUI/Sources/Logging/CalendarViewLog.swift index 56e0122c7..3735410bc 100644 --- a/Where/WhereUI/Sources/Logging/CalendarViewLog.swift +++ b/Where/WhereUI/Sources/Logging/CalendarViewLog.swift @@ -1,23 +1,25 @@ import PeriscopeCore -/// Structured events for `CalendarView`'s layout. Both cases are -/// degraded-but-handled presentation states, so they log at `.warning`. -enum CalendarViewLog: LogEvent { - case openedWithoutReport(loadState: String) - case layoutFailed(description: String) +/// Structured events for `CalendarView`'s degraded presentation states. +@LogScope("CalendarView") +enum CalendarViewLog { + @LogEvent("opened-without-report", level: .warning) + struct OpenedWithoutReport { + @LogField("load_state", exposure: .restricted, kind: .technicalState) + var loadState: String - static let eventName = "CalendarView" - - var level: LogLevel { - .warning + var message: String { + "Calendar opened without a year report (loadState: \(loadState))" + } } - var message: String { - switch self { - case let .openedWithoutReport(loadState): - "Calendar opened without a year report (loadState: \(loadState))" - case let .layoutFailed(description): - "Calendar layout failed: \(description)" + @LogEvent("layout-failed", level: .warning) + struct LayoutFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Calendar layout failed: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/EvidenceDetailModelLog.swift b/Where/WhereUI/Sources/Logging/EvidenceDetailModelLog.swift index 0b543f1e5..aac79c361 100644 --- a/Where/WhereUI/Sources/Logging/EvidenceDetailModelLog.swift +++ b/Where/WhereUI/Sources/Logging/EvidenceDetailModelLog.swift @@ -1,27 +1,22 @@ import PeriscopeCore import WhereCore -/// Structured events for `EvidenceDetailModel`. The evidence id rides on -/// `externalID` so blob-load failures trace to their row. -enum EvidenceDetailModelLog: LogEvent { - case blobLoadFailed(evidenceID: String, description: String) +@LogScope("EvidenceDetailModel") +enum EvidenceDetailModelLog { + @LogEvent("blob-load-failed", level: .warning) + struct BlobLoadFailed { + @LogField("evidence_id", exposure: .restricted, kind: .identifier) + var evidenceID: String - static let eventName = "EvidenceDetailModel" + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .blobLoadFailed(evidenceID, description): - "Failed to load evidence blob for \(evidenceID): \(description)" + var message: String { + "Failed to load evidence blob for \(evidenceID): \(description)" } - } - var externalID: String? { - switch self { - case let .blobLoadFailed(evidenceID, _): WhereStoreID.evidence(evidenceID) + var externalID: String? { + WhereStoreID.evidence(evidenceID) } } } diff --git a/Where/WhereUI/Sources/Logging/EvidenceListModelLog.swift b/Where/WhereUI/Sources/Logging/EvidenceListModelLog.swift index 4b7beab6c..e84d36a56 100644 --- a/Where/WhereUI/Sources/Logging/EvidenceListModelLog.swift +++ b/Where/WhereUI/Sources/Logging/EvidenceListModelLog.swift @@ -1,27 +1,22 @@ import PeriscopeCore import WhereCore -/// Structured events for `EvidenceListModel`. A read failure leaves the list in -/// an honest error state, so it logs at `.warning`. -enum EvidenceListModelLog: LogEvent { - case loadFailed(year: Int, description: String) +@LogScope("EvidenceListModel") +enum EvidenceListModelLog { + @LogEvent("load-failed", level: .warning) + struct LoadFailed { + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int - static let eventName = "EvidenceListModel" + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .loadFailed(year, description): - "Failed to load evidence for \(year): \(description)" + var message: String { + "Failed to load evidence for \(year): \(description)" } - } - var externalID: String? { - switch self { - case let .loadFailed(year, _): WhereStoreID.year(year) + var externalID: String? { + WhereStoreID.year(year) } } } diff --git a/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift b/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift index 530091f07..404436dbe 100644 --- a/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift +++ b/Where/WhereUI/Sources/Logging/LocationForecastModelLog.swift @@ -1,24 +1,31 @@ import PeriscopeCore -enum LocationForecastModelLog: LogEvent { - case loadFailed(description: String) - case saveFailed(description: String) - case clearFailed(description: String) - - static let eventName = "LocationForecast" +@LogScope("LocationForecast") +enum LocationForecastModelLog { + @LogEvent("load-failed", level: .warning) + struct LoadFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to load the planned stay: \(description)" + } + } - var level: LogLevel { - .warning + @LogEvent("save-failed", level: .warning) + struct SaveFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to save the planned stay: \(description)" + } } - var message: String { - switch self { - case let .loadFailed(description): - "Failed to load the planned stay: \(description)" - case let .saveFailed(description): - "Failed to save the planned stay: \(description)" - case let .clearFailed(description): - "Failed to clear the planned stay: \(description)" + @LogEvent("clear-failed", level: .warning) + struct ClearFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to clear the planned stay: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/LocationNamerLog.swift b/Where/WhereUI/Sources/Logging/LocationNamerLog.swift index 69c5720b8..e6c94af85 100644 --- a/Where/WhereUI/Sources/Logging/LocationNamerLog.swift +++ b/Where/WhereUI/Sources/Logging/LocationNamerLog.swift @@ -1,35 +1,20 @@ import PeriscopeCore -/// Structured events for `LocationNamer`'s reverse geocoding. -/// -/// Place names are best-effort sugar over coordinates the app already stores — -/// nothing depends on one resolving — so a failure is degraded-but-handled and -/// logs at `.warning`. Deliberately *not* logged: a lookup that simply has no -/// match (mid-ocean, an unnamed place). That's a legitimate empty answer, and -/// warning on it would bury the real failures under the common case. -/// -/// No coordinate rides on these events: the reason a geocode failed doesn't -/// depend on where it was, and the app's diagnostics store shouldn't accumulate -/// the user's positions as a side effect of a network error. -enum LocationNamerLog: LogEvent { - /// The geocoder refused the coordinate outright, so no request could be - /// made (an out-of-range or non-finite value reached the namer). - case unusableCoordinate - /// The geocode request failed — offline, rate-limited, or a service error. - case geocodeFailed(description: String) +@LogScope("LocationNamer") +enum LocationNamerLog { + @LogEvent( + "unusable-coordinate", + level: .warning, + message: "Skipped a place-name lookup for an unusable coordinate", + ) + struct UnusableCoordinate {} - static let eventName = "LocationNamer" - - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case .unusableCoordinate: - "Skipped a place-name lookup for an unusable coordinate" - case let .geocodeFailed(description): - "Reverse geocoding failed: \(description)" + @LogEvent("geocode-failed", level: .warning) + struct GeocodeFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Reverse geocoding failed: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/LoggedDaysModelLog.swift b/Where/WhereUI/Sources/Logging/LoggedDaysModelLog.swift index fb9650831..6bca786ac 100644 --- a/Where/WhereUI/Sources/Logging/LoggedDaysModelLog.swift +++ b/Where/WhereUI/Sources/Logging/LoggedDaysModelLog.swift @@ -1,27 +1,22 @@ import PeriscopeCore import WhereCore -/// Structured events for `LoggedDaysModel`. A read failure leaves the list in an -/// honest error state, so it logs at `.warning`. The year rides on `externalID`. -enum LoggedDaysModelLog: LogEvent { - case loadFailed(year: Int, description: String) +@LogScope("LoggedDaysModel") +enum LoggedDaysModelLog { + @LogEvent("load-failed", level: .warning) + struct LoadFailed { + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int - static let eventName = "LoggedDaysModel" + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .loadFailed(year, description): - "Failed to load logged days for \(year): \(description)" + var message: String { + "Failed to load logged days for \(year): \(description)" } - } - var externalID: String? { - switch self { - case let .loadFailed(year, _): WhereStoreID.year(year) + var externalID: String? { + WhereStoreID.year(year) } } } diff --git a/Where/WhereUI/Sources/Logging/ManualDayViewLog.swift b/Where/WhereUI/Sources/Logging/ManualDayViewLog.swift index 4834d10ca..8312a2203 100644 --- a/Where/WhereUI/Sources/Logging/ManualDayViewLog.swift +++ b/Where/WhereUI/Sources/Logging/ManualDayViewLog.swift @@ -1,21 +1,15 @@ import PeriscopeCore -/// Structured events for `ManualDayView`, the manual add/edit form. A failure to -/// load regions for grouping degrades to an ungrouped list, so it logs at -/// `.warning`. -enum ManualDayViewLog: LogEvent { - case regionGroupingLoadFailed(description: String) - - static let eventName = "ManualDayView" - - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .regionGroupingLoadFailed(description): - "Manual-day form couldn't load regions for grouping: \(description)" +/// Structured events for `ManualDayView`. +@LogScope("ManualDayView") +enum ManualDayViewLog { + @LogEvent("region-grouping-load-failed", level: .warning) + struct RegionGroupingLoadFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Manual-day form couldn't load regions for grouping: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift index 1325fb8ad..4972bfcf2 100644 --- a/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift +++ b/Where/WhereUI/Sources/Logging/OnboardingViewLog.swift @@ -1,72 +1,94 @@ import PeriscopeCore -/// Structured events for `OnboardingView`. Most are degraded-but-handled — the -/// flow continues and the user isn't stranded — so they log at `.warning`; -/// only a scope that can't be created is fatal to the launch. -enum OnboardingViewLog: LogEvent { - case regionCommitFailed(description: String) - case backupRestoreFailed(description: String) - case backupRestoreCleanupFailed(description: String) - /// The user declined (or is restricted from) location access at the - /// onboarding ask. Expected, not a failure: tracking stays - /// intended-but-inactive and Settings offers the route to grant it. - case locationPermissionDenied - /// The non-backed-up installation sidecar could not be persisted, so the - /// app cannot safely register a stable recording identity. - case installationContextWriteFailed(description: String) - /// Backup exclusion failed and the unsafe sidecar could not be removed either. - case installationContextSecurityCleanupFailed( - exclusionDescription: String, - cleanupDescription: String, +/// Structured events for `OnboardingView`. +@LogScope("Onboarding") +enum OnboardingViewLog { + @LogEvent("region-commit-failed", level: .warning) + struct RegionCommitFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to commit onboarding region picks: \(description)" + } + } + + @LogEvent("backup-restore-failed", level: .warning) + struct BackupRestoreFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Onboarding backup restore failed: \(description)" + } + } + + @LogEvent("backup-restore-cleanup-failed", level: .error) + struct BackupRestoreCleanupFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Onboarding backup restore committed but recording cleanup failed: \(description)" + } + } + + @LogEvent( + "location-permission-denied", + level: .info, + message: "Location access declined during onboarding", ) - /// A crash left an atomically written replacement that could not decode; the older - /// authoritative context remains usable and the corrupt pending copy was removed. - case discardedCorruptInstallationContextPending - /// Opening the user's store failed, so onboarding can't hand the launch a - /// world to run in. Fails the gate, landing on the failure surface. - case scopeCreationFailed(description: String) - /// The stable device registration or selected recording command could not be persisted. - case recordingConfigurationFailed(description: String) - /// Building the demo world failed. Recoverable: the intro comes back with - /// an alert, and every other way forward still works. - case demoBuildFailed(description: String) + struct LocationPermissionDenied {} - static let eventName = "Onboarding" + @LogEvent("installation-context-write-failed", level: .error) + struct InstallationContextWriteFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to persist the installation recording context: \(description)" + } + } + + @LogEvent("installation-context-security-cleanup-failed", level: .error) + struct InstallationContextSecurityCleanupFailed { + @LogField("exclusion_description", exposure: .restricted, kind: .errorDetails) + var exclusionDescription: String + @LogField("cleanup_description", exposure: .restricted, kind: .errorDetails) + var cleanupDescription: String + var message: String { + "Failed to exclude the installation recording context from backup " + + "(\(exclusionDescription)) and failed to remove it safely (\(cleanupDescription))" + } + } + + @LogEvent( + "discarded-corrupt-installation-context-pending", + level: .warning, + message: "Discarded a corrupt pending installation recording context", + ) + struct DiscardedCorruptInstallationContextPending {} + + @LogEvent("scope-creation-failed", level: .error) + struct ScopeCreationFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to open the store during onboarding: \(description)" + } + } - var level: LogLevel { - switch self { - case .regionCommitFailed, .backupRestoreFailed, .demoBuildFailed, - .discardedCorruptInstallationContextPending: .warning - case .locationPermissionDenied: .info - case .installationContextWriteFailed, .installationContextSecurityCleanupFailed, - .scopeCreationFailed, - .recordingConfigurationFailed, .backupRestoreCleanupFailed: .error + @LogEvent("recording-configuration-failed", level: .error) + struct RecordingConfigurationFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to apply the onboarding recording choice: \(description)" } } - var message: String { - switch self { - case let .regionCommitFailed(description): - "Failed to commit onboarding region picks: \(description)" - case let .backupRestoreFailed(description): - "Onboarding backup restore failed: \(description)" - case let .backupRestoreCleanupFailed(description): - "Onboarding backup restore committed but recording cleanup failed: \(description)" - case .locationPermissionDenied: - "Location access declined during onboarding" - case let .installationContextWriteFailed(description): - "Failed to persist the installation recording context: \(description)" - case let .installationContextSecurityCleanupFailed(exclusion, cleanup): - "Failed to exclude the installation recording context from backup " - + "(\(exclusion)) and failed to remove it safely (\(cleanup))" - case .discardedCorruptInstallationContextPending: - "Discarded a corrupt pending installation recording context" - case let .scopeCreationFailed(description): - "Failed to open the store during onboarding: \(description)" - case let .recordingConfigurationFailed(description): - "Failed to apply the onboarding recording choice: \(description)" - case let .demoBuildFailed(description): - "Failed to build the demo world: \(description)" + @LogEvent("demo-build-failed", level: .warning) + struct DemoBuildFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to build the demo world: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/RecordingConfigurationWarningModelLog.swift b/Where/WhereUI/Sources/Logging/RecordingConfigurationWarningModelLog.swift index 43fda9caf..f54fbc78d 100644 --- a/Where/WhereUI/Sources/Logging/RecordingConfigurationWarningModelLog.swift +++ b/Where/WhereUI/Sources/Logging/RecordingConfigurationWarningModelLog.swift @@ -1,19 +1,15 @@ import PeriscopeCore /// Structured failures for the advisory recording-configuration warning. -enum RecordingConfigurationWarningModelLog: LogEvent { - case authorityLoadFailed(description: String) - - static let eventName = "RecordingConfigurationWarning" - - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .authorityLoadFailed(description): - "Failed to resolve primary recording-device authority: \(description)" +@LogScope("RecordingConfigurationWarning") +enum RecordingConfigurationWarningModelLog { + @LogEvent("authority-load-failed", level: .warning) + struct AuthorityLoadFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Failed to resolve primary recording-device authority: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/RegionPickerViewLog.swift b/Where/WhereUI/Sources/Logging/RegionPickerViewLog.swift index 97a43ffc6..1d520683d 100644 --- a/Where/WhereUI/Sources/Logging/RegionPickerViewLog.swift +++ b/Where/WhereUI/Sources/Logging/RegionPickerViewLog.swift @@ -1,20 +1,15 @@ import PeriscopeCore -/// Structured events for `RegionPickerView`. A geometry-load failure leaves the -/// map in an honest error state (not a blank map), so it logs at `.warning`. -enum RegionPickerViewLog: LogEvent { - case mapGeometryLoadFailed(description: String) - - static let eventName = "RegionPicker" - - var level: LogLevel { - .warning - } - - var message: String { - switch self { - case let .mapGeometryLoadFailed(description): - "Region picker failed to load map geometry: \(description)" +/// Structured events for `RegionPickerView`. +@LogScope("RegionPicker") +enum RegionPickerViewLog { + @LogEvent("map-geometry-load-failed", level: .warning) + struct MapGeometryLoadFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + + var message: String { + "Region picker failed to load map geometry: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/RegionsSettingsViewLog.swift b/Where/WhereUI/Sources/Logging/RegionsSettingsViewLog.swift index a45b3cf56..d2f377732 100644 --- a/Where/WhereUI/Sources/Logging/RegionsSettingsViewLog.swift +++ b/Where/WhereUI/Sources/Logging/RegionsSettingsViewLog.swift @@ -1,24 +1,23 @@ import PeriscopeCore -/// Structured events for `RegionsSettingsView`, the post-onboarding primary-region -/// editor. Both failures leave an honest fallback (an empty picker / staying -/// open) rather than stranding the user, so they log at `.warning`. -enum RegionsSettingsViewLog: LogEvent { - case primaryRegionsLoadFailed(description: String) - case primaryRegionsSaveFailed(description: String) - - static let eventName = "RegionsSettings" - - var level: LogLevel { - .warning +/// Structured events for the primary-region editor. +@LogScope("RegionsSettings") +enum RegionsSettingsViewLog { + @LogEvent("primary-regions-load-failed", level: .warning) + struct PrimaryRegionsLoadFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to load primary regions for editing: \(description)" + } } - var message: String { - switch self { - case let .primaryRegionsLoadFailed(description): - "Failed to load primary regions for editing: \(description)" - case let .primaryRegionsSaveFailed(description): - "Failed to save primary region edits: \(description)" + @LogEvent("primary-regions-save-failed", level: .warning) + struct PrimaryRegionsSaveFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to save primary region edits: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/ResolveModelLog.swift b/Where/WhereUI/Sources/Logging/ResolveModelLog.swift index 97c5a29a1..da17a208d 100644 --- a/Where/WhereUI/Sources/Logging/ResolveModelLog.swift +++ b/Where/WhereUI/Sources/Logging/ResolveModelLog.swift @@ -1,31 +1,30 @@ import PeriscopeCore -/// Structured events for `ResolveModel`, the data-issue resolution flow. Read / -/// dismiss failures leave an honest UI error, so they log at `.warning`. A -/// dismissed issue's id rides on `externalID`. -enum ResolveModelLog: LogEvent { - case dataIssueScanFailed(description: String) - case dismissFailed(issueID: String, description: String) +@LogScope("Resolve") +enum ResolveModelLog { + @LogEvent("data-issue-scan-failed", level: .warning) + struct DataIssueScanFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to scan for data issues: \(description)" + } + } - static let eventName = "Resolve" + @LogEvent("dismiss-failed", level: .warning) + struct DismissFailed { + @LogField("issue_id", exposure: .restricted, kind: .identifier) + var issueID: String - var level: LogLevel { - .warning - } + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String - var message: String { - switch self { - case let .dataIssueScanFailed(description): - "Failed to scan for data issues: \(description)" - case let .dismissFailed(issueID, description): - "Failed to dismiss data issue \(issueID): \(description)" + var message: String { + "Failed to dismiss data issue \(issueID): \(description)" } - } - var externalID: String? { - switch self { - case let .dismissFailed(issueID, _): issueID - case .dataIssueScanFailed: nil + var externalID: String? { + issueID } } } diff --git a/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift b/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift index 0cd962407..189474c34 100644 --- a/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift +++ b/Where/WhereUI/Sources/Logging/WhereLaunchLog.swift @@ -1,23 +1,11 @@ import PeriscopeCore -/// Structured events for the app launch sequence (`WhereLaunch` / -/// `WhereBootstrap`), including the process-global log-store bootstrap. -enum WhereLaunchLog: LogEvent { - /// Names the launch spans — one budgeted span per measured launch or - /// teardown step (see `MeasuredStep`), plus the two log-store chores the - /// bootstrap runs off the critical path. - /// - /// `description` is spelled out rather than left to `String(describing:)` - /// because ``step(_:)`` carries a payload: reflection would render it - /// `step(WhereUI.LaunchStepID.resolveScope)`, leaking the module and the Swift - /// case name into a span name the tools group by. The hand-written form - /// yields `step(open-store)`, matching the step IDs everywhere else. +/// Structured events and spans for the app launch sequence. +@LogScope("WhereLaunch") +enum WhereLaunchLog { enum SpanName: Hashable, CustomStringConvertible { - /// One measured step of the launch or reset plan. case step(LaunchStepID) - /// Opening the durable Periscope store and attaching it as a sink. case openLogStore - /// Trimming persisted log history past the retention window. case pruneHistory var description: String { @@ -29,89 +17,66 @@ enum WhereLaunchLog: LogEvent { } } - case runnerCreated(reason: String) - case servicesAssembled - /// Assembling the service layer (store open + `WhereServices.make`) failed; - /// the `resolve-scope` step surfaces it and the launch parks in `.failed`. - case servicesAssemblyFailed(description: String) - /// The durable log store opened and became the active scope's sink. Fired - /// as soon as the store is browsable — retention pruning runs after, off the - /// ready path (see ``historyPruned``). - case loggingStoreReady - /// Opening the durable log store failed; logging continues through the - /// OSLog sink only, with no persisted history this launch. - case loggingStoreUnavailable(description: String) - /// Retention pruning finished. The two counts are reported separately - /// because they mean different things: `expiredEventCount` is routine, while - /// a nonzero `overflowEventCount` says this install out-logs its retention - /// window and is being held to the size cap instead. Runs after - /// ``loggingStoreReady``, so it never delays readiness. - case historyPruned(expiredEventCount: Int, overflowEventCount: Int) - /// Retention pruning failed; the store is still usable (last good history - /// preserved), it just isn't trimmed this launch. - case historyPruneFailed(description: String) - /// A detached (fire-and-forget) launch step failed. Never fatal — the - /// launch reaches `.ready` regardless and the runner records it on - /// `detachedFailures` — but it must be visible in logs too, not just on - /// observable state nothing renders (see `DetachedFailureReporter`). - case detachedStepFailed(stepID: String, description: String) + @LogEvent("runner-created") + struct RunnerCreated { + @LogField("reason", exposure: .restricted, kind: .technicalState) var reason: String + var message: String { + "Lifecycle runner created (reason: \(reason))" + } + } + + @LogEvent("services-assembled", message: "WhereServices assembled") + struct ServicesAssembled {} - static let eventName = "WhereLaunch" + @LogEvent("services-assembly-failed", level: .error) + struct ServicesAssemblyFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to assemble WhereServices: \(description)" + } + } + + @LogEvent("logging-store-ready", message: "Log store ready") + struct LoggingStoreReady {} + + @LogEvent("logging-store-unavailable", level: .error) + struct LoggingStoreUnavailable { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Log store unavailable: \(description)" + } + } - var level: LogLevel { - switch self { - case .runnerCreated, .servicesAssembled, .loggingStoreReady, .historyPruned: - .info - // The store is still usable when pruning fails (degraded-but-handled), - // unlike an outright open failure. A detached-step failure is the - // same shape: the launch stays healthy, one best-effort fan-out - // didn't land. - case .historyPruneFailed, .detachedStepFailed: - .warning - case .servicesAssemblyFailed, .loggingStoreUnavailable: - .error + @LogEvent("history-pruned") + struct HistoryPruned { + @LogField("expired_event_count", exposure: .shareable, kind: .count) + var expiredEventCount: Int + @LogField("overflow_event_count", exposure: .shareable, kind: .count) + var overflowEventCount: Int + var message: String { + "Pruned \(expiredEventCount) log event(s) past retention" + + " and \(overflowEventCount) past the size cap" } } - var message: String { - switch self { - case let .runnerCreated(reason): - "Lifecycle runner created (reason: \(reason))" - case .servicesAssembled: - "WhereServices assembled" - case let .servicesAssemblyFailed(description): - "Failed to assemble WhereServices: \(description)" - case .loggingStoreReady: - "Log store ready" - case let .loggingStoreUnavailable(description): - "Log store unavailable: \(description)" - case let .historyPruned(expiredEventCount, overflowEventCount): - "Pruned \(expiredEventCount) log event(s) past retention" - + " and \(overflowEventCount) past the size cap" - case let .historyPruneFailed(description): - "Failed to prune log history: \(description)" - case let .detachedStepFailed(stepID, description): - "Detached launch step '\(stepID)' failed: \(description)" + @LogEvent("history-prune-failed", level: .warning) + struct HistoryPruneFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to prune log history: \(description)" } } - var remoteFields: [RemoteLogField] { - switch self { - case let .historyPruned(expiredEventCount, overflowEventCount): - [ - RemoteLogField( - key: RemoteLogFieldKey("expired_event_count"), - value: .count(expiredEventCount), - ), - RemoteLogField( - key: RemoteLogFieldKey("overflow_event_count"), - value: .count(overflowEventCount), - ), - ] - case .runnerCreated, .servicesAssembled, .servicesAssemblyFailed, - .loggingStoreReady, .loggingStoreUnavailable, .historyPruneFailed, - .detachedStepFailed: - [] + @LogEvent("detached-step-failed", level: .warning) + struct DetachedStepFailed { + @LogField("step_id", exposure: .restricted, kind: .identifier) var stepID: String + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Detached launch step '\(stepID)' failed: \(description)" } } } diff --git a/Where/WhereUI/Sources/Logging/WhereModelLog.swift b/Where/WhereUI/Sources/Logging/WhereModelLog.swift index d81f38a31..0d9b8f413 100644 --- a/Where/WhereUI/Sources/Logging/WhereModelLog.swift +++ b/Where/WhereUI/Sources/Logging/WhereModelLog.swift @@ -1,65 +1,33 @@ import PeriscopeCore -/// Structured events for `WhereModel`, the app's top-level session/onboarding -/// coordinator. All are successful-lifecycle `.info` events. -enum WhereModelLog: LogEvent { - private enum RemoteKind: String, CaseIterable { - case onboardingCompleted = "onboarding-completed" - case openedRealScope = "opened-real-scope" - case startedSession = "started-session" - case endedSession = "ended-session" - case resetPreferences = "reset-preferences" - case enteredDemoMode = "entered-demo-mode" - case exitedDemoMode = "exited-demo-mode" - } +/// Structured events for `WhereModel`. +@LogScope("WhereModel") +enum WhereModelLog { + @LogEvent("onboarding-completed", message: "Onboarding completed") + struct OnboardingCompleted {} - case onboardingCompleted - /// The user's real scope was built — the app's one on-disk store open. - /// Fires when they first commit to using the app for real, not at launch. - case openedRealScope - case startedSession(year: Int) - case endedSession - case resetPreferences - /// Logged in to a demo world. Everything from here until - /// ``exitedDemoMode`` describes fabricated data in memory, so a log read - /// back later isn't mistaken for the user's real history. - case enteredDemoMode - case exitedDemoMode + @LogEvent("opened-real-scope", message: "Opened the real scope") + struct OpenedRealScope {} - static let eventName = "WhereModel" + @LogEvent("started-session") + struct StartedSession { + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int - var message: String { - switch self { - case .onboardingCompleted: - "Onboarding completed" - case .openedRealScope: - "Opened the real scope" - case let .startedSession(year): - "Started session (year: \(year))" - case .endedSession: - "Ended session" - case .resetPreferences: - "Reset preferences to first-install defaults" - case .enteredDemoMode: - "Entered demo mode" - case .exitedDemoMode: - "Exited demo mode" + var message: String { + "Started session (year: \(year))" } } - var remoteFields: [RemoteLogField] { - [RemoteLogField.eventKind(remoteKind)] - } + @LogEvent("ended-session", message: "Ended session") + struct EndedSession {} - private var remoteKind: RemoteKind { - switch self { - case .onboardingCompleted: .onboardingCompleted - case .openedRealScope: .openedRealScope - case .startedSession: .startedSession - case .endedSession: .endedSession - case .resetPreferences: .resetPreferences - case .enteredDemoMode: .enteredDemoMode - case .exitedDemoMode: .exitedDemoMode - } - } + @LogEvent("reset-preferences", message: "Reset preferences to first-install defaults") + struct ResetPreferences {} + + @LogEvent("entered-demo-mode", message: "Entered demo mode") + struct EnteredDemoMode {} + + @LogEvent("exited-demo-mode", message: "Exited demo mode") + struct ExitedDemoMode {} } diff --git a/Where/WhereUI/Sources/Logging/WhereSessionLog.swift b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift index 54038ffe2..484bf212a 100644 --- a/Where/WhereUI/Sources/Logging/WhereSessionLog.swift +++ b/Where/WhereUI/Sources/Logging/WhereSessionLog.swift @@ -1,116 +1,84 @@ import PeriscopeCore -/// Structured events for `WhereSession`, the always-on tracking/authorization -/// coordinator. Degraded-but-handled authorization states log at `.warning`; -/// successful lifecycle transitions at `.info`. -enum WhereSessionLog: LogEvent { - private enum RemoteKind: String, CaseIterable { - case whenInUseOnly = "when-in-use-only" - case locationAccessDenied = "location-access-denied" - case backgroundTrackingStarted = "background-tracking-started" - case backgroundTrackingStopped = "background-tracking-stopped" - case permissionGranted = "permission-granted" - case trackingEnabled = "tracking-enabled" - case stoppedBackgroundTracking = "stopped-background-tracking" - case recordingReconcileFailed = "recording-reconcile-failed" - case remindersUnauthorized = "reminders-unauthorized" - case summaryUnauthorized = "summary-unauthorized" - case issueAlertsUnauthorized = "issue-alerts-unauthorized" - case regionStylesLoadFailed = "region-styles-load-failed" - case erasedSession = "erased-session" - } +/// Structured events and spans for `WhereSession`. +@LogScope("WhereSession") +enum WhereSessionLog { + enum SpanName: Hashable { case foregroundRefresh } + + @LogEvent( + "when-in-use-only", + level: .warning, + message: "Location authorized for When-In-Use only; background tracking unavailable", + ) + struct WhenInUseOnly {} - /// Names the coordinator's timed span. - /// - /// Only the *composed* foreground pass is timed. Each individual step - /// (`syncAuthorization`, `applyReminderConfiguration`, …) delegates straight - /// to a `WhereCore` collaborator that spans itself, and the launch measures - /// the same steps individually; a second span per step would double every - /// reading without adding a fact. - enum SpanName: Hashable { - /// Everything the coordinator re-runs when the app returns to the - /// foreground — the wall-clock cost of a resume, from the user's side. - case foregroundRefresh + @LogEvent("location-access-denied", level: .warning) + struct LocationAccessDenied { + @LogField("status", exposure: .restricted, kind: .technicalState) var status: String + var message: String { + "Location access \(status); background tracking unavailable" + } } - case whenInUseOnly - case locationAccessDenied(status: String) - case backgroundTrackingStarted - case backgroundTrackingStopped - case permissionGranted(status: String) - case trackingEnabled - case stoppedBackgroundTracking - case recordingReconcileFailed(description: String) - case remindersUnauthorized - case summaryUnauthorized - case issueAlertsUnauthorized - case regionStylesLoadFailed(description: String) - case erasedSession + @LogEvent("background-tracking-started", message: "Background tracking started") + struct BackgroundTrackingStarted {} - static let eventName = "WhereSession" + @LogEvent("background-tracking-stopped", message: "Background tracking stopped") + struct BackgroundTrackingStopped {} - var level: LogLevel { - switch self { - case .whenInUseOnly, .locationAccessDenied, .remindersUnauthorized, - .summaryUnauthorized, .issueAlertsUnauthorized, .regionStylesLoadFailed, - .recordingReconcileFailed: - .warning - case .backgroundTrackingStarted, .backgroundTrackingStopped, .permissionGranted, - .trackingEnabled, .stoppedBackgroundTracking, .erasedSession: - .info + @LogEvent("permission-granted") + struct PermissionGranted { + @LogField("status", exposure: .restricted, kind: .technicalState) var status: String + var message: String { + "Location permission granted (\(status))" } } - var message: String { - switch self { - case .whenInUseOnly: - "Location authorized for When-In-Use only; background tracking unavailable" - case let .locationAccessDenied(status): - "Location access \(status); background tracking unavailable" - case .backgroundTrackingStarted: - "Background tracking started" - case .backgroundTrackingStopped: - "Background tracking stopped" - case let .permissionGranted(status): - "Location permission granted (\(status))" - case .trackingEnabled: - "Tracking enabled with background authorization" - case .stoppedBackgroundTracking: - "Stopped background tracking" - case let .recordingReconcileFailed(description): - "Failed to reconcile device recording policy: \(description)" - case .remindersUnauthorized: - "Logging reminders enabled but notifications not authorized" - case .summaryUnauthorized: - "Daily summary enabled but notifications not authorized" - case .issueAlertsUnauthorized: - "Issue alerts enabled but notifications not authorized" - case let .regionStylesLoadFailed(description): - "Failed to load region appearances for styling: \(description)" - case .erasedSession: - "Erased session and reset state" + @LogEvent("tracking-enabled", message: "Tracking enabled with background authorization") + struct TrackingEnabled {} + + @LogEvent("stopped-background-tracking", message: "Stopped background tracking") + struct StoppedBackgroundTracking {} + + @LogEvent("recording-reconcile-failed", level: .warning) + struct RecordingReconcileFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to reconcile device recording policy: \(description)" } } - var remoteFields: [RemoteLogField] { - [RemoteLogField.eventKind(remoteKind)] - } + @LogEvent( + "reminders-unauthorized", + level: .warning, + message: "Logging reminders enabled but notifications not authorized", + ) + struct RemindersUnauthorized {} + + @LogEvent( + "summary-unauthorized", + level: .warning, + message: "Daily summary enabled but notifications not authorized", + ) + struct SummaryUnauthorized {} - private var remoteKind: RemoteKind { - switch self { - case .whenInUseOnly: .whenInUseOnly - case .locationAccessDenied: .locationAccessDenied - case .backgroundTrackingStarted: .backgroundTrackingStarted - case .backgroundTrackingStopped: .backgroundTrackingStopped - case .permissionGranted: .permissionGranted - case .trackingEnabled: .trackingEnabled - case .stoppedBackgroundTracking: .stoppedBackgroundTracking - case .recordingReconcileFailed: .recordingReconcileFailed - case .remindersUnauthorized: .remindersUnauthorized - case .summaryUnauthorized: .summaryUnauthorized - case .issueAlertsUnauthorized: .issueAlertsUnauthorized - case .regionStylesLoadFailed: .regionStylesLoadFailed - case .erasedSession: .erasedSession + @LogEvent( + "issue-alerts-unauthorized", + level: .warning, + message: "Issue alerts enabled but notifications not authorized", + ) + struct IssueAlertsUnauthorized {} + + @LogEvent("region-styles-load-failed", level: .warning) + struct RegionStylesLoadFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to load region appearances for styling: \(description)" } } + + @LogEvent("erased-session", message: "Erased session and reset state") + struct ErasedSession {} } diff --git a/Where/WhereUI/Sources/Logging/YearReportModelLog.swift b/Where/WhereUI/Sources/Logging/YearReportModelLog.swift index 9a4be5110..04be45b13 100644 --- a/Where/WhereUI/Sources/Logging/YearReportModelLog.swift +++ b/Where/WhereUI/Sources/Logging/YearReportModelLog.swift @@ -1,93 +1,139 @@ import PeriscopeCore import WhereCore -/// Structured events for `YearReportModel`. The affected year rides on -/// `externalID`. A successful load is `.info`; read failures that leave a -/// degraded UI state are `.warning`. -enum YearReportModelLog: LogEvent { - /// Names the model's timed span. - /// - /// Only the composed pass is timed: the report read, the evidence-day fetch, - /// and the issue scan each already span themselves in `WhereCore`, so what's - /// missing at this layer is their *sum* — what a screen waits on. - enum SpanName: Hashable { - /// The scene's whole data pull: year report, evidence day keys, and the - /// Resolve badge recount. Runs on activation, on a year switch, and on - /// every committed write, so its duration is the refresh cost the UI - /// pays per store change. - case sceneRefresh +/// Structured events and spans for `YearReportModel`. +@LogScope("YearReport") +enum YearReportModelLog { + enum SpanName: Hashable { case sceneRefresh } + + @LogEvent("selected-year") + struct SelectedYear { + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int + var message: String { + "Selected year \(year)" + } + + var externalID: String? { + WhereStoreID.year(year) + } + } + + @LogEvent("report-loaded") + struct ReportLoaded { + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int + @LogField("day_count", exposure: .shareable, kind: .count) + var dayCount: Int + var message: String { + "Year report loaded for \(year) (\(dayCount) day(s))" + } + + var externalID: String? { + WhereStoreID.year(year) + } + } + + @LogEvent("report-load-failed", level: .warning) + struct ReportLoadFailed { + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to load year report for \(year): \(description)" + } + + var externalID: String? { + WhereStoreID.year(year) + } + } + + @LogEvent("evidence-day-keys-load-failed", level: .warning) + struct EvidenceDayKeysLoadFailed { + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to load evidence day keys for \(year): \(description)" + } + + var externalID: String? { + WhereStoreID.year(year) + } } - case selectedYear(year: Int) - case reportLoaded(year: Int, dayCount: Int) - case reportLoadFailed(year: Int, description: String) - case evidenceDayKeysLoadFailed(year: Int, description: String) - case dataIssueScanFailed(description: String) - case clearYearFailed(year: Int, description: String) - case locationsLoadFailed(region: String, year: Int, description: String) - case dayLocationsLoadFailed(day: String, year: Int, description: String) - case representativeCoordinatesLoadFailed(year: Int, description: String) - - static let eventName = "YearReport" - - var level: LogLevel { - switch self { - case .selectedYear, .reportLoaded: .info - case .reportLoadFailed, .evidenceDayKeysLoadFailed, .dataIssueScanFailed, - .clearYearFailed, .locationsLoadFailed, .dayLocationsLoadFailed, - .representativeCoordinatesLoadFailed: - .warning + @LogEvent("data-issue-scan-failed", level: .warning) + struct DataIssueScanFailed { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to scan for data issues: \(description)" } } - var message: String { - switch self { - case let .selectedYear(year): - "Selected year \(year)" - case let .reportLoaded(year, dayCount): - "Year report loaded for \(year) (\(dayCount) day(s))" - case let .reportLoadFailed(year, description): - "Failed to load year report for \(year): \(description)" - case let .evidenceDayKeysLoadFailed(year, description): - "Failed to load evidence day keys for \(year): \(description)" - case let .dataIssueScanFailed(description): - "Failed to scan for data issues: \(description)" - case let .clearYearFailed(year, description): - "Failed to clear year \(year): \(description)" - case let .locationsLoadFailed(region, year, description): - "Failed to load locations for \(region) in \(year): \(description)" - case let .dayLocationsLoadFailed(day, year, description): - "Failed to load locations for day \(day) in \(year): \(description)" - case let .representativeCoordinatesLoadFailed(year, description): - "Failed to load representative coordinates for \(year): \(description)" + @LogEvent("clear-year-failed", level: .warning) + struct ClearYearFailed { + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to clear year \(year): \(description)" + } + + var externalID: String? { + WhereStoreID.year(year) } } - var externalID: String? { - switch self { - case let .selectedYear(year), let .reportLoaded(year, _), - let .reportLoadFailed(year, _), let .evidenceDayKeysLoadFailed(year, _), - let .clearYearFailed(year, _), let .locationsLoadFailed(_, year, _), - let .representativeCoordinatesLoadFailed(year, _): - WhereStoreID.year(year) - case let .dayLocationsLoadFailed(day, _, _): - WhereStoreID.day(day) - case .dataIssueScanFailed: - nil + @LogEvent("locations-load-failed", level: .warning) + struct LocationsLoadFailed { + @LogField("region", exposure: .restricted, kind: .location) + var region: String + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to load locations for \(region) in \(year): \(description)" + } + + var externalID: String? { + WhereStoreID.year(year) } } - var remoteFields: [RemoteLogField] { - switch self { - case let .reportLoaded(_, dayCount): - [RemoteLogField( - key: RemoteLogFieldKey("day_count"), - value: .count(dayCount), - )] - case .selectedYear, .reportLoadFailed, .evidenceDayKeysLoadFailed, - .dataIssueScanFailed, .clearYearFailed, .locationsLoadFailed, - .dayLocationsLoadFailed, .representativeCoordinatesLoadFailed: - [] + @LogEvent("day-locations-load-failed", level: .warning) + struct DayLocationsLoadFailed { + @LogField("day", exposure: .restricted, kind: .dateTime) + var day: String + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to load locations for day \(day) in \(year): \(description)" + } + + var externalID: String? { + WhereStoreID.day(day) + } + } + + @LogEvent("representative-coordinates-load-failed", level: .warning) + struct RepresentativeCoordinatesLoadFailed { + @LogField("year", exposure: .restricted, kind: .domainValue) + var year: Int + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Failed to load representative coordinates for \(year): \(description)" + } + + var externalID: String? { + WhereStoreID.year(year) } } } diff --git a/Where/WhereUI/Sources/Manual/ManualDayView.swift b/Where/WhereUI/Sources/Manual/ManualDayView.swift index dd7559ae1..96c1a3dd6 100644 --- a/Where/WhereUI/Sources/Manual/ManualDayView.swift +++ b/Where/WhereUI/Sources/Manual/ManualDayView.swift @@ -266,9 +266,10 @@ struct ManualDayView: View { let tracked = try await report.services.primaryRegions() activeRegions.applyGrouping(tracked: tracked, usedThisYear: usedThisYear) } catch { - Self.logger(attachments: [.error(error, name: "grouping-error")]) { - .regionGroupingLoadFailed(description: error.localizedDescription) - } + Self.logger.regionGroupingLoadFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "grouping-error")], + ) } } diff --git a/Where/WhereUI/Sources/Model/AddEvidenceModel.swift b/Where/WhereUI/Sources/Model/AddEvidenceModel.swift index 041756a4e..e00435f44 100644 --- a/Where/WhereUI/Sources/Model/AddEvidenceModel.swift +++ b/Where/WhereUI/Sources/Model/AddEvidenceModel.swift @@ -93,7 +93,9 @@ public final class AddEvidenceModel { public func reportAttachmentError(_ message: String) { attachmentError = message - Self.logger { .attachmentPickFailed(description: message) } + Self.logger.attachmentPickFailed( + description: .restricted(.errorDetails, message), + ) } /// Build the `Evidence` from the form and persist it (with any attachment @@ -105,11 +107,15 @@ public final class AddEvidenceModel { let evidence = buildEvidence() do { try await services.journal.addEvidence(evidence, blob: attachment?.data) - Self.logger { .saved(evidenceID: String(describing: evidence.id)) } + Self.logger.saved( + evidenceID: .restricted(.identifier, String(describing: evidence.id)), + ) return true } catch { saveState = .failed(error.localizedDescription) - Self.logger { .saveFailed(description: error.localizedDescription) } + Self.logger.saveFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) return false } } diff --git a/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift b/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift index fc791f6ac..f0e786b84 100644 --- a/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift +++ b/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift @@ -40,12 +40,10 @@ public final class EvidenceDetailModel { blobState = .loaded(blob) } catch { blobState = .failed(error.localizedDescription) - Self.logger { - .blobLoadFailed( - evidenceID: String(describing: evidence.id), - description: error.localizedDescription, - ) - } + Self.logger.blobLoadFailed( + evidenceID: .restricted(.identifier, String(describing: evidence.id)), + description: .restricted(.errorDetails, error.localizedDescription), + ) } } diff --git a/Where/WhereUI/Sources/Model/EvidenceListModel.swift b/Where/WhereUI/Sources/Model/EvidenceListModel.swift index c8cf54408..293348ea2 100644 --- a/Where/WhereUI/Sources/Model/EvidenceListModel.swift +++ b/Where/WhereUI/Sources/Model/EvidenceListModel.swift @@ -49,7 +49,10 @@ public final class EvidenceListModel { loadState = evidence.isEmpty ? .empty : .loaded(evidence) } catch { loadState = .failed(error.localizedDescription) - Self.logger { .loadFailed(year: year, description: error.localizedDescription) } + Self.logger.loadFailed( + year: .restricted(.domainValue, year), + description: .restricted(.errorDetails, error.localizedDescription), + ) } } diff --git a/Where/WhereUI/Sources/Model/LocationForecastModel.swift b/Where/WhereUI/Sources/Model/LocationForecastModel.swift index f17a6d048..46b62afb5 100644 --- a/Where/WhereUI/Sources/Model/LocationForecastModel.swift +++ b/Where/WhereUI/Sources/Model/LocationForecastModel.swift @@ -42,7 +42,9 @@ final class LocationForecastModel { let stay = try await services.plannedStays.active() if activePlannedStay != stay { activePlannedStay = stay } } catch { - Self.logger { .loadFailed(description: error.localizedDescription) } + Self.logger.loadFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) } } @@ -124,7 +126,9 @@ final class LocationForecastModel { try await services.plannedStays.set(region: region, through: day) activePlannedStay = PlannedStay(region: region, through: day) } catch { - Self.logger { .saveFailed(description: error.localizedDescription) } + Self.logger.saveFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) throw error } } @@ -134,7 +138,9 @@ final class LocationForecastModel { try await services.plannedStays.clear() activePlannedStay = nil } catch { - Self.logger { .clearFailed(description: error.localizedDescription) } + Self.logger.clearFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) throw error } } diff --git a/Where/WhereUI/Sources/Model/LoggedDaysModel.swift b/Where/WhereUI/Sources/Model/LoggedDaysModel.swift index 7897e8bd9..f3d99c3db 100644 --- a/Where/WhereUI/Sources/Model/LoggedDaysModel.swift +++ b/Where/WhereUI/Sources/Model/LoggedDaysModel.swift @@ -71,7 +71,10 @@ public final class LoggedDaysModel { loadState = days.isEmpty ? .empty : .loaded(days) } catch { loadState = .failed(error.localizedDescription) - Self.logger { .loadFailed(year: year, description: error.localizedDescription) } + Self.logger.loadFailed( + year: .restricted(.domainValue, year), + description: .restricted(.errorDetails, error.localizedDescription), + ) } } diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 63769c7e7..976f1a694 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -264,7 +264,7 @@ public final class WhereModel { preferences.theme = theme publishThemeChange(theme) hasOnboarded = true - Self.logger { .onboardingCompleted } + Self.logger.onboardingCompleted() } /// Preview a theme without writing device preferences. @@ -481,7 +481,7 @@ public final class WhereModel { // login gets a fresh one. scopeState = .real(scope) logStoreState = scope.logStoreState - Self.logger { .openedRealScope } + Self.logger.openedRealScope() return scope } } @@ -525,7 +525,7 @@ public final class WhereModel { scopeState = .demo(scope) scope.startLogRouting() logStoreState = scope.logStoreState - Self.logger { .enteredDemoMode } + Self.logger.enteredDemoMode() } /// Leave demo mode: drop the demo session and scope, and give the real @@ -539,7 +539,7 @@ public final class WhereModel { public func deactivateDemo() async { guard isInDemoMode else { return } await logOut() - Self.logger { .exitedDemoMode } + Self.logger.exitedDemoMode() } /// Create the logged-in `WhereSession` over `scope` and return it — the @@ -556,7 +556,9 @@ public final class WhereModel { now: now, ) self.session = session - Self.logger { .startedSession(year: initialSelectedYear) } + Self.logger.startedSession( + year: .restricted(.domainValue, initialSelectedYear), + ) return session } @@ -566,13 +568,13 @@ public final class WhereModel { /// store and the installation context current at that attempt. public func endSession() async { await logOut() - Self.logger { .endedSession } + Self.logger.endedSession() } func rejoinInstallation() async throws { _ = try installationContextStore.rejoin() await logOut() - Self.logger { .endedSession } + Self.logger.endedSession() } /// Release whatever scope is active and return to logged out, ready to @@ -612,14 +614,14 @@ public final class WhereModel { diagnosticReporting.preferencesDidReset() theme = preferences.theme publishThemeChange(theme) - Self.logger { .resetPreferences } + Self.logger.resetPreferences() throw error } preferences.reset() diagnosticReporting.preferencesDidReset() theme = preferences.theme publishThemeChange(theme) - Self.logger { .resetPreferences } + Self.logger.resetPreferences() } } diff --git a/Where/WhereUI/Sources/Model/WhereScope.swift b/Where/WhereUI/Sources/Model/WhereScope.swift index 8f4cd3cef..1b6259cd4 100644 --- a/Where/WhereUI/Sources/Model/WhereScope.swift +++ b/Where/WhereUI/Sources/Model/WhereScope.swift @@ -375,9 +375,10 @@ public final class WhereScope { let description = String(describing: error) logRouting = .failed(description: description) onStateChange(self, .failed(description: description)) - Self.logger(attachments: [.error(error, name: "open-error")]) { - .loggingStoreUnavailable(description: description) - } + Self.logger.loggingStoreUnavailable( + description: .restricted(.errorDetails, description), + attachments: [.error(error, name: "open-error")], + ) return } guard let store else { @@ -386,7 +387,7 @@ public final class WhereScope { return } onStateChange(self, .ready(store)) - Self.logger { .loggingStoreReady } + Self.logger.loggingStoreReady() pruneHistory(in: store) } } @@ -401,16 +402,15 @@ public final class WhereScope { let pruned = try await Self.logger.measure(.pruneHistory, budget: .seconds(2)) { try await Self.historyPruner.prune(store) } - Self.logger { - .historyPruned( - expiredEventCount: pruned.expired, - overflowEventCount: pruned.overflowed, - ) - } + Self.logger.historyPruned( + expiredEventCount: .shared(.count, pruned.expired), + overflowEventCount: .shared(.count, pruned.overflowed), + ) } catch { - Self.logger(attachments: [.error(error, name: "prune-error")]) { - .historyPruneFailed(description: String(describing: error)) - } + Self.logger.historyPruneFailed( + description: .restricted(.errorDetails, String(describing: error)), + attachments: [.error(error, name: "prune-error")], + ) } } } diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 0fa23cc60..db405e634 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -289,11 +289,11 @@ public final class WhereSession { case .always, .notDetermined: break case .whenInUse: - Self.logger { .whenInUseOnly } + Self.logger.whenInUseOnly() case .denied, .restricted: - Self.logger { - .locationAccessDenied(status: String(describing: authorizationStatus)) - } + Self.logger.locationAccessDenied( + status: .restricted(.technicalState, String(describing: authorizationStatus)), + ) } } @@ -325,9 +325,10 @@ public final class WhereSession { let primary = try await services.primaryRegions() regionStyles = RegionStyleResolver(primaryRegions: primary) } catch { - Self.logger(attachments: [.error(error, name: "region-styles-error")]) { - .regionStylesLoadFailed(description: error.localizedDescription) - } + Self.logger.regionStylesLoadFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "region-styles-error")], + ) } } @@ -379,17 +380,18 @@ public final class WhereSession { } await synchronizeRecordingRuntimeState() if isTracking, !wasTracking { - Self.logger { .backgroundTrackingStarted } + Self.logger.backgroundTrackingStarted() } else if !isTracking, wasTracking { - Self.logger { .backgroundTrackingStopped } + Self.logger.backgroundTrackingStopped() } } catch { // Core fails closed and stops its source. Keep the UI mirror equally honest. didRegisterRecordingDevice = false await synchronizeRecordingRuntimeState() - Self.logger(attachments: [.error(error, name: "recording-reconcile-error")]) { - .recordingReconcileFailed(description: error.localizedDescription) - } + Self.logger.recordingReconcileFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "recording-reconcile-error")], + ) } } @@ -441,7 +443,9 @@ public final class WhereSession { await syncAuthorization() await reconcileTracking() if authorizationStatus.allowsBackgroundTracking { - Self.logger { .permissionGranted(status: String(describing: authorizationStatus)) } + Self.logger.permissionGranted( + status: .restricted(.technicalState, String(describing: authorizationStatus)), + ) } } @@ -454,9 +458,10 @@ public final class WhereSession { do { try await setRecordingEnabled(true) } catch { - Self.logger(attachments: [.error(error, name: "recording-enable-error")]) { - .recordingReconcileFailed(description: error.localizedDescription) - } + Self.logger.recordingReconcileFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "recording-enable-error")], + ) } } @@ -464,9 +469,10 @@ public final class WhereSession { do { try await setRecordingEnabled(false) } catch { - Self.logger(attachments: [.error(error, name: "recording-disable-error")]) { - .recordingReconcileFailed(description: error.localizedDescription) - } + Self.logger.recordingReconcileFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "recording-disable-error")], + ) } } @@ -499,9 +505,9 @@ public final class WhereSession { await synchronizeRecordingRuntimeState() permissionDenied = enabled && permissionRequestFailed if configuration.localAutomaticRecordingEnabled == true, isTracking { - Self.logger { .trackingEnabled } + Self.logger.trackingEnabled() } else if configuration.localAutomaticRecordingEnabled == false { - Self.logger { .stoppedBackgroundTracking } + Self.logger.stoppedBackgroundTracking() } } @@ -544,7 +550,7 @@ public final class WhereSession { let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedRemindersUnauthorized { - Self.logger { .remindersUnauthorized } + Self.logger.remindersUnauthorized() warnedRemindersUnauthorized = true } } else { @@ -562,7 +568,7 @@ public final class WhereSession { let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedSummaryUnauthorized { - Self.logger { .summaryUnauthorized } + Self.logger.summaryUnauthorized() warnedSummaryUnauthorized = true } } else { @@ -585,7 +591,7 @@ public final class WhereSession { let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedIssueAlertsUnauthorized { - Self.logger { .issueAlertsUnauthorized } + Self.logger.issueAlertsUnauthorized() warnedIssueAlertsUnauthorized = true } } else { @@ -631,6 +637,6 @@ public final class WhereSession { throw error } recordingRuntimeState = .unavailable - Self.logger { .erasedSession } + Self.logger.erasedSession() } } diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 08e6a20d5..2fc1dbc68 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -320,7 +320,7 @@ public final class YearReportModel { public func select(year: Int) async { guard year != selectedYear else { return } - Self.logger { .selectedYear(year: year) } + Self.logger.selectedYear(year: .restricted(.domainValue, year)) selectedYear = year // Drop the previous year's report so views fall back to their loading // state instead of rendering stale data under the new year's label. @@ -360,12 +360,10 @@ public final class YearReportModel { guard requestedYear == selectedYear else { return } if evidenceDayKeys != keys { evidenceDayKeys = keys } } catch { - Self.logger { - .evidenceDayKeysLoadFailed( - year: requestedYear, - description: error.localizedDescription, - ) - } + Self.logger.evidenceDayKeysLoadFailed( + year: .restricted(.domainValue, requestedYear), + description: .restricted(.errorDetails, error.localizedDescription), + ) } } @@ -401,7 +399,9 @@ public final class YearReportModel { } catch { // Surface the failure and keep the last good count rather than // silently blanking the badge. - Self.logger { .dataIssueScanFailed(description: error.localizedDescription) } + Self.logger.dataIssueScanFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) } } @@ -425,16 +425,18 @@ public final class YearReportModel { if changed { loadedYear = LoadedYear(details: details, previous: loadedYear) } if loadState != .loaded { loadState = .loaded } if changed { - Self.logger { - .reportLoaded(year: requestedYear, dayCount: details.report.days.count) - } + Self.logger.reportLoaded( + year: .restricted(.domainValue, requestedYear), + dayCount: .shared(.count, details.report.days.count), + ) } } catch { guard requestedYear == selectedYear else { return } loadState = .failed(.reportUnavailable(message: error.localizedDescription)) - Self.logger { - .reportLoadFailed(year: requestedYear, description: error.localizedDescription) - } + Self.logger.reportLoadFailed( + year: .restricted(.domainValue, requestedYear), + description: .restricted(.errorDetails, error.localizedDescription), + ) } } @@ -516,9 +518,10 @@ public final class YearReportModel { try await services.journal.clearYear(selectedYear) } catch { loadState = .failed(.clearFailed(message: error.localizedDescription)) - Self.logger { - .clearYearFailed(year: selectedYear, description: error.localizedDescription) - } + Self.logger.clearYearFailed( + year: .restricted(.domainValue, selectedYear), + description: .restricted(.errorDetails, error.localizedDescription), + ) } } @@ -537,13 +540,11 @@ public final class YearReportModel { do { return try await services.reports.locations(in: region, year: selectedYear) } catch { - Self.logger { - .locationsLoadFailed( - region: region.rawValue, - year: selectedYear, - description: error.localizedDescription, - ) - } + Self.logger.locationsLoadFailed( + region: .restricted(.location, region.rawValue), + year: .restricted(.domainValue, selectedYear), + description: .restricted(.errorDetails, error.localizedDescription), + ) return [] } } @@ -555,13 +556,12 @@ public final class YearReportModel { do { return try await services.reports.locations(onDay: day) } catch { - Self.logger(attachments: [.error(error, name: "day-locations-error")]) { - .dayLocationsLoadFailed( - day: day.description, - year: selectedYear, - description: error.localizedDescription, - ) - } + Self.logger.dayLocationsLoadFailed( + day: .restricted(.dateTime, day.description), + year: .restricted(.domainValue, selectedYear), + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "day-locations-error")], + ) return [:] } } @@ -573,12 +573,10 @@ public final class YearReportModel { do { return try await services.reports.representativeCoordinates(for: selectedYear) } catch { - Self.logger { - .representativeCoordinatesLoadFailed( - year: selectedYear, - description: error.localizedDescription, - ) - } + Self.logger.representativeCoordinatesLoadFailed( + year: .restricted(.domainValue, selectedYear), + description: .restricted(.errorDetails, error.localizedDescription), + ) return [:] } } diff --git a/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift b/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift index 590907054..29f9b1ea2 100644 --- a/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift +++ b/Where/WhereUI/Sources/Onboarding/OnboardingFlowModel.swift @@ -115,9 +115,10 @@ final class OnboardingFlowModel { preconditionFailure("A confirmed installation context must carry its choice.") } } catch { - Self.logger(attachments: [.error(error, name: "context-error")]) { - .installationContextWriteFailed(description: error.localizedDescription) - } + Self.logger.installationContextWriteFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "context-error")], + ) gate.fail(error) return } @@ -126,9 +127,10 @@ final class OnboardingFlowModel { do { scope = try await model.resolveScope() } catch { - Self.logger(attachments: [.error(error, name: "scope-error")]) { - .scopeCreationFailed(description: error.localizedDescription) - } + Self.logger.scopeCreationFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "scope-error")], + ) gate.fail(error) return } @@ -140,9 +142,10 @@ final class OnboardingFlowModel { do { try await configureRecording(in: scope) } catch { - Self.logger(attachments: [.error(error, name: "recording-configuration-error")]) { - .recordingConfigurationFailed(description: error.localizedDescription) - } + Self.logger.recordingConfigurationFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "recording-configuration-error")], + ) if let summary = restoreSelection.committedSummary { gate.fail(OnboardingCommittedImportSetupError( summary: summary, @@ -158,9 +161,10 @@ final class OnboardingFlowModel { do { try await selection.commit(using: scope) } catch { - Self.logger(attachments: [.error(error, name: "commit-error")]) { - .regionCommitFailed(description: error.localizedDescription) - } + Self.logger.regionCommitFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "commit-error")], + ) } } if !model.hasOnboarded { @@ -184,9 +188,10 @@ final class OnboardingFlowModel { intro.activity = .browsing } catch { intro.activity = .failed(.init(flow: .demo, error: error)) - Self.logger(attachments: [.error(error, name: "demo-error")]) { - .demoBuildFailed(description: error.localizedDescription) - } + Self.logger.demoBuildFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "demo-error")], + ) } } } @@ -253,9 +258,10 @@ final class OnboardingFlowModel { )) return false } - Self.logger(attachments: [.error(error.underlying, name: "cleanup-error")]) { - .backupRestoreCleanupFailed(description: error.underlying.localizedDescription) - } + Self.logger.backupRestoreCleanupFailed( + description: .restricted(.errorDetails, error.underlying.localizedDescription), + attachments: [.error(error.underlying, name: "cleanup-error")], + ) gate.fail(error) return false } catch let error as BackupCoordinator.CommittedImportSupersededError { @@ -281,9 +287,10 @@ final class OnboardingFlowModel { intro.activity = .failed(.init(flow: .restoreBackup, error: error)) phase = .intro isFinishing = false - Self.logger(attachments: [.error(error, name: "restore-error")]) { - .backupRestoreFailed(description: error.localizedDescription) - } + Self.logger.backupRestoreFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "restore-error")], + ) return false } } @@ -298,7 +305,7 @@ final class OnboardingFlowModel { do { try await scope.services.ingestor.requestPermission() } catch { - Self.logger { .locationPermissionDenied } + Self.logger.locationPermissionDenied() } } } diff --git a/Where/WhereUI/Sources/Primary/CalendarContentView.swift b/Where/WhereUI/Sources/Primary/CalendarContentView.swift index 0771c2441..5b080cf1b 100644 --- a/Where/WhereUI/Sources/Primary/CalendarContentView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarContentView.swift @@ -82,9 +82,12 @@ struct CalendarContentView: View { Text(String(localized: .calendarUnavailableDescription)) } .onAppear { - Self.logger { - .openedWithoutReport(loadState: String(describing: report.loadState)) - } + Self.logger.openedWithoutReport( + loadState: .restricted( + .technicalState, + String(describing: report.loadState), + ), + ) } } } @@ -125,7 +128,9 @@ struct CalendarContentView: View { Text(String(localized: .calendarUnavailableDescription)) } .onAppear { - Self.logger { .layoutFailed(description: String(describing: error)) } + Self.logger.layoutFailed( + description: .restricted(.errorDetails, String(describing: error)), + ) } } diff --git a/Where/WhereUI/Sources/Regions/RegionPickerView.swift b/Where/WhereUI/Sources/Regions/RegionPickerView.swift index b54973e57..f26d46059 100644 --- a/Where/WhereUI/Sources/Regions/RegionPickerView.swift +++ b/Where/WhereUI/Sources/Regions/RegionPickerView.swift @@ -229,9 +229,10 @@ struct RegionPickerView: View { guard !Task.isCancelled else { return } // Keep the failure observable in both the UI (error state) and the // logs rather than showing a blank map. - Self.logger(attachments: [.error(error, name: "geometry-error")]) { - .mapGeometryLoadFailed(description: error.localizedDescription) - } + Self.logger.mapGeometryLoadFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "geometry-error")], + ) mapData = .failure(error) } } diff --git a/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift index a674bb62e..89082e9fc 100644 --- a/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift +++ b/Where/WhereUI/Sources/Regions/RegionsSettingsView.swift @@ -85,9 +85,10 @@ struct RegionsSettingsView: View { let existing = try await session.services.primaryRegions() built = PrimaryRegionSelectionModel(existing: existing) } catch { - Self.logger(attachments: [.error(error, name: "load-error")]) { - .primaryRegionsLoadFailed(description: error.localizedDescription) - } + Self.logger.primaryRegionsLoadFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "load-error")], + ) // Fall back to an empty picker rather than a stuck spinner. built = PrimaryRegionSelectionModel() } @@ -103,9 +104,10 @@ struct RegionsSettingsView: View { do { try await model.commit(using: session) } catch { - Self.logger(attachments: [.error(error, name: "save-error")]) { - .primaryRegionsSaveFailed(description: error.localizedDescription) - } + Self.logger.primaryRegionsSaveFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "save-error")], + ) } dismiss() } diff --git a/Where/WhereUI/Sources/Resolution/ResolveModel.swift b/Where/WhereUI/Sources/Resolution/ResolveModel.swift index 394ce88eb..464810b83 100644 --- a/Where/WhereUI/Sources/Resolution/ResolveModel.swift +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -65,7 +65,9 @@ public final class ResolveModel { } catch { // Surface the failure and keep the last good list rather than // silently blanking the tab (which would read as "all clear"). - Self.logger { .dataIssueScanFailed(description: error.localizedDescription) } + Self.logger.dataIssueScanFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) } // Mark loaded even on failure so the view leaves the spinner (the error // was logged and the last good list preserved); a stuck spinner would be @@ -82,12 +84,10 @@ public final class ResolveModel { // recomputes the badge count a beat later. dataIssues.removeAll { $0.id == issue.id } } catch { - Self.logger { - .dismissFailed( - issueID: issue.id.storeURL.absoluteString, - description: error.localizedDescription, - ) - } + Self.logger.dismissFailed( + issueID: .restricted(.identifier, issue.id.storeURL.absoluteString), + description: .restricted(.errorDetails, error.localizedDescription), + ) } } } diff --git a/Where/WhereUI/Sources/Settings/AppIconOption.swift b/Where/WhereUI/Sources/Settings/AppIconOption.swift index d0ceb36c5..aeaeef4ac 100644 --- a/Where/WhereUI/Sources/Settings/AppIconOption.swift +++ b/Where/WhereUI/Sources/Settings/AppIconOption.swift @@ -80,9 +80,10 @@ enum AppIconCatalog { do { return try load(from: .module) } catch { - logger(attachments: [.error(error, name: "load-error")]) { - .manifestUnreadable(description: String(describing: error)) - } + logger.manifestUnreadable( + description: .restricted(.errorDetails, String(describing: error)), + attachments: [.error(error, name: "load-error")], + ) assertionFailure("Failed to load the bundled AppIcons.json manifest: \(error)") return [] } diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift index da2d3d090..31daf4d4e 100644 --- a/Where/WhereUI/Sources/Settings/BackupModel.swift +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -77,12 +77,14 @@ public final class BackupModel { let url = try await services.backup.exportBackup { continuation.yield($0) } continuation.finish() await observer.value - Self.logger { .exported } + Self.logger.exported() return url } catch { continuation.finish() presentBackupError(error) - Self.logger { .exportFailed(description: error.localizedDescription) } + Self.logger.exportFailed( + description: .restricted(.errorDetails, error.localizedDescription), + ) return nil } } diff --git a/Where/WhereUI/Sources/Settings/RecordingConfigurationWarningModel.swift b/Where/WhereUI/Sources/Settings/RecordingConfigurationWarningModel.swift index 0a80843b6..b26706065 100644 --- a/Where/WhereUI/Sources/Settings/RecordingConfigurationWarningModel.swift +++ b/Where/WhereUI/Sources/Settings/RecordingConfigurationWarningModel.swift @@ -81,9 +81,10 @@ final class RecordingConfigurationWarningModel { register(isWarningConditionActive: condition.isActive) } catch { guard sequence == refreshSequence else { return } - Self.logger(attachments: [.error(error, name: "recording-warning-error")]) { - .authorityLoadFailed(description: error.localizedDescription) - } + Self.logger.authorityLoadFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "recording-warning-error")], + ) } } diff --git a/Where/WhereUI/Sources/Shared/LocationNamer.swift b/Where/WhereUI/Sources/Shared/LocationNamer.swift index 048ab1e08..2c484e2e1 100644 --- a/Where/WhereUI/Sources/Shared/LocationNamer.swift +++ b/Where/WhereUI/Sources/Shared/LocationNamer.swift @@ -87,7 +87,7 @@ actor LocationNamer { private static func reverseGeocode(_ coordinate: Coordinate) async -> String? { let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) guard let request = MKReverseGeocodingRequest(location: location) else { - logger { .unusableCoordinate } + logger.unusableCoordinate() return nil } do { @@ -99,9 +99,10 @@ actor LocationNamer { } return PlaceComponents(representations).displayName } catch { - logger(attachments: [.error(error, name: "geocode-error")]) { - .geocodeFailed(description: error.localizedDescription) - } + logger.geocodeFailed( + description: .restricted(.errorDetails, error.localizedDescription), + attachments: [.error(error, name: "geocode-error")], + ) return nil } } diff --git a/Where/WhereUI/Tests/DemoModeTests.swift b/Where/WhereUI/Tests/DemoModeTests.swift index 2600af6a5..9549b7a01 100644 --- a/Where/WhereUI/Tests/DemoModeTests.swift +++ b/Where/WhereUI/Tests/DemoModeTests.swift @@ -363,8 +363,5 @@ struct DemoModeTests { /// A freeform probe event, so the routing tests can emit something they can /// then look for in a specific store. -private struct DemoProbeLog: LogEvent { - var message: String { - "" - } -} +@LogScope("DemoProbe") +private enum DemoProbeLog {} diff --git a/Where/WhereUI/Tests/Logging/WhereModelLogTests.swift b/Where/WhereUI/Tests/Logging/WhereModelLogTests.swift index 911a74526..56e2eeb34 100644 --- a/Where/WhereUI/Tests/Logging/WhereModelLogTests.swift +++ b/Where/WhereUI/Tests/Logging/WhereModelLogTests.swift @@ -1,30 +1,18 @@ -import PeriscopeCore import Testing @testable import WhereUI struct WhereModelLogTests { - @Test func everyEventCaseExportsADistinctSafeKind() { - let events: [WhereModelLog] = [ - .onboardingCompleted, - .openedRealScope, - .startedSession(year: 2026), - .endedSession, - .resetPreferences, - .enteredDemoMode, - .exitedDemoMode, + @Test func everyEventHasAStableDistinctName() { + let names = [ + WhereModelLog.OnboardingCompleted.eventName, + WhereModelLog.OpenedRealScope.eventName, + WhereModelLog.StartedSession.eventName, + WhereModelLog.EndedSession.eventName, + WhereModelLog.ResetPreferences.eventName, + WhereModelLog.EnteredDemoMode.eventName, + WhereModelLog.ExitedDemoMode.eventName, ] - - let kinds = events.compactMap(remoteKind) - #expect(kinds.count == events.count) - #expect(Set(kinds).count == events.count) - #expect(kinds.contains("2026") == false) - } - - private func remoteKind(_ event: WhereModelLog) -> String? { - guard let field = event.remoteFields.first, - field.key == RemoteLogFieldKey("kind"), - case let .category(category) = field.value - else { return nil } - return category.rawValue + #expect(Set(names).count == names.count) + #expect(names.allSatisfy { $0.hasPrefix("WhereModel.") }) } } diff --git a/Where/WhereUI/Tests/Logging/WhereSessionLogTests.swift b/Where/WhereUI/Tests/Logging/WhereSessionLogTests.swift index 060090ef5..d48e080e0 100644 --- a/Where/WhereUI/Tests/Logging/WhereSessionLogTests.swift +++ b/Where/WhereUI/Tests/Logging/WhereSessionLogTests.swift @@ -1,37 +1,24 @@ -import PeriscopeCore import Testing @testable import WhereUI struct WhereSessionLogTests { - @Test func everyEventCaseExportsADistinctSafeKind() { - let events: [WhereSessionLog] = [ - .whenInUseOnly, - .locationAccessDenied(status: "private status"), - .backgroundTrackingStarted, - .backgroundTrackingStopped, - .permissionGranted(status: "private status"), - .trackingEnabled, - .stoppedBackgroundTracking, - .recordingReconcileFailed(description: "private error"), - .remindersUnauthorized, - .summaryUnauthorized, - .issueAlertsUnauthorized, - .regionStylesLoadFailed(description: "private error"), - .erasedSession, + @Test func everyEventHasAStableDistinctName() { + let names = [ + WhereSessionLog.WhenInUseOnly.eventName, + WhereSessionLog.LocationAccessDenied.eventName, + WhereSessionLog.BackgroundTrackingStarted.eventName, + WhereSessionLog.BackgroundTrackingStopped.eventName, + WhereSessionLog.PermissionGranted.eventName, + WhereSessionLog.TrackingEnabled.eventName, + WhereSessionLog.StoppedBackgroundTracking.eventName, + WhereSessionLog.RecordingReconcileFailed.eventName, + WhereSessionLog.RemindersUnauthorized.eventName, + WhereSessionLog.SummaryUnauthorized.eventName, + WhereSessionLog.IssueAlertsUnauthorized.eventName, + WhereSessionLog.RegionStylesLoadFailed.eventName, + WhereSessionLog.ErasedSession.eventName, ] - - let kinds = events.compactMap(remoteKind) - #expect(kinds.count == events.count) - #expect(Set(kinds).count == events.count) - #expect(kinds.contains("private status") == false) - #expect(kinds.contains("private error") == false) - } - - private func remoteKind(_ event: WhereSessionLog) -> String? { - guard let field = event.remoteFields.first, - field.key == RemoteLogFieldKey("kind"), - case let .category(category) = field.value - else { return nil } - return category.rawValue + #expect(Set(names).count == names.count) + #expect(names.allSatisfy { $0.hasPrefix("WhereSession.") }) } } diff --git a/Where/WhereWidgets/Sources/Logging/WhereWidgetsLog.swift b/Where/WhereWidgets/Sources/Logging/WhereWidgetsLog.swift index 802e15db7..78c028a94 100644 --- a/Where/WhereWidgets/Sources/Logging/WhereWidgetsLog.swift +++ b/Where/WhereWidgets/Sources/Logging/WhereWidgetsLog.swift @@ -1,31 +1,20 @@ import PeriscopeCore -/// Structured events for the Where widget timeline provider — a separate -/// WidgetKit process, so `Periscope.shared` stays OSLog-only (no store). -enum WhereWidgetsLog: LogEvent { - /// No snapshot has been published yet (fresh install, unreadable file); the - /// provider renders the empty state. - case noPublishedSnapshot - /// The shared App Group container couldn't be opened. - case appGroupUnavailable(description: String) +@LogScope("WhereWidgets") +enum WhereWidgetsLog { + @LogEvent( + "no-published-snapshot", + level: .warning, + message: "No published widget snapshot; rendering empty state", + ) + struct NoPublishedSnapshot {} - static let eventName = "WhereWidgets" - - var level: LogLevel { - switch self { - case .noPublishedSnapshot: - .warning - case .appGroupUnavailable: - .error - } - } - - var message: String { - switch self { - case .noPublishedSnapshot: - "No published widget snapshot; rendering empty state" - case let .appGroupUnavailable(description): - "Widget App Group unavailable: \(description)" + @LogEvent("app-group-unavailable", level: .error) + struct AppGroupUnavailable { + @LogField("description", exposure: .restricted, kind: .errorDetails) + var description: String + var message: String { + "Widget App Group unavailable: \(description)" } } } diff --git a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift index a70ede87a..b0e9baf91 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift @@ -61,11 +61,12 @@ struct WhereWidgetProvider: TimelineProvider { if let snapshot = store.read() { return WhereWidgetEntry(date: now, snapshot: snapshot, theme: theme) } - Self.logger { .noPublishedSnapshot } + Self.logger.noPublishedSnapshot() } catch { - Self.logger(attachments: [.error(error, name: "app-group-error")]) { - .appGroupUnavailable(description: String(describing: error)) - } + Self.logger.appGroupUnavailable( + description: .restricted(.errorDetails, String(describing: error)), + attachments: [.error(error, name: "app-group-error")], + ) } return WhereWidgetEntry( date: now,