Ambient state snapshots, build-attributed sessions, and span tooling that can't lie - #152
Conversation
Closes the first step of the ambient-state-snapshot P0: the snapshot needs to know which events describe a lasting condition (fold them in) and which describe an instant (don't). `AmbientEvent.reporting` draws that line, and a memory warning becomes the one built-in `.occurrence` — the app is not "in a memory warning" afterwards, so it must not stick to every later record. `eventVersion` goes to 2. Decoding is hand-written for one load-bearing reason: v1 rows have no `reporting` key at all and synthesized decoding throws on a missing key rather than defaulting. Every v1 ambient event was a state change, so absence decodes as `.state`, covered by a test that feeds the decoder the v1 shape.
Pure value-type groundwork for the P0: no pipeline is wired to it yet. A snapshot is the latest value of every stateful ambient kind, and its identity is the dedupe key — `applying(_:)` returns `self` whenever nothing actually moved, so a run of records sharing one system state will share one stored row instead of one row per record. `folding(_:into:)` handles the "nothing observed yet" case so an empty snapshot is unrepresentable: a row claiming to describe the system while carrying no values would be worse than an honest absence. `AmbientKind: CodingKeyRepresentable` keeps the values dictionary encoding as a JSON object keyed by kind rather than a flat alternating array. It is deliberately not `RawRepresentable`, which could change how a kind encodes inside an `AmbientEvent` payload and invalidate stored rows.
The point of the P0: any event — not just the ambient ones — can now be joined to what the system was doing when it happened, the same way it is already joined to its session. Folding and stamping both happen inside the existing in-lock buffer path, which returns the record *as buffered* so the journal and the sinks receive the stamped copy rather than the pre-stamp original. Folding runs before stamping, so an ambient event carries the state it announces instead of the one it replaced. The drop report needs its own stamp: it is synthesized during the drain and never passes through the buffer path, so it would otherwise be the one record that couldn't say what the system was doing when the gap opened.
A record whose only copy is the journal should still say what the system was doing when it was emitted, so `LogJournalRecord` carries the stamped snapshot and an end-to-end test asserts it survives emit → disk → recover. The field is `Optional` on purpose, and a test pins that: journals are written before an upgrade and ingested after one, so an entry from a build that predates ambient state has no `ambient` key at all. Optional means synthesized decoding tolerates that instead of throwing away the journal. Ingest maps the snapshot onto its row in the next commit, with the column to put it in.
Completes the durable half of the P0. Events reference their ambient state the way they already reference their session: a `SDAmbientSnapshot` row per distinct state plus an indexed `ambientSnapshotID` on the event, so a run of events that shared one system state costs one row rather than one copy each. Snapshot values are a dictionary attribute rather than a JSON blob, so reading a snapshot back has no decode step and therefore no failure mode to swallow. Journal ingest maps recovered records onto their rows too. Retention takes unreferenced snapshots with it — one row per distinct state means they otherwise accumulate for as long as the app keeps changing network, thermal, or power state. Both new columns are optional so SwiftData can infer the migration, and that is verified rather than assumed: a store written by the previous commit's schema (from a worktree at that commit) opens under the new one, its rows read back with ambient state honestly absent, and subsequent writes with snapshots commit with zero write failures. `LogRecord.stamped(ambient:)` is a DEBUG `@_spi(Testing)` seam: store tests hand records to `write(_:)` directly, with dates they choose, so they can't obtain a stamped record from a live pipeline. One test does go end to end through a real pipeline, and another through crash-journal recovery.
Without a baseline the snapshot has nothing to say about either until the first transition — and a device that launches hot and stays hot, or a session that runs entirely in Low Power Mode, never posts one at all. Both read nonisolated `ProcessInfo` state, so `started()` needs no actor hop, matching how the accessibility source already emits its summary. The app-lifecycle source deliberately gets no baseline, and a test now pins that: reading `UIApplication.applicationState` at launch reports `.background` even for a user tap under the UIScene lifecycle — the trap `LifecycleReason.undetermined` exists to avoid — so a baseline would stamp a confidently wrong value onto every early record of a normal launch. The first real transition fills it in honestly.
Stamping state onto records only pays off if a developer can see it. The event detail view resolves the snapshot on demand — one row serves many events, so queries don't join it per row — and the export resolves the whole set once and embeds each event's state as a JSON object. A referenced snapshot that isn't found is reported rather than omitted, in both surfaces: retention only drops *unreferenced* snapshots, so a missing one is a real inconsistency and must not read as "nothing was known about the system".
The version and build number can't identify a build when the manifest pins both, so every developer build's sessions read `v1.0 (1)` and week-old events can't be tied to the code that produced them. A recorded duration has the same problem from the other side: without the optimization level there's no telling a measurement that says something about the shipping app from one taken off an `-Onone` build. `LogSession` grows an `attributes` dictionary keyed by a typed `LogSessionAttributeKey`, with well-known keys for the commit, its clean/dirty status, the configuration, the optimization level, and the compilation mode. PeriscopeCore can't reach WhereCore, so it's a seam the host app fills rather than something Periscope reads: the stamp script now writes `CONFIGURATION`, `SWIFT_OPTIMIZATION_LEVEL`, and `SWIFT_COMPILATION_MODE` into the app's Info.plist beside the commit keys, `BuildInfo` reads them back as a `Compilation` value, and `bootstrapLogging` passes `BuildInfo.logSessionAttributes` to `LogSession.current`. The viewer's session picker names the commit and optimization level instead of the pinned version pair. An unstamped bundle claims nothing rather than claiming it was built from a commit named `unknown`, so a session that can't identify its build reads as unidentified. `LogSession` decodes a payload without `attributes` as an empty set — the crash journal ingests entries written by earlier app versions — and `SDLogSession.attributes` is optional so existing rows take SwiftData's lightweight migration. Closes the "a LogSession can't name the build it came from" P2 in the root TODOs.
A p99 pooled half from an unoptimized developer build and half from an optimized one describes neither, and nothing in the number tells the reader it happened. Now that a session names its optimization level, the history can separate them. `SpanHistoryView` gains a build scope — all builds, this session, or every session built at the same optimization level — resolved against the sessions the store actually recorded, so a store whose sessions never named a level doesn't offer to group by one. The list header names what the percentiles cover, and the empty state distinguishes "nothing recorded" from "nothing in this scope". Re-scoping filters the ends already accumulated instead of refetching, so narrowing stays as live and as cheap as the unscoped reading. `all` stays the default: a reader who hasn't chosen sees every run, and narrowing is an explicit act. An unresolvable scope admits no sessions rather than widening back to every build, so a label can't say one thing while the data says another. `PeriscopeStore.currentSession` is the new seam this reads — the session writes are attributed to, `nil` before one exists rather than synthesizing one a later write would adopt.
Closes the "span-tooling quick wins" plan step. Three things the span surfaces got wrong, all of them silent: - Every read model swallowed its failures. A store read that threw, or a payload that wouldn't decode, set `.failed` (or quietly grouped a row by its message) with nothing written anywhere a developer could see it. They now report to `PeriscopeToolsLog.failures`, an OSLog channel rather than Periscope itself: these surfaces reload on every store commit, so logging into the store they read would turn one corrupt row into a refresh loop. - `SpanNode` carried parallel `ended` / `exitMode` / `duration` optionals, so a span whose end payload failed to decode rendered an exit chip beside a "running" duration — a reading that cannot be true, since the exit comes from an indexed column and the duration from the payload. One `Outcome` replaces them, and its `Timing` tells an end that measured nothing apart from one whose payload wouldn't decode. - `SpanTreeRow` read `stylesheet.row.comfortable` directly, ignoring the density it was handed. The orphan sweep also stops parsing payloads to decide relaunches: `SpanRelaunchPolicy` is persisted as a column on the began row, and the sweep filters survivors from it. A row written before the column falls back to its payload, and a payload that can't be read can't prove the span asked to survive — so it's closed, with the failure logged rather than silently deciding against the policy. `degradeSpanBegan` is the test seam for that pair of degraded shapes; nothing a test can write produces them.
Closes the "PeriscopeCore/PeriscopeTools docs" plan step. The invariants an agent can't re-derive from the code: that an ambient event declares whether it's a state or an occurrence (and why folding an occurrence in would make every later record claim the app was mid-memory- warning); that a snapshot's identity only changes when a value moves, which is what makes "one row per distinct state" true; that a session's build attributes come from the app because Periscope sits below it; that the relaunch sweep reads a column and logs when it can't; that the tools log their own failures to OSLog rather than into the store they read; and that a timing reading has to name the builds it pools. Also fixes a doc claim that was already wrong: the crash-durability section read as though the journal covered the whole process lifetime. It doesn't — it opens with the store, and `PeriscopeStore.make` is `async`. That was its own backlog item. Closes eight backlog entries across the Periscope and root TODOs, and adds the two missing `*+Display` test files that another one asked for.
Preserve build-attributed Periscope sessions while adopting scoped log-store routing and demo mode from main. Co-authored-by: Cursor <cursoragent@cursor.com>
An ambient .state event that the level floors discarded, or that the redaction hook suppressed, never reached the buffer path - so it never folded into the running AmbientSnapshot, and every later record was stamped with the state the discarded event had replaced. Floors are routing, not scrubbing: a floored ambient event now still folds, it just isn't recorded. Redaction suppression is content scrubbing, so a suppressed event clears its kind from the snapshot instead (new AmbientSnapshot.removing(_:)) - folding the value in would smear exactly what the hook suppressed across every later record, and keeping the previous value would lie.
The baseline event carried the complete summary ("enabled: voiceover,
reduce-motion") but each change event carried a single-setting delta
("voiceover: on"). Both fold into the ambient snapshot under the one
.accessibility kind, so the first toggle replaced the complete state on
every subsequently stamped record with whichever setting last moved.
Change events now re-read and emit the same full summary the baseline
does. Also writes down the accepted read-vs-fold startup race on
NotificationAmbientSource.started(): observers register before the
baseline read, both sides read current state at emit time, and the next
real transition corrects any skew.
The sweep persisted its synthetic .orphaned ends through the normal write path, which attributes rows to the active session - the one whose startSession is running the sweep. Every orphaned span from the previous launch therefore landed in the new session, polluting "this session" span-history scopes and session-filtered viewer queries with work the new launch never did. persist() now takes an explicit session attribution, and the sweep groups its ends by each began row's session so the close lands where the work happened.
Event lines carried only the session UUID, so an exported bug report dropped exactly the build attribution this branch adds to sessions - commit, configuration, optimization level. A duration in an export was unanswerable without the store it came from. The export now opens with one "record": "session" line per session the events reference (oldest first), carrying the session's identity, device/OS metadata, and its attributes; readers join event lines to them by the session UUID. The export button also logs its failures to PeriscopeToolsLog.failures instead of only flipping the alert flag.
A failed `git status --porcelain` (index lock, permissions) prints nothing, and the stamp script read empty output as a clean tree - a build could claim `clean` on no evidence. The exit code and the output are now separate signals: failure stamps `unknown`, like a checkout with no git metadata at all.
Three fixes to the Span History surface:
- Undecodable ends stopped fragmenting: the recovered bucket name now
comes from SpanEnded.nameRecovered(fromMessage:exit:) - the inverse
of the message format, living beside it in Core - which strips the
exit word, reason, and duration. Grouping by the raw message minted a
bucket per row, since the duration varies per instance.
- The scope summary counts sessions *contributing* ends, as its doc
always claimed, instead of every session the scope would admit - a
session that never recorded a span no longer pads the count.
- The empty state gets its own model-provided line instead of
lowercasing the summary, which garbled case-sensitive values
("built at -onone").
The v1 fallback decoded every reporting-less row as .state, but v1's only .memory source was the memory-warning source - and a warning describes an instant, not a condition the app stayed in. Old memory rows read back through tooling now say .occurrence, matching what they meant when they were written.
prettyPayload returned nil for both "no payload recorded" and "bytes exist but don't parse", so a corrupt payload hid the Payload section entirely - reading as if the event never carried one. The detail view now presents a PayloadPresentation: pretty JSON when it parses, an explicit unreadable marker (with the byte count) when it doesn't, and no section only when nothing was recorded.
The branch adds a model (SDAmbientSnapshot), three optional columns (spanRelaunchPolicy, session attributes, ambientSnapshotID), and an index - all shapes SwiftData should migrate lightweight, but nothing exercised that against a database an old build actually wrote. Commits Tests/Fixtures/PreAmbientSchema.store, generated by an origin/main build of PeriscopeCore (macOS worktree, scratch executable, WAL checkpointed so the single file is self-contained), and a suite that copies it to a scratch directory and opens it with the current schema: old rows and sessions read back (attributes empty), the orphan sweep closes the old launch's open span from its payload and attributes the end to the old session, v1 ambient rows decode, and new writes exercise the snapshot table. The unitTests Project.swift helper gains a resources parameter to carry the fixture into the test bundle.
| /// diagnose what the system was doing around an error. | ||
| public struct AmbientEvent: LogEvent, Hashable { | ||
| public static let eventName = "ambient" | ||
| /// v2 added ``reporting``; v1 rows decode as ``Reporting/state``. |
There was a problem hiding this comment.
Don't worry about data migration actually; since the app is pre-release we can just delete the old DB and change the model
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Done in 9a2145c — the migration tolerance is gone wholesale: the hand-written decoders (here and on LogSession), the eventVersion bump, the optional-for-migration SDLogSession.attributes column (now non-optional), the orphan sweep's pre-column payload fallback, and the fixture + upgrade tests. PeriscopeCore/AGENTS.md now records the pre-release posture: synthesized Codable, no decode tolerance, delete the store on shape changes.
| } | ||
|
|
||
| public var kind: AmbientKind | ||
| public var value: String |
There was a problem hiding this comment.
It's not new but it would be nice if this was a JSON object instead.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Done in 139b37c — value is now [String: AmbientValue], a plain JSON object end to end (payload, snapshot, store, NDJSON export). AmbientValue is a bare JSON scalar (string/int/double/bool) with a single-value Codable, so a payload reads as {"status": "satisfied", "expensive": false} rather than a case-keyed enum wrapper. Sources now report typed fields — accessibility is one named Bool per setting, power mode a real boolean — and messages render mechanically as sorted key=value pairs.
| case reporting | ||
| } | ||
|
|
||
| public init(from decoder: any Decoder) throws { |
There was a problem hiding this comment.
...Once we just change the schema we can remove this manual implementation.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Done in 9a2145c — the hand-written init(from:) is gone and the conformance is fully synthesized again (the eventVersion = 2 bump went with it).
| /// `CodingKeyRepresentable` so dictionaries keyed by it encode as JSON | ||
| /// objects instead of the flat alternating key/value array a dictionary with | ||
| /// non-string keys falls back to. | ||
| struct StringCodingKey: CodingKey { |
There was a problem hiding this comment.
I don't think this is useful. It's OK to have a nested struct. Let's remove this.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Done in 335ac36 — StringCodingKey is gone; AmbientKind and LogSessionAttributeKey each carry a private nested Key struct in their CodingKeyRepresentable conformances.
| /// and synthesized decoding throws on the missing key rather than defaulting. | ||
| /// An older build simply couldn't name itself, which is an empty set of | ||
| /// attributes — not a corrupt session. `encode(to:)` stays synthesized. | ||
| extension LogSession { |
There was a problem hiding this comment.
Per above, let's just remove this. I'll delete the DB to avoid a migration.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Done in 9a2145c — the hand-written init(from:) is removed; LogSession decodes synthesized. (Journals live beside the database, so deleting the store directory covers the old-journal case this existed for.)
| predicate: #Predicate { $0.ambientSnapshotID == snapshotID }, | ||
| ) | ||
| events.fetchLimit = 1 | ||
| if try modelContext.fetchCount(events) == 0 { |
There was a problem hiding this comment.
Should we add some sort of isEmpty fetch that sets its limit to 1 to short circuit this vs getting the whole count?
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Good instinct, with a twist: fetchCount already honors fetchLimit — that's why the limit was set — so the code never tallied the whole table. But the idiom reads as "count everything" unless you know that, which is the real problem. 5cb0afb names it: both pruning loops now go through hasNoEvents(matching:), whose doc comment states the limit-honoring fact.
There was a problem hiding this comment.
Lets just delete this per other comments.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Deleted in 9a2145c, along with the schema-upgrade suite and the Project.swift test-resources plumbing that carried it into the bundle.
There was a problem hiding this comment.
We'll be able to remove this per the above.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Removed in 9a2145c with the fixture it opened.
There was a problem hiding this comment.
If we remove StringCodingKey we can remove this.
There was a problem hiding this comment.
Posted by an AI agent on kyleve's behalf.
Removed in 335ac36 with StringCodingKey itself; the object-not-array encoding stays covered by the AmbientSnapshot and LogSession encoding tests.
…ent-snapshots-3fed
…grated Per PR review: the app is pre-release, so old databases and journals are deleted rather than carried across schema changes. Everything that existed only to read the pre-ambient-snapshot shape goes: - AmbientEvent returns to a fully synthesized Codable (no hand-written init(from:) defaulting a missing `reporting`, no eventVersion bump). - LogSession returns to synthesized decoding (no missing-`attributes` fallback). - SDLogSession.attributes becomes non-optional - it was optional only for lightweight migration. - The orphan sweep decides relaunch policy from the column alone; the pre-column payload fallback and the degradeSpanBegan seam's clearingRelaunchPolicyColumn knob are gone. A corrupt payload now only costs the synthetic end its recorded name (new column-alone test). - The committed PreAmbientSchema.store fixture, the schema-upgrade suite, and the test-target resources plumbing that carried it are deleted. PeriscopeCore/AGENTS.md now records the pre-release posture: keep Codable conformances synthesized; no decode tolerance for older rows.
Per PR review: the shared utility wasn't carrying its weight. Each CodingKeyRepresentable conformance (AmbientKind, LogSessionAttributeKey) now carries its own private nested Key struct, and the standalone file and its tests are gone. The object-not-array encoding behavior stays covered by the existing AmbientSnapshot and LogSession encoding tests.
Per PR review: the metadata-pruning loops set fetchLimit = 1 before fetchCount precisely so emptiness checks never tally a whole table - fetchCount honors the limit - but the idiom reads as "count everything" unless you know that. One named helper makes the short-circuit legible and keeps both call sites from drifting apart.
Per PR review: AmbientEvent.value was a String, so every source rendered
its state into a sentence ("enabled: voiceover, reduce-motion") that
tooling could only display or parse back apart. The value is now
[String: AmbientValue] - named fields carried as a plain JSON object all
the way through the payload, the snapshot, the store, and the export.
AmbientValue is a bare JSON scalar (string/int/double/bool) with a
hand-written single-value Codable - the documented exception - so a
stored payload reads as {"status": "satisfied", "expensive": false}
rather than a case-keyed enum wrapper. Messages render mechanically as
sorted key=value pairs, and .ambientDescription shares that rendering
with the viewer's detail section.
The sources now report typed fields: network {status, interfaces},
thermal {level}, power {low-power: Bool}, lifecycle {phase}, memory
{pressure}, and accessibility one named Bool per setting - the full
picture the per-toggle re-report was already carrying, minus the prose.
The network source's change-only filter compares values instead of
description strings. NDJSON exports snapshots as nested JSON objects
with native scalar types.
Pre-release, so the store's rows change shape without migration
(SDAmbientSnapshot.values nests the fields); the DB gets deleted.
Main restructured the launch under this branch: the store now opens lazily behind the onboarding gate (demo mode, #150), the durable log sink became a scope's concern (WhereScope), and #152/#153/#155 landed. The resolution keeps main's architecture and re-layers this branch's instrumentation onto it: - The plan keeps main's shape (gate -> resolve-scope -> start-session) with this branch's .measured() composition on every step. The ResolveScopeStep inherits the old open-store step's one-second budget (it is the store open now); ExitDemoStep gains a budget and joins its plan measured; the onboarding gate stays unmeasured (it parks on the user). - bootstrapLogging is gone with its architecture: main's startAmbientLogging stays, and the openLogStore span, the two-axis LogHistoryPruner, and the pruneHistory span move into WhereScope's store bring-up - which main had grown a simpler (olderThan-only) version of. The richer historyPruned(expired:overflowed:) event shape wins. - LaunchStepID.openStore no longer exists; MeasuredStepTests and the WhereLaunchLog doc example re-key on resolveScope. - Where/AGENTS.md keeps both sides' new sections (Scopes and the launch + Spans); the TODOs size-cap item main re-added drops in favor of this branch's completed entry.
All 19 open Periscope items and both JournalKit items checked against HEAD. None have shipped: the three durability gaps (survivesRelaunch resume, the pre-store-attach window, multi-process journal coordination) remain the oldest open work in the repo, now with current citations. Corrected a wrong premise in the span-record-modeling P0. It claimed spanID and spanExit are 'bolted onto every LogRecord'; they are computed downcasts on the record's event (LogSpan.swift:210, :216, :224), and LogRecord stores exactly one span-related field, bypassesFloors. The denormalized columns live on the store and journal shapes deliberately, so the orphan sweep reads an indexed value instead of decoding payloads. Left as a dated correction inside the item, because it changes what the item is asking for. Re-counted the hosting-smoke-test debt: 20 assertions across 10 files, not the 18 across 9 filed -- PR #152 added LogEventDetailViewHostingTests, so the conversion surface grew. PeriscopeTools/SnapshotTests still holds only PeriscopeViewerSnapshotTests, so none of the conversion has happened. Refreshed drifted citations throughout (PeriscopeStore.make :84 -> :118, add(sink:) :192 -> :223, the relaunch warn :577 -> :600, the four density sites, both rebuild-per-ping models, the inspector's uncursored requery). Confirmed clean, so not filed: Broadway does not leak below PeriscopeTools (no imports in Core/UI; Package.swift:94-99 lists it only on Tools). Validation: docs only. swiftformat --lint and attribution --check pass.
Three related things, all in service of being able to read a log weeks later and know what it meant: the system state behind every event, the build behind every session, and span surfaces that don't render claims their data can't support.
Closes nine backlog entries — the ambient-snapshot P0 in
Shared/Periscope/TODOs.md, theLogSessionbuild-naming P2 in the rootTODOs.md, and seven span-tooling / docs quick wins.Ambient state snapshots
Every event already referenced one
SDLogSessionrow for "which build, on which device". It now also references oneSDAmbientSnapshotrow for "what was the system doing" — network, thermal state, power mode, lifecycle — so correlating an error with connectivity stops being a timestamp hunt.AmbientEvent.valueis[String: AmbientValue]— named fields carried as a plain JSON object through the payload, the snapshot, the store, and the export.AmbientValueis a bare JSON scalar (string/int/double/bool) with a single-valueCodable(the documented hand-written exception), so a payload reads as{"status": "satisfied", "expensive": false}rather than a case-keyed enum wrapper. Sources report typed fields: network{status, interfaces}, thermal{level}, power{low-power: Bool}, lifecycle{phase}, memory{pressure}, accessibility one namedBoolper setting. Messages render mechanically as sortedkey=valuepairs.AmbientEvent.reporting(.state/.occurrence) draws the line between a lasting condition and a passing moment. A memory warning is the one built-in.occurrence: the app is not "in a memory warning" afterwards, so it must not stick to every later record.AmbientSnapshotis the latest value of every stateful kind, and its identity is the dedupe key —applying(_:)returnsselfwhenever nothing actually moved, so a run of records sharing one state shares one stored row.folding(_:into:)makes an empty snapshot unrepresentable: a row describing the system while carrying no values would be worse than an honestnil.ambientSnapshotID, and retention takes unreferenced snapshots with it.started(), so their state isn't unknown until it next changes.A session can name the build it came from
LogSession.current()read onlyCFBundleShortVersionString/CFBundleVersion, and the Where app pins both in the manifest — so every developer build readv1.0 (1)and weeks-old logs couldn't be tied to the code that produced them.LogSession.attributes([LogSessionAttributeKey: String]) is the seam: the host app fills it at bootstrap, keeping Periscope below the Where modules rather than reaching up for a build stamp. Where'sstamp-build-info.shnow writesWhereConfiguration/WhereSwiftOptimizationLevel/WhereSwiftCompilationModebeside the commit keys,BuildInforeads them back, and the viewer's session picker shows commit + optimization level. The stamp script also stops trusting a failedgit status: exit code and output are separate signals, so a build stampsunknown— neverclean— when the status check itself fails.The optimization level is the load-bearing field, not the commit: a span duration from an
-Ononebuild says nothing about the shipping app, and the configuration alone can't answer it (aDebugconfiguration can be compiled-O). SoSpanHistoryViewalso gained a build scope — all builds, this session, or every session built at the current one's level — and labels the active scope, because a p95 that silently pools an unoptimized build with an optimized one measures nothing.The NDJSON export carries the attribution too: it opens with one
"record": "session"line per session the exported events reference — identity, device/OS metadata, and the build attributes — so a duration in a bug report names the build it came from instead of a bare session UUID. Ambient state exports as nested JSON objects with native scalar types.Span tooling that can't lie
SpanNodecarried parallelended/exitMode/durationoptionals, so a span whose end payload failed to decode rendered an exit chip beside a "running" duration — a reading that cannot be true, since the exit comes from an indexed column and the duration from the payload. OneOutcomereplaces them, and itsTimingdistinguishes an end that measured nothing (an orphan) from one whose payload wouldn't decode.SpanEnded.nameRecovered(fromMessage:exit:)(the inverse of the message format, living beside it in Core) strips the exit word, reason, and duration, so undecodable rows of one kind share one bucket instead of fragmenting into one per row.-Onone)..orphanedend closes the previous launch's work; landing it in the sweeping session polluted "this session" readings with spans the new launch never ran. The sweep decides from the persistedspanRelaunchPolicycolumn alone; a corrupt payload only costs the synthetic end its recorded name.PeriscopeToolsLog.failures(including NDJSON export) — deliberately OSLog, not Periscope: these surfaces reload on every store commit, so logging into the store they read would turn one corrupt row into a refresh loop.SpanTreeRowreads\.logRowDensityinstead of hard-codingcomfortable, so the viewer's density picker actually reaches span-tree rows.Notable decisions
Codableconformance stays synthesized (no hand-written decoders defaulting missing keys, noeventVersionbumps for decode boundaries, no optional-for-migration columns, no committed pre-upgrade fixture).PeriscopeCore/AGENTS.mdrecords the posture.UIApplication.shared.applicationStateat launch, which reads.backgroundeven for a user tap under the UIScene lifecycle — the trapLifecycleReason.undeterminedexists to avoid. A baseline there would stamp a confidently wrong value on every early record; the first real transition fills it correctly a moment later. A test pins the omission so it reads as deliberate.NotificationAmbientSource.started().BuildInfo.logSessionAttributesis empty rather than reporting the stamp script'sunknownplaceholder as a configuration — a session that can't name its build should read as unidentified, not as a build called "unknown".degradeSpanBeganis a test seam for degraded on-disk shapes. Nothing a test can write produces a payload that won't decode, so the sweep's degraded-name path was unreachable from a test without it.Testing
tuist test Stuff-iOS-Testspasses in full (1469 tests, the multi-bundle scheme). Coverage spans the snapshot value type (including the plain-JSON-object encoding), pipeline stamping (spans, the drop report, live observers, gate-discarded ambient events), journal round-trip and crash recovery, store dedupe / reads / orphan pruning and attribution, session attribute round-tripping, the build-scope filter and its contributing-session summary, the span outcome model under unreadable payloads, and recovered-name grouping.