diff --git a/AGENTS.md b/AGENTS.md index cd7fa3b3..f0b7f74b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ | SwiftFormat | 0.60.1 | `.mise.toml` | | Swift PM | 6.2 | `Package.swift` (`swift-tools-version`) | -**Libraries** (**StuffCore**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **TestHostSupport**, **RegionKit**, **WhereCore**, **WhereUI**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles. +**Libraries** (**StuffCore**, **LifecycleKit**, **LogKit**, **LogViewerUI**, **PeriscopeCore**, **PeriscopeUI**, **PeriscopeTools**, **TestHostSupport**, **RegionKit**, **WhereCore**, **WhereUI**) are defined in the root [`Package.swift`](Package.swift) — local package for libraries, Tuist for apps and test bundles. Tuist manifests live at the repo root ([`Project.swift`](Project.swift), [`Tuist.swift`](Tuist.swift)). `Project.swift` references `Package.local(path: .relativeToRoot("."))` and declares the **Where** app, **StuffTestHost**, and unit-test targets that depend on package products. @@ -53,8 +53,8 @@ by `./sync-agents`. ## Targets -- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/)), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser), and **TestHostSupport** ([`Shared/TestHostSupport/Sources/`](Shared/TestHostSupport/Sources/), the shared UIKit hosting + run-loop helpers for hosted test bundles) under [`Shared/`](Shared/); the **Broadway** design-system libraries **BroadwayCore** / **BroadwayUI** under [`Shared/Broadway/`](Shared/Broadway/) (see [`Shared/Broadway/AGENTS.md`](Shared/Broadway/AGENTS.md)); **RegionKit** ([`Where/RegionKit/Sources/`](Where/RegionKit/Sources/), the geometry + GeoJSON + region-lookup engine WhereCore builds on) / **WhereCore** / **WhereUI** under [`Where/`](Where/). -- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)) and its embedded app extensions **WhereWidgets** ([`Where/WhereWidgets/`](Where/WhereWidgets/), home/lock-screen widgets) and **WhereShareExtension** ([`Where/WhereShareExtension/`](Where/WhereShareExtension/), a Share-sheet action that saves shared content as evidence into the App Group store), **RegionViewer** ([`Where/RegionViewer/`](Where/RegionViewer/), a thin standalone **Mac Catalyst** host for the WhereUI region-map developer tool — the only target with a `.macCatalyst` destination), **BroadwayCatalog** ([`Shared/Broadway/BroadwayCatalog/`](Shared/Broadway/BroadwayCatalog/), the Broadway component showcase app), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), and hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **RegionKitTests**, **WhereCoreTests**, **WhereUITests**, **BroadwayCoreTests**, **BroadwayUITests**, **BroadwayCatalogTests**) that depend on **StuffTestHost** + **TestHostSupport** + the relevant package product (most via the shared `unitTests` helper). +- **Package products** ([`Package.swift`](Package.swift)) — **StuffCore** ([`Shared/StuffCore/Sources/`](Shared/StuffCore/Sources/)), **LifecycleKit** ([`Shared/LifecycleKit/Sources/`](Shared/LifecycleKit/Sources/)), **LogKit** ([`Shared/LogKit/Sources/`](Shared/LogKit/Sources/), the logging facade), **LogViewerUI** ([`Shared/LogViewerUI/Sources/`](Shared/LogViewerUI/Sources/), the generic SwiftUI log viewer), **PeriscopeCore** / **PeriscopeUI** / **PeriscopeTools** ([`Shared/Periscope/`](Shared/Periscope/), the typed hierarchical observability framework — core + SwiftData store, SwiftUI environment integration, and on-device exploration tooling), **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser), and **TestHostSupport** ([`Shared/TestHostSupport/Sources/`](Shared/TestHostSupport/Sources/), the shared UIKit hosting + run-loop helpers for hosted test bundles) under [`Shared/`](Shared/); the **Broadway** design-system libraries **BroadwayCore** / **BroadwayUI** under [`Shared/Broadway/`](Shared/Broadway/) (see [`Shared/Broadway/AGENTS.md`](Shared/Broadway/AGENTS.md)); **RegionKit** ([`Where/RegionKit/Sources/`](Where/RegionKit/Sources/), the geometry + GeoJSON + region-lookup engine WhereCore builds on) / **WhereCore** / **WhereUI** under [`Where/`](Where/). +- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)) and its embedded app extensions **WhereWidgets** ([`Where/WhereWidgets/`](Where/WhereWidgets/), home/lock-screen widgets) and **WhereShareExtension** ([`Where/WhereShareExtension/`](Where/WhereShareExtension/), a Share-sheet action that saves shared content as evidence into the App Group store), **RegionViewer** ([`Where/RegionViewer/`](Where/RegionViewer/), a thin standalone **Mac Catalyst** host for the WhereUI region-map developer tool — the only target with a `.macCatalyst` destination), **BroadwayCatalog** ([`Shared/Broadway/BroadwayCatalog/`](Shared/Broadway/BroadwayCatalog/), the Broadway component showcase app), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), and hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **PeriscopeCoreTests**, **PeriscopeUITests**, **PeriscopeToolsTests**, **SwiftDataInspectorTests**, **RegionKitTests**, **WhereCoreTests**, **WhereUITests**, **BroadwayCoreTests**, **BroadwayUITests**, **BroadwayCatalogTests**) that depend on **StuffTestHost** + **TestHostSupport** + the relevant package product (most via the shared `unitTests` helper). - 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. diff --git a/Package.swift b/Package.swift index 956f5030..e8d62bf2 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,9 @@ let package = Package( .library(name: "LifecycleKit", targets: ["LifecycleKit"]), .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"]), .library(name: "SwiftDataInspector", targets: ["SwiftDataInspector"]), .library(name: "TestHostSupport", targets: ["TestHostSupport"]), .library(name: "RegionKit", targets: ["RegionKit"]), @@ -47,6 +50,25 @@ let package = Package( ], path: "Shared/LogViewerUI/Sources", ), + .target( + name: "PeriscopeCore", + path: "Shared/Periscope/PeriscopeCore/Sources", + ), + .target( + name: "PeriscopeUI", + dependencies: [ + .target(name: "PeriscopeCore"), + ], + path: "Shared/Periscope/PeriscopeUI/Sources", + ), + .target( + name: "PeriscopeTools", + dependencies: [ + .target(name: "PeriscopeCore"), + .target(name: "PeriscopeUI"), + ], + path: "Shared/Periscope/PeriscopeTools/Sources", + ), .target( name: "SwiftDataInspector", path: "Shared/SwiftDataInspector/Sources", diff --git a/Project.swift b/Project.swift index 01de375d..d03d736d 100644 --- a/Project.swift +++ b/Project.swift @@ -281,6 +281,29 @@ let project = Project( productDependency: "LogViewerUI", sources: ["Shared/LogViewerUI/Tests/**"], ), + unitTests( + name: "PeriscopeCoreTests", + bundleIdSuffix: "periscopecore", + productDependency: "PeriscopeCore", + sources: ["Shared/Periscope/PeriscopeCore/Tests/**"], + ), + unitTests( + name: "PeriscopeUITests", + bundleIdSuffix: "periscopeui", + productDependency: "PeriscopeUI", + sources: ["Shared/Periscope/PeriscopeUI/Tests/**"], + extraPackageProducts: ["PeriscopeCore"], + ), + unitTests( + name: "PeriscopeToolsTests", + bundleIdSuffix: "periscopetools", + productDependency: "PeriscopeTools", + sources: ["Shared/Periscope/PeriscopeTools/Tests/**"], + extraPackageProducts: [ + "PeriscopeCore", + "PeriscopeUI", + ], + ), unitTests( name: "SwiftDataInspectorTests", bundleIdSuffix: "swiftdatainspector", @@ -404,6 +427,9 @@ let project = Project( "LifecycleKitTests", "LogKitTests", "LogViewerUITests", + "PeriscopeCoreTests", + "PeriscopeUITests", + "PeriscopeToolsTests", "SwiftDataInspectorTests", "RegionKitTests", "WhereCoreTests", @@ -420,6 +446,9 @@ let project = Project( "LifecycleKitTests", "LogKitTests", "LogViewerUITests", + "PeriscopeCoreTests", + "PeriscopeUITests", + "PeriscopeToolsTests", "SwiftDataInspectorTests", "RegionKitTests", "WhereCoreTests", @@ -435,6 +464,9 @@ let project = Project( testScheme(name: "LifecycleKitTests"), testScheme(name: "LogKitTests"), testScheme(name: "LogViewerUITests"), + testScheme(name: "PeriscopeCoreTests"), + testScheme(name: "PeriscopeUITests"), + testScheme(name: "PeriscopeToolsTests"), testScheme(name: "SwiftDataInspectorTests"), testScheme(name: "RegionKitTests"), testScheme(name: "WhereCoreTests"), diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md new file mode 100644 index 00000000..e824fdbd --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -0,0 +1,82 @@ +# PeriscopeCore – Module Shape + +PeriscopeCore is the core of the **Periscope** observability framework: typed +`Codable` log events, the `Log` scope hierarchy, tags, spans, the sink +pipeline, ambient event sources, and the SwiftData store. See +[`README.md`](README.md) for the narrative and API. + +This file complements the root [`AGENTS.md`](../../../AGENTS.md), which owns +the build system, formatting, and global conventions. Read that first. + +## Layout + +`Sources/` groups by concern: `Events/` (the event vocabulary — levels, +tags, attachments), `Loggers/` (`Log` and the scope tree), +`Context/` (task-local and per-instance context derivation), `Spans/` +(timing and exits), `Pipeline/` (the `Periscope` system, records, sinks), +`Store/` (SwiftData persistence and queries), and `Ambient/` (environmental +event sources). Tests stay flat, named 1:1 with their source files. + +## Scope & dependencies + +- **Foundation + os + SwiftData + Network only** (plus the ObjectiveC + runtime, solely for `LogContextProviding`'s deallocation trackers). No + SwiftUI, no app code, no LogKit. UIKit is allowed **only** inside + `#if canImport(UIKit)` (ambient sources, the image-attachment + convenience). +- Layering: `PeriscopeUI` and `PeriscopeTools` depend on this module — never + the reverse. + +## Invariants + +- **Emitting never blocks the caller.** Log calls append to a lock-guarded + buffer synchronously; sinks (OSLog, SwiftData) drain asynchronously, and + sink delivery order is emission order with scope definitions first. + Live streams see buffered order too: observer yields happen *under* the + state lock (yields only buffer) — yielding outside it would let racing + emitters invert live delivery, e.g. a span's end before its began. +- **Scope IDs are deterministic** (hash of parent + name) — the same path is + the same scope across processes and launches; `begin`/`end` span pairing + and cross-layer links rely on this. +- **Persistence must retain the full hierarchy** — events reference scopes + many-to-many (links), and scopes keep their parent chain. +- **Custom levels are values, not cases.** `LogLevel` is a struct ordered by + `severity`; never switch exhaustively over "all" levels. +- **Sink failures never propagate or vanish** — the store logs them to + OSLog, counts them, and persists a synthetic `StoreWriteFailed` marker + for the lost batch; the pipeline reports drops with a synthetic + `DroppedEvents` record. +- **A failed store save must roll back** (`recoverFromFailedWrite`): + the context is discarded, row caches drop, and the session row refetches + by identity — one poisoned batch must never wedge subsequent saves or + fork the session. +- **Payloads persist as versioned JSON** (`eventName` + `eventVersion`), not + per-event schemas — changing an event's shape must not require a SwiftData + migration. +- **Every span eventually ends, and its began is delivered first.** + `measure` closes on every path (including throw/cancellation); bounded + spans expire via the watchdog; re-begins supersede rather than refuse; + relaunch orphan-closes `endsWithProcess` spans. Don't add a span path + that can leave `openSpans` growing forever (`survivesRelaunch` resume is + the one staged exception — see [`TODOs.md`](../TODOs.md)). Registration + and the `SpanBegan` record land atomically (`LogRecorder.beginSpan`), so + a span is never closable before its began is in the pipeline; the + overflow drop policy never splits a recorded pair + (`LogEvent.isProtectedFromDropping`, an event-type opt-in the span pair + events set); and redaction is transform-only + for pair records (suppression falls back to a stripped copy). Keep all + three. +- **Span pairs floor together.** The floor decision is made once, at + begin, and the whole lifecycle follows it (`OpenSpan.beganRecorded`, + `LogRecord.bypassesFloors`): a recorded began always gets its end — + normal, expired, or superseded — even if floors rise mid-span, and a + floored began silences the entire span (overdue sentinel included). + Never a dangling half. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeCoreTests`). Use in-memory stores, fresh `Periscope` systems per +test (never the shared singleton), and injected clocks. Note that +`Log()` defaults to `.shared` — a deliberate ergonomics exception to +the no-Core-defaults rule — so tests must always pass `system:` explicitly. diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md new file mode 100644 index 00000000..26670158 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -0,0 +1,139 @@ +# PeriscopeCore + +The core of **Periscope**, a typed, hierarchical observability framework. +Periscope logs **structured `Codable` events** (alongside freeform messages) +through **typed loggers** (`Log`) arranged in a **scope tree**, stamps +them with **tags**, times work with **spans**, and persists everything — +hierarchy included — to **SwiftData** so days or weeks of history stay +queryable on device. + +PeriscopeCore owns the model and the machinery: events, levels, scopes, +links, tags, spans, attachments, the sink pipeline (OSLog + SwiftData +built-in), ambient event sources, and the store. SwiftUI integration lives in +[`PeriscopeUI`](../PeriscopeUI); the on-device viewer, tracer, toast, and +inspect mode live in [`PeriscopeTools`](../PeriscopeTools). + +## Vocabulary + +| Periscope term | Industry equivalent | +|----------------|---------------------| +| Scope | OTel `InstrumentationScope` — a node in the logger hierarchy | +| Link | OTel span links — one event referencing several scopes | +| Span | OTel span — a timed operation with a shared `SpanID` | +| Session | OTel `Resource` — per-launch app/OS/device metadata | +| Tag | Datadog/Jaeger tags — typed key/value (`LogTagValue`: string, int, double, bool, or any `Codable` via `.encoding`) stamped on events | + +## Installation + +`PeriscopeCore` is a local SPM library in this repo +(`Shared/Periscope/PeriscopeCore`). Add it to a target's dependencies in +[`Package.swift`](../../../Package.swift): + +```swift +.target(name: "YourModule", dependencies: [.target(name: "PeriscopeCore")]) +``` + +## Quick start + +Define events, derive loggers, log: + +```swift +import PeriscopeCore + +struct PhotoLogs: LogEvent { + var photoID: String + var message: String { "Uploaded \(photoID)" } +} + +let root = Log() // records into Periscope.shared +let photos = root(PhotoLogs.self) // typed child scope +let album = photos(for: album.id) // child scope keyed by an entity + +album { PhotoLogs(photoID: photo.id) } // structured event +album.warning("thumbnail cache miss") // freeform, any Log can +photos(for: album.id) { PhotoLogs(photoID: photo.id) } // derive + emit in one call + +let joined = album + screenLog // link model + UI contexts +let tagged = joined.tagged(.paymentID, payment.id) // stamps every event +``` + +Wire persistence at startup: + +```swift +let store = try await PeriscopeStore.make(storage: .onDisk, session: .current()) +Periscope.shared.add(sink: store) +Periscope.shared.startDefaultAmbientSources() +``` + +## Public API + +- **Events** — `LogEvent` (`Codable & Sendable`; `eventName`, `eventVersion`, + `level`, `message`), the built-in freeform `Message`, and the extensible + `LogLevel` struct (`name` + `severity`; standard ladder `debug…fault`, + custom levels slot between). +- **Loggers** — `Log`: derive typed children (`log(PhotoLogs.self)`), + entity children (`log(for: id)`), link contexts (`+` / `linked(with:)`), + tag (`tagged(_:_:)`), and emit (trailing closure, level conveniences, + `attachments:`). Scope IDs are deterministic (parent + name), so the same + path is the same scope in any process or launch. +- **Propagation** — `log.withContext { … }` binds the context to a + `@TaskLocal`; `Log.current` reads it anywhere in the async call tree. + `LogContextProviding` gives classes a derived per-instance `.log`. +- **Spans** — `log.measure(.token) { … }` (sync/async) emits paired + `SpanBegan`/`SpanEnded` events with the exit derived automatically + (return → `.success`, throw → `.failure`, `CancellationError` → + `.cancelled`), and an optional `budget:` fires a `SpanOverdue` warning + while the closure hangs past it. Names resolve against `Event.SpanName` + (defaults to `String`); declare a `SpanName` enum on the event type for + compiler-checked tokens — the recommended style for structured events. + Open-ended flows use `begin(for:lifetime:relaunch:)`/`end(for:exit:)`. + Every span provably ends: bounded spans expire past + their budget (watchdog, `.expired`), re-begins supersede the open span + (`.superseded`), and a relaunch closes `endsWithProcess` spans the dead + process left open (`.orphaned`, duration unknowable). Durations use + `ContinuousClock`; spans mirror to `OSSignposter`. +- **Attachments** — `LogAttachment` (+ `.error`, `.json`, `.image` + conveniences) rides along with any event; blobs persist externally and + load on demand. +- **System** — `Periscope`: the recorder and `LogSink` pipeline (OSLog sink + built in), level floors (`minimumLevel`, `setMinimumLevel(_:forSubtree:)`), + flush threshold, bounded drop policy with synthetic `DroppedEvents`, + redaction hook, recent buffer + `liveRecords()` stream, ambient + sources (`startAmbientSource`, `startDefaultAmbientSources`, + `stopAmbientSources`), and the `isInspectModeEnabled` flag behind + PeriscopeTools' log view mode. +- **Store** — `PeriscopeStore` (`@ModelActor` `LogSink`): sessions + (`LogSession`), `events(matching: LogQuery)` (time range, level floor, + event name, session, scope/subtree, tags (AND), search, paging), + `events(inSpan:)`, `attachments(forEvent:)`, retention + (`pruneEvents(olderThan:/keepingNewest:)`), and a `changes()` signal. + +## How it works + +Log call sites never block: records append to a lock-guarded pending queue +and a background drain task delivers ordered batches to each sink (scope +definitions always precede the records referencing them). Error-and-above +events trigger an automatic flush; queue overflow drops oldest and reports +the gap (scope definitions and span began/ended pairs are exempt). Event payloads persist as JSON keyed by `eventName` + `eventVersion` +so old rows outlive their Swift types — `StoredLogEvent.decode(_:)` recovers +the type, and tooling degrades to raw JSON when it can't. + +## Contracts & limitations + +- Messages mirror to OSLog as `.public` — keep PII out of messages, or scrub + via the redaction hook. The hook may transform any record but cannot + suppress span began/ended records (a stripped copy records instead — + pairs never split); silence spans with level floors. +- One database for every logging system in the process; scopes and types + make it easy to split later. +- `LogContextProviding` caches one small entry per logging instance, evicted + automatically when the instance deallocates (a tracker hangs off the + instance via the ObjC runtime). Instance numbers (`#1`, `#2`, …) are never + reused within a run, so persisted identities stay unambiguous. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeCoreTests` bundle). Tests use fresh `Periscope` systems, in-memory +stores (`@_spi(Testing) PeriscopeStore.inMemory`), and condition polling — +run with `tuist test PeriscopeCoreTests`. diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AccessibilityAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AccessibilityAmbientSource.swift new file mode 100644 index 00000000..541e7a7a --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AccessibilityAmbientSource.swift @@ -0,0 +1,94 @@ +#if canImport(UIKit) + import Foundation + import UIKit + + /// Logs which accessibility settings are enabled: one summary event at + /// start (the state any of the session's events can be read against), + /// then a change event per toggle — VoiceOver, Switch Control, Reduce + /// Motion, and friends often explain "it behaves differently for this + /// user". + public struct AccessibilityAmbientSource: AmbientEventSource { + /// One observed setting: display name, change notification, and + /// current-state accessor (UIAccessibility statics are main-actor). + private struct Setting { + let name: String + let notification: Notification.Name + let isEnabled: @MainActor @Sendable () -> Bool + } + + private static let settings: [Setting] = [ + Setting( + name: "voiceover", + notification: UIAccessibility.voiceOverStatusDidChangeNotification, + isEnabled: { UIAccessibility.isVoiceOverRunning }, + ), + Setting( + name: "switch-control", + notification: UIAccessibility.switchControlStatusDidChangeNotification, + isEnabled: { UIAccessibility.isSwitchControlRunning }, + ), + Setting( + name: "reduce-motion", + notification: UIAccessibility.reduceMotionStatusDidChangeNotification, + isEnabled: { UIAccessibility.isReduceMotionEnabled }, + ), + Setting( + name: "reduce-transparency", + notification: UIAccessibility.reduceTransparencyStatusDidChangeNotification, + isEnabled: { UIAccessibility.isReduceTransparencyEnabled }, + ), + Setting( + name: "bold-text", + notification: UIAccessibility.boldTextStatusDidChangeNotification, + isEnabled: { UIAccessibility.isBoldTextEnabled }, + ), + Setting( + name: "darker-colors", + notification: UIAccessibility.darkerSystemColorsStatusDidChangeNotification, + isEnabled: { UIAccessibility.isDarkerSystemColorsEnabled }, + ), + Setting( + name: "invert-colors", + notification: UIAccessibility.invertColorsStatusDidChangeNotification, + isEnabled: { UIAccessibility.isInvertColorsEnabled }, + ), + Setting( + name: "grayscale", + notification: UIAccessibility.grayscaleStatusDidChangeNotification, + isEnabled: { UIAccessibility.isGrayscaleEnabled }, + ), + ] + + private let tokens = AmbientObserverTokens() + + public init() {} + + public func start(log: Log) { + tokens.replace(with: Self.settings.map { setting in + NotificationCenter.default.addObserver( + forName: setting.notification, + object: nil, + queue: .main, + ) { _ in + MainActor.assumeIsolated { + let state = setting.isEnabled() ? "on" : "off" + log { + AmbientEvent(kind: .accessibility, value: "\(setting.name): \(state)") + } + } + } + }) + Task { @MainActor in + let enabled = Self.settings.filter { $0.isEnabled() }.map(\.name) + let summary = enabled.isEmpty + ? "none enabled" + : "enabled: \(enabled.joined(separator: ", "))" + log { AmbientEvent(kind: .accessibility, value: summary) } + } + } + + public func stop() { + tokens.removeAll() + } + } +#endif diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift new file mode 100644 index 00000000..f501e5d7 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift @@ -0,0 +1,51 @@ +import Foundation + +/// The category of an ambient event — typed so a kind can't silently typo +/// into a new, untracked identifier. Apps add their own: +/// +/// ```swift +/// extension AmbientKind { +/// static let pushToken = AmbientKind("push-token") +/// } +/// ``` +public struct AmbientKind: Hashable, Sendable, Codable, CustomStringConvertible { + public let rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public var description: String { + rawValue + } +} + +extension AmbientKind { + public static let appLifecycle = AmbientKind("app-lifecycle") + public static let memory = AmbientKind("memory") + public static let network = AmbientKind("network") + public static let thermalState = AmbientKind("thermal-state") + public static let powerMode = AmbientKind("power-mode") + public static let accessibility = AmbientKind("accessibility") +} + +/// The standard event ambient sources emit: environmental context — +/// backgrounding, memory pressure, connectivity, thermal state — that helps +/// diagnose what the system was doing around an error. +public struct AmbientEvent: LogEvent, Hashable { + public static let eventName = "ambient" + + public var kind: AmbientKind + public var value: String + public var level: LogLevel + + public var message: String { + "\(kind): \(value)" + } + + public init(kind: AmbientKind, value: String, level: LogLevel = .info) { + self.kind = kind + self.value = value + self.level = level + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift new file mode 100644 index 00000000..0c683112 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift @@ -0,0 +1,83 @@ +import Foundation +import os + +/// A source of ambient/environmental events. Built-ins cover app lifecycle, +/// memory warnings, network path, thermal state, low power mode, and +/// accessibility settings; apps conform to add their own (push +/// registration, sync status, …). +/// +/// Sources are registered with ``Periscope/startAmbientSource(_:)``, which +/// retains them and hands them a logger under the shared ambient scope; +/// ``Periscope/stopAmbientSources()`` stops and releases them. +public protocol AmbientEventSource: Sendable { + /// Begin observing and log every observed change into `log`. A + /// restart must replace the prior observation, not double it (the + /// built-ins swap their observer tokens wholesale). + func start(log: Log) + + /// End the observation: remove notification observers, cancel + /// monitors. Nothing may keep logging (or retaining the logger's + /// system) after this returns. + func stop() +} + +/// Retains `NotificationCenter` observer tokens for a `Sendable` source +/// struct. The block-based observer API holds its token strongly *in the +/// center* until removal — dropping the token doesn't end the observation, +/// it makes it unremovable (and immortalizes everything the block +/// captures, including the logger's whole system). Public because +/// app-defined notification-based sources face the same trap. +public struct AmbientObserverTokens: Sendable { + private let tokens = OSAllocatedUnfairLock<[any NSObjectProtocol]>(uncheckedState: []) + + public init() {} + + /// Store `new`, removing any previously stored observers first — a + /// restart replaces the observation rather than doubling it. + public func replace(with new: [any NSObjectProtocol]) { + let old = tokens.withLockUnchecked { boxed -> [any NSObjectProtocol] in + let old = boxed + boxed = new + return old + } + for token in old { + NotificationCenter.default.removeObserver(token) + } + } + + public func removeAll() { + replace(with: []) + } +} + +extension Periscope { + /// Retain `source` and start it with a logger under this system's + /// ambient scope. + public func startAmbientSource(_ source: some AmbientEventSource) { + retainAmbientSource(source) + source.start(log: Log(recorder: self)) + } + + /// Stop and release every ambient source started on this system — the + /// counterpart to ``startAmbientSource(_:)``. Sources stop observing + /// immediately; events they already emitted still deliver. + public func stopAmbientSources() { + for source in releaseAmbientSources() { + source.stop() + } + } + + /// Start every built-in ambient source: network path, thermal state, + /// low power mode, and (where UIKit exists) app lifecycle, memory + /// warnings, and accessibility settings. + public func startDefaultAmbientSources() { + startAmbientSource(NetworkPathAmbientSource()) + startAmbientSource(ThermalStateAmbientSource()) + startAmbientSource(LowPowerModeAmbientSource()) + #if canImport(UIKit) + startAmbientSource(AppLifecycleAmbientSource()) + startAmbientSource(MemoryWarningAmbientSource()) + startAmbientSource(AccessibilityAmbientSource()) + #endif + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift new file mode 100644 index 00000000..cfe68e01 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift @@ -0,0 +1,47 @@ +#if canImport(UIKit) + import Foundation + import UIKit + + /// Logs scene lifecycle transitions — background, foreground, active, + /// inactive — so error investigations can see what the app was doing. + public struct AppLifecycleAmbientSource: AmbientEventSource { + private let tokens = AmbientObserverTokens() + + public init() {} + + public func start(log: Log) { + tokens.replace(with: [ + observe( + UIApplication.didEnterBackgroundNotification, + log: log, + value: "background", + ), + observe( + UIApplication.willEnterForegroundNotification, + log: log, + value: "foreground", + ), + observe(UIApplication.didBecomeActiveNotification, log: log, value: "active"), + observe(UIApplication.willResignActiveNotification, log: log, value: "inactive"), + ]) + } + + public func stop() { + tokens.removeAll() + } + + private func observe( + _ name: Notification.Name, + log: Log, + value: String, + ) -> any NSObjectProtocol { + NotificationCenter.default.addObserver( + forName: name, + object: nil, + queue: nil, + ) { _ in + log { AmbientEvent(kind: .appLifecycle, value: value) } + } + } + } +#endif diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift new file mode 100644 index 00000000..72ac6671 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Logs Low Power Mode transitions — background work behaves differently +/// under it, which matters when diagnosing "it only breaks sometimes". +public struct LowPowerModeAmbientSource: AmbientEventSource { + private let tokens = AmbientObserverTokens() + + public init() {} + + public func start(log: Log) { + let token = NotificationCenter.default.addObserver( + forName: .NSProcessInfoPowerStateDidChange, + object: nil, + queue: nil, + ) { _ in + let enabled = ProcessInfo.processInfo.isLowPowerModeEnabled + log { AmbientEvent(kind: .powerMode, value: enabled ? "low-power" : "normal") } + } + tokens.replace(with: [token]) + } + + public func stop() { + tokens.removeAll() + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift new file mode 100644 index 00000000..3bcb287d --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift @@ -0,0 +1,27 @@ +#if canImport(UIKit) + import Foundation + import UIKit + + /// Logs system memory warnings at `.warning` — the classic missing + /// context when diagnosing a jetsam-adjacent crash. + public struct MemoryWarningAmbientSource: AmbientEventSource { + private let tokens = AmbientObserverTokens() + + public init() {} + + public func start(log: Log) { + let token = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil, + ) { _ in + log { AmbientEvent(kind: .memory, value: "warning", level: .warning) } + } + tokens.replace(with: [token]) + } + + public func stop() { + tokens.removeAll() + } + } +#endif diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift new file mode 100644 index 00000000..e3d3062b --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift @@ -0,0 +1,65 @@ +import Foundation +import Network +import os + +/// Logs network path changes (`NWPathMonitor`) — connectivity loss, +/// airplane-mode-style transitions, and interface changes (Wi-Fi ↔ +/// cellular). +public struct NetworkPathAmbientSource: AmbientEventSource { + /// The running monitor; boxed so the source stays a Sendable value. + private let monitor = OSAllocatedUnfairLock(uncheckedState: nil) + + public init() {} + + public func start(log: Log) { + let started = NWPathMonitor() + started.pathUpdateHandler = { path in + log { AmbientEvent(kind: .network, value: Self.describe(path)) } + } + started.start(queue: DispatchQueue(label: "com.stuff.periscope.network-path")) + let previous = monitor.withLockUnchecked { boxed -> NWPathMonitor? in + let previous = boxed + boxed = started + return previous + } + // A repeated start replaces the monitor; without the cancel the old + // one would keep running (and logging) forever. + previous?.cancel() + } + + public func stop() { + let running = monitor.withLockUnchecked { boxed -> NWPathMonitor? in + let running = boxed + boxed = nil + return running + } + running?.cancel() + } + + private static func describe(_ path: NWPath) -> String { + switch path.status { + case .satisfied: + let interfaces = path.availableInterfaces.map(\.type.ambientName) + return "satisfied (\(interfaces.joined(separator: ", ")))" + case .unsatisfied: + return "unsatisfied" + case .requiresConnection: + return "requires-connection" + @unknown default: + return "unknown" + } + } +} + +extension NWInterface.InterfaceType { + fileprivate var ambientName: String { + switch self { + case .wifi: "wifi" + case .cellular: "cellular" + case .wiredEthernet: "wired" + case .loopback: "loopback" + case .other: "other" + @unknown default: "unknown" + } + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift new file mode 100644 index 00000000..3543cd73 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Logs thermal state changes; `serious` and `critical` log at `.warning` +/// since the system is about to start throttling. +public struct ThermalStateAmbientSource: AmbientEventSource { + private let tokens = AmbientObserverTokens() + + public init() {} + + public func start(log: Log) { + let token = NotificationCenter.default.addObserver( + forName: ProcessInfo.thermalStateDidChangeNotification, + object: nil, + queue: nil, + ) { _ in + log { Self.event(for: ProcessInfo.processInfo.thermalState) } + } + tokens.replace(with: [token]) + } + + public func stop() { + tokens.removeAll() + } + + /// The ambient event for a given thermal state — exposed for tests via + /// `@_spi(Testing)` so the mapping stays asserted. + @_spi(Testing) public static func event(for state: ProcessInfo.ThermalState) -> AmbientEvent { + let value: String + let level: LogLevel + switch state { + case .nominal: + value = "nominal" + level = .info + case .fair: + value = "fair" + level = .info + case .serious: + value = "serious" + level = .warning + case .critical: + value = "critical" + level = .warning + @unknown default: + value = "unknown" + level = .info + } + return AmbientEvent(kind: .thermalState, value: value, level: level) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift new file mode 100644 index 00000000..1afd252e --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift @@ -0,0 +1,63 @@ +import Foundation + +/// The task-local log context — the MDC / swift-distributed-tracing +/// pattern. `Log.withContext { … }` binds a context here; it propagates to +/// every structured child task, so deep helpers can log with full context +/// via `Log.current` without a logger threaded through each signature. +enum AmbientLogContext { + struct Context { + var scopes: [LogScope] + var tags: [LogTag] + var recorder: any LogRecorder + } + + @TaskLocal static var current: Context? +} + +extension Log { + /// The ambient logger, typed to `Event`: the context bound by the + /// nearest enclosing ``withContext(isolation:_:)``, or a root logger on + /// ``Periscope/shared`` when none is bound. Freeform helpers use + /// `Log.current`. + public static var current: Log { + guard let context = AmbientLogContext.current else { + return Log() + } + return Log(scopes: context.scopes, tags: context.tags, recorder: context.recorder) + } + + /// Runs `body` with this log's context ambient for the whole async call + /// tree — `Log.current` inside (and in structured child tasks) carries + /// these scopes. Nested calls link: the inner log's scopes come first, + /// the enclosing ambient scopes follow. + public func withContext( + isolation: isolated (any Actor)? = #isolation, + _ body: () async throws -> R, + ) async rethrows -> R { + try await AmbientLogContext.$current.withValue( + ambientContext(), + operation: body, + isolation: isolation, + ) + } + + /// The synchronous form of ``withContext(isolation:_:)``. + public func withContext(_ body: () throws -> R) rethrows -> R { + try AmbientLogContext.$current.withValue(ambientContext(), operation: body) + } + + /// This log's scopes and tags, linked onto any already-ambient context + /// (this log's scopes stay primary and its tags win key conflicts; + /// duplicate scopes collapse). + private func ambientContext() -> AmbientLogContext.Context { + var scopes = scopes + var tags = tags + if let existing = AmbientLogContext.current { + for scope in existing.scopes where !scopes.contains(scope) { + scopes.append(scope) + } + tags = tags.merging(existing.tags) + } + return AmbientLogContext.Context(scopes: scopes, tags: tags, recorder: recorder) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/InstanceID.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/InstanceID.swift new file mode 100644 index 00000000..3c0851f9 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/InstanceID.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Identifies one live object instance: its pointer identity *plus* its +/// dynamic type, so a recycled pointer from a deallocated object of another +/// type can never be mistaken for the original — and debug output names the +/// type instead of showing a bare address. +public struct InstanceID: Hashable, Sendable, CustomDebugStringConvertible { + /// Pointer identity of the instance. + let object: ObjectIdentifier + + /// The instance's dynamic type. + public let type: Any.Type + + /// The dynamic type's name, e.g. `"PhotoController"` — derived on + /// demand from ``type``. + public var typeName: String { + String(describing: type) + } + + public init(of instance: AnyObject) { + object = ObjectIdentifier(instance) + type = Swift.type(of: instance) + } + + public var debugDescription: String { + "\(typeName)@0x\(String(UInt(bitPattern: object), radix: 16))" + } + + public static func == (lhs: InstanceID, rhs: InstanceID) -> Bool { + lhs.object == rhs.object && ObjectIdentifier(lhs.type) == ObjectIdentifier(rhs.type) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(object) + hasher.combine(ObjectIdentifier(type)) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift new file mode 100644 index 00000000..5adf93e4 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift @@ -0,0 +1,142 @@ +import Foundation +import ObjectiveC +import os + +/// Gives a class a derived `.log` without passing loggers around. +/// +/// The logger's scope is derived from the type and the instance: a root +/// scope named after the conforming type, with one child scope per instance +/// (`#1`, `#2`, …, cached by the system so an instance keeps its identity +/// for its lifetime). Filtering the type's scope subtree finds every +/// instance's events. +/// +/// ```swift +/// final class PhotoController: LogContextProviding { +/// typealias LogEventType = PhotoLogs // omit for freeform-only logging +/// +/// func refresh() { +/// log.info("refreshing") // scoped to this instance +/// log { PhotoLogs.refreshed } +/// } +/// } +/// ``` +/// +/// Conformers log into ``Periscope/shared`` unless they override +/// ``logSystem``. +public protocol LogContextProviding: AnyObject { + /// The structured event type `log` emits. Defaults to ``Message`` for + /// freeform-only conformers. + associatedtype LogEventType: LogEvent = Message + + /// The system this object logs into; defaults to ``Periscope/shared``. + var logSystem: Periscope { get } +} + +extension LogContextProviding { + public var logSystem: Periscope { + .shared + } + + /// A logger scoped to this instance (type root scope → instance scope). + public var log: Log { + logSystem.instanceLog(for: self) + } +} + +extension Periscope { + /// The instance-scoped logger backing ``LogContextProviding/log``. + /// Repeated calls for the same instance return the same scope. + public func instanceLog( + for object: Object, + ) -> Log { + let scopes = instanceScopes.scopes(for: object) + defineScope(scopes.type) + return Log(scopes: [scopes.instance], tags: [], recorder: self) + } +} + +/// The per-type and per-instance scopes `InstanceScopeRegistry` hands out. +struct InstanceScopePair { + var type: LogScope + var instance: LogScope +} + +/// Caches one scope per live instance so `LogContextProviding.log` is stable +/// and readable: instances number `#1`, `#2`, … within their type's root +/// scope. +/// +/// Entries are keyed by ``InstanceID`` (pointer *and* type) and evicted when +/// the instance deallocates — a retained tracker hangs off each instance via +/// the ObjC runtime, and its `deinit` (which runs strictly before the +/// allocator can recycle the address) removes the entry. Instance numbers +/// are monotonic and never reused within a run, so a persisted `#3` always +/// means one specific instance. +final class InstanceScopeRegistry: Sendable { + private struct State { + var scopesByInstance: [InstanceID: InstanceScopePair] = [:] + var nextIndexByType: [String: Int] = [:] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + /// Entries currently cached (i.e. tracked instances still alive). + var trackedInstanceCount: Int { + state.withLock { $0.scopesByInstance.count } + } + + func scopes(for object: AnyObject) -> InstanceScopePair { + let id = InstanceID(of: object) + let (pair, isNew) = state.withLock { state -> (InstanceScopePair, Bool) in + if let cached = state.scopesByInstance[id] { + return (cached, false) + } + let index = state.nextIndexByType[id.typeName, default: 1] + state.nextIndexByType[id.typeName] = index + 1 + let typeScope = LogScope.root(named: id.typeName) + let pair = InstanceScopePair( + type: typeScope, + instance: typeScope.child(named: "#\(index)"), + ) + state.scopesByInstance[id] = pair + return (pair, true) + } + if isNew { + installDeallocationTracker(on: object, id: id) + } + return pair + } + + /// Called by a tracker's `deinit` when its host instance deallocates. + func release(_ id: InstanceID) { + state.withLock { $0.scopesByInstance[id] = nil } + } + + /// Retain a tracker on the instance whose `deinit` evicts the cache + /// entry. The association key is this registry's own pointer, so an + /// object logged into two systems carries one tracker per registry. + private func installDeallocationTracker(on object: AnyObject, id: InstanceID) { + objc_setAssociatedObject( + object, + Unmanaged.passUnretained(self).toOpaque(), + InstanceDeallocationTracker(id: id, registry: self), + .OBJC_ASSOCIATION_RETAIN, + ) + } +} + +/// Released exactly when its host instance deallocates; evicts the host's +/// registry entry from `deinit`. Holds the registry weakly so trackers on +/// long-lived objects don't keep short-lived (test) registries alive. +private final class InstanceDeallocationTracker { + private let id: InstanceID + private weak var registry: InstanceScopeRegistry? + + init(id: InstanceID, registry: InstanceScopeRegistry) { + self.id = id + self.registry = registry + } + + deinit { + registry?.release(id) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift new file mode 100644 index 00000000..a50865c7 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift @@ -0,0 +1,109 @@ +import Foundation +#if canImport(UIKit) + import UIKit +#endif + +/// Arbitrary data attached to a log event — an error, a response body, a +/// screenshot, any payload that gives the event context. Attachments +/// persist with `@Attribute(.externalStorage)`, so large blobs live beside +/// the database rather than inside event rows. +public struct LogAttachment: Hashable, Sendable { + /// What an attachment's bytes are — the common capture types as cases, + /// anything else as `.other` with its MIME type. Persists as the MIME + /// string, so `.other` round-trips losslessly. + public enum ContentType: Hashable, Sendable { + case json + case png + case jpeg + case plainText + /// An uncommon type, identified by its MIME type + /// (e.g. `"application/octet-stream"`). + case other(String) + + /// The MIME type string this case persists and displays as. + public var mimeType: String { + switch self { + case .json: "application/json" + case .png: "image/png" + case .jpeg: "image/jpeg" + case .plainText: "text/plain" + case let .other(mimeType): mimeType + } + } + + /// The case for a stored MIME string — known types map to their + /// case, everything else stays `.other`. + public init(mimeType: String) { + self = switch mimeType { + case "application/json": .json + case "image/png": .png + case "image/jpeg": .jpeg + case "text/plain": .plainText + default: .other(mimeType) + } + } + } + + public let name: String + public let contentType: ContentType + public let data: Data + + public init(name: String, contentType: ContentType, data: Data) { + self.name = name + self.contentType = contentType + self.data = data + } +} + +extension LogAttachment { + /// An error, captured as JSON (description, domain, code). + public static func error(_ error: any Error, name: String) -> LogAttachment { + let bridged = error as NSError + let payload: [String: String] = [ + "description": String(describing: error), + "domain": bridged.domain, + "code": String(bridged.code), + ] + // Encoding [String: String] cannot fail today; if a refactor ever + // makes it possible, debug builds should stop rather than persist + // an attachment that reads as a successful capture. + let data: Data + do { + data = try JSONEncoder().encode(payload) + } catch { + assertionFailure("Encoding the error payload failed: \(error)") + data = Data("{}".utf8) + } + return LogAttachment(name: name, contentType: .json, data: data) + } + + /// Any `Encodable` value, captured as JSON. + public static func json(_ value: some Encodable, name: String) throws -> LogAttachment { + try LogAttachment( + name: name, + contentType: .json, + data: JSONEncoder().encode(value), + ) + } + + #if canImport(UIKit) + /// An image, captured as PNG. Returns `nil` for images with no + /// bitmap representation. + public static func image(_ image: UIImage, name: String) -> LogAttachment? { + guard let data = image.pngData() else { return nil } + return LogAttachment(name: name, contentType: .png, data: data) + } + #endif +} + +/// Attachment metadata as carried on queried events — the blob itself loads +/// on demand through `PeriscopeStore.attachments(forEvent:)`. +public struct LogAttachmentInfo: Hashable, Sendable { + public let name: String + public let contentType: LogAttachment.ContentType + + public init(name: String, contentType: LogAttachment.ContentType) { + self.name = name + self.contentType = contentType + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift new file mode 100644 index 00000000..acf5ecd2 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift @@ -0,0 +1,86 @@ +import Foundation + +/// A structured, strongly typed log event. +/// +/// Conforming types are plain `Codable` values — their stored properties are +/// the structured payload that Periscope persists and the tooling can decode +/// and display. Each event also renders a human-readable `message` line and +/// carries a `level`. +/// +/// ```swift +/// struct PhotoUploaded: LogEvent { +/// var photoID: String +/// var byteCount: Int +/// var message: String { "Uploaded photo \(photoID) (\(byteCount) bytes)" } +/// } +/// ``` +/// +/// Events are emitted through a typed logger: `Log` can log +/// only `PhotoUploaded` values (plus freeform ``Message`` conveniences). +public protocol LogEvent: Codable, Sendable { + /// The token type naming this event's spans — `log.measure(.saveEvent)` + /// resolves against it. Defaults to `String` for freeform span names; + /// declare a nested enum for typed tokens: + /// + /// ```swift + /// struct DatabaseLogs: LogEvent { + /// enum SpanName: Hashable, Sendable { case saveEvent, migration } + /// // ... + /// } + /// ``` + associatedtype SpanName: Hashable, Sendable = String + + /// Stable name the event persists under; defaults to the type name. + /// + /// Persisted payloads are keyed by this name (plus ``eventVersion``), so + /// renaming a type without overriding `eventName` orphans its history. + static var eventName: String { get } + + /// Version of the payload shape, persisted alongside ``eventName`` so + /// old rows remain identifiable after a type changes shape. Defaults + /// to 1; bump when changing stored properties incompatibly. + static var eventVersion: Int { get } + + /// Severity of this event. Defaults to ``LogLevel/info``. + var level: LogLevel { get } + + /// Human-readable rendering, shown in Console.app and the log viewer. + var message: String { get } + + /// An identifier linking this event to the object it's about — a + /// photo's URI in the local store, a Core Data managed object ID's + /// URI representation — so tooling can find every event about an + /// object (`LogQuery.externalID`) or look the object up from an + /// event. Defaults to `nil`; the format is the app's to choose. + var externalID: String? { get } + + /// Whether the overflow drop policy must keep records of this event + /// under queue pressure (see + /// ``Periscope/Configuration/pendingBufferCapacity``). Defaults to + /// `false`; span began/ended events opt in so pairs never split. + /// Reserve for events whose *absence* corrupts the story the log + /// tells — protected records can push the queue past its bound. + static var isProtectedFromDropping: Bool { get } +} + +extension LogEvent { + public static var eventName: String { + String(describing: Self.self) + } + + public static var eventVersion: Int { + 1 + } + + public var level: LogLevel { + .info + } + + public static var isProtectedFromDropping: Bool { + false + } + + public var externalID: String? { + nil + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogLevel.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogLevel.swift new file mode 100644 index 00000000..eb584ab6 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogLevel.swift @@ -0,0 +1,127 @@ +import Foundation +import os + +/// A log severity: a display `name` plus a numeric `severity` that orders it +/// against every other level. +/// +/// `LogLevel` is a struct rather than an enum so apps can define their own +/// levels alongside the standard ladder (`debug` → `info` → `notice` → +/// `warning` → `error` → `fault`). Standard severities are spaced 100 apart +/// so custom levels can slot between them: +/// +/// ```swift +/// extension LogLevel { +/// static let audit = LogLevel(name: "audit", severity: 450) // warning < audit < error +/// } +/// ``` +/// +/// This matches OTel's severity-number + severity-text model. Two levels are +/// equal only when both name and severity match; ordering compares severity +/// alone. +public struct LogLevel: Hashable, Comparable, Codable, Sendable { + /// Display name, e.g. `"warning"`. + public var name: String + + /// Numeric rank used for ordering and threshold checks; higher is more + /// severe. + public var severity: Int + + /// The `OSLogType` Console.app sees for records at this level — + /// severity-band default from ``defaultOSLogType(forSeverity:)``, or + /// whatever a custom level passed at init. Not part of a level's + /// identity, and not persisted: it only routes live OSLog mirroring, + /// so decoded levels re-derive the band default. + public var osLogType: OSLogType + + public init(name: String, severity: Int) { + self.init( + name: name, + severity: severity, + osLogType: Self.defaultOSLogType(forSeverity: severity), + ) + } + + /// A level with an explicit OSLog mapping — for custom levels whose + /// Console visibility shouldn't follow their severity band (say, an + /// `audit` level between warning and error that must surface as + /// `.error`). + public init(name: String, severity: Int, osLogType: OSLogType) { + self.name = name + self.severity = severity + self.osLogType = osLogType + } + + public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + lhs.severity < rhs.severity + } + + // Identity is name + severity alone, as documented above — the OSLog + // mapping is routing metadata, not part of what a level *is*. + + public static func == (lhs: LogLevel, rhs: LogLevel) -> Bool { + lhs.name == rhs.name && lhs.severity == rhs.severity + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(severity) + } + + private enum CodingKeys: String, CodingKey { + case name + case severity + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + name: container.decode(String.self, forKey: .name), + severity: container.decode(Int.self, forKey: .severity), + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(severity, forKey: .severity) + } +} + +extension LogLevel { + // Explicit OSLog mappings, not the band default: these initializers + // must not call defaultOSLogType(forSeverity:), which reads the very + // statics being initialized — recursive static init traps at runtime. + public static let debug = LogLevel(name: "debug", severity: 100, osLogType: .debug) + public static let info = LogLevel(name: "info", severity: 200, osLogType: .info) + public static let notice = LogLevel(name: "notice", severity: 300, osLogType: .default) + public static let warning = LogLevel(name: "warning", severity: 400, osLogType: .default) + public static let error = LogLevel(name: "error", severity: 500, osLogType: .error) + public static let fault = LogLevel(name: "fault", severity: 600, osLogType: .fault) + + /// The standard ladder, least to most severe. Custom levels are not + /// listed here — UI that filters by level should derive choices from the + /// levels actually present in the data, not this list alone. + public static let standardLevels: [LogLevel] = [ + .debug, + .info, + .notice, + .warning, + .error, + .fault, + ] + + /// The severity-band default OSLog mapping. + /// + /// `warning` intentionally maps to `.default` (like `notice`), not + /// `.error`, so warnings don't inflate Console's error-level queries — + /// the same trade-off LogKit makes. + public static func defaultOSLogType(forSeverity severity: Int) -> OSLogType { + switch severity { + case .. LogTagValue { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return try .encoded(json: String(decoding: encoder.encode(value), as: UTF8.self)) + } + + /// The canonical display/persistence string for this value. + public var stringValue: String { + switch self { + case let .string(value): value + case let .int(value): String(value) + case let .double(value): String(value) + case let .bool(value): String(value) + case let .encoded(json): json + } + } + + /// The persistence discriminator, so `.int(3)` and `.string("3")` + /// round-trip as themselves. + var kind: String { + switch self { + case .string: "string" + case .int: "int" + case .double: "double" + case .bool: "bool" + case .encoded: "encoded" + } + } + + /// Restore from persisted columns. An unrecognized kind (a future + /// case read by an old build) degrades to `.string` — the value stays + /// visible rather than dropping the tag. + init(kind: String, stored: String) { + switch kind { + case "int": self = Int(stored).map(LogTagValue.int) ?? .string(stored) + case "double": self = Double(stored).map(LogTagValue.double) ?? .string(stored) + case "bool": self = Bool(stored).map(LogTagValue.bool) ?? .string(stored) + case "encoded": self = .encoded(json: stored) + default: self = .string(stored) + } + } +} + +extension LogTagValue: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, + ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral +{ + public init(stringLiteral value: String) { + self = .string(value) + } + + public init(integerLiteral value: Int) { + self = .int(value) + } + + public init(floatLiteral value: Double) { + self = .double(value) + } + + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +/// Types that convert directly into a tag value, so `tagged(_:_:)` accepts +/// plain `String`/`Int`/`Double`/`Bool` variables without wrapping. +public protocol LogTagValueConvertible { + var logTagValue: LogTagValue { get } +} + +extension LogTagValue: LogTagValueConvertible { + public var logTagValue: LogTagValue { + self + } +} + +extension String: LogTagValueConvertible { + public var logTagValue: LogTagValue { + .string(self) + } +} + +extension Int: LogTagValueConvertible { + public var logTagValue: LogTagValue { + .int(self) + } +} + +extension Double: LogTagValueConvertible { + public var logTagValue: LogTagValue { + .double(self) + } +} + +extension Bool: LogTagValueConvertible { + public var logTagValue: LogTagValue { + .bool(self) + } +} + +/// One key/value tag, as stamped, stored, and queried. +/// +/// Tags are orthogonal to the scope hierarchy: a tagged context stamps every +/// event logged through it (e.g. the current payment's ID across a whole UI +/// flow), and any event can carry any tags regardless of where it sits in +/// the tree. +public struct LogTag: Hashable, Sendable, Codable { + public let key: LogTagKey + public let value: LogTagValue + + public init(key: LogTagKey, value: LogTagValue) { + self.key = key + self.value = value + } + + /// Key, value kind, and canonical value joined into one string — the + /// store's single indexed column, so tag predicates stay one + /// comparison. The kind keeps `.int(3)` and `.string("3")` distinct. + public var pair: String { + "\(key.rawValue)\u{1F}\(value.kind)\u{1F}\(value.stringValue)" + } +} + +extension [LogTag] { + /// The value for `key`, if present. Tag lists are unique by key. + public subscript(key: LogTagKey) -> LogTagValue? { + first { $0.key == key }?.value + } + + /// Set `value` for `key` — replacing in place when the key is already + /// present (a re-tag keeps its position), appending otherwise. + mutating func set(_ value: LogTagValue, forKey key: LogTagKey) { + if let index = firstIndex(where: { $0.key == key }) { + self[index] = LogTag(key: key, value: value) + } else { + append(LogTag(key: key, value: value)) + } + } + + /// This list plus `other`'s tags for keys not already present — the + /// receiver wins key conflicts (link semantics: the left side is + /// primary). + func merging(_ other: [LogTag]) -> [LogTag] { + var merged = self + for tag in other where merged[tag.key] == nil { + merged.append(tag) + } + return merged + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/Message.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/Message.swift new file mode 100644 index 00000000..eb7937bd --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/Message.swift @@ -0,0 +1,24 @@ +import Foundation + +/// The built-in freeform log event: a rendered string plus a level. +/// +/// `Message` is what the level convenience methods on `Log` (`log.debug(_:)`, +/// `log.error(_:)`, …) emit, so every typed logger stays freeform-capable — +/// the generic `Event` constraint applies to custom structured events only. +public struct Message: LogEvent, Hashable { + public static let eventName = "message" + + public var level: LogLevel + + /// The stored, already-rendered message text. + public var text: String + + public var message: String { + text + } + + public init(level: LogLevel, _ text: String) { + self.level = level + self.text = text + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift new file mode 100644 index 00000000..24782fac --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift @@ -0,0 +1,261 @@ +import Foundation + +/// A typed, hierarchical logger: a pure value that captures *where in the +/// system* events come from, and can only emit `Event` values (plus freeform +/// ``Message`` conveniences). +/// +/// Loggers form a tree of scopes. Calling a log with an event type derives a +/// child logger typed to it; calling with an identifier derives a child scope +/// keyed to that identifier; `+` links two loggers so events carry both +/// contexts: +/// +/// ```swift +/// let root = Log(recorder: recorder) +/// let photos = root(PhotoLogs.self) // child scope, typed PhotoLogs +/// let album = photos(for: album.id) // child scope keyed by id +/// album { PhotoLogs.uploaded(photo.id) } // emits with full context +/// +/// let joined = album + uiLog // events reference both scopes +/// ``` +/// +/// Deriving the same path twice yields the same scope (see ``ScopeID``), so +/// loggers can be rebuilt anywhere without coordination. +public struct Log: Sendable { + /// The scopes events emitted here belong to: the primary scope first, + /// then any linked scopes. + public let scopes: [LogScope] + + /// The tags stamped on every event emitted here (see ``tagged(_:_:)``). + public let tags: [LogTag] + + let recorder: any LogRecorder + + /// The scope this logger derives children from. + public var primaryScope: LogScope { + scopes[0] + } + + /// A root logger whose scope is named after `Event`. + public init(recorder: any LogRecorder) { + self.init(scopes: [LogScope.root(named: Event.eventName)], tags: [], recorder: recorder) + } + + init(scopes: [LogScope], tags: [LogTag], recorder: any LogRecorder) { + precondition(!scopes.isEmpty, "A Log must have at least one scope") + self.scopes = scopes + self.tags = tags + self.recorder = recorder + recorder.defineScope(scopes[0]) + } + + // MARK: Deriving children + + /// A child logger typed to `Child`, under a child scope named after it. + public func callAsFunction(_: Child.Type) -> Log { + deriving(childNamed: Child.eventName) + } + + /// A child logger for a specific entity, under a child scope named by + /// `id` — e.g. one scope per album, per payment, per request. + public func callAsFunction(for id: some Hashable & Sendable) -> Log { + deriving(childNamed: String(describing: id)) + } + + private func deriving(childNamed name: String) -> Log { + var scopes = scopes + scopes[0] = primaryScope.child(named: name) + return Log(scopes: scopes, tags: tags, recorder: recorder) + } + + // MARK: Linking + + /// A logger whose events reference both sides' scopes — the "join" + /// between two contexts, e.g. a model object's log and the UI's log. + /// Duplicate scopes collapse and tags merge; the left side stays + /// primary and wins tag-key conflicts. + public static func + (lhs: Log, rhs: Log) -> Log { + lhs.linked(with: rhs) + } + + /// The spelled-out form of `+`. + public func linked(with other: Log) -> Log { + var merged = scopes + for scope in other.scopes where !merged.contains(scope) { + merged.append(scope) + } + return Log(scopes: merged, tags: tags.merging(other.tags), recorder: recorder) + } + + // MARK: Retyping + + /// This same context — scopes, tags, recorder — retyped to emit a + /// different event type. No child scope is derived (unlike calling with + /// an event type); adapters use this to carry a context across a typed + /// boundary, e.g. the SwiftUI environment's freeform accessor. + public func retyped(to _: Other.Type) -> Log { + Log(scopes: scopes, tags: tags, recorder: recorder) + } + + // MARK: Tagging + + /// A logger that stamps `key: value` on every event it emits, on top of + /// the tags already accumulated. Tags flow down derivations and links — + /// tag a flow's root once (say, the current payment's ID) and every + /// event under it carries the tag, wherever it sits in the tree. + /// Values are typed (see ``LogTagValue``); `String`, `Int`, `Double`, + /// and `Bool` convert directly. Re-tagging a key replaces its value. + public func tagged(_ key: LogTagKey, _ value: some LogTagValueConvertible) -> Log { + var tags = tags + tags.set(value.logTagValue, forKey: key) + return Log(scopes: scopes, tags: tags, recorder: recorder) + } + + // MARK: Emitting + + /// Log a structured event with this logger's full context. + public func callAsFunction( + function: StaticString = #function, + fileID: StaticString = #fileID, + _ event: () -> Event, + ) { + emit(event(), callSite: LogCallSite(function: function, fileID: fileID)) + } + + /// Log a structured event with attached data — errors, payloads, + /// screenshots (see ``LogAttachment``). + public func callAsFunction( + attachments: [LogAttachment], + function: StaticString = #function, + fileID: StaticString = #fileID, + _ event: () -> Event, + ) { + emit( + event(), + attachments: attachments, + callSite: LogCallSite(function: function, fileID: fileID), + ) + } + + /// Derive the typed child scope and log one event into it, in a single + /// expression: `log(PhotoLogs.self) { PhotoLogs(photoID: id) }`. + /// + /// This overload exists because Swift resolves a *value* call's + /// arguments and trailing closure as one `callAsFunction` application — + /// unlike *type* callees (SwiftUI's Layouts), which get an implicit + /// init-then-call split. Without it, the spelling above fails to + /// compile and must be written as two statements. + public func callAsFunction( + _ type: Child.Type, + function: StaticString = #function, + fileID: StaticString = #fileID, + _ event: () -> Child, + ) { + let child: Log = callAsFunction(type) + child.emit(event(), callSite: LogCallSite(function: function, fileID: fileID)) + } + + /// Derive the entity-keyed child scope and log one event into it, in a + /// single expression: `album(for: photo.id) { .uploaded }`. Exists for + /// the same trailing-closure reason as the typed variant above. + public func callAsFunction( + for id: some Hashable & Sendable, + function: StaticString = #function, + fileID: StaticString = #fileID, + _ event: () -> Event, + ) { + let child: Log = callAsFunction(for: id) + child.emit(event(), callSite: LogCallSite(function: function, fileID: fileID)) + } + + func emit( + _ event: any LogEvent, + attachments: [LogAttachment] = [], + bypassingFloors: Bool = false, + callSite: LogCallSite? = nil, + ) { + var record = LogRecord( + date: Date(), + event: event, + scopes: scopes.map(\.id), + tags: tags, + attachments: attachments, + callSite: callSite, + ) + record.bypassesFloors = bypassingFloors + recorder.record(record) + } +} + +/// Freeform logging: every `Log` can emit ``Message`` events at any level, +/// regardless of its `Event` type — the generic constraint applies to custom +/// structured events only. +extension Log { + public func log( + _ level: LogLevel, + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + guard recorder.shouldRecord(level: level, scopes: scopes.map(\.id)) else { return } + emit( + Message(level: level, text()), + attachments: attachments, + callSite: LogCallSite(function: function, fileID: fileID), + ) + } + + public func debug( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.debug, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func info( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.info, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func notice( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.notice, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func warning( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.warning, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func error( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.error, text(), attachments: attachments, function: function, fileID: fileID) + } + + public func fault( + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + function: StaticString = #function, + fileID: StaticString = #fileID, + ) { + log(.fault, text(), attachments: attachments, function: function, fileID: fileID) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScope.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScope.swift new file mode 100644 index 00000000..ca06f2f2 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScope.swift @@ -0,0 +1,42 @@ +import Foundation + +/// One node in the log hierarchy — the Periscope equivalent of an OTel +/// `InstrumentationScope`. +/// +/// Scopes form a tree: each has a name and an optional parent, and its +/// ``ScopeID`` is derived deterministically from that path (see `ScopeID`), +/// so the same path is the same scope everywhere. Events reference scopes +/// many-to-many — normally one leaf scope, several when logs are linked. +public struct LogScope: Hashable, Codable, Sendable, Identifiable { + public let id: ScopeID + public let name: String + public let parentID: ScopeID? + + /// A root scope (no parent) with the given name. + public static func root(named name: String) -> LogScope { + LogScope(id: .derive(parent: nil, name: name), name: name, parentID: nil) + } + + /// A child of this scope with the given name. + public func child(named name: String) -> LogScope { + LogScope(id: .derive(parent: id, name: name), name: name, parentID: id) + } + + /// The ancestor chain ending at `id`, root first, resolved through + /// `resolve` (a scope map, the system, or the store). The chain stops + /// at the first scope `resolve` can't supply, so an unknown `id` + /// yields an empty chain. The shared walk behind every scope-path + /// rendering — display joins with `" / "`, exports with `"/"`. + public static func ancestry( + of id: ScopeID, + resolve: (ScopeID) -> LogScope?, + ) -> [LogScope] { + var chain: [LogScope] = [] + var next: ScopeID? = id + while let current = next, let scope = resolve(current) { + chain.append(scope) + next = scope.parentID + } + return chain.reversed() + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Loggers/ScopeID.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/ScopeID.swift new file mode 100644 index 00000000..25dcc4aa --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/ScopeID.swift @@ -0,0 +1,63 @@ +import CryptoKit +import Foundation + +/// The identity of a scope in the log hierarchy. +/// +/// Scope IDs are **deterministic**: an ID is derived from the parent's ID and +/// the scope's name, so deriving the same path twice — in two places, or in +/// two different launches — yields the *same* scope. That's what lets a model +/// layer and the UI arrive at a shared scope independently, and what keeps +/// weeks of persisted logs pointing at one scope row per path. +public struct ScopeID: Hashable, Sendable, CustomStringConvertible { + public let rawValue: UUID + + public var description: String { + rawValue.uuidString + } + + /// Fixed namespace mixed into every derivation so Periscope scope IDs + /// can't collide with other UUID sources. + private static let namespace = Data("com.stuff.periscope.scope".utf8) + + /// Derive the ID for a scope `name` under `parent` (`nil` for roots): + /// SHA-256 over namespace + parent + name, truncated to a UUID. + static func derive(parent: ScopeID?, name: String) -> ScopeID { + var hasher = SHA256() + hasher.update(data: namespace) + if let parent { + withUnsafeBytes(of: parent.rawValue.uuid) { hasher.update(bufferPointer: $0) } + } + hasher.update(data: Data(name.utf8)) + let digest = Array(hasher.finalize().prefix(16)) + let uuid = UUID(uuid: ( + digest[0], + digest[1], + digest[2], + digest[3], + digest[4], + digest[5], + digest[6], + digest[7], + digest[8], + digest[9], + digest[10], + digest[11], + digest[12], + digest[13], + digest[14], + digest[15], + )) + return ScopeID(rawValue: uuid) + } +} + +extension ScopeID: Codable { + public init(from decoder: any Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(UUID.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift new file mode 100644 index 00000000..e65f4fd1 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Where an event was emitted: the calling function and file, captured +/// via `#function`/`#fileID` defaults at the log call site. +public struct LogCallSite: Hashable, Codable, Sendable { + /// E.g. `"uploadPhoto(_:)"`. + public let function: String + /// E.g. `"Where/PhotoUploader.swift"`. + public let fileID: String + + public init(function: StaticString, fileID: StaticString) { + self.function = String(describing: function) + self.fileID = String(describing: fileID) + } + + public init(function: String, fileID: String) { + self.function = function + self.fileID = fileID + } + + /// Display form: `"PhotoUploader.swift · uploadPhoto(_:)"`. + public var description: String { + let fileName = fileID.split(separator: "/").last.map(String.init) ?? fileID + return "\(fileName) · \(function)" + } +} + +/// One emitted log event with its context: the event value itself, when it +/// happened, and the scopes it belongs to. +/// +/// Records carry the live `LogEvent` value — encoding to a persistable +/// payload happens later, in the store, off the caller's thread. +public struct LogRecord: Sendable, Identifiable { + public let id: UUID + public let date: Date + public let event: any LogEvent + + /// The scopes this record belongs to, primary first. Normally one; more + /// when the emitting log was linked (`+`). + public let scopes: [ScopeID] + + /// The tags the emitting context had accumulated (see `Log.tagged`). + public let tags: [LogTag] + + /// Data attached at the call site (see `LogAttachment`). + public let attachments: [LogAttachment] + + /// The function and file that emitted this record; `nil` for + /// system-synthesized records (drop reports, orphan closes, expiry). + public let callSite: LogCallSite? + + /// Skips the recorder's level floors on delivery. Span lifecycle + /// records set this: the floor decision is made once, at `begin`, and + /// the whole pair follows it — a recorded began must get its end even + /// if floors rose mid-span (see `Log.begin`). + var bypassesFloors = false + + public init( + id: UUID = UUID(), + date: Date, + event: any LogEvent, + scopes: [ScopeID], + tags: [LogTag] = [], + attachments: [LogAttachment] = [], + callSite: LogCallSite? = nil, + ) { + self.id = id + self.date = date + self.event = event + self.scopes = scopes + self.tags = tags + self.attachments = attachments + self.callSite = callSite + } + + public var level: LogLevel { + event.level + } + + public var message: String { + event.message + } + + public var eventName: String { + type(of: event).eventName + } + + public var eventVersion: Int { + type(of: event).eventVersion + } + + /// The event's associated-object identifier, when it declares one. + public var externalID: String? { + event.externalID + } + + /// Whether the overflow drop policy must keep this record — the + /// event type's ``LogEvent/isProtectedFromDropping`` opt-in. Span + /// began/ended events set it so pairs never split under drop + /// pressure: a dropped began strands its end, and a dropped end + /// reads as still-open until the next launch's orphan sweep. + /// (`SpanOverdue` stays droppable — a disposable warning.) + var isProtectedFromDropping: Bool { + type(of: event).isProtectedFromDropping + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecorder.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecorder.swift new file mode 100644 index 00000000..a82cf76b --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecorder.swift @@ -0,0 +1,36 @@ +import Foundation + +/// The recording backend a `Log` emits into. +/// +/// The production conformer is the `Periscope` system; tests use lightweight +/// in-memory recorders. Both methods are called synchronously from arbitrary +/// threads at log call sites, so implementations must not block. +public protocol LogRecorder: Sendable { + /// Register a scope. Called on every `Log` derivation with a + /// deterministic scope, so implementations must be idempotent per + /// ``LogScope/id``. + func defineScope(_ scope: LogScope) + + /// Record one emitted event. + func record(_ record: LogRecord) + + /// Whether a record at `level` in `scopes` would be kept. `Log` checks + /// this before rendering freeform messages so filtered-out logging + /// skips string construction; recorders must still enforce their own + /// policy inside `record`. + func shouldRecord(level: LogLevel, scopes: [ScopeID]) -> Bool + + /// Track a span opened by `Log.begin(for:lifetime:relaunch:)`, + /// recording `began` (the span's `SpanBegan`; `nil` when the begin-time + /// floors hid the whole pair) *atomically* with the registration: a + /// span must never be visible for closing before its began is in the + /// pipeline, or a racing supersede or `end(for:)` could record the + /// span's end first. When `key` was already open, the prior span is + /// returned (removed) so the caller can close it as superseded — that + /// end trails the new began, which is fine: the prior span's own began + /// was recorded when *it* registered. + func beginSpan(key: SpanKey, span: OpenSpan, began: LogRecord?) -> OpenSpan? + + /// Stop tracking and return the open span for `key`, if any. + func closeSpan(key: SpanKey) -> OpenSpan? +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogSink.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogSink.swift new file mode 100644 index 00000000..b6892dcf --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogSink.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A destination in the `Periscope` pipeline — the OTel-exporter / +/// swift-log-`LogHandler` role. +/// +/// Built-ins are ``OSLogSink`` (Console.app) and the SwiftData store; apps +/// register their own for remote upload, crash-reporter breadcrumbs, or test +/// assertions. Sinks receive work **asynchronously in batches** shortly +/// after emit — log call sites never wait on a sink. +/// +/// The system guarantees a scope's definition is delivered before any record +/// referencing it, and preserves record order within and across batches. +public protocol LogSink: Sendable { + /// Register scopes. May contain scopes delivered before (late-added + /// sinks get the full registry replayed) — implementations must be + /// idempotent per ``LogScope/id``. + func defineScopes(_ scopes: [LogScope]) async + + /// Deliver a batch of records, oldest first. + func write(_ records: [LogRecord]) async + + /// Persist anything buffered. Called at flush points — on demand, and + /// (per the flush policy) when high-severity events demand durability. + func flush() async +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/OSLogSink.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/OSLogSink.swift new file mode 100644 index 00000000..ede2a2fa --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/OSLogSink.swift @@ -0,0 +1,85 @@ +import Foundation +import os + +/// The built-in Console.app sink: mirrors every record to `os.Logger`. +/// +/// The category is the record's *root* scope name; deeper scopes appear as a +/// `[path/below/root]` prefix on the message, so Console filters stay coarse +/// while the full hierarchy remains readable. Messages log as `.public` — +/// the same PII-free-messages contract LogKit documents. +public struct OSLogSink: LogSink { + private struct State { + var scopes: [ScopeID: LogScope] = [:] + var loggers: [String: os.Logger] = [:] + } + + public let subsystem: String + private let state: OSAllocatedUnfairLock + + public init(subsystem: String) { + self.subsystem = subsystem + state = OSAllocatedUnfairLock(initialState: State()) + } + + public func defineScopes(_ scopes: [LogScope]) async { + state.withLock { state in + for scope in scopes { + state.scopes[scope.id] = scope + } + } + } + + public func write(_ records: [LogRecord]) async { + for record in records { + let logger = logger(category: categoryName(for: record)) + let message = formattedMessage(for: record) + logger.log(level: record.level.osLogType, "\(message, privacy: .public)") + } + } + + public func flush() async { + // os.Logger writes synchronously; nothing buffered here. + } + + /// The Console category: the name of the primary scope's root ancestor. + @_spi(Testing) public func categoryName(for record: LogRecord) -> String { + primaryPath(for: record).first?.name ?? "periscope" + } + + /// The logged text: the primary scope path below the root (when any), + /// then the record's message, then any tags (sorted by key). + @_spi(Testing) public func formattedMessage(for record: LogRecord) -> String { + var message = record.message + let path = primaryPath(for: record).dropFirst().map(\.name) + if !path.isEmpty { + message = "[\(path.joined(separator: "/"))] \(message)" + } + if !record.tags.isEmpty { + let tags = record.tags + .sorted { $0.key.rawValue < $1.key.rawValue } + .map { "\($0.key)=\($0.value.stringValue)" } + message += " {\(tags.joined(separator: ", "))}" + } + return message + } + + /// The primary scope's ancestor chain, root first. Empty when the + /// record's primary scope was never defined here. + private func primaryPath(for record: LogRecord) -> [LogScope] { + guard let primary = record.scopes.first else { return [] } + return state.withLock { state in + LogScope.ancestry(of: primary) { state.scopes[$0] } + } + } + + private func logger(category: String) -> os.Logger { + state.withLock { state in + if let logger = state.loggers[category] { + return logger + } + let logger = os.Logger(subsystem: subsystem, category: category) + state.loggers[category] = logger + return logger + } + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift new file mode 100644 index 00000000..8bcd2af1 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift @@ -0,0 +1,735 @@ +import Foundation +import os + +/// The Periscope system: the recorder every `Log` emits into, and the +/// pipeline that fans records out to ``LogSink``s. +/// +/// Emitting never blocks the caller — `record` appends to a lock-guarded +/// pending queue and returns; a background drain task delivers batches to +/// each sink in order. The system also keeps a bounded buffer of recent +/// records for live UI (toasts, viewers) via ``recentRecords()`` and +/// ``liveRecords()``. +/// +/// Around the pipeline the system owns the policies: +/// +/// - **Level floors** — ``minimumLevel`` plus per-subtree overrides +/// (``setMinimumLevel(_:forSubtree:)``), checked at emit time before any +/// other work. +/// - **Flush policy** — records at ``Configuration/flushThreshold`` or above +/// trigger an automatic ``flush()`` so pre-crash context reaches disk. +/// - **Drop policy** — the pending queue is bounded by +/// ``Configuration/pendingBufferCapacity``; on overflow the oldest records +/// drop and a synthetic ``DroppedEvents`` record marks the gap. Scope +/// definitions and span began/ended pairs are exempt — pairs never split. +/// - **Redaction** — ``Configuration/redact`` transforms (or suppresses) +/// every record before it is buffered or delivered anywhere. Span +/// began/ended records are transform-only: suppression falls back to a +/// stripped copy, so redaction can't split a pair. +/// +/// Most apps use ``shared`` (preconfigured with an ``OSLogSink``) and add +/// their persistence sink at startup; tests build private systems. +public final class Periscope: LogRecorder, Sendable { + /// The process-wide system, mirroring to OSLog under the main bundle's + /// identifier. Add further sinks (e.g. the SwiftData store) at startup. + public static let shared = Periscope( + configuration: Configuration(), + sinks: [OSLogSink(subsystem: Bundle.main.bundleIdentifier ?? "com.stuff.periscope")], + ) + + public struct Configuration: Sendable { + /// Maximum records retained in the recent-records buffer. + public var recentBufferCapacity: Int + + /// Maximum records queued for sink delivery; on overflow the oldest + /// drop (scope definitions and span began/ended records never + /// drop) and a ``DroppedEvents`` record reports the gap. + public var pendingBufferCapacity: Int + + /// Maximum records buffered per ``liveRecords()`` observer that + /// falls behind; the oldest buffered records drop first, so a slow + /// or stuck consumer sees the newest activity instead of growing + /// memory without bound. + public var liveBufferCapacity: Int + + /// Records at this level or above trigger an automatic ``flush()``, + /// so the most important events don't sit in sink buffers when the + /// process dies. + public var flushThreshold: LogLevel + + /// Applied to every admitted record before it is buffered or + /// delivered. Return a transformed record to scrub PII, or `nil` + /// to suppress the record entirely. `nil` hook means no redaction. + /// + /// Runs only for records that pass the level floors — floors apply + /// to the record *as emitted* (redaction is content scrubbing, not + /// routing), and redaction code never touches records the floor + /// discards. + /// + /// Span began/ended records are *transform-only*: returning `nil` + /// for one records a stripped copy instead (tags and attachments + /// dropped, a `SpanEnded`'s freeform exit reason blanked) — a + /// suppressed half would strand its partner. Use level floors to + /// silence spans. + public var redact: (@Sendable (LogRecord) -> LogRecord?)? + + public init( + recentBufferCapacity: Int = 500, + pendingBufferCapacity: Int = 5000, + liveBufferCapacity: Int = 256, + flushThreshold: LogLevel = .error, + redact: (@Sendable (LogRecord) -> LogRecord?)? = nil, + ) { + self.recentBufferCapacity = recentBufferCapacity + self.pendingBufferCapacity = pendingBufferCapacity + self.liveBufferCapacity = liveBufferCapacity + self.flushThreshold = flushThreshold + self.redact = redact + } + } + + /// The synthetic event reporting records dropped by the overflow policy. + public struct DroppedEvents: LogEvent { + public static let eventName = "dropped-events" + + public let count: Int + + public var level: LogLevel { + .warning + } + + public var message: String { + "\(count) log event(s) dropped before delivery" + } + + public init(count: Int) { + self.count = count + } + } + + /// One entry in the ordered pending queue. A single queue keeps scope + /// definitions strictly before the records that reference them. + private enum PendingItem { + case scope(LogScope) + case record(LogRecord) + } + + private struct State { + var scopes: [ScopeID: LogScope] = [:] + var sinks: [any LogSink] = [] + var pending: [PendingItem] = [] + var pendingRecordCount = 0 + var droppedCount = 0 + var recent: [LogRecord] = [] + var observers: [UUID: AsyncStream.Continuation] = [:] + var globalFloor: LogLevel? + var subtreeFloors: [ScopeID: LogLevel] = [:] + var openSpans: [SpanKey: OpenSpan] = [:] + var ambientSources: [any AmbientEventSource] = [] + var inspectModeEnabled = false + var inspectObservers: [UUID: AsyncStream.Continuation] = [:] + /// The active drain task; `nil` exactly when nothing is draining. + var drainTask: Task? + /// The span watchdog. Generation-tagged: respawning with an earlier + /// wake time invalidates the old task so it can't clobber state. + var watchdogTask: Task? + var watchdogGeneration = 0 + var watchdogWakeAt: ContinuousClock.Instant? + /// The active auto-flush task; `nil` exactly when none is running. + var autoFlushTask: Task? + /// A qualifying record arrived while an auto-flush was in flight; + /// one follow-up flush covers every such record. + var autoFlushPending = false + } + + /// The scope Periscope's own synthetic events (drop reports) log under. + public let systemScope = LogScope.root(named: "Periscope") + + public let configuration: Configuration + private let state: OSAllocatedUnfairLock + + /// Backs `LogContextProviding` — see `instanceLog(for:)`. + let instanceScopes = InstanceScopeRegistry() + + public init(configuration: Configuration, sinks: [any LogSink]) { + precondition( + configuration.recentBufferCapacity > 0, + "recentBufferCapacity must be positive", + ) + precondition( + configuration.pendingBufferCapacity > 0, + "pendingBufferCapacity must be positive", + ) + precondition( + configuration.liveBufferCapacity > 0, + "liveBufferCapacity must be positive", + ) + self.configuration = configuration + state = OSAllocatedUnfairLock(initialState: State(sinks: sinks)) + defineScope(systemScope) + } + + // MARK: Sinks + + /// Register a sink. All scopes defined so far are replayed to the + /// pipeline so the new sink can resolve every record it will see. The + /// replay is *prepended*: records already pending must not reach the + /// new sink ahead of the scopes they reference (existing sinks see the + /// definitions again — idempotence is part of the sink contract). + public func add(sink: some LogSink) { + state.withLock { state in + state.sinks.append(sink) + state.pending.insert( + contentsOf: state.scopes.values.map(PendingItem.scope), + at: 0, + ) + } + scheduleDrainIfNeeded() + } + + // MARK: Level floors + + /// The global minimum level; records below it are discarded at emit. + /// `nil` (the default) records everything. Subtree overrides set via + /// ``setMinimumLevel(_:forSubtree:)`` take precedence within their + /// subtree. + public var minimumLevel: LogLevel? { + get { state.withLock(\.globalFloor) } + set { state.withLock { $0.globalFloor = newValue } } + } + + /// Override the minimum level for a scope and all its descendants — + /// quiet a noisy subsystem, or open the floor for one area while the + /// global floor stays high. Pass `nil` to clear the override. The + /// nearest overridden ancestor wins. + public func setMinimumLevel(_ level: LogLevel?, forSubtree scope: ScopeID) { + state.withLock { $0.subtreeFloors[scope] = level } + } + + /// Whether a record at `level` in `scopes` would be recorded. `Log` + /// checks this before rendering freeform messages, so filtered-out + /// logging skips string construction entirely. A record passes when + /// *any* of its scopes admits it — a linked record stays visible as + /// long as one of its contexts wants it. + public func shouldRecord(level: LogLevel, scopes: [ScopeID]) -> Bool { + state.withLock { state in + Self.passesFloor(level: level, scopes: scopes, state: state) + } + } + + private static func passesFloor(level: LogLevel, scopes: [ScopeID], state: State) -> Bool { + guard !state.subtreeFloors.isEmpty || state.globalFloor != nil else { return true } + guard !scopes.isEmpty else { + guard let floor = state.globalFloor else { return true } + return level >= floor + } + return scopes.contains { scope in + guard let floor = effectiveFloor(for: scope, state: state) else { return true } + return level >= floor + } + } + + /// The nearest ancestor override, else the global floor. `nil` means + /// no floor applies. + private static func effectiveFloor(for scope: ScopeID, state: State) -> LogLevel? { + var next: ScopeID? = scope + while let id = next { + if let floor = state.subtreeFloors[id] { + return floor + } + next = state.scopes[id]?.parentID + } + return state.globalFloor + } + + // MARK: LogRecorder + + public func defineScope(_ scope: LogScope) { + let isNew = state.withLock { state in + guard state.scopes[scope.id] == nil else { return false } + state.scopes[scope.id] = scope + state.pending.append(.scope(scope)) + return true + } + guard isNew else { return } + scheduleDrainIfNeeded() + } + + public func record(_ original: LogRecord) { + // Floor first: redaction must not run (touching PII) for records + // the floor discards anyway. Floors apply to the record as emitted; + // span lifecycle records carry their begin-time decision instead + // (see `LogRecord.bypassesFloors`). + if !original.bypassesFloors { + guard shouldRecord(level: original.level, scopes: original.scopes) else { return } + } + guard let record = redacted(original) else { return } + state.withLock { state in + Self.buffer(record, into: &state, configuration: configuration) + } + scheduleFollowUp(for: record) + } + + /// Append `record` to the buffers and yield it to live observers — the + /// in-lock half of delivery, shared by ``record(_:)`` and + /// ``beginSpan(key:span:began:)``. Yields happen *inside* the lock so + /// live streams see buffered order: yields only buffer (no consumer + /// runs under us), and out-of-lock yields from racing emitters could + /// invert — e.g. a span's end reaching an observer before its began. + private static func buffer( + _ record: LogRecord, + into state: inout State, + configuration: Configuration, + ) { + append(record, to: &state, configuration: configuration) + for observer in state.observers.values { + observer.yield(record) + } + } + + /// The outside-lock tail of delivery: kick the drain, and auto-flush + /// for records at the flush threshold. + private func scheduleFollowUp(for record: LogRecord) { + scheduleDrainIfNeeded() + if record.level >= configuration.flushThreshold { + scheduleAutoFlush() + } + } + + /// Apply the configured redaction hook. Span pair records are + /// *transform-only*: a hook may rewrite them, but `nil` (suppression) + /// falls back to a stripped copy — a suppressed half would strand its + /// partner (see `LogRecord.isProtectedFromDropping`), and level floors + /// are the supported way to silence spans. + private func redacted(_ record: LogRecord) -> LogRecord? { + guard let redact = configuration.redact else { return record } + if let transformed = redact(record) { return transformed } + guard record.isProtectedFromDropping else { return nil } + return record.strippedOfSensitivePayload() + } + + /// Request an automatic flush, coalescing: one task flushes no matter + /// how many qualifying records arrive (an error storm must not spawn a + /// task per record), and records landing mid-flush get exactly one + /// follow-up flush so their durability is still covered. + private func scheduleAutoFlush() { + state.withLock { state in + guard state.autoFlushTask == nil else { + state.autoFlushPending = true + return + } + state.autoFlushTask = Task { await self.runAutoFlush() } + } + } + + private func runAutoFlush() async { + while true { + await flush() + let runAgain = state.withLock { state -> Bool in + if state.autoFlushPending { + state.autoFlushPending = false + return true + } + state.autoFlushTask = nil + return false + } + guard runAgain else { return } + } + } + + /// Append to the recent buffer and pending queue, applying both bounds. + private static func append( + _ record: LogRecord, + to state: inout State, + configuration: Configuration, + ) { + state.recent.append(record) + let recentOverflow = state.recent.count - configuration.recentBufferCapacity + if recentOverflow > 0 { + state.recent.removeFirst(recentOverflow) + } + + state.pending.append(.record(record)) + state.pendingRecordCount += 1 + let pendingOverflow = state.pendingRecordCount - configuration.pendingBufferCapacity + if pendingOverflow > 0 { + var remainingToDrop = pendingOverflow + state.pending.removeAll { item in + guard remainingToDrop > 0, + case let .record(record) = item, + !record.isProtectedFromDropping + else { return false } + remainingToDrop -= 1 + return true + } + // Protected records (span pairs, like scope definitions) never + // drop, so a queue saturated with them can exceed the bound — + // they're rare and small, and a split pair is worse than a + // briefly oversized queue. + let dropped = pendingOverflow - remainingToDrop + state.pendingRecordCount -= dropped + state.droppedCount += dropped + } + } + + /// Resolve a scope the system has seen. + public func scope(for id: ScopeID) -> LogScope? { + state.withLock { $0.scopes[id] } + } + + /// Keep an ambient source alive until stopped — see + /// `startAmbientSource(_:)`. + func retainAmbientSource(_ source: some AmbientEventSource) { + state.withLock { $0.ambientSources.append(source) } + } + + /// Release every retained ambient source, returning them so the caller + /// can stop them — see `stopAmbientSources()`. + func releaseAmbientSources() -> [any AmbientEventSource] { + state.withLock { state in + let sources = state.ambientSources + state.ambientSources = [] + return sources + } + } + + /// The developer "log view mode" flag: when enabled, inspectable UI + /// (PeriscopeTools' `logInspectable` modifier) reveals the events behind + /// each wrapped view. This is the flag's source of truth — observable + /// mirrors (PeriscopeTools' inspector) follow it via + /// ``inspectModeChanges()``, so writing here or through a mirror + /// converges either way. + public var isInspectModeEnabled: Bool { + get { state.withLock(\.inspectModeEnabled) } + set { + // Yield *inside* the lock: yields only buffer (no consumer runs + // under us), and racing setters outside the lock could deliver + // out of order — with bufferingNewest(1) a subscriber would + // then hold the losing value forever. + state.withLock { state in + guard state.inspectModeEnabled != newValue else { return } + state.inspectModeEnabled = newValue + for observer in state.inspectObservers.values { + observer.yield(newValue) + } + } + } + } + + /// The inspect flag over time: the current value immediately, then one + /// per change (redundant writes don't re-yield). Only the latest value + /// buffers for a slow consumer. The observer is unregistered when the + /// stream's consumer cancels. + public func inspectModeChanges() -> AsyncStream { + let id = UUID() + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + state.withLock { state in + continuation.yield(state.inspectModeEnabled) + state.inspectObservers[id] = continuation + } + continuation.onTermination = { [weak self] _ in + self?.state.withLock { state in + state.inspectObservers[id] = nil + } + } + } + } + + // MARK: Open spans + + public func beginSpan(key: SpanKey, span: OpenSpan, began: LogRecord?) -> OpenSpan? { + // Redact outside the lock, like `record(_:)` — the closure is user + // code and may itself log, which would deadlock under our lock. + // `redacted` never suppresses a protected began (transform-only), + // so a floor-admitted pair stays whole through redaction too. + let buffered = began.flatMap { redacted($0) } + // One lock acquisition for registry + buffer: the span becomes + // visible for closing only with its began already in the pipeline, + // so no interleaving can record this span's end first. + let superseded = state.withLock { state -> OpenSpan? in + let prior = state.openSpans.removeValue(forKey: key) + state.openSpans[key] = span + if let buffered { + Self.buffer(buffered, into: &state, configuration: configuration) + } + return prior + } + if let buffered { + scheduleFollowUp(for: buffered) + } + scheduleWatchdogIfNeeded() + return superseded + } + + public func closeSpan(key: SpanKey) -> OpenSpan? { + state.withLock { $0.openSpans.removeValue(forKey: key) } + } + + /// A snapshot of every span currently open via `begin(for:)`, longest + /// running first — the data behind "what's in flight right now" + /// developer surfaces. + public func openSpans() -> [OpenSpan] { + state.withLock { Array($0.openSpans.values) } + .sorted { $0.start < $1.start } + } + + /// Close every bounded open span whose budget has elapsed as of `now`, + /// emitting its ``SpanEnded`` (`.expired`) with the begin-time context. + /// The watchdog calls this at deadlines; tests call it directly with + /// fabricated instants instead of sleeping. + @_spi(Testing) public func sweepOverdueSpans(now: ContinuousClock.Instant) { + let expired: [OpenSpan] = state.withLock { state in + var overdue: [OpenSpan] = [] + for (key, span) in state.openSpans { + guard case let .bounded(budget) = span.lifetime, + span.start + budget <= now + else { continue } + state.openSpans[key] = nil + overdue.append(span) + } + return overdue + } + for span in expired { + SpanSignposts.end(span.id) + guard case let .bounded(budget) = span.lifetime else { continue } + guard span.beganRecorded else { continue } + var closing = LogRecord( + date: Date(), + event: SpanEnded( + spanID: span.id, + name: span.name, + duration: now - span.start, + exit: .expired(budget: budget), + ), + scopes: span.scopes, + tags: span.tags, + ) + closing.bypassesFloors = true + record(closing) + } + } + + /// The earliest expiry among bounded open spans, if any. + private static func earliestDeadline(in state: State) -> ContinuousClock.Instant? { + state.openSpans.values.compactMap { span -> ContinuousClock.Instant? in + guard case let .bounded(budget) = span.lifetime else { return nil } + return span.start + budget + }.min() + } + + /// Ensure a watchdog task will wake at (or before) the earliest bounded + /// deadline; respawn when a new span needs an earlier wake than the + /// current sleep. + private func scheduleWatchdogIfNeeded() { + state.withLock { state in + guard let next = Self.earliestDeadline(in: state) else { return } + if state.watchdogTask != nil, let wakeAt = state.watchdogWakeAt, wakeAt <= next { + return + } + state.watchdogTask?.cancel() + state.watchdogGeneration += 1 + state.watchdogWakeAt = next + let generation = state.watchdogGeneration + // Weak self throughout: the watchdog may sleep for minutes, and + // it must not keep a discarded system (test suites make many) + // alive until its next wake. Strong promotion is per-call only, + // never held across the sleep. + state.watchdogTask = Task { [weak self] in + while true { + guard let wakeAt = self?.nextWatchdogWake(generation: generation) else { + return + } + try? await Task.sleep(until: wakeAt, clock: .continuous) + if Task.isCancelled { return } + self?.sweepOverdueSpans(now: ContinuousClock().now) + } + } + } + } + + /// The watchdog's loop head: the next deadline to sleep until, or `nil` + /// when this generation is stale or nothing bounded remains open (in + /// which case the watchdog retires). + private func nextWatchdogWake(generation: Int) -> ContinuousClock.Instant? { + state.withLock { state in + guard state.watchdogGeneration == generation else { return nil } + guard let next = Self.earliestDeadline(in: state) else { + state.watchdogTask = nil + state.watchdogWakeAt = nil + return nil + } + state.watchdogWakeAt = next + return next + } + } + + // MARK: Live records + + /// The most recent records, oldest first (bounded by + /// ``Configuration/recentBufferCapacity``). + public func recentRecords() -> [LogRecord] { + state.withLock(\.recent) + } + + /// The number of currently registered ``liveRecords()`` observers — + /// lets tests assert subscription lifecycles deterministically. + @_spi(Testing) public var liveObserverCount: Int { + state.withLock { $0.observers.count } + } + + /// Every record emitted from now on, one at a time. The observer is + /// unregistered automatically when the stream's consumer cancels. + /// Buffering is bounded (``Configuration/liveBufferCapacity``): a + /// consumer that falls behind loses the *oldest* buffered records — + /// live surfaces want the newest activity, and the durable history is + /// the store's job. + public func liveRecords() -> AsyncStream { + let id = UUID() + return AsyncStream( + bufferingPolicy: .bufferingNewest(configuration.liveBufferCapacity), + ) { continuation in + state.withLock { state in + state.observers[id] = continuation + } + continuation.onTermination = { [weak self] _ in + self?.state.withLock { state in + state.observers[id] = nil + } + } + } + } + + // MARK: Draining + + /// Wait until everything pending has reached every sink, then ask each + /// sink to persist its own buffers. + public func flush() async { + while let task = state.withLock({ $0.drainTask }) { + await task.value + } + let sinks = state.withLock(\.sinks) + for sink in sinks { + await sink.flush() + } + } + + private func scheduleDrainIfNeeded() { + state.withLock { state in + guard state.drainTask == nil, !state.pending.isEmpty else { return } + state.drainTask = Task { await self.drain() } + } + } + + private func drain() async { + while true { + let next: ( + items: [PendingItem], + sinks: [any LogSink], + dropReport: LogRecord?, + )? = state.withLock { state in + guard !state.pending.isEmpty else { + state.drainTask = nil + return nil + } + let items = state.pending + state.pending.removeAll() + state.pendingRecordCount = 0 + var dropReport: LogRecord? + if state.droppedCount > 0 { + dropReport = LogRecord( + date: Date(), + event: DroppedEvents(count: state.droppedCount), + scopes: [systemScope.id], + ) + state.droppedCount = 0 + } + return (items, state.sinks, dropReport) + } + guard var (items, sinks, dropReport) = next else { return } + if let dropReport { + announceDropReport(dropReport) + // The gap sits at the front of the record backlog, so the + // report slots before the surviving records — but after the + // leading scope run, so a just-added sink has its replay + // (including the system scope) before any record. + let leadingScopes = items.prefix { item in + if case .scope = item { true } else { false } + } + items.insert(.record(dropReport), at: leadingScopes.count) + } + for chunk in Self.chunked(items) { + for sink in sinks { + switch chunk { + case let .scopes(scopes): await sink.defineScopes(scopes) + case let .records(records): await sink.write(records) + } + } + } + } + } + + /// Surface a synthetic drop report in the recent buffer and live streams + /// (it never re-enters the pending queue). Yields in-lock, like + /// ``buffer(_:into:configuration:)``, so live order matches. + private func announceDropReport(_ record: LogRecord) { + state.withLock { state in + state.recent.append(record) + let overflow = state.recent.count - configuration.recentBufferCapacity + if overflow > 0 { + state.recent.removeFirst(overflow) + } + for observer in state.observers.values { + observer.yield(record) + } + } + } + + /// A run of consecutive same-kind pending items, ready for sink delivery. + private enum Chunk { + case scopes([LogScope]) + case records([LogRecord]) + } + + /// Group consecutive pending items so order is preserved while sinks + /// still receive batches. Accumulates runs in mutable buffers — O(n), + /// where rewriting the last chunk per item would copy it every + /// iteration and go quadratic on large backlogs. + private static func chunked(_ items: [PendingItem]) -> [Chunk] { + var chunks: [Chunk] = [] + var scopeRun: [LogScope] = [] + var recordRun: [LogRecord] = [] + + func closeScopeRun() { + guard !scopeRun.isEmpty else { return } + chunks.append(.scopes(scopeRun)) + scopeRun.removeAll() + } + + func closeRecordRun() { + guard !recordRun.isEmpty else { return } + chunks.append(.records(recordRun)) + recordRun.removeAll() + } + + for item in items { + switch item { + case let .scope(scope): + closeRecordRun() + scopeRun.append(scope) + case let .record(record): + closeScopeRun() + recordRun.append(record) + } + } + closeScopeRun() + closeRecordRun() + return chunks + } +} + +extension Log { + /// A root logger recording into a Periscope system — `Log()` + /// logs through ``Periscope/shared``. + public init(system: Periscope = .shared) { + self.init(recorder: system) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift new file mode 100644 index 00000000..5f0a317b --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift @@ -0,0 +1,584 @@ +import Foundation +import os + +/// The identity shared by a span's begin and end events. +public struct SpanID: Hashable, Sendable, CustomStringConvertible { + public let rawValue: UUID + + public init() { + rawValue = UUID() + } + + public init(rawValue: UUID) { + self.rawValue = rawValue + } + + public var description: String { + rawValue.uuidString + } +} + +extension SpanID: Codable { + public init(from decoder: any Decoder) throws { + rawValue = try decoder.singleValueContainer().decode(UUID.self) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} + +/// Marks the start of a timed span (`Log.measure` / `Log.begin(for:)`). +/// Carries the span's lifetime and relaunch policy so the watchdog and the +/// relaunch sweep can honor them from the persisted payload alone. +public struct SpanBegan: LogEvent { + public static let eventName = "span-began" + public static let eventVersion = 2 + /// Half of a span pair — see `LogEvent.isProtectedFromDropping`. + public static let isProtectedFromDropping = true + + public let spanID: SpanID + public let name: String + public let lifetime: SpanLifetime + public let relaunchPolicy: SpanRelaunchPolicy + + public var message: String { + "▶ \(name)" + } + + public init( + spanID: SpanID, + name: String, + lifetime: SpanLifetime, + relaunchPolicy: SpanRelaunchPolicy, + ) { + self.spanID = spanID + self.name = name + self.lifetime = lifetime + self.relaunchPolicy = relaunchPolicy + } +} + +/// Marks the end of a timed span: the measured duration (monotonic — +/// `ContinuousClock`; `nil` when unknowable, i.e. orphaned across process +/// death) and how it ended. +/// +/// Abnormal exits (`superseded`, `expired`, `orphaned`, `failure`) log at +/// `.warning`; `success` and `cancelled` (a normal lifecycle outcome) stay +/// at `.info`. +public struct SpanEnded: LogEvent { + public static let eventName = "span-ended" + public static let eventVersion = 2 + /// Half of a span pair — see `LogEvent.isProtectedFromDropping`. + public static let isProtectedFromDropping = true + + public let spanID: SpanID + public let name: String + public let duration: Duration? + public let exit: SpanExit + + public var level: LogLevel { + switch exit.mode { + case .success, .cancelled: .info + case .failure, .superseded, .expired, .orphaned: .warning + } + } + + public var message: String { + var text = "◀ \(name) \(exit.mode.described)" + if let reason = exit.reason { + text += ": \(reason)" + } + if let duration { + text += " (\(duration.formatted()))" + } + return text + } + + public init(spanID: SpanID, name: String, duration: Duration?, exit: SpanExit) { + self.spanID = spanID + self.name = name + self.duration = duration + self.exit = exit + } +} + +extension SpanExit.Mode { + fileprivate var described: String { + switch self { + case .success: "succeeded" + case .failure: "failed" + case .cancelled: "cancelled" + case .superseded: "superseded" + case .expired: "expired" + case .orphaned: "orphaned" + } + } +} + +/// Lets sinks and the store identify span events without matching each +/// concrete type. +protocol SpanCarrying { + var spanID: SpanID { get } +} + +extension SpanBegan: SpanCarrying {} +extension SpanEnded: SpanCarrying {} + +/// Emitted while a budgeted `measure` closure is still running past its +/// budget — the mid-hang signal. Unlike a bounded span's `.expired` close, +/// the span stays open and ends normally when (if) the closure returns. +public struct SpanOverdue: LogEvent { + public static let eventName = "span-overdue" + + public let spanID: SpanID + public let name: String + public let budget: Duration + + public var level: LogLevel { + .warning + } + + public var message: String { + "⏰ \(name) still running past its \(budget.formatted()) budget" + } + + public init(spanID: SpanID, name: String, budget: Duration) { + self.spanID = spanID + self.name = name + self.budget = budget + } +} + +extension SpanOverdue: SpanCarrying {} + +extension LogRecord { + /// The pair-integrity fallback for a redaction hook that tries to + /// suppress a protected span record: the same event with its PII + /// carriers removed — tags and attachments dropped, a `SpanEnded`'s + /// freeform exit reason blanked. Identity, date, scopes, and the + /// floor bypass are preserved. (Span names are typed tokens by + /// convention, not user data.) + func strippedOfSensitivePayload() -> LogRecord { + var strippedEvent: any LogEvent = event + if let ended = event as? SpanEnded { + strippedEvent = SpanEnded( + spanID: ended.spanID, + name: ended.name, + duration: ended.duration, + exit: SpanExit(mode: ended.exit.mode, reason: nil), + ) + } + var stripped = LogRecord( + id: id, + date: date, + event: strippedEvent, + scopes: scopes, + tags: [], + attachments: [], + callSite: callSite, + ) + stripped.bypassesFloors = bypassesFloors + return stripped + } + + /// The span this record belongs to, when its event is a span event. + public var spanID: SpanID? { + (event as? SpanCarrying)?.spanID + } + + /// How the span ended, when this record is a ``SpanEnded`` — the store + /// persists its mode as a queryable column. + public var spanExit: SpanExit? { + (event as? SpanEnded)?.exit + } +} + +/// Identifies an open `begin(for:)` span: begin and end pair when they use +/// the same identifier on a logger with the same primary scope — and since +/// scopes are deterministic, that logger can be rebuilt anywhere. +public struct SpanKey: Hashable, Sendable { + public let scope: ScopeID + public let identifier: String + + public init(scope: ScopeID, identifier: String) { + self.scope = scope + self.identifier = identifier + } +} + +/// A span begun with `Log.begin(for:)` that hasn't ended yet. Carries the +/// beginning context (scopes, tags) so system-initiated closes — expiry, +/// supersession — attribute their `SpanEnded` like the begin was, plus the +/// begin-time floor decision the whole pair follows. +public struct OpenSpan: Sendable { + public let id: SpanID + public let name: String + public let start: ContinuousClock.Instant + public let lifetime: SpanLifetime + /// Whether the floors admitted the `SpanBegan` when the span opened — + /// its end (normal, expired, or superseded) is recorded iff this is + /// true, so pairs never dangle across floor changes. + public let beganRecorded: Bool + public let scopes: [ScopeID] + public let tags: [LogTag] + + public init( + id: SpanID, + name: String, + start: ContinuousClock.Instant, + lifetime: SpanLifetime, + beganRecorded: Bool, + scopes: [ScopeID], + tags: [LogTag], + ) { + self.id = id + self.name = name + self.start = start + self.lifetime = lifetime + self.beganRecorded = beganRecorded + self.scopes = scopes + self.tags = tags + } +} + +/// Mirrors span begin/end pairs to os_signpost so Periscope spans appear in +/// Instruments' timeline. Signposts fire at emission time (not sink +/// delivery), so intervals carry real durations. +enum SpanSignposts { + private static let signposter = OSSignposter( + subsystem: "com.stuff.periscope", + category: "Spans", + ) + + private static let intervals = OSAllocatedUnfairLock<[SpanID: OSSignpostIntervalState]>( + uncheckedState: [:], + ) + + static func begin(_ span: SpanID, name: String) { + let state = signposter.beginInterval( + "Span", + id: signposter.makeSignpostID(), + "\(name, privacy: .public)", + ) + intervals.withLockUnchecked { $0[span] = state } + } + + static func end(_ span: SpanID) { + let state = intervals.withLockUnchecked { $0.removeValue(forKey: span) } + guard let state else { return } + signposter.endInterval("Span", state) + } +} + +/// Timing: measure closures, and open-ended begin/end spans keyed by +/// identifier. Span names resolve against `Event.SpanName`, which defaults +/// to `String` — declare a `SpanName` enum on the event type for +/// compiler-checked tokens (`log.measure(.saveEvent)`), the recommended +/// style for structured events; freeform loggers measure ad hoc. +extension Log { + /// Times `body` between paired ``SpanBegan``/``SpanEnded`` events + /// sharing one ``SpanID``. The exit is derived automatically: return → + /// `.success`, throw → `.failure` (with the error described), + /// `CancellationError` → `.cancelled`. Names resolve against + /// `Event.SpanName`, so typed events get leading-dot tokens + /// (`log.measure(.saveEvent) { … }`). + @discardableResult + public func measure(_ name: Event.SpanName, _ body: () throws -> R) rethrows -> R { + try timedSpan(named: String(describing: name), budget: nil, body) + } + + /// A `measure` with an expectation: if `body` is still running after + /// `budget`, a ``SpanOverdue`` warning fires *while it hangs* — the + /// span itself still ends normally with its derived exit. + @discardableResult + public func measure( + _ name: Event.SpanName, + budget: Duration, + _ body: () throws -> R, + ) rethrows -> R { + try timedSpan(named: String(describing: name), budget: budget, body) + } + + /// The `async` form of `measure`; preserves the caller's isolation. + @discardableResult + public func measure( + _ name: Event.SpanName, + isolation: isolated (any Actor)? = #isolation, + _ body: () async throws -> R, + ) async rethrows -> R { + try await timedSpan( + named: String(describing: name), + budget: nil, + isolation: isolation, + body, + ) + } + + /// The `async` form of the budgeted `measure`. + @discardableResult + public func measure( + _ name: Event.SpanName, + budget: Duration, + isolation: isolated (any Actor)? = #isolation, + _ body: () async throws -> R, + ) async rethrows -> R { + try await timedSpan( + named: String(describing: name), + budget: budget, + isolation: isolation, + body, + ) + } + + private func timedSpan( + named spanName: String, + budget: Duration?, + _ body: () throws -> R, + ) rethrows -> R { + let span = SpanID() + let clock = ContinuousClock() + let start = clock.now + let recorded = beginMeasuredSpan(span, name: spanName) + var sentinel: Task? + var overdueGate: OSAllocatedUnfairLock? + if recorded, let budget { + let gate = OSAllocatedUnfairLock(initialState: false) + overdueGate = gate + sentinel = startOverdueSentinel(span: span, name: spanName, budget: budget, gate: gate) + } + defer { sentinel?.cancel() } + do { + let result = try body() + endMeasuredSpan( + span, + name: spanName, + duration: clock.now - start, + exit: .success, + recorded: recorded, + overdueGate: overdueGate, + ) + return result + } catch { + endMeasuredSpan( + span, + name: spanName, + duration: clock.now - start, + exit: Self.exit(for: error), + recorded: recorded, + overdueGate: overdueGate, + ) + throw error + } + } + + private func timedSpan( + named spanName: String, + budget: Duration?, + isolation _: isolated (any Actor)?, + _ body: () async throws -> R, + ) async rethrows -> R { + let span = SpanID() + let clock = ContinuousClock() + let start = clock.now + let recorded = beginMeasuredSpan(span, name: spanName) + var sentinel: Task? + var overdueGate: OSAllocatedUnfairLock? + if recorded, let budget { + let gate = OSAllocatedUnfairLock(initialState: false) + overdueGate = gate + sentinel = startOverdueSentinel(span: span, name: spanName, budget: budget, gate: gate) + } + defer { sentinel?.cancel() } + do { + let result = try await body() + endMeasuredSpan( + span, + name: spanName, + duration: clock.now - start, + exit: .success, + recorded: recorded, + overdueGate: overdueGate, + ) + return result + } catch { + endMeasuredSpan( + span, + name: spanName, + duration: clock.now - start, + exit: Self.exit(for: error), + recorded: recorded, + overdueGate: overdueGate, + ) + throw error + } + } + + /// One short-lived task per *budgeted* measure: sleep the budget and, + /// if the closure hasn't finished (which cancels the sentinel), emit + /// the overdue warning. The emission is serialized with the span's end + /// through `gate`, so a sentinel that loses the race at the budget + /// boundary can never record an overdue *after* the span ended. + private func startOverdueSentinel( + span: SpanID, + name: String, + budget: Duration, + gate: OSAllocatedUnfairLock, + ) -> Task { + Task { [self] in + try? await Task.sleep(for: budget) + guard !Task.isCancelled else { return } + gate.withLock { ended in + guard !ended else { return } + emit(SpanOverdue(spanID: span, name: name, budget: budget)) + } + } + } + + /// Signposts the start and, when the floors admit it, records the + /// `SpanBegan`. Returns the floor decision the whole pair follows — + /// including the overdue sentinel, which stays silent for a span the + /// floors hid. + private func beginMeasuredSpan(_ span: SpanID, name: String) -> Bool { + let began = SpanBegan( + spanID: span, + name: name, + lifetime: .scoped, + relaunchPolicy: .endsWithProcess, + ) + let recorded = recorder.shouldRecord(level: began.level, scopes: scopes.map(\.id)) + SpanSignposts.begin(span, name: name) + if recorded { + emit(began, bypassingFloors: true) + } + return recorded + } + + private func endMeasuredSpan( + _ span: SpanID, + name: String, + duration: Duration, + exit: SpanExit, + recorded: Bool, + overdueGate: OSAllocatedUnfairLock?, + ) { + SpanSignposts.end(span) + guard recorded else { return } + let ended = SpanEnded(spanID: span, name: name, duration: duration, exit: exit) + if let overdueGate { + // MARK: - and-emit under the gate: after this, the sentinel stays + + // silent — never an overdue following the end. + overdueGate.withLock { hasEnded in + hasEnded = true + emit(ended, bypassingFloors: true) + } + } else { + emit(ended, bypassingFloors: true) + } + } + + private static func exit(for error: any Error) -> SpanExit { + error is CancellationError ? .cancelled : .failure(String(describing: error)) + } + + /// Open a span for `id` — e.g. `log.begin(for: payment)` when a payment + /// flow starts. Close it later with ``end(for:exit:)`` from any logger + /// with the same primary scope. Beginning an already-open key closes + /// the prior span as `.superseded` (the flow restarted) rather than + /// refusing — no lockout, no leak. + /// + /// `lifetime` is deliberately explicit: bounded spans expire (and stop + /// leaking) when they outlive their budget; indefinite spans are a + /// conscious opt-in. `relaunch` decides what a later launch does with a + /// span this process never ends. + public func begin( + for id: some Hashable & Sendable, + lifetime: SpanLifetime, + relaunch: SpanRelaunchPolicy = .endsWithProcess, + ) { + let name = String(describing: id) + let key = SpanKey(scope: primaryScope.id, identifier: name) + let began = SpanBegan( + spanID: SpanID(), + name: name, + lifetime: lifetime, + relaunchPolicy: relaunch, + ) + // The floor decision is made once, here, for the whole pair: a + // recorded began always gets its end (even if floors rise + // mid-span), and a floored began silences the entire span — + // never a dangling half. Signposts are unaffected; they're a + // separate channel. + let beganRecorded = recorder.shouldRecord(level: began.level, scopes: scopes.map(\.id)) + let span = OpenSpan( + id: began.spanID, + name: name, + start: ContinuousClock().now, + lifetime: lifetime, + beganRecorded: beganRecorded, + scopes: scopes.map(\.id), + tags: tags, + ) + var beganRecord: LogRecord? + if beganRecorded { + var record = LogRecord( + date: Date(), + event: began, + scopes: scopes.map(\.id), + tags: tags, + ) + record.bypassesFloors = true + beganRecord = record + } + // Registration and the began land atomically (see + // `LogRecorder.beginSpan`), so a racing supersede or `end(for:)` + // can't record this span's end first. The superseded close follows + // the *new* began — cause before effect: the re-begin is what + // closed it. + if let superseded = recorder.beginSpan(key: key, span: span, began: beganRecord) { + SpanSignposts.end(superseded.id) + if superseded.beganRecorded { + var closing = LogRecord( + date: Date(), + event: SpanEnded( + spanID: superseded.id, + name: superseded.name, + duration: span.start - superseded.start, + exit: .superseded, + ), + scopes: superseded.scopes, + tags: superseded.tags, + ) + closing.bypassesFloors = true + recorder.record(closing) + } + } + SpanSignposts.begin(span.id, name: name) + } + + /// Close the span opened with ``begin(for:lifetime:relaunch:)`` for the + /// same identifier, recording how it ended (`.success`, + /// `.failure("card declined")`, `.cancelled`, …). Ending a span that + /// isn't open logs a warning. + public func end(for id: some Hashable & Sendable, exit: SpanExit) { + let name = String(describing: id) + let key = SpanKey(scope: primaryScope.id, identifier: name) + guard let open = recorder.closeSpan(key: key) else { + warning("end(for: \(name)) without a matching begin") + return + } + SpanSignposts.end(open.id) + guard open.beganRecorded else { return } + emit( + SpanEnded( + spanID: open.id, + name: open.name, + duration: ContinuousClock().now - open.start, + exit: exit, + ), + bypassingFloors: true, + ) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Spans/SpanExit.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/SpanExit.swift new file mode 100644 index 00000000..11e4fab5 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Spans/SpanExit.swift @@ -0,0 +1,90 @@ +import Foundation + +/// How a span ended: a closed set of modes plus an optional freeform +/// reason ("card declined", "user tapped cancel"). Richer payloads ride +/// along as `LogAttachment`s on the surrounding events. +public struct SpanExit: Hashable, Codable, Sendable { + public enum Mode: String, CaseIterable, Codable, Sendable { + /// The operation completed as intended. + case success + /// The operation failed. `measure` derives this from a thrown error. + case failure + /// The operation was called off — a normal lifecycle outcome, not a + /// failure. `measure` derives this from `CancellationError`. + case cancelled + /// A new `begin(for:)` for the same key replaced this span — the + /// flow restarted without ending. + case superseded + /// The watchdog closed a bounded span that outlived its budget. + case expired + /// A relaunch closed a span the previous process left open. + case orphaned + } + + public var mode: Mode + public var reason: String? + + public init(mode: Mode, reason: String?) { + self.mode = mode + self.reason = reason + } + + public static let success = SpanExit(mode: .success, reason: nil) + public static let failure = SpanExit(mode: .failure, reason: nil) + public static let cancelled = SpanExit(mode: .cancelled, reason: nil) + public static let superseded = SpanExit(mode: .superseded, reason: nil) + public static let orphaned = SpanExit(mode: .orphaned, reason: nil) + + public static func success(_ reason: String) -> SpanExit { + SpanExit(mode: .success, reason: reason) + } + + public static func failure(_ reason: String) -> SpanExit { + SpanExit(mode: .failure, reason: reason) + } + + public static func cancelled(_ reason: String) -> SpanExit { + SpanExit(mode: .cancelled, reason: reason) + } + + public static func superseded(_ reason: String) -> SpanExit { + SpanExit(mode: .superseded, reason: reason) + } + + public static func orphaned(_ reason: String) -> SpanExit { + SpanExit(mode: .orphaned, reason: reason) + } + + public static func expired(_ reason: String) -> SpanExit { + SpanExit(mode: .expired, reason: reason) + } + + public static func expired(budget: Duration) -> SpanExit { + SpanExit(mode: .expired, reason: "exceeded \(budget.formatted()) budget") + } +} + +/// How long a span is allowed to stay open. +public enum SpanLifetime: Hashable, Codable, Sendable { + /// Bound to a `measure` closure — it cannot outlive the call. + case scoped + /// Expected to end within `budget`; the system's watchdog closes it as + /// `.expired` past that, so a lost `end(for:)` can't leak it forever. + case bounded(budget: Duration) + /// Legitimately open-ended (a whole payment flow, a long download). + /// Never expires while the process lives; on relaunch its + /// ``SpanRelaunchPolicy`` decides. + case indefinite +} + +/// What a relaunch does with a span the previous process left open. The +/// policy is recorded on the `SpanBegan` payload, so the relaunch sweep can +/// honor it without any in-memory state surviving. +public enum SpanRelaunchPolicy: String, Codable, Sendable { + /// The next launch closes it as `.orphaned` — the flow did not survive. + case endsWithProcess + /// The next launch leaves it open. (Resuming — re-seeding the open-span + /// registry with wall-clock durations — is staged work; see + /// `Shared/Periscope/TODOs.md`.) + case survivesRelaunch +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift new file mode 100644 index 00000000..9c024695 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Filters for `PeriscopeStore` event queries. Unset fields don't filter; +/// set fields combine with AND. +/// +/// ```swift +/// var query = LogQuery() +/// query.minimumLevel = .warning +/// query.scope = .subtree(photosLog.primaryScope.id) +/// query.limit = 100 +/// let events = try await store.events(matching: query) // newest first +/// ``` +public struct LogQuery: Sendable { + /// Only events at or after this date. + public var start: Date? + /// Only events at or before this date. + public var end: Date? + /// Only events at this severity or above. + public var minimumLevel: LogLevel? + /// Only events persisted under this exact event name. + public var eventName: String? + /// Only events from this session (launch). + public var sessionID: UUID? + /// Only events referencing the given scope — exactly, or anywhere in + /// its subtree. + public var scope: ScopeFilter? + /// Only events stamped with *all* of these exact key/value tags. + /// Empty doesn't filter. + public var tags: [LogTag] = [] + /// Only span-ended events with this exit mode — "everything that + /// failed", "everything that expired". + public var spanExitMode: SpanExit.Mode? + /// Only events declaring this ``LogEvent/externalID`` — "every event + /// about this object". + public var externalID: String? + /// Only events whose message matches this text + /// (`localizedStandardContains`). + public var messageContains: String? + /// Page size; unset fetches everything that matches. + public var limit: Int? + /// Page offset into the newest-first ordering. + public var offset: Int? + + public init() {} +} + +/// How a query matches an event's scopes. +public enum ScopeFilter: Hashable, Sendable { + /// The event references exactly this scope. + case exactly(ScopeID) + /// The event references this scope or any of its descendants. + case subtree(ScopeID) +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift new file mode 100644 index 00000000..8fc0e512 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Per-launch resource metadata — OTel's `Resource`. Every persisted event +/// references the session that produced it, so weeks-old logs stay +/// attributable to a specific build on a specific device. +public struct LogSession: Sendable, Identifiable, Hashable, Codable { + public let id: UUID + public let startedAt: Date + public let appVersion: String + public let buildNumber: String + public let osVersion: String + public let deviceModel: String + + public init( + id: UUID, + startedAt: Date, + appVersion: String, + buildNumber: String, + osVersion: String, + deviceModel: String, + ) { + self.id = id + self.startedAt = startedAt + self.appVersion = appVersion + self.buildNumber = buildNumber + self.osVersion = osVersion + self.deviceModel = deviceModel + } + + /// A fresh session describing this launch: main-bundle version info, + /// OS version, and hardware model. + public static func current() -> LogSession { + let info = Bundle.main.infoDictionary ?? [:] + return LogSession( + id: UUID(), + startedAt: Date(), + appVersion: info["CFBundleShortVersionString"] as? String ?? "unknown", + buildNumber: info["CFBundleVersion"] as? String ?? "unknown", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + deviceModel: hardwareModel(), + ) + } + + /// The `uname` machine identifier (e.g. `iPhone17,1`, `arm64` on + /// simulators and Macs). + private static func hardwareModel() -> String { + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafeBytes(of: &systemInfo.machine) { buffer in + String(decoding: buffer.prefix(while: { $0 != 0 }), as: UTF8.self) + } + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift new file mode 100644 index 00000000..f9955255 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift @@ -0,0 +1,212 @@ +import Foundation +import SwiftData + +/// The SwiftData records behind `PeriscopeStore`. Internal — callers only +/// ever see value types (`StoredLogEvent`, `LogScope`, `LogSession`). +/// +/// Events keep their scopes two ways on purpose: `orderedScopeIDs` preserves +/// the emission order (primary scope first) faithfully for display, while +/// the `scopes` relationship exists for predicate-based querying (scope and +/// subtree filters). Payloads persist as JSON keyed by `eventName` + +/// `eventVersion`, so old rows outlive their Swift types and degrade to raw +/// JSON instead of requiring schema migrations. +@Model +final class SDLogEvent { + #Index( + [\.date], + [\.severity], + [\.eventName], + [\.sessionID], + [\.spanID], + [\.spanExitMode], + [\.externalID], + ) + + var eventID: UUID + var date: Date + /// Store-assigned monotonic insertion order — breaks ties between + /// events in the same millisecond so "newest first" stays stable. + var sequence: Int + var severity: Int + var levelName: String + var eventName: String + var eventVersion: Int + var message: String + /// The event's stored properties, JSON-encoded. + var payload: Data + /// Every scope the event references, primary first, in emission order. + var orderedScopeIDs: [UUID] + var sessionID: UUID + /// Set on span begin/end events so a span's pair resolves in one fetch. + var spanID: UUID? + /// `SpanExit.Mode.rawValue` on span-ended events — queryable, so the + /// viewer can filter "everything that failed/expired/orphaned". + var spanExitMode: String? + /// The emitting function/file (`#function`/`#fileID`), when captured. + var callFunction: String? + var callFileID: String? + /// The event's associated-object identifier (`LogEvent.externalID`), + /// indexed so "every event about this object" is one query. + var externalID: String? + var scopes: [SDLogScope] + var tags: [SDLogTag] + + @Relationship(deleteRule: .cascade, inverse: \SDLogAttachment.event) + var attachments: [SDLogAttachment] + + init( + eventID: UUID, + date: Date, + sequence: Int, + severity: Int, + levelName: String, + eventName: String, + eventVersion: Int, + message: String, + payload: Data, + orderedScopeIDs: [UUID], + sessionID: UUID, + spanID: UUID?, + spanExitMode: String?, + callFunction: String?, + callFileID: String?, + externalID: String?, + scopes: [SDLogScope], + tags: [SDLogTag], + attachments: [SDLogAttachment], + ) { + self.eventID = eventID + self.date = date + self.sequence = sequence + self.severity = severity + self.levelName = levelName + self.eventName = eventName + self.eventVersion = eventVersion + self.message = message + self.payload = payload + self.orderedScopeIDs = orderedScopeIDs + self.sessionID = sessionID + self.spanID = spanID + self.spanExitMode = spanExitMode + self.callFunction = callFunction + self.callFileID = callFileID + self.externalID = externalID + self.scopes = scopes + self.tags = tags + self.attachments = attachments + } +} + +/// One attachment blob, cascade-deleted with its event. The bytes use +/// external storage so screenshots and payloads live beside the database. +@Model +final class SDLogAttachment { + var name: String + var contentType: String + /// Position within the event's attachments (relationships are + /// unordered). + var index: Int + @Attribute(.externalStorage) var data: Data + var event: SDLogEvent? + + init(name: String, contentType: String, index: Int, data: Data) { + self.name = name + self.contentType = contentType + self.index = index + self.data = data + } +} + +/// One row per distinct key/value tag pair, shared by every event carrying +/// it — tag queries resolve through this relationship. +@Model +final class SDLogTag { + #Index([\.pair]) + + var key: String + /// The `LogTagValue` discriminator (string/int/double/bool/encoded), + /// so typed values round-trip as themselves. + var valueKind: String + /// The value's canonical string form. + var value: String + /// Key, kind, and value joined — a single indexed column so tag + /// predicates stay one comparison (see `LogTag.pair`). + var pair: String + + @Relationship(inverse: \SDLogEvent.tags) + var events: [SDLogEvent] + + init(tag: LogTag) { + key = tag.key.rawValue + valueKind = tag.value.kind + value = tag.value.stringValue + pair = tag.pair + events = [] + } +} + +/// One scope row per deterministic `ScopeID` — shared across sessions, so +/// the hierarchy accumulates rather than duplicating per launch. +@Model +final class SDLogScope { + #Index([\.scopeID]) + + @Attribute(.unique) var scopeID: UUID + var name: String + var parentID: UUID? + + @Relationship(inverse: \SDLogEvent.scopes) + var events: [SDLogEvent] + + init(scopeID: UUID, name: String, parentID: UUID?) { + self.scopeID = scopeID + self.name = name + self.parentID = parentID + events = [] + } +} + +/// One row per app launch (see `LogSession`). +@Model +final class SDLogSession { + #Index([\.startedAt]) + + @Attribute(.unique) var sessionID: UUID + var startedAt: Date + var appVersion: String + var buildNumber: String + var osVersion: String + var deviceModel: String + + init(session: LogSession) { + sessionID = session.id + startedAt = session.startedAt + appVersion = session.appVersion + buildNumber = session.buildNumber + osVersion = session.osVersion + deviceModel = session.deviceModel + } + + var toValue: LogSession { + LogSession( + id: sessionID, + startedAt: startedAt, + appVersion: appVersion, + buildNumber: buildNumber, + osVersion: osVersion, + deviceModel: deviceModel, + ) + } +} + +enum PeriscopeSchema { + static var models: [any PersistentModel.Type] { + [ + SDLogEvent.self, + SDLogScope.self, + SDLogSession.self, + SDLogTag.self, + SDLogAttachment.self, + ] + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift new file mode 100644 index 00000000..5b1c7f12 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift @@ -0,0 +1,964 @@ +import Foundation +import os +import SwiftData + +/// The SwiftData-backed log store: the durable `LogSink`. +/// +/// One store holds every logging system's events in one database — events +/// keep their full scope hierarchy (many-to-many), their session (per-launch +/// resource metadata), and their JSON payload, so weeks of history stay +/// queryable by time, level, event type, scope subtree, and session. +/// +/// The store is a `@ModelActor`: all reads and writes run on its executor +/// against `modelContext`, and every delivered batch commits in one save — +/// `flush()` has nothing left to do. Sink failures can't propagate (the +/// pipeline is fire-and-forget), so persistence errors log to OSLog and +/// count in ``writeFailureCount`` rather than vanishing — and the failed +/// transaction rolls back so one poisoned batch can't wedge every save +/// after it. +/// +/// Wire it in at startup: +/// +/// ```swift +/// let store = try await PeriscopeStore.make( +/// storage: .onDisk, +/// session: .current(), +/// ) +/// Periscope.shared.add(sink: store) +/// ``` +@ModelActor +public actor PeriscopeStore: LogSink { + /// Backing storage. `onDisk` persists to `Periscope.store` in the app's + /// default SwiftData location; `inMemory` is for tests and previews. + public enum Storage: Sendable { + case inMemory + case onDisk + } + + /// Internal-failure telemetry: logging must never crash or throw into + /// the pipeline, so persistence problems land here and in OSLog. + private static let failureLogger = os.Logger( + subsystem: "com.stuff.periscope", + category: "PeriscopeStore", + ) + + /// This launch's session identity — survives write recovery, so events + /// after a rollback still attribute to the same launch. + private var activeSession: LogSession? + private var activeSessionRow: SDLogSession? + private var scopeRowCache: [UUID: SDLogScope] = [:] + private var tagRowCache: [LogTag: SDLogTag] = [:] + private var changeObservers: [UUID: AsyncStream.Continuation] = [:] + private var writeFailures = 0 + private var nextSequence: Int? + + #if DEBUG + private var pendingWriteFailure: (any Error)? + #endif + + public static func makeContainer(storage: Storage) throws -> ModelContainer { + let schema = Schema(PeriscopeSchema.models) + let configuration = ModelConfiguration( + "Periscope", + schema: schema, + isStoredInMemoryOnly: storage == .inMemory, + ) + return try ModelContainer(for: schema, configurations: [configuration]) + } + + /// App-wiring factory: opens the store and starts `session` so every + /// subsequent event is attributed to this launch. + public static func make(storage: Storage, session: LogSession) async throws -> PeriscopeStore { + let container = try makeContainer(storage: storage) + let store = PeriscopeStore(modelContainer: container) + try await store.startSession(session) + return store + } + + /// Test/preview factory: a fresh in-memory store per call. + @_spi(Testing) public static func inMemory( + session: LogSession, + ) async throws -> PeriscopeStore { + try await make(storage: .inMemory, session: session) + } + + // MARK: Sessions + + /// Record `session` as this launch's resource metadata; every event + /// written afterwards references it. Starting a session also declares + /// every earlier session dead: spans they left open (and whose policy + /// is `.endsWithProcess`) close as `.orphaned`. + public func startSession(_ session: LogSession) throws { + activeSession = session + let row = SDLogSession(session: session) + modelContext.insert(row) + do { + try modelContext.save() + } catch { + recoverFromFailedWrite() + throw error + } + activeSessionRow = row + closeOrphanedSpans(startedSessionID: session.id) + } + + /// Close spans that earlier sessions began but never ended. The begin + /// payload's ``SpanRelaunchPolicy`` decides: `.endsWithProcess` spans + /// get a synthetic ``SpanEnded`` (`.orphaned`, duration unknowable — + /// the process died at an unknown point); `.survivesRelaunch` spans + /// stay open. Runs degraded-but-handled: a sweep failure logs and + /// counts, it never fails the session start. + private func closeOrphanedSpans(startedSessionID: UUID) { + do { + let beganName = SpanBegan.eventName + let endedName = SpanEnded.eventName + + // This runs on the launch path with weeks of history behind it, + // so the began/ended passes fetch only the spanID column; full + // rows (payload for the policy, tags for attribution) load only + // for the few orphan candidates. + var beganDescriptor = FetchDescriptor( + predicate: #Predicate { + $0.eventName == beganName && $0.sessionID != startedSessionID + }, + ) + beganDescriptor.propertiesToFetch = [\.spanID] + let beganIDs = try modelContext.fetch(beganDescriptor).compactMap(\.spanID) + guard !beganIDs.isEmpty else { return } + + var endedDescriptor = FetchDescriptor( + predicate: #Predicate { $0.eventName == endedName }, + ) + endedDescriptor.propertiesToFetch = [\.spanID] + let endedSpanIDs = try Set(modelContext.fetch(endedDescriptor).compactMap(\.spanID)) + + let candidateIDs: [UUID?] = beganIDs.filter { !endedSpanIDs.contains($0) } + guard !candidateIDs.isEmpty else { return } + + let began = try modelContext.fetch(Self.readDescriptor( + predicate: #Predicate { + $0.eventName == beganName && candidateIDs.contains($0.spanID) + }, + )) + + var orphans: [LogRecord] = [] + for row in began { + guard let spanID = row.spanID else { continue } + // A payload that no longer decodes can't prove it wanted to + // survive — closing it is the honest fallback. + let event = try? JSONDecoder().decode(SpanBegan.self, from: row.payload) + if event?.relaunchPolicy == .survivesRelaunch { + continue + } + orphans.append(LogRecord( + date: Date(), + event: SpanEnded( + spanID: SpanID(rawValue: spanID), + name: event?.name ?? row.message, + duration: nil, + exit: .orphaned, + ), + scopes: row.orderedScopeIDs.map(ScopeID.init(rawValue:)), + tags: Self.tags(from: row), + )) + } + guard !orphans.isEmpty else { return } + try persist(orphans) + notifyChanged() + } catch { + recoverFromFailedWrite() + writeFailures += 1 + Self.failureLogger.warning("Failed to close orphaned spans: \(error)") + } + } + + /// Every recorded session, newest first. + public func sessions() throws -> [LogSession] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.startedAt, order: .reverse)], + ) + return try modelContext.fetch(descriptor).map(\.toValue) + } + + /// The launch-attribution row for ``activeSession`` (defaulting to + /// `LogSession.current()` when the app never called + /// ``startSession(_:)``). Refetches a committed row when the cached + /// reference was dropped by write recovery, so the session identity + /// never forks. + private func ensureActiveSession() throws -> SDLogSession { + if let activeSessionRow { + return activeSessionRow + } + let session = activeSession ?? .current() + activeSession = session + let id = session.id + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.sessionID == id }, + ) + descriptor.fetchLimit = 1 + if let existing = try modelContext.fetch(descriptor).first { + activeSessionRow = existing + return existing + } + let row = SDLogSession(session: session) + modelContext.insert(row) + activeSessionRow = row + return row + } + + // MARK: LogSink + + public func defineScopes(_ scopes: [LogScope]) async { + do { + for scope in scopes { + try upsertScopeRow(scope) + } + try throwInjectedFailureIfPending() + try modelContext.save() + } catch { + recoverFromFailedWrite() + writeFailures += 1 + Self.failureLogger.error("Failed to persist \(scopes.count) scopes: \(error)") + } + } + + public func write(_ records: [LogRecord]) async { + guard !records.isEmpty else { return } + do { + try persist(records) + notifyChanged() + } catch { + recoverFromFailedWrite() + writeFailures += 1 + Self.failureLogger.error("Failed to persist \(records.count) log events: \(error)") + persistWriteFailureMarker( + lostRecordCount: records.count, + reason: String(describing: error), + ) + } + } + + /// After a rolled-back write, persist a ``StoreWriteFailed`` marker so + /// the durable history is honest about its own gap — the batch is gone, + /// but the store says so where the viewer can see it. Best-effort: if + /// the marker's own tiny save also fails, the OSLog line above is the + /// last signal (no recursion). + private func persistWriteFailureMarker(lostRecordCount: Int, reason: String) { + let marker = LogRecord( + date: Date(), + event: StoreWriteFailed(lostRecordCount: lostRecordCount, reason: reason), + scopes: [], + ) + do { + try persist([marker]) + notifyChanged() + } catch { + recoverFromFailedWrite() + Self.failureLogger.error("Failed to persist the write-failure marker: \(error)") + } + } + + public func flush() async { + // Every write commits in its own save; nothing is buffered here. + } + + /// Persistence failures observed so far (also logged to OSLog). + @_spi(Testing) public var writeFailureCount: Int { + writeFailures + } + + /// Registered `changes()` observers — lets tests assert subscription + /// lifecycles (e.g. that a rebound viewer released its old stream). + @_spi(Testing) public var changeObserverCount: Int { + changeObservers.count + } + + #if DEBUG + /// Test seam: the next staged write (`write`, `defineScopes`, or a + /// deletion) fails with `error` just before its save would commit, + /// exercising the rollback/recovery path. + @_spi(Testing) public func injectNextWriteFailure(_ error: any Error) { + pendingWriteFailure = error + } + #endif + + /// Discard the failed transaction so a poisoned batch can't wedge every + /// save after it, and drop state that may reference rolled-back rows: + /// the row caches, and the session-row reference (`ensureActiveSession` + /// refetches or reinserts the same session identity on the next write). + private func recoverFromFailedWrite() { + modelContext.rollback() + scopeRowCache.removeAll() + tagRowCache.removeAll() + activeSessionRow = nil + } + + /// Throws the injected test failure, if any (DEBUG-only seam; a no-op + /// in release). + private func throwInjectedFailureIfPending() throws { + #if DEBUG + if let pendingWriteFailure { + self.pendingWriteFailure = nil + throw pendingWriteFailure + } + #endif + } + + /// The next monotonic insertion sequence, resuming past the largest + /// stored value on the first write of a launch. + private func takeSequence() throws -> Int { + if let nextSequence { + self.nextSequence = nextSequence + 1 + return nextSequence + } + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.sequence, order: .reverse)], + ) + descriptor.fetchLimit = 1 + let highest = try modelContext.fetch(descriptor).first?.sequence ?? -1 + nextSequence = highest + 2 + return highest + 1 + } + + private func persist(_ records: [LogRecord]) throws { + let session = try ensureActiveSession() + for record in records { + let payload: Data + do { + payload = try JSONEncoder().encode(record.event) + } catch { + // Keep the row (message, level, scopes survive) — degraded + // but handled. + payload = Data() + Self.failureLogger.warning( + "Payload for \(record.eventName) failed to encode: \(error)", + ) + } + let scopeRows = try record.scopes.map { try scopeRow(for: $0.rawValue) } + let tagRows = try record.tags.map { tag in + try tagRow(for: tag) + } + let attachmentRows = record.attachments.enumerated().map { index, attachment in + SDLogAttachment( + name: attachment.name, + contentType: attachment.contentType.mimeType, + index: index, + data: attachment.data, + ) + } + let row = try SDLogEvent( + eventID: record.id, + date: record.date, + sequence: takeSequence(), + severity: record.level.severity, + levelName: record.level.name, + eventName: record.eventName, + eventVersion: record.eventVersion, + message: record.message, + payload: payload, + orderedScopeIDs: record.scopes.map(\.rawValue), + sessionID: session.sessionID, + spanID: record.spanID?.rawValue, + spanExitMode: record.spanExit?.mode.rawValue, + callFunction: record.callSite?.function, + callFileID: record.callSite?.fileID, + externalID: record.externalID, + scopes: scopeRows, + tags: tagRows, + attachments: attachmentRows, + ) + modelContext.insert(row) + } + try throwInjectedFailureIfPending() + try modelContext.save() + } + + // MARK: Scopes + + /// All scopes ever defined, in no particular order. + public func scopes() throws -> [LogScope] { + try modelContext.fetch(FetchDescriptor()).map(Self.scopeValue) + } + + /// Resolve one scope. + public func scope(for id: ScopeID) throws -> LogScope? { + try fetchScopeRow(id: id.rawValue).map(Self.scopeValue) + } + + private static func scopeValue(_ row: SDLogScope) -> LogScope { + LogScope( + id: ScopeID(rawValue: row.scopeID), + name: row.name, + parentID: row.parentID.map(ScopeID.init(rawValue:)), + ) + } + + /// Insert or update the row for `scope` (idempotent per scope ID). + private func upsertScopeRow(_ scope: LogScope) throws { + let row = try scopeRow(for: scope.id.rawValue) + if row.name != scope.name { + row.name = scope.name + } + if row.parentID != scope.parentID?.rawValue { + row.parentID = scope.parentID?.rawValue + } + } + + /// Fetch-or-create a scope row. Records normally arrive after their + /// scope definitions, but an unknown scope still gets a placeholder row + /// (empty name) that a later definition fills in. + private func scopeRow(for id: UUID) throws -> SDLogScope { + if let cached = scopeRowCache[id] { + return cached + } + if let existing = try fetchScopeRow(id: id) { + scopeRowCache[id] = existing + return existing + } + let row = SDLogScope(scopeID: id, name: "", parentID: nil) + modelContext.insert(row) + scopeRowCache[id] = row + return row + } + + private func fetchScopeRow(id: UUID) throws -> SDLogScope? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.scopeID == id }, + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first + } + + // MARK: Tags + + /// Fetch-or-create the shared row for a key/value tag pair. + /// The typed tags for an event row, key-sorted for deterministic order + /// (the relationship itself is unordered). + private static func tags(from row: SDLogEvent) -> [LogTag] { + row.tags + .map { tagRow in + LogTag( + key: LogTagKey(tagRow.key), + value: LogTagValue(kind: tagRow.valueKind, stored: tagRow.value), + ) + } + .sorted { $0.key.rawValue < $1.key.rawValue } + } + + private func tagRow(for tag: LogTag) throws -> SDLogTag { + if let cached = tagRowCache[tag] { + return cached + } + let pair = tag.pair + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.pair == pair }, + ) + descriptor.fetchLimit = 1 + if let existing = try modelContext.fetch(descriptor).first { + tagRowCache[tag] = existing + return existing + } + let row = SDLogTag(tag: tag) + modelContext.insert(row) + tagRowCache[tag] = row + return row + } + + /// `subtree` plus every descendant, resolved against the stored + /// hierarchy. + private func subtreeIDs(of subtree: ScopeID) throws -> [UUID] { + let rows = try modelContext.fetch(FetchDescriptor()) + var childrenByParent: [UUID: [UUID]] = [:] + for row in rows { + if let parent = row.parentID { + childrenByParent[parent, default: []].append(row.scopeID) + } + } + var result: [UUID] = [] + var frontier = [subtree.rawValue] + while let next = frontier.popLast() { + result.append(next) + frontier.append(contentsOf: childrenByParent[next] ?? []) + } + return result + } + + // MARK: Queries + + /// Events matching `query`, newest first. + public func events(matching query: LogQuery) throws -> [StoredLogEvent] { + let start = query.start ?? .distantPast + let end = query.end ?? .distantFuture + let minSeverity = query.minimumLevel?.severity ?? Int.min + let filtersName = query.eventName != nil + let name = query.eventName ?? "" + let filtersSession = query.sessionID != nil + let session = query.sessionID ?? UUID() + let filtersSearch = !(query.messageContains ?? "").isEmpty + let search = query.messageContains ?? "" + let filtersScope = query.scope != nil + let scopeIDs: [UUID] = switch query.scope { + case let .exactly(id): [id.rawValue] + case let .subtree(id): try subtreeIDs(of: id) + case nil: [] + } + let filtersTags = !query.tags.isEmpty + let tagPairs = query.tags.map(\.pair) + let filtersExit = query.spanExitMode != nil + let exitMode: String? = query.spanExitMode?.rawValue + let filtersExternalID = query.externalID != nil + let externalID: String? = query.externalID + + let predicate = Self.eventsPredicate( + start: start, + end: end, + minSeverity: minSeverity, + filtersName: filtersName, + name: name, + filtersSession: filtersSession, + session: session, + filtersExit: filtersExit, + exitMode: exitMode, + filtersExternalID: filtersExternalID, + externalID: externalID, + filtersSearch: filtersSearch, + search: search, + filtersScope: filtersScope, + scopeIDs: scopeIDs, + filtersTags: filtersTags, + tagPairs: tagPairs, + ) + + var descriptor = Self.readDescriptor( + predicate: predicate, + sortBy: [ + SortDescriptor(\.date, order: .reverse), + SortDescriptor(\.sequence, order: .reverse), + ], + ) + if let limit = query.limit { + descriptor.fetchLimit = limit + } + if let offset = query.offset { + descriptor.fetchOffset = offset + } + return try modelContext.fetch(descriptor).map(Self.eventValue) + } + + /// The full filter predicate, hand-built in the shape `#Predicate` + /// would expand to — but as *statements*, one small `let` per + /// condition. A single macro expression with this many conditions is + /// one giant inference tree, and it exceeds the type-checker's budget + /// on slower machines (CI failed on what compiled locally); statement + /// form type-checks each condition independently in milliseconds and + /// scales linearly with future filters. + private static func eventsPredicate( + start: Date, + end: Date, + minSeverity: Int, + filtersName: Bool, + name: String, + filtersSession: Bool, + session: UUID, + filtersExit: Bool, + exitMode: String?, + filtersExternalID: Bool, + externalID: String?, + filtersSearch: Bool, + search: String, + filtersScope: Bool, + scopeIDs: [UUID], + filtersTags: Bool, + tagPairs: [String], + ) -> Predicate { + Predicate({ event in + let afterStart = PredicateExpressions.build_Comparison( + lhs: PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.date, + ), + rhs: PredicateExpressions.build_Arg(start), + op: .greaterThanOrEqual, + ) + let beforeEnd = PredicateExpressions.build_Comparison( + lhs: PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.date, + ), + rhs: PredicateExpressions.build_Arg(end), + op: .lessThanOrEqual, + ) + let atOrAboveFloor = PredicateExpressions.build_Comparison( + lhs: PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.severity, + ), + rhs: PredicateExpressions.build_Arg(minSeverity), + op: .greaterThanOrEqual, + ) + // Each optional filter keeps the `!filters || matches` shape + // the macro version used. + let matchesName = PredicateExpressions.build_Disjunction( + lhs: PredicateExpressions.build_Negation( + PredicateExpressions.build_Arg(filtersName), + ), + rhs: PredicateExpressions.build_Equal( + lhs: PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.eventName, + ), + rhs: PredicateExpressions.build_Arg(name), + ), + ) + let matchesSession = PredicateExpressions.build_Disjunction( + lhs: PredicateExpressions.build_Negation( + PredicateExpressions.build_Arg(filtersSession), + ), + rhs: PredicateExpressions.build_Equal( + lhs: PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.sessionID, + ), + rhs: PredicateExpressions.build_Arg(session), + ), + ) + let matchesExit = PredicateExpressions.build_Disjunction( + lhs: PredicateExpressions.build_Negation( + PredicateExpressions.build_Arg(filtersExit), + ), + rhs: PredicateExpressions.build_Equal( + lhs: PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.spanExitMode, + ), + rhs: PredicateExpressions.build_Arg(exitMode), + ), + ) + let matchesExternalID = PredicateExpressions.build_Disjunction( + lhs: PredicateExpressions.build_Negation( + PredicateExpressions.build_Arg(filtersExternalID), + ), + rhs: PredicateExpressions.build_Equal( + lhs: PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.externalID, + ), + rhs: PredicateExpressions.build_Arg(externalID), + ), + ) + let matchesSearch = PredicateExpressions.build_Disjunction( + lhs: PredicateExpressions.build_Negation( + PredicateExpressions.build_Arg(filtersSearch), + ), + rhs: PredicateExpressions.build_localizedStandardContains( + PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.message, + ), + PredicateExpressions.build_Arg(search), + ), + ) + let matchesScope = PredicateExpressions.build_Disjunction( + lhs: PredicateExpressions.build_Negation( + PredicateExpressions.build_Arg(filtersScope), + ), + rhs: PredicateExpressions.build_contains( + PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.scopes, + ), + ) { scope in + PredicateExpressions.build_contains( + PredicateExpressions.build_Arg(scopeIDs), + PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(scope), + keyPath: \.scopeID, + ), + ) + }, + ) + // AND across the requested tags without a dynamic expression + // tree: an event row can't carry duplicate pairs, so "matches + // all N tags" is "N of its tag rows have a pair in the list". + let matchesTag = PredicateExpressions.build_Disjunction( + lhs: PredicateExpressions.build_Negation( + PredicateExpressions.build_Arg(filtersTags), + ), + rhs: PredicateExpressions.build_Equal( + lhs: PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_filter( + PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.tags, + ), + ) { tag in + PredicateExpressions.build_contains( + PredicateExpressions.build_Arg(tagPairs), + PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(tag), + keyPath: \.pair, + ), + ) + }, + keyPath: \.count, + ), + rhs: PredicateExpressions.build_Arg(tagPairs.count), + ), + ) + + let dates = PredicateExpressions.build_Conjunction( + lhs: afterStart, + rhs: beforeEnd, + ) + let base = PredicateExpressions.build_Conjunction( + lhs: dates, + rhs: atOrAboveFloor, + ) + let named = PredicateExpressions.build_Conjunction( + lhs: base, + rhs: matchesName, + ) + let sessioned = PredicateExpressions.build_Conjunction( + lhs: named, + rhs: matchesSession, + ) + let exited = PredicateExpressions.build_Conjunction( + lhs: sessioned, + rhs: matchesExit, + ) + let externallyIdentified = PredicateExpressions.build_Conjunction( + lhs: exited, + rhs: matchesExternalID, + ) + let searched = PredicateExpressions.build_Conjunction( + lhs: externallyIdentified, + rhs: matchesSearch, + ) + let scoped = PredicateExpressions.build_Conjunction( + lhs: searched, + rhs: matchesScope, + ) + return PredicateExpressions.build_Conjunction( + lhs: scoped, + rhs: matchesTag, + ) + }) + } + + /// Both halves of a span (begin and end events sharing `span`), newest + /// first. Kept separate from ``events(matching:)`` so the hot general + /// predicate stays small. + public func events(inSpan span: SpanID) throws -> [StoredLogEvent] { + let id: UUID? = span.rawValue + let descriptor = Self.readDescriptor( + predicate: #Predicate { $0.spanID == id }, + sortBy: [ + SortDescriptor(\.date, order: .reverse), + SortDescriptor(\.sequence, order: .reverse), + ], + ) + return try modelContext.fetch(descriptor).map(Self.eventValue) + } + + /// One persisted event by ID (for the tracer and inspectors). + public func event(id: UUID) throws -> StoredLogEvent? { + try fetchEventRow(id: id).map(Self.eventValue) + } + + /// An event's attachments with their bytes loaded, in attach order. + /// Queried events carry only ``LogAttachmentInfo`` so list fetches + /// never pull blobs. + public func attachments(forEvent id: UUID) throws -> [LogAttachment] { + guard let row = try fetchEventRow(id: id) else { return [] } + return row.attachments + .sorted { $0.index < $1.index } + .map { row in + LogAttachment( + name: row.name, + contentType: LogAttachment.ContentType(mimeType: row.contentType), + data: row.data, + ) + } + } + + private func fetchEventRow(id: UUID) throws -> SDLogEvent? { + var descriptor = Self.readDescriptor(predicate: #Predicate { $0.eventID == id }) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first + } + + /// The descriptor for reads that map rows to values: `eventValue` + /// touches the `tags` and `attachments` relationships on every row, so + /// prefetch them — otherwise each row faults each relationship in its + /// own round trip (N+1). Attachment *blobs* still load lazily; only the + /// metadata rows prefetch. + private static func readDescriptor( + predicate: Predicate? = nil, + sortBy: [SortDescriptor] = [], + ) -> FetchDescriptor { + var descriptor = FetchDescriptor(predicate: predicate, sortBy: sortBy) + descriptor.relationshipKeyPathsForPrefetching = [\.tags, \.attachments] + return descriptor + } + + private static func eventValue(_ row: SDLogEvent) -> StoredLogEvent { + StoredLogEvent( + id: row.eventID, + date: row.date, + sequence: row.sequence, + level: LogLevel(name: row.levelName, severity: row.severity), + eventName: row.eventName, + eventVersion: row.eventVersion, + message: row.message, + payload: row.payload, + scopes: row.orderedScopeIDs.map(ScopeID.init(rawValue:)), + tags: tags(from: row), + spanID: row.spanID.map(SpanID.init(rawValue:)), + spanExitMode: row.spanExitMode.flatMap(SpanExit.Mode.init(rawValue:)), + callSite: row.callFunction.flatMap { function in + row.callFileID.map { LogCallSite(function: function, fileID: $0) } + }, + externalID: row.externalID, + attachments: row.attachments + .sorted { $0.index < $1.index } + .map { row in + LogAttachmentInfo( + name: row.name, + contentType: LogAttachment.ContentType(mimeType: row.contentType), + ) + }, + sessionID: row.sessionID, + ) + } + + // MARK: Retention + + /// Delete events older than `cutoff`; returns how many were removed. + /// Metadata the deletion orphans — event-less sessions, tag rows, and + /// scope branches — goes with it, so long-term growth stays bounded by + /// the events actually retained. + public func pruneEvents(olderThan cutoff: Date) throws -> Int { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.date < cutoff }, + ) + return try delete(modelContext.fetch(descriptor)) + } + + /// Keep only the newest `count` events; returns how many were removed. + public func pruneEvents(keepingNewest count: Int) throws -> Int { + var descriptor = FetchDescriptor( + sortBy: [ + SortDescriptor(\.date, order: .reverse), + SortDescriptor(\.sequence, order: .reverse), + ], + ) + descriptor.fetchOffset = count + return try delete(modelContext.fetch(descriptor)) + } + + /// Delete every stored event (developer tooling's "clear"). + public func deleteAllEvents() throws { + _ = try delete(modelContext.fetch(FetchDescriptor())) + } + + private func delete(_ rows: [SDLogEvent]) throws -> Int { + guard !rows.isEmpty else { return 0 } + for row in rows { + modelContext.delete(row) + } + do { + try throwInjectedFailureIfPending() + try modelContext.save() + } catch { + // Roll the staged deletions back — otherwise the next unrelated + // save would silently commit them. + recoverFromFailedWrite() + throw error + } + do { + // A second save, after the deletions committed: inverse + // relationships (`scope.events`, `tag.events`) only reflect + // removed rows once saved, so orphanhood isn't provable inside + // the same transaction. + try pruneOrphanedMetadata() + try modelContext.save() + } catch { + recoverFromFailedWrite() + throw error + } + notifyChanged() + return rows.count + } + + /// Remove metadata rows the deletion just orphaned, in the same save — + /// without this, retention only bounds the events table: one session + /// row per launch and one tag row per distinct pair (unbounded when + /// values are entity IDs) accumulate forever. + /// + /// - Sessions with no remaining events go, except the active launch's. + /// - Tag rows with no remaining events go (their cache entries too). + /// - Scopes go leaf-first when they have no events *and* no children, + /// so ancestors of still-populated scopes survive for path + /// resolution. + private func pruneOrphanedMetadata() throws { + for session in try modelContext.fetch(FetchDescriptor()) { + let sessionID = session.sessionID + guard sessionID != activeSession?.id else { continue } + var events = FetchDescriptor( + predicate: #Predicate { $0.sessionID == sessionID }, + ) + events.fetchLimit = 1 + if try modelContext.fetchCount(events) == 0 { + modelContext.delete(session) + } + } + + for tag in try modelContext.fetch(FetchDescriptor()) where tag.events.isEmpty { + tagRowCache[LogTag( + key: LogTagKey(tag.key), + value: LogTagValue(kind: tag.valueKind, stored: tag.value), + )] = nil + modelContext.delete(tag) + } + + var scopes = try modelContext.fetch(FetchDescriptor()) + var removedLeaf = true + while removedLeaf { + removedLeaf = false + let parentIDs = Set(scopes.compactMap(\.parentID)) + scopes.removeAll { scope in + guard scope.events.isEmpty, !parentIDs.contains(scope.scopeID) else { + return false + } + scopeRowCache[scope.scopeID] = nil + modelContext.delete(scope) + removedLeaf = true + return true + } + } + } + + // MARK: Change notification + + /// Pings after every committed write or deletion — live viewers refresh + /// off this signal. + public func changes() -> AsyncStream { + let id = UUID() + return AsyncStream { continuation in + changeObservers[id] = continuation + continuation.onTermination = { _ in + Task { await self.removeChangeObserver(id) } + } + } + } + + private func removeChangeObserver(_ id: UUID) { + changeObservers[id] = nil + } + + private func notifyChanged() { + for continuation in changeObservers.values { + continuation.yield(()) + } + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift new file mode 100644 index 00000000..f8f00065 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift @@ -0,0 +1,110 @@ +import Foundation + +/// The synthetic event `PeriscopeStore` persists after a failed, +/// rolled-back write — the durable history's marker for its own gap. +/// The lost batch's contents are gone by definition; this records how +/// many records vanished and why. +public struct StoreWriteFailed: LogEvent { + public static let eventName = "store-write-failed" + + public let lostRecordCount: Int + public let reason: String + + public var level: LogLevel { + .warning + } + + public var message: String { + "\(lostRecordCount) record(s) failed to persist: \(reason)" + } + + public init(lostRecordCount: Int, reason: String) { + self.lostRecordCount = lostRecordCount + self.reason = reason + } +} + +/// A persisted log event, as returned by `PeriscopeStore` queries — the +/// value-type snapshot of a stored row. +/// +/// The structured payload is retained as JSON keyed by ``eventName`` + +/// ``eventVersion``: ``decode(_:)`` recovers the original event type when it +/// still matches, and tooling can fall back to rendering ``payload`` as raw +/// JSON when the type has changed or no longer exists. +public struct StoredLogEvent: Sendable, Identifiable, Hashable { + public let id: UUID + public let date: Date + /// Store-assigned monotonic insertion order — the tiebreak when two + /// events share a date, so merged query results sort stably. + public let sequence: Int + public let level: LogLevel + public let eventName: String + public let eventVersion: Int + public let message: String + /// The event's stored properties, JSON-encoded. + public let payload: Data + /// Every scope the event references, primary first, in emission order. + public let scopes: [ScopeID] + /// The tags the event was stamped with. + public let tags: [LogTag] + /// The span this event begins or ends, when it is a span event. + public let spanID: SpanID? + /// How the span ended, when this is a span-ended event (the reason + /// lives in the payload — decode `SpanEnded` for it). + public let spanExitMode: SpanExit.Mode? + /// The emitting function/file, when the call site captured one. + public let callSite: LogCallSite? + /// The event's associated-object identifier (`LogEvent.externalID`). + public let externalID: String? + /// Attachment metadata; bytes load via + /// `PeriscopeStore.attachments(forEvent:)`. + public let attachments: [LogAttachmentInfo] + public let sessionID: UUID + + public init( + id: UUID, + date: Date, + sequence: Int, + level: LogLevel, + eventName: String, + eventVersion: Int, + message: String, + payload: Data, + scopes: [ScopeID], + tags: [LogTag], + spanID: SpanID?, + spanExitMode: SpanExit.Mode?, + callSite: LogCallSite?, + externalID: String?, + attachments: [LogAttachmentInfo], + sessionID: UUID, + ) { + self.id = id + self.date = date + self.sequence = sequence + self.level = level + self.eventName = eventName + self.eventVersion = eventVersion + self.message = message + self.payload = payload + self.scopes = scopes + self.tags = tags + self.spanID = spanID + self.spanExitMode = spanExitMode + self.callSite = callSite + self.externalID = externalID + self.attachments = attachments + self.sessionID = sessionID + } + + public var primaryScope: ScopeID? { + scopes.first + } + + /// Decode the structured payload back to its event type. Throws when + /// the stored JSON no longer matches the type's shape — callers degrade + /// to ``payload`` / ``message`` rather than losing the row. + public func decode(_: Event.Type) throws -> Event { + try JSONDecoder().decode(Event.self, from: payload) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/AccessibilityAmbientSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AccessibilityAmbientSourceTests.swift new file mode 100644 index 00000000..c1af32ae --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/AccessibilityAmbientSourceTests.swift @@ -0,0 +1,49 @@ +#if canImport(UIKit) + import Foundation + import PeriscopeCore + import Testing + import UIKit + + struct AccessibilityAmbientSourceTests { + @Test func startLogsTheCurrentSettingsSummary() async { + let sink = CapturingSink() + let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + system.startAmbientSource(AccessibilityAmbientSource()) + + // The summary hops to the main actor; wait for it rather than + // racing it. The simulator has nothing enabled. + let summarized = await waitUntil { + sink.records.contains { $0.message == "accessibility: none enabled" } + } + #expect(summarized) + } + + @Test func settingChangesLogTheCurrentState() async { + let sink = CapturingSink() + let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + system.startAmbientSource(AccessibilityAmbientSource()) + + NotificationCenter.default.post( + name: UIAccessibility.voiceOverStatusDidChangeNotification, + object: nil, + ) + + // The observer runs on the main queue; poll for delivery. The + // simulator reports VoiceOver off. + let logged = await waitUntil { + sink.records.contains { $0.message == "accessibility: voiceover: off" } + } + #expect(logged) + + system.stopAmbientSources() + NotificationCenter.default.post( + name: UIAccessibility.voiceOverStatusDidChangeNotification, + object: nil, + ) + await system.flush() + #expect( + sink.records.count(where: { $0.message == "accessibility: voiceover: off" }) == 1, + ) + } + } +#endif diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift new file mode 100644 index 00000000..5ce26f83 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift @@ -0,0 +1,97 @@ +import Foundation +import os +import PeriscopeCore +import Testing + +/// A source that logs one event the moment it starts. +private struct ImmediateSource: AmbientEventSource { + func start(log: Log) { + log { AmbientEvent(kind: AmbientKind("test-kind"), value: "started") } + } + + func stop() {} +} + +/// Observes a test-unique notification through `AmbientObserverTokens`, so +/// stop/restart semantics can be asserted without cross-talk from other +/// tests posting process-global system notifications. +private struct NotificationSource: AmbientEventSource { + let name: Notification.Name + private let tokens = AmbientObserverTokens() + + func start(log: Log) { + let token = NotificationCenter.default.addObserver( + forName: name, + object: nil, + queue: nil, + ) { _ in + log { AmbientEvent(kind: AmbientKind("test-kind"), value: "fired") } + } + tokens.replace(with: [token]) + } + + func stop() { + tokens.removeAll() + } +} + +struct AmbientEventSourceTests { + let sink = CapturingSink() + let system: Periscope + + init() { + system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + } + + @Test func startedSourcesLogUnderTheAmbientScope() async { + system.startAmbientSource(ImmediateSource()) + await system.flush() + + #expect(sink.records.count == 1) + #expect(sink.records.first?.message == "test-kind: started") + + let scope = sink.records.first?.scopes.first + #expect(scope.flatMap { system.scope(for: $0) }?.name == AmbientEvent.eventName) + } + + @Test func defaultSourcesStartAndStopWithoutIncident() async { + // Built-in sources observe real system notifications; starting and + // stopping them must register and unregister cleanly while the + // system stays consumable. + system.startDefaultAmbientSources() + system.stopAmbientSources() + let log = Log(system: system) + log.info("still logging") + await system.flush() + + #expect(sink.records.contains { $0.message == "still logging" }) + } + + @Test func stoppedSourcesNoLongerObserve() async { + let name = Notification.Name("periscope-test-\(UUID().uuidString)") + system.startAmbientSource(NotificationSource(name: name)) + + NotificationCenter.default.post(name: name, object: nil) + await system.flush() + #expect(sink.records.count(where: { $0.message == "test-kind: fired" }) == 1) + + system.stopAmbientSources() + NotificationCenter.default.post(name: name, object: nil) + await system.flush() + #expect(sink.records.count(where: { $0.message == "test-kind: fired" }) == 1) + } + + @Test func restartingASourceReplacesItsObservationInsteadOfDoubling() async { + let name = Notification.Name("periscope-test-\(UUID().uuidString)") + let source = NotificationSource(name: name) + let log = Log(system: system) + source.start(log: log) + source.start(log: log) + + NotificationCenter.default.post(name: name, object: nil) + await system.flush() + + #expect(sink.records.count(where: { $0.message == "test-kind: fired" }) == 1) + source.stop() + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventTests.swift new file mode 100644 index 00000000..48f21d69 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventTests.swift @@ -0,0 +1,29 @@ +import Foundation +import PeriscopeCore +import Testing + +struct AmbientEventTests { + @Test func messageCombinesKindAndValue() { + let event = AmbientEvent(kind: .network, value: "unsatisfied") + #expect(event.message == "network: unsatisfied") + #expect(event.level == .info) + } + + @Test func levelCanBeRaised() { + let event = AmbientEvent(kind: .memory, value: "warning", level: .warning) + #expect(event.level == .warning) + } + + @Test func appsCanDefineTheirOwnKinds() { + let custom = AmbientKind("push-token") + let event = AmbientEvent(kind: custom, value: "refreshed") + #expect(event.message == "push-token: refreshed") + } + + @Test func roundTripsThroughCodable() throws { + let event = AmbientEvent(kind: .thermalState, value: "serious", level: .warning) + let data = try JSONEncoder().encode(event) + let decoded = try JSONDecoder().decode(AmbientEvent.self, from: data) + #expect(decoded == event) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift new file mode 100644 index 00000000..6191ff76 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift @@ -0,0 +1,145 @@ +import Foundation +import PeriscopeCore +import Testing + +struct AmbientLogContextTests { + let sink = CapturingSink() + let system: Periscope + + init() { + system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + } + + @Test func currentFallsBackToASharedRootLogger() { + let current = Log.current + #expect(current.primaryScope.name == "AppLogs") + #expect(current.primaryScope.parentID == nil) + } + + @Test func withContextMakesTheLoggerAmbient() async { + let log = Log(system: system) + + await log.withContext { + Log.current.info("deep") + } + await system.flush() + + #expect(sink.records.map(\.message) == ["deep"]) + #expect(sink.records.first?.scopes == log.scopes.map(\.id)) + } + + @Test func currentCanBeTypedToAnyEvent() async { + let log = Log(system: system) + + await log.withContext { + Log.current { PhotoLogs(photoID: "p1") } + } + await system.flush() + + #expect(sink.records.map(\.message) == ["photo p1"]) + #expect(sink.records.first?.scopes == log.scopes.map(\.id)) + } + + @Test func nestedContextsLinkWithTheInnerLogPrimary() async { + let model = Log(system: system) + let ui = Log(system: system) + + await model.withContext { + await ui.withContext { + Log.current.info("both") + } + } + await system.flush() + + let expected = (ui.scopes + model.scopes).map(\.id) + #expect(sink.records.first?.scopes == expected) + } + + @Test func nestingTheSameContextTwiceCollapsesDuplicates() async { + let log = Log(system: system) + + await log.withContext { + await log.withContext { + Log.current.info("once") + } + } + await system.flush() + + #expect(sink.records.first?.scopes == log.scopes.map(\.id)) + } + + @Test func contextPropagatesIntoStructuredChildTasks() async { + let log = Log(system: system) + + await log.withContext { + await withTaskGroup(of: Void.self) { group in + group.addTask { + Log.current.info("from child task") + } + await group.waitForAll() + } + } + await system.flush() + + #expect(sink.records.map(\.message) == ["from child task"]) + #expect(sink.records.first?.scopes == log.scopes.map(\.id)) + } + + @Test func synchronousWithContextBindsTheContextToo() async { + let log = Log(system: system) + + log.withContext { + Log.current.info("sync") + } + await system.flush() + + #expect(sink.records.map(\.message) == ["sync"]) + #expect(sink.records.first?.scopes == log.scopes.map(\.id)) + } + + @Test func ambientTagsStampEventsLoggedThroughCurrent() async { + let key = LogTagKey("payment-id") + let log = Log(system: system).tagged(key, "pay_123") + + await log.withContext { + Log.current.info("tagged") + } + await system.flush() + + #expect(sink.records.first?.tags == [LogTag(key: key, value: "pay_123")]) + } + + @Test func nestedContextTagsMergeWithTheInnerWinning() async { + let key = LogTagKey("payment-id") + let outer = Log(system: system).tagged(key, "outer") + let inner = Log(system: system) + .tagged(key, "inner") + .tagged(LogTagKey("extra"), "e") + + await outer.withContext { + await inner.withContext { + Log.current.info("both") + } + } + await system.flush() + + #expect(sink.records.first?.tags == [ + LogTag(key: key, value: "inner"), + LogTag(key: LogTagKey("extra"), value: "e"), + ]) + } + + @Test func contextEndsWhenWithContextReturns() async { + let log = Log(system: system) + await log.withContext {} + + let after = Log.current + #expect(after.primaryScope == LogScope.root(named: "AppLogs")) + } + + @Test func withContextReturnsTheBodyValue() async { + let log = Log(system: system) + let value = await log.withContext { 42 } + #expect(value == 42) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/AppLifecycleAmbientSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AppLifecycleAmbientSourceTests.swift new file mode 100644 index 00000000..06554a93 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/AppLifecycleAmbientSourceTests.swift @@ -0,0 +1,32 @@ +import Foundation +import PeriscopeCore +import Testing +import UIKit + +struct AppLifecycleAmbientSourceTests { + let sink = CapturingSink() + let system: Periscope + + init() { + system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + system.startAmbientSource(AppLifecycleAmbientSource()) + } + + @Test(arguments: [ + (UIApplication.didEnterBackgroundNotification, "background"), + (UIApplication.willEnterForegroundNotification, "foreground"), + (UIApplication.didBecomeActiveNotification, "active"), + (UIApplication.willResignActiveNotification, "inactive"), + ]) + func lifecycleNotificationsLogTransitions( + name: Notification.Name, + value: String, + ) async { + NotificationCenter.default.post(name: name, object: nil) + await system.flush() + + #expect(sink.records.contains { record in + record.message == "app-lifecycle: \(value)" + }) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift b/Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift new file mode 100644 index 00000000..16f8487c --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift @@ -0,0 +1,44 @@ +import Foundation +import PeriscopeCore +import Testing + +private final class FirstFixture {} +private final class SecondFixture {} + +struct InstanceIDTests { + @Test func sameInstanceYieldsEqualIDs() { + let object = FirstFixture() + #expect(InstanceID(of: object) == InstanceID(of: object)) + #expect(InstanceID(of: object).hashValue == InstanceID(of: object).hashValue) + } + + @Test func distinctLiveInstancesYieldDistinctIDs() { + // Both must stay alive for the comparison: a released temporary's + // address gets recycled immediately (the exact phenomenon the + // dealloc trackers guard against). + let first = FirstFixture() + let second = FirstFixture() + #expect(InstanceID(of: first) != InstanceID(of: second)) + withExtendedLifetime(first) {} + withExtendedLifetime(second) {} + } + + @Test func debugDescriptionNamesTheType() { + let id = InstanceID(of: FirstFixture()) + #expect(id.debugDescription.hasPrefix("FirstFixture@0x")) + #expect(id.typeName == "FirstFixture") + #expect(id.type == FirstFixture.self) + } + + @Test func worksAsADictionaryKey() { + let first = FirstFixture() + let second = SecondFixture() + var counts: [InstanceID: Int] = [:] + counts[InstanceID(of: first)] = 1 + counts[InstanceID(of: second)] = 2 + + #expect(counts[InstanceID(of: first)] == 1) + #expect(counts[InstanceID(of: second)] == 2) + #expect(counts.count == 2) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift new file mode 100644 index 00000000..df21842c --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift @@ -0,0 +1,69 @@ +import Foundation +import PeriscopeCore +import Testing +#if canImport(UIKit) + import UIKit +#endif + +private struct DownloadFailure: Error {} + +struct LogAttachmentTests { + @Test func rawDataAttachmentKeepsItsFields() { + let attachment = LogAttachment( + name: "response", + contentType: .other("application/octet-stream"), + data: Data([1, 2, 3]), + ) + #expect(attachment.name == "response") + #expect(attachment.contentType.mimeType == "application/octet-stream") + #expect(attachment.data == Data([1, 2, 3])) + } + + @Test func contentTypesRoundTripThroughTheirMIMEStrings() { + let known: [LogAttachment.ContentType] = [.json, .png, .jpeg, .plainText] + for type in known { + #expect(LogAttachment.ContentType(mimeType: type.mimeType) == type) + } + let uncommon = LogAttachment.ContentType(mimeType: "application/octet-stream") + #expect(uncommon == .other("application/octet-stream")) + #expect(uncommon.mimeType == "application/octet-stream") + } + + @Test func errorAttachmentCapturesDescriptionDomainAndCode() throws { + let error = NSError(domain: "com.stuff.test", code: 42, userInfo: nil) + let attachment = LogAttachment.error(error, name: "failure") + + #expect(attachment.contentType == .json) + let decoded = try JSONDecoder().decode([String: String].self, from: attachment.data) + #expect(decoded["domain"] == "com.stuff.test") + #expect(decoded["code"] == "42") + #expect(decoded["description"]?.isEmpty == false) + } + + @Test func swiftErrorsAttachToo() throws { + let attachment = LogAttachment.error(DownloadFailure(), name: "failure") + let decoded = try JSONDecoder().decode([String: String].self, from: attachment.data) + #expect(decoded["description"]?.contains("DownloadFailure") == true) + } + + @Test func jsonAttachmentRoundTripsEncodableValues() throws { + let attachment = try LogAttachment.json(PhotoLogs(photoID: "p1"), name: "photo") + #expect(attachment.contentType == .json) + let decoded = try JSONDecoder().decode(PhotoLogs.self, from: attachment.data) + #expect(decoded.photoID == "p1") + } + + #if canImport(UIKit) + @Test func imageAttachmentEncodesPNG() throws { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)) + let image = renderer.image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) + } + + let attachment = try #require(LogAttachment.image(image, name: "screenshot")) + #expect(attachment.contentType == .png) + #expect(!attachment.data.isEmpty) + } + #endif +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift new file mode 100644 index 00000000..f3886837 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift @@ -0,0 +1,159 @@ +import Foundation +@testable import PeriscopeCore +import Testing + +private final class FreeformController: LogContextProviding { + let system: Periscope + + var logSystem: Periscope { + system + } + + init(system: Periscope) { + self.system = system + } +} + +private final class TypedController: LogContextProviding { + typealias LogEventType = PhotoLogs + + let system: Periscope + + var logSystem: Periscope { + system + } + + init(system: Periscope) { + self.system = system + } +} + +struct LogContextProvidingTests { + let sink = CapturingSink() + let system: Periscope + + init() { + system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + } + + @Test func logScopesToTheTypeAndInstance() throws { + let controller = FreeformController(system: system) + let scope = controller.log.primaryScope + + #expect(scope.name == "#1") + let parentID = try #require(scope.parentID) + let parent = try #require(system.scope(for: parentID)) + #expect(parent.name == "FreeformController") + #expect(parent.parentID == nil) + } + + @Test func sameInstanceKeepsItsScope() { + let controller = FreeformController(system: system) + #expect(controller.log.primaryScope == controller.log.primaryScope) + } + + @Test func distinctInstancesGetDistinctNumberedScopes() { + let first = FreeformController(system: system) + let second = FreeformController(system: system) + + #expect(first.log.primaryScope.name == "#1") + #expect(second.log.primaryScope.name == "#2") + #expect(first.log.primaryScope != second.log.primaryScope) + #expect(first.log.primaryScope.parentID == second.log.primaryScope.parentID) + } + + @Test func instanceCountersAreIndependentPerType() { + _ = FreeformController(system: system).log + let typed = TypedController(system: system) + #expect(typed.log.primaryScope.name == "#1") + } + + @Test func typedConformersGetATypedLogger() async { + let controller = TypedController(system: system) + + controller.log { PhotoLogs(photoID: "p1") } + await system.flush() + + #expect(sink.records.map(\.message) == ["photo p1"]) + #expect(sink.records.first?.scopes == [controller.log.primaryScope.id]) + } + + @Test func freeformLoggingWorksOnAnyConformer() async { + let controller = FreeformController(system: system) + + controller.log.warning("degraded") + await system.flush() + + #expect(sink.records.map(\.message) == ["degraded"]) + #expect(sink.records.first?.level == .warning) + } + + @Test func bothScopesAreDefinedInTheSystem() { + let controller = FreeformController(system: system) + let instance = controller.log.primaryScope + + #expect(system.scope(for: instance.id) == instance) + let parentID = instance.parentID + #expect(parentID.flatMap { system.scope(for: $0) } != nil) + } + + // MARK: Instance lifecycle + + /// Registers one instance's scopes and lets the instance die on return. + private func trackAndRelease(in registry: InstanceScopeRegistry) -> InstanceScopePair { + let controller = FreeformController(system: system) + return registry.scopes(for: controller) + } + + @Test func entriesEvictWhenTheInstanceDeallocates() throws { + let registry = InstanceScopeRegistry() + + let controller: FreeformController? = FreeformController(system: system) + _ = try registry.scopes(for: #require(controller)) + #expect(registry.trackedInstanceCount == 1) + + _ = consume controller + #expect(registry.trackedInstanceCount == 0) + } + + @Test func instanceNumbersAreNeverReusedWithinARun() { + let registry = InstanceScopeRegistry() + + let first = trackAndRelease(in: registry) + #expect(first.instance.name == "#1") + #expect(registry.trackedInstanceCount == 0) + + // A later instance — whether or not the allocator recycles the + // address — must get a fresh identity, not the dead one's. + let second = FreeformController(system: system) + let pair = registry.scopes(for: second) + #expect(pair.instance.name == "#2") + #expect(pair.instance != first.instance) + } + + @Test func aliveInstancesKeepTheirEntryAcrossRepeatedLookups() { + let registry = InstanceScopeRegistry() + let controller = FreeformController(system: system) + + let first = registry.scopes(for: controller) + let second = registry.scopes(for: controller) + + #expect(first.instance == second.instance) + #expect(registry.trackedInstanceCount == 1) + } + + @Test func trackersFromDifferentRegistriesCoexistOnOneInstance() throws { + let firstRegistry = InstanceScopeRegistry() + let secondRegistry = InstanceScopeRegistry() + + let controller: FreeformController? = FreeformController(system: system) + _ = try firstRegistry.scopes(for: #require(controller)) + _ = try secondRegistry.scopes(for: #require(controller)) + #expect(firstRegistry.trackedInstanceCount == 1) + #expect(secondRegistry.trackedInstanceCount == 1) + + _ = consume controller + #expect(firstRegistry.trackedInstanceCount == 0) + #expect(secondRegistry.trackedInstanceCount == 0) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogEventTests.swift new file mode 100644 index 00000000..6abf79ef --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogEventTests.swift @@ -0,0 +1,53 @@ +import Foundation +import PeriscopeCore +import Testing + +private struct PhotoUploaded: LogEvent { + var photoID: String + var byteCount: Int + var message: String { + "Uploaded photo \(photoID) (\(byteCount) bytes)" + } +} + +private struct DiskFull: LogEvent { + static let eventName = "disk-full" + static let eventVersion = 2 + var level: LogLevel { + .error + } + + var message: String { + "Disk full" + } +} + +struct LogEventTests { + @Test func eventNameDefaultsToTypeName() { + #expect(PhotoUploaded.eventName == "PhotoUploaded") + } + + @Test func eventNameCanBeOverridden() { + #expect(DiskFull.eventName == "disk-full") + } + + @Test func eventVersionDefaultsToOne() { + #expect(PhotoUploaded.eventVersion == 1) + #expect(DiskFull.eventVersion == 2) + } + + @Test func levelDefaultsToInfo() { + let event = PhotoUploaded(photoID: "p1", byteCount: 42) + #expect(event.level == .info) + #expect(DiskFull().level == .error) + } + + @Test func payloadRoundTripsThroughCodable() throws { + let event = PhotoUploaded(photoID: "p1", byteCount: 42) + let data = try JSONEncoder().encode(event) + let decoded = try JSONDecoder().decode(PhotoUploaded.self, from: data) + #expect(decoded.photoID == "p1") + #expect(decoded.byteCount == 42) + #expect(decoded.message == event.message) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogLevelTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogLevelTests.swift new file mode 100644 index 00000000..2ca9acee --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogLevelTests.swift @@ -0,0 +1,75 @@ +import Foundation +import os +import PeriscopeCore +import Testing + +struct LogLevelTests { + @Test func standardLadderOrdersBySeverity() { + let ladder = LogLevel.standardLevels + #expect(ladder == ladder.sorted()) + #expect(LogLevel.debug < .info) + #expect(LogLevel.info < .notice) + #expect(LogLevel.notice < .warning) + #expect(LogLevel.warning < .error) + #expect(LogLevel.error < .fault) + } + + @Test func customLevelSlotsBetweenStandardLevels() { + let audit = LogLevel(name: "audit", severity: 450) + #expect(LogLevel.warning < audit) + #expect(audit < LogLevel.error) + } + + @Test func orderingComparesSeverityAlone() { + let renamed = LogLevel(name: "renamed", severity: LogLevel.warning.severity) + #expect(!(renamed < LogLevel.warning)) + #expect(!(LogLevel.warning < renamed)) + #expect(renamed != LogLevel.warning) + } + + @Test(arguments: [ + (LogLevel.debug, OSLogType.debug), + (LogLevel.info, OSLogType.info), + (LogLevel.notice, OSLogType.default), + (LogLevel.warning, OSLogType.default), + (LogLevel.error, OSLogType.error), + (LogLevel.fault, OSLogType.fault), + ]) + func standardLevelsMapToOSLogTypes(level: LogLevel, expected: OSLogType) { + #expect(level.osLogType == expected) + } + + @Test func customLevelsInheritOSLogTypeBySeverityBand() { + #expect(LogLevel(name: "trace", severity: 50).osLogType == .debug) + #expect(LogLevel(name: "audit", severity: 450).osLogType == .default) + #expect(LogLevel(name: "critical", severity: 550).osLogType == .error) + #expect(LogLevel(name: "meltdown", severity: 900).osLogType == .fault) + } + + @Test func customLevelsCanOverrideTheirOSLogType() { + let audit = LogLevel(name: "audit", severity: 450, osLogType: .error) + #expect(audit.osLogType == .error) + + // The mapping routes OSLog mirroring; it is not part of identity. + let banded = LogLevel(name: "audit", severity: 450) + #expect(audit == banded) + #expect(audit.hashValue == banded.hashValue) + } + + @Test func roundTripsThroughCodable() throws { + let level = LogLevel(name: "audit", severity: 450) + let data = try JSONEncoder().encode(level) + let decoded = try JSONDecoder().decode(LogLevel.self, from: data) + #expect(decoded == level) + } + + @Test func decodedLevelsRederiveTheBandDefaultOSLogType() throws { + // The OSLog mapping only routes live mirroring, so it isn't + // persisted — a custom mapping decodes back to the band default. + let custom = LogLevel(name: "audit", severity: 450, osLogType: .error) + let data = try JSONEncoder().encode(custom) + let decoded = try JSONDecoder().decode(LogLevel.self, from: data) + #expect(decoded == custom) + #expect(decoded.osLogType == .default) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogRecordTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogRecordTests.swift new file mode 100644 index 00000000..2574fb53 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogRecordTests.swift @@ -0,0 +1,19 @@ +import Foundation +import PeriscopeCore +import Testing + +struct LogRecordTests { + @Test func exposesItsEventDerivedFields() { + let scope = LogScope.root(named: "photos") + let record = LogRecord( + date: Date(timeIntervalSince1970: 100), + event: PhotoLogs(photoID: "p1"), + scopes: [scope.id], + ) + #expect(record.level == .notice) + #expect(record.message == "photo p1") + #expect(record.eventName == "PhotoLogs") + #expect(record.eventVersion == 1) + #expect(record.scopes == [scope.id]) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogScopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogScopeTests.swift new file mode 100644 index 00000000..5f04f0c5 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogScopeTests.swift @@ -0,0 +1,46 @@ +import Foundation +import PeriscopeCore +import Testing + +struct LogScopeTests { + @Test func rootHasNoParent() { + let root = LogScope.root(named: "photos") + #expect(root.name == "photos") + #expect(root.parentID == nil) + } + + @Test func childKeepsItsParentChain() { + let root = LogScope.root(named: "photos") + let album = root.child(named: "album-1") + let photo = album.child(named: "photo-9") + #expect(album.parentID == root.id) + #expect(photo.parentID == album.id) + #expect(photo.name == "photo-9") + } + + @Test func ancestryWalksRootFirstThroughTheResolver() { + let root = LogScope.root(named: "app") + let photos = root.child(named: "photos") + let album = photos.child(named: "album-1") + let known = [root.id: root, photos.id: photos, album.id: album] + + let chain = LogScope.ancestry(of: album.id) { known[$0] } + #expect(chain == [root, photos, album]) + } + + @Test func ancestryStopsAtTheFirstUnresolvableScope() { + let root = LogScope.root(named: "app") + let photos = root.child(named: "photos") + let known = [photos.id: photos] // parent missing + + #expect(LogScope.ancestry(of: photos.id) { known[$0] } == [photos]) + #expect(LogScope.ancestry(of: root.id) { known[$0] }.isEmpty) + } + + @Test func roundTripsThroughCodable() throws { + let scope = LogScope.root(named: "photos").child(named: "album-1") + let data = try JSONEncoder().encode(scope) + let decoded = try JSONDecoder().decode(LogScope.self, from: data) + #expect(decoded == scope) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogSessionTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogSessionTests.swift new file mode 100644 index 00000000..0854257c --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogSessionTests.swift @@ -0,0 +1,24 @@ +import Foundation +import PeriscopeCore +import Testing + +struct LogSessionTests { + @Test func currentDescribesThisLaunch() { + let session = LogSession.current() + #expect(!session.appVersion.isEmpty) + #expect(!session.buildNumber.isEmpty) + #expect(!session.osVersion.isEmpty) + #expect(!session.deviceModel.isEmpty) + } + + @Test func eachCurrentSessionIsDistinct() { + #expect(LogSession.current().id != LogSession.current().id) + } + + @Test func roundTripsThroughCodable() throws { + let session = LogSession.fixture() + let data = try JSONEncoder().encode(session) + let decoded = try JSONDecoder().decode(LogSession.self, from: data) + #expect(decoded == session) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift new file mode 100644 index 00000000..47cccb31 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift @@ -0,0 +1,276 @@ +import Foundation +import PeriscopeCore +import Testing + +private struct DatabaseLogs: LogEvent { + enum SpanName: Hashable { + case saveEvent + case migration + } + + var message: String { + "db" + } +} + +private struct MeasureError: Error {} + +struct LogSpanTests { + let recorder = RecordingRecorder() + + @Test func measureEmitsAPairedBeginAndEnd() throws { + let log = Log(recorder: recorder) + + let result = log.measure("save") { 42 } + #expect(result == 42) + + let records = recorder.records + #expect(records.count == 2) + let began = try #require(records.first?.event as? SpanBegan) + let ended = try #require(records.last?.event as? SpanEnded) + #expect(began.spanID == ended.spanID) + #expect(began.name == "save") + #expect(began.lifetime == .scoped) + #expect(began.relaunchPolicy == .endsWithProcess) + #expect(ended.name == "save") + #expect(ended.exit == .success) + let duration = try #require(ended.duration) + #expect(duration >= .zero) + #expect(records.allSatisfy { $0.scopes == log.scopes.map(\.id) }) + } + + @Test func measureRecordsThrownErrorsAsFailures() throws { + let log = Log(recorder: recorder) + + #expect(throws: MeasureError.self) { + try log.measure("failing") { throw MeasureError() } + } + + #expect(recorder.records.count == 2) + let ended = try #require(recorder.records.last?.event as? SpanEnded) + #expect(ended.exit.mode == .failure) + #expect(ended.exit.reason?.contains("MeasureError") == true) + #expect(ended.level == .warning) + } + + @Test func measureRecordsCancellationAsCancelledNotFailed() throws { + let log = Log(recorder: recorder) + + #expect(throws: CancellationError.self) { + try log.measure("cancelled") { throw CancellationError() } + } + + let ended = try #require(recorder.records.last?.event as? SpanEnded) + #expect(ended.exit == .cancelled) + #expect(ended.level == .info) + } + + @Test func asyncMeasureTimesAsyncWork() async throws { + let log = Log(recorder: recorder) + + let result = await log.measure("async-save") { + await Task.yield() + return "done" + } + #expect(result == "done") + + let ended = try #require(recorder.records.last?.event as? SpanEnded) + #expect(ended.name == "async-save") + #expect(ended.exit == .success) + } + + @Test func budgetedMeasuresWarnWhileRunningPastTheBudget() async throws { + let log = Log(recorder: recorder) + + // The body waits for the overdue signal itself, so the test is + // condition-driven: the sentinel provably fired mid-execution. + await log.measure("slow-save", budget: .milliseconds(5)) { + _ = await waitUntil { + recorder.records.contains { $0.event is SpanOverdue } + } + } + + let records = recorder.records + let began = try #require(records.first?.event as? SpanBegan) + let overdue = try #require( + records.compactMap { $0.event as? SpanOverdue }.first, + ) + #expect(overdue.spanID == began.spanID) + #expect(overdue.name == "slow-save") + #expect(overdue.budget == .milliseconds(5)) + + let overdueRecord = try #require(records.first { $0.event is SpanOverdue }) + #expect(overdueRecord.level == .warning) + #expect(overdueRecord.spanID == began.spanID) + #expect(overdueRecord.scopes == log.scopes.map(\.id)) + + // The span still ends normally with its derived exit. + let ended = try #require(records.last?.event as? SpanEnded) + #expect(ended.exit == .success) + } + + @Test func budgetedMeasuresWithinBudgetStayQuiet() throws { + let log = Log(recorder: recorder) + + let result = log.measure("fast-save", budget: .seconds(60)) { 42 } + #expect(result == 42) + + let records = recorder.records + #expect(records.count == 2) + #expect(!records.contains { $0.event is SpanOverdue }) + let ended = try #require(records.last?.event as? SpanEnded) + #expect(ended.exit == .success) + } + + @Test func overdueEventsRoundTripThroughCodable() throws { + let overdue = SpanOverdue(spanID: SpanID(), name: "save", budget: .seconds(1)) + let data = try JSONEncoder().encode(overdue) + let decoded = try JSONDecoder().decode(SpanOverdue.self, from: data) + #expect(decoded.spanID == overdue.spanID) + #expect(decoded.budget == .seconds(1)) + } + + @Test func typedSpanTokensResolveAgainstTheEventType() throws { + let log = Log(recorder: recorder) + + log.measure(.saveEvent) {} + + let began = try #require(recorder.records.first?.event as? SpanBegan) + #expect(began.name == "saveEvent") + } + + @Test func beginAndEndPairAcrossRebuiltLoggers() throws { + let first = Log(recorder: recorder)(for: "payment-flow") + first.begin(for: "pay_1", lifetime: .indefinite) + + // A logger rebuilt from the same path pairs with the open span. + let second = Log(recorder: recorder)(for: "payment-flow") + second.end(for: "pay_1", exit: .success("payment settled")) + + let records = recorder.records + #expect(records.count == 2) + let began = try #require(records.first?.event as? SpanBegan) + let ended = try #require(records.last?.event as? SpanEnded) + #expect(began.spanID == ended.spanID) + #expect(began.lifetime == .indefinite) + #expect(ended.name == "pay_1") + #expect(ended.exit == .success("payment settled")) + #expect(ended.duration ?? .zero >= .zero) + } + + @Test func endRecordsTheGivenExit() throws { + let log = Log(recorder: recorder) + log.begin(for: "pay_1", lifetime: .indefinite) + log.end(for: "pay_1", exit: .failure("card declined")) + + let ended = try #require(recorder.records.last?.event as? SpanEnded) + #expect(ended.exit.mode == .failure) + #expect(ended.exit.reason == "card declined") + #expect(ended.level == .warning) + } + + @Test func endingWithoutABeginWarnsInsteadOfEmitting() { + let log = Log(recorder: recorder) + + log.end(for: "never-began", exit: .success) + + let records = recorder.records + #expect(records.count == 1) + #expect(records.first?.level == .warning) + #expect(records.first?.message.contains("without a matching begin") == true) + } + + @Test func rebeginningSupersedesTheOpenSpan() throws { + let log = Log(recorder: recorder) + + log.begin(for: "pay_1", lifetime: .indefinite) + log.begin(for: "pay_1", lifetime: .indefinite) + log.end(for: "pay_1", exit: .success) + + // The second began precedes the superseded end: registration and + // began record atomically, and the close it causes follows it. + let records = recorder.records + #expect(records.count == 4) + let firstBegan = try #require(records[0].event as? SpanBegan) + let secondBegan = try #require(records[1].event as? SpanBegan) + let superseded = try #require(records[2].event as? SpanEnded) + let ended = try #require(records[3].event as? SpanEnded) + + #expect(superseded.spanID == firstBegan.spanID) + #expect(superseded.exit == .superseded) + #expect(superseded.level == .warning) + #expect(ended.spanID == secondBegan.spanID) + #expect(ended.exit == .success) + #expect(secondBegan.spanID != firstBegan.spanID) + } + + @Test func supersededSpansKeepTheirBeginContext() throws { + let key = LogTagKey("payment-id") + let original = Log(recorder: recorder).tagged(key, "pay_1") + original.begin(for: "flow", lifetime: .indefinite) + + // Re-begin from an untagged logger on the same scope: the + // superseded end still carries the original begin's tags. + let rebeginner = Log(recorder: recorder) + rebeginner.begin(for: "flow", lifetime: .indefinite) + + let superseded = try #require(recorder.records.first { record in + (record.event as? SpanEnded)?.exit == .superseded + }) + #expect(superseded.tags == [LogTag(key: key, value: "pay_1")]) + #expect(superseded.scopes == original.scopes.map(\.id)) + } + + @Test func recordsExposeTheirSpanID() throws { + let log = Log(recorder: recorder) + log.measure("save") {} + log.info("not a span") + + let records = recorder.records + let began = try #require(records[0].event as? SpanBegan) + #expect(records[0].spanID == began.spanID) + #expect(records[1].spanID == began.spanID) + #expect(records[2].spanID == nil) + } + + @Test func spanEventsRoundTripThroughCodable() throws { + let span = SpanID() + let ended = SpanEnded( + spanID: span, + name: "save", + duration: .milliseconds(12), + exit: .failure("card declined"), + ) + let data = try JSONEncoder().encode(ended) + let decoded = try JSONDecoder().decode(SpanEnded.self, from: data) + #expect(decoded.spanID == span) + #expect(decoded.duration == .milliseconds(12)) + #expect(decoded.exit == .failure("card declined")) + + let began = SpanBegan( + spanID: span, + name: "save", + lifetime: .bounded(budget: .seconds(30)), + relaunchPolicy: .survivesRelaunch, + ) + let beganData = try JSONEncoder().encode(began) + let decodedBegan = try JSONDecoder().decode(SpanBegan.self, from: beganData) + #expect(decodedBegan.lifetime == .bounded(budget: .seconds(30))) + #expect(decodedBegan.relaunchPolicy == .survivesRelaunch) + } + + @Test func endedMessagesDescribeTheExit() { + let span = SpanID() + let failed = SpanEnded( + spanID: span, + name: "save", + duration: .seconds(2), + exit: .failure("card declined"), + ) + #expect(failed.message.contains("save failed: card declined")) + #expect(failed.message.contains("(")) + + let orphaned = SpanEnded(spanID: span, name: "save", duration: nil, exit: .orphaned) + #expect(orphaned.message == "◀ save orphaned") + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift new file mode 100644 index 00000000..b4cbb96c --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift @@ -0,0 +1,70 @@ +import Foundation +import PeriscopeCore +import Testing + +struct LogTagTests { + @Test func keyDescribesItsRawValue() { + #expect(LogTagKey("payment-id").description == "payment-id") + } + + @Test func keysWithTheSameRawValueAreEqual() { + #expect(LogTagKey("payment-id") == LogTagKey("payment-id")) + #expect(LogTagKey("payment-id") != LogTagKey("order-id")) + } + + @Test func tagRoundTripsThroughCodable() throws { + let tag = LogTag(key: LogTagKey("payment-id"), value: "pay_123") + let data = try JSONEncoder().encode(tag) + let decoded = try JSONDecoder().decode(LogTag.self, from: data) + #expect(decoded == tag) + } + + @Test(arguments: [ + LogTagValue.string("pay_123"), + .int(3), + .double(0.25), + .bool(false), + .encoded(json: #"{"a":1}"#), + ]) + func typedValuesRoundTripThroughCodable(value: LogTagValue) throws { + let tag = LogTag(key: LogTagKey("k"), value: value) + let decoded = try JSONDecoder().decode(LogTag.self, from: JSONEncoder().encode(tag)) + #expect(decoded == tag) + } + + @Test func literalsConvertToTypedValues() { + let string: LogTagValue = "pay_1" + let int: LogTagValue = 3 + let double: LogTagValue = 0.5 + let bool: LogTagValue = true + #expect(string == .string("pay_1")) + #expect(int == .int(3)) + #expect(double == .double(0.5)) + #expect(bool == .bool(true)) + } + + @Test func pairsDistinguishValueKinds() { + // .int(3) and .string("3") share a canonical string; the pair's + // kind segment keeps them distinct for storage and queries. + let int = LogTag(key: LogTagKey("k"), value: .int(3)) + let string = LogTag(key: LogTagKey("k"), value: .string("3")) + #expect(int.value.stringValue == string.value.stringValue) + #expect(int.pair != string.pair) + } + + @Test func encodingProducesCanonicalKeySortedJSON() throws { + struct Payload: Codable { + var beta: Int + var alpha: Int + } + let value = try LogTagValue.encoding(Payload(beta: 2, alpha: 1)) + #expect(value == .encoded(json: #"{"alpha":1,"beta":2}"#)) + } + + @Test func tagListsLookUpByKey() { + let key = LogTagKey("payment-id") + let tags: [LogTag] = [LogTag(key: key, value: "pay_1")] + #expect(tags[key] == .string("pay_1")) + #expect(tags[LogTagKey("missing")] == nil) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift new file mode 100644 index 00000000..66eb5aa9 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -0,0 +1,237 @@ +import Foundation +import PeriscopeCore +import Testing + +struct LogTests { + let recorder = RecordingRecorder() + + @Test func rootScopeIsNamedAfterTheEventType() { + let root = Log(recorder: recorder) + #expect(root.primaryScope.name == "AppLogs") + #expect(root.primaryScope.parentID == nil) + #expect(recorder.definedScopes.contains(root.primaryScope)) + } + + @Test func derivingAnEventTypeCreatesATypedChildScope() { + let root = Log(recorder: recorder) + let photos = root(PhotoLogs.self) + #expect(photos.primaryScope.name == "PhotoLogs") + #expect(photos.primaryScope.parentID == root.primaryScope.id) + #expect(recorder.definedScopes.contains(photos.primaryScope)) + } + + @Test func derivingForAnIdentifierCreatesAKeyedChildScope() { + let root = Log(recorder: recorder) + let photos = root(PhotoLogs.self) + let album = photos(for: "album-1") + #expect(album.primaryScope.name == "album-1") + #expect(album.primaryScope.parentID == photos.primaryScope.id) + } + + @Test func samePathDerivedTwiceIsTheSameScope() { + let a = Log(recorder: recorder)(PhotoLogs.self)(for: "album-1") + let b = Log(recorder: recorder)(PhotoLogs.self)(for: "album-1") + #expect(a.primaryScope == b.primaryScope) + } + + @Test func emittingRecordsTheEventWithAllScopes() throws { + let root = Log(recorder: recorder) + let photos = root(PhotoLogs.self) + + photos { PhotoLogs(photoID: "p1") } + + let record = try #require(recorder.records.first) + #expect(record.scopes == photos.scopes.map(\.id)) + #expect(record.message == "photo p1") + #expect(record.level == .notice) + } + + @Test func linkingMergesScopesKeepingLeftPrimary() { + let model = Log(recorder: recorder)(for: "photo-9") + let ui = Log(recorder: recorder)(for: "detail-screen") + + let joined = model + ui + + #expect(joined.primaryScope == model.primaryScope) + #expect(joined.scopes == model.scopes + ui.scopes) + + joined { PhotoLogs(photoID: "p9") } + #expect(recorder.records.last?.scopes == (model.scopes + ui.scopes).map(\.id)) + } + + @Test func linkingCollapsesDuplicateScopes() { + let model = Log(recorder: recorder) + let joined = model + model + #expect(joined.scopes == model.scopes) + } + + @Test func linkedFormMatchesTheOperator() { + let model = Log(recorder: recorder) + let ui = Log(recorder: recorder) + #expect(model.linked(with: ui).scopes == (model + ui).scopes) + } + + @Test func derivingFromALinkedLogKeepsLinkedScopes() { + let model = Log(recorder: recorder) + let ui = Log(recorder: recorder) + let child = (model + ui)(for: "photo-9") + #expect(child.primaryScope.parentID == model.primaryScope.id) + #expect(child.scopes.contains(ui.primaryScope)) + } + + @Test func typedDeriveAndEmitWorksAsOneExpression() throws { + let root = Log(recorder: recorder) + + root(PhotoLogs.self) { PhotoLogs(photoID: "p1") } + + let record = try #require(recorder.records.first) + #expect(record.message == "photo p1") + #expect(record.scopes == root(PhotoLogs.self).scopes.map(\.id)) + } + + @Test func keyedDeriveAndEmitWorksAsOneExpression() throws { + let photos = Log(recorder: recorder)(PhotoLogs.self) + + photos(for: "album-1") { PhotoLogs(photoID: "p2") } + + let record = try #require(recorder.records.first) + #expect(record.message == "photo p2") + #expect(record.scopes == photos(for: "album-1").scopes.map(\.id)) + } + + @Test func retypingKeepsTheContextWithoutDerivingAChild() { + let photos = Log(recorder: recorder)(PhotoLogs.self) + .tagged(LogTagKey("payment-id"), "pay_1") + + let retyped = photos.retyped(to: Message.self) + + #expect(retyped.scopes == photos.scopes) + #expect(retyped.tags == photos.tags) + + retyped.info("still in photos") + #expect(recorder.records.last?.scopes == photos.scopes.map(\.id)) + } + + @Test func attachmentsRideAlongWithEvents() { + let log = Log(recorder: recorder) + let attachment = LogAttachment( + name: "thumbnail", + contentType: .png, + data: Data([9]), + ) + + log(attachments: [attachment]) { PhotoLogs(photoID: "p1") } + log.error("boom", attachments: [attachment]) + log.info("bare") + + let records = recorder.records + #expect(records[0].attachments == [attachment]) + #expect(records[1].attachments == [attachment]) + #expect(records[2].attachments.isEmpty) + } + + @Test func taggedContextsStampEveryEvent() { + let root = Log(recorder: recorder) + let tagged = root.tagged(LogTagKey("payment-id"), "pay_123") + + tagged.info("charged") + tagged { AppLogs() } + + #expect(recorder.records.count == 2) + #expect(recorder.records.allSatisfy { $0.tags == [LogTag( + key: LogTagKey("payment-id"), + value: "pay_123", + )] }) + + root.info("untagged") + #expect(recorder.records.last?.tags.isEmpty == true) + } + + @Test func tagsFlowDownDerivations() { + let root = Log(recorder: recorder).tagged(LogTagKey("payment-id"), "pay_123") + let child = root(PhotoLogs.self)(for: "album-1") + + child.info("deep") + #expect(recorder.records.last?.tags == [LogTag( + key: LogTagKey("payment-id"), + value: "pay_123", + )]) + } + + @Test func laterTagsOverrideEarlierValuesForTheSameKey() { + let key = LogTagKey("payment-id") + let log = Log(recorder: recorder).tagged(key, "old").tagged(key, "new") + #expect(log.tags == [LogTag(key: key, value: "new")]) + } + + @Test func emitsCaptureTheirCallSite() { + let log = Log(recorder: recorder) + log.info("freeform") + log { AppLogs() } + + for record in recorder.records { + #expect(record.callSite?.function == "emitsCaptureTheirCallSite()") + #expect(record.callSite?.fileID.hasSuffix("LogTests.swift") == true) + } + #expect(recorder.records.count == 2) + } + + @Test func taggedAcceptsTypedValues() { + let log = Log(recorder: recorder) + .tagged(LogTagKey("payment-id"), "pay_1") + .tagged(LogTagKey("retry"), 3) + .tagged(LogTagKey("ratio"), 0.5) + .tagged(LogTagKey("cached"), true) + + #expect(log.tags[LogTagKey("payment-id")] == .string("pay_1")) + #expect(log.tags[LogTagKey("retry")] == .int(3)) + #expect(log.tags[LogTagKey("ratio")] == .double(0.5)) + #expect(log.tags[LogTagKey("cached")] == .bool(true)) + } + + @Test func linkingMergesTagsWithTheLeftSideWinning() { + let key = LogTagKey("payment-id") + let model = Log(recorder: recorder) + .tagged(key, "model-side") + .tagged(LogTagKey("model-only"), "m") + let ui = Log(recorder: recorder) + .tagged(key, "ui-side") + .tagged(LogTagKey("ui-only"), "u") + + let joined = model + ui + + // Arrays keep insertion order: the model's tags first (left side + // is primary), then the ui's non-conflicting keys. + #expect(joined.tags == [ + LogTag(key: key, value: "model-side"), + LogTag(key: LogTagKey("model-only"), value: "m"), + LogTag(key: LogTagKey("ui-only"), value: "u"), + ]) + } + + @Test func freeformConveniencesEmitMessageEventsAtEachLevel() { + let log = Log(recorder: recorder) + + log.debug("d") + log.info("i") + log.notice("n") + log.warning("w") + log.error("e") + log.fault("f") + log.log(LogLevel(name: "audit", severity: 450), "a") + + let records = recorder.records + #expect(records.count == 7) + #expect(records.allSatisfy { $0.eventName == Message.eventName }) + #expect(records.map(\.message) == ["d", "i", "n", "w", "e", "f", "a"]) + #expect(records.map(\.level) == [ + .debug, + .info, + .notice, + .warning, + .error, + .fault, + LogLevel(name: "audit", severity: 450), + ]) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LowPowerModeAmbientSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LowPowerModeAmbientSourceTests.swift new file mode 100644 index 00000000..1adcb354 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LowPowerModeAmbientSourceTests.swift @@ -0,0 +1,17 @@ +import Foundation +import PeriscopeCore +import Testing + +struct LowPowerModeAmbientSourceTests { + @Test func powerStateChangesLogTheCurrentMode() async { + let sink = CapturingSink() + let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + system.startAmbientSource(LowPowerModeAmbientSource()) + + NotificationCenter.default.post(name: .NSProcessInfoPowerStateDidChange, object: nil) + await system.flush() + + let expected = ProcessInfo.processInfo.isLowPowerModeEnabled ? "low-power" : "normal" + #expect(sink.records.contains { $0.message == "power-mode: \(expected)" }) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/MemoryWarningAmbientSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/MemoryWarningAmbientSourceTests.swift new file mode 100644 index 00000000..da6a2e24 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/MemoryWarningAmbientSourceTests.swift @@ -0,0 +1,21 @@ +import Foundation +import PeriscopeCore +import Testing +import UIKit + +struct MemoryWarningAmbientSourceTests { + @Test func memoryWarningsLogAtWarningLevel() async { + let sink = CapturingSink() + let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + system.startAmbientSource(MemoryWarningAmbientSource()) + + NotificationCenter.default.post( + name: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + ) + await system.flush() + + let record = sink.records.first { $0.message == "memory: warning" } + #expect(record?.level == .warning) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/MessageTests.swift b/Shared/Periscope/PeriscopeCore/Tests/MessageTests.swift new file mode 100644 index 00000000..2330dbb9 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/MessageTests.swift @@ -0,0 +1,22 @@ +import Foundation +import PeriscopeCore +import Testing + +struct MessageTests { + @Test func rendersItsStoredText() { + let message = Message(level: .warning, "Falling back to cache") + #expect(message.message == "Falling back to cache") + #expect(message.level == .warning) + } + + @Test func usesAStableEventName() { + #expect(Message.eventName == "message") + } + + @Test func roundTripsThroughCodable() throws { + let message = Message(level: .error, "Request failed") + let data = try JSONEncoder().encode(message) + let decoded = try JSONDecoder().decode(Message.self, from: data) + #expect(decoded == message) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift new file mode 100644 index 00000000..0209aec4 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift @@ -0,0 +1,33 @@ +import Foundation +import PeriscopeCore +import Testing + +struct NetworkPathAmbientSourceTests { + @Test func reportsTheInitialPathOnStart() async { + let sink = CapturingSink() + let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + system.startAmbientSource(NetworkPathAmbientSource()) + + // NWPathMonitor always delivers the current path shortly after + // starting; wait for that first update rather than a fixed delay. + let delivered = await waitUntil { + sink.records.contains { $0.message.hasPrefix("network: ") } + } + #expect(delivered) + } + + @Test func restartingCancelsThePriorMonitorAndKeepsReporting() async { + let sink = CapturingSink() + let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + let source = NetworkPathAmbientSource() + let log = Log(recorder: system) + + source.start(log: log) + source.start(log: log) // replaces (and cancels) the first monitor + + let delivered = await waitUntil { + sink.records.contains { $0.message.hasPrefix("network: ") } + } + #expect(delivered) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift new file mode 100644 index 00000000..3ee862ad --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift @@ -0,0 +1,70 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import Testing + +struct OSLogSinkTests { + let sink = OSLogSink(subsystem: "com.stuff.periscope.tests") + + private func record(primary: LogScope, message: String = "hello") -> LogRecord { + LogRecord(date: Date(), event: Message(level: .info, message), scopes: [primary.id]) + } + + @Test func categoryIsTheRootScopeName() async { + let root = LogScope.root(named: "app") + let photos = root.child(named: "photos") + let album = photos.child(named: "album-1") + await sink.defineScopes([root, photos, album]) + + #expect(sink.categoryName(for: record(primary: album)) == "app") + #expect(sink.categoryName(for: record(primary: root)) == "app") + } + + @Test func messagePrefixesThePathBelowTheRoot() async { + let root = LogScope.root(named: "app") + let photos = root.child(named: "photos") + let album = photos.child(named: "album-1") + await sink.defineScopes([root, photos, album]) + + let deep = sink.formattedMessage(for: record(primary: album)) + #expect(deep == "[photos/album-1] hello") + + let atRoot = sink.formattedMessage(for: record(primary: root)) + #expect(atRoot == "hello") + } + + @Test func tagsAppendToTheMessageSortedByKey() async { + let root = LogScope.root(named: "app") + await sink.defineScopes([root]) + let record = LogRecord( + date: Date(), + event: Message(level: .info, "hello"), + scopes: [root.id], + tags: [ + LogTag(key: LogTagKey("b-key"), value: "2"), + LogTag(key: LogTagKey("a-key"), value: "1"), + ], + ) + + #expect(sink.formattedMessage(for: record) == "hello {a-key=1, b-key=2}") + } + + @Test func unknownScopesFallBackToAPlainRendering() { + let unknown = LogScope.root(named: "never-defined") + #expect(sink.categoryName(for: record(primary: unknown)) == "periscope") + #expect(sink.formattedMessage(for: record(primary: unknown)) == "hello") + } + + @Test func writingRecordsIsSafe() async { + let root = LogScope.root(named: "app") + await sink.defineScopes([root]) + await sink.write([ + record(primary: root, message: "smoke"), + LogRecord( + date: Date(), + event: Message(level: .fault, "fault smoke"), + scopes: [root.id], + ), + ]) + await sink.flush() + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift new file mode 100644 index 00000000..35615cc5 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -0,0 +1,227 @@ +import Foundation +import os +import PeriscopeCore + +/// An in-memory `LogRecorder` that captures everything for assertions. +final class RecordingRecorder: LogRecorder, Sendable { + private struct State { + var scopes: [LogScope] = [] + var records: [LogRecord] = [] + var openSpans: [SpanKey: OpenSpan] = [:] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var definedScopes: [LogScope] { + state.withLock(\.scopes) + } + + var records: [LogRecord] { + state.withLock(\.records) + } + + func defineScope(_ scope: LogScope) { + state.withLock { $0.scopes.append(scope) } + } + + func record(_ record: LogRecord) { + state.withLock { $0.records.append(record) } + } + + func shouldRecord(level _: LogLevel, scopes _: [ScopeID]) -> Bool { + true + } + + func beginSpan(key: SpanKey, span: OpenSpan, began: LogRecord?) -> OpenSpan? { + state.withLock { state in + let prior = state.openSpans.removeValue(forKey: key) + state.openSpans[key] = span + if let began { + state.records.append(began) + } + return prior + } + } + + func closeSpan(key: SpanKey) -> OpenSpan? { + state.withLock { $0.openSpans.removeValue(forKey: key) } + } +} + +/// A deterministic SplitMix64 generator so fuzz tests replay failures +/// exactly from their seed. +struct SeededRandom: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + state = seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } +} + +/// Polls `predicate` until it holds or `timeout` elapses, returning whether +/// it ever held — condition-based waiting, never a fixed sleep. +func waitUntil( + timeout: Duration = .seconds(5), + _ predicate: @Sendable () -> Bool, +) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if predicate() { return true } + try? await Task.sleep(for: .milliseconds(2)) + } + return predicate() +} + +/// A `LogSink` whose `write` blocks until `open()` — used to hold the drain +/// task mid-delivery so tests can deterministically overflow the pending +/// queue. +final class GateSink: LogSink, Sendable { + private struct State { + var isOpen = false + var waiters: [CheckedContinuation] = [] + var batchCount = 0 + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + /// Batches received so far (counted before blocking). + var batchCount: Int { + state.withLock(\.batchCount) + } + + /// Release every blocked `write` and let future writes pass through. + func open() { + let waiters = state.withLock { state in + state.isOpen = true + let waiters = state.waiters + state.waiters = [] + return waiters + } + for waiter in waiters { + waiter.resume() + } + } + + func defineScopes(_: [LogScope]) async {} + + func write(_: [LogRecord]) async { + state.withLock { $0.batchCount += 1 } + await withCheckedContinuation { continuation in + let resumeNow = state.withLock { state in + if state.isOpen { + return true + } + state.waiters.append(continuation) + return false + } + if resumeNow { + continuation.resume() + } + } + } + + func flush() async {} +} + +/// An in-memory `LogSink` that captures deliveries in order for assertions. +final class CapturingSink: LogSink, Sendable { + enum Delivery { + case scopes([LogScope]) + case records([LogRecord]) + } + + private struct State { + var deliveries: [Delivery] = [] + var flushCount = 0 + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var deliveries: [Delivery] { + state.withLock(\.deliveries) + } + + var flushCount: Int { + state.withLock(\.flushCount) + } + + var definedScopes: [LogScope] { + deliveries.flatMap { delivery -> [LogScope] in + guard case let .scopes(scopes) = delivery else { return [] } + return scopes + } + } + + var records: [LogRecord] { + deliveries.flatMap { delivery -> [LogRecord] in + guard case let .records(records) = delivery else { return [] } + return records + } + } + + func defineScopes(_ scopes: [LogScope]) async { + state.withLock { $0.deliveries.append(.scopes(scopes)) } + } + + func write(_ records: [LogRecord]) async { + state.withLock { $0.deliveries.append(.records(records)) } + } + + func flush() async { + state.withLock { $0.flushCount += 1 } + } +} + +extension LogSession { + /// A deterministic session for store tests. + static func fixture( + id: UUID = UUID(), + startedAt: Date = Date(timeIntervalSinceReferenceDate: 0), + ) -> LogSession { + LogSession( + id: id, + startedAt: startedAt, + appVersion: "1.0", + buildNumber: "42", + osVersion: "TestOS 1.0", + deviceModel: "TestDevice1,1", + ) + } +} + +/// A freeform record with an explicit date, for deterministic store tests. +func makeRecord( + _ text: String, + level: LogLevel = .info, + date: Date, + scopes: [ScopeID], +) -> LogRecord { + LogRecord(date: date, event: Message(level: level, text), scopes: scopes) +} + +/// Shared fixture events used across suites. +struct AppLogs: LogEvent { + var message: String { + "app" + } +} + +struct PhotoLogs: LogEvent { + var photoID: String + var level: LogLevel { + .notice + } + + var message: String { + "photo \(photoID)" + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift new file mode 100644 index 00000000..614e4237 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -0,0 +1,802 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import Testing + +private struct InjectedSaveFailure: Error {} + +struct PeriscopeStoreTests { + private func date(_ offset: TimeInterval) -> Date { + Date(timeIntervalSinceReferenceDate: offset) + } + + /// A store with a small defined hierarchy: app → photos → album-1. + private func makeStore() async throws -> ( + store: PeriscopeStore, + root: LogScope, + photos: LogScope, + album: LogScope + ) { + let store = try await PeriscopeStore.inMemory(session: .fixture()) + let root = LogScope.root(named: "app") + let photos = root.child(named: "photos") + let album = photos.child(named: "album-1") + await store.defineScopes([root, photos, album]) + return (store, root, photos, album) + } + + @Test func eventsComeBackNewestFirst() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([ + makeRecord("first", date: date(1), scopes: [root.id]), + makeRecord("second", date: date(2), scopes: [root.id]), + makeRecord("third", date: date(3), scopes: [root.id]), + ]) + + let events = try await store.events(matching: LogQuery()) + #expect(events.map(\.message) == ["third", "second", "first"]) + } + + @Test func payloadsDecodeBackToTheirEventTypes() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([ + LogRecord(date: date(1), event: PhotoLogs(photoID: "p1"), scopes: [root.id]), + ]) + + let event = try #require(try await store.events(matching: LogQuery()).first) + #expect(event.eventName == "PhotoLogs") + #expect(event.eventVersion == 1) + #expect(try event.decode(PhotoLogs.self).photoID == "p1") + } + + @Test func minimumLevelFiltersBySeverity() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([ + makeRecord("d", level: .debug, date: date(1), scopes: [root.id]), + makeRecord("w", level: .warning, date: date(2), scopes: [root.id]), + makeRecord("e", level: .error, date: date(3), scopes: [root.id]), + ]) + + var query = LogQuery() + query.minimumLevel = .warning + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["e", "w"]) + } + + @Test func customLevelsRoundTripThroughStorage() async throws { + let (store, root, _, _) = try await makeStore() + let audit = LogLevel(name: "audit", severity: 450) + await store.write([makeRecord("a", level: audit, date: date(1), scopes: [root.id])]) + + let event = try #require(try await store.events(matching: LogQuery()).first) + #expect(event.level == audit) + } + + @Test func timeRangeFilters() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([ + makeRecord("early", date: date(10), scopes: [root.id]), + makeRecord("mid", date: date(20), scopes: [root.id]), + makeRecord("late", date: date(30), scopes: [root.id]), + ]) + + var query = LogQuery() + query.start = date(15) + query.end = date(25) + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["mid"]) + } + + @Test func eventNameFilters() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([ + makeRecord("plain", date: date(1), scopes: [root.id]), + LogRecord(date: date(2), event: PhotoLogs(photoID: "p1"), scopes: [root.id]), + ]) + + var query = LogQuery() + query.eventName = PhotoLogs.eventName + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["photo p1"]) + } + + @Test func sessionFilters() async throws { + let (store, root, _, _) = try await makeStore() + let firstSession = LogSession.fixture() + try await store.startSession(firstSession) + await store.write([makeRecord("in first", date: date(1), scopes: [root.id])]) + + let secondSession = LogSession.fixture(startedAt: date(100)) + try await store.startSession(secondSession) + await store.write([makeRecord("in second", date: date(101), scopes: [root.id])]) + + var query = LogQuery() + query.sessionID = firstSession.id + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["in first"]) + } + + @Test func messageSearchFilters() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([ + makeRecord("Uploading photo", date: date(1), scopes: [root.id]), + makeRecord("Deleting album", date: date(2), scopes: [root.id]), + ]) + + var query = LogQuery() + query.messageContains = "photo" + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["Uploading photo"]) + } + + @Test func exactScopeFilters() async throws { + let (store, root, photos, album) = try await makeStore() + await store.write([ + makeRecord("at root", date: date(1), scopes: [root.id]), + makeRecord("at photos", date: date(2), scopes: [photos.id]), + makeRecord("at album", date: date(3), scopes: [album.id]), + ]) + + var query = LogQuery() + query.scope = .exactly(photos.id) + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["at photos"]) + } + + @Test func subtreeFiltersIncludeDescendants() async throws { + let (store, root, photos, album) = try await makeStore() + await store.write([ + makeRecord("at root", date: date(1), scopes: [root.id]), + makeRecord("at photos", date: date(2), scopes: [photos.id]), + makeRecord("at album", date: date(3), scopes: [album.id]), + ]) + + var query = LogQuery() + query.scope = .subtree(photos.id) + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["at album", "at photos"]) + } + + @Test func linkedEventsMatchEitherScope() async throws { + let (store, root, photos, _) = try await makeStore() + await store.write([ + makeRecord("linked", date: date(1), scopes: [photos.id, root.id]), + ]) + + var byRoot = LogQuery() + byRoot.scope = .exactly(root.id) + var byPhotos = LogQuery() + byPhotos.scope = .exactly(photos.id) + + #expect(try await store.events(matching: byRoot).count == 1) + #expect(try await store.events(matching: byPhotos).count == 1) + + let stored = try #require(try await store.events(matching: LogQuery()).first) + #expect(stored.scopes == [photos.id, root.id]) + } + + @Test func attachmentsPersistAndLoadOnDemand() async throws { + let (store, root, _, _) = try await makeStore() + let first = LogAttachment(name: "a", contentType: .plainText, data: Data([1])) + let second = LogAttachment(name: "b", contentType: .png, data: Data([2, 3])) + let record = LogRecord( + date: date(1), + event: Message(level: .error, "failed"), + scopes: [root.id], + attachments: [first, second], + ) + await store.write([record, makeRecord("bare", date: date(2), scopes: [root.id])]) + + let stored = try #require(try await store.events(matching: LogQuery()) + .first { $0.message == "failed" }) + #expect(stored.attachments == [ + LogAttachmentInfo(name: "a", contentType: .plainText), + LogAttachmentInfo(name: "b", contentType: .png), + ]) + + let loaded = try await store.attachments(forEvent: record.id) + #expect(loaded == [first, second]) + + let bare = try #require(try await store.events(matching: LogQuery()) + .first { $0.message == "bare" }) + #expect(bare.attachments.isEmpty) + #expect(try await store.attachments(forEvent: UUID()).isEmpty) + } + + @Test func spanEventsResolveBySpanID() async throws { + let (store, root, _, _) = try await makeStore() + let span = SpanID() + await store.write([ + LogRecord( + date: date(1), + event: SpanBegan( + spanID: span, + name: "save", + lifetime: .indefinite, + relaunchPolicy: .endsWithProcess, + ), + scopes: [root.id], + ), + makeRecord("unrelated", date: date(2), scopes: [root.id]), + LogRecord( + date: date(3), + event: SpanEnded( + spanID: span, + name: "save", + duration: .seconds(2), + exit: .success, + ), + scopes: [root.id], + ), + ]) + + let pair = try await store.events(inSpan: span) + #expect(pair.count == 2) + #expect(pair.allSatisfy { $0.spanID == span }) + #expect(try pair.first.map { try $0.decode(SpanEnded.self).duration } == .seconds(2)) + + let unrelated = try await store.events(matching: LogQuery()) + .first { $0.message == "unrelated" } + #expect(unrelated?.spanID == nil) + } + + @Test func spanExitModesPersistAndFilter() async throws { + let (store, root, _, _) = try await makeStore() + let failedSpan = SpanID() + await store.write([ + LogRecord( + date: date(1), + event: SpanEnded( + spanID: failedSpan, + name: "save", + duration: .seconds(1), + exit: .failure("card declined"), + ), + scopes: [root.id], + ), + LogRecord( + date: date(2), + event: SpanEnded( + spanID: SpanID(), + name: "sync", + duration: .seconds(1), + exit: .success, + ), + scopes: [root.id], + ), + makeRecord("not a span", date: date(3), scopes: [root.id]), + ]) + + var query = LogQuery() + query.spanExitMode = .failure + let failures = try await store.events(matching: query) + #expect(failures.map(\.spanID) == [failedSpan]) + #expect(failures.first?.spanExitMode == .failure) + + let all = try await store.events(matching: LogQuery()) + #expect(all.first { $0.message == "not a span" }?.spanExitMode == nil) + } + + // MARK: Orphaned spans + + private func writeSpanBegan( + _ store: PeriscopeStore, + span: SpanID, + name: String, + relaunch: SpanRelaunchPolicy, + scope: LogScope, + tags: [LogTag] = [], + ) async { + await store.write([ + LogRecord( + date: date(1), + event: SpanBegan( + spanID: span, + name: name, + lifetime: .indefinite, + relaunchPolicy: relaunch, + ), + scopes: [scope.id], + tags: tags, + ), + ]) + } + + @Test func relaunchClosesOrphanedSpans() async throws { + let (store, root, _, _) = try await makeStore() + let span = SpanID() + let key = LogTagKey("payment-id") + await writeSpanBegan( + store, + span: span, + name: "checkout", + relaunch: .endsWithProcess, + scope: root, + tags: [LogTag(key: key, value: "pay_1")], + ) + + // A new session declares the earlier one dead. + try await store.startSession(.fixture(startedAt: date(100))) + + let pair = try await store.events(inSpan: span) + #expect(pair.count == 2) + let orphanRow = try #require(pair.first { $0.eventName == SpanEnded.eventName }) + let orphan = try orphanRow.decode(SpanEnded.self) + #expect(orphan.exit == .orphaned) + #expect(orphan.duration == nil) + #expect(orphan.name == "checkout") + #expect(orphanRow.scopes == [root.id]) + #expect(orphanRow.tags == [LogTag(key: key, value: "pay_1")]) + #expect(orphanRow.level == .warning) + } + + @Test func relaunchLeavesSurvivingSpansOpen() async throws { + let (store, root, _, _) = try await makeStore() + let span = SpanID() + await writeSpanBegan( + store, + span: span, + name: "long-download", + relaunch: .survivesRelaunch, + scope: root, + ) + + try await store.startSession(.fixture(startedAt: date(100))) + + let events = try await store.events(inSpan: span) + #expect(events.count == 1) + #expect(events.first?.eventName == SpanBegan.eventName) + } + + @Test func relaunchIgnoresProperlyEndedSpans() async throws { + let (store, root, _, _) = try await makeStore() + let span = SpanID() + await writeSpanBegan( + store, + span: span, + name: "save", + relaunch: .endsWithProcess, + scope: root, + ) + await store.write([ + LogRecord( + date: date(2), + event: SpanEnded( + spanID: span, + name: "save", + duration: .seconds(1), + exit: .success, + ), + scopes: [root.id], + ), + ]) + + try await store.startSession(.fixture(startedAt: date(100))) + + let events = try await store.events(inSpan: span) + #expect(events.count == 2) + #expect(events.compactMap { try? $0.decode(SpanEnded.self) }.count == 1) + } + + @Test func tagsPersistAndFilter() async throws { + let (store, root, _, _) = try await makeStore() + let key = LogTagKey("payment-id") + await store.write([ + LogRecord( + date: date(1), + event: Message(level: .info, "for pay_1"), + scopes: [root.id], + tags: [LogTag(key: key, value: "pay_1")], + ), + LogRecord( + date: date(2), + event: Message(level: .info, "for pay_2"), + scopes: [root.id], + tags: [LogTag(key: key, value: "pay_2")], + ), + makeRecord("untagged", date: date(3), scopes: [root.id]), + ]) + + var query = LogQuery() + query.tags = [LogTag(key: key, value: "pay_1")] + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["for pay_1"]) + #expect(events.first?.tags == [LogTag(key: key, value: "pay_1")]) + + let all = try await store.events(matching: LogQuery()) + #expect(all.first { $0.message == "untagged" }?.tags.isEmpty == true) + } + + @Test func externalIDsPersistAndFilter() async throws { + struct PhotoUploaded: LogEvent { + var photoURI: String + var message: String { + "uploaded" + } + + var externalID: String? { + photoURI + } + } + + let (store, root, _, _) = try await makeStore() + await store.write([ + LogRecord( + date: date(1), + event: PhotoUploaded(photoURI: "photos://p1"), + scopes: [root.id], + ), + LogRecord( + date: date(2), + event: PhotoUploaded(photoURI: "photos://p2"), + scopes: [root.id], + ), + makeRecord("no object", date: date(3), scopes: [root.id]), + ]) + + var query = LogQuery() + query.externalID = "photos://p1" + let events = try await store.events(matching: query) + #expect(events.count == 1) + #expect(events.first?.externalID == "photos://p1") + + let all = try await store.events(matching: LogQuery()) + #expect(all.first { $0.message == "no object" }?.externalID == nil) + } + + @Test func callSitesPersistAndReadBack() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([ + LogRecord( + date: date(1), + event: Message(level: .info, "located"), + scopes: [root.id], + callSite: LogCallSite(function: "uploadPhoto(_:)", fileID: "App/Uploader.swift"), + ), + makeRecord("system-synthesized", date: date(2), scopes: [root.id]), + ]) + + let events = try await store.events(matching: LogQuery()) + let located = try #require(events.first { $0.message == "located" }) + #expect(located.callSite?.function == "uploadPhoto(_:)") + #expect(located.callSite?.fileID == "App/Uploader.swift") + #expect(events.first { $0.message == "system-synthesized" }?.callSite == nil) + } + + @Test func multipleQueryTagsCombineWithAND() async throws { + let (store, root, _, _) = try await makeStore() + let payment = LogTagKey("payment-id") + let retry = LogTagKey("retry") + await store.write([ + LogRecord( + date: date(1), + event: Message(level: .info, "both"), + scopes: [root.id], + tags: [ + LogTag(key: payment, value: "pay_1"), + LogTag(key: retry, value: .int(2)), + ], + ), + LogRecord( + date: date(2), + event: Message(level: .info, "payment only"), + scopes: [root.id], + tags: [LogTag(key: payment, value: "pay_1")], + ), + LogRecord( + date: date(3), + event: Message(level: .info, "retry only"), + scopes: [root.id], + tags: [LogTag(key: retry, value: .int(2))], + ), + ]) + + var query = LogQuery() + query.tags = [ + LogTag(key: payment, value: "pay_1"), + LogTag(key: retry, value: .int(2)), + ] + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["both"]) + } + + @Test func typedTagValuesRoundTripAndStayDistinctFromStrings() async throws { + let (store, root, _, _) = try await makeStore() + let key = LogTagKey("retry") + await store.write([ + LogRecord( + date: date(1), + event: Message(level: .info, "typed int"), + scopes: [root.id], + tags: [LogTag(key: key, value: .int(3))], + ), + LogRecord( + date: date(2), + event: Message(level: .info, "stringly"), + scopes: [root.id], + tags: [LogTag(key: key, value: .string("3"))], + ), + ]) + + // The kind survives persistence: filtering by .int(3) must not + // match .string("3"), and the read-back value stays typed. + var query = LogQuery() + query.tags = [LogTag(key: key, value: .int(3))] + let events = try await store.events(matching: query) + #expect(events.map(\.message) == ["typed int"]) + #expect(events.first?.tags[key] == .int(3)) + } + + @Test func limitAndOffsetPageNewestFirst() async throws { + let (store, root, _, _) = try await makeStore() + await store.write((1 ... 5).map { index in + makeRecord("\(index)", date: date(TimeInterval(index)), scopes: [root.id]) + }) + + var page = LogQuery() + page.limit = 2 + #expect(try await store.events(matching: page).map(\.message) == ["5", "4"]) + + page.offset = 2 + #expect(try await store.events(matching: page).map(\.message) == ["3", "2"]) + } + + @Test func scopeDefinitionsAreIdempotent() async throws { + let (store, root, photos, album) = try await makeStore() + await store.defineScopes([root, photos, album]) + + let scopes = try await store.scopes() + #expect(scopes.count == 3) + #expect(try await store.scope(for: photos.id) == photos) + } + + @Test func lateScopeDefinitionsFillPlaceholders() async throws { + let store = try await PeriscopeStore.inMemory(session: .fixture()) + let orphan = LogScope.root(named: "late") + await store.write([makeRecord("early", date: date(1), scopes: [orphan.id])]) + + let placeholder = try #require(try await store.scope(for: orphan.id)) + #expect(placeholder.name.isEmpty) + + await store.defineScopes([orphan]) + #expect(try await store.scope(for: orphan.id) == orphan) + #expect(try await store.events(matching: LogQuery()).count == 1) + } + + @Test func pruneOlderThanRemovesOnlyOldEvents() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([ + makeRecord("old", date: date(1), scopes: [root.id]), + makeRecord("new", date: date(100), scopes: [root.id]), + ]) + + let removed = try await store.pruneEvents(olderThan: date(50)) + #expect(removed == 1) + #expect(try await store.events(matching: LogQuery()).map(\.message) == ["new"]) + } + + @Test func pruningRemovesOrphanedSessionsTagsAndScopes() async throws { + let store = try await PeriscopeStore.inMemory(session: .fixture()) + let root = LogScope.root(named: "app") + let album = root.child(named: "album-1") + let key = LogTagKey("payment-id") + await store.defineScopes([root, album]) + await store.write([ + LogRecord( + date: date(1), + event: Message(level: .info, "old"), + scopes: [album.id], + tags: [LogTag(key: key, value: "pay_old")], + ), + ]) + + // A later launch writes newer events under the root only. + let liveSession = LogSession.fixture(startedAt: date(100)) + try await store.startSession(liveSession) + await store.write([ + LogRecord( + date: date(200), + event: Message(level: .info, "new"), + scopes: [root.id], + tags: [LogTag(key: key, value: "pay_new")], + ), + ]) + + #expect(try await store.pruneEvents(olderThan: date(50)) == 1) + + // The dead launch's session, the orphaned tag pair, and the + // event-less album leaf are gone; root keeps its event, and the + // live session survives. + #expect(try await store.sessions().map(\.id) == [liveSession.id]) + #expect(try await store.scope(for: album.id) == nil) + #expect(try await store.scope(for: root.id) == root) + + // A pruned tag pair is re-creatable — the row cache dropped its + // deleted entry rather than handing back a dead row. + await store.write([ + LogRecord( + date: date(300), + event: Message(level: .info, "old pair reused"), + scopes: [root.id], + tags: [LogTag(key: key, value: "pay_old")], + ), + ]) + var query = LogQuery() + query.tags = [LogTag(key: key, value: "pay_old")] + #expect(try await store.events(matching: query).map(\.message) == ["old pair reused"]) + } + + @Test func pruneKeepingNewestKeepsTheCount() async throws { + let (store, root, _, _) = try await makeStore() + await store.write((1 ... 5).map { index in + makeRecord("\(index)", date: date(TimeInterval(index)), scopes: [root.id]) + }) + + let removed = try await store.pruneEvents(keepingNewest: 2) + #expect(removed == 3) + #expect(try await store.events(matching: LogQuery()).map(\.message) == ["5", "4"]) + } + + @Test func deleteAllEventsClearsTheStore() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([makeRecord("gone", date: date(1), scopes: [root.id])]) + + try await store.deleteAllEvents() + #expect(try await store.events(matching: LogQuery()).isEmpty) + } + + @Test func changesPingOnWritesAndDeletions() async throws { + let (store, root, _, _) = try await makeStore() + var iterator = await store.changes().makeAsyncIterator() + + await store.write([makeRecord("one", date: date(1), scopes: [root.id])]) + #expect(await iterator.next() != nil) + + try await store.deleteAllEvents() + #expect(await iterator.next() != nil) + } + + @Test func sessionsListNewestFirstWithMetadata() async throws { + let store = try await PeriscopeStore.inMemory(session: .fixture(startedAt: date(0))) + let later = LogSession.fixture(startedAt: date(100)) + try await store.startSession(later) + + let sessions = try await store.sessions() + #expect(sessions.count == 2) + #expect(sessions.first == later) + #expect(sessions.first?.appVersion == "1.0") + #expect(sessions.first?.deviceModel == "TestDevice1,1") + } + + @Test func eventByIDResolves() async throws { + let (store, root, _, _) = try await makeStore() + let record = makeRecord("target", date: date(1), scopes: [root.id]) + await store.write([record]) + + let found = try #require(try await store.event(id: record.id)) + #expect(found.message == "target") + #expect(try await store.event(id: UUID()) == nil) + } + + // MARK: Write-failure recovery + + @Test func failedWritesRollBackSoLaterWritesSucceed() async throws { + let (store, root, _, _) = try await makeStore() + + await store.injectNextWriteFailure(InjectedSaveFailure()) + await store.write([makeRecord("poisoned", date: date(1), scopes: [root.id])]) + #expect(await store.writeFailureCount == 1) + + await store.write([makeRecord("healthy", date: date(2), scopes: [root.id])]) + + // The poisoned batch is gone, but the store says so durably: a + // StoreWriteFailed marker records the gap's size and cause. + let events = try await store.events(matching: LogQuery()) + #expect(events.map(\.message).contains("healthy")) + #expect(!events.map(\.message).contains("poisoned")) + let marker = try #require(events.first { $0.eventName == StoreWriteFailed.eventName }) + let decoded = try marker.decode(StoreWriteFailed.self) + #expect(decoded.lostRecordCount == 1) + #expect(decoded.reason.contains("InjectedSaveFailure")) + #expect(await store.writeFailureCount == 1) + } + + @Test func recoveryDropsStaleRowCachesButKeepsScopesWorking() async throws { + let store = try await PeriscopeStore.inMemory(session: .fixture()) + let scope = LogScope.root(named: "late") + let key = LogTagKey("payment-id") + + // The failed batch stages a placeholder scope row and a tag row; + // both roll back with it. + await store.injectNextWriteFailure(InjectedSaveFailure()) + await store.write([ + LogRecord( + date: date(1), + event: Message(level: .info, "poisoned"), + scopes: [scope.id], + tags: [LogTag(key: key, value: "pay_1")], + ), + ]) + #expect(try await store.scope(for: scope.id) == nil) + + await store.defineScopes([scope]) + await store.write([ + LogRecord( + date: date(2), + event: Message(level: .info, "healthy"), + scopes: [scope.id], + tags: [LogTag(key: key, value: "pay_1")], + ), + ]) + + #expect(try await store.scope(for: scope.id) == scope) + var query = LogQuery() + query.tags = [LogTag(key: key, value: "pay_1")] + #expect(try await store.events(matching: query).map(\.message) == ["healthy"]) + } + + @Test func recoveryKeepsTheSessionIdentity() async throws { + let session = LogSession.fixture() + let store = try await PeriscopeStore.inMemory(session: session) + let root = LogScope.root(named: "app") + await store.defineScopes([root]) + + await store.injectNextWriteFailure(InjectedSaveFailure()) + await store.write([makeRecord("poisoned", date: date(1), scopes: [root.id])]) + await store.write([makeRecord("healthy", date: date(2), scopes: [root.id])]) + + let event = try #require(try await store.events(matching: LogQuery()).first) + #expect(event.sessionID == session.id) + #expect(try await store.sessions().count == 1) + } + + @Test func failedScopeDefinitionsRecoverOnRetry() async throws { + let store = try await PeriscopeStore.inMemory(session: .fixture()) + let scope = LogScope.root(named: "app") + + await store.injectNextWriteFailure(InjectedSaveFailure()) + await store.defineScopes([scope]) + #expect(await store.writeFailureCount == 1) + #expect(try await store.scope(for: scope.id) == nil) + + await store.defineScopes([scope]) + #expect(try await store.scope(for: scope.id) == scope) + #expect(try await store.scopes().count == 1) + } + + @Test func failedDeletionsRollBackInsteadOfLingering() async throws { + let (store, root, _, _) = try await makeStore() + await store.write([ + makeRecord("old", date: date(1), scopes: [root.id]), + makeRecord("new", date: date(100), scopes: [root.id]), + ]) + + await store.injectNextWriteFailure(InjectedSaveFailure()) + await #expect(throws: InjectedSaveFailure.self) { + try await store.pruneEvents(olderThan: date(50)) + } + #expect(try await store.events(matching: LogQuery()).count == 2) + + // The staged deletions must not ride along with the next commit. + await store.write([makeRecord("later", date: date(200), scopes: [root.id])]) + #expect(try await store.events(matching: LogQuery()).count == 3) + + let removed = try await store.pruneEvents(olderThan: date(50)) + #expect(removed == 1) + #expect(try await store.events(matching: LogQuery()).map(\.message) == ["later", "new"]) + } + + @Test func endToEndThroughThePeriscopeSystem() async throws { + let store = try await PeriscopeStore.inMemory(session: .fixture()) + let system = Periscope(configuration: Periscope.Configuration(), sinks: [store]) + let photos = Log(system: system)(PhotoLogs.self) + + photos { PhotoLogs(photoID: "p1") } + photos.warning("degraded") + await system.flush() + + let events = try await store.events(matching: LogQuery()) + #expect(events.map(\.message) == ["degraded", "photo p1"]) + #expect(events.allSatisfy { $0.scopes == photos.scopes.map(\.id) }) + #expect(try await store.scope(for: photos.primaryScope.id)?.name == "PhotoLogs") + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift new file mode 100644 index 00000000..05e011ea --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -0,0 +1,1098 @@ +import Foundation +import os +@_spi(Testing) import PeriscopeCore +import Testing + +struct PeriscopeTests { + let sink = CapturingSink() + + private func makeSystem(recentBufferCapacity: Int = 500) -> Periscope { + Periscope( + configuration: Periscope.Configuration(recentBufferCapacity: recentBufferCapacity), + sinks: [sink], + ) + } + + @Test func recordsReachSinksInEmissionOrder() async { + let system = makeSystem() + let log = Log(system: system) + + log.info("one") + log.info("two") + log.info("three") + await system.flush() + + #expect(sink.records.map(\.message) == ["one", "two", "three"]) + #expect(sink.flushCount == 1) + } + + @Test func scopeDefinitionsArriveBeforeRecords() async throws { + let system = makeSystem() + let log = Log(system: system) + + log.info("hello") + await system.flush() + + let deliveries = sink.deliveries + let scopeIndex = try #require(deliveries.firstIndex { delivery in + guard case let .scopes(scopes) = delivery else { return false } + return scopes.contains(log.primaryScope) + }) + let recordIndex = try #require(deliveries.firstIndex { delivery in + guard case let .records(records) = delivery else { return false } + return records.contains { $0.message == "hello" } + }) + #expect(scopeIndex < recordIndex) + } + + @Test func rootLoggerDefinesItsScopeInTheSystem() { + let system = makeSystem() + let log = Log(system: system) + #expect(system.scope(for: log.primaryScope.id) == log.primaryScope) + } + + @Test func lateAddedSinkGetsTheScopeRegistryReplayed() async { + let system = makeSystem() + let log = Log(system: system) + log.info("before") + await system.flush() + + let late = CapturingSink() + system.add(sink: late) + await system.flush() + + #expect(late.definedScopes.contains(log.primaryScope)) + #expect(late.records.isEmpty) + } + + @Test func replayedScopesAreIdempotentForExistingSinks() async { + let system = makeSystem() + let log = Log(system: system) + await system.flush() + + system.add(sink: CapturingSink()) + await system.flush() + + // The original sink sees the definition again; contract is + // idempotence per scope ID, not exactly-once delivery. + #expect(sink.definedScopes.count(where: { $0 == log.primaryScope }) >= 1) + } + + @Test func recentBufferKeepsOnlyTheNewestRecords() async { + let system = makeSystem(recentBufferCapacity: 3) + let log = Log(system: system) + + for index in 1 ... 5 { + log.info("\(index)") + } + await system.flush() + + #expect(system.recentRecords().map(\.message) == ["3", "4", "5"]) + #expect(sink.records.count == 5) + } + + @Test func liveRecordsYieldsEachNewRecord() async { + let system = makeSystem() + let log = Log(system: system) + + var iterator = system.liveRecords().makeAsyncIterator() + log.info("one") + log.warning("two") + + #expect(await iterator.next()?.message == "one") + #expect(await iterator.next()?.message == "two") + } + + @Test func slowLiveObserversKeepOnlyTheNewestRecords() async { + let system = Periscope( + configuration: Periscope.Configuration(liveBufferCapacity: 3), + sinks: [sink], + ) + let log = Log(system: system) + + // Nothing consumes yet — the buffer must cap at the newest three. + var iterator = system.liveRecords().makeAsyncIterator() + for index in 1 ... 5 { + log.info("\(index)") + } + + #expect(await iterator.next()?.message == "3") + #expect(await iterator.next()?.message == "4") + #expect(await iterator.next()?.message == "5") + + // Once caught up, new records flow through normally. + log.info("6") + #expect(await iterator.next()?.message == "6") + } + + @Test func beganRecordsReachLiveObserversAndTheRecentBuffer() async { + // begin(for:) buffers its began through beginSpan, bypassing + // record(_:) — the live-stream and recent-buffer side effects must + // not drift between the two paths. + let system = makeSystem() + let log = Log(system: system) + + var iterator = system.liveRecords().makeAsyncIterator() + log.begin(for: "checkout", lifetime: .indefinite) + + let live = await iterator.next() + #expect(live?.event is SpanBegan) + #expect(system.recentRecords().contains { $0.event is SpanBegan }) + log.end(for: "checkout", exit: .success) + } + + @Test func liveObserversSeeSpanLifecyclesInBufferedOrder() async { + // A re-begin closes the prior span as superseded; the live stream + // must replay the exact buffered order — began, began, superseded + // end, end — never an end ahead of its began. + let system = makeSystem() + let log = Log(system: system) + + var iterator = system.liveRecords().makeAsyncIterator() + log.begin(for: "checkout", lifetime: .indefinite) + log.begin(for: "checkout", lifetime: .indefinite) + log.end(for: "checkout", exit: .success) + + let first = await iterator.next() + let second = await iterator.next() + let third = await iterator.next() + let fourth = await iterator.next() + #expect(first?.event is SpanBegan) + #expect(second?.event is SpanBegan) + #expect((third?.spanExit)?.mode == .superseded) + #expect(third?.spanID == first?.spanID) + #expect((fourth?.spanExit)?.mode == .success) + #expect(fourth?.spanID == second?.spanID) + } + + @Test func flushIsSafeWhenNothingIsPending() async { + let system = makeSystem() + await system.flush() + #expect(sink.records.isEmpty) + #expect(sink.flushCount == 1) + } + + @Test func interleavedScopesAndRecordsDeliverGroupedInOrder() async throws { + // Hold the drain so an interleaved backlog accumulates, then verify + // the single stolen batch reaches the sink as ordered runs. + let gate = GateSink() + let system = Periscope(configuration: Periscope.Configuration(), sinks: [gate, sink]) + let root = Log(system: system) + + root.info("r0") + let drainBlocked = await waitUntil { gate.batchCount >= 1 } + try #require(drainBlocked) + + root.info("r1") + let photos = root(PhotoLogs.self) // defines a scope mid-stream + photos.info("r2") + let album = photos(for: "album-1") // and another + album.info("r3") + gate.open() + await system.flush() + + let backlog = sink.deliveries.drop(while: { delivery in + guard case let .records(records) = delivery else { return true } + return records.first?.message == "r0" + }) + let shape = backlog.map { delivery in + switch delivery { + case let .scopes(scopes): "scopes(\(scopes.map(\.name).joined(separator: ",")))" + case let .records(records): "records(\(records.map(\.message).joined(separator: ",")))" + } + } + #expect(shape == [ + "records(r1)", + "scopes(PhotoLogs)", + "records(r2)", + "scopes(album-1)", + "records(r3)", + ]) + } + + @Test func recordsEmittedDuringADrainStillArrive() async { + let system = makeSystem() + let log = Log(system: system) + + for index in 1 ... 100 { + log.info("\(index)") + } + await system.flush() + + #expect(sink.records.count == 100) + #expect(sink.records.map(\.message) == (1 ... 100).map(String.init)) + } + + @Test func inspectModeFlagRoundTrips() { + let system = makeSystem() + #expect(!system.isInspectModeEnabled) + system.isInspectModeEnabled = true + #expect(system.isInspectModeEnabled) + system.isInspectModeEnabled = false + #expect(!system.isInspectModeEnabled) + } + + @Test func inspectModeStreamsConvergeUnderConcurrentWriters() async { + let system = makeSystem() + var iterator = system.inspectModeChanges().makeAsyncIterator() + #expect(await iterator.next() == false) + + // Hammer the flag from two tasks, then make one final authoritative + // write. In-lock yields mean the newest buffered value is always + // the flag's final state — an out-of-order yield would strand the + // subscriber on the losing value. + await withTaskGroup(of: Void.self) { group in + group.addTask { + for _ in 0 ..< 200 { + system.isInspectModeEnabled = true + } + } + group.addTask { + for _ in 0 ..< 200 { + system.isInspectModeEnabled = false + } + } + await group.waitForAll() + } + system.isInspectModeEnabled = true + + // Nothing consumed during the storm, so bufferingNewest(1) holds + // exactly one value: with in-lock yields it must be the final one. + #expect(await iterator.next() == true) + #expect(system.isInspectModeEnabled) + } + + @Test func inspectModeChangesYieldTheCurrentValueThenChangesOnly() async { + let system = makeSystem() + system.isInspectModeEnabled = true + + var iterator = system.inspectModeChanges().makeAsyncIterator() + #expect(await iterator.next() == true) + + system.isInspectModeEnabled = true // redundant — must not yield + system.isInspectModeEnabled = false + #expect(await iterator.next() == false) + } + + // MARK: Level floors + + @Test func globalFloorDiscardsRecordsBelowIt() async { + let system = makeSystem() + system.minimumLevel = .warning + let log = Log(system: system) + + log.debug("quiet") + log.info("quiet") + log.warning("loud") + log { AppLogs() } // AppLogs is .info — below the floor. + await system.flush() + + #expect(sink.records.map(\.message) == ["loud"]) + #expect(system.recentRecords().map(\.message) == ["loud"]) + } + + @Test func subtreeFloorOverridesTheGlobalFloor() async { + let system = makeSystem() + system.minimumLevel = .error + let root = Log(system: system) + let photos = root(PhotoLogs.self) + system.setMinimumLevel(.debug, forSubtree: photos.primaryScope.id) + + root.info("root info") + photos.debug("photos debug") + photos(for: "album-1").debug("album debug") + await system.flush() + + #expect(sink.records.map(\.message) == ["photos debug", "album debug"]) + } + + @Test func nearestSubtreeOverrideWins() async { + let system = makeSystem() + let root = Log(system: system) + let photos = root(PhotoLogs.self) + let album = photos(for: "album-1") + system.setMinimumLevel(.error, forSubtree: photos.primaryScope.id) + system.setMinimumLevel(.debug, forSubtree: album.primaryScope.id) + + photos.info("blocked") + album.debug("allowed") + await system.flush() + + #expect(sink.records.map(\.message) == ["allowed"]) + } + + @Test func clearingASubtreeOverrideRestoresTheGlobalFloor() { + let system = makeSystem() + let root = Log(system: system) + system.setMinimumLevel(.error, forSubtree: root.primaryScope.id) + #expect(!system.shouldRecord(level: .info, scopes: [root.primaryScope.id])) + + system.setMinimumLevel(nil, forSubtree: root.primaryScope.id) + #expect(system.shouldRecord(level: .info, scopes: [root.primaryScope.id])) + } + + @Test func linkedRecordsPassWhenAnyScopeAdmitsThem() async { + let system = makeSystem() + system.minimumLevel = .debug + let model = Log(system: system) + let ui = Log(system: system) + system.setMinimumLevel(.error, forSubtree: model.primaryScope.id) + + (model + ui).info("visible via the UI scope") + model.info("blocked") + await system.flush() + + #expect(sink.records.map(\.message) == ["visible via the UI scope"]) + } + + // MARK: Span pairs and floors + + @Test func spanEndsBypassFloorsRaisedMidSpan() async { + let system = makeSystem() + let log = Log(system: system) + + log.begin(for: "pay_1", lifetime: .indefinite) + system.minimumLevel = .fault + log.end(for: "pay_1", exit: .success) // .info — would normally floor + await system.flush() + + let ends = sink.records.compactMap { $0.event as? SpanEnded } + #expect(ends.map(\.exit.mode) == [.success]) + } + + @Test func flooredBeginsSilenceTheWholeSpan() async { + let system = makeSystem() + system.minimumLevel = .fault + let log = Log(system: system) + + log.begin(for: "hidden", lifetime: .indefinite) + system.minimumLevel = nil // even reopening the floors mid-span + log.end(for: "hidden", exit: .failure("boom")) + await system.flush() + + // Neither half emitted — floors hid the span entirely, never a + // dangling end. (No "without a matching begin" warning either: the + // span was tracked, just silent.) + #expect(sink.records.isEmpty) + } + + @Test func expiredEndsFollowTheirBeganAcrossFloorChanges() async { + let system = makeSystem() + let log = Log(system: system) + + log.begin(for: "recorded", lifetime: .bounded(budget: .seconds(1))) + system.minimumLevel = .fault + log.begin(for: "hidden", lifetime: .bounded(budget: .seconds(1))) + + system.sweepOverdueSpans(now: ContinuousClock().now + .seconds(5)) + await system.flush() + + let ends = sink.records.compactMap { $0.event as? SpanEnded } + #expect(ends.map(\.name) == ["recorded"]) + #expect(ends.first?.exit.mode == .expired) + } + + @Test func supersededEndsFollowTheirBegan() async { + let system = makeSystem() + let log = Log(system: system) + + log.begin(for: "flow", lifetime: .indefinite) + system.minimumLevel = .fault + log.begin(for: "flow", lifetime: .indefinite) // supersedes silently-visible pair + + await system.flush() + + // The first span's began was recorded, so its superseded end is + // too; the second began is floored and its whole span stays silent. + let ends = sink.records.compactMap { $0.event as? SpanEnded } + #expect(ends.map(\.exit.mode) == [.superseded]) + #expect(sink.records.compactMap { $0.event as? SpanBegan }.count == 1) + } + + @Test func measureEndsBypassFloorsRaisedMidBody() async { + let system = makeSystem() + let log = Log(system: system) + + log.measure("save") { + system.minimumLevel = .fault + } + await system.flush() + + let ends = sink.records.compactMap { $0.event as? SpanEnded } + #expect(ends.map(\.exit.mode) == [.success]) + } + + @Test func flooredMeasuresAreFullySilentIncludingOverdue() async { + let system = makeSystem() + system.minimumLevel = .fault + let log = Log(system: system) + + await log.measure("hidden", budget: .milliseconds(5)) { + // Outlast the budget so a non-suppressed sentinel would fire. + try? await Task.sleep(for: .milliseconds(30)) + } + await system.flush() + + #expect(sink.records.isEmpty) + } + + @Test func filteredFreeformLoggingSkipsMessageRendering() { + let system = makeSystem() + system.minimumLevel = .warning + let log = Log(system: system) + + var rendered = false + func render() -> String { + rendered = true + return "expensive" + } + + log.debug(render()) + #expect(!rendered) + + log.error(render()) + #expect(rendered) + } + + // MARK: Redaction + + @Test func redactionTransformsRecordsBeforeAnyDelivery() async { + let system = Periscope( + configuration: Periscope.Configuration(redact: { record in + LogRecord( + id: record.id, + date: record.date, + event: Message(level: record.level, "[redacted]"), + scopes: record.scopes, + ) + }), + sinks: [sink], + ) + let log = Log(system: system) + + log.info("card number 4242") + await system.flush() + + #expect(sink.records.map(\.message) == ["[redacted]"]) + #expect(system.recentRecords().map(\.message) == ["[redacted]"]) + } + + @Test func redactionNeverRunsForFloorFilteredRecords() async { + let redactionCount = OSAllocatedUnfairLock(initialState: 0) + let system = Periscope( + configuration: Periscope.Configuration(redact: { record in + redactionCount.withLock { $0 += 1 } + return record + }), + sinks: [sink], + ) + system.minimumLevel = .warning + let log = Log(system: system) + + log.debug("filtered freeform") + log { AppLogs() } // .info structured event — filtered in record() + log.warning("admitted") + await system.flush() + + #expect(redactionCount.withLock { $0 } == 1) + #expect(sink.records.map(\.message) == ["admitted"]) + } + + @Test func redactionCanSuppressARecordEntirely() async { + let system = Periscope( + configuration: Periscope.Configuration(redact: { record in + record.message.contains("secret") ? nil : record + }), + sinks: [sink], + ) + let log = Log(system: system) + + log.info("secret token") + log.info("fine") + await system.flush() + + #expect(sink.records.map(\.message) == ["fine"]) + } + + @Test func redactionCannotSplitSpanPairs() async throws { + // The hook tries to suppress every span record. Suppression is + // transform-only for pairs: a stripped copy records instead — + // tags and attachments dropped, the exit reason blanked — so + // redaction can never strand half of a span. + let key = LogTagKey("payment-id") + let system = Periscope( + configuration: Periscope.Configuration(redact: { record in + record.spanID == nil ? record : nil + }), + sinks: [sink], + ) + let log = Log(system: system).tagged(key, "pay_1") + + log.begin(for: "checkout", lifetime: .indefinite) + log.end(for: "checkout", exit: .failure("card 4242 declined")) + await system.flush() + + let began = try #require(sink.records.first { $0.event is SpanBegan }) + let ended = try #require(sink.records.first { $0.event is SpanEnded }) + #expect(began.tags.isEmpty) + #expect(ended.tags.isEmpty) + #expect(ended.spanID == began.spanID) + #expect(ended.spanExit?.mode == .failure) + #expect(ended.spanExit?.reason == nil) + } + + // MARK: Flush policy + + @Test func recordsAtTheFlushThresholdTriggerAnAutomaticFlush() async { + let system = makeSystem() + let log = Log(system: system) + + log.error("boom") + + let flushed = await waitUntil { sink.flushCount >= 1 } + #expect(flushed) + #expect(sink.records.map(\.message) == ["boom"]) + } + + @Test func errorStormsCoalesceIntoAFewFlushes() async throws { + // Hold the drain mid-delivery so the whole storm lands while the + // first auto-flush is still waiting — deterministic coalescing. + let gate = GateSink() + let system = Periscope( + configuration: Periscope.Configuration(), + sinks: [gate, sink], + ) + let log = Log(system: system) + + log.error("e1") + let drainBlocked = await waitUntil { gate.batchCount >= 1 } + try #require(drainBlocked) + + for index in 2 ... 50 { + log.error("e\(index)") + } + gate.open() + + let delivered = await waitUntil { sink.records.count == 50 } + #expect(delivered) + let settled = await waitUntil { sink.flushCount >= 1 } + #expect(settled) + + // One flush for the storm, at most one follow-up for records that + // landed mid-flush — never one per record. + #expect(sink.flushCount <= 2) + } + + @Test func autoFlushRecoversAfterSettling() async { + let system = makeSystem() + let log = Log(system: system) + + log.error("first") + let first = await waitUntil { sink.flushCount >= 1 } + #expect(first) + let countAfterFirst = sink.flushCount + + log.error("second") + let second = await waitUntil { sink.flushCount > countAfterFirst } + #expect(second) + #expect(sink.records.map(\.message) == ["first", "second"]) + } + + @Test func recordsBelowTheFlushThresholdDoNotFlushSinks() async { + let system = makeSystem() + let log = Log(system: system) + + log.warning("just a warning") + let delivered = await waitUntil { sink.records.count == 1 } + #expect(delivered) + #expect(sink.flushCount == 0) + } + + // MARK: Span watchdog + + @Test func sweepExpiresOnlyOverdueBoundedSpans() async throws { + let system = makeSystem() + let log = Log(system: system) + let now = ContinuousClock().now + + log.begin(for: "overdue", lifetime: .bounded(budget: .seconds(10))) + log.begin(for: "within-budget", lifetime: .bounded(budget: .seconds(120))) + log.begin(for: "open-ended", lifetime: .indefinite) + + system.sweepOverdueSpans(now: now + .seconds(60)) + await system.flush() + + let expired = sink.records.compactMap { $0.event as? SpanEnded } + #expect(expired.count == 1) + let ended = try #require(expired.first) + #expect(ended.name == "overdue") + #expect(ended.exit.mode == .expired) + #expect(ended.exit.reason?.contains("budget") == true) + // `now` was captured just before the begin, so the measured age is + // a hair under the sweep offset — the meaningful bound is the + // budget it blew through. + let duration = try #require(ended.duration) + #expect(duration >= .seconds(10)) + } + + @Test func expiredSpansKeepTheirBeginContext() async throws { + let system = makeSystem() + let key = LogTagKey("payment-id") + let log = Log(system: system)(for: "checkout").tagged(key, "pay_1") + + log.begin(for: "pay_1", lifetime: .bounded(budget: .seconds(1))) + system.sweepOverdueSpans(now: ContinuousClock().now + .seconds(5)) + await system.flush() + + let expired = try #require(sink.records.first { record in + (record.event as? SpanEnded)?.exit.mode == .expired + }) + #expect(expired.scopes == log.scopes.map(\.id)) + #expect(expired.tags == [LogTag(key: key, value: "pay_1")]) + } + + @Test func expiredSpansFreeTheirKeyForReuse() async { + let system = makeSystem() + let log = Log(system: system) + + log.begin(for: "flow", lifetime: .bounded(budget: .seconds(1))) + system.sweepOverdueSpans(now: ContinuousClock().now + .seconds(5)) + + // A fresh begin after expiry must not read as superseded. + log.begin(for: "flow", lifetime: .indefinite) + log.end(for: "flow", exit: .success) + await system.flush() + + let ends = sink.records.compactMap { $0.event as? SpanEnded } + #expect(ends.map(\.exit.mode) == [.expired, .success]) + } + + @Test func openSpansSnapshotTracksLifecycleLongestRunningFirst() { + let system = makeSystem() + let log = Log(system: system) + #expect(system.openSpans().isEmpty) + + log.begin(for: "first", lifetime: .indefinite) + log.begin(for: "second", lifetime: .bounded(budget: .seconds(60))) + #expect(system.openSpans().map(\.name) == ["first", "second"]) + + log.end(for: "first", exit: .success) + #expect(system.openSpans().map(\.name) == ["second"]) + + system.sweepOverdueSpans(now: ContinuousClock().now + .seconds(120)) + #expect(system.openSpans().isEmpty) + } + + @Test func theWatchdogExpiresSpansOnItsOwn() async { + let system = makeSystem() + let log = Log(system: system) + + log.begin(for: "quick", lifetime: .bounded(budget: .milliseconds(20))) + + let expired = await waitUntil { + sink.records.contains { record in + (record.event as? SpanEnded)?.exit.mode == .expired + } + } + #expect(expired) + } + + @Test func anEarlierDeadlineWakesTheWatchdogSooner() async { + let system = makeSystem() + let log = Log(system: system) + + // The watchdog is already asleep until the 30s deadline when the + // 20ms span opens — only a respawn with the earlier wake time can + // expire it within this test's budget. + log.begin(for: "slow", lifetime: .bounded(budget: .seconds(30))) + log.begin(for: "quick", lifetime: .bounded(budget: .milliseconds(20))) + + let expired = await waitUntil { + sink.records.contains { record in + guard let ended = record.event as? SpanEnded else { return false } + return ended.name == "quick" && ended.exit.mode == .expired + } + } + #expect(expired) + } + + @Test func theWatchdogDoesNotKeepDeadSystemsAlive() async { + weak var weakSystem: Periscope? + do { + let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + weakSystem = system + let log = Log(system: system) + log.begin(for: "long", lifetime: .bounded(budget: .seconds(120))) + // Let the drain retire so its (short-lived) strong capture ends. + await system.flush() + } + + // The watchdog sleeps until the 120s deadline; holding the system + // strongly through that sleep would fail this within the budget. + let released = await waitUntil { weakSystem == nil } + #expect(released) + } + + // MARK: Drop policy + + @Test func overflowNeverDropsScopeDefinitions() async throws { + let gate = GateSink() + let system = Periscope( + configuration: Periscope.Configuration(pendingBufferCapacity: 3), + sinks: [gate, sink], + ) + let log = Log(system: system) + + log.info("r0") + let drainBlocked = await waitUntil { gate.batchCount >= 1 } + try #require(drainBlocked) + + // Interleave fresh scope definitions with enough records to force + // the drop policy: records may drop, definitions must not. + var children: [LogScope] = [] + for index in 1 ... 6 { + let child = log(for: "child-\(index)") + children.append(child.primaryScope) + child.info("r\(index)") + } + gate.open() + await system.flush() + + for child in children { + #expect(sink.definedScopes.contains(child)) + } + let messages = sink.records.map(\.message) + #expect(messages.contains("3 log event(s) dropped before delivery")) + #expect(messages.suffix(3) == ["r4", "r5", "r6"]) + #expect(!messages.contains("r1")) + } + + @Test(arguments: [0x5EED_0001, 2, 42, 987_654_321] as [UInt64]) + func pipelineSurvivesSeededConcurrentInterleavings(seed: UInt64) async { + enum FuzzOp: Sendable { + case emit + case derive + case flush + case addSink(Int) + } + + // Generate the whole interleaving script up front so a failing seed + // replays exactly. + var rng = SeededRandom(seed: seed) + let taskCount = 4 + let opsPerTask = 60 + let extraSinks = [CapturingSink(), CapturingSink()] + var scripts: [[FuzzOp]] = [] + var unusedExtraSinks = Array(extraSinks.indices) + for task in 0 ..< taskCount { + var script: [FuzzOp] = [] + for _ in 0 ..< opsPerTask { + switch Int.random(in: 0 ..< 100, using: &rng) { + case ..<70: + script.append(.emit) + case ..<85: + script.append(.derive) + case ..<95: + script.append(.flush) + default: + // Sinks register once each, from the first task only. + if task == 0, !unusedExtraSinks.isEmpty { + script.append(.addSink(unusedExtraSinks.removeFirst())) + } else { + script.append(.emit) + } + } + } + scripts.append(script) + } + + let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + let emittedByTask = await withTaskGroup( + of: (task: Int, emitted: Int).self, + returning: [Int: Int].self, + ) { group in + for (task, script) in scripts.enumerated() { + group.addTask { + var log = Log(system: system)(for: "task-\(task)") + var emitted = 0 + var derived = 0 + for op in script { + switch op { + case .emit: + log.info("t\(task)-\(emitted)") + emitted += 1 + case .derive: + derived += 1 + log = log(for: "d\(derived)") + case .flush: + await system.flush() + case let .addSink(index): + system.add(sink: extraSinks[index]) + } + } + return (task, emitted) + } + } + var counts: [Int: Int] = [:] + for await result in group { + counts[result.task] = result.emitted + } + return counts + } + await system.flush() + + // Nothing lost, nothing duplicated, per-emitter order preserved. + let messages = sink.records.map(\.message) + let totalEmitted = emittedByTask.values.reduce(0, +) + #expect(messages.count == totalEmitted) + #expect(Set(messages).count == messages.count) + for (task, emitted) in emittedByTask { + let expected = (0 ..< emitted).map { "t\(task)-\($0)" } + #expect(messages.filter { $0.hasPrefix("t\(task)-") } == expected) + } + + // Every sink — including late-added ones fed by the scope replay — + // saw each record's scopes defined before the record itself. + for candidate in [sink] + extraSinks { + var defined: Set = [] + for delivery in candidate.deliveries { + switch delivery { + case let .scopes(scopes): + defined.formUnion(scopes.map(\.id)) + case let .records(records): + for record in records { + #expect(record.scopes.allSatisfy(defined.contains)) + } + } + } + } + } + + @Test func customEventsCanOptIntoDropProtection() async throws { + struct AuditEvent: LogEvent { + static let isProtectedFromDropping = true + var message: String { + "audit-entry" + } + } + + let gate = GateSink() + let system = Periscope( + configuration: Periscope.Configuration(pendingBufferCapacity: 3), + sinks: [gate, sink], + ) + let log = Log(system: system) + + log.info("r0") + let drainBlocked = await waitUntil { gate.batchCount >= 1 } + try #require(drainBlocked) + + log(AuditEvent.self) { AuditEvent() } + for index in 1 ... 5 { + log.info("r\(index)") + } + gate.open() + await system.flush() + + let messages = sink.records.map(\.message) + #expect(messages.contains("audit-entry")) + #expect(!messages.contains("r1")) + } + + @Test func overflowNeverSplitsSpanPairs() async throws { + let gate = GateSink() + let system = Periscope( + configuration: Periscope.Configuration(pendingBufferCapacity: 3), + sinks: [gate, sink], + ) + let log = Log(system: system) + + log.info("r0") + let drainBlocked = await waitUntil { gate.batchCount >= 1 } + try #require(drainBlocked) + + // A complete span pair queues first; the freeform flood behind it + // overflows the queue. Only the freeform records may drop. + log.begin(for: "checkout", lifetime: .indefinite) + log.end(for: "checkout", exit: .success) + for index in 1 ... 4 { + log.info("r\(index)") + } + gate.open() + await system.flush() + + let messages = sink.records.map(\.message) + #expect(messages.contains("▶ checkout")) + #expect(messages.contains { $0.hasPrefix("◀ checkout succeeded") }) + #expect(messages.contains("3 log event(s) dropped before delivery")) + #expect(messages.contains("r4")) + #expect(!messages.contains("r1")) + } + + @Test(arguments: [0xBEA7_0001, 11, 4242, 555_555_555] as [UInt64]) + func spanLifecyclesSurviveSeededConcurrentInterleavings(seed: UInt64) async { + enum FuzzOp: Sendable { + case emit(levelIndex: Int) + case begin(key: Int) + case end(key: Int) + case setFloor(index: Int) + case flush + } + + let levels = 4 + let floors: [LogLevel?] = [nil, .info, .warning, .fault] + let keyCount = 3 + let taskCount = 4 + let opsPerTask = 60 + + // Generate the whole interleaving script up front so a failing seed + // replays exactly. + var rng = SeededRandom(seed: seed) + var scripts: [[FuzzOp]] = [] + for _ in 0 ..< taskCount { + var script: [FuzzOp] = [] + for _ in 0 ..< opsPerTask { + switch Int.random(in: 0 ..< 100, using: &rng) { + case ..<40: + script.append(.emit(levelIndex: Int.random(in: 0 ..< levels, using: &rng))) + case ..<60: + script.append(.begin(key: Int.random(in: 0 ..< keyCount, using: &rng))) + case ..<75: + script.append(.end(key: Int.random(in: 0 ..< keyCount, using: &rng))) + case ..<92: + script.append(.setFloor(index: Int.random( + in: 0 ..< floors.count, + using: &rng, + ))) + default: + script.append(.flush) + } + } + scripts.append(script) + } + + // A small pending queue adds drop pressure under load: drops only + // ever remove freeform records (oldest first, order preserved) — + // never a span half, which stays assertable below. + let system = Periscope( + configuration: Periscope.Configuration(pendingBufferCapacity: 16), + sinks: [sink], + ) + await withTaskGroup(of: Void.self) { group in + for (task, script) in scripts.enumerated() { + group.addTask { + // Freeform emits go through a per-task child scope so + // per-emitter order is checkable; span keys stay on the + // shared root scope so begins collide — and supersede — + // across tasks. + let rootLog = Log(system: system) + let taskLog = rootLog(for: "task-\(task)") + var emitted = 0 + for op in script { + switch op { + case let .emit(levelIndex): + let text = "t\(task)-\(emitted)" + switch levelIndex { + case 0: taskLog.debug(text) + case 1: taskLog.info(text) + case 2: taskLog.warning(text) + default: taskLog.error(text) + } + emitted += 1 + case let .begin(key): + rootLog.begin(for: "k\(key)", lifetime: .indefinite) + case let .end(key): + rootLog.end(for: "k\(key)", exit: .success) + case let .setFloor(index): + system.minimumLevel = floors[index] + case .flush: + await system.flush() + } + } + } + } + } + + // Close whatever the scripts left open, then drain. + let rootLog = Log(system: system) + for key in 0 ..< keyCount { + rootLog.end(for: "k\(key)", exit: .success) + } + await system.flush() + #expect(system.openSpans().isEmpty) + + // Floors and drops only *remove*: each task's delivered freeform + // messages stay in emission order (strictly increasing suffixes, + // no duplicates), whatever the floor or queue was doing. + let messages = sink.records.map(\.message) + for task in 0 ..< taskCount { + let prefix = "t\(task)-" + let delivered = messages + .filter { $0.hasPrefix(prefix) } + .compactMap { Int($0.dropFirst(prefix.count)) } + #expect(delivered == delivered.sorted()) + #expect(Set(delivered).count == delivered.count) + } + + // Span pairs never dangle across floor changes or supersession: + // every span the sink saw is exactly a began *then* its ended — + // a floored begin silences the whole pair instead, and + // registration + began land atomically (`beginSpan`) so no + // interleaving delivers an end first. + var lifecycles: [SpanID: [LogRecord]] = [:] + for record in sink.records { + guard let span = record.spanID else { continue } + lifecycles[span, default: []].append(record) + } + for (span, records) in lifecycles { + #expect(records.count == 2, "span \(span) should be a began/ended pair") + #expect(records.first?.event is SpanBegan) + #expect(records.last?.event is SpanEnded) + } + + // Scope definitions still precede every record that references them. + var defined: Set = [] + for delivery in sink.deliveries { + switch delivery { + case let .scopes(scopes): + defined.formUnion(scopes.map(\.id)) + case let .records(records): + for record in records { + #expect(record.scopes.allSatisfy(defined.contains)) + } + } + } + } + + @Test func overflowingThePendingQueueDropsOldestAndReportsTheGap() async throws { + let gate = GateSink() + let system = Periscope( + configuration: Periscope.Configuration(pendingBufferCapacity: 3), + sinks: [gate, sink], + ) + let log = Log(system: system) + + log.info("r0") + let drainBlocked = await waitUntil { gate.batchCount >= 1 } + try #require(drainBlocked) + + for index in 1 ... 5 { + log.info("r\(index)") + } + gate.open() + await system.flush() + + let messages = sink.records.map(\.message) + #expect(messages.first == "r0") + #expect(messages.contains("2 log event(s) dropped before delivery")) + #expect(messages.suffix(3) == ["r3", "r4", "r5"]) + #expect(!messages.contains("r1")) + #expect(!messages.contains("r2")) + + let report = try #require( + sink.records.first { $0.eventName == Periscope.DroppedEvents.eventName }, + ) + #expect(report.level == .warning) + #expect(report.scopes == [system.systemScope.id]) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/ScopeIDTests.swift b/Shared/Periscope/PeriscopeCore/Tests/ScopeIDTests.swift new file mode 100644 index 00000000..1e8297f8 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/ScopeIDTests.swift @@ -0,0 +1,34 @@ +import Foundation +import PeriscopeCore +import Testing + +struct ScopeIDTests { + @Test func samePathDerivesTheSameID() { + let a = LogScope.root(named: "photos").child(named: "album-1") + let b = LogScope.root(named: "photos").child(named: "album-1") + #expect(a.id == b.id) + } + + @Test func differentNamesDeriveDifferentIDs() { + let root = LogScope.root(named: "photos") + #expect(root.child(named: "album-1").id != root.child(named: "album-2").id) + } + + @Test func differentParentsDeriveDifferentIDs() { + let photos = LogScope.root(named: "photos") + let videos = LogScope.root(named: "videos") + #expect(photos.child(named: "item").id != videos.child(named: "item").id) + } + + @Test func rootAndChildOfSameNameDiffer() { + let root = LogScope.root(named: "photos") + #expect(root.id != root.child(named: "photos").id) + } + + @Test func roundTripsThroughCodable() throws { + let id = LogScope.root(named: "photos").id + let data = try JSONEncoder().encode(id) + let decoded = try JSONDecoder().decode(ScopeID.self, from: data) + #expect(decoded == id) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift b/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift new file mode 100644 index 00000000..b1f518a3 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift @@ -0,0 +1,60 @@ +import Foundation +import PeriscopeCore +import Testing + +struct SpanExitTests { + @Test func staticsCarryTheirModes() { + #expect(SpanExit.success.mode == .success) + #expect(SpanExit.success.reason == nil) + #expect(SpanExit.failure("card declined").reason == "card declined") + #expect(SpanExit.cancelled("user backed out").mode == .cancelled) + #expect(SpanExit.superseded.mode == .superseded) + #expect(SpanExit.orphaned.mode == .orphaned) + } + + @Test func everyModeHasAReasonTakingFactory() { + let exits: [SpanExit] = [ + .success("done"), + .failure("card declined"), + .cancelled("user backed out"), + .superseded("flow restarted"), + .expired("gave up"), + .orphaned("previous process died"), + ] + #expect(exits.map(\.mode) == SpanExit.Mode.allCases) + #expect(exits.allSatisfy { $0.reason != nil }) + } + + @Test func expiredCarriesItsBudget() { + let exit = SpanExit.expired(budget: .seconds(30)) + #expect(exit.mode == .expired) + #expect(exit.reason?.contains("budget") == true) + } + + @Test func exitRoundTripsThroughCodable() throws { + let exit = SpanExit.failure("network timeout") + let data = try JSONEncoder().encode(exit) + let decoded = try JSONDecoder().decode(SpanExit.self, from: data) + #expect(decoded == exit) + } + + @Test func lifetimeRoundTripsThroughCodable() throws { + for lifetime in [ + SpanLifetime.scoped, + .bounded(budget: .milliseconds(1500)), + .indefinite, + ] { + let data = try JSONEncoder().encode(lifetime) + let decoded = try JSONDecoder().decode(SpanLifetime.self, from: data) + #expect(decoded == lifetime) + } + } + + @Test func relaunchPolicyRoundTripsThroughCodable() throws { + for policy in [SpanRelaunchPolicy.endsWithProcess, .survivesRelaunch] { + let data = try JSONEncoder().encode(policy) + let decoded = try JSONDecoder().decode(SpanRelaunchPolicy.self, from: data) + #expect(decoded == policy) + } + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift new file mode 100644 index 00000000..baefac2f --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -0,0 +1,49 @@ +import Foundation +import PeriscopeCore +import Testing + +struct StoredLogEventTests { + private func makeStored(payload: Data) -> StoredLogEvent { + let scope = LogScope.root(named: "photos") + return StoredLogEvent( + id: UUID(), + date: Date(timeIntervalSinceReferenceDate: 100), + sequence: 0, + level: .notice, + eventName: PhotoLogs.eventName, + eventVersion: PhotoLogs.eventVersion, + message: "photo p1", + payload: payload, + scopes: [scope.id], + tags: [LogTag(key: LogTagKey("payment-id"), value: "pay_123")], + spanID: nil, + spanExitMode: nil, + callSite: nil, + externalID: nil, + attachments: [], + sessionID: UUID(), + ) + } + + @Test func decodeRecoversTheOriginalEvent() throws { + let payload = try JSONEncoder().encode(PhotoLogs(photoID: "p1")) + let stored = makeStored(payload: payload) + + let decoded = try stored.decode(PhotoLogs.self) + #expect(decoded.photoID == "p1") + } + + @Test func decodeThrowsWhenTheShapeNoLongerMatches() throws { + let payload = try JSONEncoder().encode(Message(level: .info, "not a photo")) + let stored = makeStored(payload: payload) + + #expect(throws: (any Error).self) { + try stored.decode(PhotoLogs.self) + } + } + + @Test func primaryScopeIsTheFirstScope() { + let stored = makeStored(payload: Data()) + #expect(stored.primaryScope == stored.scopes.first) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/ThermalStateAmbientSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/ThermalStateAmbientSourceTests.swift new file mode 100644 index 00000000..002abc82 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/ThermalStateAmbientSourceTests.swift @@ -0,0 +1,37 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import Testing + +struct ThermalStateAmbientSourceTests { + @Test func thermalChangeNotificationsLogTheCurrentState() async { + let sink = CapturingSink() + let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + system.startAmbientSource(ThermalStateAmbientSource()) + + NotificationCenter.default.post( + name: ProcessInfo.thermalStateDidChangeNotification, + object: nil, + ) + await system.flush() + + let expected = ThermalStateAmbientSource + .event(for: ProcessInfo.processInfo.thermalState) + #expect(sink.records.contains { $0.message == expected.message }) + } + + @Test(arguments: [ + (ProcessInfo.ThermalState.nominal, "nominal", LogLevel.info), + (.fair, "fair", .info), + (.serious, "serious", .warning), + (.critical, "critical", .warning), + ]) + func statesMapToValuesAndLevels( + state: ProcessInfo.ThermalState, + value: String, + level: LogLevel, + ) { + let event = ThermalStateAmbientSource.event(for: state) + #expect(event.value == value) + #expect(event.level == level) + } +} diff --git a/Shared/Periscope/PeriscopeTools/AGENTS.md b/Shared/Periscope/PeriscopeTools/AGENTS.md new file mode 100644 index 00000000..e9e26836 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/AGENTS.md @@ -0,0 +1,49 @@ +# PeriscopeTools – Module Shape + +PeriscopeTools is the on-device log exploration tooling for +[`PeriscopeCore`](../PeriscopeCore): the latest-logs viewer, the tracer, the +debug toast, and the log view mode modifier. 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. + +## Layout + +`Sources/` groups one directory per tool — `Viewer/`, `Tracer/`, `Alerts/`, +`InspectMode/`, `Spans/` — plus `Components/` for the display pieces they +share (event rows, detail view, level/exit display extensions). Tests stay +flat, named 1:1 with their source files. + +## Scope & dependencies + +- **SwiftUI + PeriscopeCore + PeriscopeUI.** No app code — app-specific + wiring (which store, which alert handler) comes in via configuration. +- **Intended for DEBUG / developer surfaces**; consumers gate entry points + behind `#if DEBUG`. Developer-facing strings are plain literals here. + +## Invariants + +- **Read-only over the store.** Tooling queries `PeriscopeCore`'s store and + live buffer; it never records events of its own (except through the normal + logging API). +- **The toast is hookable** — apps override the default handler rather than + this module special-casing any app. Handlers must not log at or above the + alerter threshold (they'd alert themselves in a loop). +- **`Periscope.isInspectModeEnabled` is the inspect flag's source of + truth** — `PeriscopeInspector` is its observable SwiftUI mirror, synced + both ways: the inspector writes through, and direct system writes flow + back via `inspectModeChanges()`. Either side may write; they converge. +- **Merged multi-query results sort by `(date, sequence)`** — the tracer and + inspector combine several store queries, and the store's insertion + sequence is the tiebreak that keeps same-millisecond events stable. +- **Tool views rebind on in-place input swaps** — each view's `.task(id:)` + is keyed on store identity plus its other inputs and rebuilds the model + when they change; a new identity-relevant input must join the key, or + the view silently keeps serving the old inputs. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeToolsTests`). Seed an in-memory store, drive the view models +directly, and host views with `TestHostSupport`'s `show()` helpers. diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md new file mode 100644 index 00000000..57b3126f --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -0,0 +1,80 @@ +# PeriscopeTools + +On-device log exploration for the **Periscope** observability framework +([`PeriscopeCore`](../PeriscopeCore)): a searchable latest-logs viewer, a +tracer that follows an error back through time and up the scope tree, a +hookable debug toast for warnings and errors, and a "log view mode" that +reveals the events behind any wrapped view. + +## Installation + +`PeriscopeTools` is a local SPM library in this repo +(`Shared/Periscope/PeriscopeTools`). Add it to a target's dependencies in +[`Package.swift`](../../../Package.swift): + +```swift +.target(name: "YourModule", dependencies: [.target(name: "PeriscopeTools")]) +``` + +## Quick start + +All tools are developer surfaces — gate them behind `#if DEBUG` or a +developer menu. + +```swift +// The viewer, pushed from a developer settings screen: +NavigationLink("Logs") { + PeriscopeViewer(store: store, title: "Logs") +} + +// The debug toast, started once at launch: +let alerter = PeriscopeAlerter( + system: .shared, + threshold: .warning, + handler: LocalNotificationAlertHandler(), +) +alerter.start() + +// Log view mode, wired at the root and toggled from developer settings: +RootView().periscopeInspector(inspector) // PeriscopeInspector +PaymentRow(payment).logInspectable(payment) // any Log or provider +Toggle("Log View Mode", isOn: $inspector.isEnabled) +``` + +## Public API + +- **`PeriscopeViewer(store:title:)`** — the latest-logs viewer: newest-first + list over a `PeriscopeStore`, searchable, filterable by level / event type + / scope subtree / session / span exit, paged, with exit-mode chips on + span rows, per-event detail (exit + reason, payload JSON, tags, + attachments), and NDJSON export for bug reports. Push it inside an + existing `NavigationStack`. +- **`LogTraceView(store:origin:)`** — the tracer: from one event (typically + an error), shows the trail that led up to it — earlier events in the + subtrees of all its (linked) scopes, events logged at ancestor scopes on + the way up the tree (never siblings), and its span pair — newest first. + Reachable from every event detail's Trace button, and each trail row's + detail can trace further back. +- **`PeriscopeAlerter(system:threshold:handler:)`** — the debug toast + engine: watches a system's live records and routes everything at the + threshold or above to a `PeriscopeAlertHandler`. The built-in + `LocalNotificationAlertHandler` posts a local notification (provisional + authorization, delivered quietly); apps with their own toast system + conform to the protocol instead. +- **Log view mode** — `PeriscopeInspector` (observable wrapper over + `Periscope.isInspectModeEnabled` plus the store), injected via + `View.periscopeInspector(_:)`. `View.logInspectable(_:)` (taking a `Log` + or a `LogContextProviding` model) badges the view while the mode is on; + tapping the badge presents every stored event in that context's scope + subtrees, live-refreshing, each linking into detail and the tracer. +- **`OpenSpansView(system:)`** — every span currently open via + `begin(for:)`, longest running first, with ticking ages, lifetimes, and + scope paths. Reads the system (open spans are live state, not store + history); push it from a developer menu. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeToolsTests` bundle): models are driven directly over in-memory +stores (`@_spi(Testing) PeriscopeStore.inMemory`), views host via +`TestHostSupport.show`. Run with `tuist test PeriscopeToolsTests`. diff --git a/Shared/Periscope/PeriscopeTools/Sources/Alerts/LocalNotificationAlertHandler.swift b/Shared/Periscope/PeriscopeTools/Sources/Alerts/LocalNotificationAlertHandler.swift new file mode 100644 index 00000000..6fd08df2 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Alerts/LocalNotificationAlertHandler.swift @@ -0,0 +1,116 @@ +import Foundation +import os +import PeriscopeCore +import UserNotifications + +/// The default debug toast: posts each alerted record as a local +/// notification (requesting provisional authorization, which delivers +/// quietly without prompting). The authorization outcome is asked for once +/// and cached — an error storm must not do a daemon round-trip per record. +/// Apps with their own toast system implement ``PeriscopeAlertHandler`` +/// instead. +public struct LocalNotificationAlertHandler: PeriscopeAlertHandler { + /// The cached authorization outcome. `unknown` retries on the next + /// alert (a failed request may be transient); `denied` goes quiet + /// without re-asking. + private enum Authorization { + case unknown + case granted + case denied + } + + /// Failures posting the alert can't alert (that would loop), so they go + /// straight to OSLog. + private nonisolated static let failureLogger = os.Logger( + subsystem: "com.stuff.periscope", + category: "alerts", + ) + + private let center: any AlertNotificationCenter + private let authorization = OSAllocatedUnfairLock(initialState: Authorization.unknown) + + public init() { + self.init(center: UNUserNotificationCenterAlertAdapter(center: .current())) + } + + init(center: any AlertNotificationCenter) { + self.center = center + } + + public func handle(_ record: LogRecord) { + Task { + await post(for: record) + } + } + + /// The posting path, factored from `handle` so tests can await it + /// deterministically. Explicitly nonisolated (the handler protocol is + /// `@MainActor`, which would otherwise infer isolation here) so the + /// non-Sendable `UNNotificationRequest` is built in a disconnected + /// region and can be sent to the center. + nonisolated func post(for record: LogRecord) async { + do { + switch authorization.withLock({ $0 }) { + case .granted: + break + case .denied: + return + case .unknown: + let granted = try await center.requestAuthorization( + options: [.alert, .sound, .provisional], + ) + authorization.withLock { $0 = granted ? .granted : .denied } + guard granted else { + Self.failureLogger.warning( + "Log alert notifications not authorized; alerts stay quiet", + ) + return + } + } + try await center.add(Self.request(for: record)) + } catch { + Self.failureLogger.warning( + "Failed to post log alert notification: \(error)", + ) + } + } + + nonisolated static func request(for record: LogRecord) -> UNNotificationRequest { + let content = UNMutableNotificationContent() + content.title = "\(record.level.name.capitalized): \(record.eventName)" + content.body = record.message + return UNNotificationRequest( + identifier: "periscope-alert-\(record.id.uuidString)", + content: content, + trigger: nil, + ) + } +} + +/// The slice of `UNUserNotificationCenter` the alert handler needs — a seam +/// so tests can drive authorization outcomes and capture posts (the same +/// shape as Where's `NotificationReminderCenter`). +protocol AlertNotificationCenter: Sendable { + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool + func add(_ request: sending UNNotificationRequest) async throws +} + +/// Production bridge. `@unchecked Sendable` is justified the same way as +/// Where's adapter: `UNUserNotificationCenter` is documented thread-safe. +final class UNUserNotificationCenterAlertAdapter: AlertNotificationCenter, + @unchecked Sendable +{ + private let center: UNUserNotificationCenter + + init(center: UNUserNotificationCenter) { + self.center = center + } + + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + try await center.requestAuthorization(options: options) + } + + func add(_ request: sending UNNotificationRequest) async throws { + try await center.add(request) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Alerts/PeriscopeAlerter.swift b/Shared/Periscope/PeriscopeTools/Sources/Alerts/PeriscopeAlerter.swift new file mode 100644 index 00000000..6ef0cc13 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Alerts/PeriscopeAlerter.swift @@ -0,0 +1,72 @@ +import Foundation +import PeriscopeCore + +/// Receives high-severity records the moment they're emitted — the hook +/// behind the debug toast. Apps with their own toast/notification system +/// conform and pass their handler to ``PeriscopeAlerter``; the built-in +/// default is ``LocalNotificationAlertHandler``. +/// +/// Handlers run on the main actor and must not log at or above the +/// alerter's threshold, or they'd alert themselves in a loop. +@MainActor +public protocol PeriscopeAlertHandler { + func handle(_ record: LogRecord) +} + +/// Watches a `Periscope` system's live records and routes everything at +/// `threshold` or above to a ``PeriscopeAlertHandler`` — the engine behind +/// "a toast appears when an error is logged". Intended for debug builds; +/// gate construction behind `#if DEBUG`: +/// +/// ```swift +/// #if DEBUG +/// let alerter = PeriscopeAlerter( +/// system: .shared, +/// threshold: .warning, +/// handler: LocalNotificationAlertHandler(), +/// ) +/// alerter.start() +/// #endif +/// ``` +@MainActor +public final class PeriscopeAlerter { + private let system: Periscope + private let threshold: LogLevel + private let handler: any PeriscopeAlertHandler + private var task: Task? + + public init( + system: Periscope, + threshold: LogLevel, + handler: any PeriscopeAlertHandler, + ) { + self.system = system + self.threshold = threshold + self.handler = handler + } + + deinit { + task?.cancel() + } + + /// Begin watching. Only records emitted after this point alert; + /// starting twice is a no-op. + public func start() { + guard task == nil else { return } + let stream = system.liveRecords() + let threshold = threshold + let handler = handler + task = Task { @MainActor in + for await record in stream { + guard record.level >= threshold else { continue } + handler.handle(record) + } + } + } + + /// Stop watching; ``start()`` may be called again later. + public func stop() { + task?.cancel() + task = nil + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift new file mode 100644 index 00000000..84ce1345 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift @@ -0,0 +1,148 @@ +import Foundation +import PeriscopeCore +import SwiftUI + +/// Everything about one stored event: metadata, message, scope path, tags, +/// the structured payload as pretty-printed JSON, and attachments (bytes +/// loaded on demand). +struct LogEventDetailView: View { + let event: StoredLogEvent + let scopePath: String + let store: PeriscopeStore + + @State private var attachments: Result<[LogAttachment], Error>? + + var body: some View { + List { + Section("Event") { + LabeledContent("Level") { + LogLevelBadge(level: event.level) + } + LabeledContent("Type", value: "\(event.eventName) v\(event.eventVersion)") + LabeledContent( + "Date", + value: event.date.formatted(date: .abbreviated, time: .standard), + ) + if !scopePath.isEmpty { + LabeledContent("Scope", value: scopePath) + } + if let span = event.spanID { + LabeledContent("Span", value: span.description) + } + if let exitMode = event.spanExitMode { + LabeledContent("Exit") { + HStack(spacing: 6) { + SpanExitBadge(mode: exitMode) + if let reason = event.exitReason { + Text(reason) + } + } + } + } + if let callSite = event.callSite { + LabeledContent("Emitted From", value: callSite.description) + } + if let externalID = event.externalID { + LabeledContent("Object", value: externalID) + } + LabeledContent("Session", value: event.sessionID.uuidString) + } + + Section("Message") { + Text(event.message) + .font(.callout) + .textSelection(.enabled) + } + + if !event.tags.isEmpty { + Section("Tags") { + ForEach(event.tags, id: \.key) { tag in + LabeledContent(tag.key.rawValue, value: tag.value.stringValue) + } + } + } + + if let payload = event.prettyPayload { + Section("Payload") { + Text(payload) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + + if !event.attachments.isEmpty { + Section("Attachments") { + attachmentsContent + } + } + } + .navigationTitle(event.eventName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + NavigationLink { + LogTraceView(store: store, origin: event) + } label: { + Label( + "Trace", + systemImage: "point.bottomleft.forward.to.point.topright.scurvepath", + ) + } + } + } + .task(id: Inputs(store: ObjectIdentifier(store), event: event.id)) { + attachments = nil + do { + attachments = try await .success(store.attachments(forEvent: event.id)) + } catch { + attachments = .failure(error) + } + } + } + + /// The identity of this view's inputs — re-keying the task reloads the + /// attachments when either changes in place. + private struct Inputs: Equatable { + let store: ObjectIdentifier + let event: UUID + } + + @ViewBuilder + private var attachmentsContent: some View { + switch attachments { + case nil: + ProgressView() + case let .failure(error): + Label(String(describing: error), systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + case let .success(loaded): + ForEach(Array(loaded.enumerated()), id: \.offset) { _, attachment in + LabeledContent(attachment.name) { + Text("\(attachment.contentType.mimeType) · \(attachment.data.count) bytes") + } + } + } + } +} + +extension StoredLogEvent { + /// The exit's freeform reason, from the payload (the mode is columnar, + /// the reason is not); `nil` when there is none or the payload no + /// longer decodes. + var exitReason: String? { + (try? decode(SpanEnded.self))?.exit.reason + } + + /// The stored payload, pretty-printed; `nil` when the event carried no + /// structured fields or the payload isn't JSON. + var prettyPayload: String? { + guard !payload.isEmpty, + let object = try? JSONSerialization.jsonObject(with: payload), + let data = try? JSONSerialization.data( + withJSONObject: object, + options: [.prettyPrinted, .sortedKeys], + ) + else { return nil } + return String(decoding: data, as: UTF8.self) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventRow.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventRow.swift new file mode 100644 index 00000000..da2a04a2 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventRow.swift @@ -0,0 +1,51 @@ +import PeriscopeCore +import SwiftUI + +/// The one-line event summary shared by the viewer, tracer, and inspector: +/// severity badge, event type, timestamp, message, and scope path. +struct LogEventRow: View { + let event: StoredLogEvent + let scopePath: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + LogLevelBadge(level: event.level) + if let exitMode = event.spanExitMode { + SpanExitBadge(mode: exitMode) + } + Text(event.eventName) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Text(event.date, format: .dateTime.hour().minute().second()) + .font(.caption2) + .monospacedDigit() + .foregroundStyle(.tertiary) + } + Text(event.message) + .font(.callout) + .lineLimit(3) + if !scopePath.isEmpty { + Text(scopePath) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } +} + +/// The severity badge shared by the viewer, tracer, and inspector. +struct LogLevelBadge: 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) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Components/LogLevel+Display.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogLevel+Display.swift new file mode 100644 index 00000000..b963f5ff --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Components/LogLevel+Display.swift @@ -0,0 +1,27 @@ +import PeriscopeCore +import SwiftUI + +extension LogLevel { + /// Capitalized name shown in badges and the level filter. + var displayName: String { + name.capitalized + } + + /// Uppercased label used in badges and export text. + var badgeLabel: String { + name.uppercased() + } + + /// Tint escalating with severity — banded so custom levels inherit a + /// sensible color from their position in the ladder. + var tint: Color { + switch severity { + case .., limit: Int = 500) -> some View { + modifier(LogInspectableModifier(scopes: log.scopes.map(\.id), limit: limit)) + } + + /// Inspectability keyed to a `LogContextProviding` model's instance + /// context — `.logInspectable(payment)`. + public func logInspectable( + _ provider: some LogContextProviding, + limit: Int = 500, + ) -> some View { + logInspectable(provider.log, limit: limit) + } +} + +struct LogInspectableModifier: ViewModifier { + let scopes: [ScopeID] + let limit: Int + + @Environment(\.periscopeInspector) private var inspector + @State private var isPresentingEvents = false + + func body(content: Content) -> some View { + content.overlay(alignment: .topTrailing) { + if let inspector, inspector.isEnabled { + Button("Inspect Logs", systemImage: "waveform.badge.magnifyingglass") { + isPresentingEvents = true + } + .labelStyle(.iconOnly) + .font(.caption) + .padding(4) + .background(.purple.opacity(0.85), in: .circle) + .foregroundStyle(.white) + .padding(2) + .sheet(isPresented: $isPresentingEvents) { + NavigationStack { + LogInspectorView(store: inspector.store, scopes: scopes, limit: limit) + } + } + } + } + } +} + +/// The sheet log view mode presents: the inspected context's events, newest +/// first, each linking into the standard event detail (and from there the +/// tracer). +struct LogInspectorView: View { + let store: PeriscopeStore + let scopes: [ScopeID] + let limit: Int + + @State private var model: LogInspectorModel + @Environment(\.dismiss) private var dismiss + + init(store: PeriscopeStore, scopes: [ScopeID], limit: Int) { + self.store = store + self.scopes = scopes + self.limit = limit + _model = State(initialValue: LogInspectorModel( + store: store, + inspectedScopes: scopes, + limit: limit, + )) + } + + /// The identity of this view's inputs — re-keying the task rebinds the + /// model when any changes in place. + private struct Inputs: Equatable { + let store: ObjectIdentifier + let scopes: [ScopeID] + let limit: Int + } + + var body: some View { + content + .navigationTitle("Element Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .task(id: Inputs(store: ObjectIdentifier(store), scopes: scopes, limit: limit)) { + if model.store !== store || model.inspectedScopes != scopes + || model.limit != limit + { + model = LogInspectorModel( + store: store, + inspectedScopes: scopes, + limit: limit, + ) + } + await model.run() + } + } + + @ViewBuilder + private var content: some View { + switch model.state { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .failed(reason): + ContentUnavailableView( + "Logs Unavailable", + systemImage: "exclamationmark.triangle", + description: Text(reason), + ) + case let .loaded(events) where events.isEmpty: + ContentUnavailableView( + "No Events", + systemImage: "doc.text.magnifyingglass", + description: Text("Nothing has been logged in this element's scopes."), + ) + case let .loaded(events): + List(events) { event in + NavigationLink { + LogEventDetailView( + event: event, + scopePath: model.scopePath(for: event), + store: store, + ) + } label: { + LogEventRow(event: event, scopePath: model.scopePath(for: event)) + } + } + .listStyle(.plain) + } + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectorModel.swift b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectorModel.swift new file mode 100644 index 00000000..8b0aaad9 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectorModel.swift @@ -0,0 +1,80 @@ +import Foundation +import Observation +import PeriscopeCore + +/// Drives the log-view-mode event list: everything stored in the subtrees +/// of an inspected view's scopes, merged newest first — "every event +/// associated with this element". +@MainActor +@Observable +final class LogInspectorModel { + enum LoadState { + case loading + case loaded([StoredLogEvent]) + case failed(String) + } + + /// Cap on the loaded events; the modifier default is 500. + let limit: Int + + /// Exposed so the hosting view can detect input swaps and rebuild. + let store: PeriscopeStore + let inspectedScopes: [ScopeID] + + private(set) var state: LoadState = .loading + private(set) var scopes: [ScopeID: LogScope] = [:] + + init(store: PeriscopeStore, inspectedScopes: [ScopeID], limit: Int) { + self.store = store + self.inspectedScopes = inspectedScopes + self.limit = limit + } + + var events: [StoredLogEvent] { + guard case let .loaded(events) = state else { return [] } + return events + } + + /// Initial load plus live refresh — run from `.task` so dismissing the + /// sheet cancels the stream. The changes stream is acquired *before* + /// the initial load so a commit landing mid-load can't fall into the + /// gap between loading and subscribing. + func run() async { + let changes = await store.changes() + await load() + for await _ in changes { + guard !Task.isCancelled else { return } + await load() + } + } + + func load() async { + do { + let scopeList = try await store.scopes() + scopes = Dictionary(uniqueKeysWithValues: scopeList.map { ($0.id, $0) }) + + var collected: [UUID: StoredLogEvent] = [:] + for scope in inspectedScopes { + var query = LogQuery() + query.scope = .subtree(scope) + query.limit = limit + for event in try await store.events(matching: query) { + collected[event.id] = event + } + } + let ordered = collected.values.sorted { lhs, rhs in + (lhs.date, lhs.sequence) > (rhs.date, rhs.sequence) + } + state = .loaded(Array(ordered.prefix(limit))) + } catch { + state = .failed(String(describing: error)) + } + } + + func scopePath(for event: StoredLogEvent) -> String { + guard let primary = event.primaryScope else { return "" } + return LogScope.ancestry(of: primary) { scopes[$0] } + .map(\.name) + .joined(separator: " / ") + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/InspectMode/PeriscopeInspector.swift b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/PeriscopeInspector.swift new file mode 100644 index 00000000..17fdaaa4 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/PeriscopeInspector.swift @@ -0,0 +1,70 @@ +import Observation +import PeriscopeCore +import SwiftUI + +/// The observable face of "log view mode": mirrors a `Periscope` system's +/// inspect flag both ways — toggling ``isEnabled`` writes through, and +/// direct writes to `Periscope.isInspectModeEnabled` (the source of truth) +/// flow back via its change stream — plus the store inspectable views +/// query. Inject one near the root and bind a developer-settings toggle to +/// it: +/// +/// ```swift +/// RootView() +/// .periscopeInspector(inspector) +/// +/// // in developer settings: +/// Toggle("Log View Mode", isOn: $inspector.isEnabled) +/// ``` +@MainActor +@Observable +public final class PeriscopeInspector { + public let system: Periscope + public let store: PeriscopeStore + + @ObservationIgnored private var observationTask: Task? + + /// Whether log view mode is on. Writes through to + /// `Periscope.isInspectModeEnabled`; the no-change guards on both + /// sides keep the mirror loop-free. + public var isEnabled: Bool { + didSet { + guard isEnabled != oldValue else { return } + system.isInspectModeEnabled = isEnabled + } + } + + public init(system: Periscope, store: PeriscopeStore) { + self.system = system + self.store = store + isEnabled = system.isInspectModeEnabled + observationTask = Task { [weak self, system] in + for await enabled in system.inspectModeChanges() { + guard let self else { return } + if isEnabled != enabled { + isEnabled = enabled + } + } + } + } + + deinit { + observationTask?.cancel() + } +} + +extension EnvironmentValues { + /// The inspector wired by ``SwiftUICore/View/periscopeInspector(_:)``; + /// `nil` where none was injected (inspectable views then render + /// unchanged). + @Entry public var periscopeInspector: PeriscopeInspector? +} + +extension View { + /// Make ``PeriscopeInspector`` available to every `logInspectable` view + /// below — typically applied once at the app root, gated to developer + /// builds. + public func periscopeInspector(_ inspector: PeriscopeInspector) -> some View { + environment(\.periscopeInspector, inspector) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Spans/OpenSpansView.swift b/Shared/Periscope/PeriscopeTools/Sources/Spans/OpenSpansView.swift new file mode 100644 index 00000000..4fe04dcc --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Spans/OpenSpansView.swift @@ -0,0 +1,87 @@ +import PeriscopeCore +import SwiftUI + +/// The open-spans developer surface: every span currently open via +/// `begin(for:)`, longest running first, with ticking ages, lifetimes, and +/// scope paths — "what's in flight right now, and is anything stuck". +/// +/// Reads the `Periscope` system directly (open spans are system state, not +/// store history) and re-snapshots once a second. Designed to be pushed +/// inside an existing `NavigationStack` from a developer menu. +public struct OpenSpansView: View { + private let system: Periscope + + public init(system: Periscope) { + self.system = system + } + + public var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { _ in + content(spans: system.openSpans(), now: ContinuousClock().now) + } + .navigationTitle("Open Spans") + .navigationBarTitleDisplayMode(.inline) + } + + @ViewBuilder + private func content(spans: [OpenSpan], now: ContinuousClock.Instant) -> some View { + if spans.isEmpty { + ContentUnavailableView( + "No Open Spans", + systemImage: "point.bottomleft.forward.to.point.topright.scurvepath", + description: Text("Spans opened with begin(for:) appear here while they run."), + ) + } else { + List(spans, id: \.id) { span in + OpenSpanRow(span: span, now: now, scopePath: scopePath(for: span)) + } + .listStyle(.plain) + } + } + + private func scopePath(for span: OpenSpan) -> String { + guard let primary = span.scopes.first else { return "" } + return LogScope.ancestry(of: primary) { system.scope(for: $0) } + .map(\.name) + .joined(separator: " / ") + } +} + +private struct OpenSpanRow: View { + let span: OpenSpan + let now: ContinuousClock.Instant + let scopePath: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(span.name) + .font(.callout.weight(.medium)) + Spacer() + Text((now - span.start).formatted()) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + } + HStack(spacing: 8) { + Text(lifetimeLabel) + .font(.caption2) + .foregroundStyle(.secondary) + if !scopePath.isEmpty { + Text(scopePath) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .padding(.vertical, 2) + } + + private var lifetimeLabel: String { + switch span.lifetime { + case .scoped: "scoped" + case let .bounded(budget): "budget \(budget.formatted())" + case .indefinite: "indefinite" + } + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceModel.swift b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceModel.swift new file mode 100644 index 00000000..f0fed153 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceModel.swift @@ -0,0 +1,101 @@ +import Foundation +import Observation +import PeriscopeCore + +/// Drives ``LogTraceView``: from an origin event, collects the events that +/// led up to it — everything earlier in the subtrees of the origin's scopes +/// (all of them, so linked model + UI contexts both trace), everything +/// logged directly at their ancestor scopes on the way up the tree, and the +/// origin's span pair — merged newest first. +@MainActor +@Observable +final class LogTraceModel { + enum LoadState { + case loading + case loaded([StoredLogEvent]) + case failed(String) + } + + /// Cap on the assembled trail (and on each underlying query); the + /// view default is 500. + let limit: Int + + let origin: StoredLogEvent + /// Exposed so the hosting view can detect a store swap and rebuild. + let store: PeriscopeStore + + private(set) var state: LoadState = .loading + private(set) var scopes: [ScopeID: LogScope] = [:] + + init(store: PeriscopeStore, origin: StoredLogEvent, limit: Int) { + self.store = store + self.origin = origin + self.limit = limit + } + + /// The trail leading up to the origin (origin itself excluded), newest + /// first. + var trail: [StoredLogEvent] { + guard case let .loaded(events) = state else { return [] } + return events + } + + func load() async { + do { + let scopeList = try await store.scopes() + scopes = Dictionary(uniqueKeysWithValues: scopeList.map { ($0.id, $0) }) + + var collected: [UUID: StoredLogEvent] = [:] + if let span = origin.spanID { + for event in try await store.events(inSpan: span) { + collected[event.id] = event + } + } + for filter in traceFilters() { + var query = LogQuery() + query.end = origin.date + query.scope = filter + query.limit = limit + for event in try await store.events(matching: query) { + collected[event.id] = event + } + } + + // "Leading up to it" means strictly before: the date-bounded + // queries are inclusive, so same-millisecond events that landed + // *after* the origin (higher insertion sequence) — and the + // origin itself — are trimmed here, where the ordering key + // already lives. + let ordered = collected.values + .filter { ($0.date, $0.sequence) < (origin.date, origin.sequence) } + .sorted { lhs, rhs in + (lhs.date, lhs.sequence) > (rhs.date, rhs.sequence) + } + state = .loaded(Array(ordered.prefix(limit))) + } catch { + state = .failed(String(describing: error)) + } + } + + func scopePath(for event: StoredLogEvent) -> String { + guard let primary = event.primaryScope else { return "" } + return LogScope.ancestry(of: primary) { scopes[$0] } + .map(\.name) + .joined(separator: " / ") + } + + /// Subtree filters for each of the origin's scopes (events within the + /// same contexts), plus exact filters for every ancestor on the way to + /// each root (the enclosing layers' own events) — but not siblings. + private func traceFilters() -> [ScopeFilter] { + var filters = origin.scopes.map(ScopeFilter.subtree) + for scope in origin.scopes { + var next = scopes[scope]?.parentID + while let id = next { + filters.append(.exactly(id)) + next = scopes[id]?.parentID + } + } + return filters + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceView.swift b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceView.swift new file mode 100644 index 00000000..59b9169b --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceView.swift @@ -0,0 +1,80 @@ +import PeriscopeCore +import SwiftUI + +/// The log tracer: starting from one event (typically an error), shows the +/// events that led up to it — back through time, across its linked scopes, +/// and up the scope tree — so an error can be followed to its origin. +/// Tapping a trail event opens its detail, from which tracing can continue +/// further back. +/// +/// Designed to be pushed inside an existing `NavigationStack`. +public struct LogTraceView: View { + private let store: PeriscopeStore + private let origin: StoredLogEvent + private let limit: Int + @State private var model: LogTraceModel + + public init(store: PeriscopeStore, origin: StoredLogEvent, limit: Int = 500) { + self.store = store + self.origin = origin + self.limit = limit + _model = State(initialValue: LogTraceModel(store: store, origin: origin, limit: limit)) + } + + /// The identity of this view's inputs — re-keying the task rebinds the + /// model when any changes in place. + private struct Inputs: Equatable { + let store: ObjectIdentifier + let origin: UUID + let limit: Int + } + + public var body: some View { + List { + Section("Origin") { + LogEventRow( + event: model.origin, + scopePath: model.scopePath(for: model.origin), + ) + } + Section("Leading up to it") { + content + } + } + .listStyle(.plain) + .navigationTitle("Trace") + .navigationBarTitleDisplayMode(.inline) + .task(id: Inputs(store: ObjectIdentifier(store), origin: origin.id, limit: limit)) { + if model.store !== store || model.origin.id != origin.id || model.limit != limit { + model = LogTraceModel(store: store, origin: origin, limit: limit) + } + await model.load() + } + } + + @ViewBuilder + private var content: some View { + switch model.state { + case .loading: + ProgressView() + case let .failed(reason): + Label(reason, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + case let .loaded(trail) where trail.isEmpty: + Text("No earlier events in this event's scopes.") + .foregroundStyle(.secondary) + case let .loaded(trail): + ForEach(trail) { event in + NavigationLink { + LogEventDetailView( + event: event, + scopePath: model.scopePath(for: event), + store: store, + ) + } label: { + LogEventRow(event: event, scopePath: model.scopePath(for: event)) + } + } + } + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift new file mode 100644 index 00000000..c10f39a2 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift @@ -0,0 +1,99 @@ +import Foundation +import PeriscopeCore + +/// Renders stored events as NDJSON (one JSON object per line, oldest first) +/// for attaching to bug reports. Structured payloads embed as nested JSON; +/// keys are sorted so output is deterministic. +enum NDJSONExporter { + private static let timestampFormat = Date.ISO8601FormatStyle( + includingFractionalSeconds: true, + ) + + /// `events` newest first (as queried); the export reads chronologically. + static func export(events: [StoredLogEvent], scopes: [ScopeID: LogScope]) -> String { + events.reversed() + .map { line(for: $0, scopes: scopes) } + .joined(separator: "\n") + } + + static func line(for event: StoredLogEvent, scopes: [ScopeID: LogScope]) -> String { + var object: [String: Any] = [ + "date": event.date.formatted(timestampFormat), + "level": event.level.name, + "severity": event.level.severity, + "event": event.eventName, + "version": event.eventVersion, + "message": event.message, + "session": event.sessionID.uuidString, + ] + let path = scopePath(for: event, scopes: scopes) + if !path.isEmpty { + object["scopePath"] = path + } + if !event.tags.isEmpty { + object["tags"] = Dictionary( + uniqueKeysWithValues: event.tags + .map { ($0.key.rawValue, jsonValue(for: $0.value)) }, + ) + } + if let span = event.spanID { + object["span"] = span.rawValue.uuidString + } + if let exitMode = event.spanExitMode { + object["spanExit"] = exitMode.rawValue + } + if let callSite = event.callSite { + object["function"] = callSite.function + object["file"] = callSite.fileID + } + if let externalID = event.externalID { + object["externalID"] = externalID + } + if !event.payload.isEmpty { + if let payload = try? JSONSerialization.jsonObject(with: event.payload) { + object["payload"] = payload + } else { + // Persisted payloads are JSONEncoder output, so this means + // on-disk corruption — the export must say the payload + // existed and didn't survive, not silently omit the key. + object["payloadError"] = "unparseable (\(event.payload.count) bytes)" + } + } + guard let data = try? JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys], + ) else { + // Every value above is a JSON-safe type; failing to serialize is + // a programmer error. + assertionFailure("NDJSON line failed to serialize") + return "{}" + } + return String(decoding: data, as: UTF8.self) + } + + /// A tag value as its native JSON type — numbers stay numbers, bools + /// stay bools; an `.encoded` payload embeds as parsed JSON when it + /// parses, its raw string otherwise. + private static func jsonValue(for value: LogTagValue) -> Any { + switch value { + case let .string(string): string + case let .int(int): int + case let .double(double): double + case let .bool(bool): bool + case let .encoded(json): + (try? JSONSerialization.jsonObject( + with: Data(json.utf8), + options: [.fragmentsAllowed], + )) ?? json + } + } + + /// The primary scope's path (root → leaf), e.g. `"app/photos/album-1"` + /// — exports join with `"/"` where display surfaces use `" / "`. + static func scopePath(for event: StoredLogEvent, scopes: [ScopeID: LogScope]) -> String { + guard let primary = event.primaryScope else { return "" } + return LogScope.ancestry(of: primary) { scopes[$0] } + .map(\.name) + .joined(separator: "/") + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift new file mode 100644 index 00000000..2e821c7a --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift @@ -0,0 +1,186 @@ +import PeriscopeCore +import SwiftUI + +/// The latest-logs viewer: a reverse-chronological, searchable list over a +/// `PeriscopeStore`, filterable by level, event type, scope subtree, and +/// session, with NDJSON export for bug reports. +/// +/// Designed to be pushed inside an existing `NavigationStack` (it sets a +/// navigation title and toolbar but does not create its own stack). A +/// developer surface — gate it behind `#if DEBUG` or a developer menu. +public struct PeriscopeViewer: View { + private let store: PeriscopeStore + private let title: String + @State private var model: PeriscopeViewerModel + @State private var export: NDJSONExport? + @State private var exportFailed = false + + public init(store: PeriscopeStore, title: String = "Logs") { + self.store = store + self.title = title + _model = State(initialValue: PeriscopeViewerModel(store: store)) + } + + public var body: some View { + @Bindable var model = model + content + .navigationTitle(title) + .toolbar { + ToolbarItem(placement: .primaryAction) { + filterMenu + } + ToolbarItem(placement: .topBarTrailing) { + exportButton + } + } + .searchable(text: $model.searchText) + .sheet(item: $export) { export in + NDJSONExportSheet(export: export) + } + .alert("Export Failed", isPresented: $exportFailed) { + Button("OK", role: .cancel) {} + } + // Keyed on the store's identity: swapping stores in place + // cancels the old model's live stream and rebinds a fresh model + // — `State(initialValue:)` alone would keep serving the first + // store forever. Export state is per-store too: a sheet or + // failure alert generated against the old store shouldn't + // survive the swap. + .task(id: ObjectIdentifier(store)) { + if model.store !== store { + model = PeriscopeViewerModel(store: store) + export = nil + exportFailed = false + } + await model.run() + } + } + + @ViewBuilder + private var content: some View { + switch model.state { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case let .failed(reason): + ContentUnavailableView( + "Logs Unavailable", + systemImage: "exclamationmark.triangle", + description: Text(reason), + ) + case let .loaded(events) where events.isEmpty: + ContentUnavailableView( + "No Logs", + systemImage: "doc.text.magnifyingglass", + description: Text("No stored events match the current filters."), + ) + case let .loaded(events): + List { + ForEach(events) { event in + NavigationLink { + LogEventDetailView( + event: event, + scopePath: model.scopePath(for: event), + store: store, + ) + } label: { + LogEventRow(event: event, scopePath: model.scopePath(for: event)) + } + } + if model.canLoadMore { + Button("Load More") { + Task { await model.loadMore() } + } + .frame(maxWidth: .infinity) + } + } + .listStyle(.plain) + } + } + + private var filterMenu: some View { + @Bindable var model = model + return Menu { + Picker("Level", selection: $model.minimumLevel) { + Text("All Levels").tag(LogLevel?.none) + ForEach(model.availableLevels, id: \.self) { level in + Text(level.displayName).tag(LogLevel?.some(level)) + } + } + Picker("Event", selection: $model.selectedEventName) { + Text("All Events").tag(String?.none) + ForEach(model.eventNames, id: \.self) { name in + Text(name).tag(String?.some(name)) + } + } + Picker("Scope", selection: $model.selectedScope) { + Text("All Scopes").tag(ScopeID?.none) + ForEach(model.scopeChoices) { choice in + Text(choice.path).tag(ScopeID?.some(choice.id)) + } + } + Picker("Session", selection: $model.selectedSessionID) { + Text("All Sessions").tag(UUID?.none) + ForEach(model.sessions) { session in + Text(sessionLabel(session)).tag(UUID?.some(session.id)) + } + } + Picker("Span Exit", selection: $model.selectedSpanExitMode) { + Text("All Events").tag(SpanExit.Mode?.none) + ForEach(SpanExit.Mode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(SpanExit.Mode?.some(mode)) + } + } + } label: { + Label("Filter", systemImage: "line.3.horizontal.decrease.circle") + } + } + + private var exportButton: some View { + Button { + Task { + do { + export = try await NDJSONExport(text: model.exportNDJSON()) + } catch { + exportFailed = true + } + } + } label: { + Label("Export", systemImage: "square.and.arrow.up") + } + } + + private func sessionLabel(_ session: LogSession) -> String { + let started = session.startedAt.formatted(date: .abbreviated, time: .shortened) + return "\(started) — v\(session.appVersion) (\(session.buildNumber))" + } +} + +/// A generated NDJSON export, presented in a share sheet. +struct NDJSONExport: Identifiable { + let id = UUID() + let text: String +} + +private struct NDJSONExportSheet: View { + let export: NDJSONExport + + var body: some View { + NavigationStack { + ScrollView { + Text(export.text) + .font(.caption.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle("NDJSON Export") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + ShareLink(item: export.text, preview: SharePreview("Periscope Logs")) + } + } + } + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewerModel.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewerModel.swift new file mode 100644 index 00000000..bde072f4 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewerModel.swift @@ -0,0 +1,182 @@ +import Foundation +import Observation +import PeriscopeCore + +/// Drives ``PeriscopeViewer``: pages `PeriscopeStore` queries into +/// observable state, re-querying when filters change or the store commits +/// new events. +@MainActor +@Observable +final class PeriscopeViewerModel { + /// One load's outcome — loading, a page stack, or an honest failure. + enum LoadState { + case loading + case loaded([StoredLogEvent]) + case failed(String) + } + + /// A scope option in the filter menu, labeled by its full path. + struct ScopeChoice: Identifiable, Hashable { + let id: ScopeID + let path: String + } + + static let pageSize = 200 + + /// Exposed so the hosting view can detect a store swap and rebuild. + let store: PeriscopeStore + @ObservationIgnored private var reloadTask: Task? + @ObservationIgnored private var generation = 0 + + private(set) var state: LoadState = .loading + private(set) var scopes: [ScopeID: LogScope] = [:] + private(set) var sessions: [LogSession] = [] + private(set) var canLoadMore = false + + var searchText = "" { + didSet { if searchText != oldValue { scheduleReload() } } + } + + /// `nil` shows every level. + var minimumLevel: LogLevel? { + didSet { if minimumLevel != oldValue { scheduleReload() } } + } + + /// `nil` shows every event type. + var selectedEventName: String? { + didSet { if selectedEventName != oldValue { scheduleReload() } } + } + + /// `nil` shows every session. + var selectedSessionID: UUID? { + didSet { if selectedSessionID != oldValue { scheduleReload() } } + } + + /// `nil` shows every scope; set filters to that scope's subtree. + var selectedScope: ScopeID? { + didSet { if selectedScope != oldValue { scheduleReload() } } + } + + /// `nil` shows everything; set restricts to span-ended events with + /// that exit ("everything that failed"). + var selectedSpanExitMode: SpanExit.Mode? { + didSet { if selectedSpanExitMode != oldValue { scheduleReload() } } + } + + init(store: PeriscopeStore) { + self.store = store + } + + /// The loaded page stack (empty while loading or failed). + var events: [StoredLogEvent] { + guard case let .loaded(events) = state else { return [] } + return events + } + + /// Distinct event names among loaded events, for the type filter. + var eventNames: [String] { + Array(Set(events.map(\.eventName))).sorted() + } + + /// The standard ladder plus any custom levels present in loaded events. + var availableLevels: [LogLevel] { + Array(Set(LogLevel.standardLevels + events.map(\.level))).sorted() + } + + /// Every known scope, labeled by full path, for the scope filter. + var scopeChoices: [ScopeChoice] { + scopes.keys + .map { ScopeChoice(id: $0, path: path(for: $0)) } + .sorted { $0.path < $1.path } + } + + /// Initial load plus live refresh — run from `.task` so leaving the + /// screen cancels the stream. The changes stream is acquired *before* + /// the initial load: a commit landing mid-load buffers in the stream + /// and triggers a refresh, instead of falling into the gap between + /// loading and subscribing. + func run() async { + let changes = await store.changes() + await load() + for await _ in changes { + guard !Task.isCancelled else { return } + await load() + } + } + + /// Query the first page for the active filters, plus the scope and + /// session catalogs the filter menus need. + func load() async { + generation += 1 + let requested = generation + do { + var query = activeQuery + query.limit = Self.pageSize + let page = try await store.events(matching: query) + let scopeList = try await store.scopes() + let sessionList = try await store.sessions() + guard requested == generation else { return } + state = .loaded(page) + scopes = Dictionary(uniqueKeysWithValues: scopeList.map { ($0.id, $0) }) + sessions = sessionList + canLoadMore = page.count == Self.pageSize + } catch { + guard requested == generation else { return } + state = .failed(String(describing: error)) + } + } + + /// Fetch and append the next page. + func loadMore() async { + guard case let .loaded(current) = state, canLoadMore else { return } + let requested = generation + do { + var query = activeQuery + query.limit = Self.pageSize + query.offset = current.count + let next = try await store.events(matching: query) + guard requested == generation else { return } + state = .loaded(current + next) + canLoadMore = next.count == Self.pageSize + } catch { + guard requested == generation else { return } + state = .failed(String(describing: error)) + } + } + + /// Every event matching the active filters (unpaged), as NDJSON. + func exportNDJSON() async throws -> String { + let all = try await store.events(matching: activeQuery) + return NDJSONExporter.export(events: all, scopes: scopes) + } + + /// The primary scope's path for a row's caption. + func scopePath(for event: StoredLogEvent) -> String { + guard let primary = event.primaryScope else { return "" } + return path(for: primary) + } + + private func path(for scope: ScopeID) -> String { + LogScope.ancestry(of: scope) { scopes[$0] } + .map(\.name) + .joined(separator: " / ") + } + + private var activeQuery: LogQuery { + var query = LogQuery() + query.minimumLevel = minimumLevel + query.eventName = selectedEventName + query.sessionID = selectedSessionID + query.scope = selectedScope.map(ScopeFilter.subtree) + query.spanExitMode = selectedSpanExitMode + query.messageContains = searchText.isEmpty ? nil : searchText + return query + } + + private func scheduleReload() { + reloadTask?.cancel() + reloadTask = Task { [weak self] in + await self?.load() + } + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift new file mode 100644 index 00000000..9f0c4855 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift @@ -0,0 +1,117 @@ +import Foundation +import os +import PeriscopeCore +@testable import PeriscopeTools +import Testing +import UserNotifications + +private struct FakeAuthorizationError: Error {} + +/// An `AlertNotificationCenter` that records authorization traffic and +/// posted identifiers, with scriptable grant/failure outcomes. +private final class FakeAlertCenter: AlertNotificationCenter, Sendable { + private struct State { + var grantsAuthorization = true + var authorizationFails = false + var authorizationRequestCount = 0 + var addedIdentifiers: [String] = [] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + var authorizationRequestCount: Int { + state.withLock(\.authorizationRequestCount) + } + + var addedIdentifiers: [String] { + state.withLock(\.addedIdentifiers) + } + + func setGrantsAuthorization(_ grants: Bool) { + state.withLock { $0.grantsAuthorization = grants } + } + + func setAuthorizationFails(_ fails: Bool) { + state.withLock { $0.authorizationFails = fails } + } + + func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool { + try state.withLock { state in + state.authorizationRequestCount += 1 + if state.authorizationFails { + throw FakeAuthorizationError() + } + return state.grantsAuthorization + } + } + + func add(_ request: sending UNNotificationRequest) async throws { + let identifier = request.identifier + state.withLock { $0.addedIdentifiers.append(identifier) } + } +} + +@MainActor +struct LocalNotificationAlertHandlerTests { + private let center = FakeAlertCenter() + + private func makeRecord(_ message: String) -> LogRecord { + LogRecord( + date: Date(), + event: Message(level: .error, message), + scopes: [LogScope.root(named: "app").id], + ) + } + + @Test func requestsCarryTheRecordsSeverityAndMessage() { + let record = LogRecord( + date: Date(), + event: Message(level: .error, "Upload failed"), + scopes: [LogScope.root(named: "app").id], + ) + + let request = LocalNotificationAlertHandler.request(for: record) + + #expect(request.content.title == "Error: message") + #expect(request.content.body == "Upload failed") + #expect(request.identifier == "periscope-alert-\(record.id.uuidString)") + #expect(request.trigger == nil) + } + + @Test func authorizationIsRequestedOnceAcrossPosts() async { + let handler = LocalNotificationAlertHandler(center: center) + + await handler.post(for: makeRecord("one")) + await handler.post(for: makeRecord("two")) + await handler.post(for: makeRecord("three")) + + #expect(center.authorizationRequestCount == 1) + #expect(center.addedIdentifiers.count == 3) + } + + @Test func deniedAuthorizationGoesQuietWithoutReasking() async { + center.setGrantsAuthorization(false) + let handler = LocalNotificationAlertHandler(center: center) + + await handler.post(for: makeRecord("one")) + await handler.post(for: makeRecord("two")) + + #expect(center.authorizationRequestCount == 1) + #expect(center.addedIdentifiers.isEmpty) + } + + @Test func failedAuthorizationRequestsRetryOnTheNextAlert() async { + center.setAuthorizationFails(true) + let handler = LocalNotificationAlertHandler(center: center) + + await handler.post(for: makeRecord("during failure")) + #expect(center.authorizationRequestCount == 1) + #expect(center.addedIdentifiers.isEmpty) + + // A transient failure must not be cached as denial. + center.setAuthorizationFails(false) + await handler.post(for: makeRecord("after recovery")) + #expect(center.authorizationRequestCount == 2) + #expect(center.addedIdentifiers.count == 1) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift new file mode 100644 index 00000000..14376385 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift @@ -0,0 +1,69 @@ +import Foundation +import PeriscopeCore +@testable import PeriscopeTools +import Testing + +struct LogEventDetailViewTests { + private func stored( + eventName: String = "message", + payload: Data = Data(), + ) -> StoredLogEvent { + StoredLogEvent( + id: UUID(), + date: date(1), + sequence: 0, + level: .info, + eventName: eventName, + eventVersion: 1, + message: "hello", + payload: payload, + scopes: [LogScope.root(named: "app").id], + tags: [], + spanID: nil, + spanExitMode: nil, + callSite: nil, + externalID: nil, + attachments: [], + sessionID: UUID(), + ) + } + + @Test func exitReasonDecodesFromThePayload() throws { + let ended = SpanEnded( + spanID: SpanID(), + name: "checkout", + duration: nil, + exit: .failure("card declined"), + ) + let event = try stored(eventName: "span-ended", payload: JSONEncoder().encode(ended)) + + #expect(event.exitReason == "card declined") + } + + @Test func exitReasonIsNilWithoutAReason() throws { + let ended = SpanEnded(spanID: SpanID(), name: "checkout", duration: nil, exit: .success) + let event = try stored(eventName: "span-ended", payload: JSONEncoder().encode(ended)) + + #expect(event.exitReason == nil) + } + + @Test func exitReasonIsNilWhenThePayloadDoesNotDecode() { + #expect(stored(payload: Data()).exitReason == nil) + #expect(stored(payload: Data([0xFF, 0x00])).exitReason == nil) + } + + @Test func prettyPayloadIndentsAndSortsKeys() throws { + let payload = try JSONEncoder().encode(["zebra": 1, "apple": 2]) + let pretty = try #require(stored(payload: payload).prettyPayload) + + #expect(pretty.contains("\n")) + let apple = try #require(pretty.range(of: "\"apple\"")) + let zebra = try #require(pretty.range(of: "\"zebra\"")) + #expect(apple.lowerBound < zebra.lowerBound) + } + + @Test func prettyPayloadIsNilForEmptyOrGarbagePayloads() { + #expect(stored(payload: Data()).prettyPayload == nil) + #expect(stored(payload: Data([0xFF, 0x00])).prettyPayload == nil) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogInspectableHostingTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogInspectableHostingTests.swift new file mode 100644 index 00000000..34dd773c --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LogInspectableHostingTests.swift @@ -0,0 +1,53 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +@testable import PeriscopeTools +import SwiftUI +import TestHostSupport +import Testing +import UIKit + +@MainActor +struct LogInspectableHostingTests { + private func makeInspector() async throws -> PeriscopeInspector { + let store = try await PeriscopeStore.inMemory(session: makeSession()) + let system = Periscope(configuration: Periscope.Configuration(), sinks: []) + return PeriscopeInspector(system: system, store: store) + } + + @Test func inspectableViewsHostWithTheModeOff() async throws { + let inspector = try await makeInspector() + let log = Log(system: inspector.system) + + let host = UIHostingController(rootView: Text("Payment Row") + .logInspectable(log) + .periscopeInspector(inspector)) + try show(host) { _ in + try waitFor { host.view.window != nil } + } + } + + @Test func inspectableViewsHostWithTheModeOn() async throws { + let inspector = try await makeInspector() + inspector.isEnabled = true + let log = Log(system: inspector.system) + + let host = UIHostingController(rootView: Text("Payment Row") + .logInspectable(log) + .periscopeInspector(inspector)) + try show(host) { _ in + try waitFor { host.view.window != nil } + } + } + + @Test func inspectorViewHostsOverSeededEvents() async throws { + let (store, _, photos, album) = try await makeSeededStore() + await store.write([makeRecord("deep", date: date(1), scopes: [album.id])]) + + let host = UIHostingController(rootView: NavigationStack { + LogInspectorView(store: store, scopes: [photos.id], limit: 500) + }) + try show(host) { _ in + try waitFor { host.view.window != nil } + } + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift new file mode 100644 index 00000000..d9fc71da --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift @@ -0,0 +1,72 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +@testable import PeriscopeTools +import Testing + +@MainActor +struct LogInspectorModelTests { + @Test func collectsSubtreeEventsNewestFirst() async throws { + let (store, root, photos, album) = try await makeSeededStore() + await store.write([ + makeRecord("elsewhere", date: date(1), scopes: [root.id]), + makeRecord("at photos", date: date(2), scopes: [photos.id]), + makeRecord("in the album", date: date(3), scopes: [album.id]), + ]) + + let model = LogInspectorModel(store: store, inspectedScopes: [photos.id], limit: 500) + await model.load() + + #expect(model.events.map(\.message) == ["in the album", "at photos"]) + } + + @Test func mergesEventsAcrossLinkedScopes() async throws { + let (store, _, photos, album) = try await makeSeededStore() + let screen = LogScope.root(named: "detail-screen") + await store.defineScopes([screen]) + await store.write([ + makeRecord("model side", date: date(1), scopes: [album.id]), + makeRecord("ui side", date: date(2), scopes: [screen.id]), + ]) + + let model = LogInspectorModel( + store: store, + inspectedScopes: [photos.id, screen.id], + limit: 500, + ) + await model.load() + + #expect(model.events.map(\.message) == ["ui side", "model side"]) + } + + @Test func emptyScopesLoadAsEmpty() async throws { + let (store, _, photos, _) = try await makeSeededStore() + let model = LogInspectorModel(store: store, inspectedScopes: [photos.id], limit: 500) + await model.load() + #expect(model.events.isEmpty) + if case .failed = model.state { + Issue.record("An empty subtree should load as empty, not fail") + } + } + + @Test func runRefreshesLiveWhenTheStoreCommits() async throws { + let (store, _, photos, album) = try await makeSeededStore() + let model = LogInspectorModel(store: store, inspectedScopes: [photos.id], limit: 500) + let task = Task { await model.run() } + defer { task.cancel() } + + await store.write([makeRecord("live", date: date(1), scopes: [album.id])]) + let shown = await waitUntil { model.events.map(\.message) == ["live"] } + #expect(shown) + } + + @Test func scopePathsResolve() async throws { + let (store, _, photos, album) = try await makeSeededStore() + await store.write([makeRecord("deep", date: date(1), scopes: [album.id])]) + + let model = LogInspectorModel(store: store, inspectedScopes: [photos.id], limit: 500) + await model.load() + + let event = try #require(model.events.first) + #expect(model.scopePath(for: event) == "app / photos / album-1") + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift new file mode 100644 index 00000000..db8a742c --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift @@ -0,0 +1,139 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +@testable import PeriscopeTools +import Testing + +@MainActor +struct LogTraceModelTests { + private func originEvent(in store: PeriscopeStore) async throws -> StoredLogEvent { + try #require(try await store.events(matching: LogQuery()).first) + } + + @Test func traceCollectsEarlierEventsInTheSameScopeNewestFirst() async throws { + let (store, _, _, album) = try await makeSeededStore() + await store.write([ + makeRecord("earlier", date: date(1), scopes: [album.id]), + makeRecord("later", date: date(2), scopes: [album.id]), + makeRecord("origin error", level: .error, date: date(3), scopes: [album.id]), + makeRecord("after the origin", date: date(4), scopes: [album.id]), + ]) + let origin = try await originEvent(in: store) + #expect(origin.message == "after the origin") + + // Trace from the error, not the newest event. + let error = try #require(try await store.events(matching: LogQuery()) + .first { $0.message == "origin error" }) + let model = LogTraceModel(store: store, origin: error, limit: 500) + await model.load() + + #expect(model.trail.map(\.message) == ["later", "earlier"]) + } + + @Test func traceWalksUpAncestorsButNotIntoSiblings() async throws { + let (store, root, photos, album) = try await makeSeededStore() + let sibling = photos.child(named: "album-2") + await store.defineScopes([sibling]) + await store.write([ + makeRecord("at root", date: date(1), scopes: [root.id]), + makeRecord("at photos", date: date(2), scopes: [photos.id]), + makeRecord("in the sibling", date: date(3), scopes: [sibling.id]), + makeRecord("origin", level: .error, date: date(4), scopes: [album.id]), + ]) + let origin = try await originEvent(in: store) + let model = LogTraceModel(store: store, origin: origin, limit: 500) + await model.load() + + #expect(model.trail.map(\.message) == ["at photos", "at root"]) + } + + @Test func traceFollowsLinkedScopes() async throws { + let (store, _, _, album) = try await makeSeededStore() + let screen = LogScope.root(named: "detail-screen") + await store.defineScopes([screen]) + await store.write([ + makeRecord("ui context", date: date(1), scopes: [screen.id]), + makeRecord("origin", level: .error, date: date(2), scopes: [album.id, screen.id]), + ]) + let origin = try await originEvent(in: store) + let model = LogTraceModel(store: store, origin: origin, limit: 500) + await model.load() + + #expect(model.trail.map(\.message) == ["ui context"]) + } + + @Test func traceIncludesTheOriginsSpanPair() async throws { + let (store, root, _, album) = try await makeSeededStore() + let span = SpanID() + await store.write([ + LogRecord( + date: date(1), + event: SpanBegan( + spanID: span, + name: "save", + lifetime: .indefinite, + relaunchPolicy: .endsWithProcess, + ), + scopes: [root.id], + ), + LogRecord( + date: date(2), + event: SpanEnded( + spanID: span, + name: "save", + duration: .seconds(1), + exit: .success, + ), + scopes: [album.id], + ), + ]) + let origin = try await originEvent(in: store) + #expect(origin.spanID == span) + + let model = LogTraceModel(store: store, origin: origin, limit: 500) + await model.load() + + #expect(model.trail.contains { $0.spanID == span && $0.eventName == "span-began" }) + } + + @Test func sameMillisecondEventsAfterTheOriginAreExcluded() async throws { + let (store, _, _, album) = try await makeSeededStore() + let sharedInstant = date(5) + await store.write([ + makeRecord("before", date: sharedInstant, scopes: [album.id]), + makeRecord("origin", level: .error, date: sharedInstant, scopes: [album.id]), + makeRecord("after", date: sharedInstant, scopes: [album.id]), + ]) + let origin = try #require(try await store.events(matching: LogQuery()) + .first { $0.message == "origin" }) + + let model = LogTraceModel(store: store, origin: origin, limit: 500) + await model.load() + + #expect(model.trail.map(\.message) == ["before"]) + } + + @Test func traceExcludesTheOriginItself() async throws { + let (store, _, _, album) = try await makeSeededStore() + await store.write([makeRecord("origin", date: date(1), scopes: [album.id])]) + let origin = try await originEvent(in: store) + + let model = LogTraceModel(store: store, origin: origin, limit: 500) + await model.load() + + #expect(model.trail.isEmpty) + } + + @Test func scopePathsResolveForTrailRows() async throws { + let (store, _, photos, album) = try await makeSeededStore() + await store.write([ + makeRecord("context", date: date(1), scopes: [photos.id]), + makeRecord("origin", date: date(2), scopes: [album.id]), + ]) + let origin = try await originEvent(in: store) + let model = LogTraceModel(store: store, origin: origin, limit: 500) + await model.load() + + let context = try #require(model.trail.first) + #expect(model.scopePath(for: context) == "app / photos") + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogTraceViewHostingTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogTraceViewHostingTests.swift new file mode 100644 index 00000000..a327e326 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LogTraceViewHostingTests.swift @@ -0,0 +1,26 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import PeriscopeTools +import SwiftUI +import TestHostSupport +import Testing +import UIKit + +@MainActor +struct LogTraceViewHostingTests { + @Test func tracerHostsFromAnOriginEvent() async throws { + let (store, _, _, album) = try await makeSeededStore() + await store.write([ + makeRecord("context", date: date(1), scopes: [album.id]), + makeRecord("origin", level: .error, date: date(2), scopes: [album.id]), + ]) + let origin = try #require(try await store.events(matching: LogQuery()).first) + + let host = UIHostingController(rootView: NavigationStack { + LogTraceView(store: store, origin: origin) + }) + try show(host) { _ in + try waitFor { host.view.window != nil } + } + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift new file mode 100644 index 00000000..6842e9af --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift @@ -0,0 +1,136 @@ +import Foundation +import PeriscopeCore +@testable import PeriscopeTools +import Testing + +struct NDJSONExporterTests { + private let root = LogScope.root(named: "app") + private let sessionID = UUID() + + private var scopes: [ScopeID: LogScope] { + let photos = root.child(named: "photos") + return [root.id: root, photos.id: photos] + } + + private func stored( + message: String, + date: Date, + payload: Data = Data(), + tags: [LogTag] = [], + spanExitMode: SpanExit.Mode? = nil, + ) -> StoredLogEvent { + StoredLogEvent( + id: UUID(), + date: date, + sequence: 0, + level: .warning, + eventName: "message", + eventVersion: 1, + message: message, + payload: payload, + scopes: [root.child(named: "photos").id], + tags: tags, + spanID: nil, + spanExitMode: spanExitMode, + callSite: nil, + externalID: nil, + attachments: [], + sessionID: sessionID, + ) + } + + @Test func unparseablePayloadsAreMarkedNotOmitted() throws { + // Persisted payloads are JSONEncoder output, so garbage bytes mean + // on-disk corruption — the export line must say a payload existed + // and didn't survive, not silently drop the key. + let line = NDJSONExporter.line( + for: stored(message: "hello", date: date(1), payload: Data([0xFF, 0x00])), + scopes: scopes, + ) + + let object = try #require( + try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any], + ) + #expect(object["payload"] == nil) + #expect(object["payloadError"] as? String == "unparseable (2 bytes)") + } + + @Test func exportsOneLinePerEventOldestFirst() { + let export = NDJSONExporter.export( + events: [ + stored(message: "newest", date: date(2)), + stored(message: "oldest", date: date(1)), + ], + scopes: scopes, + ) + + let lines = export.split(separator: "\n") + #expect(lines.count == 2) + #expect(lines[0].contains("\"oldest\"")) + #expect(lines[1].contains("\"newest\"")) + } + + @Test func linesCarryTheEventFields() throws { + let payload = try JSONEncoder().encode(PhotoLogs(photoID: "p1")) + let line = NDJSONExporter.line( + for: stored( + message: "hello", + date: date(1), + payload: payload, + tags: [LogTag(key: LogTagKey("payment-id"), value: "pay_1")], + ), + scopes: scopes, + ) + + let object = try #require( + try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any], + ) + #expect(object["message"] as? String == "hello") + #expect(object["level"] as? String == "warning") + #expect(object["severity"] as? Int == LogLevel.warning.severity) + #expect(object["scopePath"] as? String == "app/photos") + #expect(object["session"] as? String == sessionID.uuidString) + #expect((object["tags"] as? [String: String])?["payment-id"] == "pay_1") + #expect((object["payload"] as? [String: Any])?["photoID"] as? String == "p1") + } + + @Test func linesCarryTheSpanExitWhenPresent() throws { + let line = NDJSONExporter.line( + for: stored(message: "◀ save failed", date: date(1), spanExitMode: .failure), + scopes: scopes, + ) + let object = try #require( + try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any], + ) + #expect(object["spanExit"] as? String == "failure") + } + + @Test func unknownScopesAndEmptyPayloadsExportCleanly() throws { + let orphan = StoredLogEvent( + id: UUID(), + date: date(1), + sequence: 0, + level: .info, + eventName: "message", + eventVersion: 1, + message: "bare", + payload: Data(), + scopes: [LogScope.root(named: "never-defined").id], + tags: [], + spanID: nil, + spanExitMode: nil, + callSite: nil, + externalID: nil, + attachments: [], + sessionID: sessionID, + ) + + let line = NDJSONExporter.line(for: orphan, scopes: scopes) + let object = try #require( + try JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any], + ) + #expect(object["scopePath"] == nil) + #expect(object["payload"] == nil) + #expect(object["message"] as? String == "bare") + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift b/Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift new file mode 100644 index 00000000..1e0bf97c --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift @@ -0,0 +1,41 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import PeriscopeTools +import SwiftUI +import TestHostSupport +import Testing +import UIKit + +private struct AppLogs: LogEvent { + var message: String { + "app" + } +} + +@MainActor +struct OpenSpansViewHostingTests { + @Test func hostsWithOpenSpans() throws { + let system = Periscope(configuration: Periscope.Configuration(), sinks: []) + let log = Log(system: system)(for: "checkout") + log.begin(for: "pay_1", lifetime: .bounded(budget: .seconds(120))) + log.begin(for: "pay_2", lifetime: .indefinite) + + let host = UIHostingController(rootView: NavigationStack { + OpenSpansView(system: system) + }) + try show(host) { _ in + try waitFor { host.view.window != nil } + } + } + + @Test func hostsEmpty() throws { + let system = Periscope(configuration: Periscope.Configuration(), sinks: []) + + let host = UIHostingController(rootView: NavigationStack { + OpenSpansView(system: system) + }) + try show(host) { _ in + try waitFor { host.view.window != nil } + } + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift new file mode 100644 index 00000000..e99226d5 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift @@ -0,0 +1,92 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +@testable import PeriscopeTools +import Testing + +@MainActor +private final class CapturingAlertHandler: PeriscopeAlertHandler { + private(set) var records: [LogRecord] = [] + + func handle(_ record: LogRecord) { + records.append(record) + } +} + +/// A fixture event for alerter routing. +private struct AppLogs: LogEvent { + var message: String { + "app" + } +} + +@MainActor +struct PeriscopeAlerterTests { + let system = Periscope(configuration: Periscope.Configuration(), sinks: []) + private let handler = CapturingAlertHandler() + + @Test func routesRecordsAtOrAboveTheThreshold() async { + let alerter = PeriscopeAlerter(system: system, threshold: .warning, handler: handler) + alerter.start() + let log = Log(system: system) + + log.info("quiet") + log.warning("toast me") + log.error("toast me too") + + let delivered = await waitUntil { handler.records.count == 2 } + #expect(delivered) + #expect(handler.records.map(\.message) == ["toast me", "toast me too"]) + #expect(handler.records.allSatisfy { $0.level >= .warning }) + } + + @Test func onlyRecordsEmittedAfterStartAlert() async { + let log = Log(system: system) + log.error("before start") + + let alerter = PeriscopeAlerter(system: system, threshold: .warning, handler: handler) + alerter.start() + log.error("after start") + + let delivered = await waitUntil { !handler.records.isEmpty } + #expect(delivered) + #expect(handler.records.map(\.message) == ["after start"]) + } + + @Test func stopEndsAlerting() async { + let alerter = PeriscopeAlerter(system: system, threshold: .warning, handler: handler) + alerter.start() + let log = Log(system: system) + + log.error("first") + let first = await waitUntil { handler.records.count == 1 } + #expect(first) + + // Once the observer is unregistered, an emission provably cannot + // reach the handler — no sentinel race. + alerter.stop() + let unsubscribed = await waitUntil { system.liveObserverCount == 0 } + #expect(unsubscribed) + + log.error("while stopped") + #expect(handler.records.map(\.message) == ["first"]) + } + + @Test func startingTwiceDoesNotDuplicateSubscriptions() async { + let alerter = PeriscopeAlerter(system: system, threshold: .warning, handler: handler) + alerter.start() + alerter.start() + + // One observer means one delivery stream — asserted directly + // instead of racing a duplicate delivery against the expectation. + #expect(system.liveObserverCount == 1) + + let log = Log(system: system) + log.error("once") + log.error("sentinel") + let delivered = await waitUntil { + handler.records.contains { $0.message == "sentinel" } + } + #expect(delivered) + #expect(handler.records.map(\.message) == ["once", "sentinel"]) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift new file mode 100644 index 00000000..5f952c17 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift @@ -0,0 +1,59 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +@testable import PeriscopeTools +import Testing + +@MainActor +struct PeriscopeInspectorTests { + @Test func initMirrorsTheSystemFlag() async throws { + let store = try await PeriscopeStore.inMemory(session: makeSession()) + let system = Periscope(configuration: Periscope.Configuration(), sinks: []) + system.isInspectModeEnabled = true + + let inspector = PeriscopeInspector(system: system, store: store) + #expect(inspector.isEnabled) + } + + @Test func togglingWritesThroughToTheSystem() async throws { + let store = try await PeriscopeStore.inMemory(session: makeSession()) + let system = Periscope(configuration: Periscope.Configuration(), sinks: []) + let inspector = PeriscopeInspector(system: system, store: store) + + inspector.isEnabled = true + #expect(system.isInspectModeEnabled) + + inspector.isEnabled = false + #expect(!system.isInspectModeEnabled) + } + + @Test func directSystemWritesFlowBackIntoTheInspector() async throws { + let store = try await PeriscopeStore.inMemory(session: makeSession()) + let system = Periscope(configuration: Periscope.Configuration(), sinks: []) + let inspector = PeriscopeInspector(system: system, store: store) + #expect(!inspector.isEnabled) + + system.isInspectModeEnabled = true + let mirroredOn = await waitUntil { inspector.isEnabled } + #expect(mirroredOn) + + system.isInspectModeEnabled = false + let mirroredOff = await waitUntil { !inspector.isEnabled } + #expect(mirroredOff) + } + + @Test func mixedWritersConvergeWithoutPingPong() async throws { + let store = try await PeriscopeStore.inMemory(session: makeSession()) + let system = Periscope(configuration: Periscope.Configuration(), sinks: []) + let inspector = PeriscopeInspector(system: system, store: store) + + inspector.isEnabled = true + system.isInspectModeEnabled = false + inspector.isEnabled = true + system.isInspectModeEnabled = false + + let converged = await waitUntil { + !inspector.isEnabled && !system.isInspectModeEnabled + } + #expect(converged) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift new file mode 100644 index 00000000..752a4a28 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift @@ -0,0 +1,110 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import TestHostSupport +import UIKit + +/// Shared fixture event for the tools suites. +struct PhotoLogs: LogEvent { + var photoID: String + var message: String { + "photo \(photoID)" + } +} + +/// A deterministic session for store-backed tests. +func makeSession(startedAt: Date = Date(timeIntervalSinceReferenceDate: 0)) -> LogSession { + LogSession( + id: UUID(), + startedAt: startedAt, + appVersion: "1.0", + buildNumber: "42", + osVersion: "TestOS 1.0", + deviceModel: "TestDevice1,1", + ) +} + +/// An in-memory store with the app → photos → album-1 hierarchy defined. +func makeSeededStore() async throws -> ( + store: PeriscopeStore, + root: LogScope, + photos: LogScope, + album: LogScope +) { + let store = try await PeriscopeStore.inMemory(session: makeSession()) + let root = LogScope.root(named: "app") + let photos = root.child(named: "photos") + let album = photos.child(named: "album-1") + await store.defineScopes([root, photos, album]) + return (store, root, photos, album) +} + +/// A freeform record with an explicit date. +func makeRecord( + _ text: String, + level: LogLevel = .info, + date: Date, + scopes: [ScopeID], + tags: [LogTag] = [], +) -> LogRecord { + LogRecord( + date: date, + event: Message(level: level, text), + scopes: scopes, + tags: tags, + ) +} + +func date(_ offset: TimeInterval) -> Date { + Date(timeIntervalSinceReferenceDate: offset) +} + +/// Polls `predicate` on the main actor until it holds or the budget runs +/// out — models reload on their own tasks, so tests wait for the resulting +/// state rather than racing it. +@MainActor +func waitUntil(_ predicate: () -> Bool) async -> Bool { + for _ in 0 ..< 2000 { + if predicate() { return true } + try? await Task.sleep(for: .milliseconds(2)) + } + return predicate() +} + +/// The async-predicate form, for conditions that consult an actor +/// (e.g. store observer counts). +@MainActor +func waitUntil(_ predicate: () async -> Bool) async -> Bool { + for _ in 0 ..< 2000 { + if await predicate() { return true } + try? await Task.sleep(for: .milliseconds(2)) + } + return await predicate() +} + +/// `TestHostSupport.show` with an async body: hosts `viewController` in the +/// test host's hierarchy for the duration of `body`, so tests can await +/// SwiftUI/task work while the view stays on screen. +@MainActor +func showHosted( + _ viewController: ViewController, + _ body: (ViewController) async throws -> Void, +) async throws { + guard let rootVC = hostKeyWindow()?.rootViewController else { + throw TestHostError("No root view controller in test host.") + } + // Match TestHostSupport.show: run window animations at 100x so tests + // never wait on real transition durations. + defer { rootVC.view.window?.layer.speed = 1 } + rootVC.view.window?.layer.speed = 100 + rootVC.addChild(viewController) + viewController.view.frame = rootVC.view.bounds + rootVC.view.addSubview(viewController.view) + viewController.view.layoutIfNeeded() + viewController.didMove(toParent: rootVC) + defer { + viewController.willMove(toParent: nil) + viewController.view.removeFromSuperview() + viewController.removeFromParent() + } + try await body(viewController) +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerHostingTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerHostingTests.swift new file mode 100644 index 00000000..a4313926 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerHostingTests.swift @@ -0,0 +1,84 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import PeriscopeTools +import SwiftUI +import TestHostSupport +import Testing +import UIKit + +/// Drives in-place input swaps for rebinding tests. +@MainActor +@Observable +private final class StoreHolder { + var store: PeriscopeStore + + init(store: PeriscopeStore) { + self.store = store + } +} + +private struct SwappingHost: View { + let holder: StoreHolder + + var body: some View { + NavigationStack { + PeriscopeViewer(store: holder.store, title: "Logs") + } + } +} + +@MainActor +struct PeriscopeViewerHostingTests { + @Test func swappingTheStoreInPlaceRebindsTheViewer() async throws { + let (storeA, _, _, _) = try await makeSeededStore() + let storeB = try await PeriscopeStore.inMemory(session: makeSession()) + let holder = StoreHolder(store: storeA) + + let host = UIHostingController(rootView: SwappingHost(holder: holder)) + try await showHosted(host) { _ in + let boundToA = await waitUntil { + await storeA.changeObserverCount == 1 + } + #expect(boundToA) + + holder.store = storeB + + // The re-keyed task rebinds to B and cancelling the old task + // releases A's stream — State(initialValue:) alone would leave + // the viewer pinned to A. + let boundToB = await waitUntil { + await storeB.changeObserverCount == 1 + } + #expect(boundToB) + let releasedA = await waitUntil { + await storeA.changeObserverCount == 0 + } + #expect(releasedA) + } + } + + @Test func viewerHostsOverASeededStore() async throws { + let (store, root, _, _) = try await makeSeededStore() + await store.write([ + makeRecord("hello viewer", date: date(1), scopes: [root.id]), + ]) + + let host = UIHostingController(rootView: NavigationStack { + PeriscopeViewer(store: store, title: "Logs") + }) + try show(host) { _ in + try waitFor { host.view.window != nil } + } + } + + @Test func viewerHostsOverAnEmptyStore() async throws { + let store = try await PeriscopeStore.inMemory(session: makeSession()) + + let host = UIHostingController(rootView: NavigationStack { + PeriscopeViewer(store: store, title: "Logs") + }) + try show(host) { _ in + try waitFor { host.view.window != nil } + } + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift new file mode 100644 index 00000000..84194abc --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift @@ -0,0 +1,186 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +@testable import PeriscopeTools +import Testing + +@MainActor +struct PeriscopeViewerModelTests { + @Test func loadShowsEventsNewestFirst() async throws { + let (store, root, _, _) = try await makeSeededStore() + await store.write([ + makeRecord("first", date: date(1), scopes: [root.id]), + makeRecord("second", date: date(2), scopes: [root.id]), + ]) + + let model = PeriscopeViewerModel(store: store) + await model.load() + + #expect(model.events.map(\.message) == ["second", "first"]) + #expect(!model.canLoadMore) + } + + @Test func failedLoadsSurfaceHonestly() async throws { + // A fresh model over an empty store loads fine; the failed state is + // covered by construction — assert the loaded-empty branch here. + let (store, _, _, _) = try await makeSeededStore() + let model = PeriscopeViewerModel(store: store) + await model.load() + #expect(model.events.isEmpty) + if case .failed = model.state { + Issue.record("Empty store should load as an empty page, not fail") + } + } + + @Test func minimumLevelFilterRequeries() async throws { + let (store, root, _, _) = try await makeSeededStore() + await store.write([ + makeRecord("noise", level: .debug, date: date(1), scopes: [root.id]), + makeRecord("boom", level: .error, date: date(2), scopes: [root.id]), + ]) + + let model = PeriscopeViewerModel(store: store) + await model.load() + #expect(model.events.count == 2) + + model.minimumLevel = .warning + let filtered = await waitUntil { model.events.map(\.message) == ["boom"] } + #expect(filtered) + } + + @Test func searchTextFilters() async throws { + let (store, root, _, _) = try await makeSeededStore() + await store.write([ + makeRecord("Uploading photo", date: date(1), scopes: [root.id]), + makeRecord("Deleting album", date: date(2), scopes: [root.id]), + ]) + + let model = PeriscopeViewerModel(store: store) + model.searchText = "photo" + let filtered = await waitUntil { + model.events.map(\.message) == ["Uploading photo"] + } + #expect(filtered) + } + + @Test func scopeFilterQueriesTheSubtree() async throws { + let (store, root, photos, album) = try await makeSeededStore() + await store.write([ + makeRecord("at root", date: date(1), scopes: [root.id]), + makeRecord("at photos", date: date(2), scopes: [photos.id]), + makeRecord("at album", date: date(3), scopes: [album.id]), + ]) + + let model = PeriscopeViewerModel(store: store) + model.selectedScope = photos.id + let filtered = await waitUntil { + model.events.map(\.message) == ["at album", "at photos"] + } + #expect(filtered) + } + + @Test func spanExitFilterRequeries() async throws { + let (store, root, _, _) = try await makeSeededStore() + await store.write([ + LogRecord( + date: date(1), + event: SpanEnded( + spanID: SpanID(), + name: "save", + duration: .seconds(1), + exit: .failure("boom"), + ), + scopes: [root.id], + ), + makeRecord("noise", date: date(2), scopes: [root.id]), + ]) + + let model = PeriscopeViewerModel(store: store) + model.selectedSpanExitMode = .failure + let filtered = await waitUntil { + model.events.map(\.spanExitMode) == [.failure] + } + #expect(filtered) + } + + @Test func pagingLoadsMoreAndStopsAtTheEnd() async throws { + let (store, root, _, _) = try await makeSeededStore() + let total = PeriscopeViewerModel.pageSize + 5 + await store.write((1 ... total).map { index in + makeRecord("\(index)", date: date(TimeInterval(index)), scopes: [root.id]) + }) + + let model = PeriscopeViewerModel(store: store) + await model.load() + #expect(model.events.count == PeriscopeViewerModel.pageSize) + #expect(model.canLoadMore) + + await model.loadMore() + #expect(model.events.count == total) + #expect(!model.canLoadMore) + #expect(model.events.first?.message == "\(total)") + #expect(model.events.last?.message == "1") + } + + @Test func scopePathResolvesThroughTheHierarchy() async throws { + let (store, _, _, album) = try await makeSeededStore() + await store.write([makeRecord("deep", date: date(1), scopes: [album.id])]) + + let model = PeriscopeViewerModel(store: store) + await model.load() + + let event = try #require(model.events.first) + #expect(model.scopePath(for: event) == "app / photos / album-1") + } + + @Test func filterCatalogsComeFromTheStore() async throws { + let (store, root, _, _) = try await makeSeededStore() + await store.write([ + makeRecord("plain", date: date(1), scopes: [root.id]), + LogRecord(date: date(2), event: PhotoLogs(photoID: "p1"), scopes: [root.id]), + ]) + + let model = PeriscopeViewerModel(store: store) + await model.load() + + #expect(model.eventNames == ["PhotoLogs", "message"].sorted()) + #expect(model.sessions.count == 1) + #expect(model.scopeChoices.map(\.path).contains("app / photos / album-1")) + #expect(model.availableLevels == LogLevel.standardLevels) + } + + @Test func runRefreshesLiveWhenTheStoreCommits() async throws { + let (store, root, _, _) = try await makeSeededStore() + let model = PeriscopeViewerModel(store: store) + let task = Task { await model.run() } + defer { task.cancel() } + + // Race the initial load deliberately: a commit landing while (or + // right after) it runs must still end up displayed. + await store.write([makeRecord("live", date: date(1), scopes: [root.id])]) + let shown = await waitUntil { model.events.map(\.message) == ["live"] } + #expect(shown) + + await store.write([makeRecord("later", date: date(2), scopes: [root.id])]) + let refreshed = await waitUntil { + model.events.map(\.message) == ["later", "live"] + } + #expect(refreshed) + } + + @Test func exportUsesTheActiveFiltersUnpaged() async throws { + let (store, root, _, _) = try await makeSeededStore() + await store.write([ + makeRecord("keep me", level: .error, date: date(1), scopes: [root.id]), + makeRecord("drop me", level: .debug, date: date(2), scopes: [root.id]), + ]) + + let model = PeriscopeViewerModel(store: store) + model.minimumLevel = .warning + let filtered = await waitUntil { model.events.map(\.message) == ["keep me"] } + #expect(filtered) + + let export = try await model.exportNDJSON() + #expect(export.contains("keep me")) + #expect(!export.contains("drop me")) + } +} diff --git a/Shared/Periscope/PeriscopeUI/AGENTS.md b/Shared/Periscope/PeriscopeUI/AGENTS.md new file mode 100644 index 00000000..e94e5f86 --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/AGENTS.md @@ -0,0 +1,31 @@ +# PeriscopeUI – Module Shape + +PeriscopeUI is the SwiftUI integration for +[`PeriscopeCore`](../PeriscopeCore): the `logContext` modifier and +environment accessors that flow log scopes through a view hierarchy. 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 + PeriscopeCore.** No app code; the developer tooling views live + in [`PeriscopeTools`](../PeriscopeTools), not here. +- This module adapts Core to SwiftUI — logging behavior, persistence, and + policy all belong in Core. + +## Invariants + +- **Stacked `logContext` modifiers link, not replace** — a child's context is + the union of every ancestor's scopes plus merged tags, nearest modifier + primary (`Log.linked(with:)` semantics; don't reimplement the merge here). +- **`\.logContext` always yields a usable logger** — outside any modifier it + falls back to a root `Log` on `Periscope.shared`, mirroring + `Log.current`. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeUITests`). Host views with `TestHostSupport`'s `show()` helpers and +assert against a fresh `Periscope` system per test. diff --git a/Shared/Periscope/PeriscopeUI/README.md b/Shared/Periscope/PeriscopeUI/README.md new file mode 100644 index 00000000..4cfbceb6 --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/README.md @@ -0,0 +1,63 @@ +# PeriscopeUI + +SwiftUI integration for the **Periscope** observability framework +([`PeriscopeCore`](../PeriscopeCore)): flow log scopes through the view +hierarchy with the `logContext` modifier, so any view can log with its full +context — model and UI — inherited automatically from the environment. + +## Installation + +`PeriscopeUI` is a local SPM library in this repo +(`Shared/Periscope/PeriscopeUI`). Add it to a target's dependencies in +[`Package.swift`](../../../Package.swift): + +```swift +.target(name: "YourModule", dependencies: [.target(name: "PeriscopeUI")]) +``` + +## Quick start + +Contribute contexts where views are built, read them where events happen: + +```swift +PhotoDetailView() + .logContext(model.photo) // any LogContextProviding model + .logContext(screenLog) // or any Log value + +struct PhotoDetailView: View { + @Environment(\.logContext) private var log + + var body: some View { + Button("Save") { + log.info("save tapped") // freeform, full context + let photos = log(PhotoLogs.self) // or derive typed loggers + photos { PhotoLogs.saved } + } + } +} +``` + +## Public API + +- `View.logContext(_ log: Log)` — contribute a logger's + scopes and tags to descendants. +- `View.logContext(_ provider: some LogContextProviding)` — contribute a + model object's instance context directly. +- `EnvironmentValues.logContext: Log` — the accumulated context; + falls back to a root logger on `Periscope.shared` outside any modifier. + +## How it works + +Each `logContext` modifier **links** its context onto whatever enclosing +modifiers already contributed (`Log.linked(with:)` semantics): stacking +modifiers unions scopes and merges tags, with the nearest modifier primary. +The environment value is a plain `Log` — deriving typed loggers or +emitting events goes through the normal PeriscopeCore API, so nothing here +duplicates logging behavior. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeUITests` bundle): probe views read `\.logContext` and log on +appear, hosted via `TestHostSupport.show`, asserted against a private +`Periscope` system's recent buffer. Run with `tuist test PeriscopeUITests`. diff --git a/Shared/Periscope/PeriscopeUI/Sources/LogContextEnvironment.swift b/Shared/Periscope/PeriscopeUI/Sources/LogContextEnvironment.swift new file mode 100644 index 00000000..1e7cfe61 --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/Sources/LogContextEnvironment.swift @@ -0,0 +1,47 @@ +import PeriscopeCore +import SwiftUI + +extension EnvironmentValues { + /// The accumulated context, or `nil` above the first `logContext` + /// modifier. Internal so the public accessor can supply the fallback. + @Entry var accumulatedLogContext: Log? + + /// The accumulated log context: every scope and tag contributed by + /// enclosing ``SwiftUICore/View/logContext(_:)-(Log<_>)`` modifiers, + /// nearest one primary. Views log freeform directly + /// (`log.info("tapped")`) or derive typed loggers + /// (`log(PhotoLogs.self)`). + /// + /// Outside any `logContext` modifier this falls back to a root logger + /// on `Periscope.shared`, mirroring `Log.current`. + public var logContext: Log { + accumulatedLogContext ?? Log() + } +} + +extension View { + /// Contribute `log`'s context to this view hierarchy: descendants' + /// `\.logContext` links these scopes and tags onto whatever enclosing + /// modifiers already contributed (this log primary — the nearest + /// modifier wins), so views log with the full model + UI context "for + /// free". + /// + /// ```swift + /// PhotoDetailView() + /// .logContext(model.photoLog) // model-layer context + /// .logContext(screenLog) // this screen's context + /// ``` + public func logContext(_ log: Log) -> some View { + transformEnvironment(\.accumulatedLogContext) { current in + let contributed = log.retyped(to: Message.self) + current = current.map { contributed.linked(with: $0) } ?? contributed + } + } + + /// Contribute a `LogContextProviding` model's instance context — + /// `.logContext(model.photo)` scopes descendants' logging to that + /// object. + public func logContext(_ provider: some LogContextProviding) -> some View { + logContext(provider.log) + } +} diff --git a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift new file mode 100644 index 00000000..e7e012a5 --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift @@ -0,0 +1,131 @@ +import PeriscopeCore +import PeriscopeUI +import SwiftUI +import TestHostSupport +import Testing +import UIKit + +/// Reads the accumulated context from the environment and logs once on +/// appear, exercising exactly what production views do. +private struct FreeformProbe: View { + @Environment(\.logContext) private var log + + var body: some View { + Color.clear.onAppear { + log.info("probe") + } + } +} + +/// Derives a typed logger from the environment context before emitting. +private struct TypedProbe: View { + @Environment(\.logContext) private var log + + var body: some View { + Color.clear.onAppear { + log(PhotoLogs.self) { PhotoLogs(photoID: "p1") } + } + } +} + +private final class PhotoModel: LogContextProviding { + let system: Periscope + + var logSystem: Periscope { + system + } + + init(system: Periscope) { + self.system = system + } +} + +@MainActor +struct LogContextEnvironmentTests { + private func showAndAwaitRecords( + _ view: some View, + system: Periscope, + ) throws -> [LogRecord] { + let host = UIHostingController(rootView: AnyView(view)) + var records: [LogRecord] = [] + try show(host) { _ in + try waitFor { !system.recentRecords().isEmpty } + records = system.recentRecords() + } + return records + } + + @Test func contextOutsideAnyModifierFallsBackToASharedRoot() { + let log = EnvironmentValues().logContext + #expect(log.primaryScope.name == Message.eventName) + #expect(log.primaryScope.parentID == nil) + } + + @Test func modifierGivesDescendantsTheContext() throws { + let system = makeSystem() + let screen = Log(system: system)(for: "detail-screen") + + let records = try showAndAwaitRecords( + FreeformProbe().logContext(screen), + system: system, + ) + + #expect(records.first?.message == "probe") + #expect(records.first?.scopes == screen.scopes.map(\.id)) + } + + @Test func stackedModifiersLinkWithTheNearestPrimary() throws { + let system = makeSystem() + let model = Log(system: system)(for: "photo-9") + let screen = Log(system: system)(for: "detail-screen") + + let records = try showAndAwaitRecords( + FreeformProbe() + .logContext(model) + .logContext(screen), + system: system, + ) + + let expected = (model.scopes + screen.scopes).map(\.id) + #expect(records.first?.scopes == expected) + } + + @Test func tagsFlowThroughTheEnvironment() throws { + let system = makeSystem() + let key = LogTagKey("payment-id") + let screen = Log(system: system).tagged(key, "pay_123") + + let records = try showAndAwaitRecords( + FreeformProbe().logContext(screen), + system: system, + ) + + #expect(records.first?.tags == [LogTag(key: key, value: "pay_123")]) + } + + @Test func typedLoggersDeriveFromTheEnvironmentContext() throws { + let system = makeSystem() + let screen = Log(system: system)(for: "detail-screen") + + let records = try showAndAwaitRecords( + TypedProbe().logContext(screen), + system: system, + ) + + #expect(records.first?.message == "photo p1") + let primary = try #require(records.first?.scopes.first) + #expect(system.scope(for: primary)?.parentID == screen.primaryScope.id) + } + + @Test func contextProvidersContributeTheirInstanceScope() throws { + let system = makeSystem() + let model = PhotoModel(system: system) + + let records = try showAndAwaitRecords( + FreeformProbe().logContext(model), + system: system, + ) + + #expect(records.first?.scopes == model.log.scopes.map(\.id)) + } +} diff --git a/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUITestSupport.swift b/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUITestSupport.swift new file mode 100644 index 00000000..e9900947 --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUITestSupport.swift @@ -0,0 +1,20 @@ +import PeriscopeCore + +/// Shared fixture events for the UI suites. +struct AppLogs: LogEvent { + var message: String { + "app" + } +} + +struct PhotoLogs: LogEvent { + var photoID: String + var message: String { + "photo \(photoID)" + } +} + +/// A sinkless system — assertions read the synchronous recent buffer. +func makeSystem() -> Periscope { + Periscope(configuration: Periscope.Configuration(), sinks: []) +} diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md new file mode 100644 index 00000000..7b5525f9 --- /dev/null +++ b/Shared/Periscope/TODOs.md @@ -0,0 +1,84 @@ +# Periscope todos + +## Usage +- Tag issues with conventional commit semantics: feat, fix, refactor, perf, test, docs + - Eg "- feat: Add log viewer to settings page" +- Nest tasks that depend on other tasks. +- Don't delete completed tasks, move them to the "Completed issues" section at the bottom. + +# Open issues + +## P0s (Must do) +- design: Crash durability — the async pipeline can drop events exactly at crash time. Options from PR review: a synchronous per-event journal the async queue ingests, or writing straight to the store. Needs a plan/build loop. +- design: Span record modeling — `spanID`/`spanExit` bolted onto every `LogRecord` (and `bypassesFloors` as a one-off flag) feels wrong; consider `enum { case span(Span), case event(Event) }` or a dedicated span record type. Plan/build loop. +- design: Decompose `Periscope` (the type and its flat `State` — group watchdog/inspect/ambient/live-observer state into sub-structs) and `PeriscopeStore` into children per behavioral area. Plan/build loop. +- design: `ScopeID` derivation — hash-derived vs a concatenated, human-readable path that preserves the input for debugging. Plan/build loop. +- design: `LogContextProviding` parent hierarchy — instance logs need a way to nest under a container's context (e.g. a controller inside another controller). Plan/build loop. +- design: Ambient state snapshots — ambients should persist their *current state* (session-style) alongside change events, so any event joins to the system state at that moment. Plan/build loop. +- feat: Implement `SpanRelaunchPolicy.survivesRelaunch` resume mechanics. The policy is already recorded on `SpanBegan` payloads and the relaunch sweep honors it (surviving spans are left open, not orphan-closed), but nothing re-seeds them: `end(for:)` in the new process warns "without a matching begin". Needs an async bootstrap step at store/system startup that queries unmatched surviving `SpanBegan` events and re-opens them in `Periscope.openSpans` — plus wall-clock durations for resumed spans (`ContinuousClock` instants don't survive reboot; `SpanEnded.duration` is already optional for this) and accepting that signpost intervals can't resume. + + +## P1s (Should do) +- 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. + + +## P2s (Nice to have) + + +# Completed issues + +## PR review pass (bugs + mechanical reshapes) +- fix: Ambient sources retain their NotificationCenter observer tokens (`AmbientObserverTokens`) — dropped tokens made observations unremovable and immortalized the captured system; restarts now replace instead of doubling. `AmbientEventSource` gains `stop()` and `Periscope.stopAmbientSources()`. +- feat: `LogAttachment.ContentType` enum (json/png/jpeg/plainText + `.other(mime)`); `LogLevel.osLogType` is a stored, overridable property (identity and Codable stay name + severity); `SpanExit` factories for every mode. +- feat: Tags reshaped — `LogTagValue` (typed values incl. any Codable), `[LogTag]` lists everywhere, multi-tag AND queries via filter-count predicate; SDLogTag gains `valueKind`. +- refactor: `InstanceID` stores the `Any.Type`, deriving the name on demand; drop protection is a `LogEvent.isProtectedFromDropping` opt-in rather than concrete type checks. +- feat: `StoreWriteFailed` marker persists after rolled-back writes; retention prunes orphaned sessions, tag rows, and event-less scope branches (leaf-first) in a follow-up save. +- feat: `#function`/`#fileID` capture on every emit (`LogCallSite` → columns → detail view + NDJSON); `LogEvent.externalID` links events to app objects (indexed column, `LogQuery.externalID`). +- feat: `AccessibilityAmbientSource` — start summary plus per-toggle changes across the UIAccessibility settings; tool caps raised to 500 and per-instance. + +## Second review pass +- fix: Redaction can no longer split span pairs — `SpanBegan`/`SpanEnded` records are transform-only through the hook: returning `nil` records a stripped copy instead (tags and attachments dropped, `SpanEnded.exit.reason` blanked; `strippedOfSensitivePayload`), since a suppressed half would strand its partner. Level floors remain the supported way to silence spans; `SpanOverdue` stays suppressible. +- fix: Live-stream yields moved inside the state lock (`Periscope.buffer`), so `liveRecords()` observers see buffered order — racing emitters could previously invert live delivery (e.g. a span's end before its began; sinks and `recentRecords()` were always ordered). The record/beginSpan delivery choreography now shares one helper so the paths can't drift, with tests covering begans through the `beginSpan` bypass. +- fix: `begin(for:)` registers the span and records its `SpanBegan` atomically (`LogRecorder.beginSpan`), so a racing supersede or `end(for:)` can never deliver a span's end before its began; the span-lifecycle fuzz now asserts strict began-then-ended pairs. The superseded close deliberately follows the *new* began (cause before effect). +- fix: The overflow drop policy exempts span began/ended records (like scope definitions), so drop pressure can never split a recorded pair — no parentless ends, no spans stuck reading "open" until next launch's orphan sweep. `SpanOverdue` stays droppable; the span-lifecycle fuzz runs under a small queue to keep this covered. +- fix: Budgeted `measure` sentinels serialize with the span's end through a per-measure gate, so a sentinel losing the race at the budget boundary can never record a `SpanOverdue` after the `SpanEnded`. +- refactor: The six copies of scope-path walking (OSLogSink, the three tool models, OpenSpansView, NDJSONExporter) collapse into `LogScope.ancestry(of:resolve:)`; display joins with `" / "`, exports with `"/"` — now documented as deliberate. +- fix: `showHosted` runs window animations at 100x and restores them, matching `TestHostSupport.show`. +- fix: `PeriscopeViewer` clears its export sheet and failure alert when the store is swapped in place, alongside the model rebuild. +- test: Span-lifecycle fuzz — seeded concurrent interleavings of emits, `begin`/`end` on shared keys (cross-task supersession), global floor flips, and flushes; asserts per-emitter delivery order under floors, no dangling span halves, an empty open-span registry after cleanup, and scopes-before-records. +- fix: `events(matching:)` builds its filter predicate as statements (hand-written `PredicateExpressions`, one `let` per condition) instead of one `#Predicate` macro expression — the macro's single inference tree exceeded the type-checker budget on CI's slower runners, and the statement form also removes the two-variant workaround so every filter (including span exit) combines in one predicate. +- perf: The orphan sweep (app-launch path) fetches only the `spanID` column for its began/ended passes; full rows load exclusively for orphan candidates. (Finding 1.) +- fix: Inspect-mode changes yield inside the state lock so racing setters can't strand `bufferingNewest(1)` subscribers on a stale value. (Finding 2.) +- fix: Span pairs floor together — the begin-time floor decision (`OpenSpan.beganRecorded`) governs the whole lifecycle via `LogRecord.bypassesFloors`; no dangling halves across floor changes. (Finding 3.) +- fix: The span watchdog holds the system weakly (strong promotion per call, never across sleeps), so discarded systems release immediately; adds the missing respawn-on-earlier-deadline test. (Finding 4.) + + +## P2s (Nice to have) +- fix: `NetworkPathAmbientSource.start` cancels the prior monitor when restarted (it used to keep running and logging forever); `AmbientEventSource.start` documents its called-exactly-once contract. +- refactor: Tool views (`PeriscopeViewer`, `LogInspectorView`, `LogTraceView`, `LogEventDetailView`) rebuild their models when identity-relevant inputs change in place — `.task(id:)` keyed on store identity plus each view's inputs, verified by a store-swap hosting test against `changes()` observer counts. +- fix: `PeriscopeInspector` now mirrors both ways — direct writes to `Periscope.isInspectModeEnabled` flow back through the new `inspectModeChanges()` stream, with no-change guards on both sides keeping the loop stable. +- feat: `OpenSpansView(system:)` — the open-spans developer surface: longest-running first with ticking ages, lifetimes/budgets, and scope paths, over the new `Periscope.openSpans()` snapshot. +- feat: Span exits are first-class in the tooling — an indexed `spanExitMode` column with `LogQuery.spanExitMode` ("everything that failed"), exit-mode chips on rows, an Exit row with the reason in event detail, a viewer filter, and NDJSON export of the mode. +- feat: Derive-and-emit `callAsFunction` overloads — `log(PhotoLogs.self) { … }` and `album(for: id) { … }` now compile as single expressions (Swift resolves a value call's args + trailing closure as one application; type callees like SwiftUI Layouts get an implicit init-then-call split, value callees don't). +- perf: `LocalNotificationAlertHandler` caches its authorization outcome (granted/denied; transient request failures retry) behind an `AlertNotificationCenter` seam modeled on Where's `NotificationReminderCenter`, so error storms don't do a daemon round-trip per alert. + + +## P1s (Should do) +- fix: Check level floors before running redaction in `Periscope.record` — redaction code no longer executes (touching PII) for records the floor discards; floors apply to the record as emitted. +- fix: PeriscopeTools/PeriscopeUI used iOS-only API while the package advertised `.macOS(.v26)`. Resolved from the other side: Foreman's removal made the package iOS-only, so macOS is no longer advertised anywhere. (Core's `#if canImport(UIKit)` gating stays — it's still correct per-SDK hygiene.) +- fix: The tracer trims its trail to events strictly `(date, sequence)`-before the origin, so same-millisecond events that landed after it (and a traced `SpanBegan`'s own end event) no longer appear under "leading up to it". +- feat: Optional budget for `measure` spans — `log.measure(.saveEvent, budget: .seconds(1)) { … }` emits a `SpanOverdue` warning *while the closure hangs* (per-call sentinel task, cancelled on completion); the span still ends normally with its derived exit. + + +## P0s (Must do) +- fix: Roll back failed `PeriscopeStore` saves so one poisoned batch can't wedge every subsequent save; recovery drops row caches and refetches the session row by identity. (Code review finding 1.) +- fix: Key `InstanceScopeRegistry` by `InstanceID` (pointer + type) and evict entries via associated-object deallocation trackers, so recycled pointers can't inherit a dead object's logging identity. (Code review finding 2.) +- fix: Subscribe to `store.changes()` before the initial load in `PeriscopeViewerModel`/`LogInspectorModel`, so commits landing mid-load can't be missed. (Code review finding 3.) +- fix: Coalesce auto-flushes — one task per error storm with a single follow-up, instead of one task per qualifying record. (Code review finding 4.) +- fix: Bound `liveRecords()` observer buffers with `.bufferingNewest` (`Configuration.liveBufferCapacity`) so a slow consumer can't grow memory without bound. (Code review finding 5.) +- feat: Span lifecycle — `SpanLifetime` (scoped/bounded/indefinite) with a watchdog that expires over-budget spans, `SpanExit` modes (success/failure/cancelled/superseded/expired/orphaned) with derived exits for `measure`, re-begin superseding instead of locking out, and relaunch orphan-closing per `SpanRelaunchPolicy`. (Code review finding 6.) +- perf: Rewrite `Periscope.chunked(_:)` to accumulate runs in mutable buffers — the last-chunk-rewrite approach copied the accumulated chunk per item and went quadratic on large backlogs. (Code review finding 7.) +- perf: Prefetch the `tags` and `attachments` relationships on `PeriscopeStore` event reads (`relationshipKeyPathsForPrefetching`) so value mapping doesn't fault each relationship per row (N+1). (Code review finding 8.) +- test: Seeded fuzz/adversarial test for the pipeline (concurrent emit + derive + `add(sink:)` + flush interleavings from fixed seeds), asserting no loss/duplication, per-emitter order, and scopes-before-records on every sink. +- test: Cover the drop policy's scope-definitions-never-drop promise by overflowing a gated queue with interleaved definitions and records. +- test: Make the alerter lifecycle tests deterministic via `Periscope.liveObserverCount` (`@_spi(Testing)`) instead of racing duplicate deliveries against a `Task.yield()`.