From f32e0e00b22e551b18914bdc0ce820f0813ee26f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 12:15:02 -0400 Subject: [PATCH 01/73] Scaffold Periscope modules: Core, UI, and Tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the three PeriscopeCore/PeriscopeUI/PeriscopeTools SPM library targets under Shared/Periscope/ with per-module README.md/AGENTS.md, placeholder sources, and hosted test bundles wired into Project.swift (unitTests helper, per-bundle schemes, Stuff-iOS-Tests CI scheme). Pure groundwork — no behavior yet; the framework's API lands in subsequent commits. Closes plan step: Scaffold PeriscopeCore/UI/Tools targets, docs, test bundles, and scheme wiring. --- AGENTS.md | 6 +-- Package.swift | 22 +++++++++ Project.swift | 32 +++++++++++++ Shared/Periscope/PeriscopeCore/AGENTS.md | 32 +++++++++++++ Shared/Periscope/PeriscopeCore/README.md | 47 +++++++++++++++++++ .../PeriscopeCore/Sources/PeriscopeCore.swift | 5 ++ .../Tests/PeriscopeCoreScaffoldTests.swift | 9 ++++ Shared/Periscope/PeriscopeTools/AGENTS.md | 30 ++++++++++++ Shared/Periscope/PeriscopeTools/README.md | 29 ++++++++++++ .../Sources/PeriscopeTools.swift | 5 ++ .../Tests/PeriscopeToolsScaffoldTests.swift | 9 ++++ Shared/Periscope/PeriscopeUI/AGENTS.md | 27 +++++++++++ Shared/Periscope/PeriscopeUI/README.md | 28 +++++++++++ .../PeriscopeUI/Sources/PeriscopeUI.swift | 5 ++ .../Tests/PeriscopeUIScaffoldTests.swift | 9 ++++ 15 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 Shared/Periscope/PeriscopeCore/AGENTS.md create mode 100644 Shared/Periscope/PeriscopeCore/README.md create mode 100644 Shared/Periscope/PeriscopeCore/Sources/PeriscopeCore.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreScaffoldTests.swift create mode 100644 Shared/Periscope/PeriscopeTools/AGENTS.md create mode 100644 Shared/Periscope/PeriscopeTools/README.md create mode 100644 Shared/Periscope/PeriscopeTools/Sources/PeriscopeTools.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsScaffoldTests.swift create mode 100644 Shared/Periscope/PeriscopeUI/AGENTS.md create mode 100644 Shared/Periscope/PeriscopeUI/README.md create mode 100644 Shared/Periscope/PeriscopeUI/Sources/PeriscopeUI.swift create mode 100644 Shared/Periscope/PeriscopeUI/Tests/PeriscopeUIScaffoldTests.swift diff --git a/AGENTS.md b/AGENTS.md index 22149d65..d25161af 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**, **RegionKit**, **WhereCore**, **WhereUI**, **WhereTesting**) 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**, **RegionKit**, **WhereCore**, **WhereUI**, **WhereTesting**) 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), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **RegionKit** ([`Where/RegionKit/Sources/`](Where/RegionKit/Sources/), the geometry + GeoJSON + region-lookup engine WhereCore builds on) / **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/); **ForemanCore** ([`Foreman/ForemanCore/Sources/`](Foreman/ForemanCore/Sources/), the only **macOS-only** package library, which processes a generated-symbol `Resources/Localizable.xcstrings`) under [`Foreman/`](Foreman/). -- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **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), **Foreman** ([`Foreman/Foreman/`](Foreman/Foreman/), the **native-macOS** menu bar app that runs Cursor local agent workers per repo; its user-facing copy is a generated-symbol `Resources/Localizable.xcstrings` via `STRING_CATALOG_GENERATE_SYMBOLS`), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **SwiftDataInspectorTests**, **RegionKitTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product, and the hostless macOS bundle **ForemanCoreTests**. +- **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), and **SwiftDataInspector** ([`Shared/SwiftDataInspector/Sources/`](Shared/SwiftDataInspector/Sources/), the generic SwiftData browser) under [`Shared/`](Shared/); **RegionKit** ([`Where/RegionKit/Sources/`](Where/RegionKit/Sources/), the geometry + GeoJSON + region-lookup engine WhereCore builds on) / **WhereCore** / **WhereUI** / **WhereTesting** under [`Where/`](Where/); **ForemanCore** ([`Foreman/ForemanCore/Sources/`](Foreman/ForemanCore/Sources/), the only **macOS-only** package library, which processes a generated-symbol `Resources/Localizable.xcstrings`) under [`Foreman/`](Foreman/). +- **Tuist targets** ([`Project.swift`](Project.swift)) — **Where** app ([`Where/Where/`](Where/Where/)), **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), **Foreman** ([`Foreman/Foreman/`](Foreman/Foreman/), the **native-macOS** menu bar app that runs Cursor local agent workers per repo; its user-facing copy is a generated-symbol `Resources/Localizable.xcstrings` via `STRING_CATALOG_GENERATE_SYMBOLS`), **StuffTestHost** ([`Shared/StuffTestHost/`](Shared/StuffTestHost/)), **WhereTests** (app tests, no host), hosted **\*Tests** bundles (**StuffCoreTests**, **LifecycleKitTests**, **LogKitTests**, **LogViewerUITests**, **PeriscopeCoreTests**, **PeriscopeUITests**, **PeriscopeToolsTests**, **SwiftDataInspectorTests**, **RegionKitTests**, **WhereCoreTests**, **WhereUITests**) that depend on **StuffTestHost** + **WhereTesting** + the relevant package product, and the hostless macOS bundle **ForemanCoreTests**. - Add SPM library targets in `Package.swift` and wire apps/tests in `Project.swift` (see existing `unitTests` helper; macOS test bundles are declared directly, like `ForemanCoreTests`). A new module also ships a root `README.md` and `AGENTS.md` — see [Per-module docs](#per-module-docs). - **CI schemes**: the workspace mixes iOS and macOS targets, which no single xcodebuild destination can build, so CI runs two explicit shared schemes — **Stuff-iOS-Tests** (all iOS bundles) and **Foreman-macOS-Tests** (Foreman app + ForemanCoreTests). New iOS test bundles must be added to the `Stuff-iOS-Tests` scheme in `Project.swift` or CI won't run them. diff --git a/Package.swift b/Package.swift index 879b3557..f4a90f3c 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,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: "RegionKit", targets: ["RegionKit"]), .library(name: "WhereCore", targets: ["WhereCore"]), @@ -56,6 +59,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 59681e76..e782f5d6 100644 --- a/Project.swift +++ b/Project.swift @@ -295,6 +295,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", @@ -363,6 +386,9 @@ let project = Project( "LifecycleKitTests", "LogKitTests", "LogViewerUITests", + "PeriscopeCoreTests", + "PeriscopeUITests", + "PeriscopeToolsTests", "SwiftDataInspectorTests", "RegionKitTests", "WhereCoreTests", @@ -374,6 +400,9 @@ let project = Project( "LifecycleKitTests", "LogKitTests", "LogViewerUITests", + "PeriscopeCoreTests", + "PeriscopeUITests", + "PeriscopeToolsTests", "SwiftDataInspectorTests", "RegionKitTests", "WhereCoreTests", @@ -392,6 +421,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..f036e8fd --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -0,0 +1,32 @@ +# 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. + +## Scope & dependencies + +- **Foundation + os + SwiftData + Network only.** No SwiftUI, no app code, no + LogKit. UIKit is allowed **only** inside `#if canImport(UIKit)` for ambient + sources (memory warnings, background/foreground). +- 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. +- **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. + +## 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. diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md new file mode 100644 index 00000000..23cd5328 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -0,0 +1,47 @@ +# 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). + +> **Status:** scaffolding. The API below lands incrementally; sections are +> filled in as each piece ships. + +## 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 — key/value 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")]) +``` + +## Public API + +Landing incrementally — see the sources for what exists today. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeCoreTests` bundle). Tests use in-memory stores and injected +clocks; run with `tuist test PeriscopeCoreTests`. diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeCore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeCore.swift new file mode 100644 index 00000000..2c293511 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeCore.swift @@ -0,0 +1,5 @@ +// PeriscopeCore — core types and SwiftData persistence for the Periscope +// observability framework: typed log events, hierarchical scopes, tags, +// spans, the sink pipeline, and the on-device store. +// +// The module's API lands incrementally; see README.md for the overview. diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreScaffoldTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreScaffoldTests.swift new file mode 100644 index 00000000..58fee025 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreScaffoldTests.swift @@ -0,0 +1,9 @@ +import PeriscopeCore +import Testing + +/// Scaffold smoke test — replaced as the module's real suites land. +struct PeriscopeCoreScaffoldTests { + @Test func moduleImports() { + #expect(Bool(true)) + } +} diff --git a/Shared/Periscope/PeriscopeTools/AGENTS.md b/Shared/Periscope/PeriscopeTools/AGENTS.md new file mode 100644 index 00000000..c9e51c3d --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/AGENTS.md @@ -0,0 +1,30 @@ +# 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. + +## 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. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeToolsTests`). Seed an in-memory store, drive the view models +directly, and host views with `WhereTesting`'s `show()` helpers. diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md new file mode 100644 index 00000000..bcd228af --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -0,0 +1,29 @@ +# 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. + +> **Status:** scaffolding. The API below lands incrementally; sections are +> filled in as each piece ships. + +## 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")]) +``` + +## Public API + +Landing incrementally — see the sources for what exists today. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeToolsTests` bundle). Run with `tuist test PeriscopeToolsTests`. diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeTools.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeTools.swift new file mode 100644 index 00000000..a1f69127 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeTools.swift @@ -0,0 +1,5 @@ +// PeriscopeTools — on-device log exploration for the Periscope observability +// framework: the latest-logs viewer, the tracer, the debug toast, and the +// log view mode modifier. +// +// The module's API lands incrementally; see README.md for the overview. diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsScaffoldTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsScaffoldTests.swift new file mode 100644 index 00000000..f7db7a29 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsScaffoldTests.swift @@ -0,0 +1,9 @@ +import PeriscopeTools +import Testing + +/// Scaffold smoke test — replaced as the module's real suites land. +struct PeriscopeToolsScaffoldTests { + @Test func moduleImports() { + #expect(Bool(true)) + } +} diff --git a/Shared/Periscope/PeriscopeUI/AGENTS.md b/Shared/Periscope/PeriscopeUI/AGENTS.md new file mode 100644 index 00000000..b9b5401e --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/AGENTS.md @@ -0,0 +1,27 @@ +# 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. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeUITests`). Host views with `WhereTesting`'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..0ca80169 --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/README.md @@ -0,0 +1,28 @@ +# 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. + +> **Status:** scaffolding. The API below lands incrementally; sections are +> filled in as each piece ships. + +## 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")]) +``` + +## Public API + +Landing incrementally — see the sources for what exists today. + +## Testing + +Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` +(`PeriscopeUITests` bundle). Run with `tuist test PeriscopeUITests`. diff --git a/Shared/Periscope/PeriscopeUI/Sources/PeriscopeUI.swift b/Shared/Periscope/PeriscopeUI/Sources/PeriscopeUI.swift new file mode 100644 index 00000000..32fe04f0 --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/Sources/PeriscopeUI.swift @@ -0,0 +1,5 @@ +// PeriscopeUI — SwiftUI integration for the Periscope observability +// framework: the `logContext` modifier and environment accessors that flow +// log scopes through a view hierarchy. +// +// The module's API lands incrementally; see README.md for the overview. diff --git a/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUIScaffoldTests.swift b/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUIScaffoldTests.swift new file mode 100644 index 00000000..3f886810 --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUIScaffoldTests.swift @@ -0,0 +1,9 @@ +import PeriscopeUI +import Testing + +/// Scaffold smoke test — replaced as the module's real suites land. +struct PeriscopeUIScaffoldTests { + @Test func moduleImports() { + #expect(Bool(true)) + } +} From 65dc66b2dc5fe760e83c878492a0e113d9f23ad1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 12:16:55 -0400 Subject: [PATCH 02/73] Add Periscope event model: LogEvent, Message, LogLevel LogEvent is the Codable+Sendable protocol for structured events (stable eventName + eventVersion for persisted payloads, default .info level, rendered message). Message is the built-in freeform event the level conveniences will emit. LogLevel is an extensible struct (name + severity, OTel-style) so apps can define custom levels; OSLogType maps by severity band, with warning intentionally on .default like LogKit. Closes plan step: Core event model: LogEvent protocol, Message event, extensible LogLevel. --- .../PeriscopeCore/Sources/LogEvent.swift | 51 +++++++++++++ .../PeriscopeCore/Sources/LogLevel.swift | 74 +++++++++++++++++++ .../PeriscopeCore/Sources/Message.swift | 24 ++++++ .../PeriscopeCore/Sources/PeriscopeCore.swift | 5 -- .../PeriscopeCore/Tests/LogEventTests.swift | 53 +++++++++++++ .../PeriscopeCore/Tests/LogLevelTests.swift | 55 ++++++++++++++ .../PeriscopeCore/Tests/MessageTests.swift | 22 ++++++ .../Tests/PeriscopeCoreScaffoldTests.swift | 9 --- 8 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogLevel.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Message.swift delete mode 100644 Shared/Periscope/PeriscopeCore/Sources/PeriscopeCore.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogEventTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogLevelTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/MessageTests.swift delete mode 100644 Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreScaffoldTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift new file mode 100644 index 00000000..edee1049 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift @@ -0,0 +1,51 @@ +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 { + /// 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 } +} + +extension LogEvent { + public static var eventName: String { + String(describing: Self.self) + } + + public static var eventVersion: Int { + 1 + } + + public var level: LogLevel { + .info + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogLevel.swift b/Shared/Periscope/PeriscopeCore/Sources/LogLevel.swift new file mode 100644 index 00000000..563f90e8 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogLevel.swift @@ -0,0 +1,74 @@ +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 + + public init(name: String, severity: Int) { + self.name = name + self.severity = severity + } + + public static func < (lhs: LogLevel, rhs: LogLevel) -> Bool { + lhs.severity < rhs.severity + } +} + +extension LogLevel { + public static let debug = LogLevel(name: "debug", severity: 100) + public static let info = LogLevel(name: "info", severity: 200) + public static let notice = LogLevel(name: "notice", severity: 300) + public static let warning = LogLevel(name: "warning", severity: 400) + public static let error = LogLevel(name: "error", severity: 500) + public static let fault = LogLevel(name: "fault", severity: 600) + + /// 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 `OSLogType` Console.app sees for this level. + /// + /// Mapped by severity band so custom levels inherit a sensible type. + /// `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 var osLogType: OSLogType { + switch severity { + case .. Date: Wed, 8 Jul 2026 12:22:09 -0400 Subject: [PATCH 03/73] Add Log: scope tree, id scoping, links via + Log is a Sendable value struct over a deterministic scope tree: ScopeID derives from parent + name (SHA-256, namespaced), so the same path is the same scope in any process or launch. Deriving an event type creates a typed child scope; deriving for an identifier keys a child scope to an entity; + / linked(with:) merge scope sets so one event can reference both a model context and a UI context. Freeform level conveniences emit the built-in Message event on any typed logger. Records flow through the LogRecorder protocol (the Periscope system lands next; tests use an in-memory recorder). Closes plan step: Log value type: scope tree, id scoping, links via +. --- .../Periscope/PeriscopeCore/Sources/Log.swift | 127 ++++++++++++++++++ .../PeriscopeCore/Sources/LogRecord.swift | 39 ++++++ .../PeriscopeCore/Sources/LogRecorder.swift | 16 +++ .../PeriscopeCore/Sources/LogScope.swift | 24 ++++ .../PeriscopeCore/Sources/ScopeID.swift | 63 +++++++++ .../PeriscopeCore/Tests/LogRecordTests.swift | 19 +++ .../PeriscopeCore/Tests/LogScopeTests.swift | 27 ++++ .../PeriscopeCore/Tests/LogTests.swift | 107 +++++++++++++++ .../Tests/PeriscopeCoreTestSupport.swift | 47 +++++++ .../PeriscopeCore/Tests/ScopeIDTests.swift | 34 +++++ 10 files changed, 503 insertions(+) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Log.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogScope.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/ScopeID.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogRecordTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogScopeTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/ScopeIDTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Log.swift new file mode 100644 index 00000000..35af2e11 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Log.swift @@ -0,0 +1,127 @@ +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] + + 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)], recorder: recorder) + } + + init(scopes: [LogScope], recorder: any LogRecorder) { + precondition(!scopes.isEmpty, "A Log must have at least one scope") + self.scopes = scopes + 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, 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; the left side stays primary. + 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, recorder: recorder) + } + + // MARK: Emitting + + /// Log a structured event with this logger's full context. + public func callAsFunction(_ event: () -> Event) { + emit(event()) + } + + func emit(_ event: any LogEvent) { + recorder.record(LogRecord(date: Date(), event: event, scopes: scopes.map(\.id))) + } +} + +/// 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) { + emit(Message(level: level, text())) + } + + public func debug(_ text: @autoclosure () -> String) { + log(.debug, text()) + } + + public func info(_ text: @autoclosure () -> String) { + log(.info, text()) + } + + public func notice(_ text: @autoclosure () -> String) { + log(.notice, text()) + } + + public func warning(_ text: @autoclosure () -> String) { + log(.warning, text()) + } + + public func error(_ text: @autoclosure () -> String) { + log(.error, text()) + } + + public func fault(_ text: @autoclosure () -> String) { + log(.fault, text()) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift new file mode 100644 index 00000000..8ccd43c1 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift @@ -0,0 +1,39 @@ +import Foundation + +/// 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] + + public init(id: UUID = UUID(), date: Date, event: any LogEvent, scopes: [ScopeID]) { + self.id = id + self.date = date + self.event = event + self.scopes = scopes + } + + 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 + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift new file mode 100644 index 00000000..1723c737 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift @@ -0,0 +1,16 @@ +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) +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogScope.swift b/Shared/Periscope/PeriscopeCore/Sources/LogScope.swift new file mode 100644 index 00000000..d3659897 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogScope.swift @@ -0,0 +1,24 @@ +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) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/ScopeID.swift b/Shared/Periscope/PeriscopeCore/Sources/ScopeID.swift new file mode 100644 index 00000000..25dcc4aa --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/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/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..3a95e2b8 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogScopeTests.swift @@ -0,0 +1,27 @@ +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 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/LogTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift new file mode 100644 index 00000000..276d3791 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -0,0 +1,107 @@ +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 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/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift new file mode 100644 index 00000000..83016674 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -0,0 +1,47 @@ +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] = [] + } + + 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) } + } +} + +/// 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/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) + } +} From 1f8eda42afd3a37f09347a25c02c0a58f0a4ec0d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 12:27:58 -0400 Subject: [PATCH 04/73] Add Periscope system: LogSink pipeline, recorder, OSLog sink Periscope is the process-wide recorder Logs emit into. Emitting never blocks: records and scope definitions append to one ordered, lock-based pending queue and a background drain task delivers batches to each LogSink (scope definitions always precede the records that reference them; late-added sinks get the scope registry replayed, idempotent per scope ID). A bounded recent-records buffer plus a per-record liveRecords() stream feed live UI. flush() awaits full delivery, then each sink's own flush. OSLogSink mirrors records to os.Logger with the root scope as category and the sub-path prefixed on the message; Periscope.shared ships with one under the main bundle ID. Log gains init(system: .shared) to match the planned Log() spelling. Closes plan step: Periscope system: LogSink pipeline, non-blocking recorder, OSLog sink. --- .../PeriscopeCore/Sources/LogSink.swift | 25 +++ .../PeriscopeCore/Sources/OSLogSink.swift | 82 +++++++ .../PeriscopeCore/Sources/Periscope.swift | 207 ++++++++++++++++++ .../PeriscopeCore/Tests/OSLogSinkTests.swift | 54 +++++ .../Tests/PeriscopeCoreTestSupport.swift | 49 +++++ .../PeriscopeCore/Tests/PeriscopeTests.swift | 120 ++++++++++ 6 files changed, 537 insertions(+) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogSink.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Periscope.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSink.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSink.swift new file mode 100644 index 00000000..b6892dcf --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/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/OSLogSink.swift b/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift new file mode 100644 index 00000000..5933735e --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift @@ -0,0 +1,82 @@ +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. + @_spi(Testing) public func formattedMessage(for record: LogRecord) -> String { + let path = primaryPath(for: record).dropFirst().map(\.name) + guard !path.isEmpty else { return record.message } + return "[\(path.joined(separator: "/"))] \(record.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 + var path: [LogScope] = [] + var next: ScopeID? = primary + while let id = next, let scope = state.scopes[id] { + path.append(scope) + next = scope.parentID + } + return path.reversed() + } + } + + 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/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift new file mode 100644 index 00000000..2180a9cd --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -0,0 +1,207 @@ +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()``. +/// +/// 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 + + public init(recentBufferCapacity: Int = 500) { + self.recentBufferCapacity = recentBufferCapacity + } + } + + /// 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 recent: [LogRecord] = [] + var observers: [UUID: AsyncStream.Continuation] = [:] + /// The active drain task; `nil` exactly when nothing is draining. + var drainTask: Task? + } + + public let configuration: Configuration + private let state: OSAllocatedUnfairLock + + public init(configuration: Configuration, sinks: [any LogSink]) { + precondition( + configuration.recentBufferCapacity > 0, + "recentBufferCapacity must be positive", + ) + self.configuration = configuration + state = OSAllocatedUnfairLock(initialState: State(sinks: sinks)) + } + + // 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. + public func add(sink: some LogSink) { + state.withLock { state in + state.sinks.append(sink) + state.pending.append(contentsOf: state.scopes.values.map(PendingItem.scope)) + } + scheduleDrainIfNeeded() + } + + // 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(_ record: LogRecord) { + let observers = state.withLock { state in + state.recent.append(record) + let overflow = state.recent.count - configuration.recentBufferCapacity + if overflow > 0 { + state.recent.removeFirst(overflow) + } + state.pending.append(.record(record)) + return Array(state.observers.values) + } + for observer in observers { + observer.yield(record) + } + scheduleDrainIfNeeded() + } + + /// Resolve a scope the system has seen. + public func scope(for id: ScopeID) -> LogScope? { + state.withLock { $0.scopes[id] } + } + + // MARK: Live records + + /// The most recent records, oldest first (bounded by + /// ``Configuration/recentBufferCapacity``). + public func recentRecords() -> [LogRecord] { + state.withLock(\.recent) + } + + /// Every record emitted from now on, one at a time. The observer is + /// unregistered automatically when the stream's consumer cancels. + public func liveRecords() -> AsyncStream { + let id = UUID() + return AsyncStream { 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])? = state.withLock { state in + guard !state.pending.isEmpty else { + state.drainTask = nil + return nil + } + let items = state.pending + state.pending.removeAll() + return (items, state.sinks) + } + guard let (items, sinks) = next else { return } + 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) + } + } + } + } + } + + /// 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. + private static func chunked(_ items: [PendingItem]) -> [Chunk] { + var chunks: [Chunk] = [] + for item in items { + switch (chunks.last, item) { + case let (.scopes(scopes), .scope(scope)): + chunks[chunks.count - 1] = .scopes(scopes + [scope]) + case let (.records(records), .record(record)): + chunks[chunks.count - 1] = .records(records + [record]) + case let (_, .scope(scope)): + chunks.append(.scopes([scope])) + case let (_, .record(record)): + chunks.append(.records([record])) + } + } + 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/Tests/OSLogSinkTests.swift b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift new file mode 100644 index 00000000..393d6593 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift @@ -0,0 +1,54 @@ +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 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 index 83016674..e1de53c3 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -28,6 +28,55 @@ final class RecordingRecorder: LogRecorder, Sendable { } } +/// 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 } + } +} + /// Shared fixture events used across suites. struct AppLogs: LogEvent { var message: String { diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift new file mode 100644 index 00000000..06f4e4c1 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -0,0 +1,120 @@ +import Foundation +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 first = try #require(sink.deliveries.first) + guard case let .scopes(scopes) = first else { + Issue.record("Expected the scope definition to be delivered first") + return + } + #expect(scopes.contains(log.primaryScope)) + } + + @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 flushIsSafeWhenNothingIsPending() async { + let system = makeSystem() + await system.flush() + #expect(sink.records.isEmpty) + #expect(sink.flushCount == 1) + } + + @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)) + } +} From 6d8c89e60c97ced47e92ce205fe236e19afd2039 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 12:36:02 -0400 Subject: [PATCH 05/73] Add pipeline policies: level floors, flush/drop, redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Level floors: Periscope.minimumLevel plus per-subtree overrides (nearest overridden ancestor wins; linked records pass when any of their scopes admits them), checked at emit before other work — Log's freeform conveniences consult shouldRecord first so filtered logging skips message rendering entirely. Flush policy: records at flushThreshold (default .error) trigger an automatic flush so high-severity context reaches sinks promptly. Drop policy: the pending queue is bounded; on overflow the oldest records drop (scope definitions never drop) and a synthetic DroppedEvents record under the Periscope system scope reports the gap. Redaction: an optional configuration hook transforms or suppresses every record before it is buffered or delivered anywhere. Closes plan step: Pipeline policies: level floors, flush/drop policy, redaction hook. --- .../Periscope/PeriscopeCore/Sources/Log.swift | 1 + .../PeriscopeCore/Sources/LogRecorder.swift | 6 + .../PeriscopeCore/Sources/Periscope.swift | 217 +++++++++++++++++- .../Tests/PeriscopeCoreTestSupport.swift | 70 ++++++ .../PeriscopeCore/Tests/PeriscopeTests.swift | 185 +++++++++++++++ 5 files changed, 467 insertions(+), 12 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Log.swift index 35af2e11..071e8b0d 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Log.swift @@ -98,6 +98,7 @@ public struct Log: Sendable { /// structured events only. extension Log { public func log(_ level: LogLevel, _ text: @autoclosure () -> String) { + guard recorder.shouldRecord(level: level, scopes: scopes.map(\.id)) else { return } emit(Message(level: level, text())) } diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift index 1723c737..f401acb0 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift @@ -13,4 +13,10 @@ public protocol LogRecorder: Sendable { /// 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 } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 2180a9cd..4840ceaa 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -10,6 +10,19 @@ import os /// 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. +/// - **Redaction** — ``Configuration/redact`` transforms (or suppresses) +/// every record before it is buffered or delivered anywhere. +/// /// 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 { @@ -24,8 +37,50 @@ public final class Periscope: LogRecorder, Sendable { /// Maximum records retained in the recent-records buffer. public var recentBufferCapacity: Int - public init(recentBufferCapacity: Int = 500) { + /// Maximum records queued for sink delivery; on overflow the oldest + /// drop (scope definitions never drop) and a ``DroppedEvents`` + /// record reports the gap. + public var pendingBufferCapacity: 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 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. + public var redact: (@Sendable (LogRecord) -> LogRecord?)? + + public init( + recentBufferCapacity: Int = 500, + pendingBufferCapacity: Int = 5000, + flushThreshold: LogLevel = .error, + redact: (@Sendable (LogRecord) -> LogRecord?)? = nil, + ) { self.recentBufferCapacity = recentBufferCapacity + self.pendingBufferCapacity = pendingBufferCapacity + 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 } } @@ -40,12 +95,19 @@ public final class Periscope: LogRecorder, Sendable { 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] = [:] /// The active drain task; `nil` exactly when nothing is draining. var drainTask: Task? } + /// 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 @@ -54,8 +116,13 @@ public final class Periscope: LogRecorder, Sendable { configuration.recentBufferCapacity > 0, "recentBufferCapacity must be positive", ) + precondition( + configuration.pendingBufferCapacity > 0, + "pendingBufferCapacity must be positive", + ) self.configuration = configuration state = OSAllocatedUnfairLock(initialState: State(sinks: sinks)) + defineScope(systemScope) } // MARK: Sinks @@ -70,6 +137,61 @@ public final class Periscope: LogRecorder, Sendable { 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) { @@ -83,20 +205,55 @@ public final class Periscope: LogRecorder, Sendable { scheduleDrainIfNeeded() } - public func record(_ record: LogRecord) { - let observers = state.withLock { state in - state.recent.append(record) - let overflow = state.recent.count - configuration.recentBufferCapacity - if overflow > 0 { - state.recent.removeFirst(overflow) - } - state.pending.append(.record(record)) + public func record(_ original: LogRecord) { + let record: LogRecord + if let redact = configuration.redact { + guard let redacted = redact(original) else { return } + record = redacted + } else { + record = original + } + let observers: [AsyncStream.Continuation]? = state.withLock { state in + guard Self.passesFloor(level: record.level, scopes: record.scopes, state: state) + else { return nil } + Self.append(record, to: &state, configuration: configuration) return Array(state.observers.values) } + guard let observers else { return } for observer in observers { observer.yield(record) } scheduleDrainIfNeeded() + if record.level >= configuration.flushThreshold { + Task { await self.flush() } + } + } + + /// 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 .record = item else { return false } + remainingToDrop -= 1 + return true + } + state.pendingRecordCount -= pendingOverflow + state.droppedCount += pendingOverflow + } } /// Resolve a scope the system has seen. @@ -151,16 +308,36 @@ public final class Periscope: LogRecorder, Sendable { private func drain() async { while true { - let next: (items: [PendingItem], sinks: [any LogSink])? = state.withLock { state in + 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() - return (items, state.sinks) + 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 let (items, sinks, dropReport) = next else { return } + if let dropReport { + announceDropReport(dropReport) + for sink in sinks { + await sink.write([dropReport]) + } } - guard let (items, sinks) = next else { return } for chunk in Self.chunked(items) { for sink in sinks { switch chunk { @@ -172,6 +349,22 @@ public final class Periscope: LogRecorder, Sendable { } } + /// Surface a synthetic drop report in the recent buffer and live streams + /// (it never re-enters the pending queue). + private func announceDropReport(_ record: LogRecord) { + let observers = state.withLock { state in + state.recent.append(record) + let overflow = state.recent.count - configuration.recentBufferCapacity + if overflow > 0 { + state.recent.removeFirst(overflow) + } + return Array(state.observers.values) + } + for observer in observers { + observer.yield(record) + } + } + /// A run of consecutive same-kind pending items, ready for sink delivery. private enum Chunk { case scopes([LogScope]) diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift index e1de53c3..108dffe0 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -26,6 +26,76 @@ final class RecordingRecorder: LogRecorder, Sendable { func record(_ record: LogRecord) { state.withLock { $0.records.append(record) } } + + func shouldRecord(level _: LogLevel, scopes _: [ScopeID]) -> Bool { + true + } +} + +/// 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. diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 06f4e4c1..82671b0b 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -117,4 +117,189 @@ struct PeriscopeTests { #expect(sink.records.count == 100) #expect(sink.records.map(\.message) == (1 ... 100).map(String.init)) } + + // 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"]) + } + + @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 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"]) + } + + // 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 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: Drop policy + + @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]) + } } From 39c4d60cabdccaead0bd40369f4fd86e2052f9f4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 12:39:34 -0400 Subject: [PATCH 06/73] Add LogContextProviding: derived per-instance loggers in Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conforming classes get a .log derived from type + instance identity: a root scope named after the type with numbered child scopes (#1, #2, …) per instance, cached by the system so an instance keeps its identity — no logger threading, no SwiftUI dependency, and a type's subtree finds every instance's events. LogEventType (default Message) types the logger; logSystem (default .shared) picks the system. Also hardens the scope-ordering test to index-based assertions now that systems define their own Periscope scope at init. Closes plan step: LogContextProviding protocol in Core for models and controllers. --- .../Sources/LogContextProviding.swift | 94 ++++++++++++++++++ .../PeriscopeCore/Sources/Periscope.swift | 3 + .../Tests/LogContextProvidingTests.swift | 99 +++++++++++++++++++ .../PeriscopeCore/Tests/PeriscopeTests.swift | 16 +-- 4 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift b/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift new file mode 100644 index 00000000..e6b2d6fd --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift @@ -0,0 +1,94 @@ +import Foundation +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], 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 one small struct per logging instance and live for the +/// process — intended for controllers and model objects, not per-request +/// throwaways. +final class InstanceScopeRegistry: Sendable { + private struct State { + var scopesByInstance: [ObjectIdentifier: InstanceScopePair] = [:] + var nextIndexByType: [String: Int] = [:] + } + + private let state = OSAllocatedUnfairLock(initialState: State()) + + func scopes(for object: AnyObject) -> InstanceScopePair { + let id = ObjectIdentifier(object) + let typeName = String(describing: type(of: object)) + return state.withLock { state in + if let cached = state.scopesByInstance[id] { + return cached + } + let index = state.nextIndexByType[typeName, default: 1] + state.nextIndexByType[typeName] = index + 1 + let typeScope = LogScope.root(named: typeName) + let pair = InstanceScopePair( + type: typeScope, + instance: typeScope.child(named: "#\(index)"), + ) + state.scopesByInstance[id] = pair + return pair + } + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 4840ceaa..c286e57c 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -111,6 +111,9 @@ public final class Periscope: LogRecorder, Sendable { 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, diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift new file mode 100644 index 00000000..8d2011be --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift @@ -0,0 +1,99 @@ +import Foundation +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) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 82671b0b..eab46c38 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -32,12 +32,16 @@ struct PeriscopeTests { log.info("hello") await system.flush() - let first = try #require(sink.deliveries.first) - guard case let .scopes(scopes) = first else { - Issue.record("Expected the scope definition to be delivered first") - return - } - #expect(scopes.contains(log.primaryScope)) + 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() { From b24a07557e06c71f02b30193e755703098955aa7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 12:41:54 -0400 Subject: [PATCH 07/73] Add task-local context propagation: withContext and Log.current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log.withContext { … } (async and sync forms; async preserves caller isolation via #isolation) binds the log's context to a @TaskLocal, so Log.current — typed to any event, Log.current for freeform — carries the full scope set anywhere in the async call tree, including structured child tasks, without threading loggers through signatures. Nested contexts link (inner log primary, duplicates collapse); with no ambient context, Log.current falls back to a root logger on .shared. Closes plan step: Task-local context propagation: withContext and Log.current. --- .../Sources/AmbientLogContext.swift | 59 +++++++++ .../Tests/AmbientLogContextTests.swift | 113 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift b/Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift new file mode 100644 index 00000000..2bd8ec73 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift @@ -0,0 +1,59 @@ +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 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, 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, linked onto any already-ambient context (this + /// log's scopes stay primary; duplicates collapse). + private func ambientContext() -> AmbientLogContext.Context { + var scopes = scopes + if let existing = AmbientLogContext.current { + for scope in existing.scopes where !scopes.contains(scope) { + scopes.append(scope) + } + } + return AmbientLogContext.Context(scopes: scopes, recorder: recorder) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift new file mode 100644 index 00000000..216989af --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift @@ -0,0 +1,113 @@ +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 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) + } +} From 1b8f5303ff6760b84b9b1e65f80581f07bcd068d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 12:51:59 -0400 Subject: [PATCH 08/73] Add PeriscopeStore: SwiftData persistence, sessions, queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeriscopeStore is the durable LogSink: a @ModelActor over SDLogEvent / SDLogScope / SDLogSession with #Index on the hot query fields. Events persist with their full scope hierarchy (event↔scope many-to-many plus the ordered scope list for display), a store-assigned monotonic sequence for stable same-millisecond ordering, a JSON payload keyed by eventName+eventVersion (decode(_:) recovers the type; tooling degrades to raw JSON), and the session that produced them — one SDLogSession per launch carrying app version, build, OS, and device model. Query API: events(matching:) filters by time range, level floor, event name, session, exact scope, scope subtree, and message search, newest first with limit/offset paging; scopes/sessions/event(id:) round out reads. Retention: pruneEvents(olderThan:)/(keepingNewest:), deleteAllEvents, and a changes() stream that pings after commits. Sink failures log to OSLog and count in an @_spi(Testing) counter — never silently dropped. Closes plan step: PeriscopeStore @ModelActor: schema, session metadata, indexes, retention, query API. --- .../PeriscopeCore/Sources/LogQuery.swift | 37 ++ .../PeriscopeCore/Sources/LogSession.swift | 53 +++ .../Sources/PeriscopeSchema.swift | 121 ++++++ .../Sources/PeriscopeStore.swift | 393 ++++++++++++++++++ .../Sources/StoredLogEvent.swift | 55 +++ .../PeriscopeCore/Tests/LogSessionTests.swift | 24 ++ .../Tests/PeriscopeCoreTestSupport.swift | 27 ++ .../Tests/PeriscopeStoreTests.swift | 289 +++++++++++++ .../Tests/StoredLogEventTests.swift | 42 ++ 9 files changed, 1041 insertions(+) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogSession.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogSessionTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift b/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift new file mode 100644 index 00000000..7d26e38b --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift @@ -0,0 +1,37 @@ +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.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 exactly this scope. + public var scope: ScopeID? + /// Only events referencing this scope or any of its descendants. + public var subtree: ScopeID? + /// 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() {} +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSession.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSession.swift new file mode 100644 index 00000000..8fc0e512 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/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/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift new file mode 100644 index 00000000..51b77109 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift @@ -0,0 +1,121 @@ +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]) + + 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 + var scopes: [SDLogScope] + + init( + eventID: UUID, + date: Date, + sequence: Int, + severity: Int, + levelName: String, + eventName: String, + eventVersion: Int, + message: String, + payload: Data, + orderedScopeIDs: [UUID], + sessionID: UUID, + scopes: [SDLogScope], + ) { + 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.scopes = scopes + } +} + +/// 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] + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift new file mode 100644 index 00000000..385e06e8 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -0,0 +1,393 @@ +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. +/// +/// 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", + ) + + private var activeSessionRow: SDLogSession? + private var scopeRowCache: [UUID: SDLogScope] = [:] + private var changeObservers: [UUID: AsyncStream.Continuation] = [:] + private var writeFailures = 0 + private var nextSequence: Int? + + 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. + public func startSession(_ session: LogSession) throws { + let row = SDLogSession(session: session) + modelContext.insert(row) + try modelContext.save() + activeSessionRow = row + } + + /// 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, created from `LogSession.current()` when + /// the app never called ``startSession(_:)`` explicitly. + private func ensureActiveSession() throws -> SDLogSession { + if let activeSessionRow { + return activeSessionRow + } + let row = SDLogSession(session: .current()) + modelContext.insert(row) + activeSessionRow = row + return row + } + + // MARK: LogSink + + public func defineScopes(_ scopes: [LogScope]) async { + do { + for scope in scopes { + try upsertScopeRow(scope) + } + try modelContext.save() + } catch { + 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 { + writeFailures += 1 + Self.failureLogger.error("Failed to persist \(records.count) log events: \(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 + } + + /// 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 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, + scopes: scopeRows, + ) + modelContext.insert(row) + } + 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 + } + + /// `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 scope = query.scope?.rawValue ?? UUID() + let filtersSubtree = query.subtree != nil + let subtree = try query.subtree.map(subtreeIDs(of:)) ?? [] + + let predicate = #Predicate { event in + event.date >= start && event.date <= end + && event.severity >= minSeverity + && (!filtersName || event.eventName == name) + && (!filtersSession || event.sessionID == session) + && (!filtersSearch || event.message.localizedStandardContains(search)) + && (!filtersScope || event.scopes.contains { $0.scopeID == scope }) + && (!filtersSubtree || event.scopes.contains { subtree.contains($0.scopeID) }) + } + + var descriptor = FetchDescriptor( + 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) + } + + /// One persisted event by ID (for the tracer and inspectors). + public func event(id: UUID) throws -> StoredLogEvent? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.eventID == id }, + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first.map(Self.eventValue) + } + + private static func eventValue(_ row: SDLogEvent) -> StoredLogEvent { + StoredLogEvent( + id: row.eventID, + date: row.date, + 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:)), + sessionID: row.sessionID, + ) + } + + // MARK: Retention + + /// Delete events older than `cutoff`; returns how many were removed. + /// Scopes and sessions stay — they're tiny and keep old exports legible. + 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) + } + try modelContext.save() + notifyChanged() + return rows.count + } + + // 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/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift new file mode 100644 index 00000000..444eb34a --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift @@ -0,0 +1,55 @@ +import Foundation + +/// 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 + 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] + public let sessionID: UUID + + public init( + id: UUID, + date: Date, + level: LogLevel, + eventName: String, + eventVersion: Int, + message: String, + payload: Data, + scopes: [ScopeID], + sessionID: UUID, + ) { + self.id = id + self.date = date + self.level = level + self.eventName = eventName + self.eventVersion = eventVersion + self.message = message + self.payload = payload + self.scopes = scopes + 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/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/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift index 108dffe0..1a76f1ed 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -147,6 +147,33 @@ final class CapturingSink: LogSink, Sendable { } } +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 { diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift new file mode 100644 index 00000000..03eb27f9 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -0,0 +1,289 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import Testing + +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 = 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.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 = root.id + var byPhotos = LogQuery() + byPhotos.scope = 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 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 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) + } + + @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/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift new file mode 100644 index 00000000..d9bcbe9b --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -0,0 +1,42 @@ +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), + level: .notice, + eventName: PhotoLogs.eventName, + eventVersion: PhotoLogs.eventVersion, + message: "photo p1", + payload: payload, + scopes: [scope.id], + 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) + } +} From a17dc79e317dc46545d406c7ca37b6e5e34bbfd2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 12:59:51 -0400 Subject: [PATCH 09/73] Add the tag system: tagged contexts, stamping, tag queries Tags are orthogonal to the scope hierarchy: log.tagged(key, value) returns a context that stamps every event it emits (typed LogTagKey per the identifier convention). Tags flow down derivations, merge across links and nested ambient contexts (primary/inner side wins key conflicts), mirror into OSLog messages as a sorted {k=v} suffix, and persist via shared SDLogTag rows with an indexed key+value pair column. LogQuery gains tag filtering, and the exact/subtree scope filters collapse into one ScopeFilter enum (which also keeps the fetch predicate within type-checker limits). Closes plan step: Tag system: tagged contexts stamping events, tag-based queries. --- .../Sources/AmbientLogContext.swift | 14 ++++-- .../Periscope/PeriscopeCore/Sources/Log.swift | 35 +++++++++++--- .../Sources/LogContextProviding.swift | 2 +- .../PeriscopeCore/Sources/LogQuery.swift | 19 ++++++-- .../PeriscopeCore/Sources/LogRecord.swift | 12 ++++- .../PeriscopeCore/Sources/LogTag.swift | 37 +++++++++++++++ .../PeriscopeCore/Sources/OSLogSink.swift | 15 ++++-- .../Sources/PeriscopeSchema.swift | 32 ++++++++++++- .../Sources/PeriscopeStore.swift | 47 +++++++++++++++++-- .../Sources/StoredLogEvent.swift | 4 ++ .../Tests/AmbientLogContextTests.swift | 29 ++++++++++++ .../PeriscopeCore/Tests/LogTagTests.swift | 21 +++++++++ .../PeriscopeCore/Tests/LogTests.swift | 46 ++++++++++++++++++ .../PeriscopeCore/Tests/OSLogSinkTests.swift | 13 +++++ .../Tests/PeriscopeStoreTests.swift | 37 +++++++++++++-- .../Tests/StoredLogEventTests.swift | 1 + 16 files changed, 334 insertions(+), 30 deletions(-) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogTag.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift b/Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift index 2bd8ec73..de7c96b2 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift @@ -7,6 +7,7 @@ import Foundation enum AmbientLogContext { struct Context { var scopes: [LogScope] + var tags: [LogTagKey: String] var recorder: any LogRecorder } @@ -22,7 +23,7 @@ extension Log { guard let context = AmbientLogContext.current else { return Log() } - return Log(scopes: context.scopes, recorder: context.recorder) + return Log(scopes: context.scopes, tags: context.tags, recorder: context.recorder) } /// Runs `body` with this log's context ambient for the whole async call @@ -45,15 +46,20 @@ extension Log { try AmbientLogContext.$current.withValue(ambientContext(), operation: body) } - /// This log's scopes, linked onto any already-ambient context (this - /// log's scopes stay primary; duplicates collapse). + /// 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) } + for (key, value) in existing.tags where tags[key] == nil { + tags[key] = value + } } - return AmbientLogContext.Context(scopes: scopes, recorder: recorder) + return AmbientLogContext.Context(scopes: scopes, tags: tags, recorder: recorder) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Log.swift index 071e8b0d..87fc6afe 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Log.swift @@ -25,6 +25,9 @@ public struct Log: Sendable { /// then any linked scopes. public let scopes: [LogScope] + /// The tags stamped on every event emitted here (see ``tagged(_:_:)``). + public let tags: [LogTagKey: String] + let recorder: any LogRecorder /// The scope this logger derives children from. @@ -34,12 +37,13 @@ public struct Log: Sendable { /// A root logger whose scope is named after `Event`. public init(recorder: any LogRecorder) { - self.init(scopes: [LogScope.root(named: Event.eventName)], recorder: recorder) + self.init(scopes: [LogScope.root(named: Event.eventName)], tags: [:], recorder: recorder) } - init(scopes: [LogScope], recorder: any LogRecorder) { + init(scopes: [LogScope], tags: [LogTagKey: String], 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]) } @@ -60,14 +64,15 @@ public struct Log: Sendable { private func deriving(childNamed name: String) -> Log { var scopes = scopes scopes[0] = primaryScope.child(named: name) - return Log(scopes: scopes, recorder: recorder) + 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; the left side stays primary. + /// 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) } @@ -78,7 +83,23 @@ public struct Log: Sendable { for scope in other.scopes where !merged.contains(scope) { merged.append(scope) } - return Log(scopes: merged, recorder: recorder) + var tags = tags + for (key, value) in other.tags where tags[key] == nil { + tags[key] = value + } + return Log(scopes: merged, 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. + public func tagged(_ key: LogTagKey, _ value: String) -> Log { + var tags = tags + tags[key] = value + return Log(scopes: scopes, tags: tags, recorder: recorder) } // MARK: Emitting @@ -89,7 +110,9 @@ public struct Log: Sendable { } func emit(_ event: any LogEvent) { - recorder.record(LogRecord(date: Date(), event: event, scopes: scopes.map(\.id))) + recorder.record( + LogRecord(date: Date(), event: event, scopes: scopes.map(\.id), tags: tags), + ) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift b/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift index e6b2d6fd..430e3a23 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift @@ -50,7 +50,7 @@ extension Periscope { ) -> Log { let scopes = instanceScopes.scopes(for: object) defineScope(scopes.type) - return Log(scopes: [scopes.instance], recorder: self) + return Log(scopes: [scopes.instance], tags: [:], recorder: self) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift b/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift index 7d26e38b..5b376a4b 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift @@ -6,7 +6,7 @@ import Foundation /// ```swift /// var query = LogQuery() /// query.minimumLevel = .warning -/// query.subtree = photosLog.primaryScope.id +/// query.scope = .subtree(photosLog.primaryScope.id) /// query.limit = 100 /// let events = try await store.events(matching: query) // newest first /// ``` @@ -21,10 +21,11 @@ public struct LogQuery: Sendable { public var eventName: String? /// Only events from this session (launch). public var sessionID: UUID? - /// Only events referencing exactly this scope. - public var scope: ScopeID? - /// Only events referencing this scope or any of its descendants. - public var subtree: ScopeID? + /// Only events referencing the given scope — exactly, or anywhere in + /// its subtree. + public var scope: ScopeFilter? + /// Only events stamped with this exact key/value tag. + public var tag: LogTag? /// Only events whose message matches this text /// (`localizedStandardContains`). public var messageContains: String? @@ -35,3 +36,11 @@ public struct LogQuery: Sendable { 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/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift index 8ccd43c1..90c67f62 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift @@ -14,11 +14,21 @@ public struct LogRecord: Sendable, Identifiable { /// when the emitting log was linked (`+`). public let scopes: [ScopeID] - public init(id: UUID = UUID(), date: Date, event: any LogEvent, scopes: [ScopeID]) { + /// The tags the emitting context had accumulated (see `Log.tagged`). + public let tags: [LogTagKey: String] + + public init( + id: UUID = UUID(), + date: Date, + event: any LogEvent, + scopes: [ScopeID], + tags: [LogTagKey: String] = [:], + ) { self.id = id self.date = date self.event = event self.scopes = scopes + self.tags = tags } public var level: LogLevel { diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogTag.swift b/Shared/Periscope/PeriscopeCore/Sources/LogTag.swift new file mode 100644 index 00000000..33f2673e --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogTag.swift @@ -0,0 +1,37 @@ +import Foundation + +/// A typed tag key — apps declare their keys once so a tag can't silently +/// typo into a new, untracked identifier: +/// +/// ```swift +/// extension LogTagKey { +/// static let paymentID = LogTagKey("payment-id") +/// } +/// ``` +public struct LogTagKey: Hashable, Sendable, Codable, CustomStringConvertible { + public let rawValue: String + + public init(_ rawValue: String) { + self.rawValue = rawValue + } + + public var description: String { + rawValue + } +} + +/// One key/value tag pair, as 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: String + + public init(key: LogTagKey, value: String) { + self.key = key + self.value = value + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift b/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift index 5933735e..80078636 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift @@ -47,11 +47,20 @@ public struct OSLogSink: LogSink { } /// The logged text: the primary scope path below the root (when any), - /// then the record's message. + /// 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) - guard !path.isEmpty else { return record.message } - return "[\(path.joined(separator: "/"))] \(record.message)" + 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)" } + message += " {\(tags.joined(separator: ", "))}" + } + return message } /// The primary scope's ancestor chain, root first. Empty when the diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift index 51b77109..3536e5d9 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift @@ -30,6 +30,7 @@ final class SDLogEvent { var orderedScopeIDs: [UUID] var sessionID: UUID var scopes: [SDLogScope] + var tags: [SDLogTag] init( eventID: UUID, @@ -44,6 +45,7 @@ final class SDLogEvent { orderedScopeIDs: [UUID], sessionID: UUID, scopes: [SDLogScope], + tags: [SDLogTag], ) { self.eventID = eventID self.date = date @@ -57,6 +59,34 @@ final class SDLogEvent { self.orderedScopeIDs = orderedScopeIDs self.sessionID = sessionID self.scopes = scopes + self.tags = tags + } +} + +/// 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 + var value: String + /// `key` and `value` joined with a separator — a single indexed column + /// so tag predicates stay one comparison. + var pair: String + + @Relationship(inverse: \SDLogEvent.tags) + var events: [SDLogEvent] + + init(key: String, value: String) { + self.key = key + self.value = value + pair = Self.pairValue(key: key, value: value) + events = [] + } + + static func pairValue(key: String, value: String) -> String { + "\(key)\u{1F}\(value)" } } @@ -116,6 +146,6 @@ final class SDLogSession { enum PeriscopeSchema { static var models: [any PersistentModel.Type] { - [SDLogEvent.self, SDLogScope.self, SDLogSession.self] + [SDLogEvent.self, SDLogScope.self, SDLogSession.self, SDLogTag.self] } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index 385e06e8..6e90d425 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -42,6 +42,7 @@ public actor PeriscopeStore: LogSink { 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? @@ -168,6 +169,9 @@ public actor PeriscopeStore: LogSink { ) } let scopeRows = try record.scopes.map { try scopeRow(for: $0.rawValue) } + let tagRows = try record.tags.map { key, value in + try tagRow(for: LogTag(key: key, value: value)) + } let row = try SDLogEvent( eventID: record.id, date: record.date, @@ -181,6 +185,7 @@ public actor PeriscopeStore: LogSink { orderedScopeIDs: record.scopes.map(\.rawValue), sessionID: session.sessionID, scopes: scopeRows, + tags: tagRows, ) modelContext.insert(row) } @@ -243,6 +248,28 @@ public actor PeriscopeStore: LogSink { return try modelContext.fetch(descriptor).first } + // MARK: Tags + + /// Fetch-or-create the shared row for a key/value tag pair. + private func tagRow(for tag: LogTag) throws -> SDLogTag { + if let cached = tagRowCache[tag] { + return cached + } + let pair = SDLogTag.pairValue(key: tag.key.rawValue, value: tag.value) + 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(key: tag.key.rawValue, value: tag.value) + 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] { @@ -276,9 +303,15 @@ public actor PeriscopeStore: LogSink { let filtersSearch = !(query.messageContains ?? "").isEmpty let search = query.messageContains ?? "" let filtersScope = query.scope != nil - let scope = query.scope?.rawValue ?? UUID() - let filtersSubtree = query.subtree != nil - let subtree = try query.subtree.map(subtreeIDs(of:)) ?? [] + let scopeIDs: [UUID] = switch query.scope { + case let .exactly(id): [id.rawValue] + case let .subtree(id): try subtreeIDs(of: id) + case nil: [] + } + let filtersTag = query.tag != nil + let tagPair = query.tag.map { + SDLogTag.pairValue(key: $0.key.rawValue, value: $0.value) + } ?? "" let predicate = #Predicate { event in event.date >= start && event.date <= end @@ -286,8 +319,8 @@ public actor PeriscopeStore: LogSink { && (!filtersName || event.eventName == name) && (!filtersSession || event.sessionID == session) && (!filtersSearch || event.message.localizedStandardContains(search)) - && (!filtersScope || event.scopes.contains { $0.scopeID == scope }) - && (!filtersSubtree || event.scopes.contains { subtree.contains($0.scopeID) }) + && (!filtersScope || event.scopes.contains { scopeIDs.contains($0.scopeID) }) + && (!filtersTag || event.tags.contains { $0.pair == tagPair }) } var descriptor = FetchDescriptor( @@ -325,6 +358,10 @@ public actor PeriscopeStore: LogSink { message: row.message, payload: row.payload, scopes: row.orderedScopeIDs.map(ScopeID.init(rawValue:)), + tags: Dictionary( + row.tags.map { (LogTagKey($0.key), $0.value) }, + uniquingKeysWith: { first, _ in first }, + ), sessionID: row.sessionID, ) } diff --git a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift index 444eb34a..7f2810ec 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift @@ -18,6 +18,8 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { 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: [LogTagKey: String] public let sessionID: UUID public init( @@ -29,6 +31,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { message: String, payload: Data, scopes: [ScopeID], + tags: [LogTagKey: String], sessionID: UUID, ) { self.id = id @@ -39,6 +42,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { self.message = message self.payload = payload self.scopes = scopes + self.tags = tags self.sessionID = sessionID } diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift index 216989af..c602f840 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift @@ -97,6 +97,35 @@ struct AmbientLogContextTests { #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 == [key: "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 == [key: "inner", LogTagKey("extra"): "e"]) + } + @Test func contextEndsWhenWithContextReturns() async { let log = Log(system: system) await log.withContext {} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift new file mode 100644 index 00000000..e2ebccdd --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift @@ -0,0 +1,21 @@ +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) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift index 276d3791..520a055f 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -79,6 +79,52 @@ struct LogTests { #expect(child.scopes.contains(ui.primaryScope)) } + @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 == [LogTagKey("payment-id"): "pay_123"] }) + + root.info("untagged") + #expect(recorder.records.last?.tags == [:]) + } + + @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 == [LogTagKey("payment-id"): "pay_123"]) + } + + @Test func laterTagsOverrideEarlierValuesForTheSameKey() { + let key = LogTagKey("payment-id") + let log = Log(recorder: recorder).tagged(key, "old").tagged(key, "new") + #expect(log.tags == [key: "new"]) + } + + @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 + + #expect(joined.tags == [ + key: "model-side", + LogTagKey("model-only"): "m", + LogTagKey("ui-only"): "u", + ]) + } + @Test func freeformConveniencesEmitMessageEventsAtEachLevel() { let log = Log(recorder: recorder) diff --git a/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift index 393d6593..465d01c4 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift @@ -32,6 +32,19 @@ struct OSLogSinkTests { #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: [LogTagKey("b-key"): "2", LogTagKey("a-key"): "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") diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 03eb27f9..86ac8c31 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -135,7 +135,7 @@ struct PeriscopeStoreTests { ]) var query = LogQuery() - query.scope = photos.id + query.scope = .exactly(photos.id) let events = try await store.events(matching: query) #expect(events.map(\.message) == ["at photos"]) } @@ -149,7 +149,7 @@ struct PeriscopeStoreTests { ]) var query = LogQuery() - query.subtree = photos.id + query.scope = .subtree(photos.id) let events = try await store.events(matching: query) #expect(events.map(\.message) == ["at album", "at photos"]) } @@ -161,9 +161,9 @@ struct PeriscopeStoreTests { ]) var byRoot = LogQuery() - byRoot.scope = root.id + byRoot.scope = .exactly(root.id) var byPhotos = LogQuery() - byPhotos.scope = photos.id + byPhotos.scope = .exactly(photos.id) #expect(try await store.events(matching: byRoot).count == 1) #expect(try await store.events(matching: byPhotos).count == 1) @@ -172,6 +172,35 @@ struct PeriscopeStoreTests { #expect(stored.scopes == [photos.id, root.id]) } + @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: [key: "pay_1"], + ), + LogRecord( + date: date(2), + event: Message(level: .info, "for pay_2"), + scopes: [root.id], + tags: [key: "pay_2"], + ), + makeRecord("untagged", date: date(3), scopes: [root.id]), + ]) + + var query = LogQuery() + query.tag = 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 == [key: "pay_1"]) + + let all = try await store.events(matching: LogQuery()) + #expect(all.first { $0.message == "untagged" }?.tags == [:]) + } + @Test func limitAndOffsetPageNewestFirst() async throws { let (store, root, _, _) = try await makeStore() await store.write((1 ... 5).map { index in diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift index d9bcbe9b..7f93de6a 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -14,6 +14,7 @@ struct StoredLogEventTests { message: "photo p1", payload: payload, scopes: [scope.id], + tags: [LogTagKey("payment-id"): "pay_123"], sessionID: UUID(), ) } From d07e7709a75b40ff781468457864534da5c66b94 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 13:07:10 -0400 Subject: [PATCH 10/73] Add spans: measure, begin/end pairs, os_signpost mirroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log.measure(token) { … } (sync and async, isolation-preserving) emits paired SpanBegan/SpanEnded events sharing a SpanID, with the end event emitted even on throw and durations measured by ContinuousClock — tokens type-check against Event.SpanName (default String), so typed events measure with leading-dot enums. log.begin(for:)/end(for:) open and close spans keyed by primary scope + identifier via the recorder, so a rebuilt logger on the same path closes the pair; mismatched calls warn instead of silently dropping. Spans mirror to OSSignposter at emission time (real durations in Instruments, not sink-delivery time). The store persists an indexed spanID column with events(inSpan:) kept separate from the general query predicate. Closes plan step: Spans: measure closures, begin/end groups, os_signpost mirroring. --- .../PeriscopeCore/Sources/LogEvent.swift | 12 + .../PeriscopeCore/Sources/LogRecorder.swift | 7 + .../PeriscopeCore/Sources/LogSpan.swift | 216 ++++++++++++++++++ .../PeriscopeCore/Sources/Periscope.swift | 20 ++ .../Sources/PeriscopeSchema.swift | 6 +- .../Sources/PeriscopeStore.swift | 17 ++ .../Sources/StoredLogEvent.swift | 4 + .../PeriscopeCore/Tests/LogSpanTests.swift | 134 +++++++++++ .../Tests/PeriscopeCoreTestSupport.swift | 14 ++ .../Tests/PeriscopeStoreTests.swift | 27 +++ .../Tests/StoredLogEventTests.swift | 1 + 11 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift index edee1049..e58df6a3 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift @@ -18,6 +18,18 @@ import Foundation /// Events are emitted through a typed logger: `Log` can log /// only `PhotoUploaded` values (plus freeform ``Message`` conveniences). public protocol LogEvent: Codable, Sendable { + /// The token type naming this event's spans — `log.measure(.saveEvent)` + /// resolves against it. Defaults to `String` for freeform span names; + /// declare a nested enum for typed tokens: + /// + /// ```swift + /// struct DatabaseLogs: LogEvent { + /// enum SpanName: Hashable, Sendable { case saveEvent, migration } + /// // ... + /// } + /// ``` + associatedtype SpanName: Hashable, Sendable = String + /// Stable name the event persists under; defaults to the type name. /// /// Persisted payloads are keyed by this name (plus ``eventVersion``), so diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift index f401acb0..891b2ca4 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift @@ -19,4 +19,11 @@ public protocol LogRecorder: Sendable { /// 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:)`. Returns the new span's ID, + /// or `nil` when `key` is already open. + func openSpan(key: SpanKey, name: String, start: ContinuousClock.Instant) -> SpanID? + + /// Stop tracking and return the open span for `key`, if any. + func closeSpan(key: SpanKey) -> OpenSpan? } diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift new file mode 100644 index 00000000..a01c1fc1 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift @@ -0,0 +1,216 @@ +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:)`). +public struct SpanBegan: LogEvent { + public static let eventName = "span-began" + + public let spanID: SpanID + public let name: String + + public var message: String { + "▶ \(name)" + } + + public init(spanID: SpanID, name: String) { + self.spanID = spanID + self.name = name + } +} + +/// Marks the end of a timed span, carrying the measured duration +/// (monotonic — `ContinuousClock`, not wall-clock deltas). +public struct SpanEnded: LogEvent { + public static let eventName = "span-ended" + + public let spanID: SpanID + public let name: String + public let duration: Duration + + public var message: String { + "◀ \(name) (\(duration.formatted()))" + } + + public init(spanID: SpanID, name: String, duration: Duration) { + self.spanID = spanID + self.name = name + self.duration = duration + } +} + +/// 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 {} + +extension LogRecord { + /// The span this record belongs to, when its event is a span event. + public var spanID: SpanID? { + (event as? SpanCarrying)?.spanID + } +} + +/// 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. +public struct OpenSpan: Sendable { + public let id: SpanID + public let name: String + public let start: ContinuousClock.Instant + + public init(id: SpanID, name: String, start: ContinuousClock.Instant) { + self.id = id + self.name = name + self.start = start + } +} + +/// 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 are typed tokens (`log.measure(.saveEvent)`), not +/// raw strings. +extension Log { + /// Times `body` between paired ``SpanBegan``/``SpanEnded`` events + /// sharing one ``SpanID``. The end event is emitted even when `body` + /// throws. 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 { + let span = SpanID() + let spanName = String(describing: name) + let clock = ContinuousClock() + let start = clock.now + SpanSignposts.begin(span, name: spanName) + emit(SpanBegan(spanID: span, name: spanName)) + defer { + SpanSignposts.end(span) + emit(SpanEnded(spanID: span, name: spanName, duration: clock.now - start)) + } + return try 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 { + let span = SpanID() + let spanName = String(describing: name) + let clock = ContinuousClock() + let start = clock.now + SpanSignposts.begin(span, name: spanName) + emit(SpanBegan(spanID: span, name: spanName)) + defer { + SpanSignposts.end(span) + emit(SpanEnded(spanID: span, name: spanName, duration: clock.now - start)) + } + return try await body() + } + + /// Open a span for `id` — e.g. `log.begin(for: payment)` when a payment + /// flow starts. Close it later with ``end(for:)`` from any logger with + /// the same primary scope. Beginning an already-open span logs a + /// warning instead of restarting it. + public func begin(for id: some Hashable & Sendable) { + let name = String(describing: id) + let key = SpanKey(scope: primaryScope.id, identifier: name) + guard let span = recorder.openSpan(key: key, name: name, start: ContinuousClock().now) + else { + warning("begin(for: \(name)) while that span is already open") + return + } + SpanSignposts.begin(span, name: name) + emit(SpanBegan(spanID: span, name: name)) + } + + /// Close the span opened with ``begin(for:)`` for the same identifier, + /// emitting the ``SpanEnded`` with the measured duration. Ending a span + /// that isn't open logs a warning. + public func end(for id: some Hashable & Sendable) { + 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) + emit(SpanEnded( + spanID: open.id, + name: open.name, + duration: ContinuousClock().now - open.start, + )) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index c286e57c..578b0c30 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -101,6 +101,7 @@ public final class Periscope: LogRecorder, Sendable { var observers: [UUID: AsyncStream.Continuation] = [:] var globalFloor: LogLevel? var subtreeFloors: [ScopeID: LogLevel] = [:] + var openSpans: [SpanKey: OpenSpan] = [:] /// The active drain task; `nil` exactly when nothing is draining. var drainTask: Task? } @@ -264,6 +265,25 @@ public final class Periscope: LogRecorder, Sendable { state.withLock { $0.scopes[id] } } + // MARK: Open spans + + public func openSpan( + key: SpanKey, + name: String, + start: ContinuousClock.Instant, + ) -> SpanID? { + state.withLock { state in + guard state.openSpans[key] == nil else { return nil } + let span = OpenSpan(id: SpanID(), name: name, start: start) + state.openSpans[key] = span + return span.id + } + } + + public func closeSpan(key: SpanKey) -> OpenSpan? { + state.withLock { $0.openSpans.removeValue(forKey: key) } + } + // MARK: Live records /// The most recent records, oldest first (bounded by diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift index 3536e5d9..d7d0ee7a 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift @@ -12,7 +12,7 @@ import SwiftData /// JSON instead of requiring schema migrations. @Model final class SDLogEvent { - #Index([\.date], [\.severity], [\.eventName], [\.sessionID]) + #Index([\.date], [\.severity], [\.eventName], [\.sessionID], [\.spanID]) var eventID: UUID var date: Date @@ -29,6 +29,8 @@ final class SDLogEvent { /// 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? var scopes: [SDLogScope] var tags: [SDLogTag] @@ -44,6 +46,7 @@ final class SDLogEvent { payload: Data, orderedScopeIDs: [UUID], sessionID: UUID, + spanID: UUID?, scopes: [SDLogScope], tags: [SDLogTag], ) { @@ -58,6 +61,7 @@ final class SDLogEvent { self.payload = payload self.orderedScopeIDs = orderedScopeIDs self.sessionID = sessionID + self.spanID = spanID self.scopes = scopes self.tags = tags } diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index 6e90d425..91492c01 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -184,6 +184,7 @@ public actor PeriscopeStore: LogSink { payload: payload, orderedScopeIDs: record.scopes.map(\.rawValue), sessionID: session.sessionID, + spanID: record.spanID?.rawValue, scopes: scopeRows, tags: tagRows, ) @@ -339,6 +340,21 @@ public actor PeriscopeStore: LogSink { return try modelContext.fetch(descriptor).map(Self.eventValue) } + /// 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 = FetchDescriptor( + 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? { var descriptor = FetchDescriptor( @@ -362,6 +378,7 @@ public actor PeriscopeStore: LogSink { row.tags.map { (LogTagKey($0.key), $0.value) }, uniquingKeysWith: { first, _ in first }, ), + spanID: row.spanID.map(SpanID.init(rawValue:)), sessionID: row.sessionID, ) } diff --git a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift index 7f2810ec..670b68d8 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift @@ -20,6 +20,8 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { public let scopes: [ScopeID] /// The tags the event was stamped with. public let tags: [LogTagKey: String] + /// The span this event begins or ends, when it is a span event. + public let spanID: SpanID? public let sessionID: UUID public init( @@ -32,6 +34,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { payload: Data, scopes: [ScopeID], tags: [LogTagKey: String], + spanID: SpanID?, sessionID: UUID, ) { self.id = id @@ -43,6 +46,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { self.payload = payload self.scopes = scopes self.tags = tags + self.spanID = spanID self.sessionID = sessionID } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift new file mode 100644 index 00000000..6d8cd1a7 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift @@ -0,0 +1,134 @@ +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(ended.name == "save") + #expect(ended.duration >= .zero) + #expect(records.allSatisfy { $0.scopes == log.scopes.map(\.id) }) + } + + @Test func measureEmitsTheEndEvenWhenTheBodyThrows() throws { + let log = Log(recorder: recorder) + + #expect(throws: MeasureError.self) { + try log.measure("failing") { throw MeasureError() } + } + + #expect(recorder.records.count == 2) + #expect(recorder.records.last?.event is SpanEnded) + } + + @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") + } + + @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") + + // 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") + + 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(ended.name == "pay_1") + #expect(ended.duration >= .zero) + } + + @Test func endingWithoutABeginWarnsInsteadOfEmitting() { + let log = Log(recorder: recorder) + + log.end(for: "never-began") + + 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 doubleBeginWarnsAndKeepsTheOriginalSpanOpen() throws { + let log = Log(recorder: recorder) + + log.begin(for: "pay_1") + log.begin(for: "pay_1") + log.end(for: "pay_1") + + let records = recorder.records + #expect(records.count == 3) + #expect(records[1].level == .warning) + let began = try #require(records[0].event as? SpanBegan) + let ended = try #require(records[2].event as? SpanEnded) + #expect(began.spanID == ended.spanID) + } + + @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)) + let data = try JSONEncoder().encode(ended) + let decoded = try JSONDecoder().decode(SpanEnded.self, from: data) + #expect(decoded.spanID == span) + #expect(decoded.duration == .milliseconds(12)) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift index 1a76f1ed..d7030e78 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -7,6 +7,7 @@ final class RecordingRecorder: LogRecorder, Sendable { private struct State { var scopes: [LogScope] = [] var records: [LogRecord] = [] + var openSpans: [SpanKey: OpenSpan] = [:] } private let state = OSAllocatedUnfairLock(initialState: State()) @@ -30,6 +31,19 @@ final class RecordingRecorder: LogRecorder, Sendable { func shouldRecord(level _: LogLevel, scopes _: [ScopeID]) -> Bool { true } + + func openSpan(key: SpanKey, name: String, start: ContinuousClock.Instant) -> SpanID? { + state.withLock { state in + guard state.openSpans[key] == nil else { return nil } + let span = OpenSpan(id: SpanID(), name: name, start: start) + state.openSpans[key] = span + return span.id + } + } + + func closeSpan(key: SpanKey) -> OpenSpan? { + state.withLock { $0.openSpans.removeValue(forKey: key) } + } } /// Polls `predicate` until it holds or `timeout` elapses, returning whether diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 86ac8c31..f827c38d 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -172,6 +172,33 @@ struct PeriscopeStoreTests { #expect(stored.scopes == [photos.id, root.id]) } + @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"), + scopes: [root.id], + ), + makeRecord("unrelated", date: date(2), scopes: [root.id]), + LogRecord( + date: date(3), + event: SpanEnded(spanID: span, name: "save", duration: .seconds(2)), + 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 tagsPersistAndFilter() async throws { let (store, root, _, _) = try await makeStore() let key = LogTagKey("payment-id") diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift index 7f93de6a..abf1ecf3 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -15,6 +15,7 @@ struct StoredLogEventTests { payload: payload, scopes: [scope.id], tags: [LogTagKey("payment-id"): "pay_123"], + spanID: nil, sessionID: UUID(), ) } From 27eb055ac2e9256946bd3988b85aa8fbfe2809b4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 13:10:10 -0400 Subject: [PATCH 11/73] Add attachments: LogAttachment API and external-storage persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogAttachment (name, MIME content type, Data) attaches arbitrary payloads to any event: log(attachments:) for structured events and an attachments: parameter on the freeform level methods. Conveniences cover the common payloads — .error (description/domain/code as JSON), .json for any Encodable, and .image (PNG, UIKit-gated) — plus raw Data via the base init. Persistence uses @Attribute(.externalStorage) rows cascade-deleted with their event; queried events carry lightweight LogAttachmentInfo metadata and bytes load on demand through PeriscopeStore.attachments(forEvent:), so list fetches never pull blobs. Closes plan step: Attachments: LogAttachment API and external-storage persistence. --- .../Periscope/PeriscopeCore/Sources/Log.swift | 50 +++++++++----- .../PeriscopeCore/Sources/LogAttachment.swift | 66 +++++++++++++++++++ .../PeriscopeCore/Sources/LogRecord.swift | 5 ++ .../Sources/PeriscopeSchema.swift | 33 +++++++++- .../Sources/PeriscopeStore.swift | 28 +++++++- .../Sources/StoredLogEvent.swift | 5 ++ .../Tests/LogAttachmentTests.swift | 59 +++++++++++++++++ .../PeriscopeCore/Tests/LogTests.swift | 18 +++++ .../Tests/PeriscopeStoreTests.swift | 28 ++++++++ .../Tests/StoredLogEventTests.swift | 1 + 10 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LogAttachment.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Log.swift index 87fc6afe..a03533f8 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Log.swift @@ -109,10 +109,20 @@ public struct Log: Sendable { emit(event()) } - func emit(_ event: any LogEvent) { - recorder.record( - LogRecord(date: Date(), event: event, scopes: scopes.map(\.id), tags: tags), - ) + /// Log a structured event with attached data — errors, payloads, + /// screenshots (see ``LogAttachment``). + public func callAsFunction(attachments: [LogAttachment], _ event: () -> Event) { + emit(event(), attachments: attachments) + } + + func emit(_ event: any LogEvent, attachments: [LogAttachment] = []) { + recorder.record(LogRecord( + date: Date(), + event: event, + scopes: scopes.map(\.id), + tags: tags, + attachments: attachments, + )) } } @@ -120,32 +130,36 @@ public struct Log: Sendable { /// regardless of its `Event` type — the generic constraint applies to custom /// structured events only. extension Log { - public func log(_ level: LogLevel, _ text: @autoclosure () -> String) { + public func log( + _ level: LogLevel, + _ text: @autoclosure () -> String, + attachments: [LogAttachment] = [], + ) { guard recorder.shouldRecord(level: level, scopes: scopes.map(\.id)) else { return } - emit(Message(level: level, text())) + emit(Message(level: level, text()), attachments: attachments) } - public func debug(_ text: @autoclosure () -> String) { - log(.debug, text()) + public func debug(_ text: @autoclosure () -> String, attachments: [LogAttachment] = []) { + log(.debug, text(), attachments: attachments) } - public func info(_ text: @autoclosure () -> String) { - log(.info, text()) + public func info(_ text: @autoclosure () -> String, attachments: [LogAttachment] = []) { + log(.info, text(), attachments: attachments) } - public func notice(_ text: @autoclosure () -> String) { - log(.notice, text()) + public func notice(_ text: @autoclosure () -> String, attachments: [LogAttachment] = []) { + log(.notice, text(), attachments: attachments) } - public func warning(_ text: @autoclosure () -> String) { - log(.warning, text()) + public func warning(_ text: @autoclosure () -> String, attachments: [LogAttachment] = []) { + log(.warning, text(), attachments: attachments) } - public func error(_ text: @autoclosure () -> String) { - log(.error, text()) + public func error(_ text: @autoclosure () -> String, attachments: [LogAttachment] = []) { + log(.error, text(), attachments: attachments) } - public func fault(_ text: @autoclosure () -> String) { - log(.fault, text()) + public func fault(_ text: @autoclosure () -> String, attachments: [LogAttachment] = []) { + log(.fault, text(), attachments: attachments) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogAttachment.swift b/Shared/Periscope/PeriscopeCore/Sources/LogAttachment.swift new file mode 100644 index 00000000..09573d47 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LogAttachment.swift @@ -0,0 +1,66 @@ +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 { + public let name: String + /// MIME type, e.g. `"application/json"`, `"image/png"`. + public let contentType: String + public let data: Data + + public init(name: String, contentType: String, 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. + let data = (try? JSONEncoder().encode(payload)) ?? Data() + return LogAttachment(name: name, contentType: "application/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: "application/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: "image/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: String + + public init(name: String, contentType: String) { + self.name = name + self.contentType = contentType + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift index 90c67f62..c413d1c8 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift @@ -17,18 +17,23 @@ public struct LogRecord: Sendable, Identifiable { /// The tags the emitting context had accumulated (see `Log.tagged`). public let tags: [LogTagKey: String] + /// Data attached at the call site (see `LogAttachment`). + public let attachments: [LogAttachment] + public init( id: UUID = UUID(), date: Date, event: any LogEvent, scopes: [ScopeID], tags: [LogTagKey: String] = [:], + attachments: [LogAttachment] = [], ) { self.id = id self.date = date self.event = event self.scopes = scopes self.tags = tags + self.attachments = attachments } public var level: LogLevel { diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift index d7d0ee7a..6d179782 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift @@ -34,6 +34,9 @@ final class SDLogEvent { var scopes: [SDLogScope] var tags: [SDLogTag] + @Relationship(deleteRule: .cascade, inverse: \SDLogAttachment.event) + var attachments: [SDLogAttachment] + init( eventID: UUID, date: Date, @@ -49,6 +52,7 @@ final class SDLogEvent { spanID: UUID?, scopes: [SDLogScope], tags: [SDLogTag], + attachments: [SDLogAttachment], ) { self.eventID = eventID self.date = date @@ -64,6 +68,27 @@ final class SDLogEvent { self.spanID = spanID 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 } } @@ -150,6 +175,12 @@ final class SDLogSession { enum PeriscopeSchema { static var models: [any PersistentModel.Type] { - [SDLogEvent.self, SDLogScope.self, SDLogSession.self, SDLogTag.self] + [ + SDLogEvent.self, + SDLogScope.self, + SDLogSession.self, + SDLogTag.self, + SDLogAttachment.self, + ] } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index 91492c01..0b00a7b4 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -172,6 +172,14 @@ public actor PeriscopeStore: LogSink { let tagRows = try record.tags.map { key, value in try tagRow(for: LogTag(key: key, value: value)) } + let attachmentRows = record.attachments.enumerated().map { index, attachment in + SDLogAttachment( + name: attachment.name, + contentType: attachment.contentType, + index: index, + data: attachment.data, + ) + } let row = try SDLogEvent( eventID: record.id, date: record.date, @@ -187,6 +195,7 @@ public actor PeriscopeStore: LogSink { spanID: record.spanID?.rawValue, scopes: scopeRows, tags: tagRows, + attachments: attachmentRows, ) modelContext.insert(row) } @@ -357,11 +366,25 @@ public actor PeriscopeStore: LogSink { /// 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 { LogAttachment(name: $0.name, contentType: $0.contentType, data: $0.data) } + } + + private func fetchEventRow(id: UUID) throws -> SDLogEvent? { var descriptor = FetchDescriptor( predicate: #Predicate { $0.eventID == id }, ) descriptor.fetchLimit = 1 - return try modelContext.fetch(descriptor).first.map(Self.eventValue) + return try modelContext.fetch(descriptor).first } private static func eventValue(_ row: SDLogEvent) -> StoredLogEvent { @@ -379,6 +402,9 @@ public actor PeriscopeStore: LogSink { uniquingKeysWith: { first, _ in first }, ), spanID: row.spanID.map(SpanID.init(rawValue:)), + attachments: row.attachments + .sorted { $0.index < $1.index } + .map { LogAttachmentInfo(name: $0.name, contentType: $0.contentType) }, sessionID: row.sessionID, ) } diff --git a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift index 670b68d8..ee1abfbc 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift @@ -22,6 +22,9 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { public let tags: [LogTagKey: String] /// The span this event begins or ends, when it is a span event. public let spanID: SpanID? + /// Attachment metadata; bytes load via + /// `PeriscopeStore.attachments(forEvent:)`. + public let attachments: [LogAttachmentInfo] public let sessionID: UUID public init( @@ -35,6 +38,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { scopes: [ScopeID], tags: [LogTagKey: String], spanID: SpanID?, + attachments: [LogAttachmentInfo], sessionID: UUID, ) { self.id = id @@ -47,6 +51,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { self.scopes = scopes self.tags = tags self.spanID = spanID + self.attachments = attachments self.sessionID = sessionID } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift new file mode 100644 index 00000000..70c3fbf4 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift @@ -0,0 +1,59 @@ +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: "application/octet-stream", + data: Data([1, 2, 3]), + ) + #expect(attachment.name == "response") + #expect(attachment.contentType == "application/octet-stream") + #expect(attachment.data == Data([1, 2, 3])) + } + + @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 == "application/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 == "application/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 == "image/png") + #expect(!attachment.data.isEmpty) + } + #endif +} diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift index 520a055f..a795c461 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -79,6 +79,24 @@ struct LogTests { #expect(child.scopes.contains(ui.primaryScope)) } + @Test func attachmentsRideAlongWithEvents() { + let log = Log(recorder: recorder) + let attachment = LogAttachment( + name: "thumbnail", + contentType: "image/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") diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index f827c38d..4b0245d2 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -172,6 +172,34 @@ struct PeriscopeStoreTests { #expect(stored.scopes == [photos.id, root.id]) } + @Test func attachmentsPersistAndLoadOnDemand() async throws { + let (store, root, _, _) = try await makeStore() + let first = LogAttachment(name: "a", contentType: "text/plain", data: Data([1])) + let second = LogAttachment(name: "b", contentType: "image/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: "text/plain"), + LogAttachmentInfo(name: "b", contentType: "image/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() diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift index abf1ecf3..d55bf599 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -16,6 +16,7 @@ struct StoredLogEventTests { scopes: [scope.id], tags: [LogTagKey("payment-id"): "pay_123"], spanID: nil, + attachments: [], sessionID: UUID(), ) } From 76567fd2b421bc2ad09d99b31e2f4f9a38756804 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 13:15:27 -0400 Subject: [PATCH 12/73] Add ambient event sources: protocol and built-in observers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AmbientEventSource is the extensible seam for environmental context: Periscope.startAmbientSource(_:) retains a source for the process lifetime and hands it a logger under the shared ambient scope. Sources emit the standard AmbientEvent (typed AmbientKind + value, level raisable). Built-ins — started together via startDefaultAmbientSources() — cover app lifecycle transitions and memory warnings (UIKit-gated), network path changes via NWPathMonitor (connectivity and interface changes), thermal state (serious/critical at .warning), and Low Power Mode. Closes plan step: Ambient event sources: protocol plus built-in system observers. --- .../PeriscopeCore/Sources/AmbientEvent.swift | 50 +++++++++++++++++++ .../Sources/AmbientEventSource.swift | 36 +++++++++++++ .../Sources/AppLifecycleAmbientSource.swift | 27 ++++++++++ .../Sources/LowPowerModeAmbientSource.swift | 18 +++++++ .../Sources/MemoryWarningAmbientSource.swift | 20 ++++++++ .../Sources/NetworkPathAmbientSource.swift | 49 ++++++++++++++++++ .../PeriscopeCore/Sources/Periscope.swift | 7 +++ .../Sources/ThermalStateAmbientSource.swift | 42 ++++++++++++++++ .../Tests/AmbientEventSourceTests.swift | 42 ++++++++++++++++ .../Tests/AmbientEventTests.swift | 29 +++++++++++ .../AppLifecycleAmbientSourceTests.swift | 32 ++++++++++++ .../LowPowerModeAmbientSourceTests.swift | 17 +++++++ .../MemoryWarningAmbientSourceTests.swift | 21 ++++++++ .../Tests/NetworkPathAmbientSourceTests.swift | 18 +++++++ .../ThermalStateAmbientSourceTests.swift | 37 ++++++++++++++ 15 files changed, 445 insertions(+) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/AmbientEvent.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/AmbientEventSource.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/AppLifecycleAmbientSource.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/LowPowerModeAmbientSource.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/MemoryWarningAmbientSource.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift create mode 100644 Shared/Periscope/PeriscopeCore/Sources/ThermalStateAmbientSource.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/AmbientEventTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/AppLifecycleAmbientSourceTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/LowPowerModeAmbientSourceTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/MemoryWarningAmbientSourceTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/ThermalStateAmbientSourceTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/AmbientEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/AmbientEvent.swift new file mode 100644 index 00000000..14e02eff --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/AmbientEvent.swift @@ -0,0 +1,50 @@ +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") +} + +/// 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/AmbientEventSource.swift b/Shared/Periscope/PeriscopeCore/Sources/AmbientEventSource.swift new file mode 100644 index 00000000..704cfda3 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/AmbientEventSource.swift @@ -0,0 +1,36 @@ +import Foundation + +/// A source of ambient/environmental events. Built-ins cover app lifecycle, +/// memory warnings, network path, thermal state, and low power mode; +/// apps conform to add their own (push registration, sync status, …). +/// +/// Sources are registered with ``Periscope/startAmbientSource(_:)``, which +/// retains them for the process lifetime and hands them a logger under the +/// shared ambient scope. +public protocol AmbientEventSource: Sendable { + /// Begin observing and log every observed change into `log`. Called + /// once, when the source is registered. + func start(log: Log) +} + +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)) + } + + /// Start every built-in ambient source: network path, thermal state, + /// low power mode, and (where UIKit exists) app lifecycle and memory + /// warnings. + public func startDefaultAmbientSources() { + startAmbientSource(NetworkPathAmbientSource()) + startAmbientSource(ThermalStateAmbientSource()) + startAmbientSource(LowPowerModeAmbientSource()) + #if canImport(UIKit) + startAmbientSource(AppLifecycleAmbientSource()) + startAmbientSource(MemoryWarningAmbientSource()) + #endif + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/AppLifecycleAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/AppLifecycleAmbientSource.swift new file mode 100644 index 00000000..e5ef04e8 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/AppLifecycleAmbientSource.swift @@ -0,0 +1,27 @@ +#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 { + public init() {} + + public func start(log: Log) { + 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") + } + + private func observe(_ name: Notification.Name, log: Log, value: String) { + _ = NotificationCenter.default.addObserver( + forName: name, + object: nil, + queue: nil, + ) { _ in + log { AmbientEvent(kind: .appLifecycle, value: value) } + } + } + } +#endif diff --git a/Shared/Periscope/PeriscopeCore/Sources/LowPowerModeAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/LowPowerModeAmbientSource.swift new file mode 100644 index 00000000..8164d3d8 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/LowPowerModeAmbientSource.swift @@ -0,0 +1,18 @@ +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 { + public init() {} + + public func start(log: Log) { + _ = NotificationCenter.default.addObserver( + forName: .NSProcessInfoPowerStateDidChange, + object: nil, + queue: nil, + ) { _ in + let enabled = ProcessInfo.processInfo.isLowPowerModeEnabled + log { AmbientEvent(kind: .powerMode, value: enabled ? "low-power" : "normal") } + } + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/MemoryWarningAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/MemoryWarningAmbientSource.swift new file mode 100644 index 00000000..2c15a771 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/MemoryWarningAmbientSource.swift @@ -0,0 +1,20 @@ +#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 { + public init() {} + + public func start(log: Log) { + _ = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: nil, + ) { _ in + log { AmbientEvent(kind: .memory, value: "warning", level: .warning) } + } + } + } +#endif diff --git a/Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift new file mode 100644 index 00000000..fbfbcfda --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift @@ -0,0 +1,49 @@ +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")) + monitor.withLockUnchecked { $0 = started } + } + + 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/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 578b0c30..01e57bff 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -102,6 +102,7 @@ public final class Periscope: LogRecorder, Sendable { var globalFloor: LogLevel? var subtreeFloors: [ScopeID: LogLevel] = [:] var openSpans: [SpanKey: OpenSpan] = [:] + var ambientSources: [any AmbientEventSource] = [] /// The active drain task; `nil` exactly when nothing is draining. var drainTask: Task? } @@ -265,6 +266,12 @@ public final class Periscope: LogRecorder, Sendable { state.withLock { $0.scopes[id] } } + /// Keep an ambient source alive for the process lifetime — see + /// `startAmbientSource(_:)`. + func retainAmbientSource(_ source: some AmbientEventSource) { + state.withLock { $0.ambientSources.append(source) } + } + // MARK: Open spans public func openSpan( diff --git a/Shared/Periscope/PeriscopeCore/Sources/ThermalStateAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/ThermalStateAmbientSource.swift new file mode 100644 index 00000000..7083bdf9 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/ThermalStateAmbientSource.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Logs thermal state changes; `serious` and `critical` log at `.warning` +/// since the system is about to start throttling. +public struct ThermalStateAmbientSource: AmbientEventSource { + public init() {} + + public func start(log: Log) { + _ = NotificationCenter.default.addObserver( + forName: ProcessInfo.thermalStateDidChangeNotification, + object: nil, + queue: nil, + ) { _ in + log { Self.event(for: ProcessInfo.processInfo.thermalState) } + } + } + + /// 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/Tests/AmbientEventSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift new file mode 100644 index 00000000..5fe1d7b0 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift @@ -0,0 +1,42 @@ +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") } + } +} + +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 defaultSourcesStartWithoutIncident() async { + // Built-in sources observe real system notifications; starting them + // must at minimum register cleanly and keep the system consumable. + system.startDefaultAmbientSources() + let log = Log(system: system) + log.info("still logging") + await system.flush() + + #expect(sink.records.contains { $0.message == "still logging" }) + } +} 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/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/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/NetworkPathAmbientSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift new file mode 100644 index 00000000..5718b38d --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift @@ -0,0 +1,18 @@ +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) + } +} 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) + } +} From 3e8005409062bda85784d7886fea014f521ef73c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 13:16:21 -0400 Subject: [PATCH 13/73] Document PeriscopeCore: full README and AGENTS invariants PeriscopeCore's feature set is complete, so replace the scaffold-status README with the real overview (quick start, public API map, pipeline behavior, contracts) and expand the module AGENTS invariants (deterministic scope IDs, sink failure visibility, versioned JSON payloads). Docs-only change. Part of plan step: Ambient event sources (final Core docs pass). --- Shared/Periscope/PeriscopeCore/AGENTS.md | 16 ++++- Shared/Periscope/PeriscopeCore/README.md | 87 ++++++++++++++++++++++-- 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index f036e8fd..2fcf689c 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -11,19 +11,29 @@ the build system, formatting, and global conventions. Read that first. ## Scope & dependencies - **Foundation + os + SwiftData + Network only.** No SwiftUI, no app code, no - LogKit. UIKit is allowed **only** inside `#if canImport(UIKit)` for ambient - sources (memory warnings, background/foreground). + 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. + buffer synchronously; sinks (OSLog, SwiftData) drain asynchronously, and + sink delivery order is emission order with scope definitions first. +- **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 + and counts them; the pipeline reports drops with a synthetic + `DroppedEvents` record. +- **Payloads persist as versioned JSON** (`eventName` + `eventVersion`), not + per-event schemas — changing an event's shape must not require a SwiftData + migration. ## Testing diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index 23cd5328..c5667457 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -13,9 +13,6 @@ 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). -> **Status:** scaffolding. The API below lands incrementally; sections are -> filled in as each piece ships. - ## Vocabulary | Periscope term | Industry equivalent | @@ -36,12 +33,90 @@ inspect mode live in [`PeriscopeTools`](../PeriscopeTools). .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 + +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 -Landing incrementally — see the sources for what exists today. +- **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; `begin(for:)`/`end(for:)` for open-ended + spans. 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, and ambient + sources (`startAmbientSource`, `startDefaultAmbientSources`). +- **Store** — `PeriscopeStore` (`@ModelActor` `LogSink`): sessions + (`LogSession`), `events(matching: LogQuery)` (time range, level floor, + event name, session, scope/subtree, tag, 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. 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. +- 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 for the + process lifetime — meant for controllers/models, not per-request values. ## Testing Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` -(`PeriscopeCoreTests` bundle). Tests use in-memory stores and injected -clocks; run with `tuist test PeriscopeCoreTests`. +(`PeriscopeCoreTests` bundle). Tests use fresh `Periscope` systems, in-memory +stores (`@_spi(Testing) PeriscopeStore.inMemory`), and condition polling — +run with `tuist test PeriscopeCoreTests`. From bfa036aa80d5adf531105d113ea1fa5a3e4c7567 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 13:20:10 -0400 Subject: [PATCH 14/73] Add PeriscopeUI: logContext modifier and environment accessor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View.logContext(_:) contributes a logger's scopes and tags to its descendants, linking onto whatever enclosing modifiers already contributed (nearest primary, tags merged — Log.linked(with:) semantics); an overload takes any LogContextProviding model directly, so .logContext(model.photo) + .logContext(screenLog) stack. Views read @Environment(\.logContext) — a Log that logs freeform immediately or derives typed loggers — with a Periscope.shared root fallback outside any modifier, mirroring Log.current. Core gains Log.retyped(to:), the context-preserving type change the environment accessor is built on. Module README/AGENTS updated to the real API. Closes plan step: PeriscopeUI: logContext modifier and environment accessor. --- .../Periscope/PeriscopeCore/Sources/Log.swift | 10 ++ .../PeriscopeCore/Tests/LogTests.swift | 13 ++ Shared/Periscope/PeriscopeUI/AGENTS.md | 6 +- Shared/Periscope/PeriscopeUI/README.md | 45 +++++- .../Sources/LogContextEnvironment.swift | 47 +++++++ .../PeriscopeUI/Sources/PeriscopeUI.swift | 5 - .../Tests/LogContextEnvironmentTests.swift | 132 ++++++++++++++++++ .../Tests/PeriscopeUIScaffoldTests.swift | 9 -- .../Tests/PeriscopeUITestSupport.swift | 20 +++ 9 files changed, 267 insertions(+), 20 deletions(-) create mode 100644 Shared/Periscope/PeriscopeUI/Sources/LogContextEnvironment.swift delete mode 100644 Shared/Periscope/PeriscopeUI/Sources/PeriscopeUI.swift create mode 100644 Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift delete mode 100644 Shared/Periscope/PeriscopeUI/Tests/PeriscopeUIScaffoldTests.swift create mode 100644 Shared/Periscope/PeriscopeUI/Tests/PeriscopeUITestSupport.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Log.swift index a03533f8..0e59b8c3 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Log.swift @@ -90,6 +90,16 @@ public struct Log: Sendable { return Log(scopes: merged, tags: 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 diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift index a795c461..fff06b98 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -79,6 +79,19 @@ struct LogTests { #expect(child.scopes.contains(ui.primaryScope)) } + @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( diff --git a/Shared/Periscope/PeriscopeUI/AGENTS.md b/Shared/Periscope/PeriscopeUI/AGENTS.md index b9b5401e..f3d38a15 100644 --- a/Shared/Periscope/PeriscopeUI/AGENTS.md +++ b/Shared/Periscope/PeriscopeUI/AGENTS.md @@ -18,7 +18,11 @@ the build system, formatting, and global conventions. Read that first. ## Invariants - **Stacked `logContext` modifiers link, not replace** — a child's context is - the union of every ancestor's scopes plus merged tags. + 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 diff --git a/Shared/Periscope/PeriscopeUI/README.md b/Shared/Periscope/PeriscopeUI/README.md index 0ca80169..08a811f5 100644 --- a/Shared/Periscope/PeriscopeUI/README.md +++ b/Shared/Periscope/PeriscopeUI/README.md @@ -5,9 +5,6 @@ SwiftUI integration for the **Periscope** observability framework hierarchy with the `logContext` modifier, so any view can log with its full context — model and UI — inherited automatically from the environment. -> **Status:** scaffolding. The API below lands incrementally; sections are -> filled in as each piece ships. - ## Installation `PeriscopeUI` is a local SPM library in this repo @@ -18,11 +15,49 @@ context — model and UI — inherited automatically from the environment. .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 -Landing incrementally — see the sources for what exists today. +- `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). Run with `tuist test PeriscopeUITests`. +(`PeriscopeUITests` bundle): probe views read `\.logContext` and log on +appear, hosted via `WhereTesting.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/Sources/PeriscopeUI.swift b/Shared/Periscope/PeriscopeUI/Sources/PeriscopeUI.swift deleted file mode 100644 index 32fe04f0..00000000 --- a/Shared/Periscope/PeriscopeUI/Sources/PeriscopeUI.swift +++ /dev/null @@ -1,5 +0,0 @@ -// PeriscopeUI — SwiftUI integration for the Periscope observability -// framework: the `logContext` modifier and environment accessors that flow -// log scopes through a view hierarchy. -// -// The module's API lands incrementally; see README.md for the overview. diff --git a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift new file mode 100644 index 00000000..787e94fb --- /dev/null +++ b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift @@ -0,0 +1,132 @@ +import PeriscopeCore +import PeriscopeUI +import SwiftUI +import Testing +import UIKit +import WhereTesting + +/// 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 { + let photos = log(PhotoLogs.self) + photos { 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 == [key: "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/PeriscopeUIScaffoldTests.swift b/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUIScaffoldTests.swift deleted file mode 100644 index 3f886810..00000000 --- a/Shared/Periscope/PeriscopeUI/Tests/PeriscopeUIScaffoldTests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import PeriscopeUI -import Testing - -/// Scaffold smoke test — replaced as the module's real suites land. -struct PeriscopeUIScaffoldTests { - @Test func moduleImports() { - #expect(Bool(true)) - } -} 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: []) +} From ce9099e5c678f4106179396c200f549966c9187a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 13:26:57 -0400 Subject: [PATCH 15/73] Add PeriscopeTools latest-logs viewer with NDJSON export PeriscopeViewer renders a PeriscopeStore newest-first: searchable, filterable by level (standard ladder plus custom levels present), event type, scope subtree, and session, paged 200 at a time, with live refresh off the store's changes() signal and honest loading/failed/ empty states. Rows show a severity badge, event type, timestamp, and scope path; the detail screen shows tags, the pretty-printed payload JSON, and attachments loaded on demand. Export renders the active filter's full result set as NDJSON (deterministic sorted keys, oldest first, payload embedded as nested JSON) into a share sheet. Closes plan step: PeriscopeTools: latest-logs viewer with search, filters, NDJSON export. --- Shared/Periscope/PeriscopeTools/README.md | 4 +- .../Sources/LogEventDetailView.swift | 106 +++++++++ .../Sources/LogLevel+Display.swift | 27 +++ .../Sources/NDJSONExporter.swift | 69 ++++++ .../Sources/PeriscopeTools.swift | 5 - .../Sources/PeriscopeViewer.swift | 213 ++++++++++++++++++ .../Sources/PeriscopeViewerModel.swift | 174 ++++++++++++++ .../Tests/NDJSONExporterTests.swift | 100 ++++++++ .../Tests/PeriscopeToolsScaffoldTests.swift | 9 - .../Tests/PeriscopeToolsTestSupport.swift | 69 ++++++ .../Tests/PeriscopeViewerHostingTests.swift | 35 +++ .../Tests/PeriscopeViewerModelTests.swift | 143 ++++++++++++ 12 files changed, 938 insertions(+), 16 deletions(-) create mode 100644 Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift create mode 100644 Shared/Periscope/PeriscopeTools/Sources/LogLevel+Display.swift create mode 100644 Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift delete mode 100644 Shared/Periscope/PeriscopeTools/Sources/PeriscopeTools.swift create mode 100644 Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift create mode 100644 Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift delete mode 100644 Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsScaffoldTests.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerHostingTests.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md index bcd228af..48f89afa 100644 --- a/Shared/Periscope/PeriscopeTools/README.md +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -6,8 +6,8 @@ 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. -> **Status:** scaffolding. The API below lands incrementally; sections are -> filled in as each piece ships. +> **Status:** the viewer has landed; the tracer, toast, and log view mode +> land incrementally. ## Installation diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift new file mode 100644 index 00000000..acd96908 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift @@ -0,0 +1,106 @@ +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) + } + LabeledContent("Session", value: event.sessionID.uuidString) + } + + Section("Message") { + Text(event.message) + .font(.callout) + .textSelection(.enabled) + } + + if !event.tags.isEmpty { + Section("Tags") { + ForEach( + event.tags.sorted { $0.key.rawValue < $1.key.rawValue }, + id: \.key, + ) { key, value in + LabeledContent(key.rawValue, value: value) + } + } + } + + if let payload = prettyPayload { + Section("Payload") { + Text(payload) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + + if !event.attachments.isEmpty { + Section("Attachments") { + attachmentsContent + } + } + } + .navigationTitle(event.eventName) + .navigationBarTitleDisplayMode(.inline) + .task { + do { + attachments = try await .success(store.attachments(forEvent: event.id)) + } catch { + attachments = .failure(error) + } + } + } + + @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) · \(attachment.data.count) bytes") + } + } + } + } + + /// The stored payload, pretty-printed; `nil` when the event carried no + /// structured fields or the payload isn't JSON. + private var prettyPayload: String? { + guard !event.payload.isEmpty, + let object = try? JSONSerialization.jsonObject(with: event.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/LogLevel+Display.swift b/Shared/Periscope/PeriscopeTools/Sources/LogLevel+Display.swift new file mode 100644 index 00000000..b963f5ff --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/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 .. 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, $0.value) }, + ) + } + if let span = event.spanID { + object["span"] = span.rawValue.uuidString + } + if !event.payload.isEmpty, + let payload = try? JSONSerialization.jsonObject(with: event.payload) + { + object["payload"] = payload + } + 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) + } + + /// The primary scope's path (root → leaf), e.g. `"app/photos/album-1"`. + static func scopePath(for event: StoredLogEvent, scopes: [ScopeID: LogScope]) -> String { + guard let primary = event.primaryScope else { return "" } + var names: [String] = [] + var next: ScopeID? = primary + while let id = next, let scope = scopes[id] { + names.append(scope.name) + next = scope.parentID + } + return names.reversed().joined(separator: "/") + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeTools.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeTools.swift deleted file mode 100644 index a1f69127..00000000 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeTools.swift +++ /dev/null @@ -1,5 +0,0 @@ -// PeriscopeTools — on-device log exploration for the Periscope observability -// framework: the latest-logs viewer, the tracer, the debug toast, and the -// log view mode modifier. -// -// The module's API lands incrementally; see README.md for the overview. diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift new file mode 100644 index 00000000..e40a060e --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift @@ -0,0 +1,213 @@ +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) {} + } + .task { + 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)) + } + } + } 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")) + } + } + } + } +} + +private 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) + 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/PeriscopeViewerModel.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift new file mode 100644 index 00000000..854f6031 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift @@ -0,0 +1,174 @@ +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 + + private 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() } } + } + + 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. + func run() async { + await load() + for await _ in await store.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 { + var names: [String] = [] + var next: ScopeID? = scope + while let id = next, let resolved = scopes[id] { + names.append(resolved.name) + next = resolved.parentID + } + return names.reversed().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.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/NDJSONExporterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift new file mode 100644 index 00000000..c752429f --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift @@ -0,0 +1,100 @@ +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: [LogTagKey: String] = [:], + ) -> StoredLogEvent { + StoredLogEvent( + id: UUID(), + date: date, + level: .warning, + eventName: "message", + eventVersion: 1, + message: message, + payload: payload, + scopes: [root.child(named: "photos").id], + tags: tags, + spanID: nil, + attachments: [], + sessionID: sessionID, + ) + } + + @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: [LogTagKey("payment-id"): "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 unknownScopesAndEmptyPayloadsExportCleanly() throws { + let orphan = StoredLogEvent( + id: UUID(), + date: date(1), + level: .info, + eventName: "message", + eventVersion: 1, + message: "bare", + payload: Data(), + scopes: [LogScope.root(named: "never-defined").id], + tags: [:], + spanID: 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/PeriscopeToolsScaffoldTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsScaffoldTests.swift deleted file mode 100644 index f7db7a29..00000000 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsScaffoldTests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import PeriscopeTools -import Testing - -/// Scaffold smoke test — replaced as the module's real suites land. -struct PeriscopeToolsScaffoldTests { - @Test func moduleImports() { - #expect(Bool(true)) - } -} diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift new file mode 100644 index 00000000..2a17d3af --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift @@ -0,0 +1,69 @@ +import Foundation +@_spi(Testing) import PeriscopeCore + +/// 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: [LogTagKey: String] = [:], +) -> 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() +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerHostingTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerHostingTests.swift new file mode 100644 index 00000000..25076a7b --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerHostingTests.swift @@ -0,0 +1,35 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import PeriscopeTools +import SwiftUI +import Testing +import UIKit +import WhereTesting + +@MainActor +struct PeriscopeViewerHostingTests { + @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..ca7ed677 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift @@ -0,0 +1,143 @@ +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 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 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")) + } +} From e964ba8e0d042de8f0d77453634fee9c562e0661 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 13:30:13 -0400 Subject: [PATCH 16/73] Add PeriscopeTools log tracer across time and hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LogTraceView walks backward from an origin event: the trail merges earlier events from the subtrees of all the origin's scopes (linked model + UI contexts both trace), events logged directly at ancestor scopes up each root chain (siblings excluded), and the origin's span pair — deduplicated and ordered newest first with the store's insertion sequence as tiebreak (StoredLogEvent now exposes sequence for exactly that). Every event detail gains a Trace button, and trail rows open their own detail so tracing can continue further back. Closes plan step: PeriscopeTools: log tracer across time and hierarchy. --- .../Sources/PeriscopeStore.swift | 1 + .../Sources/StoredLogEvent.swift | 5 + .../Tests/StoredLogEventTests.swift | 1 + Shared/Periscope/PeriscopeTools/README.md | 11 +- .../Sources/LogEventDetailView.swift | 12 ++ .../PeriscopeTools/Sources/LogEventRow.swift | 48 ++++++++ .../Sources/LogTraceModel.swift | 96 +++++++++++++++ .../PeriscopeTools/Sources/LogTraceView.swift | 65 ++++++++++ .../Sources/PeriscopeViewer.swift | 44 ------- .../Tests/LogTraceModelTests.swift | 112 ++++++++++++++++++ .../Tests/LogTraceViewHostingTests.swift | 26 ++++ .../Tests/NDJSONExporterTests.swift | 2 + 12 files changed, 377 insertions(+), 46 deletions(-) create mode 100644 Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift create mode 100644 Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift create mode 100644 Shared/Periscope/PeriscopeTools/Sources/LogTraceView.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/LogTraceViewHostingTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index 0b00a7b4..eb45287b 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -391,6 +391,7 @@ public actor PeriscopeStore: LogSink { StoredLogEvent( id: row.eventID, date: row.date, + sequence: row.sequence, level: LogLevel(name: row.levelName, severity: row.severity), eventName: row.eventName, eventVersion: row.eventVersion, diff --git a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift index ee1abfbc..a47c5cf5 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift @@ -10,6 +10,9 @@ import Foundation 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 @@ -30,6 +33,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { public init( id: UUID, date: Date, + sequence: Int, level: LogLevel, eventName: String, eventVersion: Int, @@ -43,6 +47,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { ) { self.id = id self.date = date + self.sequence = sequence self.level = level self.eventName = eventName self.eventVersion = eventVersion diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift index d55bf599..2ed4a785 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -8,6 +8,7 @@ struct StoredLogEventTests { return StoredLogEvent( id: UUID(), date: Date(timeIntervalSinceReferenceDate: 100), + sequence: 0, level: .notice, eventName: PhotoLogs.eventName, eventVersion: PhotoLogs.eventVersion, diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md index 48f89afa..6ed2c75b 100644 --- a/Shared/Periscope/PeriscopeTools/README.md +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -6,7 +6,7 @@ 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. -> **Status:** the viewer has landed; the tracer, toast, and log view mode +> **Status:** the viewer and tracer have landed; the toast and log view mode > land incrementally. ## Installation @@ -21,7 +21,14 @@ reveals the events behind any wrapped view. ## Public API -Landing incrementally — see the sources for what exists today. +- **`PeriscopeViewer(store:title:)`** — the latest-logs viewer: newest-first + list over a `PeriscopeStore`, searchable, filterable by level / event type + / scope subtree / session, paged, with per-event detail (payload JSON, + tags, attachments) and NDJSON export for bug reports. Push it inside an + existing `NavigationStack`. + +The tracer, debug toast, and log view mode land incrementally — see the +sources for what exists today. ## Testing diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift index acd96908..9de88e79 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift @@ -65,6 +65,18 @@ struct LogEventDetailView: View { } .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 { do { attachments = try await .success(store.attachments(forEvent: event.id)) diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift b/Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift new file mode 100644 index 00000000..efa9f196 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift @@ -0,0 +1,48 @@ +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) + 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/LogTraceModel.swift b/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift new file mode 100644 index 00000000..52269352 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift @@ -0,0 +1,96 @@ +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). + static let limit = 100 + + let origin: StoredLogEvent + private let store: PeriscopeStore + + private(set) var state: LoadState = .loading + private(set) var scopes: [ScopeID: LogScope] = [:] + + init(store: PeriscopeStore, origin: StoredLogEvent) { + self.store = store + self.origin = origin + } + + /// 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 = Self.limit + for event in try await store.events(matching: query) { + collected[event.id] = event + } + } + collected[origin.id] = nil + + let ordered = collected.values.sorted { lhs, rhs in + (lhs.date, lhs.sequence) > (rhs.date, rhs.sequence) + } + state = .loaded(Array(ordered.prefix(Self.limit))) + } catch { + state = .failed(String(describing: error)) + } + } + + func scopePath(for event: StoredLogEvent) -> String { + guard let primary = event.primaryScope else { return "" } + var names: [String] = [] + var next: ScopeID? = primary + while let id = next, let scope = scopes[id] { + names.append(scope.name) + next = scope.parentID + } + return names.reversed().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/LogTraceView.swift b/Shared/Periscope/PeriscopeTools/Sources/LogTraceView.swift new file mode 100644 index 00000000..9b291e76 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/LogTraceView.swift @@ -0,0 +1,65 @@ +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 + @State private var model: LogTraceModel + + public init(store: PeriscopeStore, origin: StoredLogEvent) { + self.store = store + _model = State(initialValue: LogTraceModel(store: store, origin: origin)) + } + + 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 { + 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/PeriscopeViewer.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift index e40a060e..a5499458 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift @@ -167,47 +167,3 @@ private struct NDJSONExportSheet: View { } } } - -private 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) - 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/Tests/LogTraceModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift new file mode 100644 index 00000000..a26d79e0 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift @@ -0,0 +1,112 @@ +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) + 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) + 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) + 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"), + scopes: [root.id], + ), + LogRecord( + date: date(2), + event: SpanEnded(spanID: span, name: "save", duration: .seconds(1)), + scopes: [album.id], + ), + ]) + let origin = try await originEvent(in: store) + #expect(origin.spanID == span) + + let model = LogTraceModel(store: store, origin: origin) + await model.load() + + #expect(model.trail.contains { $0.spanID == span && $0.eventName == "span-began" }) + } + + @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) + 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) + 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..82fafa60 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LogTraceViewHostingTests.swift @@ -0,0 +1,26 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import PeriscopeTools +import SwiftUI +import Testing +import UIKit +import WhereTesting + +@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 index c752429f..57f6d36f 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift @@ -21,6 +21,7 @@ struct NDJSONExporterTests { StoredLogEvent( id: UUID(), date: date, + sequence: 0, level: .warning, eventName: "message", eventVersion: 1, @@ -77,6 +78,7 @@ struct NDJSONExporterTests { let orphan = StoredLogEvent( id: UUID(), date: date(1), + sequence: 0, level: .info, eventName: "message", eventVersion: 1, From db75b15f10da97c1e7cbb2916fc95636b39f1f88 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 13:32:42 -0400 Subject: [PATCH 17/73] Add PeriscopeTools hookable debug toast for warning+ events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeriscopeAlerter watches a Periscope system's live records and routes everything at its threshold or above to a PeriscopeAlertHandler — the hookable seam, so apps with their own toast/notification stack conform instead of overriding UI. The built-in LocalNotificationAlertHandler posts each alerted record as a local notification under provisional authorization (quiet delivery, no permission prompt), logging post failures to OSLog rather than alerting recursively. start()/stop() manage the watch; only records emitted after start alert, and double start is a no-op. Closes plan step: PeriscopeTools: hookable debug toast for warning+ events. --- Shared/Periscope/PeriscopeTools/README.md | 15 ++- .../LocalNotificationAlertHandler.swift | 47 ++++++++++ .../Sources/PeriscopeAlerter.swift | 72 ++++++++++++++ .../LocalNotificationAlertHandlerTests.swift | 22 +++++ .../Tests/PeriscopeAlerterTests.swift | 94 +++++++++++++++++++ 5 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift create mode 100644 Shared/Periscope/PeriscopeTools/Sources/PeriscopeAlerter.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md index 6ed2c75b..f98af51c 100644 --- a/Shared/Periscope/PeriscopeTools/README.md +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -6,8 +6,8 @@ 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. -> **Status:** the viewer and tracer have landed; the toast and log view mode -> land incrementally. +> **Status:** the viewer, tracer, and toast have landed; the log view mode +> lands incrementally. ## Installation @@ -27,8 +27,15 @@ reveals the events behind any wrapped view. tags, attachments) and NDJSON export for bug reports. Push it inside an existing `NavigationStack`. -The tracer, debug toast, and log view mode land incrementally — see the -sources for what exists today. +- **`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. + +The debug toast and log view mode land incrementally — see the sources for +what exists today. ## Testing diff --git a/Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift b/Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift new file mode 100644 index 00000000..486927b8 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift @@ -0,0 +1,47 @@ +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). Apps with their own toast system implement +/// ``PeriscopeAlertHandler`` instead. +public struct LocalNotificationAlertHandler: PeriscopeAlertHandler { + /// Failures posting the alert can't alert (that would loop), so they go + /// straight to OSLog. + private static let failureLogger = os.Logger( + subsystem: "com.stuff.periscope", + category: "alerts", + ) + + public init() {} + + public func handle(_ record: LogRecord) { + let request = Self.request(for: record) + Task { + let center = UNUserNotificationCenter.current() + do { + _ = try await center.requestAuthorization( + options: [.alert, .sound, .provisional], + ) + try await center.add(request) + } catch { + Self.failureLogger.warning( + "Failed to post log alert notification: \(error)", + ) + } + } + } + + 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, + ) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeAlerter.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeAlerter.swift new file mode 100644 index 00000000..6ef0cc13 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/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/Tests/LocalNotificationAlertHandlerTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift new file mode 100644 index 00000000..5ac26d6e --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift @@ -0,0 +1,22 @@ +import Foundation +import PeriscopeCore +@testable import PeriscopeTools +import Testing + +@MainActor +struct LocalNotificationAlertHandlerTests { + @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) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift new file mode 100644 index 00000000..dd240bca --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift @@ -0,0 +1,94 @@ +import Foundation +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) + + alerter.stop() + log.error("while stopped") + + // A fresh alerter only sees records emitted after it starts, so once + // its sentinel arrives, the stopped alerter demonstrably never + // delivered "while stopped". + let restarted = PeriscopeAlerter(system: system, threshold: .warning, handler: handler) + restarted.start() + log.error("sentinel") + let sentinel = await waitUntil { + handler.records.contains { $0.message == "sentinel" } + } + #expect(sentinel) + #expect(!handler.records.contains { $0.message == "while stopped" }) + } + + @Test func startingTwiceDoesNotDuplicateAlerts() async { + let alerter = PeriscopeAlerter(system: system, threshold: .warning, handler: handler) + alerter.start() + alerter.start() + let log = Log(system: system) + + log.error("once") + let delivered = await waitUntil { !handler.records.isEmpty } + #expect(delivered) + + // Give a duplicate delivery a chance to land before asserting. + await Task.yield() + #expect(handler.records.count == 1) + } +} From bf3af9296d995f4922d67fb9814021e862c3677d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 13:37:10 -0400 Subject: [PATCH 18/73] Add PeriscopeTools log view mode for per-view event inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View.logInspectable(_:) — taking a Log or a LogContextProviding model — badges the wrapped view while log view mode is on; tapping the badge presents every stored event in that context's scope subtrees (merged newest first across linked scopes, live-refreshing), each row linking into the standard event detail and from there the tracer. The mode's source of truth is a new Periscope.isInspectModeEnabled flag in Core; PeriscopeInspector is its observable SwiftUI mirror, injected once at the root via View.periscopeInspector(_:) and toggled from an app's developer settings. Finishes the PeriscopeTools docs (README quick start + AGENTS invariants). Closes plan step: PeriscopeTools: log view mode modifier for per-view event inspection. --- Shared/Periscope/PeriscopeCore/README.md | 5 +- .../PeriscopeCore/Sources/Periscope.swift | 11 ++ .../PeriscopeCore/Tests/PeriscopeTests.swift | 9 ++ Shared/Periscope/PeriscopeTools/AGENTS.md | 9 +- Shared/Periscope/PeriscopeTools/README.md | 48 ++++++-- .../Sources/LogInspectable.swift | 112 ++++++++++++++++++ .../Sources/LogInspectorModel.swift | 78 ++++++++++++ .../Sources/PeriscopeInspector.swift | 54 +++++++++ .../Tests/LogInspectableHostingTests.swift | 53 +++++++++ .../Tests/LogInspectorModelTests.swift | 60 ++++++++++ .../Tests/PeriscopeInspectorTests.swift | 28 +++++ 11 files changed, 456 insertions(+), 11 deletions(-) create mode 100644 Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift create mode 100644 Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift create mode 100644 Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/LogInspectableHostingTests.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index c5667457..681b7224 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -87,8 +87,9 @@ Periscope.shared.startDefaultAmbientSources() - **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, and ambient - sources (`startAmbientSource`, `startDefaultAmbientSources`). + redaction hook, recent buffer + `liveRecords()` stream, ambient + sources (`startAmbientSource`, `startDefaultAmbientSources`), 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, tag, search, paging), diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 01e57bff..54b419a2 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -103,6 +103,7 @@ public final class Periscope: LogRecorder, Sendable { var subtreeFloors: [ScopeID: LogLevel] = [:] var openSpans: [SpanKey: OpenSpan] = [:] var ambientSources: [any AmbientEventSource] = [] + var inspectModeEnabled = false /// The active drain task; `nil` exactly when nothing is draining. var drainTask: Task? } @@ -272,6 +273,16 @@ public final class Periscope: LogRecorder, Sendable { state.withLock { $0.ambientSources.append(source) } } + /// The developer "log view mode" flag: when enabled, inspectable UI + /// (PeriscopeTools' `logInspectable` modifier) reveals the events behind + /// each wrapped view. Toggle from a developer settings surface — + /// typically through PeriscopeTools' inspector, which mirrors this flag + /// observably for SwiftUI. + public var isInspectModeEnabled: Bool { + get { state.withLock(\.inspectModeEnabled) } + set { state.withLock { $0.inspectModeEnabled = newValue } } + } + // MARK: Open spans public func openSpan( diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index eab46c38..17ac66b9 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -122,6 +122,15 @@ struct PeriscopeTests { #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) + } + // MARK: Level floors @Test func globalFloorDiscardsRecordsBelowIt() async { diff --git a/Shared/Periscope/PeriscopeTools/AGENTS.md b/Shared/Periscope/PeriscopeTools/AGENTS.md index c9e51c3d..3aa1313b 100644 --- a/Shared/Periscope/PeriscopeTools/AGENTS.md +++ b/Shared/Periscope/PeriscopeTools/AGENTS.md @@ -21,7 +21,14 @@ the build system, formatting, and global conventions. Read that first. 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. + 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; writes go + through, never around it. +- **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. ## Testing diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md index f98af51c..a0264c7b 100644 --- a/Shared/Periscope/PeriscopeTools/README.md +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -6,9 +6,6 @@ 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. -> **Status:** the viewer, tracer, and toast have landed; the log view mode -> lands incrementally. - ## Installation `PeriscopeTools` is a local SPM library in this repo @@ -19,6 +16,31 @@ reveals the events behind any wrapped view. .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 @@ -26,18 +48,28 @@ reveals the events behind any wrapped view. / scope subtree / session, paged, with per-event detail (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. - -The debug toast and log view mode land incrementally — see the sources for -what exists today. +- **`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. ## Testing Swift Testing in [`Tests/`](Tests), hosted in `StuffTestHost` -(`PeriscopeToolsTests` bundle). Run with `tuist test PeriscopeToolsTests`. +(`PeriscopeToolsTests` bundle): models are driven directly over in-memory +stores (`@_spi(Testing) PeriscopeStore.inMemory`), views host via +`WhereTesting.show`. Run with `tuist test PeriscopeToolsTests`. diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift b/Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift new file mode 100644 index 00000000..a26df11c --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift @@ -0,0 +1,112 @@ +import PeriscopeCore +import SwiftUI + +extension View { + /// Mark this view inspectable in "log view mode": when the environment + /// ``PeriscopeInspector`` is enabled, the view gains a badge that opens + /// every stored event in the given context's scope subtrees — e.g. wrap + /// a payment row and see everything associated with that payment. With + /// no inspector or the mode off, the view renders unchanged. + public func logInspectable(_ log: Log) -> some View { + modifier(LogInspectableModifier(scopes: log.scopes.map(\.id))) + } + + /// Inspectability keyed to a `LogContextProviding` model's instance + /// context — `.logInspectable(payment)`. + public func logInspectable(_ provider: some LogContextProviding) -> some View { + logInspectable(provider.log) + } +} + +struct LogInspectableModifier: ViewModifier { + let scopes: [ScopeID] + + @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) + } + } + } + } + } +} + +/// 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] + + @State private var model: LogInspectorModel + @Environment(\.dismiss) private var dismiss + + init(store: PeriscopeStore, scopes: [ScopeID]) { + self.store = store + self.scopes = scopes + _model = State(initialValue: LogInspectorModel(store: store, inspectedScopes: scopes)) + } + + var body: some View { + content + .navigationTitle("Element Logs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + .task { + 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/LogInspectorModel.swift b/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift new file mode 100644 index 00000000..0bebb94b --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift @@ -0,0 +1,78 @@ +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) + } + + static let limit = 200 + + private let store: PeriscopeStore + private let inspectedScopes: [ScopeID] + + private(set) var state: LoadState = .loading + private(set) var scopes: [ScopeID: LogScope] = [:] + + init(store: PeriscopeStore, inspectedScopes: [ScopeID]) { + self.store = store + self.inspectedScopes = inspectedScopes + } + + 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. + func run() async { + await load() + for await _ in await store.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 = Self.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(Self.limit))) + } catch { + state = .failed(String(describing: error)) + } + } + + func scopePath(for event: StoredLogEvent) -> String { + guard let primary = event.primaryScope else { return "" } + var names: [String] = [] + var next: ScopeID? = primary + while let id = next, let scope = scopes[id] { + names.append(scope.name) + next = scope.parentID + } + return names.reversed().joined(separator: " / ") + } +} diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift new file mode 100644 index 00000000..339155fe --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift @@ -0,0 +1,54 @@ +import Observation +import PeriscopeCore +import SwiftUI + +/// The observable face of "log view mode": wraps a `Periscope` system's +/// inspect flag (kept in sync both ways through ``isEnabled``) 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 + + /// Whether log view mode is on. Writes through to + /// `Periscope.isInspectModeEnabled`, the flag's source of truth for + /// non-UI callers. + 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 + } +} + +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/Tests/LogInspectableHostingTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogInspectableHostingTests.swift new file mode 100644 index 00000000..e5a45f4d --- /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 Testing +import UIKit +import WhereTesting + +@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]) + }) + 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..efea868e --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift @@ -0,0 +1,60 @@ +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]) + 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], + ) + 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]) + 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 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]) + 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/PeriscopeInspectorTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift new file mode 100644 index 00000000..9bc33e69 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift @@ -0,0 +1,28 @@ +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) + } +} From 0361a2e42161bc2e57f402f04732185f83e2b5ac Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 14:03:37 -0400 Subject: [PATCH 19/73] Roll back failed PeriscopeStore saves so one bad batch can't wedge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed save previously left the ModelContext dirty: the poisoned batch's inserts stayed staged, every subsequent save re-attempted them, and a persistent failure silently lost all events from then on (a failed prune could even commit its staged deletions with the next unrelated write). Failure paths now run recoverFromFailedWrite(): rollback the transaction, drop the scope/tag row caches (they may hold rolled-back rows), and clear the session-row reference — the store now remembers its session identity as a value, and ensureActiveSession refetches or reinserts the same session on the next write, so recovery never forks the launch attribution. Throwing APIs (startSession, the prune/delete family) roll back and rethrow. Adds a DEBUG-gated @_spi(Testing) injectNextWriteFailure seam at the save funnels and covers each path: poisoned batch then healthy write, stale cache recovery with tags/placeholder scopes, session identity across recovery, scope-definition retry, and prune rollback. Addresses code-review finding 1 (store failure leaves context dirty). --- Shared/Periscope/PeriscopeCore/AGENTS.md | 4 + .../Sources/PeriscopeStore.swift | 84 +++++++++++++- .../Tests/PeriscopeStoreTests.swift | 103 ++++++++++++++++++ 3 files changed, 185 insertions(+), 6 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index 2fcf689c..fb567d3c 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -31,6 +31,10 @@ the build system, formatting, and global conventions. Read that first. - **Sink failures never propagate or vanish** — the store logs them to OSLog and counts them; 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. diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index eb45287b..d0c37238 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -13,7 +13,9 @@ import SwiftData /// 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. +/// 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: /// @@ -40,6 +42,9 @@ public actor PeriscopeStore: LogSink { 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] = [:] @@ -47,6 +52,10 @@ public actor PeriscopeStore: LogSink { 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( @@ -78,9 +87,15 @@ public actor PeriscopeStore: LogSink { /// Record `session` as this launch's resource metadata; every event /// written afterwards references it. public func startSession(_ session: LogSession) throws { + activeSession = session let row = SDLogSession(session: session) modelContext.insert(row) - try modelContext.save() + do { + try modelContext.save() + } catch { + recoverFromFailedWrite() + throw error + } activeSessionRow = row } @@ -92,13 +107,27 @@ public actor PeriscopeStore: LogSink { return try modelContext.fetch(descriptor).map(\.toValue) } - /// The launch-attribution row, created from `LogSession.current()` when - /// the app never called ``startSession(_:)`` explicitly. + /// 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 row = SDLogSession(session: .current()) + 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 @@ -111,8 +140,10 @@ public actor PeriscopeStore: LogSink { 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)") } @@ -124,6 +155,7 @@ public actor PeriscopeStore: LogSink { try persist(records) notifyChanged() } catch { + recoverFromFailedWrite() writeFailures += 1 Self.failureLogger.error("Failed to persist \(records.count) log events: \(error)") } @@ -138,6 +170,37 @@ public actor PeriscopeStore: LogSink { writeFailures } + #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 { @@ -199,6 +262,7 @@ public actor PeriscopeStore: LogSink { ) modelContext.insert(row) } + try throwInjectedFailureIfPending() try modelContext.save() } @@ -443,7 +507,15 @@ public actor PeriscopeStore: LogSink { for row in rows { modelContext.delete(row) } - try modelContext.save() + do { + try throwInjectedFailureIfPending() + try modelContext.save() + } catch { + // Roll the staged deletions back — otherwise the next unrelated + // save would silently commit them. + recoverFromFailedWrite() + throw error + } notifyChanged() return rows.count } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 4b0245d2..0ad1d071 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -2,6 +2,8 @@ import Foundation @_spi(Testing) import PeriscopeCore import Testing +private struct InjectedSaveFailure: Error {} + struct PeriscopeStoreTests { private func date(_ offset: TimeInterval) -> Date { Date(timeIntervalSinceReferenceDate: offset) @@ -356,6 +358,107 @@ struct PeriscopeStoreTests { #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])]) + + let events = try await store.events(matching: LogQuery()) + #expect(events.map(\.message) == ["healthy"]) + #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: [key: "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: [key: "pay_1"], + ), + ]) + + #expect(try await store.scope(for: scope.id) == scope) + var query = LogQuery() + query.tag = 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]) From db7092b7202eb3a61308cfe06655ee430030d679 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 14:12:45 -0400 Subject: [PATCH 20/73] Evict instance scopes on dealloc; key the registry by InstanceID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InstanceScopeRegistry keyed instances by bare ObjectIdentifier, so a deallocated object's recycled pointer handed its cached identity — and its scope — to whatever object landed at that address next, even one of a different type. (The new InstanceIDTests demonstrated the reuse live: two back-to-back temporaries compared equal by address.) Two changes: InstanceID is the new cache key — pointer identity plus dynamic type, with a debugDescription that names the type — so a recycled address can never cross types; and each tracked instance now carries a retained deallocation tracker (ObjC associated object, keyed per registry so one object logged into two systems tracks both) whose deinit evicts the cache entry before the allocator can recycle the address, closing the same-type case too. Trackers hold the registry weakly so long-lived objects don't pin short-lived test registries. Instance numbers stay monotonic — #3 always means one specific instance within a run — and the registry no longer grows unboundedly. Addresses code-review finding 2 (instance identity via recycled pointers). --- Shared/Periscope/PeriscopeCore/AGENTS.md | 8 ++- Shared/Periscope/PeriscopeCore/README.md | 6 +- .../PeriscopeCore/Sources/InstanceID.swift | 36 ++++++++++ .../Sources/LogContextProviding.swift | 72 +++++++++++++++---- .../PeriscopeCore/Tests/InstanceIDTests.swift | 43 +++++++++++ .../Tests/LogContextProvidingTests.swift | 62 +++++++++++++++- 6 files changed, 209 insertions(+), 18 deletions(-) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/InstanceID.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index fb567d3c..626f1852 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -10,9 +10,11 @@ the build system, formatting, and global conventions. Read that first. ## Scope & dependencies -- **Foundation + os + SwiftData + Network only.** No SwiftUI, no app code, no - LogKit. UIKit is allowed **only** inside `#if canImport(UIKit)` (ambient - sources, the image-attachment convenience). +- **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. diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index 681b7224..3cd516c1 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -112,8 +112,10 @@ the type, and tooling degrades to raw JSON when it can't. via the redaction hook. - 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 for the - process lifetime — meant for controllers/models, not per-request values. +- `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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/InstanceID.swift b/Shared/Periscope/PeriscopeCore/Sources/InstanceID.swift new file mode 100644 index 00000000..5f9897bf --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/InstanceID.swift @@ -0,0 +1,36 @@ +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 + + /// Identity of the instance's dynamic type. + let typeID: ObjectIdentifier + + /// The dynamic type's name, e.g. `"PhotoController"`. + public let typeName: String + + public init(of instance: AnyObject) { + object = ObjectIdentifier(instance) + let dynamicType = type(of: instance) + typeID = ObjectIdentifier(dynamicType) + typeName = String(describing: dynamicType) + } + + public var debugDescription: String { + "\(typeName)@0x\(String(UInt(bitPattern: object), radix: 16))" + } + + public static func == (lhs: InstanceID, rhs: InstanceID) -> Bool { + lhs.object == rhs.object && lhs.typeID == rhs.typeID + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(object) + hasher.combine(typeID) + } +} diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift b/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift index 430e3a23..1258fbc6 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift @@ -1,4 +1,5 @@ import Foundation +import ObjectiveC import os /// Gives a class a derived `.log` without passing loggers around. @@ -62,33 +63,80 @@ struct InstanceScopePair { /// 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 one small struct per logging instance and live for the -/// process — intended for controllers and model objects, not per-request -/// throwaways. +/// 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: [ObjectIdentifier: InstanceScopePair] = [:] + 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 = ObjectIdentifier(object) - let typeName = String(describing: type(of: object)) - return state.withLock { state in + let id = InstanceID(of: object) + let (pair, isNew) = state.withLock { state -> (InstanceScopePair, Bool) in if let cached = state.scopesByInstance[id] { - return cached + return (cached, false) } - let index = state.nextIndexByType[typeName, default: 1] - state.nextIndexByType[typeName] = index + 1 - let typeScope = LogScope.root(named: typeName) + 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 + 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/Tests/InstanceIDTests.swift b/Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift new file mode 100644 index 00000000..883bb899 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift @@ -0,0 +1,43 @@ +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") + } + + @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/LogContextProvidingTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift index 8d2011be..f3886837 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogContextProvidingTests.swift @@ -1,5 +1,5 @@ import Foundation -import PeriscopeCore +@testable import PeriscopeCore import Testing private final class FreeformController: LogContextProviding { @@ -96,4 +96,64 @@ struct LogContextProvidingTests { 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) + } } From 6a91d8cf3715336ebde64ade6b0db2aeeb1641a7 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 14:17:40 -0400 Subject: [PATCH 21/73] Subscribe to store changes before the initial load in live models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeriscopeViewerModel.run() and LogInspectorModel.run() loaded first and subscribed to store.changes() after, so a commit landing between the two never triggered a refresh — a live viewer could sit stale until the next unrelated write. Both now acquire the stream before loading; a mid-load commit buffers in the stream and refreshes right after. Adds live-refresh tests for both models (previously untested), racing a write against the initial load deliberately. Addresses code-review finding 3 (subscribe-after-load gap). --- .../Sources/LogInspectorModel.swift | 7 +++++-- .../Sources/PeriscopeViewerModel.swift | 8 ++++++-- .../Tests/LogInspectorModelTests.swift | 11 +++++++++++ .../Tests/PeriscopeViewerModelTests.swift | 19 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift b/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift index 0bebb94b..14c02939 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift @@ -33,10 +33,13 @@ final class LogInspectorModel { } /// Initial load plus live refresh — run from `.task` so dismissing the - /// sheet cancels the stream. + /// 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 await store.changes() { + for await _ in changes { guard !Task.isCancelled else { return } await load() } diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift index 854f6031..3cf3fbe8 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift @@ -84,10 +84,14 @@ final class PeriscopeViewerModel { } /// Initial load plus live refresh — run from `.task` so leaving the - /// screen cancels the stream. + /// 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 await store.changes() { + for await _ in changes { guard !Task.isCancelled else { return } await load() } diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift index efea868e..f73bc2de 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift @@ -47,6 +47,17 @@ struct LogInspectorModelTests { } } + @Test func runRefreshesLiveWhenTheStoreCommits() async throws { + let (store, _, photos, album) = try await makeSeededStore() + let model = LogInspectorModel(store: store, inspectedScopes: [photos.id]) + 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])]) diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift index ca7ed677..83d2281e 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift @@ -124,6 +124,25 @@ struct PeriscopeViewerModelTests { #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([ From eb096ac7d5e31edfd9454cd420fc8d39e1f6490e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 14:18:36 -0400 Subject: [PATCH 22/73] Coalesce auto-flushes: one task per storm, not one per record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every record at the flush threshold spawned its own Task { flush() }, each looping on the drain until the system went quiet — an error storm piled up one task per record for no added durability. Records now request the flush through a coalescing gate: a single auto-flush task runs at a time, and qualifying records that land mid-flush set a pending flag that grants exactly one follow-up flush (covering their durability) before the task retires. Deterministic storm test holds the drain with a gated sink while 50 errors land and asserts at most two sink flushes; a second test covers re-arming after settling. Addresses code-review finding 4 (auto-flush task pileup). --- .../PeriscopeCore/Sources/Periscope.swift | 36 ++++++++++++++- .../PeriscopeCore/Tests/PeriscopeTests.swift | 44 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 54b419a2..0726c50c 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -106,6 +106,11 @@ public final class Periscope: LogRecorder, Sendable { var inspectModeEnabled = false /// The active drain task; `nil` exactly when nothing is draining. var drainTask: Task? + /// 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. @@ -231,7 +236,36 @@ public final class Periscope: LogRecorder, Sendable { } scheduleDrainIfNeeded() if record.level >= configuration.flushThreshold { - Task { await self.flush() } + scheduleAutoFlush() + } + } + + /// 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 } } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 17ac66b9..7ce3e312 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -272,6 +272,50 @@ struct PeriscopeTests { #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) From 4ee72993efc1943d32c49da70d79ec6d79f10592 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 14:19:47 -0400 Subject: [PATCH 23/73] Bound liveRecords() observer buffers with bufferingNewest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit liveRecords() streams used AsyncStream's default unbounded buffering, so a slow or stuck consumer (a paused toast handler, a backgrounded inspector) accumulated every emitted record in memory indefinitely. Streams now buffer with .bufferingNewest under the new Configuration.liveBufferCapacity (default 256): a consumer that falls behind loses the oldest buffered records — live surfaces want the newest activity, and durable history is the store's job. Test covers the drop-oldest behavior and normal flow after catching up. Addresses code-review finding 5 (unbounded live-stream buffering). --- .../PeriscopeCore/Sources/Periscope.swift | 20 ++++++++++++++++- .../PeriscopeCore/Tests/PeriscopeTests.swift | 22 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 0726c50c..9174743f 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -42,6 +42,12 @@ public final class Periscope: LogRecorder, Sendable { /// 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. @@ -55,11 +61,13 @@ public final class Periscope: LogRecorder, Sendable { 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 } @@ -131,6 +139,10 @@ public final class Periscope: LogRecorder, Sendable { 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) @@ -346,9 +358,15 @@ public final class Periscope: LogRecorder, Sendable { /// 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 { continuation in + return AsyncStream( + bufferingPolicy: .bufferingNewest(configuration.liveBufferCapacity), + ) { continuation in state.withLock { state in state.observers[id] = continuation } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 7ce3e312..5e16807d 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -102,6 +102,28 @@ struct PeriscopeTests { #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 flushIsSafeWhenNothingIsPending() async { let system = makeSystem() await system.flush() From 7864429295ead08d29c35be1d3cc7c108c292d95 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 14:43:46 -0400 Subject: [PATCH 24/73] Add span lifecycle: lifetimes, exits, expiry, relaunch policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spans could leak forever: a lost end(for:) left its entry (and its signpost interval) in memory permanently, and permanently locked out re-begins for that key. Spans now have full lifecycle semantics: - SpanLifetime (explicit on begin): .bounded(budget:) spans are closed as .expired by a deadline-driven watchdog when they outlive their budget (generation-tagged task, earliest-deadline sleep; the sweep is clock-injectable via @_spi so tests never sleep); .indefinite is the conscious opt-in for open-ended flows; .scoped marks measure spans. - SpanExit on every SpanEnded: success/failure/cancelled (with optional reasons) plus system modes superseded/expired/orphaned. measure now derives exits automatically — thrown errors record as .failure and CancellationError as .cancelled instead of masquerading as success. Abnormal exits log at .warning; messages describe the exit. - Re-begins supersede: beginning an already-open key closes the prior span as .superseded (attributed with its begin-time scopes and tags, which OpenSpan now carries) instead of refusing — no lockout. - SpanRelaunchPolicy, recorded on the SpanBegan payload: at startSession the store closes unmatched endsWithProcess spans from earlier sessions as .orphaned (duration nil — unknowable across process death); survivesRelaunch spans stay open, with resume mechanics staged in Shared/Periscope/TODOs.md. SpanBegan/SpanEnded bump to eventVersion 2 (first real exercise of the versioned-payload contract). Adds Shared/Periscope/TODOs.md, seeded with the staged span work and the remaining code-review findings. Addresses code-review finding 6 (open-span and signpost leaks). --- Shared/Periscope/PeriscopeCore/AGENTS.md | 6 + Shared/Periscope/PeriscopeCore/README.md | 10 +- .../PeriscopeCore/Sources/LogRecorder.swift | 7 +- .../PeriscopeCore/Sources/LogSpan.swift | 208 +++++++++++++++--- .../PeriscopeCore/Sources/Periscope.swift | 97 +++++++- .../Sources/PeriscopeStore.swift | 60 ++++- .../PeriscopeCore/Sources/SpanExit.swift | 78 +++++++ .../PeriscopeCore/Tests/LogSpanTests.swift | 123 +++++++++-- .../Tests/PeriscopeCoreTestSupport.swift | 7 +- .../Tests/PeriscopeStoreTests.swift | 115 +++++++++- .../PeriscopeCore/Tests/PeriscopeTests.swift | 75 ++++++- .../PeriscopeCore/Tests/SpanExitTests.swift | 47 ++++ .../Tests/LogTraceModelTests.swift | 14 +- Shared/Periscope/TODOs.md | 41 ++++ 14 files changed, 812 insertions(+), 76 deletions(-) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift create mode 100644 Shared/Periscope/TODOs.md diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index 626f1852..13bd1a76 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -40,6 +40,12 @@ the build system, formatting, and global conventions. Read that first. - **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.** `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)). ## Testing diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index 3cd516c1..749bcb7c 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -79,8 +79,14 @@ Periscope.shared.startDefaultAmbientSources() `@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; `begin(for:)`/`end(for:)` for open-ended - spans. Durations use `ContinuousClock`; spans mirror to `OSSignposter`. + `SpanBegan`/`SpanEnded` events with the exit derived automatically + (return → `.success`, throw → `.failure`, `CancellationError` → + `.cancelled`); `begin(for:lifetime:relaunch:)`/`end(for:exit:)` for + open-ended spans. 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. diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift index 891b2ca4..920b94b0 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift @@ -20,9 +20,10 @@ public protocol LogRecorder: Sendable { /// policy inside `record`. func shouldRecord(level: LogLevel, scopes: [ScopeID]) -> Bool - /// Track a span opened by `Log.begin(for:)`. Returns the new span's ID, - /// or `nil` when `key` is already open. - func openSpan(key: SpanKey, name: String, start: ContinuousClock.Instant) -> SpanID? + /// Track a span opened by `Log.begin(for:lifetime:relaunch:)`. When + /// `key` was already open, the prior span is returned (removed) so the + /// caller can close it as superseded. + func openSpan(key: SpanKey, span: OpenSpan) -> OpenSpan? /// Stop tracking and return the open span for `key`, if any. func closeSpan(key: SpanKey) -> OpenSpan? diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift index a01c1fc1..3b193a1f 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift @@ -30,39 +30,86 @@ extension SpanID: Codable { } /// Marks the start of a timed span (`Log.measure` / `Log.begin(for:)`). +/// Carries the span's lifetime and relaunch policy so the watchdog and the +/// relaunch sweep can honor them from the persisted payload alone. public struct SpanBegan: LogEvent { public static let eventName = "span-began" + public static let eventVersion = 2 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) { + 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, carrying the measured duration -/// (monotonic — `ContinuousClock`, not wall-clock deltas). +/// 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 public let spanID: SpanID public let name: String - public let duration: Duration + 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 { - "◀ \(name) (\(duration.formatted()))" + 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) { + 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" + } } } @@ -95,16 +142,31 @@ public struct SpanKey: Hashable, Sendable { } } -/// A span begun with `Log.begin(for:)` that hasn't ended yet. +/// 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. public struct OpenSpan: Sendable { public let id: SpanID public let name: String public let start: ContinuousClock.Instant + public let lifetime: SpanLifetime + public let scopes: [ScopeID] + public let tags: [LogTagKey: String] - public init(id: SpanID, name: String, start: ContinuousClock.Instant) { + public init( + id: SpanID, + name: String, + start: ContinuousClock.Instant, + lifetime: SpanLifetime, + scopes: [ScopeID], + tags: [LogTagKey: String], + ) { self.id = id self.name = name self.start = start + self.lifetime = lifetime + self.scopes = scopes + self.tags = tags } } @@ -142,9 +204,11 @@ enum SpanSignposts { /// raw strings. extension Log { /// Times `body` between paired ``SpanBegan``/``SpanEnded`` events - /// sharing one ``SpanID``. The end event is emitted even when `body` - /// throws. Names resolve against `Event.SpanName`, so typed events get - /// leading-dot tokens (`log.measure(.saveEvent) { … }`). + /// 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 { let span = SpanID() @@ -152,12 +216,25 @@ extension Log { let clock = ContinuousClock() let start = clock.now SpanSignposts.begin(span, name: spanName) - emit(SpanBegan(spanID: span, name: spanName)) - defer { - SpanSignposts.end(span) - emit(SpanEnded(spanID: span, name: spanName, duration: clock.now - start)) + emit(SpanBegan( + spanID: span, + name: spanName, + lifetime: .scoped, + relaunchPolicy: .endsWithProcess, + )) + do { + let result = try body() + endMeasuredSpan(span, name: spanName, duration: clock.now - start, exit: .success) + return result + } catch { + endMeasuredSpan( + span, + name: spanName, + duration: clock.now - start, + exit: Self.exit(for: error), + ) + throw error } - return try body() } /// The `async` form of `measure`; preserves the caller's isolation. @@ -172,34 +249,94 @@ extension Log { let clock = ContinuousClock() let start = clock.now SpanSignposts.begin(span, name: spanName) - emit(SpanBegan(spanID: span, name: spanName)) - defer { - SpanSignposts.end(span) - emit(SpanEnded(spanID: span, name: spanName, duration: clock.now - start)) + emit(SpanBegan( + spanID: span, + name: spanName, + lifetime: .scoped, + relaunchPolicy: .endsWithProcess, + )) + do { + let result = try await body() + endMeasuredSpan(span, name: spanName, duration: clock.now - start, exit: .success) + return result + } catch { + endMeasuredSpan( + span, + name: spanName, + duration: clock.now - start, + exit: Self.exit(for: error), + ) + throw error } - return try await body() + } + + private func endMeasuredSpan( + _ span: SpanID, + name: String, + duration: Duration, + exit: SpanExit, + ) { + SpanSignposts.end(span) + emit(SpanEnded(spanID: span, name: name, duration: duration, exit: exit)) + } + + 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:)`` from any logger with - /// the same primary scope. Beginning an already-open span logs a - /// warning instead of restarting it. - public func begin(for id: some Hashable & Sendable) { + /// 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) - guard let span = recorder.openSpan(key: key, name: name, start: ContinuousClock().now) - else { - warning("begin(for: \(name)) while that span is already open") - return + let span = OpenSpan( + id: SpanID(), + name: name, + start: ContinuousClock().now, + lifetime: lifetime, + scopes: scopes.map(\.id), + tags: tags, + ) + if let superseded = recorder.openSpan(key: key, span: span) { + SpanSignposts.end(superseded.id) + recorder.record(LogRecord( + date: Date(), + event: SpanEnded( + spanID: superseded.id, + name: superseded.name, + duration: span.start - superseded.start, + exit: .superseded, + ), + scopes: superseded.scopes, + tags: superseded.tags, + )) } - SpanSignposts.begin(span, name: name) - emit(SpanBegan(spanID: span, name: name)) + SpanSignposts.begin(span.id, name: name) + emit(SpanBegan( + spanID: span.id, + name: name, + lifetime: lifetime, + relaunchPolicy: relaunch, + )) } - /// Close the span opened with ``begin(for:)`` for the same identifier, - /// emitting the ``SpanEnded`` with the measured duration. Ending a span - /// that isn't open logs a warning. - public func end(for id: some Hashable & Sendable) { + /// 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 { @@ -211,6 +348,7 @@ extension Log { spanID: open.id, name: open.name, duration: ContinuousClock().now - open.start, + exit: exit, )) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 9174743f..1a0ccf82 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -114,6 +114,11 @@ public final class Periscope: LogRecorder, Sendable { var inspectModeEnabled = false /// 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; @@ -331,23 +336,97 @@ public final class Periscope: LogRecorder, Sendable { // MARK: Open spans - public func openSpan( - key: SpanKey, - name: String, - start: ContinuousClock.Instant, - ) -> SpanID? { - state.withLock { state in - guard state.openSpans[key] == nil else { return nil } - let span = OpenSpan(id: SpanID(), name: name, start: start) + public func openSpan(key: SpanKey, span: OpenSpan) -> OpenSpan? { + let superseded = state.withLock { state in + let prior = state.openSpans.removeValue(forKey: key) state.openSpans[key] = span - return span.id + return prior } + scheduleWatchdogIfNeeded() + return superseded } public func closeSpan(key: SpanKey) -> OpenSpan? { state.withLock { $0.openSpans.removeValue(forKey: key) } } + /// 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 } + record(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, + )) + } + } + + /// 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 + state.watchdogTask = Task { await self.runWatchdog(generation: generation) } + } + } + + private func runWatchdog(generation: Int) async { + while true { + let wakeAt: 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 + } + guard let wakeAt else { return } + try? await Task.sleep(until: wakeAt, clock: .continuous) + if Task.isCancelled { return } + sweepOverdueSpans(now: ContinuousClock().now) + } + } + // MARK: Live records /// The most recent records, oldest first (bounded by diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index d0c37238..1cace75e 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -85,7 +85,9 @@ public actor PeriscopeStore: LogSink { // MARK: Sessions /// Record `session` as this launch's resource metadata; every event - /// written afterwards references it. + /// 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) @@ -97,6 +99,62 @@ public actor PeriscopeStore: LogSink { 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 + let began = try modelContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.eventName == beganName && $0.sessionID != startedSessionID + }, + )) + guard !began.isEmpty else { return } + let ended = try modelContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.eventName == endedName }, + )) + let endedSpanIDs = Set(ended.compactMap(\.spanID)) + + var orphans: [LogRecord] = [] + for row in began { + guard let spanID = row.spanID, !endedSpanIDs.contains(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: Dictionary( + row.tags.map { (LogTagKey($0.key), $0.value) }, + uniquingKeysWith: { first, _ in first }, + ), + )) + } + 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. diff --git a/Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift b/Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift new file mode 100644 index 00000000..5ca4c072 --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift @@ -0,0 +1,78 @@ +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, 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 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/Tests/LogSpanTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift index 6d8cd1a7..30774126 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift @@ -30,12 +30,16 @@ struct LogSpanTests { 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.duration >= .zero) + #expect(ended.exit == .success) + let duration = try #require(ended.duration) + #expect(duration >= .zero) #expect(records.allSatisfy { $0.scopes == log.scopes.map(\.id) }) } - @Test func measureEmitsTheEndEvenWhenTheBodyThrows() throws { + @Test func measureRecordsThrownErrorsAsFailures() throws { let log = Log(recorder: recorder) #expect(throws: MeasureError.self) { @@ -43,7 +47,22 @@ struct LogSpanTests { } #expect(recorder.records.count == 2) - #expect(recorder.records.last?.event is SpanEnded) + 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 { @@ -57,6 +76,7 @@ struct LogSpanTests { let ended = try #require(recorder.records.last?.event as? SpanEnded) #expect(ended.name == "async-save") + #expect(ended.exit == .success) } @Test func typedSpanTokensResolveAgainstTheEventType() throws { @@ -70,25 +90,38 @@ struct LogSpanTests { @Test func beginAndEndPairAcrossRebuiltLoggers() throws { let first = Log(recorder: recorder)(for: "payment-flow") - first.begin(for: "pay_1") + 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") + 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.duration >= .zero) + #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") + log.end(for: "never-began", exit: .success) let records = recorder.records #expect(records.count == 1) @@ -96,19 +129,43 @@ struct LogSpanTests { #expect(records.first?.message.contains("without a matching begin") == true) } - @Test func doubleBeginWarnsAndKeepsTheOriginalSpanOpen() throws { + @Test func rebeginningSupersedesTheOpenSpan() throws { let log = Log(recorder: recorder) - log.begin(for: "pay_1") - log.begin(for: "pay_1") - log.end(for: "pay_1") + log.begin(for: "pay_1", lifetime: .indefinite) + log.begin(for: "pay_1", lifetime: .indefinite) + log.end(for: "pay_1", exit: .success) let records = recorder.records - #expect(records.count == 3) - #expect(records[1].level == .warning) - let began = try #require(records[0].event as? SpanBegan) - let ended = try #require(records[2].event as? SpanEnded) - #expect(began.spanID == ended.spanID) + #expect(records.count == 4) + let firstBegan = try #require(records[0].event as? SpanBegan) + let superseded = try #require(records[1].event as? SpanEnded) + let secondBegan = try #require(records[2].event as? SpanBegan) + 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 == [key: "pay_1"]) + #expect(superseded.scopes == original.scopes.map(\.id)) } @Test func recordsExposeTheirSpanID() throws { @@ -125,10 +182,42 @@ struct LogSpanTests { @Test func spanEventsRoundTripThroughCodable() throws { let span = SpanID() - let ended = SpanEnded(spanID: span, name: "save", duration: .milliseconds(12)) + 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/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift index d7030e78..a333c100 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -32,12 +32,11 @@ final class RecordingRecorder: LogRecorder, Sendable { true } - func openSpan(key: SpanKey, name: String, start: ContinuousClock.Instant) -> SpanID? { + func openSpan(key: SpanKey, span: OpenSpan) -> OpenSpan? { state.withLock { state in - guard state.openSpans[key] == nil else { return nil } - let span = OpenSpan(id: SpanID(), name: name, start: start) + let prior = state.openSpans.removeValue(forKey: key) state.openSpans[key] = span - return span.id + return prior } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 0ad1d071..21b81bed 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -208,13 +208,23 @@ struct PeriscopeStoreTests { await store.write([ LogRecord( date: date(1), - event: SpanBegan(spanID: span, name: "save"), + 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)), + event: SpanEnded( + spanID: span, + name: "save", + duration: .seconds(2), + exit: .success, + ), scopes: [root.id], ), ]) @@ -229,6 +239,107 @@ struct PeriscopeStoreTests { #expect(unrelated?.spanID == nil) } + // MARK: Orphaned spans + + private func writeSpanBegan( + _ store: PeriscopeStore, + span: SpanID, + name: String, + relaunch: SpanRelaunchPolicy, + scope: LogScope, + tags: [LogTagKey: String] = [:], + ) 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: [key: "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 == [key: "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") diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 5e16807d..631a643c 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -1,5 +1,5 @@ import Foundation -import PeriscopeCore +@_spi(Testing) import PeriscopeCore import Testing struct PeriscopeTests { @@ -348,6 +348,79 @@ struct PeriscopeTests { #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 == [key: "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 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) + } + // MARK: Drop policy @Test func overflowingThePendingQueueDropsOldestAndReportsTheGap() async throws { diff --git a/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift b/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift new file mode 100644 index 00000000..013e648b --- /dev/null +++ b/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift @@ -0,0 +1,47 @@ +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 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/PeriscopeTools/Tests/LogTraceModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift index a26d79e0..91e1f525 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift @@ -67,12 +67,22 @@ struct LogTraceModelTests { await store.write([ LogRecord( date: date(1), - event: SpanBegan(spanID: span, name: "save"), + 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)), + event: SpanEnded( + spanID: span, + name: "save", + duration: .seconds(1), + exit: .success, + ), scopes: [album.id], ), ]) diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md new file mode 100644 index 00000000..96ff8125 --- /dev/null +++ b/Shared/Periscope/TODOs.md @@ -0,0 +1,41 @@ +# 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) +- 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. +- perf: `events(matching:)` faults `tags` and `attachments` relationships per fetched row (N+1). Set `relationshipKeyPathsForPrefetching` on the fetch descriptors. (Code review finding 8.) +- perf: `Periscope.chunked(_:)` groups pending items with `array + [element]`, which is quadratic in batch size — a full 5,000-record backlog does millions of element copies inside the drain. Append into a mutable current-chunk buffer instead. (Code review finding 7.) + +## P1s (Should do) +- fix: Run redaction after the level-floor check in `Periscope.record` — today user redaction code executes (and touches PII) for records the floor is about to discard. +- fix: PeriscopeTools/PeriscopeUI don't compile for native macOS (`navigationBarTitleDisplayMode`, `.topBarTrailing`) even though `Package.swift` advertises `.macOS(.v26)` for all products. Nothing in CI builds them for macOS today, so this is a latent break for the first macOS consumer (`LogViewerUI` has the same issue as precedent). Either gate the iOS-only modifiers or stop advertising macOS for the UI modules. +- fix: The tracer window (`LogQuery.end = origin.date`) is inclusive and sequence isn't in the predicate, so same-millisecond events *after* the origin appear in "leading up to it". +- test: Seeded fuzz/adversarial test for the pipeline state machine (concurrent emit + `add(sink:)` + flush + drop interleavings), per the repo convention for many-branch machinery. +- feat: Optional budget for `measure` spans (`log.measure(.saveEvent, budget: .seconds(1))`) — closure spans can't leak, but "this save should never take >1s" deserves the same overdue signal `begin` spans get. + +## P2s (Nice to have) +- fix: `NetworkPathAmbientSource.start` called twice silently replaces the boxed monitor without cancelling the old one; ambient sources in general have no stop/double-start story. +- refactor: `PeriscopeViewer`/`LogInspectorView`/`LogTraceView` capture their model via `State(initialValue:)`; a parent that later passes a different store/origin keeps the stale model. Fine for dev tools — document or key the views. +- fix: `PeriscopeInspector` syncs one-way after init; direct writes to `Periscope.isInspectModeEnabled` don't reflect back into the observable mirror. +- feat: An "open spans" developer surface (viewer section listing currently-open spans with age and budget) — the data exists in `Periscope.openSpans`. +- feat: Surface span exits in the viewer beyond the message text (badge tint by exit mode, filter by exit). +- test: `PeriscopeAlerterTests.startingTwiceDoesNotDuplicateAlerts` asserts after a single `Task.yield()`, so a duplicate could land just after the assertion passes — wait for a sentinel instead. +- docs: `log(PhotoLogs.self) { event }` parses as one call and fails to compile; document the required two-step spelling near `callAsFunction`. +- perf: `LocalNotificationAlertHandler` requests notification authorization on every alert; cache the grant. + +# Completed issues + +## 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.) From 55c7937583d82ef84f6659f0c03e67dc1bb5224a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 14:51:21 -0400 Subject: [PATCH 25/73] Make drain chunking linear instead of quadratic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Periscope.chunked(_:) grouped pending items by rewriting the last chunk with array + [element], copying the accumulated run on every iteration — quadratic in batch size, so a full 5,000-record backlog did ~12.5M element copies inside the drain. Runs now accumulate in mutable buffers and close when the item kind flips: O(n). Adds a gated-drain regression test asserting an interleaved backlog (records, scope definitions, records, …) reaches the sink as ordered runs. Addresses code-review finding 7 (quadratic chunking). --- .../PeriscopeCore/Sources/Periscope.swift | 37 +++++++++++++----- .../PeriscopeCore/Tests/PeriscopeTests.swift | 38 +++++++++++++++++++ Shared/Periscope/TODOs.md | 3 +- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 1a0ccf82..b91611e4 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -544,21 +544,38 @@ public final class Periscope: LogRecorder, Sendable { } /// Group consecutive pending items so order is preserved while sinks - /// still receive batches. + /// 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 (chunks.last, item) { - case let (.scopes(scopes), .scope(scope)): - chunks[chunks.count - 1] = .scopes(scopes + [scope]) - case let (.records(records), .record(record)): - chunks[chunks.count - 1] = .records(records + [record]) - case let (_, .scope(scope)): - chunks.append(.scopes([scope])) - case let (_, .record(record)): - chunks.append(.records([record])) + switch item { + case let .scope(scope): + closeRecordRun() + scopeRun.append(scope) + case let .record(record): + closeScopeRun() + recordRun.append(record) } } + closeScopeRun() + closeRecordRun() return chunks } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 631a643c..fc9e914d 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -131,6 +131,44 @@ struct PeriscopeTests { #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) diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 96ff8125..790828cc 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -11,8 +11,6 @@ ## P0s (Must do) - 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. - perf: `events(matching:)` faults `tags` and `attachments` relationships per fetched row (N+1). Set `relationshipKeyPathsForPrefetching` on the fetch descriptors. (Code review finding 8.) -- perf: `Periscope.chunked(_:)` groups pending items with `array + [element]`, which is quadratic in batch size — a full 5,000-record backlog does millions of element copies inside the drain. Append into a mutable current-chunk buffer instead. (Code review finding 7.) - ## P1s (Should do) - fix: Run redaction after the level-floor check in `Periscope.record` — today user redaction code executes (and touches PII) for records the floor is about to discard. - fix: PeriscopeTools/PeriscopeUI don't compile for native macOS (`navigationBarTitleDisplayMode`, `.topBarTrailing`) even though `Package.swift` advertises `.macOS(.v26)` for all products. Nothing in CI builds them for macOS today, so this is a latent break for the first macOS consumer (`LogViewerUI` has the same issue as precedent). Either gate the iOS-only modifiers or stop advertising macOS for the UI modules. @@ -39,3 +37,4 @@ - 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.) From 06b40010fe9651b2db64d999fb8383744c57a383 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 14:53:18 -0400 Subject: [PATCH 26/73] Prefetch tags and attachments on store event reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eventValue maps every fetched row's tags and attachments relationships into the returned value, so each row of a 200-event viewer page faulted both relationships in their own round trips (N+1). Event-read descriptors (events(matching:), events(inSpan:), fetchEventRow, and the orphan sweep's began fetch, which reuses row tags) now share a readDescriptor helper that sets relationshipKeyPathsForPrefetching for both. Attachment blobs still load lazily — only the metadata rows prefetch. Behavior unchanged; existing read-path tests cover it. Addresses code-review finding 8 (N+1 relationship faulting). --- .../Sources/PeriscopeStore.swift | 25 ++++++++++++++----- Shared/Periscope/TODOs.md | 3 ++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index 1cace75e..81257f6a 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -112,7 +112,8 @@ public actor PeriscopeStore: LogSink { do { let beganName = SpanBegan.eventName let endedName = SpanEnded.eventName - let began = try modelContext.fetch(FetchDescriptor( + // The orphan records reuse each began row's tags, so prefetch. + let began = try modelContext.fetch(Self.readDescriptor( predicate: #Predicate { $0.eventName == beganName && $0.sessionID != startedSessionID }, @@ -455,7 +456,7 @@ public actor PeriscopeStore: LogSink { && (!filtersTag || event.tags.contains { $0.pair == tagPair }) } - var descriptor = FetchDescriptor( + var descriptor = Self.readDescriptor( predicate: predicate, sortBy: [ SortDescriptor(\.date, order: .reverse), @@ -476,7 +477,7 @@ public actor PeriscopeStore: LogSink { /// predicate stays small. public func events(inSpan span: SpanID) throws -> [StoredLogEvent] { let id: UUID? = span.rawValue - let descriptor = FetchDescriptor( + let descriptor = Self.readDescriptor( predicate: #Predicate { $0.spanID == id }, sortBy: [ SortDescriptor(\.date, order: .reverse), @@ -502,13 +503,25 @@ public actor PeriscopeStore: LogSink { } private func fetchEventRow(id: UUID) throws -> SDLogEvent? { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.eventID == id }, - ) + 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, diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 790828cc..315733c4 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -10,7 +10,7 @@ ## P0s (Must do) - 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. -- perf: `events(matching:)` faults `tags` and `attachments` relationships per fetched row (N+1). Set `relationshipKeyPathsForPrefetching` on the fetch descriptors. (Code review finding 8.) + ## P1s (Should do) - fix: Run redaction after the level-floor check in `Periscope.record` — today user redaction code executes (and touches PII) for records the floor is about to discard. - fix: PeriscopeTools/PeriscopeUI don't compile for native macOS (`navigationBarTitleDisplayMode`, `.topBarTrailing`) even though `Package.swift` advertises `.macOS(.v26)` for all products. Nothing in CI builds them for macOS today, so this is a latent break for the first macOS consumer (`LogViewerUI` has the same issue as precedent). Either gate the iOS-only modifiers or stop advertising macOS for the UI modules. @@ -38,3 +38,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.) From 3e05f896be7ff7abce161e95889540163bdbbc47 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 15:17:20 -0400 Subject: [PATCH 27/73] Add pipeline fuzz + drop-policy tests; fix sink-replay ordering The new seeded fuzz test (fixed SplitMix64 seeds, four concurrent tasks interleaving emit/derive/flush/add-sink) immediately caught a real ordering bug: add(sink:) appended its scope replay to the END of the pending queue, so records already pending reached the new sink before the definitions of the scopes they reference. The replay is now prepended, and the drop report slots after the leading scope run rather than being written first, preserving the same guarantee. The fuzz invariants assert no record loss or duplication, per-emitter emission order, and scopes-before-records on every sink including late-added ones. Also covers the drop policy's scope-definitions-never-drop promise (gated overflow with interleaved definitions and records), and makes the alerter lifecycle tests deterministic: a new @_spi(Testing) Periscope.liveObserverCount asserts the subscription count directly instead of racing duplicate deliveries against a Task.yield(), and the stop test verifies unsubscription before emitting rather than relying on a sentinel from a second alerter. Addresses the code review's missing-tests items. --- .../PeriscopeCore/Sources/Periscope.swift | 27 +++- .../Tests/PeriscopeCoreTestSupport.swift | 18 +++ .../PeriscopeCore/Tests/PeriscopeTests.swift | 133 ++++++++++++++++++ .../Tests/PeriscopeAlerterTests.swift | 38 +++-- Shared/Periscope/TODOs.md | 5 +- 5 files changed, 194 insertions(+), 27 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index b91611e4..c636b2e3 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -156,11 +156,17 @@ public final class Periscope: LogRecorder, Sendable { // 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. + /// 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.append(contentsOf: state.scopes.values.map(PendingItem.scope)) + state.pending.insert( + contentsOf: state.scopes.values.map(PendingItem.scope), + at: 0, + ) } scheduleDrainIfNeeded() } @@ -435,6 +441,12 @@ public final class Periscope: LogRecorder, Sendable { 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 @@ -503,12 +515,17 @@ public final class Periscope: LogRecorder, Sendable { } return (items, state.sinks, dropReport) } - guard let (items, sinks, dropReport) = next else { return } + guard var (items, sinks, dropReport) = next else { return } if let dropReport { announceDropReport(dropReport) - for sink in sinks { - await sink.write([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 { diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift index a333c100..0098694e 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -45,6 +45,24 @@ final class RecordingRecorder: LogRecorder, Sendable { } } +/// 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( diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index fc9e914d..71d0d33e 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -461,6 +461,139 @@ struct PeriscopeTests { // 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 overflowingThePendingQueueDropsOldestAndReportsTheGap() async throws { let gate = GateSink() let system = Periscope( diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift index dd240bca..e99226d5 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeAlerterTests.swift @@ -1,5 +1,5 @@ import Foundation -import PeriscopeCore +@_spi(Testing) import PeriscopeCore @testable import PeriscopeTools import Testing @@ -61,34 +61,32 @@ struct PeriscopeAlerterTests { 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() - log.error("while stopped") + let unsubscribed = await waitUntil { system.liveObserverCount == 0 } + #expect(unsubscribed) - // A fresh alerter only sees records emitted after it starts, so once - // its sentinel arrives, the stopped alerter demonstrably never - // delivered "while stopped". - let restarted = PeriscopeAlerter(system: system, threshold: .warning, handler: handler) - restarted.start() - log.error("sentinel") - let sentinel = await waitUntil { - handler.records.contains { $0.message == "sentinel" } - } - #expect(sentinel) - #expect(!handler.records.contains { $0.message == "while stopped" }) + log.error("while stopped") + #expect(handler.records.map(\.message) == ["first"]) } - @Test func startingTwiceDoesNotDuplicateAlerts() async { + @Test func startingTwiceDoesNotDuplicateSubscriptions() async { let alerter = PeriscopeAlerter(system: system, threshold: .warning, handler: handler) alerter.start() alerter.start() - let log = Log(system: system) + // 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") - let delivered = await waitUntil { !handler.records.isEmpty } + log.error("sentinel") + let delivered = await waitUntil { + handler.records.contains { $0.message == "sentinel" } + } #expect(delivered) - - // Give a duplicate delivery a chance to land before asserting. - await Task.yield() - #expect(handler.records.count == 1) + #expect(handler.records.map(\.message) == ["once", "sentinel"]) } } diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 315733c4..f2ffb369 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -15,7 +15,6 @@ - fix: Run redaction after the level-floor check in `Periscope.record` — today user redaction code executes (and touches PII) for records the floor is about to discard. - fix: PeriscopeTools/PeriscopeUI don't compile for native macOS (`navigationBarTitleDisplayMode`, `.topBarTrailing`) even though `Package.swift` advertises `.macOS(.v26)` for all products. Nothing in CI builds them for macOS today, so this is a latent break for the first macOS consumer (`LogViewerUI` has the same issue as precedent). Either gate the iOS-only modifiers or stop advertising macOS for the UI modules. - fix: The tracer window (`LogQuery.end = origin.date`) is inclusive and sequence isn't in the predicate, so same-millisecond events *after* the origin appear in "leading up to it". -- test: Seeded fuzz/adversarial test for the pipeline state machine (concurrent emit + `add(sink:)` + flush + drop interleavings), per the repo convention for many-branch machinery. - feat: Optional budget for `measure` spans (`log.measure(.saveEvent, budget: .seconds(1))`) — closure spans can't leak, but "this save should never take >1s" deserves the same overdue signal `begin` spans get. ## P2s (Nice to have) @@ -24,7 +23,6 @@ - fix: `PeriscopeInspector` syncs one-way after init; direct writes to `Periscope.isInspectModeEnabled` don't reflect back into the observable mirror. - feat: An "open spans" developer surface (viewer section listing currently-open spans with age and budget) — the data exists in `Periscope.openSpans`. - feat: Surface span exits in the viewer beyond the message text (badge tint by exit mode, filter by exit). -- test: `PeriscopeAlerterTests.startingTwiceDoesNotDuplicateAlerts` asserts after a single `Task.yield()`, so a duplicate could land just after the assertion passes — wait for a sentinel instead. - docs: `log(PhotoLogs.self) { event }` parses as one call and fails to compile; document the required two-step spelling near `callAsFunction`. - perf: `LocalNotificationAlertHandler` requests notification authorization on every alert; cache the grant. @@ -39,3 +37,6 @@ - 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()`. From f6e0f48aea2abf0c6de8d418e56fc52f52435590 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 15:24:25 -0400 Subject: [PATCH 28/73] Check level floors before running redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Periscope.record ran the redaction hook on every record before the floor check, so user redaction code executed — touching PII — for records the floor was about to discard (every filtered debug message under a .warning floor). Floors now gate first; redaction runs only for admitted records. Contract documented on Configuration.redact: floors apply to the record as emitted — redaction is content scrubbing, not routing. Test asserts the hook never fires for floor-filtered records (freeform or structured) and exactly once for admitted ones. Addresses code-review low-severity item: redaction-before-floor ordering. --- .../PeriscopeCore/Sources/Periscope.swift | 19 ++++++++++------ .../PeriscopeCore/Tests/PeriscopeTests.swift | 22 +++++++++++++++++++ Shared/Periscope/TODOs.md | 4 +++- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index c636b2e3..f4df715b 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -53,9 +53,14 @@ public final class Periscope: LogRecorder, Sendable { /// process dies. public var flushThreshold: LogLevel - /// Applied to every 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. + /// 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. public var redact: (@Sendable (LogRecord) -> LogRecord?)? public init( @@ -240,6 +245,9 @@ public final class Periscope: LogRecorder, Sendable { } 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. + guard shouldRecord(level: original.level, scopes: original.scopes) else { return } let record: LogRecord if let redact = configuration.redact { guard let redacted = redact(original) else { return } @@ -247,13 +255,10 @@ public final class Periscope: LogRecorder, Sendable { } else { record = original } - let observers: [AsyncStream.Continuation]? = state.withLock { state in - guard Self.passesFloor(level: record.level, scopes: record.scopes, state: state) - else { return nil } + let observers = state.withLock { state in Self.append(record, to: &state, configuration: configuration) return Array(state.observers.values) } - guard let observers else { return } for observer in observers { observer.yield(record) } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 71d0d33e..07534a60 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -1,4 +1,5 @@ import Foundation +import os @_spi(Testing) import PeriscopeCore import Testing @@ -303,6 +304,27 @@ struct PeriscopeTests { #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 diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index f2ffb369..50c31c07 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -12,7 +12,6 @@ - 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: Run redaction after the level-floor check in `Periscope.record` — today user redaction code executes (and touches PII) for records the floor is about to discard. - fix: PeriscopeTools/PeriscopeUI don't compile for native macOS (`navigationBarTitleDisplayMode`, `.topBarTrailing`) even though `Package.swift` advertises `.macOS(.v26)` for all products. Nothing in CI builds them for macOS today, so this is a latent break for the first macOS consumer (`LogViewerUI` has the same issue as precedent). Either gate the iOS-only modifiers or stop advertising macOS for the UI modules. - fix: The tracer window (`LogQuery.end = origin.date`) is inclusive and sequence isn't in the predicate, so same-millisecond events *after* the origin appear in "leading up to it". - feat: Optional budget for `measure` spans (`log.measure(.saveEvent, budget: .seconds(1))`) — closure spans can't leak, but "this save should never take >1s" deserves the same overdue signal `begin` spans get. @@ -28,6 +27,9 @@ # Completed issues +## 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. + ## 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.) From b9f5a59c76560997112996f07c062d4d5918e297 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 15:30:17 -0400 Subject: [PATCH 29/73] Close out the macOS-compilation TODO: package is iOS-only now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foreman's removal on main dropped the .macOS platform from Package.swift, so PeriscopeUI/PeriscopeTools no longer advertise a platform they can't compile for — the latent break is gone from the other side. Core keeps its canImport(UIKit) gating as per-SDK hygiene. Docs-only change (TODOs.md). Addresses code-review low-severity item: macOS compilation of the UI modules. --- Shared/Periscope/TODOs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 50c31c07..21217518 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -12,7 +12,6 @@ - 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: PeriscopeTools/PeriscopeUI don't compile for native macOS (`navigationBarTitleDisplayMode`, `.topBarTrailing`) even though `Package.swift` advertises `.macOS(.v26)` for all products. Nothing in CI builds them for macOS today, so this is a latent break for the first macOS consumer (`LogViewerUI` has the same issue as precedent). Either gate the iOS-only modifiers or stop advertising macOS for the UI modules. - fix: The tracer window (`LogQuery.end = origin.date`) is inclusive and sequence isn't in the predicate, so same-millisecond events *after* the origin appear in "leading up to it". - feat: Optional budget for `measure` spans (`log.measure(.saveEvent, budget: .seconds(1))`) — closure spans can't leak, but "this save should never take >1s" deserves the same overdue signal `begin` spans get. @@ -29,6 +28,7 @@ ## 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.) ## 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.) From aebba99812f5364f62678963ba5913ecfdc2abbd Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 15:37:08 -0400 Subject: [PATCH 30/73] Trim the tracer trail to events strictly before the origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trail queries bound by origin.date inclusively and the insertion sequence isn't part of the store predicate, so same-millisecond events that landed after the origin appeared under "leading up to it" — and tracing from a SpanBegan showed its own end event there too. The model now filters collected events to strictly (date, sequence) before the origin, where the ordering key already lives, keeping the type-check-sensitive LogQuery predicate untouched. Test straddles the origin's sequence within one millisecond. Addresses code-review low-severity item: tracer same-millisecond window. --- .../PeriscopeTools/Sources/LogTraceModel.swift | 14 ++++++++++---- .../Tests/LogTraceModelTests.swift | 17 +++++++++++++++++ Shared/Periscope/TODOs.md | 2 +- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift b/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift index 52269352..5f371a4a 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift @@ -57,11 +57,17 @@ final class LogTraceModel { collected[event.id] = event } } - collected[origin.id] = nil - let ordered = collected.values.sorted { lhs, rhs in - (lhs.date, lhs.sequence) > (rhs.date, rhs.sequence) - } + // "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(Self.limit))) } catch { state = .failed(String(describing: error)) diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift index 91e1f525..40a3418d 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift @@ -95,6 +95,23 @@ struct LogTraceModelTests { #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) + 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])]) diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 21217518..32895d52 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -12,7 +12,6 @@ - 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: The tracer window (`LogQuery.end = origin.date`) is inclusive and sequence isn't in the predicate, so same-millisecond events *after* the origin appear in "leading up to it". - feat: Optional budget for `measure` spans (`log.measure(.saveEvent, budget: .seconds(1))`) — closure spans can't leak, but "this save should never take >1s" deserves the same overdue signal `begin` spans get. ## P2s (Nice to have) @@ -29,6 +28,7 @@ ## 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". ## 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.) From f5b8bb7296fe47d8a2d75fc12aa9594eb899d355 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 15:44:24 -0400 Subject: [PATCH 31/73] Add optional budgets to measure spans with mid-hang overdue warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log.measure(.saveEvent, budget: .seconds(1)) { … } (sync and async) emits a structured SpanOverdue warning while the closure is still running past its budget — the signal arrives mid-hang, when it's actionable, not after the fact. Each budgeted call spawns one short-lived sentinel task (sleep the budget, emit unless cancelled), cancelled when the closure finishes; the shared watchdog is deliberately not involved, since its per-key tracking would make concurrent same-token measures supersede each other. The span itself stays .scoped with derived exits; SpanOverdue carries the SpanID (so events(inSpan:) and the tracer link it) and the budget. The four measure variants now share the timedSpan core. Addresses the review follow-up: budget signal for closure spans. --- Shared/Periscope/PeriscopeCore/README.md | 5 +- .../PeriscopeCore/Sources/LogSpan.swift | 108 ++++++++++++++++-- .../PeriscopeCore/Tests/LogSpanTests.swift | 51 +++++++++ Shared/Periscope/TODOs.md | 3 +- 4 files changed, 156 insertions(+), 11 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index 749bcb7c..58cc825c 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -81,8 +81,9 @@ Periscope.shared.startDefaultAmbientSources() - **Spans** — `log.measure(.token) { … }` (sync/async) emits paired `SpanBegan`/`SpanEnded` events with the exit derived automatically (return → `.success`, throw → `.failure`, `CancellationError` → - `.cancelled`); `begin(for:lifetime:relaunch:)`/`end(for:exit:)` for - open-ended spans. Every span provably ends: bounded spans expire past + `.cancelled`), and an optional `budget:` fires a `SpanOverdue` warning + while the closure hangs past it; `begin(for:lifetime:relaunch:)`/ + `end(for:exit:)` for open-ended spans. 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift index 3b193a1f..cb563168 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift @@ -122,6 +122,33 @@ protocol SpanCarrying { 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 span this record belongs to, when its event is a span event. public var spanID: SpanID? { @@ -211,8 +238,58 @@ extension Log { /// (`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 spanName = String(describing: name) let clock = ContinuousClock() let start = clock.now SpanSignposts.begin(span, name: spanName) @@ -222,6 +299,8 @@ extension Log { lifetime: .scoped, relaunchPolicy: .endsWithProcess, )) + let sentinel = budget.map { startOverdueSentinel(span: span, name: spanName, budget: $0) } + defer { sentinel?.cancel() } do { let result = try body() endMeasuredSpan(span, name: spanName, duration: clock.now - start, exit: .success) @@ -237,15 +316,13 @@ extension Log { } } - /// The `async` form of `measure`; preserves the caller's isolation. - @discardableResult - public func measure( - _ name: Event.SpanName, - isolation _: isolated (any Actor)? = #isolation, + private func timedSpan( + named spanName: String, + budget: Duration?, + isolation _: isolated (any Actor)?, _ body: () async throws -> R, ) async rethrows -> R { let span = SpanID() - let spanName = String(describing: name) let clock = ContinuousClock() let start = clock.now SpanSignposts.begin(span, name: spanName) @@ -255,6 +332,8 @@ extension Log { lifetime: .scoped, relaunchPolicy: .endsWithProcess, )) + let sentinel = budget.map { startOverdueSentinel(span: span, name: spanName, budget: $0) } + defer { sentinel?.cancel() } do { let result = try await body() endMeasuredSpan(span, name: spanName, duration: clock.now - start, exit: .success) @@ -270,6 +349,21 @@ extension Log { } } + /// 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. + private func startOverdueSentinel( + span: SpanID, + name: String, + budget: Duration, + ) -> Task { + Task { [self] in + try? await Task.sleep(for: budget) + guard !Task.isCancelled else { return } + emit(SpanOverdue(spanID: span, name: name, budget: budget)) + } + } + private func endMeasuredSpan( _ span: SpanID, name: String, diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift index 30774126..d803ca28 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift @@ -79,6 +79,57 @@ struct LogSpanTests { #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) diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 32895d52..985fe17e 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -12,8 +12,6 @@ - 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) -- feat: Optional budget for `measure` spans (`log.measure(.saveEvent, budget: .seconds(1))`) — closure spans can't leak, but "this save should never take >1s" deserves the same overdue signal `begin` spans get. - ## P2s (Nice to have) - fix: `NetworkPathAmbientSource.start` called twice silently replaces the boxed monitor without cancelling the old one; ambient sources in general have no stop/double-start story. - refactor: `PeriscopeViewer`/`LogInspectorView`/`LogTraceView` capture their model via `State(initialValue:)`; a parent that later passes a different store/origin keeps the stale model. Fine for dev tools — document or key the views. @@ -29,6 +27,7 @@ - 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.) From 535cf8168898bd4c3bf9bd855b0c9464bf054697 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 15:49:24 -0400 Subject: [PATCH 32/73] Cancel the prior network monitor on ambient source restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NetworkPathAmbientSource.start replaced its boxed NWPathMonitor without cancelling the old one, which kept running and logging forever. The swap now returns the previous monitor and cancels it. AmbientEventSource.start documents its called-exactly-once contract — the notification-based built-ins deliberately stay unguarded rather than growing lock boxes for a call pattern nothing produces. Addresses code-review low-severity item: ambient source double-start. --- .../Sources/AmbientEventSource.swift | 5 ++++- .../Sources/NetworkPathAmbientSource.swift | 9 ++++++++- .../Tests/NetworkPathAmbientSourceTests.swift | 15 +++++++++++++++ Shared/Periscope/TODOs.md | 4 +++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/AmbientEventSource.swift b/Shared/Periscope/PeriscopeCore/Sources/AmbientEventSource.swift index 704cfda3..b2883ddb 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/AmbientEventSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/AmbientEventSource.swift @@ -9,7 +9,10 @@ import Foundation /// shared ambient scope. public protocol AmbientEventSource: Sendable { /// Begin observing and log every observed change into `log`. Called - /// once, when the source is registered. + /// exactly once, when the source is registered — sources observe for + /// the process lifetime and are not required to tolerate repeated + /// starts (the notification-based built-ins would double their + /// observers). func start(log: Log) } diff --git a/Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift index fbfbcfda..7ae52599 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift @@ -17,7 +17,14 @@ public struct NetworkPathAmbientSource: AmbientEventSource { log { AmbientEvent(kind: .network, value: Self.describe(path)) } } started.start(queue: DispatchQueue(label: "com.stuff.periscope.network-path")) - monitor.withLockUnchecked { $0 = started } + 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() } private static func describe(_ path: NWPath) -> String { diff --git a/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift index 5718b38d..0209aec4 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/NetworkPathAmbientSourceTests.swift @@ -15,4 +15,19 @@ struct NetworkPathAmbientSourceTests { } #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/TODOs.md b/Shared/Periscope/TODOs.md index 985fe17e..7b626999 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -13,7 +13,6 @@ ## P1s (Should do) ## P2s (Nice to have) -- fix: `NetworkPathAmbientSource.start` called twice silently replaces the boxed monitor without cancelling the old one; ambient sources in general have no stop/double-start story. - refactor: `PeriscopeViewer`/`LogInspectorView`/`LogTraceView` capture their model via `State(initialValue:)`; a parent that later passes a different store/origin keeps the stale model. Fine for dev tools — document or key the views. - fix: `PeriscopeInspector` syncs one-way after init; direct writes to `Periscope.isInspectModeEnabled` don't reflect back into the observable mirror. - feat: An "open spans" developer surface (viewer section listing currently-open spans with age and budget) — the data exists in `Periscope.openSpans`. @@ -23,6 +22,9 @@ # Completed issues +## 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. + ## 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.) From b226eef060224a84ddbacb43f50bbdaf925b6d2c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 15:56:53 -0400 Subject: [PATCH 33/73] Rebind tool views when their inputs change in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeriscopeViewer, LogInspectorView, LogTraceView, and LogEventDetailView captured their models via State(initialValue:), so a parent reconstructing them in place with a different store, origin, or scope set silently kept the first inputs forever. Each view's .task(id:) is now keyed on the store's actor identity plus its other identity inputs and rebuilds the model when they no longer match — re-keying also cancels the old task, tearing down the old model's changes() subscription. Verified by a hosting test that swaps stores under a live viewer and asserts, via a new @_spi(Testing) changeObserverCount on PeriscopeStore, that the viewer binds to the new store and releases the old stream. Tools test support gains an async showHosted helper and an async-predicate waitUntil; the rebinding rule is recorded as a PeriscopeTools AGENTS invariant. Addresses code-review low-severity item: State-from-init input capture. --- .../Sources/PeriscopeStore.swift | 6 +++ Shared/Periscope/PeriscopeTools/AGENTS.md | 4 ++ .../Sources/LogEventDetailView.swift | 10 +++- .../Sources/LogInspectable.swift | 12 ++++- .../Sources/LogInspectorModel.swift | 5 +- .../Sources/LogTraceModel.swift | 3 +- .../PeriscopeTools/Sources/LogTraceView.swift | 14 +++++- .../Sources/PeriscopeViewer.swift | 9 +++- .../Sources/PeriscopeViewerModel.swift | 3 +- .../Tests/PeriscopeToolsTestSupport.swift | 37 ++++++++++++++ .../Tests/PeriscopeViewerHostingTests.swift | 49 +++++++++++++++++++ Shared/Periscope/TODOs.md | 2 +- 12 files changed, 145 insertions(+), 9 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index 81257f6a..81178914 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -229,6 +229,12 @@ public actor PeriscopeStore: LogSink { 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, diff --git a/Shared/Periscope/PeriscopeTools/AGENTS.md b/Shared/Periscope/PeriscopeTools/AGENTS.md index 3aa1313b..89425267 100644 --- a/Shared/Periscope/PeriscopeTools/AGENTS.md +++ b/Shared/Periscope/PeriscopeTools/AGENTS.md @@ -29,6 +29,10 @@ the build system, formatting, and global conventions. Read that first. - **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 diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift index 9de88e79..f9d2e61f 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift @@ -77,7 +77,8 @@ struct LogEventDetailView: View { } } } - .task { + .task(id: Inputs(store: ObjectIdentifier(store), event: event.id)) { + attachments = nil do { attachments = try await .success(store.attachments(forEvent: event.id)) } catch { @@ -86,6 +87,13 @@ struct LogEventDetailView: View { } } + /// 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 { diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift b/Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift index a26df11c..dd5dea64 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift @@ -62,6 +62,13 @@ struct LogInspectorView: View { _model = State(initialValue: LogInspectorModel(store: store, inspectedScopes: scopes)) } + /// The identity of this view's inputs — re-keying the task rebinds the + /// model when either changes in place. + private struct Inputs: Equatable { + let store: ObjectIdentifier + let scopes: [ScopeID] + } + var body: some View { content .navigationTitle("Element Logs") @@ -71,7 +78,10 @@ struct LogInspectorView: View { Button("Done") { dismiss() } } } - .task { + .task(id: Inputs(store: ObjectIdentifier(store), scopes: scopes)) { + if model.store !== store || model.inspectedScopes != scopes { + model = LogInspectorModel(store: store, inspectedScopes: scopes) + } await model.run() } } diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift b/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift index 14c02939..ce7badc4 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift @@ -16,8 +16,9 @@ final class LogInspectorModel { static let limit = 200 - private let store: PeriscopeStore - private let inspectedScopes: [ScopeID] + /// 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] = [:] diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift b/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift index 5f371a4a..57525ed1 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift @@ -20,7 +20,8 @@ final class LogTraceModel { static let limit = 100 let origin: StoredLogEvent - private let store: PeriscopeStore + /// 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] = [:] diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogTraceView.swift b/Shared/Periscope/PeriscopeTools/Sources/LogTraceView.swift index 9b291e76..01b57d94 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogTraceView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogTraceView.swift @@ -10,13 +10,22 @@ import SwiftUI /// Designed to be pushed inside an existing `NavigationStack`. public struct LogTraceView: View { private let store: PeriscopeStore + private let origin: StoredLogEvent @State private var model: LogTraceModel public init(store: PeriscopeStore, origin: StoredLogEvent) { self.store = store + self.origin = origin _model = State(initialValue: LogTraceModel(store: store, origin: origin)) } + /// The identity of this view's inputs — re-keying the task rebinds the + /// model when either changes in place. + private struct Inputs: Equatable { + let store: ObjectIdentifier + let origin: UUID + } + public var body: some View { List { Section("Origin") { @@ -32,7 +41,10 @@ public struct LogTraceView: View { .listStyle(.plain) .navigationTitle("Trace") .navigationBarTitleDisplayMode(.inline) - .task { + .task(id: Inputs(store: ObjectIdentifier(store), origin: origin.id)) { + if model.store !== store || model.origin.id != origin.id { + model = LogTraceModel(store: store, origin: origin) + } await model.load() } } diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift index a5499458..be34b61b 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift @@ -40,7 +40,14 @@ public struct PeriscopeViewer: View { .alert("Export Failed", isPresented: $exportFailed) { Button("OK", role: .cancel) {} } - .task { + // 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. + .task(id: ObjectIdentifier(store)) { + if model.store !== store { + model = PeriscopeViewerModel(store: store) + } await model.run() } } diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift index 3cf3fbe8..cb5f676f 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift @@ -23,7 +23,8 @@ final class PeriscopeViewerModel { static let pageSize = 200 - private let store: PeriscopeStore + /// 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 diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift index 2a17d3af..74201d83 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift @@ -1,5 +1,7 @@ import Foundation @_spi(Testing) import PeriscopeCore +import UIKit +import WhereTesting /// Shared fixture event for the tools suites. struct PhotoLogs: LogEvent { @@ -67,3 +69,38 @@ func waitUntil(_ predicate: () -> Bool) async -> Bool { } 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() +} + +/// `WhereTesting.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 WhereTestingError("No root view controller in test host.") + } + 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 index 25076a7b..3eb05b4c 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerHostingTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerHostingTests.swift @@ -6,8 +6,57 @@ import Testing import UIKit import WhereTesting +/// 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([ diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 7b626999..94e5ef00 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -13,7 +13,6 @@ ## P1s (Should do) ## P2s (Nice to have) -- refactor: `PeriscopeViewer`/`LogInspectorView`/`LogTraceView` capture their model via `State(initialValue:)`; a parent that later passes a different store/origin keeps the stale model. Fine for dev tools — document or key the views. - fix: `PeriscopeInspector` syncs one-way after init; direct writes to `Periscope.isInspectModeEnabled` don't reflect back into the observable mirror. - feat: An "open spans" developer surface (viewer section listing currently-open spans with age and budget) — the data exists in `Periscope.openSpans`. - feat: Surface span exits in the viewer beyond the message text (badge tint by exit mode, filter by exit). @@ -24,6 +23,7 @@ ## 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. ## 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. From ff720fec783fc2548ef4200d774265b923bf2f2c Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 16:01:20 -0400 Subject: [PATCH 34/73] Sync the inspect-mode mirror both ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeriscopeInspector read Periscope.isInspectModeEnabled once at init and only wrote through afterwards, so direct writes to the Core flag — the documented source of truth — left the SwiftUI mirror stale (wrong toggle state, badges not reacting). Periscope now publishes the flag via inspectModeChanges() (current value, then one per change; the setter gains the standard no-change guard so redundant writes don't yield; bufferingNewest(1) since only the latest state matters), and the inspector subscribes for its lifetime, mirroring changes back into isEnabled. No-change guards on both sides keep the loop stable — covered by a mixed-writers convergence test plus direct-write reflection tests, and the AGENTS invariant now reads 'either side may write; they converge.' Addresses code-review low-severity item: inspector one-way sync. --- .../PeriscopeCore/Sources/Periscope.swift | 39 +++++++++++++++++-- .../PeriscopeCore/Tests/PeriscopeTests.swift | 12 ++++++ Shared/Periscope/PeriscopeTools/AGENTS.md | 5 ++- .../Sources/PeriscopeInspector.swift | 28 ++++++++++--- .../Tests/PeriscopeInspectorTests.swift | 31 +++++++++++++++ Shared/Periscope/TODOs.md | 2 +- 6 files changed, 104 insertions(+), 13 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index f4df715b..4da97756 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -117,6 +117,7 @@ public final class Periscope: LogRecorder, Sendable { 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 @@ -337,12 +338,42 @@ public final class Periscope: LogRecorder, Sendable { /// The developer "log view mode" flag: when enabled, inspectable UI /// (PeriscopeTools' `logInspectable` modifier) reveals the events behind - /// each wrapped view. Toggle from a developer settings surface — - /// typically through PeriscopeTools' inspector, which mirrors this flag - /// observably for SwiftUI. + /// 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 { state.withLock { $0.inspectModeEnabled = newValue } } + set { + let observers: [AsyncStream.Continuation]? = state.withLock { state in + guard state.inspectModeEnabled != newValue else { return nil } + state.inspectModeEnabled = newValue + return Array(state.inspectObservers.values) + } + guard let observers else { return } + for observer in observers { + 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 diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 07534a60..1ce66e8d 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -192,6 +192,18 @@ struct PeriscopeTests { #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 { diff --git a/Shared/Periscope/PeriscopeTools/AGENTS.md b/Shared/Periscope/PeriscopeTools/AGENTS.md index 89425267..903caa8c 100644 --- a/Shared/Periscope/PeriscopeTools/AGENTS.md +++ b/Shared/Periscope/PeriscopeTools/AGENTS.md @@ -24,8 +24,9 @@ the build system, formatting, and global conventions. Read that first. 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; writes go - through, never around it. + 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. diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift index 339155fe..17fdaaa4 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift @@ -2,10 +2,12 @@ import Observation import PeriscopeCore import SwiftUI -/// The observable face of "log view mode": wraps a `Periscope` system's -/// inspect flag (kept in sync both ways through ``isEnabled``) plus the -/// store inspectable views query. Inject one near the root and bind a -/// developer-settings toggle to it: +/// 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() @@ -20,9 +22,11 @@ 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 flag's source of truth for - /// non-UI callers. + /// `Periscope.isInspectModeEnabled`; the no-change guards on both + /// sides keep the mirror loop-free. public var isEnabled: Bool { didSet { guard isEnabled != oldValue else { return } @@ -34,6 +38,18 @@ public final class PeriscopeInspector { 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() } } diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift index 9bc33e69..5f952c17 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeInspectorTests.swift @@ -25,4 +25,35 @@ struct PeriscopeInspectorTests { 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/TODOs.md b/Shared/Periscope/TODOs.md index 94e5ef00..2f918495 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -13,7 +13,6 @@ ## P1s (Should do) ## P2s (Nice to have) -- fix: `PeriscopeInspector` syncs one-way after init; direct writes to `Periscope.isInspectModeEnabled` don't reflect back into the observable mirror. - feat: An "open spans" developer surface (viewer section listing currently-open spans with age and budget) — the data exists in `Periscope.openSpans`. - feat: Surface span exits in the viewer beyond the message text (badge tint by exit mode, filter by exit). - docs: `log(PhotoLogs.self) { event }` parses as one call and fails to compile; document the required two-step spelling near `callAsFunction`. @@ -24,6 +23,7 @@ ## 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. ## 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. From 3c2d279f54490bf60891f2883d79e41d6e522763 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 16:05:26 -0400 Subject: [PATCH 35/73] Add OpenSpansView: the open-spans developer surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Periscope.openSpans() exposes a snapshot of every span currently open via begin(for:), longest running first. OpenSpansView renders it with ticking ages (TimelineView re-snapshots once a second — no change-stream plumbing for live state that only needs a heartbeat), each row showing the span's name, age, lifetime/budget, and scope path. It reads the system rather than the store — open spans are live state, not history — and pushes from a developer menu inside an ambient NavigationStack. Snapshot lifecycle covered in Core tests (begin/end/expiry all reflected); hosting smoke tests cover populated and empty states. Addresses the review follow-up: open-spans developer surface. --- .../PeriscopeCore/Sources/Periscope.swift | 8 ++ .../PeriscopeCore/Tests/PeriscopeTests.swift | 16 ++++ Shared/Periscope/PeriscopeTools/README.md | 4 + .../Sources/OpenSpansView.swift | 91 +++++++++++++++++++ .../Tests/OpenSpansViewHostingTests.swift | 41 +++++++++ Shared/Periscope/TODOs.md | 2 +- 6 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift create mode 100644 Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 4da97756..970e33cb 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -392,6 +392,14 @@ public final class Periscope: LogRecorder, Sendable { 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 diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 1ce66e8d..93e0778f 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -479,6 +479,22 @@ struct PeriscopeTests { #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) diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md index a0264c7b..52f0ef90 100644 --- a/Shared/Periscope/PeriscopeTools/README.md +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -66,6 +66,10 @@ Toggle("Log View Mode", isOn: $inspector.isEnabled) 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 diff --git a/Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift b/Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift new file mode 100644 index 00000000..e71378f1 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift @@ -0,0 +1,91 @@ +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 "" } + var names: [String] = [] + var next: ScopeID? = primary + while let id = next, let scope = system.scope(for: id) { + names.append(scope.name) + next = scope.parentID + } + return names.reversed().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/Tests/OpenSpansViewHostingTests.swift b/Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift new file mode 100644 index 00000000..28319fdf --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/OpenSpansViewHostingTests.swift @@ -0,0 +1,41 @@ +import Foundation +@_spi(Testing) import PeriscopeCore +import PeriscopeTools +import SwiftUI +import Testing +import UIKit +import WhereTesting + +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/TODOs.md b/Shared/Periscope/TODOs.md index 2f918495..6383ed26 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -13,7 +13,6 @@ ## P1s (Should do) ## P2s (Nice to have) -- feat: An "open spans" developer surface (viewer section listing currently-open spans with age and budget) — the data exists in `Periscope.openSpans`. - feat: Surface span exits in the viewer beyond the message text (badge tint by exit mode, filter by exit). - docs: `log(PhotoLogs.self) { event }` parses as one call and fails to compile; document the required two-step spelling near `callAsFunction`. - perf: `LocalNotificationAlertHandler` requests notification authorization on every alert; cache the grant. @@ -24,6 +23,7 @@ - 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. ## 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. From cf1b94d4e2723ea1bb233f61e3bbb85e1eb414d4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 16:20:50 -0400 Subject: [PATCH 36/73] Make span exits first-class in the store and tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDLogEvent gains an indexed spanExitMode column (persisted from SpanEnded events via the new LogRecord.spanExit accessor), surfaced on StoredLogEvent and queryable via LogQuery.spanExitMode — 'everything that failed/expired/orphaned' is now one indexed query instead of payload archaeology. The viewer adds a span-exit filter, rows show a tinted exit chip beside the level badge, event detail renders Exit + reason (reason decoded from the payload; the mode is columnar), and NDJSON export includes the mode. The ninth predicate condition pushed the #Predicate macro past the type-checker's budget (and the ClosedRange workaround compiles but crashes SwiftData's SQL translation at runtime), so events(matching:) now builds one of two predicate variants: the exit variant replaces the event-name condition — an exit filter only matches span-ended events, so a conflicting name filter provably returns nothing, guarded explicitly. Addresses the review follow-up: surface span exits beyond message text. --- .../PeriscopeCore/Sources/LogQuery.swift | 3 ++ .../PeriscopeCore/Sources/LogSpan.swift | 6 +++ .../Sources/PeriscopeSchema.swift | 14 ++++++- .../Sources/PeriscopeStore.swift | 39 +++++++++++++++---- .../PeriscopeCore/Sources/SpanExit.swift | 2 +- .../Sources/StoredLogEvent.swift | 5 +++ .../Tests/PeriscopeStoreTests.swift | 37 ++++++++++++++++++ .../Tests/StoredLogEventTests.swift | 1 + Shared/Periscope/PeriscopeTools/README.md | 5 ++- .../Sources/LogEventDetailView.swift | 17 ++++++++ .../PeriscopeTools/Sources/LogEventRow.swift | 3 ++ .../Sources/NDJSONExporter.swift | 3 ++ .../Sources/PeriscopeViewer.swift | 6 +++ .../Sources/PeriscopeViewerModel.swift | 7 ++++ .../Sources/SpanExit+Display.swift | 36 +++++++++++++++++ .../Tests/NDJSONExporterTests.swift | 14 +++++++ .../Tests/PeriscopeViewerModelTests.swift | 24 ++++++++++++ Shared/Periscope/TODOs.md | 2 +- 18 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 Shared/Periscope/PeriscopeTools/Sources/SpanExit+Display.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift b/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift index 5b376a4b..49ea8b6e 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift @@ -26,6 +26,9 @@ public struct LogQuery: Sendable { public var scope: ScopeFilter? /// Only events stamped with this exact key/value tag. public var tag: LogTag? + /// Only span-ended events with this exit mode — "everything that + /// failed", "everything that expired". + public var spanExitMode: SpanExit.Mode? /// Only events whose message matches this text /// (`localizedStandardContains`). public var messageContains: String? diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift index cb563168..e0b87f1d 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift @@ -154,6 +154,12 @@ extension LogRecord { 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift index 6d179782..a3ff2422 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift @@ -12,7 +12,14 @@ import SwiftData /// JSON instead of requiring schema migrations. @Model final class SDLogEvent { - #Index([\.date], [\.severity], [\.eventName], [\.sessionID], [\.spanID]) + #Index( + [\.date], + [\.severity], + [\.eventName], + [\.sessionID], + [\.spanID], + [\.spanExitMode], + ) var eventID: UUID var date: Date @@ -31,6 +38,9 @@ final class SDLogEvent { 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? var scopes: [SDLogScope] var tags: [SDLogTag] @@ -50,6 +60,7 @@ final class SDLogEvent { orderedScopeIDs: [UUID], sessionID: UUID, spanID: UUID?, + spanExitMode: String?, scopes: [SDLogScope], tags: [SDLogTag], attachments: [SDLogAttachment], @@ -66,6 +77,7 @@ final class SDLogEvent { self.orderedScopeIDs = orderedScopeIDs self.sessionID = sessionID self.spanID = spanID + self.spanExitMode = spanExitMode self.scopes = scopes self.tags = tags self.attachments = attachments diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index 81178914..76a43745 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -321,6 +321,7 @@ public actor PeriscopeStore: LogSink { orderedScopeIDs: record.scopes.map(\.rawValue), sessionID: session.sessionID, spanID: record.spanID?.rawValue, + spanExitMode: record.spanExit?.mode.rawValue, scopes: scopeRows, tags: tagRows, attachments: attachmentRows, @@ -452,14 +453,35 @@ public actor PeriscopeStore: LogSink { SDLogTag.pairValue(key: $0.key.rawValue, value: $0.value) } ?? "" - let predicate = #Predicate { event in - event.date >= start && event.date <= end - && event.severity >= minSeverity - && (!filtersName || event.eventName == name) - && (!filtersSession || event.sessionID == session) - && (!filtersSearch || event.message.localizedStandardContains(search)) - && (!filtersScope || event.scopes.contains { scopeIDs.contains($0.scopeID) }) - && (!filtersTag || event.tags.contains { $0.pair == tagPair }) + // Two predicate variants, because a ninth && condition pushes the + // #Predicate macro past the type-checker's budget ("unable to + // type-check in reasonable time"). The exit variant *replaces* the + // event-name condition rather than adding one: an exit filter only + // matches span-ended events, so a conflicting name filter provably + // matches nothing. + let predicate: Predicate + if let exit = query.spanExitMode { + guard !filtersName || name == SpanEnded.eventName else { return [] } + let exitMode: String? = exit.rawValue + predicate = #Predicate { event in + event.spanExitMode == exitMode + && event.date >= start && event.date <= end + && event.severity >= minSeverity + && (!filtersSession || event.sessionID == session) + && (!filtersSearch || event.message.localizedStandardContains(search)) + && (!filtersScope || event.scopes.contains { scopeIDs.contains($0.scopeID) }) + && (!filtersTag || event.tags.contains { $0.pair == tagPair }) + } + } else { + predicate = #Predicate { event in + event.date >= start && event.date <= end + && event.severity >= minSeverity + && (!filtersName || event.eventName == name) + && (!filtersSession || event.sessionID == session) + && (!filtersSearch || event.message.localizedStandardContains(search)) + && (!filtersScope || event.scopes.contains { scopeIDs.contains($0.scopeID) }) + && (!filtersTag || event.tags.contains { $0.pair == tagPair }) + } } var descriptor = Self.readDescriptor( @@ -544,6 +566,7 @@ public actor PeriscopeStore: LogSink { uniquingKeysWith: { first, _ in first }, ), spanID: row.spanID.map(SpanID.init(rawValue:)), + spanExitMode: row.spanExitMode.flatMap(SpanExit.Mode.init(rawValue:)), attachments: row.attachments .sorted { $0.index < $1.index } .map { LogAttachmentInfo(name: $0.name, contentType: $0.contentType) }, diff --git a/Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift b/Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift index 5ca4c072..0befc1bb 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift @@ -4,7 +4,7 @@ import Foundation /// 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, 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. diff --git a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift index a47c5cf5..fb7fe764 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift @@ -25,6 +25,9 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { public let tags: [LogTagKey: String] /// 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? /// Attachment metadata; bytes load via /// `PeriscopeStore.attachments(forEvent:)`. public let attachments: [LogAttachmentInfo] @@ -42,6 +45,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { scopes: [ScopeID], tags: [LogTagKey: String], spanID: SpanID?, + spanExitMode: SpanExit.Mode?, attachments: [LogAttachmentInfo], sessionID: UUID, ) { @@ -56,6 +60,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { self.scopes = scopes self.tags = tags self.spanID = spanID + self.spanExitMode = spanExitMode self.attachments = attachments self.sessionID = sessionID } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 21b81bed..25d85e63 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -239,6 +239,43 @@ struct PeriscopeStoreTests { #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( diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift index 2ed4a785..89615d5d 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -17,6 +17,7 @@ struct StoredLogEventTests { scopes: [scope.id], tags: [LogTagKey("payment-id"): "pay_123"], spanID: nil, + spanExitMode: nil, attachments: [], sessionID: UUID(), ) diff --git a/Shared/Periscope/PeriscopeTools/README.md b/Shared/Periscope/PeriscopeTools/README.md index 52f0ef90..5f0056a1 100644 --- a/Shared/Periscope/PeriscopeTools/README.md +++ b/Shared/Periscope/PeriscopeTools/README.md @@ -45,8 +45,9 @@ Toggle("Log View Mode", isOn: $inspector.isEnabled) - **`PeriscopeViewer(store:title:)`** — the latest-logs viewer: newest-first list over a `PeriscopeStore`, searchable, filterable by level / event type - / scope subtree / session, paged, with per-event detail (payload JSON, - tags, attachments) and NDJSON export for bug reports. Push it inside an + / 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 diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift index f9d2e61f..54248051 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift @@ -29,6 +29,16 @@ struct LogEventDetailView: View { 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 = exitReason { + Text(reason) + } + } + } + } LabeledContent("Session", value: event.sessionID.uuidString) } @@ -111,6 +121,13 @@ struct LogEventDetailView: View { } } + /// 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. + private var exitReason: String? { + (try? event.decode(SpanEnded.self))?.exit.reason + } + /// The stored payload, pretty-printed; `nil` when the event carried no /// structured fields or the payload isn't JSON. private var prettyPayload: String? { diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift b/Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift index efa9f196..da2a04a2 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift @@ -11,6 +11,9 @@ struct LogEventRow: 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) diff --git a/Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift b/Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift index b7ad386c..98e812b1 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift @@ -38,6 +38,9 @@ enum NDJSONExporter { if let span = event.spanID { object["span"] = span.rawValue.uuidString } + if let exitMode = event.spanExitMode { + object["spanExit"] = exitMode.rawValue + } if !event.payload.isEmpty, let payload = try? JSONSerialization.jsonObject(with: event.payload) { diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift index be34b61b..e7f5f453 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift @@ -121,6 +121,12 @@ public struct PeriscopeViewer: View { 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") } diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift index cb5f676f..9a0dc0ee 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift @@ -57,6 +57,12 @@ final class PeriscopeViewerModel { 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 } @@ -166,6 +172,7 @@ final class PeriscopeViewerModel { query.eventName = selectedEventName query.sessionID = selectedSessionID query.scope = selectedScope.map(ScopeFilter.subtree) + query.spanExitMode = selectedSpanExitMode query.messageContains = searchText.isEmpty ? nil : searchText return query } diff --git a/Shared/Periscope/PeriscopeTools/Sources/SpanExit+Display.swift b/Shared/Periscope/PeriscopeTools/Sources/SpanExit+Display.swift new file mode 100644 index 00000000..d7c868d6 --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Sources/SpanExit+Display.swift @@ -0,0 +1,36 @@ +import PeriscopeCore +import SwiftUI + +extension SpanExit.Mode { + /// Capitalized name shown in chips and the exit filter. + var displayName: String { + rawValue.capitalized + } + + /// Chip tint: calm for expected outcomes, hot for the ones worth + /// chasing. + var tint: Color { + switch self { + case .success: .green + case .cancelled: .gray + case .superseded: .yellow + case .expired: .orange + case .failure: .red + case .orphaned: .purple + } + } +} + +/// The exit-mode chip shown beside span-ended rows and in event detail. +struct SpanExitBadge: View { + let mode: SpanExit.Mode + + var body: some View { + Text(mode.displayName) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(mode.tint.opacity(0.18), in: .capsule) + .foregroundStyle(mode.tint) + } +} diff --git a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift index 57f6d36f..e5572b98 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift @@ -17,6 +17,7 @@ struct NDJSONExporterTests { date: Date, payload: Data = Data(), tags: [LogTagKey: String] = [:], + spanExitMode: SpanExit.Mode? = nil, ) -> StoredLogEvent { StoredLogEvent( id: UUID(), @@ -30,6 +31,7 @@ struct NDJSONExporterTests { scopes: [root.child(named: "photos").id], tags: tags, spanID: nil, + spanExitMode: spanExitMode, attachments: [], sessionID: sessionID, ) @@ -74,6 +76,17 @@ struct NDJSONExporterTests { #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(), @@ -87,6 +100,7 @@ struct NDJSONExporterTests { scopes: [LogScope.root(named: "never-defined").id], tags: [:], spanID: nil, + spanExitMode: nil, attachments: [], sessionID: sessionID, ) diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift index 83d2281e..84194abc 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeViewerModelTests.swift @@ -78,6 +78,30 @@ struct PeriscopeViewerModelTests { #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 diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 6383ed26..a9ba7cea 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -13,7 +13,6 @@ ## P1s (Should do) ## P2s (Nice to have) -- feat: Surface span exits in the viewer beyond the message text (badge tint by exit mode, filter by exit). - docs: `log(PhotoLogs.self) { event }` parses as one call and fails to compile; document the required two-step spelling near `callAsFunction`. - perf: `LocalNotificationAlertHandler` requests notification authorization on every alert; cache the grant. @@ -24,6 +23,7 @@ - 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. ## 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. From f235ba03fb64000fdd74c967b0e2ac819d40d33d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 16:30:03 -0400 Subject: [PATCH 37/73] Add derive-and-emit overloads so trailing-closure chains compile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log(PhotoLogs.self) { PhotoLogs(photoID: id) } read naturally but failed to compile: Swift resolves a value call's arguments and trailing closure as a single callAsFunction application, unlike type callees (SwiftUI's Layouts) which get an implicit init-then-call split — verified by experiment. New overloads give the natural spelling a real resolution with the obvious semantics: derive the typed (or entity-keyed) child scope and emit the event into it, one expression. Derive-only forms keep their meaning via distinct arity; attachment variants stay two-step. The PeriscopeUI typed probe now uses the one-expression spelling as cross-module proof. Addresses code-review low-severity item: trailing-closure footgun (upgraded from docs-only to API after the Layout comparison). --- Shared/Periscope/PeriscopeCore/README.md | 1 + .../Periscope/PeriscopeCore/Sources/Log.swift | 27 +++++++++++++++++++ .../PeriscopeCore/Tests/LogTests.swift | 20 ++++++++++++++ .../Tests/LogContextEnvironmentTests.swift | 3 +-- Shared/Periscope/TODOs.md | 2 +- 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index 58cc825c..834faad8 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -51,6 +51,7 @@ 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Log.swift index 0e59b8c3..a0406e85 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Log.swift @@ -125,6 +125,33 @@ public struct Log: Sendable { emit(event(), attachments: attachments) } + /// 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, + _ event: () -> Child, + ) { + let child: Log = callAsFunction(type) + child.emit(event()) + } + + /// 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, + _ event: () -> Event, + ) { + let child: Log = callAsFunction(for: id) + child.emit(event()) + } + func emit(_ event: any LogEvent, attachments: [LogAttachment] = []) { recorder.record(LogRecord( date: Date(), diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift index fff06b98..78603454 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -79,6 +79,26 @@ struct LogTests { #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") diff --git a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift index 787e94fb..0f198756 100644 --- a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift +++ b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift @@ -23,8 +23,7 @@ private struct TypedProbe: View { var body: some View { Color.clear.onAppear { - let photos = log(PhotoLogs.self) - photos { PhotoLogs(photoID: "p1") } + log(PhotoLogs.self) { PhotoLogs(photoID: "p1") } } } } diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index a9ba7cea..14aff8fc 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -13,7 +13,6 @@ ## P1s (Should do) ## P2s (Nice to have) -- docs: `log(PhotoLogs.self) { event }` parses as one call and fails to compile; document the required two-step spelling near `callAsFunction`. - perf: `LocalNotificationAlertHandler` requests notification authorization on every alert; cache the grant. # Completed issues @@ -24,6 +23,7 @@ - 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). ## 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. From bf4c5d1aac52581f57985a5fe6bd4a988e027244 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 16:57:44 -0400 Subject: [PATCH 38/73] Cache notification authorization in the alert handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalNotificationAlertHandler asked UNUserNotificationCenter for authorization before every post — one daemon round-trip per alerted record, during exactly the error storms the alerter exists for. The outcome now caches in a lock box: granted goes straight to add, denied goes quiet without re-asking, and a *failed* request stays unknown so transient failures retry on the next alert. The center sits behind an AlertNotificationCenter seam modeled on Where's NotificationReminderCenter (production adapter @unchecked Sendable — UNUserNotificationCenter is documented thread-safe), with the posting path nonisolated so the non-Sendable UNNotificationRequest is built in the sending region. Tests cover one-request-across-N-posts, quiet denial, and transient-failure retry via a scriptable fake center. Addresses code-review low-severity item: per-alert authorization requests. Closes out the review's low-priority list. --- .../LocalNotificationAlertHandler.swift | 101 +++++++++++++++--- .../LocalNotificationAlertHandlerTests.swift | 95 ++++++++++++++++ Shared/Periscope/TODOs.md | 3 +- 3 files changed, 181 insertions(+), 18 deletions(-) diff --git a/Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift b/Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift index 486927b8..6fd08df2 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift @@ -5,36 +5,77 @@ import UserNotifications /// The default debug toast: posts each alerted record as a local /// notification (requesting provisional authorization, which delivers -/// quietly without prompting). Apps with their own toast system implement -/// ``PeriscopeAlertHandler`` instead. +/// 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 static let failureLogger = os.Logger( + private nonisolated static let failureLogger = os.Logger( subsystem: "com.stuff.periscope", category: "alerts", ) - public init() {} + 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) { - let request = Self.request(for: record) Task { - let center = UNUserNotificationCenter.current() - do { - _ = try await center.requestAuthorization( - options: [.alert, .sound, .provisional], - ) - try await center.add(request) - } catch { - Self.failureLogger.warning( - "Failed to post log alert notification: \(error)", - ) + 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)", + ) } } - static func request(for record: LogRecord) -> UNNotificationRequest { + nonisolated static func request(for record: LogRecord) -> UNNotificationRequest { let content = UNMutableNotificationContent() content.title = "\(record.level.name.capitalized): \(record.eventName)" content.body = record.message @@ -45,3 +86,31 @@ public struct LocalNotificationAlertHandler: PeriscopeAlertHandler { ) } } + +/// 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/Tests/LocalNotificationAlertHandlerTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift index 5ac26d6e..9f0c4855 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LocalNotificationAlertHandlerTests.swift @@ -1,10 +1,68 @@ 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(), @@ -19,4 +77,41 @@ struct LocalNotificationAlertHandlerTests { #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/TODOs.md b/Shared/Periscope/TODOs.md index 14aff8fc..ad586cae 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -13,8 +13,6 @@ ## P1s (Should do) ## P2s (Nice to have) -- perf: `LocalNotificationAlertHandler` requests notification authorization on every alert; cache the grant. - # Completed issues ## P2s (Nice to have) @@ -24,6 +22,7 @@ - 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. From 317143228d7377019e85e4f95052acebc6707f11 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 17:31:41 -0400 Subject: [PATCH 39/73] Keep the orphan sweep's launch cost off the row width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeOrphanedSpans runs inside startSession — the launch path — and materialized every span-ended row ever retained (payload included) just to build a set of IDs, plus full rows for every historical span-began. Both passes now fetch only the spanID column (propertiesToFetch); full rows load exclusively for the orphan candidates (began minus ended), which are pathologically few. Behavior unchanged — the existing orphan tests cover all three outcomes. Addresses second-review finding 1 (orphan sweep on the launch path). --- .../Sources/PeriscopeStore.swift | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index 76a43745..8f3c744c 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -112,21 +112,38 @@ public actor PeriscopeStore: LogSink { do { let beganName = SpanBegan.eventName let endedName = SpanEnded.eventName - // The orphan records reuse each began row's tags, so prefetch. - let began = try modelContext.fetch(Self.readDescriptor( + + // 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 }, - )) - guard !began.isEmpty else { return } - let ended = try modelContext.fetch(FetchDescriptor( + ) + 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) + }, )) - let endedSpanIDs = Set(ended.compactMap(\.spanID)) var orphans: [LogRecord] = [] for row in began { - guard let spanID = row.spanID, !endedSpanIDs.contains(spanID) else { continue } + 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) From 068f91658e5c1188e46e98e5a2c08edd1e7fbec3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 17:32:59 -0400 Subject: [PATCH 40/73] Yield inspect-mode changes inside the lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setter snapshotted observers under the lock but yielded outside it, so two threads toggling the flag could interleave their yields out of order — and with bufferingNewest(1), a subscriber that wasn't mid-consume would hold the losing value forever, leaving PeriscopeInspector's mirror terminally divergent. Yields now happen inside the lock (they only buffer; no consumer can run under us), so delivery order matches flag order. Stress test hammers the flag from two tasks and asserts the single buffered value equals the final write. Addresses second-review finding 2 (inspect-stream ordering race). --- .../PeriscopeCore/Sources/Periscope.swift | 16 +++++----- .../PeriscopeCore/Tests/PeriscopeTests.swift | 30 +++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 970e33cb..3b336739 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -345,14 +345,16 @@ public final class Periscope: LogRecorder, Sendable { public var isInspectModeEnabled: Bool { get { state.withLock(\.inspectModeEnabled) } set { - let observers: [AsyncStream.Continuation]? = state.withLock { state in - guard state.inspectModeEnabled != newValue else { return nil } + // 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 - return Array(state.inspectObservers.values) - } - guard let observers else { return } - for observer in observers { - observer.yield(newValue) + for observer in state.inspectObservers.values { + observer.yield(newValue) + } } } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 93e0778f..11010f11 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -192,6 +192,36 @@ struct PeriscopeTests { #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 From 043f27d1ff8bbb239d0c859636a30306fe7618e1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 17:36:46 -0400 Subject: [PATCH 41/73] Floor span pairs together: begin decides, the lifecycle follows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Span lifecycle records went through record()'s floor check individually, so floors could swallow the closing half of a recorded pair: an expired or superseded SpanEnded emitted under a raised floor vanished while openSpans moved on — leaving a dangling SpanBegan that the next launch would mislabel as .orphaned. Worse, level asymmetry (began .info, abnormal ends .warning) meant a .warning floor recorded lonely ends with no began. The floor decision is now made exactly once, at begin (or at the top of measure), and the whole pair follows it: OpenSpan carries beganRecorded, and ends — normal, expired, superseded — bypass delivery-time floors via an internal LogRecord.bypassesFloors flag when their began was recorded, or stay silent when it wasn't (overdue sentinels included). A floored began silences the entire span; a recorded began always gets its end, even if floors rise mid-span. Signposts are unaffected — a separate channel. Recorded as a Core AGENTS invariant, covered across begin/end, expiry, supersession, and both measure paths. Addresses second-review finding 3 (floors could swallow span closings). --- Shared/Periscope/PeriscopeCore/AGENTS.md | 6 + .../Periscope/PeriscopeCore/Sources/Log.swift | 12 +- .../PeriscopeCore/Sources/LogRecord.swift | 6 + .../PeriscopeCore/Sources/LogSpan.swift | 143 ++++++++++++------ .../PeriscopeCore/Sources/Periscope.swift | 15 +- .../PeriscopeCore/Tests/PeriscopeTests.swift | 91 +++++++++++ 6 files changed, 222 insertions(+), 51 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index 13bd1a76..bb79f5e7 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -46,6 +46,12 @@ the build system, formatting, and global conventions. Read that first. 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)). +- **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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Log.swift index a0406e85..639355cc 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Log.swift @@ -152,14 +152,20 @@ public struct Log: Sendable { child.emit(event()) } - func emit(_ event: any LogEvent, attachments: [LogAttachment] = []) { - recorder.record(LogRecord( + func emit( + _ event: any LogEvent, + attachments: [LogAttachment] = [], + bypassingFloors: Bool = false, + ) { + var record = LogRecord( date: Date(), event: event, scopes: scopes.map(\.id), tags: tags, attachments: attachments, - )) + ) + record.bypassesFloors = bypassingFloors + recorder.record(record) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift index c413d1c8..6b110868 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift @@ -20,6 +20,12 @@ public struct LogRecord: Sendable, Identifiable { /// Data attached at the call site (see `LogAttachment`). public let attachments: [LogAttachment] + /// 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, diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift index e0b87f1d..e75ff5ae 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift @@ -177,12 +177,17 @@ public struct SpanKey: Hashable, Sendable { /// 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. +/// 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: [LogTagKey: String] @@ -191,6 +196,7 @@ public struct OpenSpan: Sendable { name: String, start: ContinuousClock.Instant, lifetime: SpanLifetime, + beganRecorded: Bool, scopes: [ScopeID], tags: [LogTagKey: String], ) { @@ -198,6 +204,7 @@ public struct OpenSpan: Sendable { self.name = name self.start = start self.lifetime = lifetime + self.beganRecorded = beganRecorded self.scopes = scopes self.tags = tags } @@ -298,18 +305,20 @@ extension Log { let span = SpanID() let clock = ContinuousClock() let start = clock.now - SpanSignposts.begin(span, name: spanName) - emit(SpanBegan( - spanID: span, - name: spanName, - lifetime: .scoped, - relaunchPolicy: .endsWithProcess, - )) - let sentinel = budget.map { startOverdueSentinel(span: span, name: spanName, budget: $0) } + let recorded = beginMeasuredSpan(span, name: spanName) + let sentinel = (recorded ? budget : nil).map { + startOverdueSentinel(span: span, name: spanName, budget: $0) + } defer { sentinel?.cancel() } do { let result = try body() - endMeasuredSpan(span, name: spanName, duration: clock.now - start, exit: .success) + endMeasuredSpan( + span, + name: spanName, + duration: clock.now - start, + exit: .success, + recorded: recorded, + ) return result } catch { endMeasuredSpan( @@ -317,6 +326,7 @@ extension Log { name: spanName, duration: clock.now - start, exit: Self.exit(for: error), + recorded: recorded, ) throw error } @@ -331,18 +341,20 @@ extension Log { let span = SpanID() let clock = ContinuousClock() let start = clock.now - SpanSignposts.begin(span, name: spanName) - emit(SpanBegan( - spanID: span, - name: spanName, - lifetime: .scoped, - relaunchPolicy: .endsWithProcess, - )) - let sentinel = budget.map { startOverdueSentinel(span: span, name: spanName, budget: $0) } + let recorded = beginMeasuredSpan(span, name: spanName) + let sentinel = (recorded ? budget : nil).map { + startOverdueSentinel(span: span, name: spanName, budget: $0) + } defer { sentinel?.cancel() } do { let result = try await body() - endMeasuredSpan(span, name: spanName, duration: clock.now - start, exit: .success) + endMeasuredSpan( + span, + name: spanName, + duration: clock.now - start, + exit: .success, + recorded: recorded, + ) return result } catch { endMeasuredSpan( @@ -350,6 +362,7 @@ extension Log { name: spanName, duration: clock.now - start, exit: Self.exit(for: error), + recorded: recorded, ) throw error } @@ -370,14 +383,38 @@ extension Log { } } + /// 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, ) { SpanSignposts.end(span) - emit(SpanEnded(spanID: span, name: name, duration: duration, exit: exit)) + guard recorded else { return } + emit( + SpanEnded(spanID: span, name: name, duration: duration, exit: exit), + bypassingFloors: true, + ) } private static func exit(for error: any Error) -> SpanExit { @@ -401,35 +438,49 @@ extension Log { ) { let name = String(describing: id) let key = SpanKey(scope: primaryScope.id, identifier: name) + let began = SpanBegan( + spanID: SpanID(), + name: name, + lifetime: lifetime, + relaunchPolicy: relaunch, + ) + // 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: SpanID(), + id: began.spanID, name: name, start: ContinuousClock().now, lifetime: lifetime, + beganRecorded: beganRecorded, scopes: scopes.map(\.id), tags: tags, ) if let superseded = recorder.openSpan(key: key, span: span) { SpanSignposts.end(superseded.id) - recorder.record(LogRecord( - date: Date(), - event: SpanEnded( - spanID: superseded.id, - name: superseded.name, - duration: span.start - superseded.start, - exit: .superseded, - ), - scopes: superseded.scopes, - tags: superseded.tags, - )) + 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) - emit(SpanBegan( - spanID: span.id, - name: name, - lifetime: lifetime, - relaunchPolicy: relaunch, - )) + if beganRecorded { + emit(began, bypassingFloors: true) + } } /// Close the span opened with ``begin(for:lifetime:relaunch:)`` for the @@ -444,11 +495,15 @@ extension Log { return } SpanSignposts.end(open.id) - emit(SpanEnded( - spanID: open.id, - name: open.name, - duration: ContinuousClock().now - open.start, - exit: exit, - )) + 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/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 3b336739..9a9519ba 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -247,8 +247,12 @@ public final class Periscope: LogRecorder, Sendable { 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. - guard shouldRecord(level: original.level, scopes: original.scopes) else { return } + // 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 } + } let record: LogRecord if let redact = configuration.redact { guard let redacted = redact(original) else { return } @@ -421,7 +425,8 @@ public final class Periscope: LogRecorder, Sendable { for span in expired { SpanSignposts.end(span.id) guard case let .bounded(budget) = span.lifetime else { continue } - record(LogRecord( + guard span.beganRecorded else { continue } + var closing = LogRecord( date: Date(), event: SpanEnded( spanID: span.id, @@ -431,7 +436,9 @@ public final class Periscope: LogRecorder, Sendable { ), scopes: span.scopes, tags: span.tags, - )) + ) + closing.bypassesFloors = true + record(closing) } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 11010f11..f46b2794 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -305,6 +305,97 @@ struct PeriscopeTests { #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 From 166cb7db95a4bbdc0593453db1540c51300307e1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 17:38:46 -0400 Subject: [PATCH 42/73] Hold the system weakly from the span watchdog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watchdog task captured self strongly and sleeps until the earliest bounded deadline, so a discarded Periscope system — test suites create dozens — stayed alive until its next wake (up to a full budget). The loop now promotes a weak reference per call and never holds it across the sleep; a dead system's watchdog simply retires at its next check. Verified by a test that opens a 120s-budget span, drops the system, and asserts the weak reference clears — plus the previously untested respawn path: a 20ms span opened while the watchdog sleeps toward a 30s deadline must expire promptly, which only the cancel-and-respawn branch makes possible. TODOs.md records the second-review pass. Addresses second-review finding 4 (watchdog retention) and its missing respawn test. --- .../PeriscopeCore/Sources/Periscope.swift | 42 ++++++++++++------- .../PeriscopeCore/Tests/PeriscopeTests.swift | 36 ++++++++++++++++ Shared/Periscope/TODOs.md | 6 +++ 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 9a9519ba..6ab21419 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -463,26 +463,36 @@ public final class Periscope: LogRecorder, Sendable { state.watchdogGeneration += 1 state.watchdogWakeAt = next let generation = state.watchdogGeneration - state.watchdogTask = Task { await self.runWatchdog(generation: generation) } + // 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) + } + } } } - private func runWatchdog(generation: Int) async { - while true { - let wakeAt: 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 + /// 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 } - guard let wakeAt else { return } - try? await Task.sleep(until: wakeAt, clock: .continuous) - if Task.isCancelled { return } - sweepOverdueSpans(now: ContinuousClock().now) + state.watchdogWakeAt = next + return next } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index f46b2794..6604e761 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -630,6 +630,42 @@ struct PeriscopeTests { #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 { diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index ad586cae..a5875f2b 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -15,6 +15,12 @@ ## P2s (Nice to have) # Completed issues +## Second review pass +- 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. From 3c4928dceed39818c06e38dbc25d6a17a8018587 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 17:57:02 -0400 Subject: [PATCH 43/73] Build the events predicate as statements to fix the CI type-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI has been red since the first push with 'unable to type-check this expression in reasonable time' on the events(matching:) #Predicate — the macro expands the whole condition chain into one giant inference tree, and while it squeaked under the local budget, CI's slower runners hit the wall (a scratch repro shows the macro form timing out while the equivalent statement form type-checks in 0.14s). The predicate is now hand-built in exactly the shape the macro would expand to, but as statements: one small, independently type-checked let per condition, folded into a conjunction. This also removes the earlier two-variant workaround — every filter, span exit included, now combines in a single predicate, with the name+exit guard gone (the conjunction handles conflicting filters naturally) — and future filters add a constant, not compounding, type-check cost. Runtime behavior is identical; every filter path is already covered by store and viewer tests. Fixes the Build & Test (iOS) CI failure. --- .../Sources/PeriscopeStore.swift | 224 +++++++++++++++--- Shared/Periscope/TODOs.md | 1 + 2 files changed, 194 insertions(+), 31 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift index 8f3c744c..69c29b5f 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift @@ -469,37 +469,26 @@ public actor PeriscopeStore: LogSink { let tagPair = query.tag.map { SDLogTag.pairValue(key: $0.key.rawValue, value: $0.value) } ?? "" - - // Two predicate variants, because a ninth && condition pushes the - // #Predicate macro past the type-checker's budget ("unable to - // type-check in reasonable time"). The exit variant *replaces* the - // event-name condition rather than adding one: an exit filter only - // matches span-ended events, so a conflicting name filter provably - // matches nothing. - let predicate: Predicate - if let exit = query.spanExitMode { - guard !filtersName || name == SpanEnded.eventName else { return [] } - let exitMode: String? = exit.rawValue - predicate = #Predicate { event in - event.spanExitMode == exitMode - && event.date >= start && event.date <= end - && event.severity >= minSeverity - && (!filtersSession || event.sessionID == session) - && (!filtersSearch || event.message.localizedStandardContains(search)) - && (!filtersScope || event.scopes.contains { scopeIDs.contains($0.scopeID) }) - && (!filtersTag || event.tags.contains { $0.pair == tagPair }) - } - } else { - predicate = #Predicate { event in - event.date >= start && event.date <= end - && event.severity >= minSeverity - && (!filtersName || event.eventName == name) - && (!filtersSession || event.sessionID == session) - && (!filtersSearch || event.message.localizedStandardContains(search)) - && (!filtersScope || event.scopes.contains { scopeIDs.contains($0.scopeID) }) - && (!filtersTag || event.tags.contains { $0.pair == tagPair }) - } - } + let filtersExit = query.spanExitMode != nil + let exitMode: String? = query.spanExitMode?.rawValue + + let predicate = Self.eventsPredicate( + start: start, + end: end, + minSeverity: minSeverity, + filtersName: filtersName, + name: name, + filtersSession: filtersSession, + session: session, + filtersExit: filtersExit, + exitMode: exitMode, + filtersSearch: filtersSearch, + search: search, + filtersScope: filtersScope, + scopeIDs: scopeIDs, + filtersTag: filtersTag, + tagPair: tagPair, + ) var descriptor = Self.readDescriptor( predicate: predicate, @@ -517,6 +506,179 @@ public actor PeriscopeStore: LogSink { 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?, + filtersSearch: Bool, + search: String, + filtersScope: Bool, + scopeIDs: [UUID], + filtersTag: Bool, + tagPair: 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 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, + ), + ) + }, + ) + let matchesTag = PredicateExpressions.build_Disjunction( + lhs: PredicateExpressions.build_Negation( + PredicateExpressions.build_Arg(filtersTag), + ), + rhs: PredicateExpressions.build_contains( + PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(event), + keyPath: \.tags, + ), + ) { tag in + PredicateExpressions.build_Equal( + lhs: PredicateExpressions.build_KeyPath( + root: PredicateExpressions.build_Arg(tag), + keyPath: \.pair, + ), + rhs: PredicateExpressions.build_Arg(tagPair), + ) + }, + ) + + 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 searched = PredicateExpressions.build_Conjunction( + lhs: exited, + 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. diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index a5875f2b..0ee17d22 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -16,6 +16,7 @@ # Completed issues ## Second review pass +- 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.) From 7f7d5aea3bb8e6d403ef4e9f77bebd025af47ff0 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 18:13:20 -0400 Subject: [PATCH 44/73] Serialize overdue sentinels with their span's end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A budgeted measure finishing right at its budget boundary had a microseconds-wide race: the sentinel could pass its cancellation check just as the closure completed, recording a false SpanOverdue after the SpanEnded. Both emissions now serialize through a per-measure gate: the end marks-and-emits under the lock, and a sentinel that arrives after stays silent. (Emitting under the gate is safe — record() takes only the system's own lock, and nothing takes the gate inside it.) Addresses second-review low item: sentinel false positive at the budget boundary. --- .../PeriscopeCore/Sources/LogSpan.swift | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift index e75ff5ae..59234417 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift @@ -306,8 +306,12 @@ extension Log { let clock = ContinuousClock() let start = clock.now let recorded = beginMeasuredSpan(span, name: spanName) - let sentinel = (recorded ? budget : nil).map { - startOverdueSentinel(span: span, name: spanName, budget: $0) + 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 { @@ -318,6 +322,7 @@ extension Log { duration: clock.now - start, exit: .success, recorded: recorded, + overdueGate: overdueGate, ) return result } catch { @@ -327,6 +332,7 @@ extension Log { duration: clock.now - start, exit: Self.exit(for: error), recorded: recorded, + overdueGate: overdueGate, ) throw error } @@ -342,8 +348,12 @@ extension Log { let clock = ContinuousClock() let start = clock.now let recorded = beginMeasuredSpan(span, name: spanName) - let sentinel = (recorded ? budget : nil).map { - startOverdueSentinel(span: span, name: spanName, budget: $0) + 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 { @@ -354,6 +364,7 @@ extension Log { duration: clock.now - start, exit: .success, recorded: recorded, + overdueGate: overdueGate, ) return result } catch { @@ -363,6 +374,7 @@ extension Log { duration: clock.now - start, exit: Self.exit(for: error), recorded: recorded, + overdueGate: overdueGate, ) throw error } @@ -370,16 +382,22 @@ extension Log { /// 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 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 } - emit(SpanOverdue(spanID: span, name: name, budget: budget)) + gate.withLock { ended in + guard !ended else { return } + emit(SpanOverdue(spanID: span, name: name, budget: budget)) + } } } @@ -408,13 +426,22 @@ extension Log { duration: Duration, exit: SpanExit, recorded: Bool, + overdueGate: OSAllocatedUnfairLock?, ) { SpanSignposts.end(span) guard recorded else { return } - emit( - SpanEnded(spanID: span, name: name, duration: duration, exit: exit), - bypassingFloors: true, - ) + 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 { From fedc2c3579f5b6b1205cb7cfad705c536ed93106 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 18:14:39 -0400 Subject: [PATCH 45/73] Consolidate scope-path walking into LogScope.ancestry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six copies of the same parent-walk-and-reverse had accumulated across OSLogSink, the three tool models, OpenSpansView, and NDJSONExporter, differing only in how a ScopeID resolves (a captured map, the system, locked sink state) — with one already-drifted detail (display joins with ' / ', exports with '/', now documented as deliberate). The walk lives once in Core as LogScope.ancestry(of:resolve:), root first, stopping at the first unresolvable scope; call sites keep their own resolver and separator. Covered by new LogScopeTests. Addresses second-review low item: duplicated scope-path walks. --- .../PeriscopeCore/Sources/LogScope.swift | 18 ++++++++++++++++++ .../PeriscopeCore/Sources/OSLogSink.swift | 8 +------- .../PeriscopeCore/Tests/LogScopeTests.swift | 19 +++++++++++++++++++ .../Sources/LogInspectorModel.swift | 10 +++------- .../Sources/LogTraceModel.swift | 10 +++------- .../Sources/NDJSONExporter.swift | 13 +++++-------- .../Sources/OpenSpansView.swift | 10 +++------- .../Sources/PeriscopeViewerModel.swift | 10 +++------- 8 files changed, 55 insertions(+), 43 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogScope.swift b/Shared/Periscope/PeriscopeCore/Sources/LogScope.swift index d3659897..ca06f2f2 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogScope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogScope.swift @@ -21,4 +21,22 @@ public struct LogScope: Hashable, Codable, Sendable, Identifiable { 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/OSLogSink.swift b/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift index 80078636..ecd32867 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift @@ -68,13 +68,7 @@ public struct OSLogSink: LogSink { private func primaryPath(for record: LogRecord) -> [LogScope] { guard let primary = record.scopes.first else { return [] } return state.withLock { state in - var path: [LogScope] = [] - var next: ScopeID? = primary - while let id = next, let scope = state.scopes[id] { - path.append(scope) - next = scope.parentID - } - return path.reversed() + LogScope.ancestry(of: primary) { state.scopes[$0] } } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogScopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogScopeTests.swift index 3a95e2b8..5f04f0c5 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogScopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogScopeTests.swift @@ -18,6 +18,25 @@ struct LogScopeTests { #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) diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift b/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift index ce7badc4..2af64c3a 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift @@ -71,12 +71,8 @@ final class LogInspectorModel { func scopePath(for event: StoredLogEvent) -> String { guard let primary = event.primaryScope else { return "" } - var names: [String] = [] - var next: ScopeID? = primary - while let id = next, let scope = scopes[id] { - names.append(scope.name) - next = scope.parentID - } - return names.reversed().joined(separator: " / ") + return LogScope.ancestry(of: primary) { scopes[$0] } + .map(\.name) + .joined(separator: " / ") } } diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift b/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift index 57525ed1..a862a6ce 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift @@ -77,13 +77,9 @@ final class LogTraceModel { func scopePath(for event: StoredLogEvent) -> String { guard let primary = event.primaryScope else { return "" } - var names: [String] = [] - var next: ScopeID? = primary - while let id = next, let scope = scopes[id] { - names.append(scope.name) - next = scope.parentID - } - return names.reversed().joined(separator: " / ") + return LogScope.ancestry(of: primary) { scopes[$0] } + .map(\.name) + .joined(separator: " / ") } /// Subtree filters for each of the origin's scopes (events within the diff --git a/Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift b/Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift index 98e812b1..40d9ef0b 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift @@ -58,15 +58,12 @@ enum NDJSONExporter { return String(decoding: data, as: UTF8.self) } - /// The primary scope's path (root → leaf), e.g. `"app/photos/album-1"`. + /// 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 "" } - var names: [String] = [] - var next: ScopeID? = primary - while let id = next, let scope = scopes[id] { - names.append(scope.name) - next = scope.parentID - } - return names.reversed().joined(separator: "/") + return LogScope.ancestry(of: primary) { scopes[$0] } + .map(\.name) + .joined(separator: "/") } } diff --git a/Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift b/Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift index e71378f1..4fe04dcc 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift @@ -41,13 +41,9 @@ public struct OpenSpansView: View { private func scopePath(for span: OpenSpan) -> String { guard let primary = span.scopes.first else { return "" } - var names: [String] = [] - var next: ScopeID? = primary - while let id = next, let scope = system.scope(for: id) { - names.append(scope.name) - next = scope.parentID - } - return names.reversed().joined(separator: " / ") + return LogScope.ancestry(of: primary) { system.scope(for: $0) } + .map(\.name) + .joined(separator: " / ") } } diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift index 9a0dc0ee..bde072f4 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift @@ -157,13 +157,9 @@ final class PeriscopeViewerModel { } private func path(for scope: ScopeID) -> String { - var names: [String] = [] - var next: ScopeID? = scope - while let id = next, let resolved = scopes[id] { - names.append(resolved.name) - next = resolved.parentID - } - return names.reversed().joined(separator: " / ") + LogScope.ancestry(of: scope) { scopes[$0] } + .map(\.name) + .joined(separator: " / ") } private var activeQuery: LogQuery { From df39e09f08e8f544dd65628707a10a6dd411e623 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 18:17:19 -0400 Subject: [PATCH 46/73] Speed up window animations in showHosted like show() showHosted hosted views at real animation speed, so any transition a hosted test triggered ran its full duration. Mirror WhereTesting.show: set the window layer speed to 100 for the body and restore it on exit. Addresses second-review low item: showHosted lacks the layer.speed handling show() has. --- .../PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift index 74201d83..edf5a231 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift @@ -92,6 +92,10 @@ func showHosted( guard let rootVC = hostKeyWindow()?.rootViewController else { throw WhereTestingError("No root view controller in test host.") } + // Match WhereTesting.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) From b58a5d571e59bdc5960da9c543e1c45cea6bac4e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 18:17:19 -0400 Subject: [PATCH 47/73] Reset viewer export state when the store is swapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyed task rebuilds PeriscopeViewerModel when the viewer's store changes identity, but the export sheet and failure alert were left standing — a sheet generated against the old store could keep presenting stale NDJSON. Clear both alongside the model rebuild. Addresses second-review low item: stale export sheet across store swaps. --- .../Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift index e7f5f453..2e821c7a 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift @@ -43,10 +43,14 @@ public struct PeriscopeViewer: View { // 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. + // 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() } From bdde06e43a198993181faad81910ecf406a1f4ed Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 18:24:17 -0400 Subject: [PATCH 48/73] Fuzz span lifecycles, floor flips, and supersession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original pipeline fuzz never touched spans, floors, or the open-span registry. A second seeded fuzz interleaves freeform emits, begin/end on keys shared across tasks (so re-begins supersede across tasks), global floor flips, and flushes; it asserts per-emitter delivery order under floors (subsequence, strictly increasing), that no span half ever dangles (every delivered spanID is exactly one began plus one ended — floored begins silence the whole pair), an empty open-span registry after cleanup, and scopes-before-records. Writing the invariants surfaced one soft spot, recorded as a P2 in TODOs.md: begin(for:) registers in openSpans before emitting its began, so a racing supersede can deliver a span's end before its began. Pair completeness always holds; strict intra-pair ordering would need register-and-emit to be atomic. Addresses second-review missing test: fuzz doesn't exercise spans/floors. --- .../PeriscopeCore/Tests/PeriscopeTests.swift | 130 ++++++++++++++++++ Shared/Periscope/TODOs.md | 7 + 2 files changed, 137 insertions(+) diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 6604e761..55425764 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -801,6 +801,136 @@ struct PeriscopeTests { } } + @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) + } + + let system = Periscope(configuration: Periscope.Configuration(), 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 only *remove*: each task's delivered freeform messages + // stay in emission order (strictly increasing suffixes, no + // duplicates), whatever the floor was doing around them. + 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 has exactly one began and one ended — + // a floored begin silences the whole pair instead. Order within a + // pair isn't asserted: a begin registers in openSpans before its + // own SpanBegan emit runs, so a racing supersede on the same key + // can deliver the end first (recorded in TODOs.md). + 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.count(where: { $0.event is SpanBegan }) == 1) + #expect(records.count(where: { $0.event is SpanEnded }) == 1) + } + + // 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( diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 0ee17d22..37bacb40 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -13,9 +13,16 @@ ## P1s (Should do) ## P2s (Nice to have) +- fix: A cross-task supersede can deliver a span's `SpanEnded` before its `SpanBegan`: `begin(for:)` registers the span in `openSpans` *before* emitting its began, so a racing `begin` on the same key can record the superseded end first. Both records always arrive (the span-lifecycle fuzz asserts pair completeness), but strict began-before-ended ordering would need the register-and-emit to be atomic. + # Completed issues ## Second review pass +- 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 `WhereTesting.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.) From 81b953d472c6a062de32af0971cbf4c9dde954a1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 18:42:40 -0400 Subject: [PATCH 49/73] Record a span's began atomically with its registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit begin(for:) registered the span in openSpans and then emitted its SpanBegan as a second step, so a racing begin on the same key (or an end(for:)) could close the span and record its SpanEnded before the began ever entered the pipeline — the viewer and tracer, sorting by date and sequence, would render the pair as ended-before-began. LogRecorder.openSpan becomes beginSpan(key:span:began:): registration and the began record land under one state-lock acquisition, so a span is never visible for closing before its began is buffered. Redaction runs on the began before taking the lock (user code must not run under it). The superseded close now deliberately follows the *new* began — cause before effect: the re-begin is what closed it — and the caller still records it, since it needs no atomicity (the prior span left the registry with its began long since buffered). The span-lifecycle fuzz now asserts strict began-then-ended per span instead of tolerating either order, and the TODOs P2 recording this race moves to completed. Addresses review follow-up 1: end-before-began delivery. --- Shared/Periscope/PeriscopeCore/AGENTS.md | 15 ++++++---- .../PeriscopeCore/Sources/LogRecorder.swift | 14 ++++++--- .../PeriscopeCore/Sources/LogSpan.swift | 21 ++++++++++--- .../PeriscopeCore/Sources/Periscope.swift | 30 +++++++++++++++++-- .../PeriscopeCore/Tests/LogSpanTests.swift | 6 ++-- .../Tests/PeriscopeCoreTestSupport.swift | 5 +++- .../PeriscopeCore/Tests/PeriscopeTests.swift | 13 ++++---- Shared/Periscope/TODOs.md | 3 +- 8 files changed, 78 insertions(+), 29 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index bb79f5e7..0e8dff5e 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -40,12 +40,15 @@ the build system, formatting, and global conventions. Read that first. - **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.** `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)). +- **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 — keep it + that way. - **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 — diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift index 920b94b0..a82cf76b 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift @@ -20,10 +20,16 @@ public protocol LogRecorder: Sendable { /// policy inside `record`. func shouldRecord(level: LogLevel, scopes: [ScopeID]) -> Bool - /// Track a span opened by `Log.begin(for:lifetime:relaunch:)`. When - /// `key` was already open, the prior span is returned (removed) so the - /// caller can close it as superseded. - func openSpan(key: SpanKey, span: OpenSpan) -> OpenSpan? + /// 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/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift index 59234417..54d8b032 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift @@ -486,7 +486,23 @@ extension Log { scopes: scopes.map(\.id), tags: tags, ) - if let superseded = recorder.openSpan(key: key, span: span) { + 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( @@ -505,9 +521,6 @@ extension Log { } } SpanSignposts.begin(span.id, name: name) - if beganRecorded { - emit(began, bypassingFloors: true) - } } /// Close the span opened with ``begin(for:lifetime:relaunch:)`` for the diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 6ab21419..15491ed5 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -384,11 +384,35 @@ public final class Periscope: LogRecorder, Sendable { // MARK: Open spans - public func openSpan(key: SpanKey, span: OpenSpan) -> OpenSpan? { - let superseded = state.withLock { state in + 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. A + // suppressed began leaves the span registered silently, exactly as + // the record path would have dropped it. + let buffered: LogRecord? = if let began, let redact = configuration.redact { + redact(began) + } else { + began + } + // 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, observers) = state.withLock { + state -> (OpenSpan?, [AsyncStream.Continuation]) in let prior = state.openSpans.removeValue(forKey: key) state.openSpans[key] = span - return prior + guard let buffered else { return (prior, []) } + Self.append(buffered, to: &state, configuration: configuration) + return (prior, Array(state.observers.values)) + } + if let buffered { + for observer in observers { + observer.yield(buffered) + } + scheduleDrainIfNeeded() + if buffered.level >= configuration.flushThreshold { + scheduleAutoFlush() + } } scheduleWatchdogIfNeeded() return superseded diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift index d803ca28..2e18fe12 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift @@ -187,11 +187,13 @@ struct LogSpanTests { 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 superseded = try #require(records[1].event as? SpanEnded) - let secondBegan = try #require(records[2].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) diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift index 0098694e..35615cc5 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeCoreTestSupport.swift @@ -32,10 +32,13 @@ final class RecordingRecorder: LogRecorder, Sendable { true } - func openSpan(key: SpanKey, span: OpenSpan) -> OpenSpan? { + 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 } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 55425764..2485abfa 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -901,11 +901,10 @@ struct PeriscopeTests { } // Span pairs never dangle across floor changes or supersession: - // every span the sink saw has exactly one began and one ended — - // a floored begin silences the whole pair instead. Order within a - // pair isn't asserted: a begin registers in openSpans before its - // own SpanBegan emit runs, so a racing supersede on the same key - // can deliver the end first (recorded in TODOs.md). + // 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 } @@ -913,8 +912,8 @@ struct PeriscopeTests { } for (span, records) in lifecycles { #expect(records.count == 2, "span \(span) should be a began/ended pair") - #expect(records.count(where: { $0.event is SpanBegan }) == 1) - #expect(records.count(where: { $0.event is SpanEnded }) == 1) + #expect(records.first?.event is SpanBegan) + #expect(records.last?.event is SpanEnded) } // Scope definitions still precede every record that references them. diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 37bacb40..b07df5ca 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -13,11 +13,10 @@ ## P1s (Should do) ## P2s (Nice to have) -- fix: A cross-task supersede can deliver a span's `SpanEnded` before its `SpanBegan`: `begin(for:)` registers the span in `openSpans` *before* emitting its began, so a racing `begin` on the same key can record the superseded end first. Both records always arrive (the span-lifecycle fuzz asserts pair completeness), but strict began-before-ended ordering would need the register-and-emit to be atomic. - # Completed issues ## Second review pass +- 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: 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 `WhereTesting.show`. From 32b300a90be1f811449e6b0de4bcacfbadaef808 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 18:44:54 -0400 Subject: [PATCH 50/73] Exempt span pairs from the overflow drop policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drop policy removed the oldest pending records regardless of kind, so overflow could split a span pair: a dropped began strands its end (nothing repairs a parentless SpanEnded), and a dropped end leaves the span reading as still open for the rest of the session — the orphan sweep only runs at the next launch. Scope definitions were already exempt; SpanBegan/SpanEnded records now are too (LogRecord.isProtectedFromDropping), with the drop accounting counting what actually dropped. SpanOverdue stays droppable — it's a disposable warning, not half of a pair. A queue saturated with protected records can briefly exceed the bound; they're rare and small, and a split pair is worse. Covered by a deterministic gated-overflow test (pair survives, the freeform flood drops and is reported) and by running the span-lifecycle fuzz under a 16-record queue so drop pressure joins the interleavings. Addresses review follow-up 2: drop policy can split span pairs. --- Shared/Periscope/PeriscopeCore/AGENTS.md | 5 ++- Shared/Periscope/PeriscopeCore/README.md | 2 +- .../PeriscopeCore/Sources/LogSpan.swift | 10 +++++ .../PeriscopeCore/Sources/Periscope.swift | 21 ++++++--- .../PeriscopeCore/Tests/PeriscopeTests.swift | 44 +++++++++++++++++-- Shared/Periscope/TODOs.md | 1 + 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index 0e8dff5e..09f5cb9c 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -47,8 +47,9 @@ the build system, formatting, and global conventions. Read that first. 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 — keep it - that way. + a span is never closable before its began is in the pipeline, and the + overflow drop policy never splits a recorded pair + (`LogRecord.isProtectedFromDropping`) — keep both. - **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 — diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index 834faad8..cb8298ee 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -110,7 +110,7 @@ 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. Event payloads persist as JSON keyed by `eventName` + `eventVersion` +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. diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift index 54d8b032..1f4c4be9 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift @@ -150,6 +150,16 @@ public struct SpanOverdue: LogEvent { extension SpanOverdue: SpanCarrying {} extension LogRecord { + /// Whether the overflow drop policy must keep this record: span pairs + /// never split under drop pressure. Dropping a began strands its end + /// (nothing repairs a parentless `SpanEnded`); dropping an end leaves + /// the span reading as still open for the rest of the session (the + /// orphan sweep only runs at the next launch). `SpanOverdue` is a + /// disposable warning and drops like any other record. + var isProtectedFromDropping: Bool { + event is SpanBegan || event is SpanEnded + } + /// The span this record belongs to, when its event is a span event. public var spanID: SpanID? { (event as? SpanCarrying)?.spanID diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 15491ed5..2e5fbdb1 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -19,7 +19,8 @@ import os /// 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. +/// 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. /// @@ -38,8 +39,8 @@ public final class Periscope: LogRecorder, Sendable { public var recentBufferCapacity: Int /// Maximum records queued for sink delivery; on overflow the oldest - /// drop (scope definitions never drop) and a ``DroppedEvents`` - /// record reports the gap. + /// 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 @@ -320,12 +321,20 @@ public final class Periscope: LogRecorder, Sendable { if pendingOverflow > 0 { var remainingToDrop = pendingOverflow state.pending.removeAll { item in - guard remainingToDrop > 0, case .record = item else { return false } + guard remainingToDrop > 0, + case let .record(record) = item, + !record.isProtectedFromDropping + else { return false } remainingToDrop -= 1 return true } - state.pendingRecordCount -= pendingOverflow - state.droppedCount += pendingOverflow + // 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 } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 2485abfa..63bf3564 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -801,6 +801,36 @@ struct PeriscopeTests { } } + @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 { @@ -843,7 +873,13 @@ struct PeriscopeTests { scripts.append(script) } - let system = Periscope(configuration: Periscope.Configuration(), sinks: [sink]) + // 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 { @@ -887,9 +923,9 @@ struct PeriscopeTests { await system.flush() #expect(system.openSpans().isEmpty) - // Floors only *remove*: each task's delivered freeform messages - // stay in emission order (strictly increasing suffixes, no - // duplicates), whatever the floor was doing around them. + // 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)-" diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index b07df5ca..8bf23ed9 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -17,6 +17,7 @@ ## Second review pass - 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 `WhereTesting.show`. From cd33fdb049280f2541d803c2e785040e1fca2946 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 19:02:08 -0400 Subject: [PATCH 51/73] Yield to live observers under the state lock, via one shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit liveRecords() observers could see records in a different order than the buffers: record() and beginSpan snapshotted observers under the lock but yielded after releasing it, so two racing emitters could deliver inverted — including a span's end reaching a live consumer before its began, even though sinks, the store, and recentRecords() were all correctly ordered. The inspect-mode flag was fixed for this exact failure class in the second review; records now get the same treatment. Yields only buffer, so nothing runs under the lock. The append-yield-drain-autoflush choreography record() and beginSpan had each hand-rolled collapses into Periscope.buffer (in-lock half) plus scheduleFollowUp (outside-lock tail), so the two paths can't drift; announceDropReport yields in-lock too. New tests pin the beginSpan bypass path — begans reach live observers and the recent buffer — and the live stream's buffered-order replay of a full begin/supersede/end lifecycle. Addresses review follow-ups 2 through 4: live-stream ordering, the untested beginSpan bypass, and the duplicated delivery choreography. --- Shared/Periscope/PeriscopeCore/AGENTS.md | 3 + .../PeriscopeCore/Sources/Periscope.swift | 57 ++++++++++++------- .../PeriscopeCore/Tests/PeriscopeTests.swift | 40 +++++++++++++ Shared/Periscope/TODOs.md | 1 + 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index 09f5cb9c..de53b84d 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -23,6 +23,9 @@ the build system, formatting, and global conventions. Read that first. - **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. diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index 2e5fbdb1..f5b585d8 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -261,13 +261,32 @@ public final class Periscope: LogRecorder, Sendable { } else { record = original } - let observers = state.withLock { state in - Self.append(record, to: &state, configuration: configuration) - return Array(state.observers.values) + state.withLock { state in + Self.buffer(record, into: &state, configuration: configuration) } - for observer in observers { + 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() @@ -406,22 +425,16 @@ public final class Periscope: LogRecorder, Sendable { // 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, observers) = state.withLock { - state -> (OpenSpan?, [AsyncStream.Continuation]) in + let superseded = state.withLock { state -> OpenSpan? in let prior = state.openSpans.removeValue(forKey: key) state.openSpans[key] = span - guard let buffered else { return (prior, []) } - Self.append(buffered, to: &state, configuration: configuration) - return (prior, Array(state.observers.values)) + if let buffered { + Self.buffer(buffered, into: &state, configuration: configuration) + } + return prior } if let buffered { - for observer in observers { - observer.yield(buffered) - } - scheduleDrainIfNeeded() - if buffered.level >= configuration.flushThreshold { - scheduleAutoFlush() - } + scheduleFollowUp(for: buffered) } scheduleWatchdogIfNeeded() return superseded @@ -635,18 +648,18 @@ public final class Periscope: LogRecorder, Sendable { } /// Surface a synthetic drop report in the recent buffer and live streams - /// (it never re-enters the pending queue). + /// (it never re-enters the pending queue). Yields in-lock, like + /// ``buffer(_:into:configuration:)``, so live order matches. private func announceDropReport(_ record: LogRecord) { - let observers = state.withLock { state in + state.withLock { state in state.recent.append(record) let overflow = state.recent.count - configuration.recentBufferCapacity if overflow > 0 { state.recent.removeFirst(overflow) } - return Array(state.observers.values) - } - for observer in observers { - observer.yield(record) + for observer in state.observers.values { + observer.yield(record) + } } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 63bf3564..cfed5501 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -125,6 +125,46 @@ struct PeriscopeTests { #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() diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 8bf23ed9..21b8ea5f 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -16,6 +16,7 @@ # Completed issues ## Second review pass +- 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`. From 93b293785c64bc2e93d665bafc1e63782d23c4c4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 19:04:37 -0400 Subject: [PATCH 52/73] Make redaction transform-only for span pair records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redaction was the last remaining way to split a span pair: a hook returning nil for a SpanBegan left the span registered with beganRecorded intact, so every close path emitted an end whose began never entered the pipeline; nil for a SpanEnded stranded a began that was already delivered. The hook may still transform pair records freely, but suppression now falls back to a stripped copy (LogRecord.strippedOfSensitivePayload): tags and attachments dropped and a SpanEnded's freeform exit reason blanked — the PII carriers — while identity, date, scopes, and the floor bypass survive. Span names are typed tokens by convention, not user data. Level floors remain the supported way to silence spans, and SpanOverdue (disposable, not half of a pair) stays suppressible. Both record() and beginSpan route through one redacted(_:) helper. Addresses review follow-up 1: redaction could split span pairs. --- Shared/Periscope/PeriscopeCore/AGENTS.md | 6 ++- Shared/Periscope/PeriscopeCore/README.md | 4 +- .../PeriscopeCore/Sources/LogSpan.swift | 28 +++++++++++++ .../PeriscopeCore/Sources/Periscope.swift | 42 ++++++++++++------- .../PeriscopeCore/Tests/PeriscopeTests.swift | 27 ++++++++++++ Shared/Periscope/TODOs.md | 1 + 6 files changed, 89 insertions(+), 19 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index de53b84d..e520f2d7 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -50,9 +50,11 @@ the build system, formatting, and global conventions. Read that first. 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, and the + a span is never closable before its began is in the pipeline; the overflow drop policy never splits a recorded pair - (`LogRecord.isProtectedFromDropping`) — keep both. + (`LogRecord.isProtectedFromDropping`); 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 — diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index cb8298ee..ffa50a8d 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -117,7 +117,9 @@ 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. + 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift index 1f4c4be9..06ffbdfa 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift @@ -160,6 +160,34 @@ extension LogRecord { event is SpanBegan || event is SpanEnded } + /// 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: [], + ) + 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift index f5b585d8..37964b83 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift @@ -22,7 +22,9 @@ import os /// 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. +/// 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. @@ -62,6 +64,12 @@ public final class Periscope: LogRecorder, Sendable { /// 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( @@ -254,13 +262,7 @@ public final class Periscope: LogRecorder, Sendable { if !original.bypassesFloors { guard shouldRecord(level: original.level, scopes: original.scopes) else { return } } - let record: LogRecord - if let redact = configuration.redact { - guard let redacted = redact(original) else { return } - record = redacted - } else { - record = original - } + guard let record = redacted(original) else { return } state.withLock { state in Self.buffer(record, into: &state, configuration: configuration) } @@ -293,6 +295,18 @@ public final class Periscope: LogRecorder, Sendable { } } + /// 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 @@ -414,14 +428,10 @@ public final class Periscope: LogRecorder, Sendable { 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. A - // suppressed began leaves the span registered silently, exactly as - // the record path would have dropped it. - let buffered: LogRecord? = if let began, let redact = configuration.redact { - redact(began) - } else { - began - } + // 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. diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index cfed5501..758a5ca7 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -514,6 +514,33 @@ struct PeriscopeTests { #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 { diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 21b8ea5f..c50d3e13 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -16,6 +16,7 @@ # Completed issues ## 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. From 189bca1c686de02c3ddc47e01f103de16f45f0a3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 8 Jul 2026 19:15:55 -0400 Subject: [PATCH 53/73] Organize Periscope Sources into per-concern subdirectories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeriscopeCore's 30 files group into Events (the vocabulary: events, levels, tags, attachments), Loggers (Log and the scope tree), Context (task-local and per-instance derivation), Spans, Pipeline (the system, records, sinks), Store, and Ambient. PeriscopeTools groups one directory per tool (Viewer, Tracer, Alerts, InspectMode, Spans) plus Components for the shared display pieces. PeriscopeUI stays flat at one file. Pure moves (git mv) — SPM globs recursively, so no manifest changes; module AGENTS.md files document the layout. Tests stay flat, named 1:1 with their source files. --- Shared/Periscope/PeriscopeCore/AGENTS.md | 9 +++++++++ .../Sources/{ => Ambient}/AmbientEvent.swift | 0 .../Sources/{ => Ambient}/AmbientEventSource.swift | 0 .../{ => Ambient}/AppLifecycleAmbientSource.swift | 0 .../{ => Ambient}/LowPowerModeAmbientSource.swift | 0 .../{ => Ambient}/MemoryWarningAmbientSource.swift | 0 .../Sources/{ => Ambient}/NetworkPathAmbientSource.swift | 0 .../{ => Ambient}/ThermalStateAmbientSource.swift | 0 .../Sources/{ => Context}/AmbientLogContext.swift | 0 .../PeriscopeCore/Sources/{ => Context}/InstanceID.swift | 0 .../Sources/{ => Context}/LogContextProviding.swift | 0 .../Sources/{ => Events}/LogAttachment.swift | 0 .../PeriscopeCore/Sources/{ => Events}/LogEvent.swift | 0 .../PeriscopeCore/Sources/{ => Events}/LogLevel.swift | 0 .../PeriscopeCore/Sources/{ => Events}/LogTag.swift | 0 .../PeriscopeCore/Sources/{ => Events}/Message.swift | 0 .../PeriscopeCore/Sources/{ => Loggers}/Log.swift | 0 .../PeriscopeCore/Sources/{ => Loggers}/LogScope.swift | 0 .../PeriscopeCore/Sources/{ => Loggers}/ScopeID.swift | 0 .../PeriscopeCore/Sources/{ => Pipeline}/LogRecord.swift | 0 .../Sources/{ => Pipeline}/LogRecorder.swift | 0 .../PeriscopeCore/Sources/{ => Pipeline}/LogSink.swift | 0 .../PeriscopeCore/Sources/{ => Pipeline}/OSLogSink.swift | 0 .../PeriscopeCore/Sources/{ => Pipeline}/Periscope.swift | 0 .../PeriscopeCore/Sources/{ => Spans}/LogSpan.swift | 0 .../PeriscopeCore/Sources/{ => Spans}/SpanExit.swift | 0 .../PeriscopeCore/Sources/{ => Store}/LogQuery.swift | 0 .../PeriscopeCore/Sources/{ => Store}/LogSession.swift | 0 .../Sources/{ => Store}/PeriscopeSchema.swift | 0 .../Sources/{ => Store}/PeriscopeStore.swift | 0 .../Sources/{ => Store}/StoredLogEvent.swift | 0 Shared/Periscope/PeriscopeTools/AGENTS.md | 7 +++++++ .../{ => Alerts}/LocalNotificationAlertHandler.swift | 0 .../Sources/{ => Alerts}/PeriscopeAlerter.swift | 0 .../Sources/{ => Components}/LogEventDetailView.swift | 0 .../Sources/{ => Components}/LogEventRow.swift | 0 .../Sources/{ => Components}/LogLevel+Display.swift | 0 .../Sources/{ => Components}/SpanExit+Display.swift | 0 .../Sources/{ => InspectMode}/LogInspectable.swift | 0 .../Sources/{ => InspectMode}/LogInspectorModel.swift | 0 .../Sources/{ => InspectMode}/PeriscopeInspector.swift | 0 .../Sources/{ => Spans}/OpenSpansView.swift | 0 .../Sources/{ => Tracer}/LogTraceModel.swift | 0 .../Sources/{ => Tracer}/LogTraceView.swift | 0 .../Sources/{ => Viewer}/NDJSONExporter.swift | 0 .../Sources/{ => Viewer}/PeriscopeViewer.swift | 0 .../Sources/{ => Viewer}/PeriscopeViewerModel.swift | 0 47 files changed, 16 insertions(+) rename Shared/Periscope/PeriscopeCore/Sources/{ => Ambient}/AmbientEvent.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Ambient}/AmbientEventSource.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Ambient}/AppLifecycleAmbientSource.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Ambient}/LowPowerModeAmbientSource.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Ambient}/MemoryWarningAmbientSource.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Ambient}/NetworkPathAmbientSource.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Ambient}/ThermalStateAmbientSource.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Context}/AmbientLogContext.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Context}/InstanceID.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Context}/LogContextProviding.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Events}/LogAttachment.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Events}/LogEvent.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Events}/LogLevel.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Events}/LogTag.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Events}/Message.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Loggers}/Log.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Loggers}/LogScope.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Loggers}/ScopeID.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Pipeline}/LogRecord.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Pipeline}/LogRecorder.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Pipeline}/LogSink.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Pipeline}/OSLogSink.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Pipeline}/Periscope.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Spans}/LogSpan.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Spans}/SpanExit.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Store}/LogQuery.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Store}/LogSession.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Store}/PeriscopeSchema.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Store}/PeriscopeStore.swift (100%) rename Shared/Periscope/PeriscopeCore/Sources/{ => Store}/StoredLogEvent.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Alerts}/LocalNotificationAlertHandler.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Alerts}/PeriscopeAlerter.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Components}/LogEventDetailView.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Components}/LogEventRow.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Components}/LogLevel+Display.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Components}/SpanExit+Display.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => InspectMode}/LogInspectable.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => InspectMode}/LogInspectorModel.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => InspectMode}/PeriscopeInspector.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Spans}/OpenSpansView.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Tracer}/LogTraceModel.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Tracer}/LogTraceView.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Viewer}/NDJSONExporter.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Viewer}/PeriscopeViewer.swift (100%) rename Shared/Periscope/PeriscopeTools/Sources/{ => Viewer}/PeriscopeViewerModel.swift (100%) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index e520f2d7..4af82dfe 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -8,6 +8,15 @@ pipeline, ambient event sources, and the SwiftData store. See 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/AmbientEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/AmbientEvent.swift rename to Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/AmbientEventSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/AmbientEventSource.swift rename to Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/AppLifecycleAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/AppLifecycleAmbientSource.swift rename to Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LowPowerModeAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LowPowerModeAmbientSource.swift rename to Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/MemoryWarningAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/MemoryWarningAmbientSource.swift rename to Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/NetworkPathAmbientSource.swift rename to Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/ThermalStateAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/ThermalStateAmbientSource.swift rename to Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/AmbientLogContext.swift rename to Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/InstanceID.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/InstanceID.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/InstanceID.swift rename to Shared/Periscope/PeriscopeCore/Sources/Context/InstanceID.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogContextProviding.swift rename to Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogAttachment.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogAttachment.swift rename to Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogEvent.swift rename to Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogLevel.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogLevel.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogLevel.swift rename to Shared/Periscope/PeriscopeCore/Sources/Events/LogLevel.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogTag.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogTag.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogTag.swift rename to Shared/Periscope/PeriscopeCore/Sources/Events/LogTag.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/Message.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/Message.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/Message.swift rename to Shared/Periscope/PeriscopeCore/Sources/Events/Message.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/Log.swift rename to Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogScope.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScope.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogScope.swift rename to Shared/Periscope/PeriscopeCore/Sources/Loggers/LogScope.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/ScopeID.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/ScopeID.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/ScopeID.swift rename to Shared/Periscope/PeriscopeCore/Sources/Loggers/ScopeID.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogRecord.swift rename to Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecorder.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogRecorder.swift rename to Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecorder.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSink.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogSink.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogSink.swift rename to Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogSink.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/OSLogSink.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/OSLogSink.swift rename to Shared/Periscope/PeriscopeCore/Sources/Pipeline/OSLogSink.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/Periscope.swift rename to Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogSpan.swift rename to Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/SpanExit.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/SpanExit.swift rename to Shared/Periscope/PeriscopeCore/Sources/Spans/SpanExit.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogQuery.swift rename to Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/LogSession.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/LogSession.swift rename to Shared/Periscope/PeriscopeCore/Sources/Store/LogSession.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/PeriscopeSchema.swift rename to Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/PeriscopeStore.swift rename to Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift diff --git a/Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift similarity index 100% rename from Shared/Periscope/PeriscopeCore/Sources/StoredLogEvent.swift rename to Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift diff --git a/Shared/Periscope/PeriscopeTools/AGENTS.md b/Shared/Periscope/PeriscopeTools/AGENTS.md index 903caa8c..68f461fa 100644 --- a/Shared/Periscope/PeriscopeTools/AGENTS.md +++ b/Shared/Periscope/PeriscopeTools/AGENTS.md @@ -8,6 +8,13 @@ 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 diff --git a/Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift b/Shared/Periscope/PeriscopeTools/Sources/Alerts/LocalNotificationAlertHandler.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/LocalNotificationAlertHandler.swift rename to Shared/Periscope/PeriscopeTools/Sources/Alerts/LocalNotificationAlertHandler.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeAlerter.swift b/Shared/Periscope/PeriscopeTools/Sources/Alerts/PeriscopeAlerter.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/PeriscopeAlerter.swift rename to Shared/Periscope/PeriscopeTools/Sources/Alerts/PeriscopeAlerter.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/LogEventDetailView.swift rename to Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventRow.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/LogEventRow.swift rename to Shared/Periscope/PeriscopeTools/Sources/Components/LogEventRow.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogLevel+Display.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogLevel+Display.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/LogLevel+Display.swift rename to Shared/Periscope/PeriscopeTools/Sources/Components/LogLevel+Display.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/SpanExit+Display.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/SpanExit+Display.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/SpanExit+Display.swift rename to Shared/Periscope/PeriscopeTools/Sources/Components/SpanExit+Display.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/LogInspectable.swift rename to Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectorModel.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/LogInspectorModel.swift rename to Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectorModel.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/PeriscopeInspector.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/PeriscopeInspector.swift rename to Shared/Periscope/PeriscopeTools/Sources/InspectMode/PeriscopeInspector.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift b/Shared/Periscope/PeriscopeTools/Sources/Spans/OpenSpansView.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/OpenSpansView.swift rename to Shared/Periscope/PeriscopeTools/Sources/Spans/OpenSpansView.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceModel.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/LogTraceModel.swift rename to Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceModel.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/LogTraceView.swift b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceView.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/LogTraceView.swift rename to Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceView.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/NDJSONExporter.swift rename to Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewer.swift rename to Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewer.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewerModel.swift similarity index 100% rename from Shared/Periscope/PeriscopeTools/Sources/PeriscopeViewerModel.swift rename to Shared/Periscope/PeriscopeTools/Sources/Viewer/PeriscopeViewerModel.swift From 7df2a43452a9b17a8115bec1bc7ad1d260ae4a62 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 9 Jul 2026 16:42:10 -0400 Subject: [PATCH 54/73] Document Log()'s .shared default as a deliberate exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review walkthrough finding 1: keep the ergonomic no-arg Log() init (records into Periscope.shared), and note in the module AGENTS.md that it's a conscious exception to the no-Core-defaults convention — tests must always pass system: explicitly. --- Shared/Periscope/PeriscopeCore/AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index 4af82dfe..7a610137 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -75,4 +75,6 @@ event sources). Tests stay flat, named 1:1 with their source files. 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. +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. From 70020d6bf66452c0abd1f31f5e2209d90fa61410 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 9 Jul 2026 16:44:22 -0400 Subject: [PATCH 55/73] Assert instead of swallowing LogAttachment.error encode failures Review walkthrough finding 2: the 'cannot fail' encode of the error payload used (try? ...) ?? Data(), which would silently persist a zero-byte application/json attachment that reads as a successful capture if a refactor ever made encoding fail. Per the programmer-error convention (and matching the NDJSON exporter's existing treatment): assertionFailure in debug, a valid empty JSON object as the release fallback. --- .../PeriscopeCore/Sources/Events/LogAttachment.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift index 09573d47..3605c220 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift @@ -29,8 +29,16 @@ extension LogAttachment { "domain": bridged.domain, "code": String(bridged.code), ] - // Encoding [String: String] cannot fail. - let data = (try? JSONEncoder().encode(payload)) ?? Data() + // 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: "application/json", data: data) } From 830949dc0b861f9e04812374b3d14837379b46d9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 9 Jul 2026 16:46:51 -0400 Subject: [PATCH 56/73] Mark unparseable payloads in NDJSON exports instead of omitting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review walkthrough finding 3: a stored payload that no longer parses (on-disk corruption — payloads are JSONEncoder output at persist time) was silently dropped from the export line, indistinguishable from an event that never had one. The line now carries a payloadError marker with a byte-count hint, so a bug-report export is honest about exactly the row someone is likely chasing. Covered by an exporter test feeding garbage payload bytes. --- .../Sources/Viewer/NDJSONExporter.swift | 13 +++++++++---- .../Tests/NDJSONExporterTests.swift | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift index 40d9ef0b..a69530f7 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift @@ -41,10 +41,15 @@ enum NDJSONExporter { if let exitMode = event.spanExitMode { object["spanExit"] = exitMode.rawValue } - if !event.payload.isEmpty, - let payload = try? JSONSerialization.jsonObject(with: event.payload) - { - object["payload"] = payload + 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, diff --git a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift index e5572b98..ba35ebab 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift @@ -37,6 +37,22 @@ struct NDJSONExporterTests { ) } + @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: [ From 1582b1ea686067fff197ef7aef9934d5cff9fc95 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 9 Jul 2026 16:50:10 -0400 Subject: [PATCH 57/73] Extract and test LogEventDetailView's payload decode helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review walkthrough finding 4: exitReason (a SpanEnded's freeform reason lives in the payload, not a column) and prettyPayload were private on the view, so their degradation paths — an undecodable payload, garbage bytes — had no direct coverage. They're now internal StoredLogEvent extensions in the same file, pure functions of the event, with a 1:1 LogEventDetailViewTests covering reason round-trip, reasonless exits, undecodable payloads, key-sorted pretty printing, and garbage degradation. --- .../Components/LogEventDetailView.swift | 16 +++-- .../Tests/LogEventDetailViewTests.swift | 67 +++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift diff --git a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift index 54248051..64197ed8 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift @@ -33,7 +33,7 @@ struct LogEventDetailView: View { LabeledContent("Exit") { HStack(spacing: 6) { SpanExitBadge(mode: exitMode) - if let reason = exitReason { + if let reason = event.exitReason { Text(reason) } } @@ -59,7 +59,7 @@ struct LogEventDetailView: View { } } - if let payload = prettyPayload { + if let payload = event.prettyPayload { Section("Payload") { Text(payload) .font(.caption.monospaced()) @@ -120,19 +120,21 @@ struct LogEventDetailView: View { } } } +} +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. - private var exitReason: String? { - (try? event.decode(SpanEnded.self))?.exit.reason + 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. - private var prettyPayload: String? { - guard !event.payload.isEmpty, - let object = try? JSONSerialization.jsonObject(with: event.payload), + var prettyPayload: String? { + guard !payload.isEmpty, + let object = try? JSONSerialization.jsonObject(with: payload), let data = try? JSONSerialization.data( withJSONObject: object, options: [.prettyPrinted, .sortedKeys], diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift new file mode 100644 index 00000000..ac2a0a5a --- /dev/null +++ b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift @@ -0,0 +1,67 @@ +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, + 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) + } +} From 04b91382b6dc4cbb08cc89f830d9d6a15449cd1b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 9 Jul 2026 16:53:55 -0400 Subject: [PATCH 58/73] Document SpanName's String default honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review walkthrough finding 5: the LogSpan file comment and README claimed span names are 'typed tokens, not raw strings', but Event.SpanName defaults to String — measure("save") is the out-of-box spelling. Dropping the default isn't viable (an associatedtype with no default or inference path would force an empty SpanName enum onto every LogEvent conformance, Message included), so the docs now state the actual contract: names resolve against Event.SpanName, String by default; declare a SpanName enum for compiler-checked leading-dot tokens, the recommended style for structured events. --- Shared/Periscope/PeriscopeCore/README.md | 7 +++++-- Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index ffa50a8d..11154c8e 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -83,8 +83,11 @@ Periscope.shared.startDefaultAmbientSources() `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; `begin(for:lifetime:relaunch:)`/ - `end(for:exit:)` for open-ended spans. Every span provably ends: bounded spans expire past + 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift index 06ffbdfa..677bfc3b 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift @@ -278,8 +278,10 @@ enum SpanSignposts { } /// Timing: measure closures, and open-ended begin/end spans keyed by -/// identifier. Span names are typed tokens (`log.measure(.saveEvent)`), not -/// raw strings. +/// 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 → From 7049753234a84a839ca73e3cd91d9ce33b6da022 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 10:30:31 -0400 Subject: [PATCH 59/73] Add Broadway TODO do the TODOs.md for Periscope --- Shared/Periscope/TODOs.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index c50d3e13..2180bb11 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -11,10 +11,17 @@ ## P0s (Must do) - 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 + ## 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. @@ -31,6 +38,7 @@ - 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. @@ -40,12 +48,14 @@ - 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.) From da2b544ac96886da6459b126b15258f8b80c2e2f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 11:34:22 -0700 Subject: [PATCH 60/73] Retain ambient observer tokens; add stopAmbientSources() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback (ambient token comments): the notification-based ambient sources discarded the token addObserver(forName:) returns. The center keeps the observation alive regardless — so events still flowed, but the observation was unremovable and immortalized everything the block captured, including the logger's whole Periscope system; a restart would also have doubled observers. Tokens now live in AmbientObserverTokens (public — app-defined notification sources face the same trap), which swaps observations wholesale on restart. PR feedback (stop comment): AmbientEventSource gains stop(), and Periscope.stopAmbientSources() stops and releases every retained source — the counterpart to startAmbientSource(_:). NetworkPath's stop cancels its monitor. Covered by stop/no-longer-observes and restart-replaces-observation tests using test-unique notification names. --- Shared/Periscope/PeriscopeCore/README.md | 5 +- .../Sources/Ambient/AmbientEventSource.swift | 56 ++++++++++++++--- .../Ambient/AppLifecycleAmbientSource.swift | 32 ++++++++-- .../Ambient/LowPowerModeAmbientSource.swift | 9 ++- .../Ambient/MemoryWarningAmbientSource.swift | 9 ++- .../Ambient/NetworkPathAmbientSource.swift | 9 +++ .../Ambient/ThermalStateAmbientSource.swift | 9 ++- .../Sources/Pipeline/Periscope.swift | 12 +++- .../Tests/AmbientEventSourceTests.swift | 61 ++++++++++++++++++- 9 files changed, 180 insertions(+), 22 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index 11154c8e..7b679c58 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -99,8 +99,9 @@ Periscope.shared.startDefaultAmbientSources() 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`), and the - `isInspectModeEnabled` flag behind PeriscopeTools' log view mode. + 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, tag, search, paging), diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift index b2883ddb..062d496e 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift @@ -1,19 +1,52 @@ import Foundation +import os /// A source of ambient/environmental events. Built-ins cover app lifecycle, /// memory warnings, network path, thermal state, and low power mode; /// apps conform to add their own (push registration, sync status, …). /// /// Sources are registered with ``Periscope/startAmbientSource(_:)``, which -/// retains them for the process lifetime and hands them a logger under the -/// shared ambient scope. +/// 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`. Called - /// exactly once, when the source is registered — sources observe for - /// the process lifetime and are not required to tolerate repeated - /// starts (the notification-based built-ins would double their - /// observers). + /// 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 { @@ -24,6 +57,15 @@ extension Periscope { 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 and memory /// warnings. diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift index e5ef04e8..cfe68e01 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AppLifecycleAmbientSource.swift @@ -5,17 +5,37 @@ /// 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) { - 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") + 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) { - _ = NotificationCenter.default.addObserver( + private func observe( + _ name: Notification.Name, + log: Log, + value: String, + ) -> any NSObjectProtocol { + NotificationCenter.default.addObserver( forName: name, object: nil, queue: nil, diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift index 8164d3d8..72ac6671 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift @@ -3,10 +3,12 @@ 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) { - _ = NotificationCenter.default.addObserver( + let token = NotificationCenter.default.addObserver( forName: .NSProcessInfoPowerStateDidChange, object: nil, queue: nil, @@ -14,5 +16,10 @@ public struct LowPowerModeAmbientSource: AmbientEventSource { 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 index 2c15a771..3bcb287d 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/MemoryWarningAmbientSource.swift @@ -5,16 +5,23 @@ /// 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) { - _ = NotificationCenter.default.addObserver( + 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 index 7ae52599..e3d3062b 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/NetworkPathAmbientSource.swift @@ -27,6 +27,15 @@ public struct NetworkPathAmbientSource: AmbientEventSource { 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: diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift index 7083bdf9..3543cd73 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift @@ -3,16 +3,23 @@ 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) { - _ = NotificationCenter.default.addObserver( + 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift index 37964b83..8bcd2af1 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/Periscope.swift @@ -376,12 +376,22 @@ public final class Periscope: LogRecorder, Sendable { state.withLock { $0.scopes[id] } } - /// Keep an ambient source alive for the process lifetime — see + /// 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 diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift index 5fe1d7b0..5ce26f83 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientEventSourceTests.swift @@ -8,6 +8,31 @@ 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 { @@ -29,14 +54,44 @@ struct AmbientEventSourceTests { #expect(scope.flatMap { system.scope(for: $0) }?.name == AmbientEvent.eventName) } - @Test func defaultSourcesStartWithoutIncident() async { - // Built-in sources observe real system notifications; starting them - // must at minimum register cleanly and keep the system consumable. + @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() + } } From dcc0e463373cdc86696d2e4a7bf8e6c0914bd5a2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 11:37:28 -0700 Subject: [PATCH 61/73] Type LogAttachment.contentType as an enum with .other PR feedback: content types were raw MIME strings. LogAttachment.ContentType models the common capture types as cases (json, png, jpeg, plainText) with .other(String) carrying any uncommon MIME type losslessly; the store persists the MIME string and maps back on read, so no schema change. Factories and LogAttachmentInfo use the typed cases; the detail view displays via mimeType. --- .../Sources/Events/LogAttachment.swift | 51 ++++++++++++++++--- .../Sources/Store/PeriscopeStore.swift | 17 +++++-- .../Tests/LogAttachmentTests.swift | 20 ++++++-- .../PeriscopeCore/Tests/LogTests.swift | 2 +- .../Tests/PeriscopeStoreTests.swift | 8 +-- .../Components/LogEventDetailView.swift | 2 +- 6 files changed, 78 insertions(+), 22 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift index 3605c220..a50865c7 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogAttachment.swift @@ -8,12 +8,47 @@ import Foundation /// 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 - /// MIME type, e.g. `"application/json"`, `"image/png"`. - public let contentType: String + public let contentType: ContentType public let data: Data - public init(name: String, contentType: String, data: Data) { + public init(name: String, contentType: ContentType, data: Data) { self.name = name self.contentType = contentType self.data = data @@ -39,14 +74,14 @@ extension LogAttachment { assertionFailure("Encoding the error payload failed: \(error)") data = Data("{}".utf8) } - return LogAttachment(name: name, contentType: "application/json", data: data) + 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: "application/json", + contentType: .json, data: JSONEncoder().encode(value), ) } @@ -56,7 +91,7 @@ extension LogAttachment { /// bitmap representation. public static func image(_ image: UIImage, name: String) -> LogAttachment? { guard let data = image.pngData() else { return nil } - return LogAttachment(name: name, contentType: "image/png", data: data) + return LogAttachment(name: name, contentType: .png, data: data) } #endif } @@ -65,9 +100,9 @@ extension LogAttachment { /// on demand through `PeriscopeStore.attachments(forEvent:)`. public struct LogAttachmentInfo: Hashable, Sendable { public let name: String - public let contentType: String + public let contentType: LogAttachment.ContentType - public init(name: String, contentType: String) { + public init(name: String, contentType: LogAttachment.ContentType) { self.name = name self.contentType = contentType } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift index 69c29b5f..ffd5019d 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift @@ -320,7 +320,7 @@ public actor PeriscopeStore: LogSink { let attachmentRows = record.attachments.enumerated().map { index, attachment in SDLogAttachment( name: attachment.name, - contentType: attachment.contentType, + contentType: attachment.contentType.mimeType, index: index, data: attachment.data, ) @@ -706,7 +706,13 @@ public actor PeriscopeStore: LogSink { guard let row = try fetchEventRow(id: id) else { return [] } return row.attachments .sorted { $0.index < $1.index } - .map { LogAttachment(name: $0.name, contentType: $0.contentType, data: $0.data) } + .map { row in + LogAttachment( + name: row.name, + contentType: LogAttachment.ContentType(mimeType: row.contentType), + data: row.data, + ) + } } private func fetchEventRow(id: UUID) throws -> SDLogEvent? { @@ -748,7 +754,12 @@ public actor PeriscopeStore: LogSink { spanExitMode: row.spanExitMode.flatMap(SpanExit.Mode.init(rawValue:)), attachments: row.attachments .sorted { $0.index < $1.index } - .map { LogAttachmentInfo(name: $0.name, contentType: $0.contentType) }, + .map { row in + LogAttachmentInfo( + name: row.name, + contentType: LogAttachment.ContentType(mimeType: row.contentType), + ) + }, sessionID: row.sessionID, ) } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift index 70c3fbf4..df21842c 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogAttachmentTests.swift @@ -11,19 +11,29 @@ struct LogAttachmentTests { @Test func rawDataAttachmentKeepsItsFields() { let attachment = LogAttachment( name: "response", - contentType: "application/octet-stream", + contentType: .other("application/octet-stream"), data: Data([1, 2, 3]), ) #expect(attachment.name == "response") - #expect(attachment.contentType == "application/octet-stream") + #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 == "application/json") + #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") @@ -38,7 +48,7 @@ struct LogAttachmentTests { @Test func jsonAttachmentRoundTripsEncodableValues() throws { let attachment = try LogAttachment.json(PhotoLogs(photoID: "p1"), name: "photo") - #expect(attachment.contentType == "application/json") + #expect(attachment.contentType == .json) let decoded = try JSONDecoder().decode(PhotoLogs.self, from: attachment.data) #expect(decoded.photoID == "p1") } @@ -52,7 +62,7 @@ struct LogAttachmentTests { } let attachment = try #require(LogAttachment.image(image, name: "screenshot")) - #expect(attachment.contentType == "image/png") + #expect(attachment.contentType == .png) #expect(!attachment.data.isEmpty) } #endif diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift index 78603454..9287defc 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -116,7 +116,7 @@ struct LogTests { let log = Log(recorder: recorder) let attachment = LogAttachment( name: "thumbnail", - contentType: "image/png", + contentType: .png, data: Data([9]), ) diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 25d85e63..262fd9a7 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -176,8 +176,8 @@ struct PeriscopeStoreTests { @Test func attachmentsPersistAndLoadOnDemand() async throws { let (store, root, _, _) = try await makeStore() - let first = LogAttachment(name: "a", contentType: "text/plain", data: Data([1])) - let second = LogAttachment(name: "b", contentType: "image/png", data: Data([2, 3])) + 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"), @@ -189,8 +189,8 @@ struct PeriscopeStoreTests { let stored = try #require(try await store.events(matching: LogQuery()) .first { $0.message == "failed" }) #expect(stored.attachments == [ - LogAttachmentInfo(name: "a", contentType: "text/plain"), - LogAttachmentInfo(name: "b", contentType: "image/png"), + LogAttachmentInfo(name: "a", contentType: .plainText), + LogAttachmentInfo(name: "b", contentType: .png), ]) let loaded = try await store.attachments(forEvent: record.id) diff --git a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift index 64197ed8..d3714c08 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift @@ -115,7 +115,7 @@ struct LogEventDetailView: View { case let .success(loaded): ForEach(Array(loaded.enumerated()), id: \.offset) { _, attachment in LabeledContent(attachment.name) { - Text("\(attachment.contentType) · \(attachment.data.count) bytes") + Text("\(attachment.contentType.mimeType) · \(attachment.data.count) bytes") } } } From e96bdc1697429073656e954252824c9b9bf22512 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 11:41:34 -0700 Subject: [PATCH 62/73] Let custom LogLevels carry their own OSLogType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: osLogType was computed from severity bands with no way for a custom level to choose its Console visibility. It's now a stored property — the severity-band default (unchanged behavior, via defaultOSLogType(forSeverity:)) or an explicit value from the new init(name:severity:osLogType:). The mapping is routing metadata, not identity: equality/hashing stay name + severity, and Codable persists only those two (payloads never carried the mapping), so decoded levels re-derive the band default — asserted by tests. The standard ladder's statics pass their mapping explicitly: defaultOSLogType(forSeverity:) reads those very statics to find its band edges, so routing the ladder's own initializers through it was reentrant static initialization — a runtime trap the first test run caught. --- .../Sources/Events/LogLevel.swift | 71 ++++++++++++++++--- .../PeriscopeCore/Tests/LogLevelTests.swift | 20 ++++++ 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogLevel.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogLevel.swift index 563f90e8..eb584ab6 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/LogLevel.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogLevel.swift @@ -26,23 +26,77 @@ public struct LogLevel: Hashable, Comparable, Codable, Sendable { /// 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 { - public static let debug = LogLevel(name: "debug", severity: 100) - public static let info = LogLevel(name: "info", severity: 200) - public static let notice = LogLevel(name: "notice", severity: 300) - public static let warning = LogLevel(name: "warning", severity: 400) - public static let error = LogLevel(name: "error", severity: 500) - public static let fault = LogLevel(name: "fault", severity: 600) + // 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 @@ -56,13 +110,12 @@ extension LogLevel { .fault, ] - /// The `OSLogType` Console.app sees for this level. + /// The severity-band default OSLog mapping. /// - /// Mapped by severity band so custom levels inherit a sensible type. /// `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 var osLogType: OSLogType { + public static func defaultOSLogType(forSeverity severity: Int) -> OSLogType { switch severity { case .. Date: Tue, 14 Jul 2026 11:57:02 -0700 Subject: [PATCH 63/73] Round out SpanExit's reason-taking factories PR feedback: success/failure/cancelled had reason-taking factories but superseded, orphaned, and expired did not (expired only had the budget-deriving one). Every mode now has one, asserted against Mode.allCases so a new mode can't ship without its factory. --- .../PeriscopeCore/Sources/Spans/SpanExit.swift | 12 ++++++++++++ .../PeriscopeCore/Tests/SpanExitTests.swift | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Spans/SpanExit.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/SpanExit.swift index 0befc1bb..11e4fab5 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Spans/SpanExit.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Spans/SpanExit.swift @@ -47,6 +47,18 @@ public struct SpanExit: Hashable, Codable, Sendable { 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") } diff --git a/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift b/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift index 013e648b..b1f518a3 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/SpanExitTests.swift @@ -12,6 +12,19 @@ struct SpanExitTests { #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) From 0afe4b5f13439a6e02fee01f01e7702f8b7f7b82 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:10:20 -0700 Subject: [PATCH 64/73] Tags: typed values, ordered [LogTag] lists, multi-tag queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback (three comments): tag values were raw Strings and tag collections were [LogTagKey: String] dictionaries; queries took one tag. - LogTagValue types values (string/int/double/bool, plus any Codable via .encoding as canonical key-sorted JSON). Literals convert, and tagged(_:_:) accepts plain String/Int/Double/Bool variables through LogTagValueConvertible. The persisted pair string gains a kind segment so .int(3) and .string("3") stay distinct in storage and filters; SDLogTag grows a valueKind column and values round-trip typed. - Log, LogRecord, OpenSpan, StoredLogEvent, and the ambient context carry [LogTag] (unique by key, insertion-ordered — re-tagging replaces in place, links append the right side's new keys) instead of dictionaries. - LogQuery.tag becomes tags: [LogTag], ANDed. The hand-built predicate avoids a dynamic expression tree: a row can't carry duplicate pairs, so matching all N tags is 'N of its tag rows have a pair in the list' — filter + count, verified against SQLite by a two-tag store test. NDJSON exports tag values as native JSON types (numbers stay numbers); the detail view shows canonical strings. --- Shared/Periscope/PeriscopeCore/README.md | 2 +- .../Sources/Context/AmbientLogContext.swift | 6 +- .../Sources/Context/LogContextProviding.swift | 2 +- .../PeriscopeCore/Sources/Events/LogTag.swift | 155 +++++++++++++++++- .../PeriscopeCore/Sources/Loggers/Log.swift | 18 +- .../Sources/Pipeline/LogRecord.swift | 4 +- .../Sources/Pipeline/OSLogSink.swift | 2 +- .../PeriscopeCore/Sources/Spans/LogSpan.swift | 6 +- .../Sources/Store/LogQuery.swift | 5 +- .../Sources/Store/PeriscopeSchema.swift | 21 +-- .../Sources/Store/PeriscopeStore.swift | 82 +++++---- .../Sources/Store/StoredLogEvent.swift | 4 +- .../Tests/AmbientLogContextTests.swift | 7 +- .../PeriscopeCore/Tests/LogSpanTests.swift | 2 +- .../PeriscopeCore/Tests/LogTagTests.swift | 49 ++++++ .../PeriscopeCore/Tests/LogTests.swift | 35 +++- .../PeriscopeCore/Tests/OSLogSinkTests.swift | 5 +- .../Tests/PeriscopeStoreTests.swift | 86 ++++++++-- .../PeriscopeCore/Tests/PeriscopeTests.swift | 2 +- .../Tests/StoredLogEventTests.swift | 2 +- .../Components/LogEventDetailView.swift | 7 +- .../Sources/Viewer/NDJSONExporter.swift | 20 ++- .../Tests/LogEventDetailViewTests.swift | 2 +- .../Tests/NDJSONExporterTests.swift | 6 +- .../Tests/PeriscopeToolsTestSupport.swift | 2 +- .../Tests/LogContextEnvironmentTests.swift | 2 +- 26 files changed, 425 insertions(+), 109 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index 7b679c58..e74582d8 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -21,7 +21,7 @@ inspect mode live in [`PeriscopeTools`](../PeriscopeTools). | 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 — key/value stamped on events | +| Tag | Datadog/Jaeger tags — typed key/value (`LogTagValue`: string, int, double, bool, or any `Codable` via `.encoding`) stamped on events | ## Installation diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift index de7c96b2..1afd252e 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift @@ -7,7 +7,7 @@ import Foundation enum AmbientLogContext { struct Context { var scopes: [LogScope] - var tags: [LogTagKey: String] + var tags: [LogTag] var recorder: any LogRecorder } @@ -56,9 +56,7 @@ extension Log { for scope in existing.scopes where !scopes.contains(scope) { scopes.append(scope) } - for (key, value) in existing.tags where tags[key] == nil { - tags[key] = value - } + tags = tags.merging(existing.tags) } return AmbientLogContext.Context(scopes: scopes, tags: tags, recorder: recorder) } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift index 1258fbc6..5adf93e4 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/LogContextProviding.swift @@ -51,7 +51,7 @@ extension Periscope { ) -> Log { let scopes = instanceScopes.scopes(for: object) defineScope(scopes.type) - return Log(scopes: [scopes.instance], tags: [:], recorder: self) + return Log(scopes: [scopes.instance], tags: [], recorder: self) } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogTag.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogTag.swift index 33f2673e..02562af8 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/LogTag.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogTag.swift @@ -20,7 +20,121 @@ public struct LogTagKey: Hashable, Sendable, Codable, CustomStringConvertible { } } -/// One key/value tag pair, as stored and queried. +/// A tag's value: the common primitives as typed cases, or any `Codable` +/// value via ``encoding(_:)``. Literals convert directly, so +/// `log.tagged(.retryCount, 3)` and `log.tagged(.paymentID, "pay_1")` read +/// naturally. +public enum LogTagValue: Hashable, Sendable, Codable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + /// Any other `Codable` value, carried as canonical JSON — see + /// ``encoding(_:)``. + case encoded(json: String) + + /// Wrap an arbitrary `Codable` value as canonical (key-sorted) JSON, + /// so equal values always compare and persist identically. + public static func encoding(_ value: some Encodable & Sendable) throws -> 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 @@ -28,10 +142,45 @@ public struct LogTagKey: Hashable, Sendable, Codable, CustomStringConvertible { /// the tree. public struct LogTag: Hashable, Sendable, Codable { public let key: LogTagKey - public let value: String + public let value: LogTagValue - public init(key: LogTagKey, value: String) { + 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/Loggers/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift index 639355cc..876cbc7f 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift @@ -26,7 +26,7 @@ public struct Log: Sendable { public let scopes: [LogScope] /// The tags stamped on every event emitted here (see ``tagged(_:_:)``). - public let tags: [LogTagKey: String] + public let tags: [LogTag] let recorder: any LogRecorder @@ -37,10 +37,10 @@ public struct Log: Sendable { /// 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) + self.init(scopes: [LogScope.root(named: Event.eventName)], tags: [], recorder: recorder) } - init(scopes: [LogScope], tags: [LogTagKey: String], recorder: any LogRecorder) { + 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 @@ -83,11 +83,7 @@ public struct Log: Sendable { for scope in other.scopes where !merged.contains(scope) { merged.append(scope) } - var tags = tags - for (key, value) in other.tags where tags[key] == nil { - tags[key] = value - } - return Log(scopes: merged, tags: tags, recorder: recorder) + return Log(scopes: merged, tags: tags.merging(other.tags), recorder: recorder) } // MARK: Retyping @@ -106,9 +102,11 @@ public struct Log: Sendable { /// 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. - public func tagged(_ key: LogTagKey, _ value: String) -> Log { + /// 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[key] = value + tags.set(value.logTagValue, forKey: key) return Log(scopes: scopes, tags: tags, recorder: recorder) } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift index 6b110868..63587938 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift @@ -15,7 +15,7 @@ public struct LogRecord: Sendable, Identifiable { public let scopes: [ScopeID] /// The tags the emitting context had accumulated (see `Log.tagged`). - public let tags: [LogTagKey: String] + public let tags: [LogTag] /// Data attached at the call site (see `LogAttachment`). public let attachments: [LogAttachment] @@ -31,7 +31,7 @@ public struct LogRecord: Sendable, Identifiable { date: Date, event: any LogEvent, scopes: [ScopeID], - tags: [LogTagKey: String] = [:], + tags: [LogTag] = [], attachments: [LogAttachment] = [], ) { self.id = id diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/OSLogSink.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/OSLogSink.swift index ecd32867..ede2a2fa 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/OSLogSink.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/OSLogSink.swift @@ -57,7 +57,7 @@ public struct OSLogSink: LogSink { if !record.tags.isEmpty { let tags = record.tags .sorted { $0.key.rawValue < $1.key.rawValue } - .map { "\($0.key)=\($0.value)" } + .map { "\($0.key)=\($0.value.stringValue)" } message += " {\(tags.joined(separator: ", "))}" } return message diff --git a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift index 677bfc3b..1dd57891 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift @@ -181,7 +181,7 @@ extension LogRecord { date: date, event: strippedEvent, scopes: scopes, - tags: [:], + tags: [], attachments: [], ) stripped.bypassesFloors = bypassesFloors @@ -227,7 +227,7 @@ public struct OpenSpan: Sendable { /// true, so pairs never dangle across floor changes. public let beganRecorded: Bool public let scopes: [ScopeID] - public let tags: [LogTagKey: String] + public let tags: [LogTag] public init( id: SpanID, @@ -236,7 +236,7 @@ public struct OpenSpan: Sendable { lifetime: SpanLifetime, beganRecorded: Bool, scopes: [ScopeID], - tags: [LogTagKey: String], + tags: [LogTag], ) { self.id = id self.name = name diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift index 49ea8b6e..dec72aa2 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift @@ -24,8 +24,9 @@ public struct LogQuery: Sendable { /// Only events referencing the given scope — exactly, or anywhere in /// its subtree. public var scope: ScopeFilter? - /// Only events stamped with this exact key/value tag. - public var tag: LogTag? + /// 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? diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift index a3ff2422..972f3358 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift @@ -111,24 +111,25 @@ 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` and `value` joined with a separator — a single indexed column - /// so tag predicates stay one comparison. + /// 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(key: String, value: String) { - self.key = key - self.value = value - pair = Self.pairValue(key: key, value: value) + init(tag: LogTag) { + key = tag.key.rawValue + valueKind = tag.value.kind + value = tag.value.stringValue + pair = tag.pair events = [] } - - static func pairValue(key: String, value: String) -> String { - "\(key)\u{1F}\(value)" - } } /// One scope row per deterministic `ScopeID` — shared across sessions, so diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift index ffd5019d..cc5e4dd7 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift @@ -159,10 +159,7 @@ public actor PeriscopeStore: LogSink { exit: .orphaned, ), scopes: row.orderedScopeIDs.map(ScopeID.init(rawValue:)), - tags: Dictionary( - row.tags.map { (LogTagKey($0.key), $0.value) }, - uniquingKeysWith: { first, _ in first }, - ), + tags: Self.tags(from: row), )) } guard !orphans.isEmpty else { return } @@ -314,8 +311,8 @@ public actor PeriscopeStore: LogSink { ) } let scopeRows = try record.scopes.map { try scopeRow(for: $0.rawValue) } - let tagRows = try record.tags.map { key, value in - try tagRow(for: LogTag(key: key, value: value)) + let tagRows = try record.tags.map { tag in + try tagRow(for: tag) } let attachmentRows = record.attachments.enumerated().map { index, attachment in SDLogAttachment( @@ -408,11 +405,24 @@ public actor PeriscopeStore: LogSink { // 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 = SDLogTag.pairValue(key: tag.key.rawValue, value: tag.value) + let pair = tag.pair var descriptor = FetchDescriptor( predicate: #Predicate { $0.pair == pair }, ) @@ -421,7 +431,7 @@ public actor PeriscopeStore: LogSink { tagRowCache[tag] = existing return existing } - let row = SDLogTag(key: tag.key.rawValue, value: tag.value) + let row = SDLogTag(tag: tag) modelContext.insert(row) tagRowCache[tag] = row return row @@ -465,10 +475,8 @@ public actor PeriscopeStore: LogSink { case let .subtree(id): try subtreeIDs(of: id) case nil: [] } - let filtersTag = query.tag != nil - let tagPair = query.tag.map { - SDLogTag.pairValue(key: $0.key.rawValue, value: $0.value) - } ?? "" + let filtersTags = !query.tags.isEmpty + let tagPairs = query.tags.map(\.pair) let filtersExit = query.spanExitMode != nil let exitMode: String? = query.spanExitMode?.rawValue @@ -486,8 +494,8 @@ public actor PeriscopeStore: LogSink { search: search, filtersScope: filtersScope, scopeIDs: scopeIDs, - filtersTag: filtersTag, - tagPair: tagPair, + filtersTags: filtersTags, + tagPairs: tagPairs, ) var descriptor = Self.readDescriptor( @@ -527,8 +535,8 @@ public actor PeriscopeStore: LogSink { search: String, filtersScope: Bool, scopeIDs: [UUID], - filtersTag: Bool, - tagPair: String, + filtersTags: Bool, + tagPairs: [String], ) -> Predicate { Predicate({ event in let afterStart = PredicateExpressions.build_Comparison( @@ -624,24 +632,33 @@ public actor PeriscopeStore: LogSink { ) }, ) + // 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(filtersTag), + PredicateExpressions.build_Arg(filtersTags), ), - rhs: PredicateExpressions.build_contains( - PredicateExpressions.build_KeyPath( - root: PredicateExpressions.build_Arg(event), - keyPath: \.tags, + 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, ), - ) { tag in - PredicateExpressions.build_Equal( - lhs: PredicateExpressions.build_KeyPath( - root: PredicateExpressions.build_Arg(tag), - keyPath: \.pair, - ), - rhs: PredicateExpressions.build_Arg(tagPair), - ) - }, + rhs: PredicateExpressions.build_Arg(tagPairs.count), + ), ) let dates = PredicateExpressions.build_Conjunction( @@ -746,10 +763,7 @@ public actor PeriscopeStore: LogSink { message: row.message, payload: row.payload, scopes: row.orderedScopeIDs.map(ScopeID.init(rawValue:)), - tags: Dictionary( - row.tags.map { (LogTagKey($0.key), $0.value) }, - uniquingKeysWith: { first, _ in first }, - ), + tags: tags(from: row), spanID: row.spanID.map(SpanID.init(rawValue:)), spanExitMode: row.spanExitMode.flatMap(SpanExit.Mode.init(rawValue:)), attachments: row.attachments diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift index fb7fe764..4e7a0446 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift @@ -22,7 +22,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { /// Every scope the event references, primary first, in emission order. public let scopes: [ScopeID] /// The tags the event was stamped with. - public let tags: [LogTagKey: String] + 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 @@ -43,7 +43,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { message: String, payload: Data, scopes: [ScopeID], - tags: [LogTagKey: String], + tags: [LogTag], spanID: SpanID?, spanExitMode: SpanExit.Mode?, attachments: [LogAttachmentInfo], diff --git a/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift index c602f840..6191ff76 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/AmbientLogContextTests.swift @@ -106,7 +106,7 @@ struct AmbientLogContextTests { } await system.flush() - #expect(sink.records.first?.tags == [key: "pay_123"]) + #expect(sink.records.first?.tags == [LogTag(key: key, value: "pay_123")]) } @Test func nestedContextTagsMergeWithTheInnerWinning() async { @@ -123,7 +123,10 @@ struct AmbientLogContextTests { } await system.flush() - #expect(sink.records.first?.tags == [key: "inner", LogTagKey("extra"): "e"]) + #expect(sink.records.first?.tags == [ + LogTag(key: key, value: "inner"), + LogTag(key: LogTagKey("extra"), value: "e"), + ]) } @Test func contextEndsWhenWithContextReturns() async { diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift index 2e18fe12..47cccb31 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogSpanTests.swift @@ -217,7 +217,7 @@ struct LogSpanTests { let superseded = try #require(recorder.records.first { record in (record.event as? SpanEnded)?.exit == .superseded }) - #expect(superseded.tags == [key: "pay_1"]) + #expect(superseded.tags == [LogTag(key: key, value: "pay_1")]) #expect(superseded.scopes == original.scopes.map(\.id)) } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift index e2ebccdd..b4cbb96c 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTagTests.swift @@ -18,4 +18,53 @@ struct LogTagTests { 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 index 9287defc..e5f45cb3 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -138,10 +138,13 @@ struct LogTests { tagged { AppLogs() } #expect(recorder.records.count == 2) - #expect(recorder.records.allSatisfy { $0.tags == [LogTagKey("payment-id"): "pay_123"] }) + #expect(recorder.records.allSatisfy { $0.tags == [LogTag( + key: LogTagKey("payment-id"), + value: "pay_123", + )] }) root.info("untagged") - #expect(recorder.records.last?.tags == [:]) + #expect(recorder.records.last?.tags.isEmpty == true) } @Test func tagsFlowDownDerivations() { @@ -149,13 +152,29 @@ struct LogTests { let child = root(PhotoLogs.self)(for: "album-1") child.info("deep") - #expect(recorder.records.last?.tags == [LogTagKey("payment-id"): "pay_123"]) + #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 == [key: "new"]) + #expect(log.tags == [LogTag(key: key, value: "new")]) + } + + @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() { @@ -169,10 +188,12 @@ struct LogTests { 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 == [ - key: "model-side", - LogTagKey("model-only"): "m", - LogTagKey("ui-only"): "u", + LogTag(key: key, value: "model-side"), + LogTag(key: LogTagKey("model-only"), value: "m"), + LogTag(key: LogTagKey("ui-only"), value: "u"), ]) } diff --git a/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift index 465d01c4..3ee862ad 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/OSLogSinkTests.swift @@ -39,7 +39,10 @@ struct OSLogSinkTests { date: Date(), event: Message(level: .info, "hello"), scopes: [root.id], - tags: [LogTagKey("b-key"): "2", LogTagKey("a-key"): "1"], + 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}") diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 262fd9a7..1a785c64 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -284,7 +284,7 @@ struct PeriscopeStoreTests { name: String, relaunch: SpanRelaunchPolicy, scope: LogScope, - tags: [LogTagKey: String] = [:], + tags: [LogTag] = [], ) async { await store.write([ LogRecord( @@ -311,7 +311,7 @@ struct PeriscopeStoreTests { name: "checkout", relaunch: .endsWithProcess, scope: root, - tags: [key: "pay_1"], + tags: [LogTag(key: key, value: "pay_1")], ) // A new session declares the earlier one dead. @@ -325,7 +325,7 @@ struct PeriscopeStoreTests { #expect(orphan.duration == nil) #expect(orphan.name == "checkout") #expect(orphanRow.scopes == [root.id]) - #expect(orphanRow.tags == [key: "pay_1"]) + #expect(orphanRow.tags == [LogTag(key: key, value: "pay_1")]) #expect(orphanRow.level == .warning) } @@ -385,25 +385,89 @@ struct PeriscopeStoreTests { date: date(1), event: Message(level: .info, "for pay_1"), scopes: [root.id], - tags: [key: "pay_1"], + tags: [LogTag(key: key, value: "pay_1")], ), LogRecord( date: date(2), event: Message(level: .info, "for pay_2"), scopes: [root.id], - tags: [key: "pay_2"], + tags: [LogTag(key: key, value: "pay_2")], ), makeRecord("untagged", date: date(3), scopes: [root.id]), ]) var query = LogQuery() - query.tag = LogTag(key: key, value: "pay_1") + 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 == [key: "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 == [:]) + #expect(all.first { $0.message == "untagged" }?.tags.isEmpty == true) + } + + @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 { @@ -535,7 +599,7 @@ struct PeriscopeStoreTests { date: date(1), event: Message(level: .info, "poisoned"), scopes: [scope.id], - tags: [key: "pay_1"], + tags: [LogTag(key: key, value: "pay_1")], ), ]) #expect(try await store.scope(for: scope.id) == nil) @@ -546,13 +610,13 @@ struct PeriscopeStoreTests { date: date(2), event: Message(level: .info, "healthy"), scopes: [scope.id], - tags: [key: "pay_1"], + tags: [LogTag(key: key, value: "pay_1")], ), ]) #expect(try await store.scope(for: scope.id) == scope) var query = LogQuery() - query.tag = LogTag(key: key, value: "pay_1") + query.tags = [LogTag(key: key, value: "pay_1")] #expect(try await store.events(matching: query).map(\.message) == ["healthy"]) } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 758a5ca7..27ba92e2 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -648,7 +648,7 @@ struct PeriscopeTests { (record.event as? SpanEnded)?.exit.mode == .expired }) #expect(expired.scopes == log.scopes.map(\.id)) - #expect(expired.tags == [key: "pay_1"]) + #expect(expired.tags == [LogTag(key: key, value: "pay_1")]) } @Test func expiredSpansFreeTheirKeyForReuse() async { diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift index 89615d5d..a4be2675 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -15,7 +15,7 @@ struct StoredLogEventTests { message: "photo p1", payload: payload, scopes: [scope.id], - tags: [LogTagKey("payment-id"): "pay_123"], + tags: [LogTag(key: LogTagKey("payment-id"), value: "pay_123")], spanID: nil, spanExitMode: nil, attachments: [], diff --git a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift index d3714c08..17288b4c 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift @@ -50,11 +50,8 @@ struct LogEventDetailView: View { if !event.tags.isEmpty { Section("Tags") { - ForEach( - event.tags.sorted { $0.key.rawValue < $1.key.rawValue }, - id: \.key, - ) { key, value in - LabeledContent(key.rawValue, value: value) + ForEach(event.tags, id: \.key) { tag in + LabeledContent(tag.key.rawValue, value: tag.value.stringValue) } } } diff --git a/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift index a69530f7..365aa857 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift @@ -32,7 +32,8 @@ enum NDJSONExporter { } if !event.tags.isEmpty { object["tags"] = Dictionary( - uniqueKeysWithValues: event.tags.map { ($0.key.rawValue, $0.value) }, + uniqueKeysWithValues: event.tags + .map { ($0.key.rawValue, jsonValue(for: $0.value)) }, ) } if let span = event.spanID { @@ -63,6 +64,23 @@ enum NDJSONExporter { 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 { diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift index ac2a0a5a..b175cd95 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift @@ -18,7 +18,7 @@ struct LogEventDetailViewTests { message: "hello", payload: payload, scopes: [LogScope.root(named: "app").id], - tags: [:], + tags: [], spanID: nil, spanExitMode: nil, attachments: [], diff --git a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift index ba35ebab..87db4cf6 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift @@ -16,7 +16,7 @@ struct NDJSONExporterTests { message: String, date: Date, payload: Data = Data(), - tags: [LogTagKey: String] = [:], + tags: [LogTag] = [], spanExitMode: SpanExit.Mode? = nil, ) -> StoredLogEvent { StoredLogEvent( @@ -75,7 +75,7 @@ struct NDJSONExporterTests { message: "hello", date: date(1), payload: payload, - tags: [LogTagKey("payment-id"): "pay_1"], + tags: [LogTag(key: LogTagKey("payment-id"), value: "pay_1")], ), scopes: scopes, ) @@ -114,7 +114,7 @@ struct NDJSONExporterTests { message: "bare", payload: Data(), scopes: [LogScope.root(named: "never-defined").id], - tags: [:], + tags: [], spanID: nil, spanExitMode: nil, attachments: [], diff --git a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift index edf5a231..35efc0f0 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/PeriscopeToolsTestSupport.swift @@ -44,7 +44,7 @@ func makeRecord( level: LogLevel = .info, date: Date, scopes: [ScopeID], - tags: [LogTagKey: String] = [:], + tags: [LogTag] = [], ) -> LogRecord { LogRecord( date: date, diff --git a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift index 0f198756..1c53274a 100644 --- a/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift +++ b/Shared/Periscope/PeriscopeUI/Tests/LogContextEnvironmentTests.swift @@ -100,7 +100,7 @@ struct LogContextEnvironmentTests { system: system, ) - #expect(records.first?.tags == [key: "pay_123"]) + #expect(records.first?.tags == [LogTag(key: key, value: "pay_123")]) } @Test func typedLoggersDeriveFromTheEnvironmentContext() throws { From d4608161eb3f3da32480baec60fc11c9e9c1d1c5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:11:53 -0700 Subject: [PATCH 65/73] Store the dynamic type on InstanceID, deriving its name on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: InstanceID cached the type name string (plus a separate type ObjectIdentifier) at init. It now stores the Any.Type itself — metatypes are Sendable and identity-comparable — and typeName derives from it when asked. Equality and hashing go through the metatype's ObjectIdentifier, unchanged in behavior. --- Shared/Periscope/PeriscopeCore/README.md | 2 +- .../Sources/Context/InstanceID.swift | 19 ++++++++++--------- .../PeriscopeCore/Tests/InstanceIDTests.swift | 1 + 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/README.md b/Shared/Periscope/PeriscopeCore/README.md index e74582d8..26670158 100644 --- a/Shared/Periscope/PeriscopeCore/README.md +++ b/Shared/Periscope/PeriscopeCore/README.md @@ -104,7 +104,7 @@ Periscope.shared.startDefaultAmbientSources() PeriscopeTools' log view mode. - **Store** — `PeriscopeStore` (`@ModelActor` `LogSink`): sessions (`LogSession`), `events(matching: LogQuery)` (time range, level floor, - event name, session, scope/subtree, tag, search, paging), + event name, session, scope/subtree, tags (AND), search, paging), `events(inSpan:)`, `attachments(forEvent:)`, retention (`pruneEvents(olderThan:/keepingNewest:)`), and a `changes()` signal. diff --git a/Shared/Periscope/PeriscopeCore/Sources/Context/InstanceID.swift b/Shared/Periscope/PeriscopeCore/Sources/Context/InstanceID.swift index 5f9897bf..3c0851f9 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Context/InstanceID.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Context/InstanceID.swift @@ -8,17 +8,18 @@ public struct InstanceID: Hashable, Sendable, CustomDebugStringConvertible { /// Pointer identity of the instance. let object: ObjectIdentifier - /// Identity of the instance's dynamic type. - let typeID: ObjectIdentifier + /// The instance's dynamic type. + public let type: Any.Type - /// The dynamic type's name, e.g. `"PhotoController"`. - public let typeName: String + /// 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) - let dynamicType = type(of: instance) - typeID = ObjectIdentifier(dynamicType) - typeName = String(describing: dynamicType) + type = Swift.type(of: instance) } public var debugDescription: String { @@ -26,11 +27,11 @@ public struct InstanceID: Hashable, Sendable, CustomDebugStringConvertible { } public static func == (lhs: InstanceID, rhs: InstanceID) -> Bool { - lhs.object == rhs.object && lhs.typeID == rhs.typeID + lhs.object == rhs.object && ObjectIdentifier(lhs.type) == ObjectIdentifier(rhs.type) } public func hash(into hasher: inout Hasher) { hasher.combine(object) - hasher.combine(typeID) + hasher.combine(ObjectIdentifier(type)) } } diff --git a/Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift b/Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift index 883bb899..16f8487c 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/InstanceIDTests.swift @@ -27,6 +27,7 @@ struct InstanceIDTests { let id = InstanceID(of: FirstFixture()) #expect(id.debugDescription.hasPrefix("FirstFixture@0x")) #expect(id.typeName == "FirstFixture") + #expect(id.type == FirstFixture.self) } @Test func worksAsADictionaryKey() { From 89fbfd72bda0522e2ecf89f5058bd1bd17062dce Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:13:52 -0700 Subject: [PATCH 66/73] Make drop protection a LogEvent opt-in, not a type check PR feedback: the drop policy identified span pairs by matching concrete types (event is SpanBegan || event is SpanEnded). LogEvent now declares static isProtectedFromDropping (default false); the span pair events opt in, and any app event whose absence would corrupt the story its log tells can too. The transform-only redaction rule rides the same flag, so a protected event can't be suppressed into a dangling reference either. Covered by a custom-event overflow test. --- .../Sources/Events/LogEvent.swift | 12 +++++++ .../Sources/Pipeline/LogRecord.swift | 10 ++++++ .../PeriscopeCore/Sources/Spans/LogSpan.swift | 14 +++------ .../PeriscopeCore/Tests/PeriscopeTests.swift | 31 +++++++++++++++++++ 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift index e58df6a3..7eea0877 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift @@ -46,6 +46,14 @@ public protocol LogEvent: Codable, Sendable { /// Human-readable rendering, shown in Console.app and the log viewer. var message: 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 { @@ -60,4 +68,8 @@ extension LogEvent { public var level: LogLevel { .info } + + public static var isProtectedFromDropping: Bool { + false + } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift index 63587938..ed939656 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift @@ -57,4 +57,14 @@ public struct LogRecord: Sendable, Identifiable { public var eventVersion: Int { type(of: event).eventVersion } + + /// 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/Spans/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift index 1dd57891..f9cd29a0 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift @@ -35,6 +35,8 @@ extension SpanID: Codable { 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 @@ -68,6 +70,8 @@ public struct SpanBegan: LogEvent { 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 @@ -150,16 +154,6 @@ public struct SpanOverdue: LogEvent { extension SpanOverdue: SpanCarrying {} extension LogRecord { - /// Whether the overflow drop policy must keep this record: span pairs - /// never split under drop pressure. Dropping a began strands its end - /// (nothing repairs a parentless `SpanEnded`); dropping an end leaves - /// the span reading as still open for the rest of the session (the - /// orphan sweep only runs at the next launch). `SpanOverdue` is a - /// disposable warning and drops like any other record. - var isProtectedFromDropping: Bool { - event is SpanBegan || event is SpanEnded - } - /// 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 diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift index 27ba92e2..05e011ea 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeTests.swift @@ -868,6 +868,37 @@ struct PeriscopeTests { } } + @Test func customEventsCanOptIntoDropProtection() async throws { + struct AuditEvent: LogEvent { + static let isProtectedFromDropping = true + var message: String { + "audit-entry" + } + } + + let gate = GateSink() + let system = Periscope( + configuration: Periscope.Configuration(pendingBufferCapacity: 3), + 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( From 800a8d87db444b711a89677b1e1780fedaff6554 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:17:09 -0700 Subject: [PATCH 67/73] Raise tool event caps to 500 and make them per-instance PR feedback (two comments): the inspector's 200 and tracer's 100 were static constants. Both models now take limit at init; LogTraceView and the logInspectable modifiers expose it with a 500 default, and the keyed-task inputs include it so an in-place change rebuilds the model. --- .../Sources/InspectMode/LogInspectable.swift | 46 +++++++++++++------ .../InspectMode/LogInspectorModel.swift | 10 ++-- .../Sources/Tracer/LogTraceModel.swift | 12 +++-- .../Sources/Tracer/LogTraceView.swift | 15 +++--- .../Tests/LogInspectableHostingTests.swift | 2 +- .../Tests/LogInspectorModelTests.swift | 9 ++-- .../Tests/LogTraceModelTests.swift | 14 +++--- 7 files changed, 67 insertions(+), 41 deletions(-) diff --git a/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift index dd5dea64..830c2b16 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectable.swift @@ -4,22 +4,27 @@ import SwiftUI extension View { /// Mark this view inspectable in "log view mode": when the environment /// ``PeriscopeInspector`` is enabled, the view gains a badge that opens - /// every stored event in the given context's scope subtrees — e.g. wrap - /// a payment row and see everything associated with that payment. With - /// no inspector or the mode off, the view renders unchanged. - public func logInspectable(_ log: Log) -> some View { - modifier(LogInspectableModifier(scopes: log.scopes.map(\.id))) + /// the newest `limit` stored events in the given context's scope + /// subtrees — e.g. wrap a payment row and see everything associated + /// with that payment. With no inspector or the mode off, the view + /// renders unchanged. + public func logInspectable(_ log: Log, limit: Int = 500) -> some View { + 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) -> some View { - logInspectable(provider.log) + 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 @@ -38,7 +43,7 @@ struct LogInspectableModifier: ViewModifier { .padding(2) .sheet(isPresented: $isPresentingEvents) { NavigationStack { - LogInspectorView(store: inspector.store, scopes: scopes) + LogInspectorView(store: inspector.store, scopes: scopes, limit: limit) } } } @@ -52,21 +57,28 @@ struct LogInspectableModifier: ViewModifier { 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]) { + init(store: PeriscopeStore, scopes: [ScopeID], limit: Int) { self.store = store self.scopes = scopes - _model = State(initialValue: LogInspectorModel(store: store, inspectedScopes: 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 either changes in place. + /// model when any changes in place. private struct Inputs: Equatable { let store: ObjectIdentifier let scopes: [ScopeID] + let limit: Int } var body: some View { @@ -78,9 +90,15 @@ struct LogInspectorView: View { Button("Done") { dismiss() } } } - .task(id: Inputs(store: ObjectIdentifier(store), scopes: scopes)) { - if model.store !== store || model.inspectedScopes != scopes { - model = LogInspectorModel(store: store, inspectedScopes: scopes) + .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() } diff --git a/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectorModel.swift b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectorModel.swift index 2af64c3a..8b0aaad9 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectorModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/InspectMode/LogInspectorModel.swift @@ -14,7 +14,8 @@ final class LogInspectorModel { case failed(String) } - static let limit = 200 + /// 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 @@ -23,9 +24,10 @@ final class LogInspectorModel { private(set) var state: LoadState = .loading private(set) var scopes: [ScopeID: LogScope] = [:] - init(store: PeriscopeStore, inspectedScopes: [ScopeID]) { + init(store: PeriscopeStore, inspectedScopes: [ScopeID], limit: Int) { self.store = store self.inspectedScopes = inspectedScopes + self.limit = limit } var events: [StoredLogEvent] { @@ -55,7 +57,7 @@ final class LogInspectorModel { for scope in inspectedScopes { var query = LogQuery() query.scope = .subtree(scope) - query.limit = Self.limit + query.limit = limit for event in try await store.events(matching: query) { collected[event.id] = event } @@ -63,7 +65,7 @@ final class LogInspectorModel { let ordered = collected.values.sorted { lhs, rhs in (lhs.date, lhs.sequence) > (rhs.date, rhs.sequence) } - state = .loaded(Array(ordered.prefix(Self.limit))) + state = .loaded(Array(ordered.prefix(limit))) } catch { state = .failed(String(describing: error)) } diff --git a/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceModel.swift b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceModel.swift index a862a6ce..f0fed153 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceModel.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceModel.swift @@ -16,8 +16,9 @@ final class LogTraceModel { case failed(String) } - /// Cap on the assembled trail (and on each underlying query). - static let limit = 100 + /// 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. @@ -26,9 +27,10 @@ final class LogTraceModel { private(set) var state: LoadState = .loading private(set) var scopes: [ScopeID: LogScope] = [:] - init(store: PeriscopeStore, origin: StoredLogEvent) { + 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 @@ -53,7 +55,7 @@ final class LogTraceModel { var query = LogQuery() query.end = origin.date query.scope = filter - query.limit = Self.limit + query.limit = limit for event in try await store.events(matching: query) { collected[event.id] = event } @@ -69,7 +71,7 @@ final class LogTraceModel { .sorted { lhs, rhs in (lhs.date, lhs.sequence) > (rhs.date, rhs.sequence) } - state = .loaded(Array(ordered.prefix(Self.limit))) + state = .loaded(Array(ordered.prefix(limit))) } catch { state = .failed(String(describing: error)) } diff --git a/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceView.swift b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceView.swift index 01b57d94..59b9169b 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Tracer/LogTraceView.swift @@ -11,19 +11,22 @@ import SwiftUI 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) { + public init(store: PeriscopeStore, origin: StoredLogEvent, limit: Int = 500) { self.store = store self.origin = origin - _model = State(initialValue: LogTraceModel(store: store, 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 either changes in place. + /// model when any changes in place. private struct Inputs: Equatable { let store: ObjectIdentifier let origin: UUID + let limit: Int } public var body: some View { @@ -41,9 +44,9 @@ public struct LogTraceView: View { .listStyle(.plain) .navigationTitle("Trace") .navigationBarTitleDisplayMode(.inline) - .task(id: Inputs(store: ObjectIdentifier(store), origin: origin.id)) { - if model.store !== store || model.origin.id != origin.id { - model = LogTraceModel(store: store, origin: origin) + .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() } diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogInspectableHostingTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogInspectableHostingTests.swift index e5a45f4d..5e94746a 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogInspectableHostingTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogInspectableHostingTests.swift @@ -44,7 +44,7 @@ struct LogInspectableHostingTests { await store.write([makeRecord("deep", date: date(1), scopes: [album.id])]) let host = UIHostingController(rootView: NavigationStack { - LogInspectorView(store: store, scopes: [photos.id]) + 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 index f73bc2de..d9fc71da 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogInspectorModelTests.swift @@ -13,7 +13,7 @@ struct LogInspectorModelTests { makeRecord("in the album", date: date(3), scopes: [album.id]), ]) - let model = LogInspectorModel(store: store, inspectedScopes: [photos.id]) + let model = LogInspectorModel(store: store, inspectedScopes: [photos.id], limit: 500) await model.load() #expect(model.events.map(\.message) == ["in the album", "at photos"]) @@ -31,6 +31,7 @@ struct LogInspectorModelTests { let model = LogInspectorModel( store: store, inspectedScopes: [photos.id, screen.id], + limit: 500, ) await model.load() @@ -39,7 +40,7 @@ struct LogInspectorModelTests { @Test func emptyScopesLoadAsEmpty() async throws { let (store, _, photos, _) = try await makeSeededStore() - let model = LogInspectorModel(store: store, inspectedScopes: [photos.id]) + let model = LogInspectorModel(store: store, inspectedScopes: [photos.id], limit: 500) await model.load() #expect(model.events.isEmpty) if case .failed = model.state { @@ -49,7 +50,7 @@ struct LogInspectorModelTests { @Test func runRefreshesLiveWhenTheStoreCommits() async throws { let (store, _, photos, album) = try await makeSeededStore() - let model = LogInspectorModel(store: store, inspectedScopes: [photos.id]) + let model = LogInspectorModel(store: store, inspectedScopes: [photos.id], limit: 500) let task = Task { await model.run() } defer { task.cancel() } @@ -62,7 +63,7 @@ struct LogInspectorModelTests { 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]) + let model = LogInspectorModel(store: store, inspectedScopes: [photos.id], limit: 500) await model.load() let event = try #require(model.events.first) diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift index 40a3418d..db8a742c 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogTraceModelTests.swift @@ -23,7 +23,7 @@ struct LogTraceModelTests { // 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) + let model = LogTraceModel(store: store, origin: error, limit: 500) await model.load() #expect(model.trail.map(\.message) == ["later", "earlier"]) @@ -40,7 +40,7 @@ struct LogTraceModelTests { makeRecord("origin", level: .error, date: date(4), scopes: [album.id]), ]) let origin = try await originEvent(in: store) - let model = LogTraceModel(store: store, origin: origin) + let model = LogTraceModel(store: store, origin: origin, limit: 500) await model.load() #expect(model.trail.map(\.message) == ["at photos", "at root"]) @@ -55,7 +55,7 @@ struct LogTraceModelTests { 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) + let model = LogTraceModel(store: store, origin: origin, limit: 500) await model.load() #expect(model.trail.map(\.message) == ["ui context"]) @@ -89,7 +89,7 @@ struct LogTraceModelTests { let origin = try await originEvent(in: store) #expect(origin.spanID == span) - let model = LogTraceModel(store: store, origin: origin) + let model = LogTraceModel(store: store, origin: origin, limit: 500) await model.load() #expect(model.trail.contains { $0.spanID == span && $0.eventName == "span-began" }) @@ -106,7 +106,7 @@ struct LogTraceModelTests { let origin = try #require(try await store.events(matching: LogQuery()) .first { $0.message == "origin" }) - let model = LogTraceModel(store: store, origin: origin) + let model = LogTraceModel(store: store, origin: origin, limit: 500) await model.load() #expect(model.trail.map(\.message) == ["before"]) @@ -117,7 +117,7 @@ struct LogTraceModelTests { 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) + let model = LogTraceModel(store: store, origin: origin, limit: 500) await model.load() #expect(model.trail.isEmpty) @@ -130,7 +130,7 @@ struct LogTraceModelTests { makeRecord("origin", date: date(2), scopes: [album.id]), ]) let origin = try await originEvent(in: store) - let model = LogTraceModel(store: store, origin: origin) + let model = LogTraceModel(store: store, origin: origin, limit: 500) await model.load() let context = try #require(model.trail.first) From 72bee984edc74b7d3db7b44816e9a17efc04ebb8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:19:23 -0700 Subject: [PATCH 68/73] Persist a StoreWriteFailed marker after rolled-back writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: a failed write rolled back, counted, and logged to OSLog — but the durable history stayed silent about its own gap. The store now persists a synthetic StoreWriteFailed event (lost record count + reason, .warning) in its own tiny save after recovery, mirroring the pipeline's DroppedEvents pattern. Best-effort by design: if the marker's save also fails, the OSLog line is the last signal — no recursion. Scope/delete failures don't mark; definitions rebuild deterministically on the next batch and deletions lose nothing. --- .../Sources/Store/PeriscopeStore.swift | 24 +++++++++++++++++++ .../Sources/Store/StoredLogEvent.swift | 24 +++++++++++++++++++ .../Tests/PeriscopeStoreTests.swift | 9 ++++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift index cc5e4dd7..e3f8cfb6 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift @@ -231,6 +231,30 @@ public actor PeriscopeStore: LogSink { 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)") } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift index 4e7a0446..e351ba49 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift @@ -1,5 +1,29 @@ 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. /// diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 1a785c64..d8751216 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -581,8 +581,15 @@ struct PeriscopeStoreTests { 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) == ["healthy"]) + #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) } From 12e0e050db41b8844135dd583abaf06edfead542 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:36:02 -0700 Subject: [PATCH 69/73] Prune orphaned sessions, tags, and scope branches with retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: retention only bounded the events table — one session row per launch and one tag row per distinct pair (unbounded when values are entity IDs) accumulated forever, and event-less scope branches lingered. Every event deletion now runs an orphan sweep in a second save (inverse relationships only prove orphanhood after the deletions commit): sessions with no remaining events go (except the active launch's), event-less tag rows go (their cache entries dropped so a reused pair re-creates cleanly), and scopes unwind leaf-first so ancestors of still-populated scopes survive for path resolution. --- .../Sources/Store/PeriscopeStore.swift | 63 ++++++++++++++++++- .../Tests/PeriscopeStoreTests.swift | 51 +++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift index e3f8cfb6..db551dbc 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift @@ -805,7 +805,9 @@ public actor PeriscopeStore: LogSink { // MARK: Retention /// Delete events older than `cutoff`; returns how many were removed. - /// Scopes and sessions stay — they're tiny and keep old exports legible. + /// 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 }, @@ -844,10 +846,69 @@ public actor PeriscopeStore: LogSink { 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 diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index d8751216..49208a24 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -518,6 +518,57 @@ struct PeriscopeStoreTests { #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 From dc8015341c32d0d54b5ab95091979d9ea5953079 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:41:19 -0700 Subject: [PATCH 70/73] Capture #function/#fileID on log emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: every emit surface — the structured callAsFunction overloads (including derive-and-emit) and the freeform level conveniences — now captures its call site via defaulted #function/#fileID parameters into LogCallSite, carried on LogRecord, persisted as columns, exposed on StoredLogEvent, shown in event detail ('Emitted From'), and exported in NDJSON. System-synthesized records (drop reports, orphan closes, expiry, supersede ends) stay nil — no call site to claim. --- .../PeriscopeCore/Sources/Loggers/Log.swift | 93 +++++++++++++++---- .../Sources/Pipeline/LogRecord.swift | 31 +++++++ .../PeriscopeCore/Sources/Spans/LogSpan.swift | 1 + .../Sources/Store/PeriscopeSchema.swift | 7 ++ .../Sources/Store/PeriscopeStore.swift | 5 + .../Sources/Store/StoredLogEvent.swift | 4 + .../PeriscopeCore/Tests/LogTests.swift | 12 +++ .../Tests/PeriscopeStoreTests.swift | 19 ++++ .../Tests/StoredLogEventTests.swift | 1 + .../Components/LogEventDetailView.swift | 3 + .../Sources/Viewer/NDJSONExporter.swift | 4 + .../Tests/LogEventDetailViewTests.swift | 1 + .../Tests/NDJSONExporterTests.swift | 2 + 13 files changed, 164 insertions(+), 19 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift index 876cbc7f..24782fac 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Loggers/Log.swift @@ -113,14 +113,27 @@ public struct Log: Sendable { // MARK: Emitting /// Log a structured event with this logger's full context. - public func callAsFunction(_ event: () -> Event) { - emit(event()) + 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], _ event: () -> Event) { - emit(event(), attachments: attachments) + 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 @@ -133,10 +146,12 @@ public struct Log: Sendable { /// 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()) + child.emit(event(), callSite: LogCallSite(function: function, fileID: fileID)) } /// Derive the entity-keyed child scope and log one event into it, in a @@ -144,16 +159,19 @@ public struct Log: Sendable { /// 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()) + 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(), @@ -161,6 +179,7 @@ public struct Log: Sendable { scopes: scopes.map(\.id), tags: tags, attachments: attachments, + callSite: callSite, ) record.bypassesFloors = bypassingFloors recorder.record(record) @@ -175,32 +194,68 @@ extension 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) + emit( + Message(level: level, text()), + attachments: attachments, + callSite: LogCallSite(function: function, fileID: fileID), + ) } - public func debug(_ text: @autoclosure () -> String, attachments: [LogAttachment] = []) { - log(.debug, text(), attachments: attachments) + 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] = []) { - log(.info, text(), attachments: attachments) + 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] = []) { - log(.notice, text(), attachments: attachments) + 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] = []) { - log(.warning, text(), attachments: attachments) + 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] = []) { - log(.error, text(), attachments: attachments) + 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] = []) { - log(.fault, text(), attachments: attachments) + 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/Pipeline/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift index ed939656..bd474221 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift @@ -1,5 +1,30 @@ 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. /// @@ -20,6 +45,10 @@ public struct LogRecord: Sendable, Identifiable { /// 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 @@ -33,6 +62,7 @@ public struct LogRecord: Sendable, Identifiable { scopes: [ScopeID], tags: [LogTag] = [], attachments: [LogAttachment] = [], + callSite: LogCallSite? = nil, ) { self.id = id self.date = date @@ -40,6 +70,7 @@ public struct LogRecord: Sendable, Identifiable { self.scopes = scopes self.tags = tags self.attachments = attachments + self.callSite = callSite } public var level: LogLevel { diff --git a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift index f9cd29a0..5f0a317b 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Spans/LogSpan.swift @@ -177,6 +177,7 @@ extension LogRecord { scopes: scopes, tags: [], attachments: [], + callSite: callSite, ) stripped.bypassesFloors = bypassesFloors return stripped diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift index 972f3358..a6c25551 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift @@ -41,6 +41,9 @@ final class SDLogEvent { /// `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? var scopes: [SDLogScope] var tags: [SDLogTag] @@ -61,6 +64,8 @@ final class SDLogEvent { sessionID: UUID, spanID: UUID?, spanExitMode: String?, + callFunction: String?, + callFileID: String?, scopes: [SDLogScope], tags: [SDLogTag], attachments: [SDLogAttachment], @@ -78,6 +83,8 @@ final class SDLogEvent { self.sessionID = sessionID self.spanID = spanID self.spanExitMode = spanExitMode + self.callFunction = callFunction + self.callFileID = callFileID self.scopes = scopes self.tags = tags self.attachments = attachments diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift index db551dbc..d920fb8f 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift @@ -360,6 +360,8 @@ public actor PeriscopeStore: LogSink { sessionID: session.sessionID, spanID: record.spanID?.rawValue, spanExitMode: record.spanExit?.mode.rawValue, + callFunction: record.callSite?.function, + callFileID: record.callSite?.fileID, scopes: scopeRows, tags: tagRows, attachments: attachmentRows, @@ -790,6 +792,9 @@ public actor PeriscopeStore: LogSink { 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) } + }, attachments: row.attachments .sorted { $0.index < $1.index } .map { row in diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift index e351ba49..21b323cc 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift @@ -52,6 +52,8 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { /// 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? /// Attachment metadata; bytes load via /// `PeriscopeStore.attachments(forEvent:)`. public let attachments: [LogAttachmentInfo] @@ -70,6 +72,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { tags: [LogTag], spanID: SpanID?, spanExitMode: SpanExit.Mode?, + callSite: LogCallSite?, attachments: [LogAttachmentInfo], sessionID: UUID, ) { @@ -85,6 +88,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { self.tags = tags self.spanID = spanID self.spanExitMode = spanExitMode + self.callSite = callSite self.attachments = attachments self.sessionID = sessionID } diff --git a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift index e5f45cb3..66eb5aa9 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/LogTests.swift @@ -164,6 +164,18 @@ struct LogTests { #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") diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index 49208a24..c379733a 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -406,6 +406,25 @@ struct PeriscopeStoreTests { #expect(all.first { $0.message == "untagged" }?.tags.isEmpty == true) } + @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") diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift index a4be2675..e2513532 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -18,6 +18,7 @@ struct StoredLogEventTests { tags: [LogTag(key: LogTagKey("payment-id"), value: "pay_123")], spanID: nil, spanExitMode: nil, + callSite: nil, attachments: [], sessionID: UUID(), ) diff --git a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift index 17288b4c..ba4bed72 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift @@ -39,6 +39,9 @@ struct LogEventDetailView: View { } } } + if let callSite = event.callSite { + LabeledContent("Emitted From", value: callSite.description) + } LabeledContent("Session", value: event.sessionID.uuidString) } diff --git a/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift index 365aa857..b16588f8 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift @@ -42,6 +42,10 @@ enum NDJSONExporter { if let exitMode = event.spanExitMode { object["spanExit"] = exitMode.rawValue } + if let callSite = event.callSite { + object["function"] = callSite.function + object["file"] = callSite.fileID + } if !event.payload.isEmpty { if let payload = try? JSONSerialization.jsonObject(with: event.payload) { object["payload"] = payload diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift index b175cd95..d35678c3 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift @@ -21,6 +21,7 @@ struct LogEventDetailViewTests { tags: [], spanID: nil, spanExitMode: nil, + callSite: nil, attachments: [], sessionID: UUID(), ) diff --git a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift index 87db4cf6..f8b985ae 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift @@ -32,6 +32,7 @@ struct NDJSONExporterTests { tags: tags, spanID: nil, spanExitMode: spanExitMode, + callSite: nil, attachments: [], sessionID: sessionID, ) @@ -117,6 +118,7 @@ struct NDJSONExporterTests { tags: [], spanID: nil, spanExitMode: nil, + callSite: nil, attachments: [], sessionID: sessionID, ) From 2771113a6732045dce203d07d63e549a9c98dc01 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 12:57:46 -0700 Subject: [PATCH 71/73] Add LogEvent.externalID linking events to their objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: events can now declare an optional externalID — a photo's local-store URI, a managed object ID's URI form — tying every event about an object together. Defaults to nil; the format is the app's. Persisted as an indexed column, exposed on StoredLogEvent, queryable via LogQuery.externalID ('every event about this object' is one indexed fetch), shown in event detail, and exported in NDJSON. Tooling that resolves the IDs back to live objects stays future work, as the comment envisioned. --- .../Sources/Events/LogEvent.swift | 11 ++++++ .../Sources/Pipeline/LogRecord.swift | 5 +++ .../Sources/Store/LogQuery.swift | 3 ++ .../Sources/Store/PeriscopeSchema.swift | 6 +++ .../Sources/Store/PeriscopeStore.swift | 26 ++++++++++++- .../Sources/Store/StoredLogEvent.swift | 4 ++ .../Tests/PeriscopeStoreTests.swift | 37 +++++++++++++++++++ .../Tests/StoredLogEventTests.swift | 1 + .../Components/LogEventDetailView.swift | 3 ++ .../Sources/Viewer/NDJSONExporter.swift | 3 ++ .../Tests/LogEventDetailViewTests.swift | 1 + .../Tests/NDJSONExporterTests.swift | 2 + 12 files changed, 101 insertions(+), 1 deletion(-) diff --git a/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift index 7eea0877..acf5ecd2 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Events/LogEvent.swift @@ -47,6 +47,13 @@ public protocol LogEvent: Codable, Sendable { /// 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 @@ -72,4 +79,8 @@ extension LogEvent { public static var isProtectedFromDropping: Bool { false } + + public var externalID: String? { + nil + } } diff --git a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift index bd474221..e65f4fd1 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Pipeline/LogRecord.swift @@ -89,6 +89,11 @@ public struct LogRecord: Sendable, Identifiable { 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift index dec72aa2..9c024695 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/LogQuery.swift @@ -30,6 +30,9 @@ public struct LogQuery: Sendable { /// 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? diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift index a6c25551..f9955255 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeSchema.swift @@ -19,6 +19,7 @@ final class SDLogEvent { [\.sessionID], [\.spanID], [\.spanExitMode], + [\.externalID], ) var eventID: UUID @@ -44,6 +45,9 @@ final class SDLogEvent { /// 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] @@ -66,6 +70,7 @@ final class SDLogEvent { spanExitMode: String?, callFunction: String?, callFileID: String?, + externalID: String?, scopes: [SDLogScope], tags: [SDLogTag], attachments: [SDLogAttachment], @@ -85,6 +90,7 @@ final class SDLogEvent { self.spanExitMode = spanExitMode self.callFunction = callFunction self.callFileID = callFileID + self.externalID = externalID self.scopes = scopes self.tags = tags self.attachments = attachments diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift index d920fb8f..5b1c7f12 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/PeriscopeStore.swift @@ -362,6 +362,7 @@ public actor PeriscopeStore: LogSink { spanExitMode: record.spanExit?.mode.rawValue, callFunction: record.callSite?.function, callFileID: record.callSite?.fileID, + externalID: record.externalID, scopes: scopeRows, tags: tagRows, attachments: attachmentRows, @@ -505,6 +506,8 @@ public actor PeriscopeStore: LogSink { 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, @@ -516,6 +519,8 @@ public actor PeriscopeStore: LogSink { session: session, filtersExit: filtersExit, exitMode: exitMode, + filtersExternalID: filtersExternalID, + externalID: externalID, filtersSearch: filtersSearch, search: search, filtersScope: filtersScope, @@ -557,6 +562,8 @@ public actor PeriscopeStore: LogSink { session: UUID, filtersExit: Bool, exitMode: String?, + filtersExternalID: Bool, + externalID: String?, filtersSearch: Bool, search: String, filtersScope: Bool, @@ -627,6 +634,18 @@ public actor PeriscopeStore: LogSink { 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), @@ -707,8 +726,12 @@ public actor PeriscopeStore: LogSink { lhs: sessioned, rhs: matchesExit, ) - let searched = PredicateExpressions.build_Conjunction( + let externallyIdentified = PredicateExpressions.build_Conjunction( lhs: exited, + rhs: matchesExternalID, + ) + let searched = PredicateExpressions.build_Conjunction( + lhs: externallyIdentified, rhs: matchesSearch, ) let scoped = PredicateExpressions.build_Conjunction( @@ -795,6 +818,7 @@ public actor PeriscopeStore: LogSink { 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 diff --git a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift index 21b323cc..f8f00065 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Store/StoredLogEvent.swift @@ -54,6 +54,8 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { 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] @@ -73,6 +75,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { spanID: SpanID?, spanExitMode: SpanExit.Mode?, callSite: LogCallSite?, + externalID: String?, attachments: [LogAttachmentInfo], sessionID: UUID, ) { @@ -89,6 +92,7 @@ public struct StoredLogEvent: Sendable, Identifiable, Hashable { self.spanID = spanID self.spanExitMode = spanExitMode self.callSite = callSite + self.externalID = externalID self.attachments = attachments self.sessionID = sessionID } diff --git a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift index c379733a..614e4237 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/PeriscopeStoreTests.swift @@ -406,6 +406,43 @@ struct PeriscopeStoreTests { #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([ diff --git a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift index e2513532..baefac2f 100644 --- a/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift +++ b/Shared/Periscope/PeriscopeCore/Tests/StoredLogEventTests.swift @@ -19,6 +19,7 @@ struct StoredLogEventTests { spanID: nil, spanExitMode: nil, callSite: nil, + externalID: nil, attachments: [], sessionID: UUID(), ) diff --git a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift index ba4bed72..84ce1345 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Components/LogEventDetailView.swift @@ -42,6 +42,9 @@ struct LogEventDetailView: View { 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) } diff --git a/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift index b16588f8..c10f39a2 100644 --- a/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift +++ b/Shared/Periscope/PeriscopeTools/Sources/Viewer/NDJSONExporter.swift @@ -46,6 +46,9 @@ enum NDJSONExporter { 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 diff --git a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift index d35678c3..14376385 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/LogEventDetailViewTests.swift @@ -22,6 +22,7 @@ struct LogEventDetailViewTests { spanID: nil, spanExitMode: nil, callSite: nil, + externalID: nil, attachments: [], sessionID: UUID(), ) diff --git a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift index f8b985ae..6842e9af 100644 --- a/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift +++ b/Shared/Periscope/PeriscopeTools/Tests/NDJSONExporterTests.swift @@ -33,6 +33,7 @@ struct NDJSONExporterTests { spanID: nil, spanExitMode: spanExitMode, callSite: nil, + externalID: nil, attachments: [], sessionID: sessionID, ) @@ -119,6 +120,7 @@ struct NDJSONExporterTests { spanID: nil, spanExitMode: nil, callSite: nil, + externalID: nil, attachments: [], sessionID: sessionID, ) From 3f9bae35d42889abd022df2dd31e4c913829c7da Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 13:02:45 -0700 Subject: [PATCH 72/73] Add an accessibility-settings ambient source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR feedback: logs which accessibility settings are enabled — one summary event at start (the state a session's events read against) and a change event per toggle — across VoiceOver, Switch Control, Reduce Motion, Reduce Transparency, Bold Text, Darker Colors, Invert Colors, and Grayscale. UIAccessibility statics are main-actor, so observers ride the main queue and the summary hops once at start. Wired into startDefaultAmbientSources on UIKit platforms. --- .../Ambient/AccessibilityAmbientSource.swift | 94 +++++++++++++++++++ .../Sources/Ambient/AmbientEvent.swift | 1 + .../Sources/Ambient/AmbientEventSource.swift | 10 +- .../AccessibilityAmbientSourceTests.swift | 49 ++++++++++ 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 Shared/Periscope/PeriscopeCore/Sources/Ambient/AccessibilityAmbientSource.swift create mode 100644 Shared/Periscope/PeriscopeCore/Tests/AccessibilityAmbientSourceTests.swift 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 index 14e02eff..f501e5d7 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift @@ -26,6 +26,7 @@ extension AmbientKind { 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 — diff --git a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift index 062d496e..0c683112 100644 --- a/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift +++ b/Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEventSource.swift @@ -2,8 +2,9 @@ import Foundation import os /// A source of ambient/environmental events. Built-ins cover app lifecycle, -/// memory warnings, network path, thermal state, and low power mode; -/// apps conform to add their own (push registration, sync status, …). +/// 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; @@ -67,8 +68,8 @@ extension Periscope { } /// Start every built-in ambient source: network path, thermal state, - /// low power mode, and (where UIKit exists) app lifecycle and memory - /// warnings. + /// low power mode, and (where UIKit exists) app lifecycle, memory + /// warnings, and accessibility settings. public func startDefaultAmbientSources() { startAmbientSource(NetworkPathAmbientSource()) startAmbientSource(ThermalStateAmbientSource()) @@ -76,6 +77,7 @@ extension Periscope { #if canImport(UIKit) startAmbientSource(AppLifecycleAmbientSource()) startAmbientSource(MemoryWarningAmbientSource()) + startAmbientSource(AccessibilityAmbientSource()) #endif } } 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 From f19be1faffa168792a22a020324538a23f8b5014 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Tue, 14 Jul 2026 13:04:53 -0700 Subject: [PATCH 73/73] Record the PR review pass and the staged design loops in TODOs Groundwork/docs only: the completed bug + mechanical-reshape items from the PR review land in the completed section, and the six larger design questions (crash durability, span record modeling, Periscope/store decomposition, readable ScopeIDs, context parent hierarchy, ambient state snapshots) are captured as open design items for the upcoming plan/build loops. AGENTS invariants updated for the event-level drop-protection opt-in and the StoreWriteFailed marker. --- Shared/Periscope/PeriscopeCore/AGENTS.md | 8 +++++--- Shared/Periscope/TODOs.md | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Shared/Periscope/PeriscopeCore/AGENTS.md b/Shared/Periscope/PeriscopeCore/AGENTS.md index 7a610137..e824fdbd 100644 --- a/Shared/Periscope/PeriscopeCore/AGENTS.md +++ b/Shared/Periscope/PeriscopeCore/AGENTS.md @@ -42,8 +42,9 @@ event sources). Tests stay flat, named 1:1 with their source files. 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 - and counts them; the pipeline reports drops with a synthetic +- **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 @@ -61,7 +62,8 @@ event sources). Tests stay flat, named 1:1 with their source files. 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 - (`LogRecord.isProtectedFromDropping`); and redaction is transform-only + (`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 diff --git a/Shared/Periscope/TODOs.md b/Shared/Periscope/TODOs.md index 2180bb11..b0b024ed 100644 --- a/Shared/Periscope/TODOs.md +++ b/Shared/Periscope/TODOs.md @@ -9,6 +9,12 @@ # 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. @@ -21,6 +27,14 @@ # 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.