diff --git a/AGENTS.md b/AGENTS.md index 68de8623..d845887b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ by `./sync-agents`. `AGENTS.md` says what it is and how it may be used. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). - **CI scheme**: CI runs the explicit shared **Stuff-iOS-Tests** scheme (all test bundles) rather than the autogenerated `Stuff-Workspace` scheme. New test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. -- **Never double-link a package product into a test bundle that already gets it through a dynamic-framework dependency.** Xcode's default SPM integration (how `Package.local` is wired here) embeds a product's code into *every* image that links it. **WhereUI** is a dynamic framework that statically embeds its own dependencies (WhereCore, BroadwayCore/BroadwayUI, LifecycleKit, LogViewerUI, SwiftDataInspector, …), so a `*Tests` bundle that depends on **WhereUI** *and* re-lists one of those in `extraPackageProducts` ends up with a **second copy** of it. When the shared **StuffTestHost** loads several `.xctest` bundles into one process, that leaves duplicate **type metadata** for the module, and any *type-keyed runtime lookup that crosses the WhereUI boundary* — SwiftUI `EnvironmentKey`s, `UITraitBridgedEnvironmentKey` bridging, the type-keyed `BTraits`/`BThemes`/`BStylesheets` containers — silently resolves against the wrong copy and returns the default (the writer stores under one copy's key *type*, the reader looks it up under another's). It only reproduces in the **full multi-bundle scheme** (not isolated `tuist test WhereUITests` runs) and is papered over by newer Xcode linkers, so it is brutal to diagnose (it cost ~2 hours once). **Depend on such products only transitively via `WhereUI`; keep them out of `extraPackageProducts`.** `WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot` is the regression guard — it exercises a `\.stylesheet` (→ `\.bContext`) read across the boundary and fails if a duplicate copy returns. +- **Never double-link a package product into a test bundle that already gets it through a dynamic-framework dependency.** Xcode's default SPM integration (how `Package.local` is wired here) embeds a product's code into *every* image that links it. **WhereUI** is a dynamic framework that statically embeds its own dependencies (WhereCore, BroadwayCore/BroadwayUI, LifecycleKit, PeriscopeCore/PeriscopeUI/PeriscopeTools, SwiftDataInspector, …), so a `*Tests` bundle that depends on **WhereUI** *and* re-lists one of those in `extraPackageProducts` ends up with a **second copy** of it. When the shared **StuffTestHost** loads several `.xctest` bundles into one process, that leaves duplicate **type metadata** for the module, and any *type-keyed runtime lookup that crosses the WhereUI boundary* — SwiftUI `EnvironmentKey`s, `UITraitBridgedEnvironmentKey` bridging, the type-keyed `BTraits`/`BThemes`/`BStylesheets` containers — silently resolves against the wrong copy and returns the default (the writer stores under one copy's key *type*, the reader looks it up under another's). It only reproduces in the **full multi-bundle scheme** (not isolated `tuist test WhereUITests` runs) and is papered over by newer Xcode linkers, so it is brutal to diagnose (it cost ~2 hours once). **Depend on such products only transitively via `WhereUI`; keep them out of `extraPackageProducts`.** `WhereStylesheetTests.resolvesTraitAwareTokensFromTheBroadwayRoot` is the regression guard — it exercises a `\.stylesheet` (→ `\.bContext`) read across the boundary and fails if a duplicate copy returns. ## Deployment @@ -204,7 +204,8 @@ the generated (gitignored) `CLAUDE.md` is produced next to it. (or returning a `Result`/typed error) — never absorb it into a benign-looking default like `[]`, `nil`, or `false`. Don't discard errors with `try?` or an empty `catch {}` that hides the failure: at minimum a `catch` must log - (`WhereLog.warning`/`error`) *and* leave observable state honest (preserve the + (a `warning`/`error` on the relevant `WhereLog` scope, ideally a typed + `LogEvent` carrying a `LogAttachment.error`) *and* leave observable state honest (preserve the last good value or move to a `failed` state — not a default that reads as success, e.g. an empty list rendering as "all clear"). Callers decide *how* to react (rethrow, log + keep state, set a `failed` case), but the failure must diff --git a/Package.swift b/Package.swift index 119946f9..adadb7a1 100644 --- a/Package.swift +++ b/Package.swift @@ -11,8 +11,6 @@ let package = Package( .library(name: "StuffCore", targets: ["StuffCore"]), .library(name: "LifecycleKit", targets: ["LifecycleKit"]), .library(name: "JournalKit", targets: ["JournalKit"]), - .library(name: "LogKit", targets: ["LogKit"]), - .library(name: "LogViewerUI", targets: ["LogViewerUI"]), .library(name: "PeriscopeCore", targets: ["PeriscopeCore"]), .library(name: "PeriscopeUI", targets: ["PeriscopeUI"]), .library(name: "PeriscopeTools", targets: ["PeriscopeTools"]), @@ -44,17 +42,6 @@ let package = Package( name: "JournalKit", path: "Shared/JournalKit/Sources", ), - .target( - name: "LogKit", - path: "Shared/LogKit/Sources", - ), - .target( - name: "LogViewerUI", - dependencies: [ - .target(name: "LogKit"), - ], - path: "Shared/LogViewerUI/Sources", - ), .target( name: "PeriscopeCore", dependencies: [ @@ -88,7 +75,7 @@ let package = Package( .target( name: "RegionKit", dependencies: [ - .target(name: "LogKit"), + .target(name: "PeriscopeCore"), ], path: "Where/RegionKit/Sources", resources: [ @@ -98,7 +85,7 @@ let package = Package( .target( name: "WhereCore", dependencies: [ - .target(name: "LogKit"), + .target(name: "PeriscopeCore"), .target(name: "RegionKit"), .product(name: "ZIPFoundation", package: "ZIPFoundation"), ], @@ -114,8 +101,9 @@ let package = Package( .target(name: "BroadwayCore"), .target(name: "BroadwayUI"), .target(name: "LifecycleKit"), - .target(name: "LogKit"), - .target(name: "LogViewerUI"), + .target(name: "PeriscopeCore"), + .target(name: "PeriscopeTools"), + .target(name: "PeriscopeUI"), .target(name: "RegionKit"), .target(name: "SwiftDataInspector"), ], @@ -127,7 +115,7 @@ let package = Package( .target( name: "WhereIntents", dependencies: [ - .target(name: "LogKit"), + .target(name: "PeriscopeCore"), .target(name: "RegionKit"), .target(name: "WhereCore"), .target(name: "WhereUI"), diff --git a/Project.swift b/Project.swift index 1fa30eee..97f39ba1 100644 --- a/Project.swift +++ b/Project.swift @@ -93,7 +93,6 @@ let project = Project( entitlements: whereAppGroupEntitlements, dependencies: [ .package(product: "LifecycleKit"), - .package(product: "LogKit"), .package(product: "RegionKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), @@ -129,7 +128,7 @@ let project = Project( resources: ["Where/WhereWidgets/Resources/**"], entitlements: whereAppGroupEntitlements, dependencies: [ - .package(product: "LogKit"), + .package(product: "PeriscopeCore"), .package(product: "RegionKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), @@ -165,7 +164,7 @@ let project = Project( resources: ["Where/WhereShareExtension/Resources/**"], entitlements: whereAppGroupEntitlements, dependencies: [ - .package(product: "LogKit"), + .package(product: "PeriscopeCore"), .package(product: "WhereCore"), .package(product: "WhereUI"), ], @@ -189,7 +188,6 @@ let project = Project( // No App Group entitlement — the viewer only reads bundled GeoJSON // (embedded via the RegionKit dependency), never the app's store. dependencies: [ - .package(product: "LogKit"), .package(product: "RegionKit"), .package(product: "WhereCore"), .package(product: "WhereUI"), @@ -269,18 +267,6 @@ let project = Project( productDependency: "LifecycleKit", sources: ["Shared/LifecycleKit/Tests/**"], ), - unitTests( - name: "LogKitTests", - bundleIdSuffix: "logkit", - productDependency: "LogKit", - sources: ["Shared/LogKit/Tests/**"], - ), - unitTests( - name: "LogViewerUITests", - bundleIdSuffix: "logviewerui", - productDependency: "LogViewerUI", - sources: ["Shared/LogViewerUI/Tests/**"], - ), unitTests( name: "JournalKitTests", bundleIdSuffix: "journalkit", @@ -340,8 +326,9 @@ let project = Project( // BTraits/BThemes/BStylesheets containers) then silently resolves against // the wrong copy — the writer stores under one copy's key type, the // reader looks it up under another's. Everything the tests need - // (BroadwayCore/BroadwayUI, LifecycleKit, LogViewerUI, SwiftDataInspector, - // RegionKit + its GeoJSON bundle) is reached transitively through WhereUI. + // (BroadwayCore/BroadwayUI, LifecycleKit, PeriscopeCore/UI/Tools, + // SwiftDataInspector, RegionKit + its GeoJSON bundle) is reached + // transitively through WhereUI. // See the root AGENTS.md "Targets" note. unitTests( name: "WhereUITests", @@ -432,8 +419,6 @@ let project = Project( "StuffTestHost", "StuffCoreTests", "LifecycleKitTests", - "LogKitTests", - "LogViewerUITests", "JournalKitTests", "PeriscopeCoreTests", "PeriscopeUITests", @@ -452,8 +437,6 @@ let project = Project( testAction: .targets([ "StuffCoreTests", "LifecycleKitTests", - "LogKitTests", - "LogViewerUITests", "JournalKitTests", "PeriscopeCoreTests", "PeriscopeUITests", @@ -471,8 +454,6 @@ let project = Project( ), testScheme(name: "StuffCoreTests"), testScheme(name: "LifecycleKitTests"), - testScheme(name: "LogKitTests"), - testScheme(name: "LogViewerUITests"), testScheme(name: "JournalKitTests"), testScheme(name: "PeriscopeCoreTests"), testScheme(name: "PeriscopeUITests"), diff --git a/Shared/LogKit/AGENTS.md b/Shared/LogKit/AGENTS.md deleted file mode 100644 index fec110b4..00000000 --- a/Shared/LogKit/AGENTS.md +++ /dev/null @@ -1,38 +0,0 @@ -# LogKit – Module Shape - -LogKit is a logging **facade**: a `LogChannel` fans each call out to Apple -unified logging (`os.Logger`) and, in DEBUG builds, to an in-memory `LogStore` -ring buffer that an in-app viewer reads. See [`README.md`](README.md) for the -narrative and API. - -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the -build system, formatting, and global conventions. Read that first. - -## Scope & dependencies - -- Pure **Foundation + os**. It must **not** import SwiftUI, UIKit, WhereCore, - or any app code — the SwiftUI viewer lives in - [`LogViewerUI`](../LogViewerUI), and app-specific wiring (shared store, - typed categories) lives in the consuming facade (e.g. `WhereLog`). - -## Invariants - -- **Two sinks, one call.** `LogChannel` always calls `os.Logger`; the - `LogStore` write is `#if DEBUG` only, so release builds never retain log - text in memory. App call sites log through `LogChannel`, not direct - `LogStore.record`. -- **Recording never hops actors** — `LogStore` is lock-guarded so any thread - can `record` synchronously; observers get snapshots via self-unregistering - `changes()` streams. -- **`warning` maps to `OSLogType.default`**, not `.error` — intentional, so - warnings don't inflate Console error-level queries. `LogLevel` case order - *is* severity order (`Comparable` by `rawValue`); keep it intact. -- **The privacy trade-off is deliberate.** `LogChannel` takes an - already-rendered `String` logged as `.public` (that's what lets the buffer - capture text). The contract is PII-free messages; don't add - `privacy:`-style APIs or start logging user content to "fix" it. - -## Testing - -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost`. Each test uses -its own fresh `LogStore` (the production shared store is process-global). diff --git a/Shared/LogKit/README.md b/Shared/LogKit/README.md deleted file mode 100644 index 2b85f078..00000000 --- a/Shared/LogKit/README.md +++ /dev/null @@ -1,114 +0,0 @@ -# LogKit - -A tiny logging facade that fans a single call out to **Apple unified logging** -(`os.Logger`, for Console.app) and, in **DEBUG builds**, an **in-memory ring -buffer** an in-app log viewer can read. Get a channel, call `info` / `warning` / -`error`, and the line shows up both in Console and (in DEBUG) in a process-wide -buffer. - -LogKit depends only on **Foundation + os** — no app code, no UI. The SwiftUI -viewer that renders the buffer lives in a separate module, -[`LogViewerUI`](../LogViewerUI). - -## What you get - -- **One call, two sinks** — every message goes to `os.Logger` (all builds) and, - in DEBUG, into a `LogStore` buffer. Release builds pay only the `os` cost; the - buffer is compiled out at the call site. -- **A typed severity ladder** — `LogLevel` (`debug` → `info` → `notice` → - `warning` → `error` → `fault`), `Comparable` by severity, each mapped to an - `OSLogType`. -- **A bounded, thread-safe buffer** — `LogStore` is a `Sendable` ring buffer - (default capacity 1000) that records from any thread and streams snapshots to - observers via `AsyncStream`. - -## Installation - -`LogKit` is a local SPM library in this repo (`Shared/LogKit`). Add it to a -target's dependencies in [`Package.swift`](../../Package.swift): - -```swift -.target(name: "YourModule", dependencies: [.target(name: "LogKit")]) -``` - -## Quick start - -Build a `LogStore` you keep for the process, then make a `LogChannel` per -category pointed at it: - -```swift -import LogKit - -let store = LogStore() -let channel = LogChannel(subsystem: "com.example.app", category: "Networking", store: store) - -channel.info("Request succeeded") -channel.warning("Falling back to cache") -channel.error("Request failed: \(error.localizedDescription)") -``` - -Most apps don't pass the store around by hand — they wrap this in a small facade -that owns the shared store and a typed category enum (see the Where app's -`WhereLog` in `WhereCore`). - -## Public API - -```swift -public enum LogLevel: Int, Sendable, Comparable, CaseIterable, Codable { - case debug, info, notice, warning, error, fault - public var osLogType: OSLogType { /* debug/info/default/default/error/fault */ } -} - -public struct LogEntry: Sendable, Identifiable, Hashable { - public let id: UUID, date: Date, level: LogLevel - public let subsystem: String, category: String, message: String -} - -public final class LogStore: Sendable { - public init(capacity: Int = 1000) - /// Append an entry. Prefer ``LogChannel`` in app code — it only writes to - /// the store in DEBUG builds; direct `record` always retains text. - public func record(_ entry: LogEntry) - public func snapshot() -> [LogEntry] // oldest first - public func clear() - public func changes() -> AsyncStream<[LogEntry]> // current snapshot, then one per change -} - -public struct LogChannel: Sendable { - public init(subsystem: String, category: String, store: LogStore? = nil) - public func debug/info/notice/warning/error/fault(_ message: @autoclosure () -> String) -} -``` - -## How it works - -`LogChannel.emit` does two things: it calls `os.Logger.log(level:)` (always), -then — `#if DEBUG` only — appends a `LogEntry` to its `LogStore`. The store -guards its state with an `OSAllocatedUnfairLock` (so `record` never hops to the -main actor) and notifies observers by yielding a fresh snapshot into each -registered `AsyncStream`; the initial snapshot is yielded before registering the -observer. Observers are unregistered automatically when their stream's consumer -cancels. Past `capacity`, the oldest entries are evicted. - -Direct `LogStore.record` is intended for tests, previews, and the -`LogChannel` facade — app call sites should log through `LogChannel`, which only -writes to the buffer in DEBUG builds. - -## The privacy trade-off - -`LogChannel` takes an **already-rendered `String`**, not an `os` interpolation. -That's what lets it capture the text for the buffer, but it means per-argument -`os` privacy annotations (`privacy: .public` / `.private`) aren't available — the -whole message is logged as `.public`. **Keep PII out of log messages**; use this -for operational diagnostics only. - -`warning` has no dedicated `os` level, so it maps to `OSLogType.default` (same as -`notice`). It reads as a distinct level in the in-app viewer without inflating -Console's error-level queries. - -## Testing - -Swift Testing in a hosted bundle (`LogKitTests`). Drive a `LogChannel` backed by -a fresh `LogStore` and assert on `snapshot()` (level/message ordering), the -capacity eviction, `clear()`, and that `changes()` yields the initial snapshot -then one per `record`/`clear`. diff --git a/Shared/LogKit/Sources/LogChannel.swift b/Shared/LogKit/Sources/LogChannel.swift deleted file mode 100644 index 7acff9c5..00000000 --- a/Shared/LogKit/Sources/LogChannel.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Foundation -import os - -/// A logging facade that fans a single call out to both Apple unified logging -/// (`os.Logger`, for Console.app) and — in DEBUG builds — an in-memory -/// ``LogStore`` the in-app viewer reads. -/// -/// Messages are passed as already-rendered `String`s rather than `os` -/// interpolations. That is a deliberate trade-off: it lets us capture the text -/// for the buffer, but means per-argument `os` privacy (`privacy: .public` / -/// `.private`) is not available — the whole message is logged as `.public`. -/// Use this for operational diagnostics, not for anything carrying PII. -public struct LogChannel: Sendable { - private let logger: Logger - private let subsystem: String - private let category: String - private let store: LogStore? - - public init(subsystem: String, category: String, store: LogStore? = nil) { - logger = Logger(subsystem: subsystem, category: category) - self.subsystem = subsystem - self.category = category - self.store = store - } - - public func debug(_ message: @autoclosure () -> String) { - emit(.debug, message()) - } - - public func info(_ message: @autoclosure () -> String) { - emit(.info, message()) - } - - public func notice(_ message: @autoclosure () -> String) { - emit(.notice, message()) - } - - public func warning(_ message: @autoclosure () -> String) { - emit(.warning, message()) - } - - public func error(_ message: @autoclosure () -> String) { - emit(.error, message()) - } - - public func fault(_ message: @autoclosure () -> String) { - emit(.fault, message()) - } - - private func emit(_ level: LogLevel, _ message: String) { - logger.log(level: level.osLogType, "\(message, privacy: .public)") - #if DEBUG - store?.record(LogEntry( - level: level, - subsystem: subsystem, - category: category, - message: message, - )) - #endif - } -} diff --git a/Shared/LogKit/Sources/LogEntry.swift b/Shared/LogKit/Sources/LogEntry.swift deleted file mode 100644 index 47f78a5c..00000000 --- a/Shared/LogKit/Sources/LogEntry.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Foundation - -/// A single captured log line. The facade builds one per call and appends it to -/// a ``LogStore``; the viewer renders these. `message` is already-rendered text -/// (see ``LogChannel`` for the privacy trade-off that implies). -public struct LogEntry: Sendable, Identifiable, Hashable { - public let id: UUID - public let date: Date - public let level: LogLevel - public let subsystem: String - public let category: String - public let message: String - - public init( - id: UUID = UUID(), - date: Date = Date(), - level: LogLevel, - subsystem: String, - category: String, - message: String, - ) { - self.id = id - self.date = date - self.level = level - self.subsystem = subsystem - self.category = category - self.message = message - } -} diff --git a/Shared/LogKit/Sources/LogLevel.swift b/Shared/LogKit/Sources/LogLevel.swift deleted file mode 100644 index 2f671594..00000000 --- a/Shared/LogKit/Sources/LogLevel.swift +++ /dev/null @@ -1,34 +0,0 @@ -import os - -/// Severity of a captured log message, ordered from least to most severe so -/// callers can filter with comparisons (e.g. `entry.level >= .error`). The -/// cases mirror the levels `os.Logger` exposes; `osLogType` maps each back to -/// the underlying `OSLogType` the facade emits. -public enum LogLevel: Int, Sendable, Comparable, CaseIterable, Codable { - case debug - case info - case notice - case warning - case error - case fault - - public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { - lhs.rawValue < rhs.rawValue - } - - /// The `OSLogType` the facade logs this level as. `notice` is unified - /// logging's default level, so it maps to `.default`. Apple's unified - /// logging has no dedicated warning level, so `warning` also maps to - /// `.default` (the in-app viewer still shows it as a distinct level); - /// keeping it off `.error` avoids inflating Console error-level queries. - public var osLogType: OSLogType { - switch self { - case .debug: .debug - case .info: .info - case .notice: .default - case .warning: .default - case .error: .error - case .fault: .fault - } - } -} diff --git a/Shared/LogKit/Sources/LogStore.swift b/Shared/LogKit/Sources/LogStore.swift deleted file mode 100644 index 4aeabde2..00000000 --- a/Shared/LogKit/Sources/LogStore.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import os - -/// A thread-safe, bounded in-memory ring buffer of ``LogEntry`` values, shared -/// between the logging facade (which records into it from any thread/actor) and -/// the viewer (which reads snapshots and observes changes on the main actor). -/// -/// Recording never hops to the main actor: state is guarded by an -/// `OSAllocatedUnfairLock`, and observers are notified through `AsyncStream`s -/// that carry a fresh snapshot. Once `capacity` is reached the oldest entries -/// are evicted. -public final class LogStore: Sendable { - private struct State { - var entries: [LogEntry] = [] - var observers: [UUID: AsyncStream<[LogEntry]>.Continuation] = [:] - } - - /// Maximum number of entries retained; older entries are dropped past this. - public let capacity: Int - - private let state: OSAllocatedUnfairLock - - public init(capacity: Int = 1000) { - precondition(capacity > 0, "LogStore capacity must be positive") - self.capacity = capacity - state = OSAllocatedUnfairLock(initialState: State()) - } - - /// Append an entry, evicting the oldest if at capacity, and notify observers. - /// - /// Production logging should go through ``LogChannel``, which only writes to - /// the store in DEBUG builds. Direct `record` calls always retain text in - /// memory regardless of build configuration — use only from tests and - /// previews via `@_spi(Testing) import LogKit`. - @_spi(Testing) public func record(_ entry: LogEntry) { - let (snapshot, observers) = state.withLock { state -> ( - [LogEntry], - [AsyncStream<[LogEntry]>.Continuation] - ) in - state.entries.append(entry) - let overflow = state.entries.count - capacity - if overflow > 0 { - state.entries.removeFirst(overflow) - } - return (state.entries, Array(state.observers.values)) - } - for observer in observers { - observer.yield(snapshot) - } - } - - /// The current entries, oldest first. - public func snapshot() -> [LogEntry] { - state.withLock { $0.entries } - } - - /// Drop all buffered entries and notify observers with the empty snapshot. - public func clear() { - let observers = state.withLock { state -> [AsyncStream<[LogEntry]>.Continuation] in - state.entries.removeAll(keepingCapacity: true) - return Array(state.observers.values) - } - for observer in observers { - observer.yield([]) - } - } - - /// An async sequence of snapshots: yields the current buffer immediately, - /// then a fresh snapshot on every `record`/`clear`. The observer is - /// unregistered automatically when the stream's consumer cancels. - public func changes() -> AsyncStream<[LogEntry]> { - let id = UUID() - return AsyncStream<[LogEntry]> { continuation in - let initial = state.withLock(\.entries) - continuation.yield(initial) - state.withLock { state in - state.observers[id] = continuation - } - continuation.onTermination = { [weak self] _ in - self?.state.withLock { state in - state.observers[id] = nil - } - } - } - } -} diff --git a/Shared/LogKit/Tests/LogChannelTests.swift b/Shared/LogKit/Tests/LogChannelTests.swift deleted file mode 100644 index fe9ff937..00000000 --- a/Shared/LogKit/Tests/LogChannelTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -@_spi(Testing) import LogKit -import Testing - -#if DEBUG - @Test - func channelRecordsEachLevelIntoStore() { - let store = LogStore() - let channel = LogChannel(subsystem: "com.test", category: "Sample", store: store) - - channel.debug("d") - channel.info("i") - channel.notice("n") - channel.warning("w") - channel.error("e") - channel.fault("f") - - let entries = store.snapshot() - #expect(entries.map(\.level) == [.debug, .info, .notice, .warning, .error, .fault]) - #expect(entries.map(\.message) == ["d", "i", "n", "w", "e", "f"]) - #expect(entries.allSatisfy { $0.subsystem == "com.test" && $0.category == "Sample" }) - } -#endif - -@Test -func channelWithoutStoreStillLogs() { - // No store attached: should not crash, just emit to os.Logger. - let channel = LogChannel(subsystem: "com.test", category: "NoStore") - channel.error("no store attached") -} diff --git a/Shared/LogKit/Tests/LogLevelTests.swift b/Shared/LogKit/Tests/LogLevelTests.swift deleted file mode 100644 index d64a2af6..00000000 --- a/Shared/LogKit/Tests/LogLevelTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -import LogKit -import os -import Testing - -@Test -func levelsAreOrderedBySeverity() { - #expect(LogLevel.debug < .info) - #expect(LogLevel.info < .notice) - #expect(LogLevel.notice < .warning) - #expect(LogLevel.warning < .error) - #expect(LogLevel.error < .fault) - #expect(LogLevel.allCases == [.debug, .info, .notice, .warning, .error, .fault]) -} - -@Test -func osLogTypeMapping() { - #expect(LogLevel.debug.osLogType == .debug) - #expect(LogLevel.info.osLogType == .info) - #expect(LogLevel.notice.osLogType == .default) - #expect(LogLevel.warning.osLogType == .default) - #expect(LogLevel.error.osLogType == .error) - #expect(LogLevel.fault.osLogType == .fault) -} diff --git a/Shared/LogKit/Tests/LogStoreTests.swift b/Shared/LogKit/Tests/LogStoreTests.swift deleted file mode 100644 index 01491c9b..00000000 --- a/Shared/LogKit/Tests/LogStoreTests.swift +++ /dev/null @@ -1,161 +0,0 @@ -import Foundation -@_spi(Testing) import LogKit -import Testing - -private func entry(_ message: String, level: LogLevel = .info) -> LogEntry { - LogEntry(level: level, subsystem: "test", category: "test", message: message) -} - -@Test -func recordsAppendInOrder() { - let store = LogStore() - store.record(entry("a")) - store.record(entry("b")) - store.record(entry("c")) - - #expect(store.snapshot().map(\.message) == ["a", "b", "c"]) -} - -@Test -func evictsOldestPastCapacity() { - let store = LogStore(capacity: 3) - for index in 0 ..< 5 { - store.record(entry("\(index)")) - } - - #expect(store.snapshot().map(\.message) == ["2", "3", "4"]) -} - -@Test -func clearEmptiesTheBuffer() { - let store = LogStore() - store.record(entry("a")) - store.clear() - - #expect(store.snapshot().isEmpty) -} - -@Test -func changesYieldsInitialThenUpdates() async { - let store = LogStore() - store.record(entry("initial")) - - var iterator = store.changes().makeAsyncIterator() - - let first = await iterator.next() - #expect(first?.map(\.message) == ["initial"]) - - store.record(entry("second")) - let second = await iterator.next() - #expect(second?.map(\.message) == ["initial", "second"]) - - store.clear() - let third = await iterator.next() - #expect(third?.isEmpty == true) -} - -@Test -func cancelledChangesStreamUnregistersObserver() async { - let store = LogStore() - store.record(entry("before")) - - do { - var iterator = store.changes().makeAsyncIterator() - _ = await iterator.next() - } - - store.record(entry("after-cancel")) - - var iterator = store.changes().makeAsyncIterator() - let snapshot = await iterator.next() - #expect(snapshot?.map(\.message) == ["before", "after-cancel"]) -} - -@Test -func changesNotifiesMultipleObservers() async { - let store = LogStore() - store.record(entry("a")) - - var firstObserver = store.changes().makeAsyncIterator() - var secondObserver = store.changes().makeAsyncIterator() - - let firstInitial = await firstObserver.next() - let secondInitial = await secondObserver.next() - #expect(firstInitial?.map(\.message) == ["a"]) - #expect(secondInitial?.map(\.message) == ["a"]) - - store.record(entry("b")) - let firstUpdate = await firstObserver.next() - let secondUpdate = await secondObserver.next() - #expect(firstUpdate?.map(\.message) == ["a", "b"]) - #expect(secondUpdate?.map(\.message) == ["a", "b"]) -} - -@Test -func changesDeliversMonotonicSnapshotsForSequentialRecords() async { - let store = LogStore() - var iterator = store.changes().makeAsyncIterator() - _ = await iterator.next() - - var previousCount = 0 - for index in 0 ..< 10 { - store.record(entry("\(index)")) - if let snapshot = await iterator.next() { - #expect(snapshot.count == previousCount + 1) - previousCount = snapshot.count - } - } -} - -@Test -func changesDeliversFinalSnapshotAfterConcurrentRecords() async { - let store = LogStore() - var iterator = store.changes().makeAsyncIterator() - _ = await iterator.next() - - let recordCount = 50 - await withTaskGroup(of: Void.self) { group in - for index in 0 ..< recordCount { - group.addTask { - store.record(entry("\(index)")) - } - } - } - - var lastSnapshot: [LogEntry]? - while let snapshot = await iterator.next() { - lastSnapshot = snapshot - if snapshot.count == recordCount { - break - } - } - #expect(lastSnapshot?.count == recordCount) -} - -@Test -func changesInitialYieldReflectsPreRegistrationSnapshot() async { - let store = LogStore() - - // `changes()` synchronously captures and yields the snapshot that exists at - // subscription time *before* it registers the observer, so records that land - // afterwards cannot deliver an update ahead of the initial yield. Subscribing - // to an empty store and only then recording proves it deterministically: the - // records are already buffered behind the empty initial element by the time - // we consume the stream, yet the first yield is still empty. - var iterator = store.changes().makeAsyncIterator() - - for index in 0 ..< 10 { - store.record(entry("\(index)")) - } - - let initial = await iterator.next() - #expect(initial?.isEmpty == true) - - // The post-subscription records still arrive, just as later updates. - var last: [LogEntry]? - while let snapshot = await iterator.next() { - last = snapshot - if snapshot.count == 10 { break } - } - #expect(last?.count == 10) -} diff --git a/Shared/LogViewerUI/AGENTS.md b/Shared/LogViewerUI/AGENTS.md deleted file mode 100644 index 534ee318..00000000 --- a/Shared/LogViewerUI/AGENTS.md +++ /dev/null @@ -1,39 +0,0 @@ -# LogViewerUI – Module Shape - -LogViewerUI is an app-agnostic SwiftUI **log viewer** over one or more -[`LogKit`](../LogKit) `LogStore`s: hand it a `LogViewerConfiguration` (stores, -title, category display names) and `LogViewer` renders entries newest-first -with filtering, share, copy, and clear. See [`README.md`](README.md) for the -narrative and API. - -This file complements the root [`AGENTS.md`](../../AGENTS.md), which owns the -build system, formatting, and global conventions. Read that first. - -## Scope & dependencies - -- **SwiftUI + Observation + UIKit (pasteboard only) + LogKit.** It must - **not** import WhereCore or any app code — app-specific wiring comes in via - `LogViewerConfiguration`. -- **Intended for DEBUG / developer surfaces**; consumers gate the entry point - behind `#if DEBUG`. Developer-facing strings are plain literals here. -- `LogViewer` expects an ambient `NavigationStack` the consumer owns. - -## Invariants - -- **Read-only mirror.** Recording lives in `LogKit`; this module only - consumes snapshots on the main actor (the one write is `clear()`, which - empties every configured store and updates `entries` synchronously so the - list clears immediately). -- **Multiple stores merge by timestamp.** Each store is observed concurrently - and its latest snapshot kept per-store; `entries` is the re-merged, date-sorted - union, so several modules' buffers read as one chronological stream. -- **Newest-first for display, oldest-first for export** — a shared/copied log - reads chronologically. -- **The level filter is driven by `LogLevel.allCases`**, so a new level in - LogKit flows through automatically — don't hardcode the level list. - -## Testing - -Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost`: seed a -`LogStore`, drive `LogViewerModel`, assert on filtering/export; host -`LogViewer` for populated and empty states. diff --git a/Shared/LogViewerUI/README.md b/Shared/LogViewerUI/README.md deleted file mode 100644 index 0c330428..00000000 --- a/Shared/LogViewerUI/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# LogViewerUI - -A small, app-agnostic SwiftUI **log viewer** over one or more [`LogKit`](../LogKit) -`LogStore`s. Point it at a buffer (or several) and it renders captured entries -newest-first with a level badge, category, timestamp, and message, plus live -search, level/category filtering, share, copy, and clear — for *any* `LogStore`, -with no per-app code. Multiple buffers are merged chronologically, so a host can -surface several modules' logs (each with its own subsystem/category) in one view. - -It's built for **developer / DEBUG surfaces** (think a hidden "Logs" row in a -Settings screen): it reads whatever the app's loggers wrote into the shared -buffer(s) this session. - -LogViewerUI depends only on **SwiftUI + Observation + UIKit (pasteboard) + -LogKit** — no app code. - -## What you get - -- **Live list** — entries newest-first; each row shows a tinted level badge, the - (display-mapped) category, an `HH:MM:SS` timestamp, and a selectable message. - The list updates as new lines are logged (it observes `LogStore.changes()`). -- **Filtering & search** — a minimum-level picker, a category picker (built from - the categories actually present), and a `.searchable` field matching message - *or* the mapped category display name. -- **Share / copy / clear** — share or copy the currently-filtered entries as - plain text (ISO-8601 timestamps; formatting deferred until share is initiated), - copy a single message from its context menu, or clear the buffer (with - confirmation). -- **Empty states** — a `ContentUnavailableView` when nothing has been captured, - and a separate one when filters match nothing. - -## Installation - -`LogViewerUI` is a local SPM library in this repo (`Shared/LogViewerUI`). Add it -to a target's dependencies in [`Package.swift`](../../Package.swift): - -```swift -.target(name: "YourUI", dependencies: [.target(name: "LogViewerUI")]) -``` - -## Quick start - -Drop `LogViewer` into a navigation context you own and hand it a configuration -built from your store: - -```swift -import LogViewerUI - -NavigationStack { // the viewer expects an ambient stack - LogViewer(configuration: LogViewerConfiguration(store: myLogStore, title: "Logs")) -} -``` - -The view loads the current snapshot on appear and streams updates while it's on -screen. - -> `LogViewer` sets a navigation title and toolbar but **doesn't** create its own -> `NavigationStack`, so it composes inside a settings screen, tab, or sheet. - -## Configuration - -```swift -public struct LogViewerConfiguration: Sendable { - public init( - stores: [LogStore], - title: String = "Logs", - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) - - /// Convenience for the common single-buffer case. - public init( - store: LogStore, - title: String = "Logs", - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) -} -``` - -- **`stores`** — the `LogKit` buffer(s) to read and observe, merged - chronologically. The `store:` init is a convenience for the single-buffer case. -- **`title`** — the viewer's navigation title. -- **`categoryDisplayName`** — maps a raw `LogEntry.category` to a friendly name - (e.g. an app's typed-category enum → a label). Defaults to identity. - -## How it works - -`LogViewer` owns a `@MainActor @Observable LogViewerModel` that mirrors the -store(s) into `entries` and derives cached `filteredEntries` (newest-first, after -level/category/search). Observation starts in the model's `init` and iterates -each store's `LogStore.changes()` concurrently (in a task group) until the model -is deallocated, remerging the per-store snapshots by timestamp on every change — -so the list stays live without the view touching the lock-guarded stores -directly. Recording stays off the main actor in `LogKit`; this module only -consumes snapshots on the main actor for display. - -## Example: adopting it in an app (Where) - -The Where app exposes it behind a DEBUG-only entry in its floating developer -overlay, pointed at both process-wide buffers — `WhereLog` (the app/WhereCore -facade) and `RegionLog` (RegionKit) — merged into one list: - -```swift -#if DEBUG -NavigationLink { - LogViewer(configuration: LogViewerConfiguration( - stores: [WhereLog.store, RegionLog.store], - title: Strings.settingsDebugLogsTitle, - )) -} label: { - Label(Strings.settingsDebugLogsLink, systemImage: "ladybug") -} -#endif -``` - -## Testing - -Swift Testing in a hosted bundle (`LogViewerUITests`). Seed a `LogStore`, drive -`LogViewerModel` (filters, `exportText`, `clear`), and assert on -`filteredEntries`; plus hosting tests that mount `LogViewer` for the populated -and empty states. diff --git a/Shared/LogViewerUI/Sources/LogLevel+Display.swift b/Shared/LogViewerUI/Sources/LogLevel+Display.swift deleted file mode 100644 index 06b8e34e..00000000 --- a/Shared/LogViewerUI/Sources/LogLevel+Display.swift +++ /dev/null @@ -1,33 +0,0 @@ -import LogKit -import SwiftUI - -extension LogLevel { - /// Short capitalized name shown in badges and the level filter. - var displayName: String { - switch self { - case .debug: "Debug" - case .info: "Info" - case .notice: "Notice" - case .warning: "Warning" - case .error: "Error" - case .fault: "Fault" - } - } - - /// Uppercased label used in badges and export text. - var badgeLabel: String { - displayName.uppercased() - } - - /// Tint used for the level badge, escalating with severity. - var tint: Color { - switch self { - case .debug: .gray - case .info: .blue - case .notice: .teal - case .warning: .yellow - case .error: .orange - case .fault: .red - } - } -} diff --git a/Shared/LogViewerUI/Sources/LogViewer.swift b/Shared/LogViewerUI/Sources/LogViewer.swift deleted file mode 100644 index 21dd0c56..00000000 --- a/Shared/LogViewerUI/Sources/LogViewer.swift +++ /dev/null @@ -1,183 +0,0 @@ -@_spi(Testing) import LogKit -import SwiftUI -import UIKit - -/// A generic, read-only viewer over a ``LogStore``. Renders entries newest -/// first with a level badge, category, timestamp, and message, and offers -/// search, level/category filtering, share, and clear. -/// -/// Designed to be pushed inside an existing `NavigationStack` (it sets a -/// navigation title and toolbar but does not create its own stack). -public struct LogViewer: View { - private let configuration: LogViewerConfiguration - @State private var model: LogViewerModel - @State private var showClearConfirmation = false - - public init(configuration: LogViewerConfiguration) { - self.configuration = configuration - _model = State(initialValue: LogViewerModel( - stores: configuration.stores, - categoryDisplayName: configuration.categoryDisplayName, - )) - } - - public var body: some View { - @Bindable var model = model - content - .navigationTitle(configuration.title) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Menu { - Picker("Level", selection: $model.minimumLevel) { - ForEach(LogLevel.allCases, id: \.self) { level in - Text(level.displayName).tag(level) - } - } - - Picker("Category", selection: $model.selectedCategory) { - Text("All Categories").tag(String?.none) - ForEach(model.categories, id: \.self) { category in - Text(configuration.categoryDisplayName(category)) - .tag(String?.some(category)) - } - } - } label: { - Label("Filter", systemImage: "line.3.horizontal.decrease.circle") - } - } - - ToolbarItem(placement: .topBarTrailing) { - Menu { - ShareLink( - item: LogExportItem( - entries: model.filteredEntries.reversed(), - categoryDisplayName: configuration.categoryDisplayName, - ), - preview: SharePreview("Logs"), - ) { - Label("Share Logs", systemImage: "square.and.arrow.up") - } - Button(role: .destructive) { - showClearConfirmation = true - } label: { - Label("Clear Logs", systemImage: "trash") - } - } label: { - Label("More", systemImage: "ellipsis.circle") - } - .confirmationDialog( - "Clear all captured logs?", - isPresented: $showClearConfirmation, - titleVisibility: .visible, - ) { - Button("Clear Logs", role: .destructive) { model.clear() } - Button("Cancel", role: .cancel) {} - } - } - } - .searchable(text: $model.searchText) - } - - @ViewBuilder - private var content: some View { - if model.isEmpty { - ContentUnavailableView( - "No Logs", - systemImage: "doc.text.magnifyingglass", - description: Text("Logs captured this session will appear here."), - ) - } else if model.hasNoFilterMatches { - ContentUnavailableView( - "No Matching Logs", - systemImage: "line.3.horizontal.decrease.circle", - description: Text("Try adjusting your filters or search."), - ) - } else { - List(model.filteredEntries) { entry in - LogEntryRow( - entry: entry, - categoryName: configuration.categoryDisplayName(entry.category), - ) - .contextMenu { - Button { - UIPasteboard.general.string = entry.message - } label: { - Label("Copy Message", systemImage: "doc.on.doc") - } - } - } - .listStyle(.plain) - } - } -} - -/// Defers plain-text export until the share sheet requests the payload. -private struct LogExportItem: Transferable { - let entries: [LogEntry] - let categoryDisplayName: @Sendable (String) -> String - - static var transferRepresentation: some TransferRepresentation { - ProxyRepresentation { item in - LogViewerModel.formatExportText( - entries: item.entries, - categoryDisplayName: item.categoryDisplayName, - ) - } - } -} - -private struct LogEntryRow: View { - let entry: LogEntry - let categoryName: String - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - LevelBadge(level: entry.level) - Text(categoryName) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Text(entry.date, format: .dateTime.hour().minute().second()) - .font(.caption2) - .monospacedDigit() - .foregroundStyle(.tertiary) - } - Text(entry.message) - .font(.callout) - .textSelection(.enabled) - } - .padding(.vertical, 2) - } -} - -private struct LevelBadge: View { - let level: LogLevel - - var body: some View { - Text(level.badgeLabel) - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(level.tint.opacity(0.18), in: .capsule) - .foregroundStyle(level.tint) - } -} - -#if DEBUG - #Preview { - let store = LogStore() - for index in 0 ..< 6 { - let levels: [LogLevel] = [.debug, .info, .notice, .error, .fault] - store.record(LogEntry( - level: levels[index % levels.count], - subsystem: "com.example.app", - category: index.isMultiple(of: 2) ? "Networking" : "Persistence", - message: "Sample log message #\(index) describing what happened.", - )) - } - return NavigationStack { - LogViewer(configuration: LogViewerConfiguration(store: store, title: "Logs")) - } - } -#endif diff --git a/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift b/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift deleted file mode 100644 index 09e2c9ec..00000000 --- a/Shared/LogViewerUI/Sources/LogViewerConfiguration.swift +++ /dev/null @@ -1,41 +0,0 @@ -import LogKit -import SwiftUI - -/// Host-supplied configuration for ``LogViewer``. Keeps the viewer generic: the -/// host points it at one or more ``LogStore``s and supplies display strings -/// (e.g. a title and a mapping from raw category identifiers to human-readable -/// names). -/// -/// Multiple stores let a host surface buffers from several modules (each with -/// its own subsystem/category) in one viewer; entries are merged -/// chronologically. Each `LogEntry` carries its `subsystem`/`category`, so the -/// category filter still tells them apart. -public struct LogViewerConfiguration: Sendable { - /// The buffers to read and observe, merged chronologically for display. - public var stores: [LogStore] - - /// Navigation title for the viewer. - public var title: String - - /// Maps a raw `LogEntry.category` to a display name. Defaults to identity. - public var categoryDisplayName: @Sendable (String) -> String - - public init( - stores: [LogStore], - title: String = "Logs", - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) { - self.stores = stores - self.title = title - self.categoryDisplayName = categoryDisplayName - } - - /// Convenience for the common single-buffer case. - public init( - store: LogStore, - title: String = "Logs", - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) { - self.init(stores: [store], title: title, categoryDisplayName: categoryDisplayName) - } -} diff --git a/Shared/LogViewerUI/Sources/LogViewerModel.swift b/Shared/LogViewerUI/Sources/LogViewerModel.swift deleted file mode 100644 index 732a9b14..00000000 --- a/Shared/LogViewerUI/Sources/LogViewerModel.swift +++ /dev/null @@ -1,180 +0,0 @@ -import Foundation -import LogKit -import Observation - -private let exportTimestampFormatter = Date.ISO8601FormatStyle( - includingFractionalSeconds: true, -) - -private final class ObservationHandle: @unchecked Sendable { - private var tasks: [Task] = [] - - func start(_ operation: @escaping @MainActor () async -> Void) { - tasks.append(Task { await operation() }) - } - - func cancel() { - for task in tasks { - task.cancel() - } - } -} - -/// Drives ``LogViewer``: mirrors one or more ``LogStore``s into observable -/// state (merged chronologically) and applies the active filters. Recording -/// happens off the main actor in the store(s); this model only consumes -/// snapshots on the main actor for display. -@MainActor -@Observable -final class LogViewerModel { - private let stores: [LogStore] - private let categoryDisplayName: @Sendable (String) -> String - @ObservationIgnored private let observation = ObservationHandle() - - /// The most recent snapshot from each store, kept in `stores` order so a - /// change to one store re-merges without re-reading the others. - @ObservationIgnored private var latestSnapshots: [[LogEntry]] - - private(set) var entries: [LogEntry] - - private var cachedCategories: [String]? - private var cachedFilteredEntries: [LogEntry]? - - var searchText = "" { - didSet { if searchText != oldValue { invalidateFilterCache() } } - } - - var minimumLevel: LogLevel = .debug { - didSet { if minimumLevel != oldValue { invalidateFilterCache() } } - } - - /// `nil` means "all categories". - var selectedCategory: String? { - didSet { if selectedCategory != oldValue { invalidateFilterCache() } } - } - - init( - stores: [LogStore], - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) { - self.stores = stores - self.categoryDisplayName = categoryDisplayName - latestSnapshots = stores.map { $0.snapshot() } - entries = Self.merged(latestSnapshots) - // Observe each store on its own task. Each loop re-promotes `self` per - // iteration (`guard let self else { break }`), so between log lines the - // tasks hold only a weak reference: the model can deinit while parked in - // `for await`, and `deinit` then cancels every task. (An instance - // `observe()` call would instead keep `self` alive for the streams' - // whole lifetime — see `YearReportModel.observeDataChanges()`.) - for index in stores.indices { - let store = stores[index] - observation.start { [weak self] in - for await snapshot in store.changes() { - guard let self else { break } - apply(snapshot, at: index) - } - } - } - } - - /// Convenience for the common single-buffer case. - convenience init( - store: LogStore, - categoryDisplayName: @escaping @Sendable (String) -> String = { $0 }, - ) { - self.init(stores: [store], categoryDisplayName: categoryDisplayName) - } - - deinit { - observation.cancel() - } - - private func apply(_ snapshot: [LogEntry], at index: Int) { - latestSnapshots[index] = snapshot - entries = Self.merged(latestSnapshots) - invalidateEntryCache() - } - - /// Flatten every store's snapshot into one oldest-first list. Each store's - /// snapshot is already chronological; sorting by `date` interleaves them. - private static func merged(_ snapshots: [[LogEntry]]) -> [LogEntry] { - guard snapshots.count > 1 else { return snapshots.first ?? [] } - return snapshots.flatMap(\.self).sorted { $0.date < $1.date } - } - - /// Distinct categories present in the buffer, sorted for a stable filter. - var categories: [String] { - if let cachedCategories { - return cachedCategories - } - let result = Array(Set(entries.map(\.category))).sorted() - cachedCategories = result - return result - } - - /// Entries newest-first, after level/category/search filters. - var filteredEntries: [LogEntry] { - if let cachedFilteredEntries { - return cachedFilteredEntries - } - let result = entries.reversed().filter { entry in - guard entry.level >= minimumLevel else { return false } - if let selectedCategory, entry.category != selectedCategory { return false } - guard !searchText.isEmpty else { return true } - let displayCategory = categoryDisplayName(entry.category) - return entry.message.localizedCaseInsensitiveContains(searchText) - || displayCategory.localizedCaseInsensitiveContains(searchText) - } - cachedFilteredEntries = result - return result - } - - var isEmpty: Bool { - entries.isEmpty - } - - /// The store has entries, but the active filters exclude all of them. - var hasNoFilterMatches: Bool { - !isEmpty && filteredEntries.isEmpty - } - - func clear() { - for store in stores { - store.clear() - } - latestSnapshots = stores.map { $0.snapshot() } - entries = Self.merged(latestSnapshots) - invalidateEntryCache() - } - - /// Plain-text rendering of the currently-filtered entries, for share/copy. - func exportText() -> String { - Self.formatExportText( - entries: filteredEntries.reversed(), - categoryDisplayName: categoryDisplayName, - ) - } - - nonisolated static func formatExportText( - entries: [LogEntry], - categoryDisplayName: @escaping @Sendable (String) -> String, - ) -> String { - entries.map { entry in - let timestamp = entry.date.formatted(exportTimestampFormatter) - let level = entry.level.badgeLabel - let category = categoryDisplayName(entry.category) - return "\(timestamp) [\(level)] \(category): \(entry.message)" - } - .joined(separator: "\n") - } - - private func invalidateEntryCache() { - cachedCategories = nil - invalidateFilterCache() - } - - private func invalidateFilterCache() { - cachedFilteredEntries = nil - } -} diff --git a/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift b/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift deleted file mode 100644 index 06ec6681..00000000 --- a/Shared/LogViewerUI/Tests/LogViewerHostingTests.swift +++ /dev/null @@ -1,72 +0,0 @@ -@_spi(Testing) import LogKit -import LogViewerUI -import SwiftUI -import TestHostSupport -import Testing -import UIKit - -private func isViewHosted(_ hosted: UIViewController) -> Bool { - hosted.parent != nil - && hosted.view.window != nil - && hosted.view.bounds.width > 0 - && hosted.view.bounds.height > 0 -} - -@MainActor -struct LogViewerHostingTests { - @Test func viewerHostsWithEntries() throws { - let store = LogStore() - store.record(LogEntry(level: .error, subsystem: "s", category: "DB", message: "boom")) - #expect(store.snapshot().map(\.message) == ["boom"]) - - let rootView = NavigationStack { - LogViewer(configuration: LogViewerConfiguration(store: store, title: "Logs")) - } - let hosted = UIHostingController(rootView: rootView) - try show(hosted) { controller in - try waitFor { isViewHosted(controller) } - } - } - - @Test func viewerHostsWhenEmpty() throws { - let store = LogStore() - #expect(store.snapshot().isEmpty) - - let rootView = NavigationStack { - LogViewer(configuration: LogViewerConfiguration(store: store, title: "Logs")) - } - let hosted = UIHostingController(rootView: rootView) - try show(hosted) { controller in - try waitFor { isViewHosted(controller) } - } - } - - @Test func viewerHostsWithMultipleStores() throws { - // Mirrors the Settings entry, which merges WhereLog + RegionLog buffers. - let appStore = LogStore() - appStore.record(LogEntry( - level: .info, - subsystem: "app", - category: "Session", - message: "hi", - )) - let regionStore = LogStore() - regionStore.record(LogEntry( - level: .error, - subsystem: "region", - category: "RegionAttributor", - message: "boom", - )) - - let rootView = NavigationStack { - LogViewer(configuration: LogViewerConfiguration( - stores: [appStore, regionStore], - title: "Logs", - )) - } - let hosted = UIHostingController(rootView: rootView) - try show(hosted) { controller in - try waitFor { isViewHosted(controller) } - } - } -} diff --git a/Shared/LogViewerUI/Tests/LogViewerModelTests.swift b/Shared/LogViewerUI/Tests/LogViewerModelTests.swift deleted file mode 100644 index 3abd920e..00000000 --- a/Shared/LogViewerUI/Tests/LogViewerModelTests.swift +++ /dev/null @@ -1,238 +0,0 @@ -import Foundation -@_spi(Testing) import LogKit -@testable import LogViewerUI -import Testing - -@MainActor -struct LogViewerModelTests { - private func seededStore() -> LogStore { - let store = LogStore() - store.record(LogEntry(level: .debug, subsystem: "s", category: "Net", message: "debug net")) - store.record(LogEntry(level: .info, subsystem: "s", category: "Net", message: "info net")) - store.record(LogEntry(level: .error, subsystem: "s", category: "DB", message: "error db")) - store.record(LogEntry( - level: .fault, - subsystem: "s", - category: "DB", - message: "fault db crash", - )) - return store - } - - @Test - func defaultsShowAllEntriesNewestFirst() { - let model = LogViewerModel(store: seededStore()) - #expect(model.filteredEntries.map(\.message) == [ - "fault db crash", - "error db", - "info net", - "debug net", - ]) - } - - @Test - func minimumLevelFiltersOutLowerSeverity() { - let model = LogViewerModel(store: seededStore()) - model.minimumLevel = .error - #expect(model.filteredEntries.map(\.message) == ["fault db crash", "error db"]) - } - - @Test - func categoryFilterRestrictsToSelection() { - let model = LogViewerModel(store: seededStore()) - model.selectedCategory = "Net" - #expect(model.filteredEntries.map(\.category) == ["Net", "Net"]) - #expect(model.categories == ["DB", "Net"]) - } - - @Test - func searchMatchesMessageAndCategory() { - let model = LogViewerModel(store: seededStore()) - model.searchText = "crash" - #expect(model.filteredEntries.map(\.message) == ["fault db crash"]) - - model.searchText = "net" - #expect(model.filteredEntries.count == 2) - } - - @Test - func searchMatchesCategoryDisplayName() { - let store = LogStore() - store.record(LogEntry( - level: .info, - subsystem: "s", - category: "net.raw", - message: "connected", - )) - let model = LogViewerModel(store: store, categoryDisplayName: { _ in "Networking" }) - model.searchText = "network" - #expect(model.filteredEntries.map(\.message) == ["connected"]) - } - - @Test - func warningFiltersBetweenNoticeAndError() { - let store = LogStore() - store.record(LogEntry(level: .notice, subsystem: "s", category: "C", message: "n")) - store.record(LogEntry(level: .warning, subsystem: "s", category: "C", message: "w")) - store.record(LogEntry(level: .error, subsystem: "s", category: "C", message: "e")) - let model = LogViewerModel(store: store) - model.minimumLevel = .warning - #expect(model.filteredEntries.map(\.message) == ["e", "w"]) - } - - @Test - func combinedLevelCategoryAndSearchFilters() { - let model = LogViewerModel(store: seededStore()) - model.minimumLevel = .error - model.selectedCategory = "DB" - model.searchText = "fault" - #expect(model.filteredEntries.map(\.message) == ["fault db crash"]) - } - - @Test - func hasNoFilterMatchesWhenFiltersExcludeEverything() { - let model = LogViewerModel(store: seededStore()) - model.searchText = "missing-term" - #expect(!model.isEmpty) - #expect(model.hasNoFilterMatches) - #expect(model.filteredEntries.isEmpty) - } - - @Test - func clearEmptiesModelEntries() { - let model = LogViewerModel(store: seededStore()) - model.clear() - #expect(model.isEmpty) - } - - @Test - func exportTextRendersOldestFirst() { - let model = LogViewerModel(store: seededStore()) - model.minimumLevel = .error - let text = model.exportText() - let lines = text.split(separator: "\n") - #expect(lines.count == 2) - #expect(lines.first?.contains("error db") == true) - #expect(lines.last?.contains("fault db crash") == true) - } - - @Test - func exportTextUsesCategoryDisplayName() { - let model = LogViewerModel(store: seededStore(), categoryDisplayName: { category in - category == "DB" ? "Database" : category - }) - let text = model.exportText() - #expect(text.contains("Database")) - #expect(!text.contains("] DB:")) - } - - @Test - func observeReflectsLiveStoreUpdates() async { - let store = LogStore() - let model = LogViewerModel(store: store) - - store.record(LogEntry(level: .info, subsystem: "s", category: "Net", message: "live")) - - let deadline = Date(timeIntervalSinceNow: 1) - while model.entries.isEmpty, Date() < deadline { - await Task.yield() - } - - #expect(model.entries.map(\.message) == ["live"]) - } - - // MARK: - Multiple stores - - @Test - func mergesMultipleStoresChronologically() { - let base = Date(timeIntervalSince1970: 1000) - let appStore = LogStore() - appStore.record(LogEntry( - date: base, - level: .info, - subsystem: "app", - category: "Session", - message: "app 1", - )) - appStore.record(LogEntry( - date: base.addingTimeInterval(2), - level: .info, - subsystem: "app", - category: "Session", - message: "app 2", - )) - let regionStore = LogStore() - regionStore.record(LogEntry( - date: base.addingTimeInterval(1), - level: .info, - subsystem: "region", - category: "RegionAttributor", - message: "region 1", - )) - - let model = LogViewerModel(stores: [appStore, regionStore]) - // Entries interleave by date (oldest-first); display reverses to newest-first. - #expect(model.entries.map(\.message) == ["app 1", "region 1", "app 2"]) - #expect(model.filteredEntries.map(\.message) == ["app 2", "region 1", "app 1"]) - // Categories from every store show up in the filter. - #expect(model.categories == ["RegionAttributor", "Session"]) - } - - @Test - func clearEmptiesEveryStore() { - let appStore = LogStore() - appStore.record(LogEntry(level: .info, subsystem: "app", category: "C", message: "a")) - let regionStore = LogStore() - regionStore.record(LogEntry(level: .info, subsystem: "region", category: "C", message: "b")) - - let model = LogViewerModel(stores: [appStore, regionStore]) - #expect(model.entries.count == 2) - - model.clear() - #expect(model.isEmpty) - #expect(appStore.snapshot().isEmpty) - #expect(regionStore.snapshot().isEmpty) - } - - /// Guards against a retain cycle through the long-lived observation tasks: - /// they capture `[weak self]` and `deinit` cancels them, so dropping the last - /// strong reference deallocates the model even while the tasks are parked in - /// `for await` (quiet stores emit nothing on their own). - @Test - func deinitsWhileObservingStores() { - weak var weakModel: LogViewerModel? - do { - let model = LogViewerModel(stores: [LogStore(), LogStore()]) - weakModel = model - #expect(weakModel != nil) - } - #expect(weakModel == nil) - } - - @Test - func observeReflectsUpdatesFromEveryStore() async { - let appStore = LogStore() - let regionStore = LogStore() - let model = LogViewerModel(stores: [appStore, regionStore]) - - appStore.record(LogEntry( - level: .info, - subsystem: "app", - category: "C", - message: "from app", - )) - regionStore.record(LogEntry( - level: .info, - subsystem: "region", - category: "C", - message: "from region", - )) - - let deadline = Date(timeIntervalSinceNow: 1) - while model.entries.count < 2, Date() < deadline { - await Task.yield() - } - - #expect(Set(model.entries.map(\.message)) == ["from app", "from region"]) - } -} diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index b8975aff..351e322d 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -12,7 +12,7 @@ the build system, formatting, and global conventions. Read that first. - **Foundation + os + SwiftData + Network + JournalKit only** (plus the ObjectiveC runtime, solely for `LogContextProviding`'s deallocation - trackers). No SwiftUI, no app code, no LogKit. UIKit is allowed **only** + trackers). No SwiftUI, no app code. UIKit is allowed **only** inside `#if canImport(UIKit)` (ambient sources, the image-attachment convenience). - Layering: `PeriscopeUI` and `PeriscopeTools` depend on this module — never diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index cd1960ff..a3e5f602 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -21,9 +21,12 @@ - fix: After initial merge, we should come back and update the UI to consume the Shared/Broadway design system tooling, eg a PeriscopeStylesheet for components and other recommendations. - feat: Journal attachments via external storage (PR #86 review). Instead of inlining blobs ≤64KB and omitting larger ones, write attachment bytes as files beside the journal segments (the entry referencing them by filename), clean them up with segment rotation and journal removal, and re-attach them at ingest. Removes the size cliff entirely — screenshots and payloads survive crashes too. Follow-up PR after #86. - feat: Multi-process store + journal coordination. Today only app processes ingest journals (extensions journal but never ingest, so an extension launch can't delete the live app's journal) — but the reverse hole remains: an app launching while an extension session is live would ingest and delete that *live* journal out from under its open descriptor, silently ending its recoverability. Needs a claim mechanism (e.g. a claim file the writer holds, or skip-directories-with-live-claims) designed alongside App Group store sharing — which the store doesn't support yet either (exclusive sequence counters, SwiftData container coordination). +- feat: Late-attached store sinks don't backfill the pre-attach recent buffer (Where migration dogfood). `PeriscopeStore.make` is `async`, so the app attaches the on-disk sink from a launch `Task` in `AppDelegate` — every record `Periscope.shared` emits before that attach (early launch steps, ambient start-up snapshots) is already gone from the store's history even though it's still in the in-memory recent buffer. Either replay the recent buffer into a newly `add(sink:)`ed sink (opt-in, deduped by event ID like journal ingest), or document a hard "attach the store before anything logs" contract callers can't actually satisfy when store creation is async. ## P2s (Nice to have) +- feat: Inspect-by-object is scope-granular, not instance-granular (Where migration dogfood). `.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. +- 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. # Completed issues diff --git a/Where/AGENTS.md b/Where/AGENTS.md index c0b848cb..6d1f55e8 100644 --- a/Where/AGENTS.md +++ b/Where/AGENTS.md @@ -51,15 +51,29 @@ Rules the code enforces and agents must preserve: refresh inline. The scene's `YearReportModel` subscribes while it's active; `DataIssueScanner` drops its cache on the same signal. Launch is driven by [`LifecycleKit`](../Shared/LifecycleKit) (see `WhereLaunch` in WhereUI). -- **All logging goes through `WhereLog.channel(_:)`** with a typed - `WhereLog.Category` case, never a raw string. Messages log as `.public`, so - keep PII out. `info` = success of an important operation, `warning` = - degraded-but-handled, `error`/`fault` = outright failure; hot paths - (per-sample persist, widget throttle) stay quiet by design. **RegionKit** logs - through its own `RegionLog` facade (subsystem `com.stuff.regionkit`, separate - store) since it can't see `WhereLog`; the DEBUG developer log viewer is - configured with **both** buffers (`[WhereLog.store, RegionLog.store]`) so it - shows a single merged stream. +- **All logging goes through [Periscope](../Shared/Periscope)** via the + `WhereLog` facade — a `"Where"` root `Log` scope with grouping scopes + (`location`, `reminders`, `backup`, `widgets`, `session`, `evidence`, + `recentActivity`) that each collaborator derives a typed `LogEvent` leaf from + (`WhereLog.(SomeLog.self)` / `WhereLog.root(SomeLog.self)`), never a + raw string. Events log as `.public`, so keep PII out; catch-path events carry + a `LogAttachment.error(_:)`. `info` = success of an important operation, + `warning` = degraded-but-handled, `error`/`fault` = outright failure; hot + paths (per-sample persist, widget throttle) stay quiet by design. **RegionKit** + emits through its own `RegionLog` facade (a separate `"RegionKit"` root scope) + since it can't see `WhereLog`, but into the *same* process-wide + `Periscope.shared` — the app attaches one `PeriscopeStore` sink at launch, and + the DEBUG developer surface (`PeriscopeViewer`) shows every scope subtree in a + single stream. Widgets, the share extension, and the intents surface run in + their own processes, so their `Periscope.shared` stays OSLog-only (no store). + An event that concerns a store object stamps its `externalID` with the + object's canonical `store://` identity — `DataIssueID.storeURL` for dismissals, + and `WhereStoreID` (`store://days/…`, `store://years/…`, `store://evidence/…`, + `store://samples/…`) for the other families — so inspect-by-object shares the + same key the store and backups use. **RegionKit** can't see the app's + `store://` types, so it owns a parallel `region://` scheme (`RegionURL`, + `Region.regionURL` → `region://regions/`) and `RegionAttributorLog` keys on + that — a separate namespace, since regions are a bundled catalog, not store rows. - **Location comes through the `LocationSource` protocol** — production is `CoreLocationSource`; tests and previews use `ScriptedLocationSource`. Besides the passive `sampleStream`, it offers a best-effort one-shot diff --git a/Where/RegionKit/AGENTS.md b/Where/RegionKit/AGENTS.md index 5d8cf921..748782fd 100644 --- a/Where/RegionKit/AGENTS.md +++ b/Where/RegionKit/AGENTS.md @@ -10,7 +10,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Scope & dependencies -- **Pure Swift + Foundation**, plus [`LogKit`](../../Shared/LogKit). It must +- **Pure Swift + Foundation**, plus + [`PeriscopeCore`](../../Shared/Periscope/PeriscopeCore) for logging. It must **not** import SwiftUI, UIKit, SwiftData, CoreLocation, or `WhereCore` — it is the lowest layer of the feature, and `WhereCore` depends on *it*, never the reverse. @@ -45,9 +46,17 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Missing/corrupt bundled geometry (or manifest) is a programmer error** — the loader logs a `fault` via `RegionLog` *and* `assertionFailure`s (debug), degrading to `.other`/an empty catalog in release rather than crashing. -- **Logging goes through `RegionLog.channel(_:)`** (subsystem - `com.stuff.regionkit`), never `WhereLog` — RegionKit owns its own channel and - in-memory store. +- **Logging goes through `RegionLog`** — a Periscope facade with a `"RegionKit"` + root scope and one typed `LogEvent` per collaborator, emitted into + `Periscope.shared`. RegionKit owns its own root scope, never `WhereLog`, but + shares the process-wide store (the app wires the `PeriscopeStore` sink). +- **Object identities are `region://` URLs** — `RegionURL` (RegionKit's local + analog of WhereCore's `StoreURL`) builds/parses `region:///` + URLs, and `Region.regionURL` vends `region://regions/`. Used to key a + `LogEvent.externalID` (see `RegionAttributorLog`) so inspect-by-object works + without RegionKit reaching up into the app's `store://` scheme — a separate, + intentionally parallel namespace. Distinct from `Region`'s bare-`rawValue` + `Codable`, which stays the persisted form. ## Testing diff --git a/Where/RegionKit/README.md b/Where/RegionKit/README.md index 432c16f4..c1358cc6 100644 --- a/Where/RegionKit/README.md +++ b/Where/RegionKit/README.md @@ -8,7 +8,8 @@ unit-tested in isolation. RegionKit is the lowest layer of the Where feature: `WhereCore` (and, through it, `WhereUI`, the widgets, and the RegionViewer) depend on RegionKit and call -into it for lookup. RegionKit depends only on [`LogKit`](../../Shared/LogKit). +into it for lookup. RegionKit depends only on +[`PeriscopeCore`](../../Shared/Periscope/PeriscopeCore) for logging. ## What you get @@ -34,7 +35,10 @@ into it for lookup. RegionKit depends only on [`LogKit`](../../Shared/LogKit). - **`RegionGeometryCatalog`** — read-only drawable `RegionOutline`s for the developer region-map viewer (`.attribution` for a given attributor vs `.source` for the whole catalog). -- **`RegionLog`** — RegionKit's LogKit facade (subsystem `com.stuff.regionkit`). +- **`RegionLog`** — RegionKit's Periscope logging facade: one `"RegionKit"` + root scope with a typed `LogEvent` per collaborator (`RegionAttributor`, + `RegionCatalog`, `RegionGeometryCatalog`), emitted into the process-wide + `Periscope.shared` system. ## Installation diff --git a/Where/RegionKit/Sources/Region.swift b/Where/RegionKit/Sources/Region.swift index 7fa71ee8..0d84f07b 100644 --- a/Where/RegionKit/Sources/Region.swift +++ b/Where/RegionKit/Sources/Region.swift @@ -71,6 +71,15 @@ extension Region { public var localizedName: String { RegionCatalog.shared.localizedName(for: self) } + + /// Stable `region://regions/` identity, built with ``RegionURL``, for + /// logging/tooling correlation (e.g. a Periscope `LogEvent.externalID`) so + /// every event about one region shares a namespaced key. Distinct from the + /// bare-`rawValue` `Codable` used for storage — this is the tooling + /// identity, not the persisted form. + public var regionURL: URL { + RegionURL.url(collection: "regions", type: rawValue, items: [:]) + } } // MARK: - Well-known regions diff --git a/Where/RegionKit/Sources/RegionAttributor.swift b/Where/RegionKit/Sources/RegionAttributor.swift index 56320eea..af816126 100644 --- a/Where/RegionKit/Sources/RegionAttributor.swift +++ b/Where/RegionKit/Sources/RegionAttributor.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// A coordinate-to-`Region` lookup engine. Abstracted as a protocol so callers /// can hold either an immutable ``RegionAttributor`` snapshot or a live, @@ -95,9 +95,9 @@ public struct RegionAttributor: RegionAttributing { return entry.polygons.map { $0.distanceToBoundary(from: coordinate) }.min() } - /// `RegionLog` channel — surfaces missing/unparseable bundled resources as + /// `RegionLog` logger — surfaces missing/unparseable bundled resources as /// Console.app faults alongside the debug-build `assertionFailure`. - private static let logger = RegionLog.channel(.attributor) + private static let logger = RegionLog.attributor /// Loads the exterior-ring polygons for each region from its bundled /// per-region GeoJSON. Missing/corrupt geometry is a programmer error: it's @@ -108,29 +108,31 @@ public struct RegionAttributor: RegionAttributing { for region in regions { guard region != .other else { continue } guard let url = RegionCatalog.shared.geometryURL(for: region) else { - logger.fault("Missing bundled GeoJSON for region \(region.rawValue)") + logger { .missingGeometry(region: region) } assertionFailure("Missing bundled GeoJSON for region \(region.rawValue)") continue } do { let polygons = try GeoJSON.polygons(at: url) guard !polygons.isEmpty else { - logger.fault("Region \(region.rawValue) decoded no polygons") + logger { .emptyPolygons(region: region) } assertionFailure("Region \(region.rawValue) decoded no polygons") continue } entries.append(RegionPolygons(region: region, polygons: polygons)) } catch { - logger - .fault( - "Failed to decode bundled GeoJSON for region \(region.rawValue): \(error.localizedDescription)", + logger(attachments: [.error(error, name: "decode-error")]) { + .decodeFailed( + region: region, + description: error.localizedDescription, ) + } assertionFailure( "Failed to decode bundled GeoJSON for region \(region.rawValue): \(error)", ) } } - logger.info("Loaded region polygons for \(entries.count) region(s)") + logger { .loaded(regionCount: entries.count) } return entries } } diff --git a/Where/RegionKit/Sources/RegionAttributorLog.swift b/Where/RegionKit/Sources/RegionAttributorLog.swift new file mode 100644 index 00000000..15d61a57 --- /dev/null +++ b/Where/RegionKit/Sources/RegionAttributorLog.swift @@ -0,0 +1,48 @@ +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 { + /// 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" + + var level: LogLevel { + switch self { + case .missingGeometry, .emptyPolygons, .decodeFailed: .fault + case .loaded: .info + } + } + + 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)" + } + } + + var externalID: String? { + switch self { + case let .missingGeometry(region), let .emptyPolygons(region), + let .decodeFailed(region, _): + region.regionURL.absoluteString + case .loaded: + nil + } + } +} diff --git a/Where/RegionKit/Sources/RegionCatalog.swift b/Where/RegionKit/Sources/RegionCatalog.swift index 8584f9c0..011ebe00 100644 --- a/Where/RegionKit/Sources/RegionCatalog.swift +++ b/Where/RegionKit/Sources/RegionCatalog.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// The catalog of **available** regions, loaded once from the bundled /// `regions.json` manifest. It is RegionKit's single source of the region @@ -92,11 +92,11 @@ public struct RegionCatalog: Sendable { } extension RegionCatalog { - private static let logger = RegionLog.channel(.catalog) + private static let logger = RegionLog.catalog private static func loadFromBundle() -> RegionCatalog { guard let url = Bundle.module.url(forResource: "regions", withExtension: "json") else { - logger.fault("Missing required bundled regions.json manifest") + logger { .missingManifest } assertionFailure("Missing bundled regions.json") return RegionCatalog(entries: []) } @@ -111,10 +111,12 @@ extension RegionCatalog { geometryFile: item.geometry.file, ) } - logger.info("Loaded region catalog with \(entries.count) region(s)") + logger { .loaded(regionCount: entries.count) } return RegionCatalog(entries: entries) } catch { - logger.fault("Failed to decode bundled regions.json: \(error.localizedDescription)") + logger(attachments: [.error(error, name: "decode-error")]) { + .decodeFailed(description: error.localizedDescription) + } assertionFailure("Failed to decode bundled regions.json: \(error)") return RegionCatalog(entries: []) } diff --git a/Where/RegionKit/Sources/RegionCatalogLog.swift b/Where/RegionKit/Sources/RegionCatalogLog.swift new file mode 100644 index 00000000..0712275f --- /dev/null +++ b/Where/RegionKit/Sources/RegionCatalogLog.swift @@ -0,0 +1,33 @@ +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 { + /// 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) + + static let eventName = "RegionCatalog" + + var level: LogLevel { + switch self { + case .missingManifest, .decodeFailed: .fault + case .loaded: .info + } + } + + 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)" + } + } +} diff --git a/Where/RegionKit/Sources/RegionGeometryCatalogLog.swift b/Where/RegionKit/Sources/RegionGeometryCatalogLog.swift new file mode 100644 index 00000000..d318677a --- /dev/null +++ b/Where/RegionKit/Sources/RegionGeometryCatalogLog.swift @@ -0,0 +1,23 @@ +import PeriscopeCore + +/// Structured events for the developer region-map viewer's geometry load. A +/// failed load is degraded-but-handled (the viewer shows an error state), so it +/// logs at `.warning`. Public because the viewer lives in WhereUI, above +/// RegionKit, and emits through ``RegionLog/geometryCatalog``. +public enum RegionGeometryCatalogLog: LogEvent { + /// Loading the outlines for a `RegionGeometryKind` failed. + case loadFailed(kind: String, description: String) + + public static let eventName = "RegionGeometryCatalog" + + public var level: LogLevel { + .warning + } + + public var message: String { + switch self { + case let .loadFailed(kind, description): + "Region map viewer failed to load \(kind) geometry: \(description)" + } + } +} diff --git a/Where/RegionKit/Sources/RegionLog.swift b/Where/RegionKit/Sources/RegionLog.swift index 2955cb91..d95e8d4b 100644 --- a/Where/RegionKit/Sources/RegionLog.swift +++ b/Where/RegionKit/Sources/RegionLog.swift @@ -1,30 +1,37 @@ -import LogKit +import PeriscopeCore -/// Logging facade for `RegionKit`. Every logger site routes through -/// ``channel(_:)`` so messages reach both Apple unified logging (Console.app, -/// subsystem `com.stuff.regionkit`) and the shared in-memory ``LogStore`` a -/// log viewer can read (DEBUG builds only). +/// 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 { + "" + } +} + +/// Logging facade for `RegionKit`. Every logger site derives from one root +/// `Log` scoped `"RegionKit"`, so RegionKit's events form a single subtree +/// under Periscope's process-wide system (``Periscope/shared``) — the log +/// viewer filters or inspects them as a group, and each collaborator's events +/// live in their own named child scope. /// -/// RegionKit owns its own subsystem and store rather than borrowing the Where -/// app's `WhereLog`: it's a standalone lower-level module that must not depend -/// on app code. Categories are a typed enum rather than raw strings so a new -/// logger can't silently typo into an untracked category. +/// RegionKit owns its own root scope rather than borrowing the Where app's +/// `WhereLog`: it's a standalone lower-level module that must not depend on app +/// code. Loggers are typed to a per-collaborator ``LogEvent`` so a new event +/// can't silently typo into an untracked category. public enum RegionLog { - /// The subsystem every RegionKit log shares. - public static let subsystem = "com.stuff.regionkit" + /// The `"RegionKit"` root every RegionKit logger descends from. + static let root = Log(system: .shared) - /// Process-wide buffer feeding an in-app log viewer. Logging is inherently - /// process-global, so a single shared store is the natural home. - public static let store = LogStore() + /// `RegionAttributor` — surfaces missing/unparseable bundled geometry as + /// faults alongside the debug-build `assertionFailure`. + static let attributor = root(RegionAttributorLog.self) - public enum Category: String, CaseIterable, Sendable { - case attributor = "RegionAttributor" - case geometryCatalog = "RegionGeometryCatalog" - case catalog = "RegionCatalog" - } + /// `RegionCatalog` — the bundled `regions.json` manifest load. + static let catalog = root(RegionCatalogLog.self) - /// A logging channel for `category`, wired to the shared buffer. - public static func channel(_ category: Category) -> LogChannel { - LogChannel(subsystem: subsystem, category: category.rawValue, store: store) - } + /// `RegionGeometryCatalog` — the developer region-map viewer's geometry + /// load. Public because the viewer lives in WhereUI, above RegionKit. + public static let geometryCatalog = root(RegionGeometryCatalogLog.self) } diff --git a/Where/RegionKit/Sources/RegionURL.swift b/Where/RegionKit/Sources/RegionURL.swift new file mode 100644 index 00000000..f3f67e32 --- /dev/null +++ b/Where/RegionKit/Sources/RegionURL.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Builder and parser for `region:///?` identity URLs +/// — RegionKit's local analog of WhereCore's `StoreURL`. It lets a RegionKit +/// value vend a stable, namespaced URL identity (e.g. a Periscope +/// `LogEvent.externalID`) without reaching up into app code for `StoreURL`. +/// +/// `collection` is the object family (`regions`), `type` the specific instance +/// or kind within it, and named query items carry any additional identifying +/// values. RegionKit is a standalone lower module, so it owns its own +/// `region://` scheme rather than borrowing the app's `store://` one — the two +/// are intentionally separate namespaces (a region is a bundled-catalog entry, +/// not a store row). +public enum RegionURL { + public static let scheme = "region" + + /// The parsed components of a `region://` URL. A named struct (not a tuple) + /// so it can carry across call boundaries per the repo's tuple rule. + public struct Parts: Sendable, Hashable { + public let collection: String + public let type: String + public let items: [String: String] + + public init(collection: String, type: String, items: [String: String]) { + self.collection = collection + self.type = type + self.items = items + } + + /// The value for a query item, or `nil` when the key is absent. + public func value(_ key: String) -> String? { + items[key] + } + } + + /// Build a `region:///?` URL. Query items are + /// sorted by key so the same identity always produces the same string. + public static func url(collection: String, type: String, items: [String: String]) -> URL { + var components = URLComponents() + components.scheme = scheme + components.host = collection + components.path = "/" + type + components.queryItems = items.isEmpty + ? nil + : items + .sorted { $0.key < $1.key } + .map { URLQueryItem(name: $0.key, value: $0.value) } + // The inputs are controlled identifiers, so a `nil` here would be a + // programmer error (an illegal collection/type), not a recoverable one. + guard let url = components.url else { + preconditionFailure("Could not build region URL for \(collection)/\(type)") + } + return url + } + + /// Parse a `region://` URL into its components, or `nil` if it isn't a + /// well-formed `region:///` URL. + public static func parts(of url: URL) -> Parts? { + guard url.scheme == scheme, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let collection = components.host, !collection.isEmpty + else { return nil } + let path = components.path + let type = path.hasPrefix("/") ? String(path.dropFirst()) : path + guard !type.isEmpty else { return nil } + var items: [String: String] = [:] + for item in components.queryItems ?? [] { + if let value = item.value { items[item.name] = value } + } + return Parts(collection: collection, type: type, items: items) + } +} diff --git a/Where/RegionKit/Tests/RegionLogTests.swift b/Where/RegionKit/Tests/RegionLogTests.swift new file mode 100644 index 00000000..ac3edc02 --- /dev/null +++ b/Where/RegionKit/Tests/RegionLogTests.swift @@ -0,0 +1,69 @@ +import PeriscopeCore +@testable import RegionKit +import Testing + +/// Covers RegionKit's Periscope log tree: the scope names/hierarchy the +/// ``RegionLog`` facade vends, and each collaborator event's rendering. +struct RegionLogTests { + // MARK: - Scope tree + + @Test func rootScopeIsRegionKit() { + #expect(RegionLog.root.primaryScope.name == "RegionKit") + } + + @Test func collaboratorScopesDescendFromTheRoot() { + let rootID = RegionLog.root.primaryScope.id + #expect(RegionLog.attributor.primaryScope.name == "RegionAttributor") + #expect(RegionLog.attributor.primaryScope.parentID == rootID) + #expect(RegionLog.catalog.primaryScope.name == "RegionCatalog") + #expect(RegionLog.catalog.primaryScope.parentID == rootID) + #expect(RegionLog.geometryCatalog.primaryScope.name == "RegionGeometryCatalog") + #expect(RegionLog.geometryCatalog.primaryScope.parentID == rootID) + } + + // 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")) + } + + // MARK: - RegionAttributorLog + + @Test func attributorEventsCarryRegionAsExternalID() { + // The region rides on externalID as its region:// identity (see + // RegionURLTests for the exact string). + #expect( + RegionAttributorLog.missingGeometry(region: .california) + .externalID == Region.california.regionURL.absoluteString, + ) + #expect( + RegionAttributorLog.emptyPolygons(region: .canada) + .externalID == Region.canada.regionURL.absoluteString, + ) + #expect( + RegionAttributorLog.decodeFailed(region: .newYork, description: "x") + .externalID == Region.newYork.regionURL.absoluteString, + ) + #expect(RegionAttributorLog.loaded(regionCount: 2).externalID == nil) + } + + @Test func attributorFaultsAndInfo() { + #expect(RegionAttributorLog.missingGeometry(region: .california).level == .fault) + #expect(RegionAttributorLog.emptyPolygons(region: .california).level == .fault) + #expect( + RegionAttributorLog.decodeFailed(region: .california, description: "x").level == .fault, + ) + #expect(RegionAttributorLog.loaded(regionCount: 2).level == .info) + } + + // MARK: - RegionGeometryCatalogLog + + @Test func geometryCatalogFailureIsWarning() { + let event = RegionGeometryCatalogLog.loadFailed(kind: "source", description: "nope") + #expect(event.level == .warning) + #expect(event.message.contains("source")) + } +} diff --git a/Where/RegionKit/Tests/RegionURLTests.swift b/Where/RegionKit/Tests/RegionURLTests.swift new file mode 100644 index 00000000..a7535b1d --- /dev/null +++ b/Where/RegionKit/Tests/RegionURLTests.swift @@ -0,0 +1,40 @@ +import Foundation +@testable import RegionKit +import Testing + +/// Covers ``RegionURL`` (RegionKit's `region://` identity builder/parser) and +/// the `Region.regionURL` identity used for log `externalID` correlation. +struct RegionURLTests { + @Test func buildsCollectionTypeAndSortedParams() { + let url = RegionURL.url( + collection: "regions", + type: "us-CA", + items: ["b": "2", "a": "1"], + ) + #expect(url.absoluteString == "region://regions/us-CA?a=1&b=2") + } + + @Test func buildsWithoutParamsWhenEmpty() { + let url = RegionURL.url(collection: "regions", type: "canada", items: [:]) + #expect(url.absoluteString == "region://regions/canada") + } + + @Test func parsesBackIntoParts() throws { + let url = try #require(URL(string: "region://regions/us-NY?since=2026")) + let parts = try #require(RegionURL.parts(of: url)) + #expect(parts.collection == "regions") + #expect(parts.type == "us-NY") + #expect(parts.value("since") == "2026") + } + + @Test func rejectsForeignSchemes() throws { + // A store:// URL is a different namespace; RegionURL must not claim it. + let url = try #require(URL(string: "store://issues/borderDrift?day=2026-04-01")) + #expect(RegionURL.parts(of: url) == nil) + } + + @Test func regionVendsItsCanonicalIdentity() { + #expect(Region.california.regionURL.absoluteString == "region://regions/us-CA") + #expect(Region.other.regionURL.absoluteString == "region://regions/other") + } +} diff --git a/Where/RegionViewer/AGENTS.md b/Where/RegionViewer/AGENTS.md index 87194e3e..d7a6ee32 100644 --- a/Where/RegionViewer/AGENTS.md +++ b/Where/RegionViewer/AGENTS.md @@ -10,8 +10,8 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature ## Scope & rules - **Tuist app target** (bundle ID `com.stuff.regionviewer`), depending on - **WhereUI**, **WhereCore**, **RegionKit** (geometry + GeoJSON, whose resource - bundle is embedded for `RegionGeometryCatalog`), and **LogKit**. The `@main` + **WhereUI**, **WhereCore**, and **RegionKit** (geometry + GeoJSON, whose + resource bundle is embedded for `RegionGeometryCatalog`). The `@main` body is `WindowGroup { NavigationStack { RegionMapView() } }` — that's the whole target. - **Shell only, session-less.** No domain logic, SwiftData, App Group, or diff --git a/Where/Where/Sources/AppDelegate.swift b/Where/Where/Sources/AppDelegate.swift index e94e45ed..9e9efd3d 100644 --- a/Where/Where/Sources/AppDelegate.swift +++ b/Where/Where/Sources/AppDelegate.swift @@ -35,6 +35,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate { from: application.applicationState, locationAuthorization: CLLocationManager().authorizationStatus, ) + // Open the durable Periscope log store and attach it to the shared + // logging pipeline. Off the launch critical path (it touches disk); the + // shared OSLog sink covers logging until the store is attached. + WhereLaunch.bootstrapLogging(model: model) // `initializePrerequisites` installs the CLLocationManager synchronously // (so a queued location event isn't lost) and registers the // foreground-notification presenter; the rest (store open, etc.) runs as diff --git a/Where/WhereCore/AGENTS.md b/Where/WhereCore/AGENTS.md index 6670e8d6..6f3b00ec 100644 --- a/Where/WhereCore/AGENTS.md +++ b/Where/WhereCore/AGENTS.md @@ -62,7 +62,10 @@ internal shape. `Codable` and a stable SwiftData string key for free — never an ad-hoc `type:value` string or a hand-written keyed `Codable`. Build/parse with `StoreURL` so every conformer shares the `store:///?` - shape. + shape. Object families without a dedicated identity type (days, years, + evidence, samples) get their `store://` identity from `WhereStoreID`, used to + stamp Periscope `LogEvent.externalID`s so log inspect-by-object shares the + store's keys. - **No in-app data migration or legacy recovery.** A data-shape change is not migrated on read or at boot: `SD….toValue()` reads only the current shape and drops (fault-logs) a row it can't place — e.g. an `SDManualDay` with no @@ -100,8 +103,10 @@ internal shape. loads tracked-region geometry, so `distanceToBoundary` is `nil` elsewhere. - **Impossible states trap; recoverable ones surface.** `WhereStore` methods are `async throws` so the CloudKit-backed store can report I/O failure; a `catch` - must log via `WhereLog.channel(_:)` (typed `Category`, PII-free) and leave - state honest — never swallow into an empty default. + must log via a `WhereLog` typed `LogEvent` (PII-free, `.public`) and leave + state honest — never swallow into an empty default. Each collaborator emits + its own `LogEvent` under its scope (`WhereLog.(SomeLog.self)` or + `WhereLog.root(SomeLog.self)`); errors ride as `LogAttachment.error(_:)`. ## Testing diff --git a/Where/WhereCore/README.md b/Where/WhereCore/README.md index 495550d6..8419849f 100644 --- a/Where/WhereCore/README.md +++ b/Where/WhereCore/README.md @@ -7,7 +7,7 @@ widget snapshots, backups, on-device activity summaries). It is pure Swift + Foundation + SwiftData + CoreLocation + FoundationModels — **no SwiftUI or UIKit** — so all of it is unit-testable off-screen. It builds on [`RegionKit`](../RegionKit) for coordinate→region lookup and logs through -[`LogKit`](../../Shared/LogKit). +[`Periscope`](../../Shared/Periscope) via the `WhereLog` facade. Everything is reached through one `Sendable` container, **`WhereServices`**, which the presentation layer (`WhereUI`) and the widget extension talk to. For @@ -87,8 +87,9 @@ one it belongs to rather than to a god-object: a selectable look-back `RecentActivityWindow`. - **`WherePreferences`** — persisted user intent (onboarding, tracking intent, reminder / summary schedules) behind a `KeyValueStore`. -- **`WhereLog`** — the LogKit facade (subsystem `com.stuff.where`, a typed - `Category`). +- **`WhereLog`** — the Periscope logging facade: a `"Where"` root scope with + grouping scopes (`location`, `reminders`, `backup`, `widgets`, …) and a typed + `LogEvent` per collaborator, emitted into `Periscope.shared`. ## Installation diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift index fc2034be..728daa9e 100644 --- a/Where/WhereCore/Sources/Backup/BackupCoordinator.swift +++ b/Where/WhereCore/Sources/Backup/BackupCoordinator.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns backup export/import over the `BackupService` and the store, running a @@ -56,7 +56,7 @@ public actor BackupCoordinator { /// (drop the issue-scan cache, reconcile the app-icon badge + issues /// notification, republish the widget snapshot). private let onImport: @Sendable () async -> Void - private static let logger = WhereLog.channel(.backupService) + private static let logger = WhereLog.backup(BackupCoordinatorLog.self) /// Staging directory of the most recent export. Each archive lands in its /// own temporary directory; the share sheet copies the file it needs out of @@ -152,9 +152,7 @@ public actor BackupCoordinator { do { try FileManager.default.removeItem(at: previous) } catch { - Self.logger.warning( - "Failed to remove previous backup export directory: \(error.localizedDescription)", - ) + Self.logger { .removePreviousExportFailed(description: error.localizedDescription) } } } diff --git a/Where/WhereCore/Sources/Backup/BackupCoordinatorLog.swift b/Where/WhereCore/Sources/Backup/BackupCoordinatorLog.swift new file mode 100644 index 00000000..39f02fdc --- /dev/null +++ b/Where/WhereCore/Sources/Backup/BackupCoordinatorLog.swift @@ -0,0 +1,21 @@ +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 { + case removePreviousExportFailed(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)" + } + } +} diff --git a/Where/WhereCore/Sources/Backup/BackupService.swift b/Where/WhereCore/Sources/Backup/BackupService.swift index 94decfd5..d27e2661 100644 --- a/Where/WhereCore/Sources/Backup/BackupService.swift +++ b/Where/WhereCore/Sources/Backup/BackupService.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit import ZIPFoundation @@ -62,7 +62,7 @@ public struct BackupService: Sendable { private static let manifestFilename = "manifest.json" private static let assetsDirectory = "assets" - private static let logger = WhereLog.channel(.backupService) + private static let logger = WhereLog.backup(BackupServiceLog.self) public init() {} @@ -131,15 +131,23 @@ public struct BackupService: Sendable { let name = archiveName ?? Self.defaultArchiveName(for: exportedAt) let zipURL = workRoot.appendingPathComponent(name) - try fileManager.zipItem( - at: staging, - to: zipURL, - shouldKeepParent: false, - compressionMethod: .deflate, - ) - Self.logger.info( - "Wrote backup with \(samples.count) samples, \(evidence.count) evidence, \(manualDays.count) manual days, \(dismissedIssues.count) dismissals, \(trackedRegions.count) tracked regions", - ) + try Self.logger.measure(.writeArchive) { + try fileManager.zipItem( + at: staging, + to: zipURL, + shouldKeepParent: false, + compressionMethod: .deflate, + ) + } + Self.logger { + .wroteBackup( + sampleCount: samples.count, + evidenceCount: evidence.count, + manualDayCount: manualDays.count, + dismissedIssueCount: dismissedIssues.count, + trackedRegionCount: trackedRegions.count, + ) + } return zipURL } @@ -163,7 +171,9 @@ public struct BackupService: Sendable { try fileManager.createDirectory(at: extractDir, withIntermediateDirectories: true) defer { try? fileManager.removeItem(at: extractDir) } - try fileManager.unzipItem(at: url, to: extractDir) + try Self.logger.measure(.readArchive) { + try fileManager.unzipItem(at: url, to: extractDir) + } let manifestURL = extractDir.appendingPathComponent(Self.manifestFilename) guard fileManager.fileExists(atPath: manifestURL.path) else { @@ -183,9 +193,7 @@ public struct BackupService: Sendable { autoreleasepool { let assetURL = extractDir.appendingPathComponent(entry.filename) guard let data = try? Data(contentsOf: assetURL) else { - Self.logger.warning( - "Backup asset missing for evidence \(entry.evidenceId); skipping blob", - ) + Self.logger { .assetMissing(evidenceID: entry.evidenceId.uuidString) } return } blobs[entry.evidenceId] = data diff --git a/Where/WhereCore/Sources/Backup/BackupServiceLog.swift b/Where/WhereCore/Sources/Backup/BackupServiceLog.swift new file mode 100644 index 00000000..07efaf0b --- /dev/null +++ b/Where/WhereCore/Sources/Backup/BackupServiceLog.swift @@ -0,0 +1,51 @@ +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. + enum SpanName: Hashable { + case writeArchive + case readArchive + } + + case wroteBackup( + sampleCount: Int, + evidenceCount: Int, + manualDayCount: Int, + dismissedIssueCount: Int, + trackedRegionCount: Int, + ) + case assetMissing(evidenceID: String) + + static let eventName = "BackupService" + + var level: LogLevel { + switch self { + case .wroteBackup: .info + case .assetMissing: .warning + } + } + + 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" + } + } + + var externalID: String? { + switch self { + case let .assetMissing(evidenceID): WhereStoreID.evidence(evidenceID) + case .wroteBackup: nil + } + } +} diff --git a/Where/WhereCore/Sources/Journal/DayJournal.swift b/Where/WhereCore/Sources/Journal/DayJournal.swift index 25d96fec..f2f9f5c3 100644 --- a/Where/WhereCore/Sources/Journal/DayJournal.swift +++ b/Where/WhereCore/Sources/Journal/DayJournal.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns the user-sourced writes into the store — sample ingestion, manual-day @@ -21,7 +21,7 @@ public actor DayJournal { private let issueScanner: DataIssueScanner private let widgets: WidgetSnapshotPublisher - private static let logger = WhereLog.channel(.dayJournal) + private static let logger = WhereLog.root(DayJournalLog.self) init( store: any WhereStore, @@ -104,7 +104,7 @@ public actor DayJournal { let presence = DayPresence(day: day, regions: regions, audit: audit) try await store.perform { try await store.setManualDay(presence) } await reconcileAfterDayChange() - Self.logger.info("Added manual day \(day) with \(regions.count) region(s)") + Self.logger { .addedManualDay(day: String(describing: day), regionCount: regions.count) } } /// Authoritatively set the regions for a single calendar day, *replacing* @@ -121,7 +121,7 @@ public actor DayJournal { let presence = DayPresence(day: day, regions: regions, isAuthoritative: true, audit: audit) try await store.perform { try await store.setManualDay(presence) } await reconcileAfterDayChange() - Self.logger.info("Overrode day \(day) with \(regions.count) region(s)") + Self.logger { .overrodeDay(day: String(describing: day), regionCount: regions.count) } } /// Drop the manual overlay for a single calendar day, restoring the @@ -132,7 +132,7 @@ public actor DayJournal { let day = CalendarDay(from: date, in: aggregator.calendar) try await store.perform { try await store.clearManualDay(day) } await reconcileAfterDayChange() - Self.logger.info("Cleared manual overlay for day \(day)") + Self.logger { .clearedManualDay(day: String(describing: day)) } } /// Drop the manual overlays for several calendar days (the logged-days @@ -153,7 +153,7 @@ public actor DayJournal { } } await reconcileAfterDayChange() - Self.logger.info("Cleared manual overlays for \(days.count) day(s)") + Self.logger { .clearedManualDays(dayCount: days.count) } } /// Assert `regions` for every calendar day in the inclusive range @@ -185,9 +185,9 @@ public actor DayJournal { } } await reconcileAfterDayChange() - Self.logger.info( - "Backfilled \(days.count) manual day(s) with \(regions.count) region(s)", - ) + Self.logger { + .backfilledManualDays(dayCount: days.count, regionCount: regions.count) + } } // MARK: - Clearing @@ -197,7 +197,7 @@ public actor DayJournal { let dayRange = CalendarDay.yearRange(year) try await store.perform { try await store.clear(in: interval, manualDays: dayRange) } await reconcileAfterDayChange() - Self.logger.info("Cleared year \(year)") + Self.logger { .clearedYear(year: year) } } /// Erase every sample, manual day, and piece of evidence in the store, then @@ -208,14 +208,16 @@ public actor DayJournal { public func eraseAllData() async throws { try await store.perform { try await store.clearAll() } await reconcileAfterDayChange() - Self.logger.info("Erased all store data") + Self.logger { .erasedAllData } } // MARK: - Evidence public func addEvidence(_ evidence: Evidence, blob: Data? = nil) async throws { try await store.perform { try await store.write(evidence: evidence, blob: blob) } - Self.logger.info("Wrote evidence \(evidence.id) (blob: \(blob != nil))") + Self.logger { + .wroteEvidence(id: String(describing: evidence.id), hasBlob: blob != nil) + } } public func evidence(for year: Int) async throws -> [Evidence] { diff --git a/Where/WhereCore/Sources/Journal/DayJournalLog.swift b/Where/WhereCore/Sources/Journal/DayJournalLog.swift new file mode 100644 index 00000000..184a2351 --- /dev/null +++ b/Where/WhereCore/Sources/Journal/DayJournalLog.swift @@ -0,0 +1,52 @@ +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 { + 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) + + static let eventName = "DayJournal" + + 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? { + 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 + } + } +} diff --git a/Where/WhereCore/Sources/Location/LocationIngestor.swift b/Where/WhereCore/Sources/Location/LocationIngestor.swift index 0eac7b73..c7ab6e3f 100644 --- a/Where/WhereCore/Sources/Location/LocationIngestor.swift +++ b/Where/WhereCore/Sources/Location/LocationIngestor.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// Owns live GPS ingestion: the location-monitoring lifecycle, the /// single-consumer sample stream, the retry queue (mirrored to a durable @@ -84,7 +84,7 @@ public actor LocationIngestor { /// significant-change/Visits ingestion; tests inject a smaller cap. private let retryQueueCapacity: Int - private static let logger = WhereLog.channel(.locationIngestor) + private static let logger = WhereLog.location(LocationIngestorLog.self) init( store: any WhereStore, @@ -125,14 +125,14 @@ public actor LocationIngestor { guard !isMonitoring else { return } isMonitoring = true await locationSource.start() - Self.logger.info("GPS monitoring started") + Self.logger { .monitoringStarted } // Seed the in-memory queue from the durable backlog once, so samples that // failed to persist in a prior launch get retried now. if !didLoadDurableBacklog { didLoadDurableBacklog = true let restored = await outbox.load() if !restored.isEmpty { - Self.logger.info("Restored \(restored.count) sample(s) from durable retry backlog") + Self.logger { .restoredBacklog(count: restored.count) } } retryQueue = restored + retryQueue } @@ -164,7 +164,7 @@ public actor LocationIngestor { guard isMonitoring else { return } isMonitoring = false await locationSource.stop() - Self.logger.info("GPS monitoring stopped") + Self.logger { .monitoringStopped } } /// Stop ingestion and guarantee nothing else writes until the next @@ -198,7 +198,7 @@ public actor LocationIngestor { // Clear the durable mirror too; the store is about to be erased, so the // backlog must not re-drain into it on the next launch. await outbox.save([]) - Self.logger.info("GPS ingestion quiesced; retry backlog cleared") + Self.logger { .quiesced } } /// Whether GPS monitoring is currently active. Exposed so the view-model can @@ -255,7 +255,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.warning("Could not compute today's interval for foreground capture") + Self.logger { .todayIntervalUnavailable } return } let interval = DateInterval(start: startOfDay, end: endOfDay) @@ -265,9 +265,7 @@ 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.warning( - "Skipping foreground capture; could not read today's samples: \(error.localizedDescription)", - ) + Self.logger { .foregroundCaptureReadFailed(description: error.localizedDescription) } return } guard let sample = await locationSource.requestCurrentLocation() else { return } @@ -277,7 +275,7 @@ public actor LocationIngestor { // `quiesce()` either sees `acceptsSamples == false` here (we skip) or sees // the handle already set (it awaits us) — never neither. guard acceptsSamples else { return } - Self.logger.info("Captured one-shot foreground location for today") + 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. `quiesce()` awaits this handle independently. @@ -336,9 +334,12 @@ 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.error( - "Failed to persist GPS sample \(sample.id): \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "persist-error")]) { + .persistFailed( + sampleID: String(describing: sample.id), + description: error.localizedDescription, + ) + } enqueueForRetry(sample) await outbox.save(retryQueue) } @@ -346,9 +347,7 @@ public actor LocationIngestor { private func enqueueForRetry(_ sample: LocationSample) { if retryQueue.count >= retryQueueCapacity { - Self.logger.warning( - "Retry queue at capacity (\(retryQueueCapacity)); dropping oldest queued GPS sample", - ) + Self.logger { .retryQueueAtCapacity(capacity: retryQueueCapacity) } retryQueue.removeFirst() } retryQueue.append(sample) @@ -367,16 +366,22 @@ public actor LocationIngestor { try await store.perform { try await store.add(sample: sample) } persistedDays.insert(calendar.startOfDay(for: sample.timestamp)) } catch { - Self.logger.error( - "Retry still failing for GPS sample \(sample.id): \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "retry-error")]) { + .retryStillFailing( + sampleID: String(describing: sample.id), + description: error.localizedDescription, + ) + } enqueueForRetry(sample) } } if !persistedDays.isEmpty { - Self.logger.info( - "Drained retry backlog: persisted \(pending.count - retryQueue.count) sample(s) across \(persistedDays.count) day(s)", - ) + Self.logger { + .drainedBacklog( + sampleCount: pending.count - retryQueue.count, + dayCount: persistedDays.count, + ) + } } await outbox.save(retryQueue) return persistedDays diff --git a/Where/WhereCore/Sources/Location/LocationIngestorLog.swift b/Where/WhereCore/Sources/Location/LocationIngestorLog.swift new file mode 100644 index 00000000..7a2c1e89 --- /dev/null +++ b/Where/WhereCore/Sources/Location/LocationIngestorLog.swift @@ -0,0 +1,70 @@ +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 { + 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 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, .retryStillFailing: + .error + } + } + + 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 .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)" + } + } + + var externalID: String? { + switch self { + case let .persistFailed(sampleID, _), let .retryStillFailing(sampleID, _): + WhereStoreID.sample(sampleID) + case .monitoringStarted, .monitoringStopped, .restoredBacklog, .quiesced, + .todayIntervalUnavailable, .foregroundCaptureReadFailed, .capturedForegroundFix, + .retryQueueAtCapacity, .drainedBacklog: + nil + } + } +} diff --git a/Where/WhereCore/Sources/Location/LocationOutbox.swift b/Where/WhereCore/Sources/Location/LocationOutbox.swift index bc004466..c2d93c8b 100644 --- a/Where/WhereCore/Sources/Location/LocationOutbox.swift +++ b/Where/WhereCore/Sources/Location/LocationOutbox.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// A durable backlog of GPS samples that failed to persist, so a transient /// store outage (SwiftData/CloudKit) that *outlives the process* doesn't @@ -37,7 +37,7 @@ public struct NoOpLocationOutbox: LocationOutbox { public actor FileLocationOutbox: LocationOutbox { private let fileURL: URL - private static let logger = WhereLog.channel(.locationOutbox) + private static let logger = WhereLog.location(LocationOutboxLog.self) public init(fileURL: URL) { self.fileURL = fileURL @@ -55,9 +55,7 @@ public actor FileLocationOutbox: LocationOutbox { appropriateFor: nil, create: true, ) else { - logger.warning( - "No Application Support directory; using in-memory retry queue (backlog won't survive relaunch)", - ) + logger { .noApplicationSupport } return NoOpLocationOutbox() } return FileLocationOutbox(fileURL: directory.appending(path: "location-retry-outbox.json")) @@ -70,9 +68,9 @@ public actor FileLocationOutbox: LocationOutbox { } catch { // A decode failure means a corrupt or stale-format file; drop it // rather than crash-looping on every launch. - Self.logger.error( - "Dropping unreadable location retry backlog: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "read-error")]) { + .droppedUnreadableBacklog(description: error.localizedDescription) + } return [] } } @@ -86,9 +84,9 @@ public actor FileLocationOutbox: LocationOutbox { let data = try JSONEncoder().encode(samples) try data.write(to: fileURL, options: .atomic) } catch { - Self.logger.error( - "Failed to persist location retry backlog: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "persist-error")]) { + .persistBacklogFailed(description: error.localizedDescription) + } } } } diff --git a/Where/WhereCore/Sources/Location/LocationOutboxLog.swift b/Where/WhereCore/Sources/Location/LocationOutboxLog.swift new file mode 100644 index 00000000..c5aff773 --- /dev/null +++ b/Where/WhereCore/Sources/Location/LocationOutboxLog.swift @@ -0,0 +1,30 @@ +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 failures are surfaced as `.error`. +enum LocationOutboxLog: LogEvent { + case noApplicationSupport + case droppedUnreadableBacklog(description: String) + case persistBacklogFailed(description: String) + + static let eventName = "LocationOutbox" + + var level: LogLevel { + switch self { + case .noApplicationSupport: .warning + case .droppedUnreadableBacklog, .persistBacklogFailed: .error + } + } + + 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 .persistBacklogFailed(description): + "Failed to persist location retry backlog: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift index adab12dd..c6495142 100644 --- a/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStore.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit import SwiftData @@ -170,9 +170,9 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { /// this is the supported entry point for production wiring in the /// app/UI layer. public static func make(storage: Storage = .default) throws -> SwiftDataStore { - let container = try makeContainer(storage: storage) + let container = try logger.measure(.open) { try makeContainer(storage: storage) } if storage == .inMemory { - logger.info("Opened SwiftData store (mode: \(storage))") + logger { .openedInMemory(mode: 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 @@ -183,9 +183,13 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { let groupResolved = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) != nil let url = container.configurations.first?.url.path(percentEncoded: false) ?? "unknown" - logger.info( - "Opened SwiftData store (mode: \(storage), appGroupResolved: \(groupResolved), url: \(url))", - ) + logger { + .openedOnDisk( + mode: String(describing: storage), + appGroupResolved: groupResolved, + url: url, + ) + } } let store = SwiftDataStore(modelContainer: container) // On-disk stores live in a shared App Group container, so another process @@ -243,7 +247,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { ] } - private static let logger = WhereLog.channel(.swiftDataStore) + private static let logger = WhereLog.root(SwiftDataStoreLog.self) /// Fans "committed data changed" pings to `changes()` subscribers. Fired /// once per outermost `perform` commit (see `perform`). @@ -734,9 +738,12 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } } if !unknown.isEmpty { - Self.logger.warning( - "Ignored \(unknown.count) unknown tracked-region id(s): \(unknown.sorted().joined(separator: ", "))", - ) + Self.logger { + .ignoredUnknownTrackedRegions( + count: unknown.count, + ids: unknown.sorted().joined(separator: ", "), + ) + } } return resolved } @@ -774,9 +781,7 @@ public actor SwiftDataStore: WhereStore, EvidenceBlobStore { } private static func logFault(forCorrupt _: Record) { - logger.fault( - "Dropped corrupt SwiftData record of type \(String(describing: Record.self))", - ) + logger { .droppedCorruptRecord(type: String(describing: Record.self)) } } } diff --git a/Where/WhereCore/Sources/Persistence/SwiftDataStoreLog.swift b/Where/WhereCore/Sources/Persistence/SwiftDataStoreLog.swift new file mode 100644 index 00000000..f8b7a520 --- /dev/null +++ b/Where/WhereCore/Sources/Persistence/SwiftDataStoreLog.swift @@ -0,0 +1,45 @@ +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) { … }`). + enum SpanName: Hashable { + case open + } + + /// 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). + case ignoredUnknownTrackedRegions(count: Int, ids: String) + /// Dropped a record that failed to materialize into a domain value. + case droppedCorruptRecord(type: String) + + static let eventName = "SwiftDataStore" + + var level: LogLevel { + switch self { + case .openedInMemory, .openedOnDisk: .info + case .ignoredUnknownTrackedRegions: .warning + case .droppedCorruptRecord: .fault + } + } + + 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(count, ids): + "Ignored \(count) unknown tracked-region id(s): \(ids)" + case let .droppedCorruptRecord(type): + "Dropped corrupt SwiftData record of type \(type)" + } + } +} diff --git a/Where/WhereCore/Sources/Persistence/WhereStoreID.swift b/Where/WhereCore/Sources/Persistence/WhereStoreID.swift new file mode 100644 index 00000000..5a71be9a --- /dev/null +++ b/Where/WhereCore/Sources/Persistence/WhereStoreID.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Canonical `store://` identities for the Where store's object families, +/// vended as the string form used for Periscope `LogEvent.externalID`s. Keying +/// a log event's `externalID` on the same identity the store and backups use +/// lets the log tooling's inspect-by-object pull every event about one row +/// (a day, a year, an evidence item, a GPS sample) under one namespaced, +/// collision-free key — a bare `"2025"` year and an evidence UUID can't clash +/// the way flat strings in one index could. +/// +/// Mirrors ``DataIssueID``'s use of ``StoreURL`` (`store://issues/…`), which is +/// the typed exemplar: a value with a persisted identity conforms to +/// ``WhereStoreURLCodable`` and vends its own `storeURL`. These families don't +/// have (or don't yet need) a dedicated identity type, so their identity lives +/// here instead. Each is a single-identity object, so the identifying value +/// sits in the URL's `type` position (`store:///`); the +/// values are UUID strings, ISO `CalendarDay` strings, or years — all +/// path-safe. +/// +/// RegionKit deliberately does **not** use this: it sits below WhereCore (must +/// not import app code) and its regions are a bundled catalog, not store rows, +/// so `RegionAttributorLog` keeps its bare, already-stable catalog id as its +/// `externalID`. +public enum WhereStoreID { + /// `store://days/` for a logical calendar day (its `CalendarDay` + /// `description`, e.g. `2026-06-05`). + public static func day(_ isoDay: String) -> String { + StoreURL.url(collection: "days", type: isoDay, items: [:]).absoluteString + } + + /// `store://years/` for a whole year's data/scope (no `SDYear` row + /// backs it — it's the synthetic identity the year-scoped reads share). + public static func year(_ year: Int) -> String { + StoreURL.url(collection: "years", type: String(year), items: [:]).absoluteString + } + + /// `store://evidence/` for an `Evidence` row (its `id.uuidString`). + public static func evidence(_ id: String) -> String { + StoreURL.url(collection: "evidence", type: id, items: [:]).absoluteString + } + + /// `store://samples/` for a `LocationSample` (its `id.uuidString`). + public static func sample(_ id: String) -> String { + StoreURL.url(collection: "samples", type: id, items: [:]).absoluteString + } +} diff --git a/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift b/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift index 8060e475..cadc7009 100644 --- a/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift +++ b/Where/WhereCore/Sources/RecentActivity/FoundationModelSummaryGenerator.swift @@ -1,6 +1,5 @@ import Foundation import FoundationModels -import LogKit /// On-device `ActivitySummaryGenerating` backed by Apple's Foundation Models. /// Runs entirely on device (no network, no data leaves the phone), which suits @@ -8,8 +7,6 @@ import LogKit /// `ActivitySummaryUnavailableError` when the system model can't run so the UI /// can guide the user rather than showing a generic failure. public struct FoundationModelSummaryGenerator: ActivitySummaryGenerating { - private static let logger = WhereLog.channel(.recentActivitySummarizer) - public init() {} public func summarize(_ input: RecentActivityInput) async throws -> String { diff --git a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift index 68915f1e..59c23c36 100644 --- a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift +++ b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizer.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// One attributed reading in a recent-activity window: when the device was @@ -105,7 +105,7 @@ public actor RecentActivitySummarizer { private let now: @Sendable () -> Date private let segmentLimit: Int - private static let logger = WhereLog.channel(.recentActivitySummarizer) + private static let logger = WhereLog.recentActivity(RecentActivitySummarizerLog.self) init( store: any WhereStore, @@ -132,7 +132,7 @@ public actor RecentActivitySummarizer { let interval = window.interval(now: now(), calendar: calendar) let samples = try await store.samples(in: interval) guard !samples.isEmpty else { - Self.logger.info("Recent-activity summary skipped: no samples in window") + Self.logger { .skippedNoSamples } return .empty } let stops = samples.map { sample in @@ -143,12 +143,12 @@ public actor RecentActivitySummarizer { ) } let segments = Self.segments(from: stops, limit: segmentLimit) - let text = try await generator.summarize( - RecentActivityInput(interval: interval, segments: segments), - ) - Self.logger.info( - "Recent-activity summary generated from \(segments.count) segment(s)", - ) + let text = try await Self.logger.measure(.generate) { + try await generator.summarize( + RecentActivityInput(interval: interval, segments: segments), + ) + } + Self.logger { .generated(segmentCount: segments.count) } return .summary(text) } diff --git a/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizerLog.swift b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizerLog.swift new file mode 100644 index 00000000..086d6108 --- /dev/null +++ b/Where/WhereCore/Sources/RecentActivity/RecentActivitySummarizerLog.swift @@ -0,0 +1,25 @@ +import PeriscopeCore + +/// Structured events for `RecentActivitySummarizer`, the on-device look-back +/// summary. Both outcomes are `.info` — a skip (no samples) and a successful +/// generation. +enum RecentActivitySummarizerLog: LogEvent { + /// Names the summarizer's timed spans. + enum SpanName: Hashable { + case generate + } + + case skippedNoSamples + case generated(segmentCount: Int) + + static let eventName = "RecentActivitySummarizer" + + var message: String { + switch self { + case .skippedNoSamples: + "Recent-activity summary skipped: no samples in window" + case let .generated(segmentCount): + "Recent-activity summary generated from \(segmentCount) segment(s)" + } + } +} diff --git a/Where/WhereCore/Sources/RegionAttribution.swift b/Where/WhereCore/Sources/RegionAttribution.swift index 0b4a6b6c..8cfb8d06 100644 --- a/Where/WhereCore/Sources/RegionAttribution.swift +++ b/Where/WhereCore/Sources/RegionAttribution.swift @@ -1,5 +1,6 @@ import Foundation import os +import PeriscopeCore import RegionKit /// A live, swappable `RegionAttributing` derived from the store's tracked @@ -17,7 +18,7 @@ final class RegionAttribution: RegionAttributing { var trackedIDs: Set } - private static let logger = WhereLog.channel(.regionAttribution) + private static let logger = WhereLog.root(RegionAttributionLog.self) private let store: any WhereStore private let state: OSAllocatedUnfairLock @@ -76,7 +77,7 @@ 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.warning("Failed to read tracked regions for attributor rebuild: \(error)") + Self.logger { .trackedRegionsReadFailed(description: String(describing: error)) } return } let ids = Set(tracked.map(\.rawValue)) diff --git a/Where/WhereCore/Sources/RegionAttributionLog.swift b/Where/WhereCore/Sources/RegionAttributionLog.swift new file mode 100644 index 00000000..eddc4309 --- /dev/null +++ b/Where/WhereCore/Sources/RegionAttributionLog.swift @@ -0,0 +1,21 @@ +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 { + 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)" + } + } +} diff --git a/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift b/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift index 51aaf24b..05761d2d 100644 --- a/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift +++ b/Where/WhereCore/Sources/Reminders/DailySummaryReconciler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns the daily summary recap intent and the reconciliation that recomputes @@ -22,7 +22,7 @@ public actor DailySummaryReconciler { /// body stays short. static let defaultRegionLimit = 3 - private static let logger = WhereLog.channel(.dailySummaryReconciler) + private static let logger = WhereLog.reminders(DailySummaryReconcilerLog.self) init( scheduler: any DailySummaryScheduling, @@ -68,9 +68,9 @@ public actor DailySummaryReconciler { body: summaryBody(for: report), ) } catch { - Self.logger.error( - "Failed to reconcile daily summary: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "reconcile-error")]) { + .reconcileFailed(description: error.localizedDescription) + } } } diff --git a/Where/WhereCore/Sources/Reminders/DailySummaryReconcilerLog.swift b/Where/WhereCore/Sources/Reminders/DailySummaryReconcilerLog.swift new file mode 100644 index 00000000..3811cf10 --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/DailySummaryReconcilerLog.swift @@ -0,0 +1,19 @@ +import PeriscopeCore + +/// Structured events for `DailySummaryReconciler`. +enum DailySummaryReconcilerLog: LogEvent { + case reconcileFailed(description: String) + + static let eventName = "DailySummaryReconciler" + + var level: LogLevel { + .error + } + + var message: String { + switch self { + case let .reconcileFailed(description): + "Failed to reconcile daily summary: \(description)" + } + } +} diff --git a/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift b/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift index ad1edda5..6be10731 100644 --- a/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/DailySummaryScheduler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import UserNotifications /// Schedules the single repeating local notification that gives the user a @@ -56,7 +56,7 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling /// One repeating notification, so a single stable identifier is enough. private static let identifier = "com.stuff.where.daily-summary" - private static let logger = WhereLog.channel(.dailySummaryScheduler) + private static let logger = WhereLog.reminders(DailySummarySchedulerLog.self) public init(center: UNUserNotificationCenter = .current()) { self.center = UNUserNotificationCenterAdapter(center: center) @@ -70,9 +70,7 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling do { return try await center.requestAuthorization(options: [.alert, .sound, .badge]) } catch { - Self.logger.error( - "Notification authorization request failed: \(error.localizedDescription)", - ) + Self.logger { .authorizationRequestFailed(description: error.localizedDescription) } return false } } @@ -98,15 +96,11 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling case .authorized, .provisional, .ephemeral: break case .notDetermined, .denied: - Self.logger.warning( - "Daily summary enabled but notification authorization not granted; summary disabled", - ) + Self.logger { .authorizationNotGranted } await removeAllOwned() return @unknown default: - Self.logger.warning( - "Daily summary enabled but notification authorization status is unknown; summary disabled", - ) + Self.logger { .authorizationUnknown } await removeAllOwned() return } @@ -148,13 +142,13 @@ public final class UserNotificationDailySummaryScheduler: DailySummaryScheduling ) do { try await center.add(request) - Self.logger.info( - "Scheduled daily summary at \(String(format: "%02d:%02d", time.hour, time.minute))", - ) + Self.logger { + .scheduled(time: String(format: "%02d:%02d", time.hour, time.minute)) + } } catch { - Self.logger.error( - "Failed to schedule daily summary: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "schedule-error")]) { + .scheduleFailed(description: error.localizedDescription) + } } } diff --git a/Where/WhereCore/Sources/Reminders/DailySummarySchedulerLog.swift b/Where/WhereCore/Sources/Reminders/DailySummarySchedulerLog.swift new file mode 100644 index 00000000..c866de5e --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/DailySummarySchedulerLog.swift @@ -0,0 +1,39 @@ +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 + } + } + + 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)" + } + } +} diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift index 6c07771c..b16513f3 100644 --- a/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertReconciler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore /// Owns the "you have issues to resolve" notification intent and the /// reconciliation that keeps that notification in sync with the current year's @@ -21,7 +21,7 @@ public actor DataIssueAlertReconciler { var driftThresholdMeters = Double(DriftThreshold.default.rawValue) } - private static let logger = WhereLog.channel(.dataIssueAlertReconciler) + private static let logger = WhereLog.reminders(DataIssueAlertReconcilerLog.self) init( scheduler: any DataIssueAlertScheduling, @@ -72,9 +72,9 @@ public actor DataIssueAlertReconciler { body: Self.body(count: count), ) } catch { - Self.logger.error( - "Failed to reconcile issue alerts: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "reconcile-error")]) { + .reconcileFailed(description: error.localizedDescription) + } } } diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertReconcilerLog.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertReconcilerLog.swift new file mode 100644 index 00000000..02d2818e --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertReconcilerLog.swift @@ -0,0 +1,19 @@ +import PeriscopeCore + +/// Structured events for `DataIssueAlertReconciler`. +enum DataIssueAlertReconcilerLog: LogEvent { + 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)" + } + } +} diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift index 80fb41c3..7ddabc71 100644 --- a/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertScheduler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import UserNotifications /// Schedules the single local notification that nudges the user to open the @@ -65,7 +65,7 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu /// One repeating notification, so a single stable identifier is enough. private static let identifier = "com.stuff.where.data-issues" - private static let logger = WhereLog.channel(.dataIssueAlertScheduler) + private static let logger = WhereLog.reminders(DataIssueAlertSchedulerLog.self) public init(center: UNUserNotificationCenter = .current()) { self.center = UNUserNotificationCenterAdapter(center: center) @@ -79,9 +79,7 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu do { return try await center.requestAuthorization(options: [.alert, .sound, .badge]) } catch { - Self.logger.error( - "Notification authorization request failed: \(error.localizedDescription)", - ) + Self.logger { .authorizationRequestFailed(description: error.localizedDescription) } return false } } @@ -113,15 +111,11 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu case .authorized, .provisional, .ephemeral: break case .notDetermined, .denied: - Self.logger.warning( - "Issue alerts enabled but notification authorization not granted; alert disabled", - ) + Self.logger { .authorizationNotGranted } await removeAllOwned() return @unknown default: - Self.logger.warning( - "Issue alerts enabled but notification authorization status is unknown; alert disabled", - ) + Self.logger { .authorizationUnknown } await removeAllOwned() return } @@ -163,13 +157,13 @@ public final class UserNotificationDataIssueAlertScheduler: DataIssueAlertSchedu ) do { try await center.add(request) - Self.logger.info( - "Scheduled issue alert at \(String(format: "%02d:%02d", time.hour, time.minute))", - ) + Self.logger { + .scheduled(time: String(format: "%02d:%02d", time.hour, time.minute)) + } } catch { - Self.logger.error( - "Failed to schedule issue alert: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "schedule-error")]) { + .scheduleFailed(description: error.localizedDescription) + } } } diff --git a/Where/WhereCore/Sources/Reminders/DataIssueAlertSchedulerLog.swift b/Where/WhereCore/Sources/Reminders/DataIssueAlertSchedulerLog.swift new file mode 100644 index 00000000..2cb6e4d6 --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/DataIssueAlertSchedulerLog.swift @@ -0,0 +1,39 @@ +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 + } + } + + 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)" + } + } +} diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift index 27c334c3..d6aae07d 100644 --- a/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderScheduler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import UserNotifications /// Time of day (in the user's calendar) at which the daily "log before the day @@ -92,7 +92,7 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, private let calendar: Calendar private static let identifierPrefix = "com.stuff.where.logging-reminder" - private static let logger = WhereLog.channel(.loggingReminderScheduler) + private static let logger = WhereLog.reminders(LoggingReminderSchedulerLog.self) public init( center: UNUserNotificationCenter = .current(), @@ -118,9 +118,7 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, do { return try await center.requestAuthorization(options: [.alert, .sound, .badge]) } catch { - Self.logger.error( - "Notification authorization request failed: \(error.localizedDescription)", - ) + Self.logger { .authorizationRequestFailed(description: error.localizedDescription) } return false } } @@ -155,16 +153,12 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, case .authorized, .provisional, .ephemeral: break case .notDetermined, .denied: - Self.logger.warning( - "Logging reminders enabled but notification authorization not granted; reminders disabled", - ) + Self.logger { .authorizationNotGranted } await removeAllOwnedReminders() await setBadge(0) return @unknown default: - Self.logger.warning( - "Logging reminders enabled but notification authorization status is unknown; reminders disabled", - ) + Self.logger { .authorizationUnknown } await removeAllOwnedReminders() await setBadge(0) return @@ -213,9 +207,13 @@ 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.info( - "Reconciled logging reminders (scheduled \(toSchedule.count), removed \(pendingToRemove.count); badge: \(badgeCount))", - ) + Self.logger { + .reconciled( + scheduled: toSchedule.count, + removed: pendingToRemove.count, + badge: badgeCount, + ) + } } } @@ -238,9 +236,9 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, do { try await center.add(request) } catch { - Self.logger.error( - "Failed to schedule reminder \(identifier): \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "schedule-error")]) { + .scheduleFailed(identifier: identifier, description: error.localizedDescription) + } } } @@ -272,9 +270,7 @@ public final class UserNotificationReminderScheduler: LoggingReminderScheduling, do { try await center.setBadgeCount(max(0, count)) } catch { - Self.logger.error( - "Failed to set badge count: \(error.localizedDescription)", - ) + Self.logger { .badgeUpdateFailed(description: error.localizedDescription) } } } diff --git a/Where/WhereCore/Sources/Reminders/LoggingReminderSchedulerLog.swift b/Where/WhereCore/Sources/Reminders/LoggingReminderSchedulerLog.swift new file mode 100644 index 00000000..7562806f --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/LoggingReminderSchedulerLog.swift @@ -0,0 +1,42 @@ +import PeriscopeCore + +/// Structured events for `LoggingReminderScheduler` — authorization outcomes and +/// the reconcile of scheduled/removed reminders + badge. +enum LoggingReminderSchedulerLog: LogEvent { + 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) + + static let eventName = "LoggingReminderScheduler" + + var level: LogLevel { + switch self { + case .authorizationRequestFailed, .scheduleFailed, .badgeUpdateFailed: + .error + case .authorizationNotGranted, .authorizationUnknown: + .warning + case .reconciled: + .info + } + } + + 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)" + } + } +} diff --git a/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift b/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift index ef33c7a9..fd812724 100644 --- a/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift +++ b/Where/WhereCore/Sources/Reminders/ReminderReconciler.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns the daily "log before the day ends" reminder intent and the @@ -43,7 +43,7 @@ public actor ReminderReconciler { /// this many days. static let defaultWindowDays = 6 - private static let logger = WhereLog.channel(.reminderReconciler) + private static let logger = WhereLog.reminders(ReminderReconcilerLog.self) init( scheduler: any LoggingReminderScheduling, @@ -166,9 +166,9 @@ public actor ReminderReconciler { ? today : nil } catch { - Self.logger.error( - "Failed to reconcile logging reminders: \(error.localizedDescription)", - ) + Self.logger(attachments: [.error(error, name: "reconcile-error")]) { + .reconcileFailed(description: error.localizedDescription) + } } } @@ -191,9 +191,7 @@ public actor ReminderReconciler { driftThresholdMeters: config.driftThresholdMeters, ) } catch { - Self.logger.warning( - "Failed to scan data issues for badge: \(error.localizedDescription)", - ) + Self.logger { .badgeScanFailed(description: error.localizedDescription) } return 0 } } diff --git a/Where/WhereCore/Sources/Reminders/ReminderReconcilerLog.swift b/Where/WhereCore/Sources/Reminders/ReminderReconcilerLog.swift new file mode 100644 index 00000000..91d2ba64 --- /dev/null +++ b/Where/WhereCore/Sources/Reminders/ReminderReconcilerLog.swift @@ -0,0 +1,27 @@ +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 { + case reconcileFailed(description: String) + case badgeScanFailed(description: String) + + static let eventName = "ReminderReconciler" + + var level: LogLevel { + switch self { + case .reconcileFailed: .error + case .badgeScanFailed: .warning + } + } + + 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)" + } + } +} diff --git a/Where/WhereCore/Sources/WhereLog.swift b/Where/WhereCore/Sources/WhereLog.swift index 215abd34..9c581fea 100644 --- a/Where/WhereCore/Sources/WhereLog.swift +++ b/Where/WhereCore/Sources/WhereLog.swift @@ -1,51 +1,66 @@ -import LogKit +import PeriscopeCore -/// Central logging facade for the Where app and its modules. Every logger site -/// routes through ``channel(_:)`` so messages reach both Apple unified logging -/// (Console.app, subsystem `com.stuff.where`) and the shared in-memory -/// ``LogStore`` the in-app debug log viewer reads (DEBUG builds only). +/// 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 { + "" + } +} + +/// Central logging facade for the Where app and its modules. /// -/// Categories are a typed enum rather than raw strings so a new logger can't -/// silently typo into an untracked category; the raw values match the -/// historical `os.Logger` category strings exactly, keeping Console.app filters -/// working unchanged. +/// Every logger derives from one `"Where"` root `Log` and emits into the +/// process-wide Periscope system (``Periscope/shared``). Collaborators that +/// belong together sit under a shared group scope (``location``, ``reminders``, +/// ``backup``, ``widgets``, ``session``, ``evidence``, ``recentActivity``); +/// everything else hangs directly off ``root``. A collaborator derives its own +/// typed leaf — `WhereLog.location(LocationIngestorLog.self)` — so its events +/// carry a structured payload the log viewer can decode, and the loggers form a +/// hierarchy the viewer can filter or inspect by subtree. +/// +/// Upper layers (WhereUI, the extensions) derive their own leaves from the same +/// group/root loggers with event types they define locally, so `WhereLog` never +/// needs to know their event shapes. public enum WhereLog { - /// The subsystem every Where log shares. - public static let subsystem = "com.stuff.where" + // MARK: Periscope log tree + + /// The `"Where"` root every app logger descends from. + public static let root = Log(system: .shared) - /// Process-wide buffer feeding the in-app log viewer. Logging is inherently - /// process-global, so a single shared store is the natural home. The widget - /// extension runs in its own process and therefore has a distinct instance. - public static let store = LogStore() + /// GPS ingestion collaborators (`LocationIngestor`, `LocationOutbox`). + public static let location = group(.location) + /// Notification schedulers/reconcilers (logging reminders, daily summary, + /// data-issue alerts). + public static let reminders = group(.reminders) + /// Backup export/import (`BackupCoordinator`, `BackupService`). + public static let backup = group(.backup) + /// In-app widget snapshot publishing (`WidgetSnapshotPublisher`, + /// `WidgetTimelineRefresher`). + public static let widgets = group(.widgets) + /// The always-on session coordinator and its scope-tiered view models. + public static let session = group(.session) + /// Evidence capture/list/detail view models. + public static let evidence = group(.evidence) + /// On-device recent-activity summarization. + public static let recentActivity = group(.recentActivity) - public enum Category: String, CaseIterable, Sendable { - case appDelegate = "AppDelegate" - case backupService = "BackupService" - case dailySummaryReconciler = "DailySummaryReconciler" - case dailySummaryScheduler = "DailySummaryScheduler" - case dataIssueAlertReconciler = "DataIssueAlertReconciler" - case dataIssueAlertScheduler = "DataIssueAlertScheduler" - case dayJournal = "DayJournal" - case evidence = "Evidence" - case launch = "WhereLaunch" - case locationIngestor = "LocationIngestor" - case locationOutbox = "LocationOutbox" - case loggingReminderScheduler = "LoggingReminderScheduler" - case model = "WhereModel" - case recentActivitySummarizer = "RecentActivitySummarizer" - case regionAttribution = "RegionAttribution" - case reminderReconciler = "ReminderReconciler" - case session = "WhereSession" - case shareExtension = "WhereShareExtension" - case swiftDataStore = "SwiftDataStore" - case whereIntents = "WhereIntents" - case widgetRefresher = "WidgetRefresher" - case widgetSnapshotPublisher = "WidgetSnapshotPublisher" - case whereWidgets = "WhereWidgets" + private static func group(_ area: Area) -> Log { + root(for: area) } - /// A logging channel for `category`, wired to the shared buffer. - public static func channel(_ category: Category) -> LogChannel { - LogChannel(subsystem: subsystem, category: category.rawValue, store: store) + /// The intermediate grouping scopes under ``root``. A plain `Hashable` + /// token whose case name becomes the scope name (`location`, `reminders`, …). + enum Area: Hashable { + case location + case reminders + case backup + case widgets + case session + case evidence + case recentActivity } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift index be06b5a3..59ddacde 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisher.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import RegionKit /// Owns the published widget snapshot and the policy for when to rebuild it. @@ -37,7 +37,7 @@ public actor WidgetSnapshotPublisher { /// the store and reload widgets. static let defaultMaxAge: TimeInterval = 3 * 60 * 60 - private static let logger = WhereLog.channel(.widgetSnapshotPublisher) + private static let logger = WhereLog.widgets(WidgetSnapshotPublisherLog.self) init( widgetReader: WidgetDataReader, @@ -80,13 +80,14 @@ public actor WidgetSnapshotPublisher { let snapshot = try await widgetReader.snapshot(asOf: now()) await widgetRefresher.publish(snapshot) lastPublished = PublishedWidgetSnapshot(snapshot: snapshot, publishedAt: now()) - Self.logger.info( - "Published widget snapshot for \(dayLogLabel(snapshot.day)) (\(snapshot.dayRegions.count) region(s))", - ) + Self.logger { + .published( + day: dayLogLabel(snapshot.day), + regionCount: snapshot.dayRegions.count, + ) + } } catch { - Self.logger.error( - "Failed to build widget snapshot: \(error.localizedDescription)", - ) + Self.logger { .buildFailed(description: error.localizedDescription) } } } diff --git a/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisherLog.swift b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisherLog.swift new file mode 100644 index 00000000..35e40f9d --- /dev/null +++ b/Where/WhereCore/Sources/Widgets/WidgetSnapshotPublisherLog.swift @@ -0,0 +1,33 @@ +import PeriscopeCore + +/// Structured events for `WidgetSnapshotPublisher`. A publish records the day +/// and region count; a build failure surfaces as `.error`. +enum WidgetSnapshotPublisherLog: LogEvent { + case published(day: String, regionCount: Int) + case buildFailed(description: String) + + static let eventName = "WidgetSnapshotPublisher" + + var level: LogLevel { + switch self { + case .published: .info + case .buildFailed: .error + } + } + + 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? { + switch self { + case let .published(day, _): WhereStoreID.day(day) + case .buildFailed: nil + } + } +} diff --git a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift index 1f1d432c..6e854221 100644 --- a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift +++ b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresher.swift @@ -1,4 +1,4 @@ -import LogKit +import PeriscopeCore import WidgetKit /// Publishes a freshly-computed `WidgetSnapshot` for the widget extension @@ -26,18 +26,16 @@ public struct NoopWidgetTimelineRefresher: WidgetTimelineRefreshing { /// every kind rather than per-kind because all Where widgets render from the /// same snapshot, so any committed change can affect all of them. public struct WidgetCenterTimelineRefresher: WidgetTimelineRefreshing { - private static let logger = WhereLog.channel(.widgetRefresher) + private static let logger = WhereLog.widgets(WidgetTimelineRefresherLog.self) public init() {} public func publish(_ snapshot: WidgetSnapshot) async { do { try WidgetSnapshotStore.shared().write(snapshot) - Self.logger.info("Wrote widget snapshot to App Group; reloading timelines") + Self.logger { .wroteSnapshot } } catch { - Self.logger.error( - "Failed to publish widget snapshot: \(error.localizedDescription)", - ) + Self.logger { .publishFailed(description: error.localizedDescription) } } WidgetCenter.shared.reloadAllTimelines() } diff --git a/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresherLog.swift b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresherLog.swift new file mode 100644 index 00000000..0ff0f2a0 --- /dev/null +++ b/Where/WhereCore/Sources/Widgets/WidgetTimelineRefresherLog.swift @@ -0,0 +1,26 @@ +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) + + 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)" + } + } +} diff --git a/Where/WhereCore/Tests/WhereLogTests.swift b/Where/WhereCore/Tests/WhereLogTests.swift index 7a3e3e9e..085bb5ea 100644 --- a/Where/WhereCore/Tests/WhereLogTests.swift +++ b/Where/WhereCore/Tests/WhereLogTests.swift @@ -1,38 +1,88 @@ -import LogKit +import PeriscopeCore import Testing @testable import WhereCore -@Test -func channelUsesSharedSubsystemAndCategory() throws { - let store = LogStore() - let channel = LogChannel( - subsystem: WhereLog.subsystem, - category: WhereLog.Category.locationIngestor.rawValue, - store: store, - ) - channel.error("boom") - - let entry = try #require(store.snapshot().first) - #expect(entry.subsystem == "com.stuff.where") - #expect(entry.category == "LocationIngestor") -} +// MARK: - Periscope log tree + +/// Covers `WhereLog`'s Periscope scope tree: the `"Where"` root, the grouping +/// scopes, and that collaborator leaves descend from the right parent. +struct WhereLogTreeTests { + @Test func rootScopeIsWhere() { + #expect(WhereLog.root.primaryScope.name == "Where") + } + + @Test func groupScopesDescendFromTheRoot() { + let rootID = WhereLog.root.primaryScope.id + #expect(WhereLog.location.primaryScope.name == "location") + #expect(WhereLog.location.primaryScope.parentID == rootID) + #expect(WhereLog.reminders.primaryScope.name == "reminders") + #expect(WhereLog.reminders.primaryScope.parentID == rootID) + #expect(WhereLog.backup.primaryScope.name == "backup") + #expect(WhereLog.widgets.primaryScope.name == "widgets") + #expect(WhereLog.session.primaryScope.name == "session") + #expect(WhereLog.evidence.primaryScope.name == "evidence") + #expect(WhereLog.recentActivity.primaryScope.name == "recentActivity") + } -@Test -func categoryRawValuesMatchTypeNames() { - // Raw values must stay equal to the historical os.Logger category strings - // so Console.app filters keep working after the migration. - #expect(WhereLog.Category.swiftDataStore.rawValue == "SwiftDataStore") - #expect(WhereLog.Category.widgetRefresher.rawValue == "WidgetRefresher") - #expect(WhereLog.Category.recentActivitySummarizer.rawValue == "RecentActivitySummarizer") - #expect(WhereLog.Category.shareExtension.rawValue == "WhereShareExtension") - #expect(WhereLog.Category.whereIntents.rawValue == "WhereIntents") - #expect(WhereLog.Category.regionAttribution.rawValue == "RegionAttribution") - #expect(WhereLog.Category.allCases.count == 23) + @Test func collaboratorLeavesDescendFromTheirGroup() { + let ingestor = WhereLog.location(LocationIngestorLog.self) + #expect(ingestor.primaryScope.name == "LocationIngestor") + #expect(ingestor.primaryScope.parentID == WhereLog.location.primaryScope.id) + } + + @Test func directLeavesDescendFromTheRoot() { + let store = WhereLog.root(SwiftDataStoreLog.self) + #expect(store.primaryScope.name == "SwiftDataStore") + #expect(store.primaryScope.parentID == WhereLog.root.primaryScope.id) + } } -@Test -func channelFactoryRecordsIntoSharedStore() { - let before = WhereLog.store.snapshot().count - WhereLog.channel(.backupService).info("wrote backup") - #expect(WhereLog.store.snapshot().count == before + 1) +// MARK: - Event rendering + +/// Spot-checks representative collaborator events: rendered message, severity, +/// stable persisted name, and `externalID` correlation. +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) + } + + @Test func swiftDataStoreCorruptionIsAFault() { + #expect(SwiftDataStoreLog.droppedCorruptRecord(type: "SDEvidence").level == .fault) + #expect(SwiftDataStoreLog.openedInMemory(mode: "inMemory").level == .info) + #expect( + SwiftDataStoreLog.ignoredUnknownTrackedRegions(count: 1, ids: "zz").level == .warning, + ) + } + + @Test func locationIngestorTracesSampleFailuresByID() { + #expect( + LocationIngestorLog.persistFailed(sampleID: "abc", description: "x") + .externalID == WhereStoreID.sample("abc"), + ) + #expect(LocationIngestorLog.persistFailed(sampleID: "abc", description: "x") + .level == .error) + #expect(LocationIngestorLog.monitoringStarted.externalID == nil) + } + + @Test func schedulerAuthorizationOutcomesUseHonestLevels() { + #expect(LoggingReminderSchedulerLog.authorizationNotGranted.level == .warning) + #expect( + LoggingReminderSchedulerLog.authorizationRequestFailed(description: "x") + .level == .error, + ) + #expect(LoggingReminderSchedulerLog.reconciled(scheduled: 1, removed: 0, badge: 2) + .level == .info) + } + + @Test func widgetRefresherKeepsItsHistoricalEventName() { + #expect(WidgetTimelineRefresherLog.eventName == "WidgetRefresher") + } } diff --git a/Where/WhereCore/Tests/WhereStoreIDTests.swift b/Where/WhereCore/Tests/WhereStoreIDTests.swift new file mode 100644 index 00000000..cffd8652 --- /dev/null +++ b/Where/WhereCore/Tests/WhereStoreIDTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing +@testable import WhereCore + +/// Pins the exact `store://` identity strings ``WhereStoreID`` vends for log +/// `externalID`s, so the on-disk correlation key can't drift silently. +struct WhereStoreIDTests { + @Test func dayIsCollectionAndISODay() { + #expect(WhereStoreID.day("2026-06-05") == "store://days/2026-06-05") + } + + @Test func yearIsCollectionAndYear() { + #expect(WhereStoreID.year(2025) == "store://years/2025") + } + + @Test func evidenceIsCollectionAndID() { + let id = "5E1645CB-696E-4441-8154-5977E28251B8" + #expect(WhereStoreID.evidence(id) == "store://evidence/\(id)") + } + + @Test func sampleIsCollectionAndID() { + let id = "B12614F7-46AD-41CF-B3DE-6BD3C0C4822B" + #expect(WhereStoreID.sample(id) == "store://samples/\(id)") + } + + /// The identities are well-formed `store://` URLs `StoreURL` can parse back, + /// so the same scheme the store/backups use round-trips. + @Test func identitiesParseAsStoreURLs() throws { + let url = try #require(URL(string: WhereStoreID.day("2026-06-05"))) + let parts = try #require(StoreURL.parts(of: url)) + #expect(parts.collection == "days") + #expect(parts.type == "2026-06-05") + #expect(parts.items.isEmpty) + } +} diff --git a/Where/WhereIntents/README.md b/Where/WhereIntents/README.md index e857c5e1..fd827f63 100644 --- a/Where/WhereIntents/README.md +++ b/Where/WhereIntents/README.md @@ -85,7 +85,7 @@ its day-count query. `WhereIntents` is an SPM library target in [`Package.swift`](../../Package.swift) (`Where/WhereIntents/Sources`) depending on **WhereCore**, **RegionKit**, -**LogKit**, and **WhereUI** (for the snippet cards). The **Where** app links it; +**PeriscopeCore**, and **WhereUI** (for the snippet cards). The **Where** app links it; the hosted `WhereIntentsTests` bundle is wired in [`Project.swift`](../../Project.swift) via the `unitTests` helper. diff --git a/Where/WhereIntents/Sources/RecentActivitySummaryIntent.swift b/Where/WhereIntents/Sources/RecentActivitySummaryIntent.swift index e5750f8a..ee1db732 100644 --- a/Where/WhereIntents/Sources/RecentActivitySummaryIntent.swift +++ b/Where/WhereIntents/Sources/RecentActivitySummaryIntent.swift @@ -1,5 +1,6 @@ import AppIntents import Foundation +import PeriscopeCore import WhereCore /// "Summarize where I've been this week." — the on-device Foundation Models @@ -22,7 +23,7 @@ public struct RecentActivitySummaryIntent: AppIntent { self.window = window } - private static let logger = WhereLog.channel(.whereIntents) + private static let logger = WhereLog.root(WhereIntentsLog.self) public func perform() async throws -> some IntentResult & ProvidesDialog { let services = try await IntentServices.shared.current() @@ -37,9 +38,7 @@ public struct RecentActivitySummaryIntent: AppIntent { } catch let error as ActivitySummaryUnavailableError { // User-recoverable (Apple Intelligence off, model warming): surface // the reason in the dialog and log it — never a silent empty result. - Self.logger.warning( - "Recent-activity summary unavailable: \(String(describing: error.reason))", - ) + Self.logger { .recentActivityUnavailable(reason: String(describing: error.reason)) } return .result( dialog: IntentDialog("\(IntentStrings.recentActivityUnavailable(error.reason))"), ) diff --git a/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift b/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift index bca81495..c072096f 100644 --- a/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift +++ b/Where/WhereIntents/Sources/RegionEntity+Spotlight.swift @@ -1,5 +1,6 @@ import AppIntents import CoreSpotlight +import PeriscopeCore import RegionKit import WhereCore @@ -13,17 +14,19 @@ extension RegionEntity: IndexedEntity {} /// (see the app's `AppDelegate`); indexing a handful of items is cheap and /// idempotent, and re-runs pick up any change to the tracked set. public enum RegionSpotlightIndexer { - private static let logger = WhereLog.channel(.whereIntents) + private static let logger = WhereLog.root(WhereIntentsLog.self) public static func indexRegions() async { do { let entities = try await RegionEntity.tracked(from: IntentServices.shared.current()) try await CSSearchableIndex.default().indexAppEntities(entities) - logger.info("Indexed \(entities.count) region(s) for Spotlight") + logger { .spotlightIndexed(regionCount: 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.warning("Failed to index regions for Spotlight: \(error)") + logger(attachments: [.error(error, name: "index-error")]) { + .spotlightIndexFailed(description: String(describing: error)) + } } } } diff --git a/Where/WhereIntents/Sources/WhereIntentsLog.swift b/Where/WhereIntents/Sources/WhereIntentsLog.swift new file mode 100644 index 00000000..a5e00fe9 --- /dev/null +++ b/Where/WhereIntents/Sources/WhereIntentsLog.swift @@ -0,0 +1,38 @@ +import PeriscopeCore + +/// Structured events for the Where App Intents surface — Spotlight indexing of +/// the tracked regions and the recent-activity summary intent. These run in the +/// app/intents process, which keeps `Periscope.shared` OSLog-only (no +/// persistent store of its own). +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) + /// The recent-activity summary couldn't be produced (e.g. Apple + /// Intelligence is off or the model is warming). + case recentActivityUnavailable(reason: String) + + static let eventName = "WhereIntents" + + var level: LogLevel { + switch self { + case .spotlightIndexed: + .info + case .spotlightIndexFailed, .recentActivityUnavailable: + .warning + } + } + + 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)" + case let .recentActivityUnavailable(reason): + "Recent-activity summary unavailable: \(reason)" + } + } +} diff --git a/Where/WhereShareExtension/AGENTS.md b/Where/WhereShareExtension/AGENTS.md index 798b7c16..8222e977 100644 --- a/Where/WhereShareExtension/AGENTS.md +++ b/Where/WhereShareExtension/AGENTS.md @@ -11,8 +11,10 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Tuist app-extension target** ([`Project.swift`](../../Project.swift), bundle ID `com.stuff.where.share`), depending on **WhereCore**, **WhereUI**, - and **LogKit**. Embedded by the **Where** app; shares the - `group.com.stuff.where` App Group entitlement. + and **PeriscopeCore**. Embedded by the **Where** app; shares the + `group.com.stuff.where` App Group entitlement. Logs via the `WhereLog` facade + (typed `ShareExtensionLog` events); as a separate process its + `Periscope.shared` is OSLog-only (no store). - Presentation reuses WhereUI's public `EvidenceKind.symbolName`/`displayName`; only extension chrome lives in this target's `ShareStrings` + catalog. - No test bundle; the write path is covered from **WhereCore** store tests and diff --git a/Where/WhereShareExtension/README.md b/Where/WhereShareExtension/README.md index 6333923f..e151b72c 100644 --- a/Where/WhereShareExtension/README.md +++ b/Where/WhereShareExtension/README.md @@ -51,7 +51,7 @@ CloudKit container picks the write up from the shared store's history. `WhereShareExtension` is a Tuist app-extension target in [`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.share`), -depending on **WhereCore**, **WhereUI**, and **LogKit**. The main **Where** app +depending on **WhereCore**, **WhereUI**, and **PeriscopeCore**. The main **Where** app embeds the extension and shares the `group.com.stuff.where` App Group entitlement so both processes open the same SwiftData store. diff --git a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift index fa6ed38b..67c9657b 100644 --- a/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift +++ b/Where/WhereShareExtension/Sources/ShareEvidenceModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model for the share-extension compose sheet. Pulls the shared @@ -42,7 +42,7 @@ final class ShareEvidenceModel { /// Storage the extension opens; injectable so a future test can point it at /// an in-memory store instead of the shared container. private let storage: SwiftDataStore.Storage - private static let logger = WhereLog.channel(.shareExtension) + private static let logger = WhereLog.root(ShareExtensionLog.self) init( items: [NSExtensionItem], @@ -99,11 +99,13 @@ final class ShareEvidenceModel { try await store.write(evidence: item.evidence, blob: item.blob) } } - Self.logger.info("Saved \(pending.count) shared evidence record(s)") + Self.logger { .saved(evidenceCount: pending.count) } return true } catch { phase = .failed(error.localizedDescription) - Self.logger.error("Failed to save shared evidence: \(error.localizedDescription)") + Self.logger(attachments: [.error(error, name: "save-error")]) { + .saveFailed(description: error.localizedDescription) + } return false } } diff --git a/Where/WhereShareExtension/Sources/ShareExtensionLog.swift b/Where/WhereShareExtension/Sources/ShareExtensionLog.swift new file mode 100644 index 00000000..0ca081ee --- /dev/null +++ b/Where/WhereShareExtension/Sources/ShareExtensionLog.swift @@ -0,0 +1,44 @@ +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 { + /// 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. + case attachmentLoadFailed(typeIdentifier: String) + /// A shared URL provider produced nothing readable. + case urlUnreadable + /// 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 + } + } + + var message: String { + switch self { + case let .opened(itemCount): + "Share extension opened with \(itemCount) item(s)" + case let .attachmentLoadFailed(typeIdentifier): + "Failed to load shared \(typeIdentifier)" + case .urlUnreadable: + "Shared URL provider yielded no readable URL" + case let .saved(evidenceCount): + "Saved \(evidenceCount) shared evidence record(s)" + case let .saveFailed(description): + "Failed to save shared evidence: \(description)" + } + } +} diff --git a/Where/WhereShareExtension/Sources/ShareViewController.swift b/Where/WhereShareExtension/Sources/ShareViewController.swift index 0b55f11b..77db519e 100644 --- a/Where/WhereShareExtension/Sources/ShareViewController.swift +++ b/Where/WhereShareExtension/Sources/ShareViewController.swift @@ -1,4 +1,4 @@ -import LogKit +import PeriscopeCore import SwiftUI import UIKit import WhereCore @@ -8,7 +8,7 @@ import WhereCore /// `UIHostingController` and bridges its save/cancel actions to the extension /// request lifecycle. final class ShareViewController: UIViewController { - private static let logger = WhereLog.channel(.shareExtension) + private static let logger = WhereLog.root(ShareExtensionLog.self) /// The embedded SwiftUI host, re-framed to fill our bounds each layout pass. private var host: UIHostingController? @@ -17,7 +17,7 @@ final class ShareViewController: UIViewController { super.viewDidLoad() let items = (extensionContext?.inputItems as? [NSExtensionItem]) ?? [] - Self.logger.info("Share extension opened with \(items.count) item(s)") + Self.logger { .opened(itemCount: 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 e6586f94..ef55ea48 100644 --- a/Where/WhereShareExtension/Sources/SharedItemLoader.swift +++ b/Where/WhereShareExtension/Sources/SharedItemLoader.swift @@ -1,5 +1,5 @@ import Foundation -import LogKit +import PeriscopeCore import UniformTypeIdentifiers import WhereCore @@ -28,7 +28,7 @@ struct SharedAttachment { /// non-`Sendable` providers are never sent across actors. @MainActor enum SharedItemLoader { - private static let logger = WhereLog.channel(.shareExtension) + private static let logger = WhereLog.root(ShareExtensionLog.self) /// Load every attachment the providers in `items` can produce, one per /// provider, in share order. Empty when nothing yields bytes (the compose @@ -82,7 +82,7 @@ enum SharedItemLoader { } } guard let data else { - logger.warning("Failed to load shared \(type.identifier)") + logger { .attachmentLoadFailed(typeIdentifier: type.identifier) } return nil } return SharedAttachment( @@ -110,7 +110,7 @@ enum SharedItemLoader { } } guard let string else { - logger.warning("Shared URL provider yielded no readable URL") + logger { .urlUnreadable } return nil } return SharedAttachment( diff --git a/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift b/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift new file mode 100644 index 00000000..8acc17df --- /dev/null +++ b/Where/WhereUI/Sources/Developer/DeveloperLogInspection.swift @@ -0,0 +1,24 @@ +import PeriscopeCore +import SwiftUI +#if DEBUG + import PeriscopeTools +#endif + +extension View { + /// Make this view inspectable in Periscope's "log view mode" in debug + /// builds, and a no-op in release. Wraps PeriscopeTools' `logInspectable(_:)` + /// so production call sites stay clean and the tools code path compiles out + /// of release entirely. + /// + /// With the developer overlay's Log View Mode on, wrapped views gain a badge + /// that opens the newest events in `log`'s scope subtree — e.g. wrap an + /// evidence row in `WhereLog.evidence` to see everything logged under it. + @ViewBuilder + func debugLogInspectable(_ log: Log) -> some View { + #if DEBUG + logInspectable(log) + #else + self + #endif + } +} diff --git a/Where/WhereUI/Sources/Developer/DeveloperToast.swift b/Where/WhereUI/Sources/Developer/DeveloperToast.swift new file mode 100644 index 00000000..8eb99718 --- /dev/null +++ b/Where/WhereUI/Sources/Developer/DeveloperToast.swift @@ -0,0 +1,136 @@ +#if DEBUG + import Observation + import PeriscopeCore + import PeriscopeTools + import SwiftUI + + /// The DEBUG-only in-app toast surface for high-severity log records. + /// + /// A `PeriscopeAlerter` (started at `RootView`, threshold `.warning`) feeds + /// `DeveloperToastAlertHandler`, which appends to this center; the + /// `DeveloperToastOverlay` mounted above the app renders the transient + /// banners. This keeps warning/error logs visible while developing without + /// routing through the notification center (which would collide with the + /// app's real reminders). Compiled out of release entirely. + @MainActor + @Observable + final class DeveloperToastCenter { + /// One transient banner for a logged record at or above the alert + /// threshold. + struct Toast: Identifiable, Equatable { + let id = UUID() + let level: LogLevel + let title: String + let message: String + } + + private(set) var toasts: [Toast] = [] + + /// How long a toast stays on screen before it auto-dismisses. + private let lifetime: Duration + + /// The most banners shown at once; older ones drop so an error storm + /// can't fill the screen. + private let maximumVisible = 3 + + init(lifetime: Duration = .seconds(4)) { + self.lifetime = lifetime + } + + func show(_ toast: Toast) { + toasts.append(toast) + if toasts.count > maximumVisible { + toasts.removeFirst(toasts.count - maximumVisible) + } + Task { + try? await Task.sleep(for: lifetime) + dismiss(toast.id) + } + } + + func dismiss(_ id: Toast.ID) { + toasts.removeAll { $0.id == id } + } + } + + /// Routes alerted records into a `DeveloperToastCenter`. `@MainActor` per the + /// `PeriscopeAlertHandler` contract; it never logs at or above the alerter's + /// threshold, so it can't alert itself in a loop. + @MainActor + struct DeveloperToastAlertHandler: PeriscopeAlertHandler { + let center: DeveloperToastCenter + + func handle(_ record: LogRecord) { + center.show(DeveloperToastCenter.Toast( + level: record.level, + title: record.eventName, + message: record.message, + )) + } + } + + /// Renders a `DeveloperToastCenter`'s banners stacked from the top edge. + /// Non-interactive except for a tap-to-dismiss on each banner, so the app + /// behind stays usable. + struct DeveloperToastOverlay: View { + let center: DeveloperToastCenter + + var body: some View { + VStack(spacing: 8) { + ForEach(center.toasts) { toast in + DeveloperToastBanner(toast: toast) { center.dismiss(toast.id) } + .transition(.move(edge: .top).combined(with: .opacity)) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.top, 8) + .animation(.snappy, value: center.toasts) + .allowsHitTesting(!center.toasts.isEmpty) + } + } + + private struct DeveloperToastBanner: View { + let toast: DeveloperToastCenter.Toast + let onDismiss: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbol) + .foregroundStyle(tint) + VStack(alignment: .leading, spacing: 2) { + Text(toast.title) + .font(.caption.weight(.semibold)) + Text(toast.message) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(3) + } + Spacer(minLength: 0) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + .regularMaterial, + in: RoundedRectangle(cornerRadius: 12, style: .continuous), + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(tint.opacity(0.6), lineWidth: 1), + ) + .contentShape(Rectangle()) + .onTapGesture(perform: onDismiss) + .shadow(color: .black.opacity(0.2), radius: 8, y: 3) + } + + private var tint: Color { + toast.level >= .error ? .red : .orange + } + + private var symbol: String { + toast.level >= .error + ? "exclamationmark.octagon.fill" + : "exclamationmark.triangle.fill" + } + } +#endif diff --git a/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift b/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift index 6f8b838f..49f3de22 100644 --- a/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift +++ b/Where/WhereUI/Sources/Developer/DeveloperToolsView.swift @@ -1,36 +1,43 @@ #if DEBUG - import LogViewerUI - import RegionKit + import PeriscopeCore + import PeriscopeTools import SwiftDataInspector import SwiftUI import WhereCore - /// The developer tools surface — the in-app log viewer over both process - /// buffers (`WhereLog` for the app/WhereCore facade and `RegionLog` for - /// RegionKit, merged chronologically), the generic SwiftData inspector (only - /// when the live session can vend a container — previews and non-SwiftData - /// fakes don't show it), and the region map. + /// The developer tools surface — the Periscope log viewer + open-spans + /// monitor, the "Log View Mode" toggle, the generic SwiftData inspector (only + /// when the live session can vend a container), and the region map. /// /// Owns its own `NavigationStack` so the generic viewers (which expect an - /// ambient stack) work wherever it's hosted. It reads `WhereSession` as an - /// *optional* because the developer overlay is reachable before login — the - /// inspector row simply hides until a live session exists. + /// ambient stack) work wherever it's hosted. It reads the app `WhereModel` + /// (for the process-global log store) and the `WhereSession` as *optionals* + /// because the developer overlay is reachable before login and in fixtures + /// that inject only one of them — the dependent rows just hide until their + /// source exists. /// /// Compiled out of release entirely (`#if DEBUG`). struct DeveloperToolsView: View { + @Environment(WhereModel.self) private var model: WhereModel? @Environment(WhereSession.self) private var session: WhereSession? + @Environment(\.periscopeInspector) private var inspector var body: some View { NavigationStack { List { Section { + if let store = model?.logStore { + NavigationLink { + PeriscopeViewer(store: store, title: Strings.developerLogsTitle) + } label: { + Label(Strings.developerLogsLink, systemImage: "ladybug") + } + } + NavigationLink { - LogViewer(configuration: LogViewerConfiguration( - stores: [WhereLog.store, RegionLog.store], - title: Strings.developerLogsTitle, - )) + OpenSpansView(system: .shared) } label: { - Label(Strings.developerLogsLink, systemImage: "ladybug") + Label(Strings.developerOpenSpansLink, systemImage: "timer") } if let configuration = session?.swiftDataInspectorConfiguration { @@ -52,6 +59,10 @@ } footer: { Text(Strings.developerFooter) } + + if let inspector { + LogViewModeSection(inspector: inspector) + } } .navigationTitle(Strings.developerTitle) .navigationBarTitleDisplayMode(.inline) @@ -59,8 +70,25 @@ } } + /// The "Log View Mode" toggle: binds straight to the injected + /// ``PeriscopeInspector`` (the source of truth is `Periscope.shared`'s + /// inspect flag, which the inspector mirrors both ways), so flipping it + /// reveals the inspect badges `debugLogInspectable(_:)` adds across the app. + private struct LogViewModeSection: View { + @Bindable var inspector: PeriscopeInspector + + var body: some View { + Section { + Toggle(Strings.developerLogViewMode, isOn: $inspector.isEnabled) + } footer: { + Text(Strings.developerLogViewModeFooter) + } + } + } + #Preview { DeveloperToolsView() + .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) } #endif diff --git a/Where/WhereUI/Sources/Developer/RegionMapView.swift b/Where/WhereUI/Sources/Developer/RegionMapView.swift index 1ce52a44..c5d55928 100644 --- a/Where/WhereUI/Sources/Developer/RegionMapView.swift +++ b/Where/WhereUI/Sources/Developer/RegionMapView.swift @@ -1,5 +1,5 @@ -import LogKit import MapKit +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -152,8 +152,9 @@ 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.channel(.geometryCatalog) - .warning("Region map viewer failed to load \(kind.rawValue) geometry: \(error)") + RegionLog.geometryCatalog { + .loadFailed(kind: kind.rawValue, description: String(describing: error)) + } outlines = .failure(error) } } diff --git a/Where/WhereUI/Sources/Evidence/EvidenceListView.swift b/Where/WhereUI/Sources/Evidence/EvidenceListView.swift index d6ca1650..f11a4912 100644 --- a/Where/WhereUI/Sources/Evidence/EvidenceListView.swift +++ b/Where/WhereUI/Sources/Evidence/EvidenceListView.swift @@ -146,6 +146,9 @@ private struct EvidenceRow: View { .accessibilityLabel( Strings.evidenceRowAccessibility(kind: evidence.kind, date: evidence.capturedAt), ) + // Log View Mode: reveal an inspect badge that opens this archive's + // evidence-scope events. A no-op in release. + .debugLogInspectable(WhereLog.evidence) } } diff --git a/Where/WhereUI/Sources/Launch/WhereLaunch.swift b/Where/WhereUI/Sources/Launch/WhereLaunch.swift index d9f8cb1d..7aaea43f 100644 --- a/Where/WhereUI/Sources/Launch/WhereLaunch.swift +++ b/Where/WhereUI/Sources/Launch/WhereLaunch.swift @@ -1,6 +1,6 @@ import CoreLocation import LifecycleKit -import LogKit +import PeriscopeCore import SwiftUI import UIKit import UserNotifications @@ -56,7 +56,48 @@ public enum LaunchStepID: String { /// async steps. @MainActor public enum WhereLaunch { - private static let logger = WhereLog.channel(.launch) + private static let logger = WhereLog.root(WhereLaunchLog.self) + + /// How much log history the on-disk store keeps: two weeks. Older events are + /// pruned at launch so the database can't grow without bound. + private static let logRetention: TimeInterval = 14 * 24 * 60 * 60 + + /// Open the process-global Periscope store, attach it to `Periscope.shared` + /// as the durable sink, start the built-in ambient sources, and prune + /// history past `logRetention` — then hand the store to `model` so the DEBUG + /// developer surface can browse it. + /// + /// Runs off the launch critical path on its own task: opening the store + /// touches disk (and may run a lightweight migration), which must not block + /// `didFinishLaunching`. The OSLog sink already installed on + /// `Periscope.shared` covers the pre-attach window, and `add(sink:)` replays + /// every scope defined so far so the store resolves the records it sees. + /// + /// Degraded-but-handled on failure: if the store can't open, logging keeps + /// flowing through OSLog and the failure is recorded (with the error + /// attached) rather than crashing a launch over diagnostics. + /// + /// Called once from the app delegate at process launch. + public static func bootstrapLogging(model: WhereModel) { + Task { + do { + let store = try await PeriscopeStore.make( + storage: .onDisk, + session: .current(), + ) + Periscope.shared.add(sink: store) + Periscope.shared.startDefaultAmbientSources() + model.attach(logStore: store) + let cutoff = Date().addingTimeInterval(-logRetention) + let pruned = try await store.pruneEvents(olderThan: cutoff) + logger { .loggingStoreReady(prunedEventCount: pruned) } + } catch { + logger(attachments: [.error(error, name: "open-error")]) { + .loggingStoreUnavailable(description: String(describing: error)) + } + } + } + } /// Maps the process's launch-time application state to the lifecycle reason /// the runner consumes. An `.active`/`.inactive` launch is a user-visible @@ -97,7 +138,7 @@ public enum WhereLaunch { /// than in the app delegate) puts app-lifecycle wiring in one place. public static func makeLauncher(model: WhereModel, reason: LifecycleReason) -> LifecycleRunner { let bootstrap = WhereBootstrap() - logger.info("Lifecycle runner created (reason: \(reason))") + logger { .runnerCreated(reason: String(describing: reason)) } return LifecycleRunner( reason: reason, initializePrerequisites: { @@ -209,7 +250,7 @@ public enum WhereLaunch { /// off the main actor and assembles the services from the two. @MainActor public final class WhereBootstrap { - private static let logger = WhereLog.channel(.launch) + private static let logger = WhereLog.root(WhereLaunchLog.self) private var locationSource: CoreLocationSource? @@ -232,7 +273,7 @@ public final class WhereBootstrap { let store = try await Task.detached(priority: .userInitiated) { try SwiftDataStore.make() }.value - Self.logger.info("WhereServices assembled") + Self.logger { .servicesAssembled } return try await WhereServices.make( store: store, locationSource: source, diff --git a/Where/WhereUI/Sources/Launch/WhereLaunchLog.swift b/Where/WhereUI/Sources/Launch/WhereLaunchLog.swift new file mode 100644 index 00000000..9bddaa93 --- /dev/null +++ b/Where/WhereUI/Sources/Launch/WhereLaunchLog.swift @@ -0,0 +1,43 @@ +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 bounded span per launch step. + enum SpanName: Hashable { + case step + } + + case runnerCreated(reason: String) + case servicesAssembled + /// The durable log store opened and attached to `Periscope.shared`, having + /// pruned `prunedEventCount` events past the retention window. + case loggingStoreReady(prunedEventCount: Int) + /// Opening the durable log store failed; logging continues through the + /// OSLog sink only, with no persisted history this launch. + case loggingStoreUnavailable(description: String) + + static let eventName = "WhereLaunch" + + var level: LogLevel { + switch self { + case .runnerCreated, .servicesAssembled, .loggingStoreReady: + .info + case .loggingStoreUnavailable: + .error + } + } + + var message: String { + switch self { + case let .runnerCreated(reason): + "Lifecycle runner created (reason: \(reason))" + case .servicesAssembled: + "WhereServices assembled" + case let .loggingStoreReady(prunedEventCount): + "Log store ready (pruned \(prunedEventCount) event(s) past retention)" + case let .loggingStoreUnavailable(description): + "Log store unavailable: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Model/AddEvidenceModel.swift b/Where/WhereUI/Sources/Model/AddEvidenceModel.swift index 1f177d4a..041756a4 100644 --- a/Where/WhereUI/Sources/Model/AddEvidenceModel.swift +++ b/Where/WhereUI/Sources/Model/AddEvidenceModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// Bytes the user picked to attach to a new piece of evidence, plus the hints @@ -50,7 +50,7 @@ public final class AddEvidenceModel { public private(set) var attachmentError: String? private let services: WhereServices - private static let logger = WhereLog.channel(.evidence) + private static let logger = WhereLog.evidence(AddEvidenceModelLog.self) init(services: WhereServices, now: @Sendable () -> Date = { Date() }) { self.services = services @@ -93,7 +93,7 @@ public final class AddEvidenceModel { public func reportAttachmentError(_ message: String) { attachmentError = message - Self.logger.warning("Evidence attachment pick failed: \(message)") + Self.logger { .attachmentPickFailed(description: message) } } /// Build the `Evidence` from the form and persist it (with any attachment @@ -105,11 +105,11 @@ public final class AddEvidenceModel { let evidence = buildEvidence() do { try await services.journal.addEvidence(evidence, blob: attachment?.data) - Self.logger.info("Saved evidence \(evidence.id) from compose form") + Self.logger { .saved(evidenceID: String(describing: evidence.id)) } return true } catch { saveState = .failed(error.localizedDescription) - Self.logger.warning("Failed to save evidence: \(error.localizedDescription)") + Self.logger { .saveFailed(description: error.localizedDescription) } return false } } diff --git a/Where/WhereUI/Sources/Model/AddEvidenceModelLog.swift b/Where/WhereUI/Sources/Model/AddEvidenceModelLog.swift new file mode 100644 index 00000000..7a3fc0ac --- /dev/null +++ b/Where/WhereUI/Sources/Model/AddEvidenceModelLog.swift @@ -0,0 +1,38 @@ +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) + + static let eventName = "AddEvidenceModel" + + var level: LogLevel { + switch self { + case .saved: .info + case .attachmentPickFailed, .saveFailed: .warning + } + } + + 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)" + } + } + + var externalID: String? { + switch self { + case let .saved(evidenceID): WhereStoreID.evidence(evidenceID) + case .attachmentPickFailed, .saveFailed: nil + } + } +} diff --git a/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift b/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift index d628147f..fc791f6a 100644 --- a/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift +++ b/Where/WhereUI/Sources/Model/EvidenceDetailModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model for a single evidence record's detail: loads the @@ -24,7 +24,7 @@ public final class EvidenceDetailModel { public private(set) var blobState: BlobState = .idle private let services: WhereServices - private static let logger = WhereLog.channel(.evidence) + private static let logger = WhereLog.evidence(EvidenceDetailModelLog.self) init(evidence: Evidence, services: WhereServices) { self.evidence = evidence @@ -40,9 +40,12 @@ public final class EvidenceDetailModel { blobState = .loaded(blob) } catch { blobState = .failed(error.localizedDescription) - Self.logger.warning( - "Failed to load evidence blob for \(evidence.id): \(error.localizedDescription)", - ) + Self.logger { + .blobLoadFailed( + evidenceID: String(describing: evidence.id), + description: error.localizedDescription, + ) + } } } diff --git a/Where/WhereUI/Sources/Model/EvidenceDetailModelLog.swift b/Where/WhereUI/Sources/Model/EvidenceDetailModelLog.swift new file mode 100644 index 00000000..0b543f1e --- /dev/null +++ b/Where/WhereUI/Sources/Model/EvidenceDetailModelLog.swift @@ -0,0 +1,27 @@ +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) + + static let eventName = "EvidenceDetailModel" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .blobLoadFailed(evidenceID, description): + "Failed to load evidence blob for \(evidenceID): \(description)" + } + } + + var externalID: String? { + switch self { + case let .blobLoadFailed(evidenceID, _): WhereStoreID.evidence(evidenceID) + } + } +} diff --git a/Where/WhereUI/Sources/Model/EvidenceListModel.swift b/Where/WhereUI/Sources/Model/EvidenceListModel.swift index 4cf67041..c8cf5440 100644 --- a/Where/WhereUI/Sources/Model/EvidenceListModel.swift +++ b/Where/WhereUI/Sources/Model/EvidenceListModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model backing the "all evidence" sheet: loads the selected @@ -24,7 +24,7 @@ public final class EvidenceListModel { public private(set) var loadState: LoadState = .idle private let services: WhereServices - private static let logger = WhereLog.channel(.evidence) + private static let logger = WhereLog.evidence(EvidenceListModelLog.self) init(services: WhereServices) { self.services = services @@ -49,9 +49,7 @@ public final class EvidenceListModel { loadState = evidence.isEmpty ? .empty : .loaded(evidence) } catch { loadState = .failed(error.localizedDescription) - Self.logger.warning( - "Failed to load evidence for \(year): \(error.localizedDescription)", - ) + Self.logger { .loadFailed(year: year, description: error.localizedDescription) } } } diff --git a/Where/WhereUI/Sources/Model/EvidenceListModelLog.swift b/Where/WhereUI/Sources/Model/EvidenceListModelLog.swift new file mode 100644 index 00000000..4b7beab6 --- /dev/null +++ b/Where/WhereUI/Sources/Model/EvidenceListModelLog.swift @@ -0,0 +1,27 @@ +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) + + static let eventName = "EvidenceListModel" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .loadFailed(year, description): + "Failed to load evidence for \(year): \(description)" + } + } + + var externalID: String? { + switch self { + case let .loadFailed(year, _): WhereStoreID.year(year) + } + } +} diff --git a/Where/WhereUI/Sources/Model/LoggedDaysModel.swift b/Where/WhereUI/Sources/Model/LoggedDaysModel.swift index 4cfc48f1..7c7653d4 100644 --- a/Where/WhereUI/Sources/Model/LoggedDaysModel.swift +++ b/Where/WhereUI/Sources/Model/LoggedDaysModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model backing the "logged days" sheet: loads the selected year's @@ -25,7 +25,7 @@ public final class LoggedDaysModel { public private(set) var loadState: LoadState = .idle private let services: WhereServices - private static let logger = WhereLog.channel(.dayJournal) + private static let logger = WhereLog.root(LoggedDaysModelLog.self) init(services: WhereServices) { self.services = services @@ -62,9 +62,7 @@ public final class LoggedDaysModel { loadState = days.isEmpty ? .empty : .loaded(days) } catch { loadState = .failed(error.localizedDescription) - Self.logger.warning( - "Failed to load logged days for \(year): \(error.localizedDescription)", - ) + Self.logger { .loadFailed(year: year, description: error.localizedDescription) } } } diff --git a/Where/WhereUI/Sources/Model/LoggedDaysModelLog.swift b/Where/WhereUI/Sources/Model/LoggedDaysModelLog.swift new file mode 100644 index 00000000..fb965083 --- /dev/null +++ b/Where/WhereUI/Sources/Model/LoggedDaysModelLog.swift @@ -0,0 +1,27 @@ +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) + + static let eventName = "LoggedDaysModel" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .loadFailed(year, description): + "Failed to load logged days for \(year): \(description)" + } + } + + var externalID: String? { + switch self { + case let .loadFailed(year, _): WhereStoreID.year(year) + } + } +} diff --git a/Where/WhereUI/Sources/Model/RecentActivityModel.swift b/Where/WhereUI/Sources/Model/RecentActivityModel.swift index 82231b44..b063fa22 100644 --- a/Where/WhereUI/Sources/Model/RecentActivityModel.swift +++ b/Where/WhereUI/Sources/Model/RecentActivityModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model for the "last 24 hours" on-device summary. Mirrors the @@ -31,7 +31,7 @@ public final class RecentActivityModel { public var window: RecentActivityWindow = .day private let services: WhereServices - private static let logger = WhereLog.channel(.recentActivitySummarizer) + private static let logger = WhereLog.recentActivity(RecentActivityModelLog.self) init(services: WhereServices) { self.services = services @@ -51,14 +51,10 @@ public final class RecentActivityModel { } } catch let error as ActivitySummaryUnavailableError { loadState = .unavailable(error.reason) - Self.logger.warning( - "Recent-activity summary unavailable: \(String(describing: error.reason))", - ) + Self.logger { .summaryUnavailable(reason: String(describing: error.reason)) } } catch { loadState = .failed(error.localizedDescription) - Self.logger.warning( - "Recent-activity summary failed: \(error.localizedDescription)", - ) + Self.logger { .summaryFailed(description: error.localizedDescription) } } } diff --git a/Where/WhereUI/Sources/Model/RecentActivityModelLog.swift b/Where/WhereUI/Sources/Model/RecentActivityModelLog.swift new file mode 100644 index 00000000..f7926ca8 --- /dev/null +++ b/Where/WhereUI/Sources/Model/RecentActivityModelLog.swift @@ -0,0 +1,24 @@ +import PeriscopeCore + +/// Structured events for `RecentActivityModel`. Both an unavailable on-device +/// model and a generation failure leave an honest UI error, so they log at +/// `.warning`. +enum RecentActivityModelLog: LogEvent { + case summaryUnavailable(reason: String) + case summaryFailed(description: String) + + static let eventName = "RecentActivityModel" + + var level: LogLevel { + .warning + } + + var message: String { + switch self { + case let .summaryUnavailable(reason): + "Recent-activity summary unavailable: \(reason)" + case let .summaryFailed(description): + "Recent-activity summary failed: \(description)" + } + } +} diff --git a/Where/WhereUI/Sources/Model/WhereModel.swift b/Where/WhereUI/Sources/Model/WhereModel.swift index 0e871f1f..6e1e3604 100644 --- a/Where/WhereUI/Sources/Model/WhereModel.swift +++ b/Where/WhereUI/Sources/Model/WhereModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// The long-lived, app-level model: the onboarding gate, the persisted @@ -28,6 +28,15 @@ public final class WhereModel { /// `@Environment(WhereSession.self)`. public private(set) var session: WhereSession? + /// The process-global Periscope log store, opened at launch and attached to + /// `Periscope.shared` as its durable sink (see `WhereLaunch.bootstrapLogging`). + /// Held here — not on `WhereSession` — because logging spans the whole + /// process, not a login: it exists before the store opens and survives a + /// reset. `nil` until the bootstrap opens it, and in previews/tests, which + /// log only through the in-memory pipeline. The DEBUG developer surface reads + /// it to browse persisted history. + public private(set) var logStore: PeriscopeStore? + /// The persisted user intent (onboarding, tracking, reminder/summary /// schedules). Owns the defaults keys and the `reset()` the erase flow runs; /// shared by reference with the `WhereSession` so both halves read/write the @@ -44,7 +53,7 @@ public final class WhereModel { /// (the scene loads from the store once it appears). let initialReport: YearReport? - private static let logger = WhereLog.channel(.model) + private static let logger = WhereLog.root(WhereModelLog.self) /// Whether first-run onboarding has been completed. Persisted so onboarding /// shows exactly once; the launch flow gates its onboarding step on this, @@ -58,7 +67,7 @@ public final class WhereModel { /// user finishes the intro (after the permission prompt resolves). public func completeOnboarding() { hasOnboarded = true - Self.logger.info("Onboarding completed") + Self.logger { .onboardingCompleted } } public static var currentYear: Int { @@ -107,6 +116,13 @@ public final class WhereModel { services != nil } + /// Retain the process-global log store the launch bootstrap opened and + /// attached to `Periscope.shared`. Called once, off the launch critical + /// path, so the developer surface can browse persisted history. + public func attach(logStore: PeriscopeStore) { + self.logStore = logStore + } + /// Retain the service layer the launch's `open-store` step assembled (see /// `WhereBootstrap`). Idempotent — a no-op once services exist, so an /// injected preview/test value is never clobbered. `WhereBootstrap` owns @@ -129,7 +145,7 @@ public final class WhereModel { preferences: preferences, now: now, ) - Self.logger.info("Started session (year: \(initialSelectedYear))") + Self.logger { .startedSession(year: initialSelectedYear) } } /// Drop the logged-in session (the services stay retained). Run by the @@ -137,7 +153,7 @@ public final class WhereModel { /// fresh session over the erased store. public func endSession() { session = nil - Self.logger.info("Ended session") + Self.logger { .endedSession } } // MARK: - Reset / erase all @@ -162,6 +178,6 @@ public final class WhereModel { /// again; the re-driven launch's fresh session reads those defaults back. public func resetPreferences() { preferences.reset() - Self.logger.info("Reset preferences to first-install defaults") + Self.logger { .resetPreferences } } } diff --git a/Where/WhereUI/Sources/Model/WhereModelLog.swift b/Where/WhereUI/Sources/Model/WhereModelLog.swift new file mode 100644 index 00000000..0d2c6b93 --- /dev/null +++ b/Where/WhereUI/Sources/Model/WhereModelLog.swift @@ -0,0 +1,25 @@ +import PeriscopeCore + +/// Structured events for `WhereModel`, the app's top-level session/onboarding +/// coordinator. All are successful-lifecycle `.info` events. +enum WhereModelLog: LogEvent { + case onboardingCompleted + case startedSession(year: Int) + case endedSession + case resetPreferences + + static let eventName = "WhereModel" + + var message: String { + switch self { + case .onboardingCompleted: + "Onboarding completed" + case let .startedSession(year): + "Started session (year: \(year))" + case .endedSession: + "Ended session" + case .resetPreferences: + "Reset preferences to first-install defaults" + } + } +} diff --git a/Where/WhereUI/Sources/Model/WhereSession.swift b/Where/WhereUI/Sources/Model/WhereSession.swift index 11619564..b9e9ac7a 100644 --- a/Where/WhereUI/Sources/Model/WhereSession.swift +++ b/Where/WhereUI/Sources/Model/WhereSession.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore #if DEBUG import SwiftDataInspector #endif @@ -77,7 +77,7 @@ public final class WhereSession { /// access to race. @ObservationIgnored private nonisolated(unsafe) var authorizationTask: Task? - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(WhereSessionLog.self) /// The authorization the degradation warning was last evaluated against. /// `syncAuthorization()` runs on every foreground, so warning only on a @@ -205,13 +205,11 @@ public final class WhereSession { case .always, .notDetermined: break case .whenInUse: - Self.logger.warning( - "Location authorized for When-In-Use only; background tracking unavailable", - ) + Self.logger { .whenInUseOnly } case .denied, .restricted: - Self.logger.warning( - "Location access \(authorizationStatus); background tracking unavailable", - ) + Self.logger { + .locationAccessDenied(status: String(describing: authorizationStatus)) + } } } @@ -241,11 +239,11 @@ public final class WhereSession { if wantsTracking, authorizationStatus.allowsBackgroundTracking { await services.ingestor.start() isTracking = true - if !wasTracking { Self.logger.info("Background tracking started") } + if !wasTracking { Self.logger { .backgroundTrackingStarted } } } else { await services.ingestor.stop() isTracking = false - if wasTracking { Self.logger.info("Background tracking stopped") } + if wasTracking { Self.logger { .backgroundTrackingStopped } } } } @@ -277,7 +275,7 @@ public final class WhereSession { await syncAuthorization() await reconcileTracking() if authorizationStatus.allowsBackgroundTracking { - Self.logger.info("Location permission granted (\(authorizationStatus))") + Self.logger { .permissionGranted(status: String(describing: authorizationStatus)) } } } @@ -297,7 +295,7 @@ public final class WhereSession { await syncAuthorization() await reconcileTracking() if authorizationStatus.allowsBackgroundTracking { - Self.logger.info("Tracking enabled with background authorization") + Self.logger { .trackingEnabled } } } @@ -305,7 +303,7 @@ public final class WhereSession { wantsTracking = false await services.ingestor.stop() isTracking = false - Self.logger.info("Stopped background tracking") + Self.logger { .stoppedBackgroundTracking } } /// Push the persisted reminder intent to the reminder reconciler and warn if @@ -327,7 +325,7 @@ public final class WhereSession { let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedRemindersUnauthorized { - Self.logger.warning("Logging reminders enabled but notifications not authorized") + Self.logger { .remindersUnauthorized } warnedRemindersUnauthorized = true } } else { @@ -345,7 +343,7 @@ public final class WhereSession { let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedSummaryUnauthorized { - Self.logger.warning("Daily summary enabled but notifications not authorized") + Self.logger { .summaryUnauthorized } warnedSummaryUnauthorized = true } } else { @@ -368,7 +366,7 @@ public final class WhereSession { let authorized = await services.reminders.isAuthorized() if enabled, !authorized { if !warnedIssueAlertsUnauthorized { - Self.logger.warning("Issue alerts enabled but notifications not authorized") + Self.logger { .issueAlertsUnauthorized } warnedIssueAlertsUnauthorized = true } } else { @@ -388,7 +386,7 @@ public final class WhereSession { public func eraseSession() async throws { try await services.reset() isTracking = false - Self.logger.info("Erased session and reset state") + Self.logger { .erasedSession } } /// Drives the background-tracking `Toggle`. Reads the live `isTracking` diff --git a/Where/WhereUI/Sources/Model/WhereSessionLog.swift b/Where/WhereUI/Sources/Model/WhereSessionLog.swift new file mode 100644 index 00000000..e4095aa0 --- /dev/null +++ b/Where/WhereUI/Sources/Model/WhereSessionLog.swift @@ -0,0 +1,58 @@ +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 { + case whenInUseOnly + case locationAccessDenied(status: String) + case backgroundTrackingStarted + case backgroundTrackingStopped + case permissionGranted(status: String) + case trackingEnabled + case stoppedBackgroundTracking + case remindersUnauthorized + case summaryUnauthorized + case issueAlertsUnauthorized + case erasedSession + + static let eventName = "WhereSession" + + var level: LogLevel { + switch self { + case .whenInUseOnly, .locationAccessDenied, .remindersUnauthorized, + .summaryUnauthorized, .issueAlertsUnauthorized: + .warning + case .backgroundTrackingStarted, .backgroundTrackingStopped, .permissionGranted, + .trackingEnabled, .stoppedBackgroundTracking, .erasedSession: + .info + } + } + + 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 .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 .erasedSession: + "Erased session and reset state" + } + } +} diff --git a/Where/WhereUI/Sources/Model/YearReportModel.swift b/Where/WhereUI/Sources/Model/YearReportModel.swift index 999b2a75..2e0f04bf 100644 --- a/Where/WhereUI/Sources/Model/YearReportModel.swift +++ b/Where/WhereUI/Sources/Model/YearReportModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import RegionKit import WhereCore @@ -95,7 +95,7 @@ public final class YearReportModel { /// the main actor, and `deinit` runs with no other live references. @ObservationIgnored private nonisolated(unsafe) var dataChangeTask: Task? - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(YearReportModelLog.self) /// Observed mirror of `preferences.driftThresholdMeters`, which isn't itself /// observable (`WherePreferences` is a plain defaults wrapper — callers that @@ -252,7 +252,7 @@ public final class YearReportModel { public func select(year: Int) async { guard year != selectedYear else { return } - Self.logger.info("Selected year \(year)") + Self.logger { .selectedYear(year: 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. @@ -289,9 +289,12 @@ public final class YearReportModel { guard requestedYear == selectedYear else { return } if evidenceDayKeys != keys { evidenceDayKeys = keys } } catch { - Self.logger.warning( - "Failed to load evidence day keys for \(requestedYear): \(error.localizedDescription)", - ) + Self.logger { + .evidenceDayKeysLoadFailed( + year: requestedYear, + description: error.localizedDescription, + ) + } } } @@ -317,9 +320,7 @@ public final class YearReportModel { } catch { // Surface the failure and keep the last good count rather than // silently blanking the badge. - Self.logger.warning( - "Failed to scan for data issues: \(error.localizedDescription)", - ) + Self.logger { .dataIssueScanFailed(description: error.localizedDescription) } } } @@ -340,15 +341,16 @@ public final class YearReportModel { if changed { self.report = report } if loadState != .loaded { loadState = .loaded } if changed { - Self.logger - .info("Year report loaded for \(requestedYear) (\(report.days.count) day(s))") + Self.logger { + .reportLoaded(year: requestedYear, dayCount: report.days.count) + } } } catch { guard requestedYear == selectedYear else { return } loadState = .failed(.reportUnavailable(message: error.localizedDescription)) - Self.logger.warning( - "Failed to load year report for \(requestedYear): \(error.localizedDescription)", - ) + Self.logger { + .reportLoadFailed(year: requestedYear, description: error.localizedDescription) + } } } @@ -430,9 +432,9 @@ public final class YearReportModel { try await services.journal.clearYear(selectedYear) } catch { loadState = .failed(.clearFailed(message: error.localizedDescription)) - Self.logger.warning( - "Failed to clear year \(selectedYear): \(error.localizedDescription)", - ) + Self.logger { + .clearYearFailed(year: selectedYear, description: error.localizedDescription) + } } } @@ -451,9 +453,13 @@ public final class YearReportModel { do { return try await services.reports.locations(in: region, year: selectedYear) } catch { - Self.logger.warning( - "Failed to load locations for \(region.rawValue) in \(selectedYear): \(error.localizedDescription)", - ) + Self.logger { + .locationsLoadFailed( + region: region.rawValue, + year: selectedYear, + description: error.localizedDescription, + ) + } return [] } } @@ -465,9 +471,12 @@ public final class YearReportModel { do { return try await services.reports.representativeCoordinates(for: selectedYear) } catch { - Self.logger.warning( - "Failed to load representative coordinates for \(selectedYear): \(error.localizedDescription)", - ) + Self.logger { + .representativeCoordinatesLoadFailed( + year: selectedYear, + description: error.localizedDescription, + ) + } return [:] } } diff --git a/Where/WhereUI/Sources/Model/YearReportModelLog.swift b/Where/WhereUI/Sources/Model/YearReportModelLog.swift new file mode 100644 index 00000000..4b1f57c5 --- /dev/null +++ b/Where/WhereUI/Sources/Model/YearReportModelLog.swift @@ -0,0 +1,60 @@ +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 { + 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 representativeCoordinatesLoadFailed(year: Int, description: String) + + static let eventName = "YearReport" + + var level: LogLevel { + switch self { + case .selectedYear, .reportLoaded: .info + case .reportLoadFailed, .evidenceDayKeysLoadFailed, .dataIssueScanFailed, + .clearYearFailed, .locationsLoadFailed, .representativeCoordinatesLoadFailed: + .warning + } + } + + 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 .representativeCoordinatesLoadFailed(year, description): + "Failed to load representative coordinates for \(year): \(description)" + } + } + + 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 .dataIssueScanFailed: + nil + } + } +} diff --git a/Where/WhereUI/Sources/Preview/PreviewSupport.swift b/Where/WhereUI/Sources/Preview/PreviewSupport.swift index c5113e1b..81440283 100644 --- a/Where/WhereUI/Sources/Preview/PreviewSupport.swift +++ b/Where/WhereUI/Sources/Preview/PreviewSupport.swift @@ -1,5 +1,6 @@ #if DEBUG import Foundation + import PeriscopeCore import RegionKit @_spi(Testing) import WhereCore @@ -324,6 +325,23 @@ WhereModel(services: previewServices(), report: sampleReport(), selectedYear: year) } + /// An in-memory Periscope log store for the developer-surface previews and + /// hosting tests — the same durable-sink type the app opens at launch, + /// but backed by memory so nothing touches disk. + @MainActor + public static func previewLogStore() async throws -> PeriscopeStore { + try await PeriscopeStore.make(storage: .inMemory, session: .current()) + } + + /// A `loadedModel()` with an in-memory log store attached, so the + /// developer tools' log-viewer and Log View Mode rows render. + @MainActor + public static func loadedModel(withLogStore store: PeriscopeStore) -> WhereModel { + let model = loadedModel() + model.attach(logStore: store) + return model + } + /// A widget snapshot built from the sample year totals, for widget /// previews and tests. public static func sampleWidgetSnapshot( diff --git a/Where/WhereUI/Sources/Primary/CalendarView.swift b/Where/WhereUI/Sources/Primary/CalendarView.swift index 083c88f8..0f9a2941 100644 --- a/Where/WhereUI/Sources/Primary/CalendarView.swift +++ b/Where/WhereUI/Sources/Primary/CalendarView.swift @@ -1,3 +1,4 @@ +import PeriscopeCore import RegionKit import SwiftUI import WhereCore @@ -21,7 +22,7 @@ struct CalendarView: View { @State private var timelineTarget: TimelineMonthTarget? @State private var monthsLoad: Result<[CalendarMonth], Error>? - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(CalendarViewLog.self) /// Inputs that invalidate a cached month grid. private struct CalendarLoadID: Equatable { @@ -73,9 +74,9 @@ struct CalendarView: View { Text(Strings.calendarUnavailableDescription) } .onAppear { - Self.logger.warning( - "Calendar opened without a year report (loadState: \(report.loadState))", - ) + Self.logger { + .openedWithoutReport(loadState: String(describing: report.loadState)) + } } } } @@ -131,7 +132,7 @@ struct CalendarView: View { Text(Strings.calendarUnavailableDescription) } .onAppear { - Self.logger.warning("Calendar layout failed: \(error)") + Self.logger { .layoutFailed(description: String(describing: error)) } } } diff --git a/Where/WhereUI/Sources/Primary/CalendarViewLog.swift b/Where/WhereUI/Sources/Primary/CalendarViewLog.swift new file mode 100644 index 00000000..56e0122c --- /dev/null +++ b/Where/WhereUI/Sources/Primary/CalendarViewLog.swift @@ -0,0 +1,23 @@ +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) + + static let eventName = "CalendarView" + + var level: LogLevel { + .warning + } + + 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)" + } + } +} diff --git a/Where/WhereUI/Sources/Resolution/ResolveModel.swift b/Where/WhereUI/Sources/Resolution/ResolveModel.swift index d86139fa..394ce88e 100644 --- a/Where/WhereUI/Sources/Resolution/ResolveModel.swift +++ b/Where/WhereUI/Sources/Resolution/ResolveModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import RegionKit import WhereCore @@ -33,7 +33,7 @@ public final class ResolveModel { private let services: WhereServices private let preferences: WherePreferences - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(ResolveModelLog.self) #if DEBUG /// Set by the `@_spi(Testing)` seeder so `load(...)` doesn't clobber @@ -65,9 +65,7 @@ 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.warning( - "Failed to scan for data issues: \(error.localizedDescription)", - ) + Self.logger { .dataIssueScanFailed(description: 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 @@ -84,9 +82,12 @@ public final class ResolveModel { // recomputes the badge count a beat later. dataIssues.removeAll { $0.id == issue.id } } catch { - Self.logger.warning( - "Failed to dismiss data issue \(issue.id.storeURL): \(error.localizedDescription)", - ) + Self.logger { + .dismissFailed( + issueID: issue.id.storeURL.absoluteString, + description: error.localizedDescription, + ) + } } } } diff --git a/Where/WhereUI/Sources/Resolution/ResolveModelLog.swift b/Where/WhereUI/Sources/Resolution/ResolveModelLog.swift new file mode 100644 index 00000000..97c5a29a --- /dev/null +++ b/Where/WhereUI/Sources/Resolution/ResolveModelLog.swift @@ -0,0 +1,31 @@ +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) + + static let eventName = "Resolve" + + var level: LogLevel { + .warning + } + + 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 externalID: String? { + switch self { + case let .dismissFailed(issueID, _): issueID + case .dataIssueScanFailed: nil + } + } +} diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift index a9276d46..905779f2 100644 --- a/Where/WhereUI/Sources/RootView.swift +++ b/Where/WhereUI/Sources/RootView.swift @@ -1,6 +1,11 @@ import LifecycleKit +import PeriscopeUI import SwiftUI import WhereCore +#if DEBUG + import PeriscopeCore + import PeriscopeTools +#endif /// The app's root: the launch sequence gated in front of a Liquid Glass tab bar /// over the four top-level screens (Primary, Elsewhere, Resolve, Settings). @@ -20,6 +25,16 @@ public struct RootView: View { /// handed to the sibling `DeveloperOverlay` so its button rests clear of the /// tab bar. Zero when logged out (no tab bar in the tree). @State private var developerTabBarInset: CGFloat = 0 + /// Periscope's "log view mode" mirror, built once the launch bootstrap has + /// opened the log store. Injected into the environment so + /// `debugLogInspectable(_:)` badges across the app can reveal their scopes; + /// the developer overlay binds its toggle to it. + @State private var inspector: PeriscopeInspector? + /// Watches the shared pipeline for `.warning`+ records and shows them as + /// in-app toasts while developing. Retained for the process; started once + /// the store is available. + @State private var alerter: PeriscopeAlerter? + @State private var toastCenter = DeveloperToastCenter() #endif private let launcher: LifecycleRunner @@ -67,52 +82,63 @@ public struct RootView: View { // DEBUG-only and compiled out of release entirely. #if DEBUG DeveloperOverlay(tabBarInset: developerTabBarInset) + // High-severity log toasts float above everything, including the + // developer overlay, so a warning/error is visible wherever it fires. + DeveloperToastOverlay(center: toastCenter) #endif } #if DEBUG .onPreferenceChange(DeveloperTabBarInsetKey.self) { developerTabBarInset = $0 } + .environment(\.periscopeInspector, inspector) + .task { configureDeveloperLogging() } + .onChange(of: model.logStore.map(ObjectIdentifier.init)) { _, _ in + configureDeveloperLogging() + } #endif - .environment(model) - // The logged-in session appears once `open-store` builds it. Injected - // as an optional `Observable`, so the `TabView`'s `@Environment(WhereSession.self)` - // views resolve it (they only render at `.ready`, by which point it's - // present) and re-inject when a reset rebuilds it. The DEBUG developer - // overlay reads it optionally — it can appear before login, where the - // SwiftData inspector row simply hides. - .environment(model.session) - // Settings' "Erase all data & reset" runs the teardown through the - // `LifecycleRunner` that `LifecycleContainer` publishes into the - // environment, which wipes data + preferences and re-drives the launch - // sequence back to onboarding. - // - // `run()` is idempotent: in the app the delegate already kicked it off, - // so this is a no-op there; in previews/tests it's what drives the - // launch. - // - // Promote a background launch only once the scene is genuinely active. - // SwiftUI may build this view (and run `.task`) for a scene that iOS - // connected in the background; promoting then would flip the launcher to - // foreground and build the heavy `TabView` for a launch nobody sees, - // defeating the headless path. The `.onChange` below handles the later - // background→foreground transition; this initial check covers a launch - // that is already active when the view first appears. - .task { - await launcher.run() - if scenePhase == .active { - await launcher.enterForeground() + // Seed the app's root logging context so any view that logs freeform via + // `\.logContext` emits under the "Where" scope rather than a bare root. + .logContext(WhereLog.root) + .environment(model) + // The logged-in session appears once `open-store` builds it. Injected + // as an optional `Observable`, so the `TabView`'s `@Environment(WhereSession.self)` + // views resolve it (they only render at `.ready`, by which point it's + // present) and re-inject when a reset rebuilds it. The DEBUG developer + // overlay reads it optionally — it can appear before login, where the + // SwiftData inspector row simply hides. + .environment(model.session) + // Settings' "Erase all data & reset" runs the teardown through the + // `LifecycleRunner` that `LifecycleContainer` publishes into the + // environment, which wipes data + preferences and re-drives the launch + // sequence back to onboarding. + // + // `run()` is idempotent: in the app the delegate already kicked it off, + // so this is a no-op there; in previews/tests it's what drives the + // launch. + // + // Promote a background launch only once the scene is genuinely active. + // SwiftUI may build this view (and run `.task`) for a scene that iOS + // connected in the background; promoting then would flip the launcher to + // foreground and build the heavy `TabView` for a launch nobody sees, + // defeating the headless path. The `.onChange` below handles the later + // background→foreground transition; this initial check covers a launch + // that is already active when the view first appears. + .task { + await launcher.run() + if scenePhase == .active { + await launcher.enterForeground() + } } - } - .onChange(of: scenePhase) { _, newPhase in - guard newPhase == .active else { return } - Task { - await launcher.enterForeground() - await model.session?.appBecameActive() + .onChange(of: scenePhase) { _, newPhase in + guard newPhase == .active else { return } + Task { + await launcher.enterForeground() + await model.session?.appBecameActive() + } } - } - // Seed the Broadway context at the app root so descendants resolve - // `WhereStylesheet` (via `@Environment(\.stylesheet)`) against the live - // system traits and the app's themes. - .whereBroadwayRoot() + // Seed the Broadway context at the app root so descendants resolve + // `WhereStylesheet` (via `@Environment(\.stylesheet)`) against the live + // system traits and the app's themes. + .whereBroadwayRoot() } /// How the launch splash gives way to the app once the runner is `.ready`: @@ -132,6 +158,26 @@ public struct RootView: View { private var revealAnimation: Animation { reduceMotion ? stylesheet.motion.reducedReveal : stylesheet.motion.reveal } + + #if DEBUG + /// Build the log-view-mode inspector and start the toast alerter once the + /// launch bootstrap has opened the process-global store. Idempotent: it's + /// driven from both the initial `.task` (fixtures that inject a store up + /// front) and an `.onChange` (the app, where the store opens off the + /// launch path), and does nothing until the store exists or after it's + /// wired once. + private func configureDeveloperLogging() { + guard inspector == nil, let store = model.logStore else { return } + inspector = PeriscopeInspector(system: .shared, store: store) + let alerter = PeriscopeAlerter( + system: .shared, + threshold: .warning, + handler: DeveloperToastAlertHandler(center: toastCenter), + ) + alerter.start() + self.alerter = alerter + } + #endif } #if DEBUG diff --git a/Where/WhereUI/Sources/Settings/BackupModel.swift b/Where/WhereUI/Sources/Settings/BackupModel.swift index e332e132..07ad3d33 100644 --- a/Where/WhereUI/Sources/Settings/BackupModel.swift +++ b/Where/WhereUI/Sources/Settings/BackupModel.swift @@ -1,6 +1,6 @@ import Foundation -import LogKit import Observation +import PeriscopeCore import WhereCore /// View-scoped model for the Settings backup section: export/import progress and @@ -38,7 +38,7 @@ public final class BackupModel { } private let services: WhereServices - private static let logger = WhereLog.channel(.session) + private static let logger = WhereLog.session(BackupModelLog.self) public init(services: WhereServices) { self.services = services @@ -72,12 +72,12 @@ public final class BackupModel { let url = try await services.backup.exportBackup { continuation.yield($0) } continuation.finish() await observer.value - Self.logger.info("Exported backup archive") + Self.logger { .exported } return url } catch { continuation.finish() backupError = error.localizedDescription - Self.logger.warning("Backup export failed: \(error.localizedDescription)") + Self.logger { .exportFailed(description: error.localizedDescription) } return nil } } @@ -121,14 +121,20 @@ public final class BackupModel { } continuation.finish() await observer.value - Self.logger.info( - "Imported backup (\(summary.sampleCount) samples, \(summary.evidenceCount) evidence, \(summary.manualDayCount) manual days, \(summary.dismissedIssueCount) dismissals, \(summary.trackedRegionCount) tracked regions)", - ) + Self.logger { + .imported( + sampleCount: summary.sampleCount, + evidenceCount: summary.evidenceCount, + manualDayCount: summary.manualDayCount, + dismissedIssueCount: summary.dismissedIssueCount, + trackedRegionCount: summary.trackedRegionCount, + ) + } return summary } catch { continuation.finish() backupError = error.localizedDescription - Self.logger.warning("Backup import failed: \(error.localizedDescription)") + Self.logger { .importFailed(description: error.localizedDescription) } return nil } } diff --git a/Where/WhereUI/Sources/Settings/BackupModelLog.swift b/Where/WhereUI/Sources/Settings/BackupModelLog.swift new file mode 100644 index 00000000..b790fd6b --- /dev/null +++ b/Where/WhereUI/Sources/Settings/BackupModelLog.swift @@ -0,0 +1,44 @@ +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) + + static let eventName = "Backup" + + var level: LogLevel { + switch self { + case .exported, .imported: .info + case .exportFailed, .importFailed: .warning + } + } + + 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)" + } + } +} diff --git a/Where/WhereUI/Sources/Settings/LocationStatusRow.swift b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift index de920319..be53125b 100644 --- a/Where/WhereUI/Sources/Settings/LocationStatusRow.swift +++ b/Where/WhereUI/Sources/Settings/LocationStatusRow.swift @@ -25,6 +25,9 @@ struct LocationStatusRow: View { } .accessibilityElement(children: .combine) .accessibilityLabel(presentation.title) + // Log View Mode: reveal an inspect badge that opens the session's + // location/authorization events. A no-op in release. + .debugLogInspectable(WhereLog.session) } private struct Presentation { diff --git a/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift b/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift index b2e1a736..32a7870b 100644 --- a/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift +++ b/Where/WhereUI/Sources/Settings/RemindersSettingsModel.swift @@ -1,5 +1,4 @@ import Foundation -import LogKit import Observation import WhereCore @@ -141,7 +140,6 @@ public final class RemindersSettingsModel { private let preferences: WherePreferences private let now: @Sendable () -> Date private let calendar: Calendar - private static let logger = WhereLog.channel(.session) public init( services: WhereServices, diff --git a/Where/WhereUI/Sources/Shared/Strings.swift b/Where/WhereUI/Sources/Shared/Strings.swift index d2731069..439897db 100644 --- a/Where/WhereUI/Sources/Shared/Strings.swift +++ b/Where/WhereUI/Sources/Shared/Strings.swift @@ -476,6 +476,22 @@ enum Strings { localized("developer.logsTitle") } + static var developerOpenSpansLink: String { + String(localized: "developer.openSpansLink", defaultValue: "Open spans", bundle: .module) + } + + static var developerLogViewMode: String { + String(localized: "developer.logViewMode", defaultValue: "Log View Mode", bundle: .module) + } + + static var developerLogViewModeFooter: String { + String( + localized: "developer.logViewMode.footer", + defaultValue: "Reveal an inspect badge on tagged views to open their recent logs.", + bundle: .module, + ) + } + static var developerInspectorLink: String { localized("developer.inspectorLink") } diff --git a/Where/WhereUI/Tests/ScreenHostingTests.swift b/Where/WhereUI/Tests/ScreenHostingTests.swift index 1b831fee..99c53159 100644 --- a/Where/WhereUI/Tests/ScreenHostingTests.swift +++ b/Where/WhereUI/Tests/ScreenHostingTests.swift @@ -1,4 +1,5 @@ -import LogViewerUI +import PeriscopeCore +import PeriscopeTools import RegionKit import SwiftUI import TestHostSupport @@ -228,20 +229,35 @@ struct ScreenHostingTests { } } - @Test func debugLogViewerHostsWithSharedStore() throws { - // The developer tools surface pushes this viewer over WhereLog's buffer. + @Test func periscopeViewerHostsOverTheLogStore() async throws { + // The developer tools surface pushes this viewer over the process-global + // Periscope store. + let store = try await PeriscopeStore.make(storage: .inMemory, session: .current()) let rootView = NavigationStack { - LogViewer(configuration: LogViewerConfiguration(store: WhereLog.store, title: "Logs")) + PeriscopeViewer(store: store, title: "Logs") } try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) } } - @Test func developerToolsViewHosts() throws { - // Reads the logged-in session from the environment for the SwiftData - // inspector row; owns its own navigation stack for the pushed viewers. + @Test func openSpansViewHosts() throws { + // The open-spans monitor reads the shared system directly. + let rootView = NavigationStack { + OpenSpansView(system: .shared) + } + try show(UIHostingController(rootView: rootView)) { hosted in + #expect(hosted.view != nil) + } + } + + @Test func developerToolsViewHosts() async throws { + // Reads the app model (for the log store) and the logged-in session (for + // the SwiftData inspector row) from the environment; owns its own + // navigation stack for the pushed viewers. + let store = try await PeriscopeStore.make(storage: .inMemory, session: .current()) let rootView = DeveloperToolsView() + .environment(PreviewSupport.loadedModel(withLogStore: store)) .environment(PreviewSupport.loadedSession()) try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) @@ -252,6 +268,7 @@ struct ScreenHostingTests { // The floating overlay mounts (collapsed) with a session available for the // tools it can expand into. let rootView = DeveloperOverlay() + .environment(PreviewSupport.loadedModel()) .environment(PreviewSupport.loadedSession()) try show(UIHostingController(rootView: rootView)) { hosted in #expect(hosted.view != nil) diff --git a/Where/WhereUI/Tests/StringsTests.swift b/Where/WhereUI/Tests/StringsTests.swift index 86c44d70..03723d86 100644 --- a/Where/WhereUI/Tests/StringsTests.swift +++ b/Where/WhereUI/Tests/StringsTests.swift @@ -146,6 +146,12 @@ struct StringsTests { #expect(Strings.developerTitle == "Developer") #expect(Strings.developerLogsLink == "Logs") #expect(Strings.developerLogsTitle == "Logs") + #expect(Strings.developerOpenSpansLink == "Open spans") + #expect(Strings.developerLogViewMode == "Log View Mode") + #expect( + Strings.developerLogViewModeFooter + == "Reveal an inspect badge on tagged views to open their recent logs.", + ) #expect(Strings.developerInspectorLink == "SwiftData Inspector") #expect(Strings.developerInspectorTitle == "SwiftData") #expect(Strings.developerRegionMapLink == "Region map") diff --git a/Where/WhereUI/Tests/WhereLaunchTests.swift b/Where/WhereUI/Tests/WhereLaunchTests.swift index ca30ceda..f8a975cf 100644 --- a/Where/WhereUI/Tests/WhereLaunchTests.swift +++ b/Where/WhereUI/Tests/WhereLaunchTests.swift @@ -1,5 +1,6 @@ import Foundation import LifecycleKit +@_spi(Testing) import PeriscopeCore import RegionKit import TestHostSupport import Testing @@ -188,6 +189,17 @@ struct WhereLaunchTests { #expect(launcher.phase.isReady) } + @Test func attachingLogStoreExposesItOnTheModel() async throws { + // The launch bootstrap opens the process-global store off the critical + // path and hands it to the model so the developer surface can browse it. + // A fresh model has none until then. + let model = try makeModel(preferences: makePreferences()) + #expect(model.logStore == nil) + let store = try await PeriscopeStore.inMemory(session: .current()) + model.attach(logStore: store) + #expect(model.logStore === store) + } + @Test func backgroundLaunchSkipsOnboardingAndReachesReady() async throws { // Not onboarded — but a headless background launch must skip the // foreground-only onboarding step (waiting for a tap with no UI would diff --git a/Where/WhereWidgets/AGENTS.md b/Where/WhereWidgets/AGENTS.md index 7fc10db3..5d5da2e9 100644 --- a/Where/WhereWidgets/AGENTS.md +++ b/Where/WhereWidgets/AGENTS.md @@ -11,9 +11,11 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature - **Tuist app-extension target** ([`Project.swift`](../../Project.swift), bundle ID `com.stuff.where.widgets`), depending on **WhereCore**, - **WhereUI**, **RegionKit**, and **LogKit**. + **WhereUI**, **RegionKit**, and **PeriscopeCore**. - Must **not** import SwiftData, open the user's store, or duplicate aggregation logic — the app publishes; the extension only reads and renders. +- Logs via the `WhereLog` facade (typed `WhereWidgetsLog` events); as a + separate WidgetKit process its `Periscope.shared` is OSLog-only (no store). - No test bundle; behavior is covered from **WhereCore** and **WhereUI**. ## Refresh contract diff --git a/Where/WhereWidgets/README.md b/Where/WhereWidgets/README.md index 054c57d3..3ca9cbc1 100644 --- a/Where/WhereWidgets/README.md +++ b/Where/WhereWidgets/README.md @@ -44,7 +44,7 @@ app never wakes. `WhereWidgets` is a Tuist app-extension target in [`Project.swift`](../../Project.swift) (bundle ID `com.stuff.where.widgets`). It depends on **WhereCore**, **WhereUI**, **RegionKit** (for the `Region` model -its snapshot fixtures use), and **LogKit**. The main **Where** app embeds the +its snapshot fixtures use), and **PeriscopeCore**. The main **Where** app embeds the extension and shares the App Group entitlement. ## Previews diff --git a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift index cb61a610..3cc6022f 100644 --- a/Where/WhereWidgets/Sources/WhereWidgetProvider.swift +++ b/Where/WhereWidgets/Sources/WhereWidgetProvider.swift @@ -1,4 +1,4 @@ -import LogKit +import PeriscopeCore import WhereCore import WidgetKit @@ -14,7 +14,7 @@ struct WhereWidgetEntry: TimelineEntry { /// even if the app never wakes; the snapshot's data is refreshed by the app /// process after each committed write (see `WidgetTimelineRefreshing`). struct WhereWidgetProvider: TimelineProvider { - private static let logger = WhereLog.channel(.whereWidgets) + private static let logger = WhereLog.root(WhereWidgetsLog.self) private static let calendar = WidgetSnapshotFixtures.calendar func placeholder(in _: Context) -> WhereWidgetEntry { @@ -52,9 +52,11 @@ struct WhereWidgetProvider: TimelineProvider { if let snapshot = store.read() { return WhereWidgetEntry(date: now, snapshot: snapshot) } - Self.logger.warning("No published widget snapshot; rendering empty state") + Self.logger { .noPublishedSnapshot } } catch { - Self.logger.error("Widget App Group unavailable: \(error)") + Self.logger(attachments: [.error(error, name: "app-group-error")]) { + .appGroupUnavailable(description: String(describing: error)) + } } return WhereWidgetEntry( date: now, diff --git a/Where/WhereWidgets/Sources/WhereWidgetsLog.swift b/Where/WhereWidgets/Sources/WhereWidgetsLog.swift new file mode 100644 index 00000000..802e15db --- /dev/null +++ b/Where/WhereWidgets/Sources/WhereWidgetsLog.swift @@ -0,0 +1,31 @@ +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) + + 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)" + } + } +}