feat(scarfmon): perf instrumentation plumbing for iOS + Mac (Phase 1)

ScarfMon lands the always-on perf instrumentation harness. Phase 1 ships
the plumbing only; Phase 2 wires the chat measure points.

Core (ScarfCore/Diagnostics/):
- ScarfMon — public API: measure / measureAsync / event with @inline(__always)
  short-circuit when the backend set is empty so the off path is one
  branch + return. Categories are an enum, names are StaticString so
  user content cannot leak through metric tags.
- ScarfMonRingBuffer — fixed-capacity (4096) lock-protected ring; one
  os_unfair_lock per record; summary() aggregates by (category, name)
  with nearest-rank p50/p95; exportJSON() emits a one-line-per-sample
  dump for the Copy as JSON button.
- ScarfMonSignpostBackend — emits os_signpost into a dedicated
  com.scarf.mon subsystem so Instruments → Points of Interest shows
  Scarf's own measure points without a debug build.
- ScarfMonLoggerBackend — Logger(.debug) sink for users running
  `log stream --predicate 'subsystem == \"com.scarf.mon\"'`.
- ScarfMonBoot — three modes (off / signpostOnly / full); persists the
  user's choice in UserDefaults under ScarfMonMode; configure() is
  idempotent and replaces the active backend set atomically.

Tests: 11 cases covering ring ordering / wrap / reset, summary
aggregation, p95 percentiles, event vs interval semantics, install /
isActive, measure + measureAsync (including the throw path), boot
mode transitions, and JSON export round-trip. @Suite(.serialized)
because the suite mutates process-wide backend state.

App wiring:
- ScarfIOSApp.init + ScarfApp.init call ScarfMonBoot.configure(mode:)
  with the persisted mode (default .signpostOnly).
- iOS Settings → Diagnostics → Performance row leads to a list-style
  panel with the segmented mode picker, top-20 stat rows by p95, Copy
  as JSON, and Reset.
- Mac Settings → Advanced gains a ScarfMonDiagnosticsSection with the
  same shape (NSPasteboard for copy).

Open-source by design — no remote upload, no analytics. The ring buffer
never leaves the device unless the user explicitly taps Copy as JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 22:08:21 +02:00
parent 272da6a915
commit 6cf59c8a44
12 changed files with 1186 additions and 0 deletions
@@ -0,0 +1,277 @@
import Foundation
#if canImport(os)
import os
import os.signpost
#endif
/// Lightweight performance instrumentation for the Scarf app family.
///
/// Three primitives `measure(...)`, `measureAsync(...)`, `event(...)` drop
/// timing samples through whatever set of backends is currently active.
/// Backends are pluggable: an always-on `os_signpost` backend (free outside
/// Instruments), an in-memory ring buffer (drives the in-app panel), and an
/// `os.Logger` debug backend (off by default).
///
/// **Cost when off.** When no backends are registered, every entry point is
/// `@inline(__always)` and short-circuits to the body call without taking the
/// `ContinuousClock.now` reading. Open source build defaults to "signpost
/// only" that backend pays one signpost emit per call, which Apple's runtime
/// elides when no Instruments session is recording.
///
/// **Privacy.** Names are `StaticString` so we cannot accidentally pass user
/// content through a metric tag. Optional `bytes:` field on `event` tracks
/// payload size, never payload contents. The ring buffer never leaves the
/// device unless the user explicitly hits "Copy as JSON" in the Diagnostics
/// panel.
public enum ScarfMon {
// MARK: - Public API
/// Synchronous timing wrapper. The body's return value flows through
/// untouched; the time it took plus `(category, name)` are recorded.
@inline(__always)
public static func measure<T>(
_ category: Category,
_ name: StaticString,
_ body: () throws -> T
) rethrows -> T {
guard isActive else { return try body() }
let start = ContinuousClock.now
defer { record(category, name, start: start, end: ContinuousClock.now) }
return try body()
}
/// Async variant. Same shape the `defer` block fires after the body
/// returns whether or not it threw, so cancelled / failed work still
/// records its duration.
@inline(__always)
public static func measureAsync<T>(
_ category: Category,
_ name: StaticString,
_ body: () async throws -> T
) async rethrows -> T {
guard isActive else { return try await body() }
let start = ContinuousClock.now
defer { record(category, name, start: start, end: ContinuousClock.now) }
return try await body()
}
/// Single-shot timestamped event. Use for things that aren't intervals
/// (token arrivals, buffer flushes) where count + optional payload size
/// is the useful signal.
@inline(__always)
public static func event(
_ category: Category,
_ name: StaticString,
count: Int = 1,
bytes: Int? = nil
) {
guard isActive else { return }
recordEvent(category, name, count: count, bytes: bytes)
}
// MARK: - Backend management
/// Install the desired backend set. Replaces the current set atomically.
/// Call once at app boot from the launch sequence; safe to call again
/// when the user toggles a setting on or off.
public static func install(_ backends: [ScarfMonBackend]) {
lock.lock()
defer { lock.unlock() }
installed = backends
cachedActive = !backends.isEmpty
}
/// Currently-installed backends. Test-only callers should not iterate
/// this in production.
public static var currentBackends: [ScarfMonBackend] {
lock.lock()
defer { lock.unlock() }
return installed
}
/// Cheap "are we recording anything?" check. The flag is updated only
/// when `install(...)` runs, so the hot path doesn't take the lock.
@inline(__always)
public static var isActive: Bool { cachedActive }
// MARK: - Internals
private static let lock = ScarfMonLock()
nonisolated(unsafe) private static var installed: [ScarfMonBackend] = []
nonisolated(unsafe) private static var cachedActive: Bool = false
@inline(__always)
private static func record(
_ category: Category,
_ name: StaticString,
start: ContinuousClock.Instant,
end: ContinuousClock.Instant
) {
let duration = end - start
let nanos = nanoseconds(of: duration)
let backends = snapshotBackends()
let sample = Sample(
category: category,
name: name,
kind: .interval,
timestamp: Date(),
durationNanos: nanos,
count: 1,
bytes: nil
)
for backend in backends {
backend.record(sample)
}
}
@inline(__always)
private static func recordEvent(
_ category: Category,
_ name: StaticString,
count: Int,
bytes: Int?
) {
let backends = snapshotBackends()
let sample = Sample(
category: category,
name: name,
kind: .event,
timestamp: Date(),
durationNanos: 0,
count: count,
bytes: bytes
)
for backend in backends {
backend.record(sample)
}
}
private static func snapshotBackends() -> [ScarfMonBackend] {
lock.lock()
defer { lock.unlock() }
return installed
}
private static func nanoseconds(of duration: Duration) -> UInt64 {
// Duration is (seconds: Int64, attoseconds: Int64). Avoid Double
// for the seconds term to keep precision on long intervals.
let comps = duration.components
let secondsAsNanos = UInt64(max(0, comps.seconds)) &* 1_000_000_000
let attoAsNanos = UInt64(max(0, comps.attoseconds) / 1_000_000_000)
return secondsAsNanos &+ attoAsNanos
}
}
// MARK: - Categories
extension ScarfMon {
/// Stable category vocabulary. Add cases here when new subsystems get
/// instrumented; renames are breaking changes for any saved JSON dumps
/// users have shared, so prefer adding over renaming.
public enum Category: String, CaseIterable, Sendable, Codable {
case chatRender
case chatStream
case sessionLoad
case transport
case sqlite
case diskIO
case render
case other
}
}
// MARK: - Sample
/// One recorded sample. All fields are value types so the struct is trivially
/// `Sendable` across backend queues without locks.
public struct ScarfMonSample: Sendable, Hashable {
public enum Kind: String, Sendable, Codable {
case interval
case event
}
public let category: ScarfMon.Category
/// Static name string captured at the call site. Not a `String` keeping
/// it `StaticString` proves at compile time that names cannot leak user
/// data through this channel.
public let name: StaticString
public let kind: Kind
public let timestamp: Date
public let durationNanos: UInt64
public let count: Int
public let bytes: Int?
public init(
category: ScarfMon.Category,
name: StaticString,
kind: Kind,
timestamp: Date,
durationNanos: UInt64,
count: Int,
bytes: Int?
) {
self.category = category
self.name = name
self.kind = kind
self.timestamp = timestamp
self.durationNanos = durationNanos
self.count = count
self.bytes = bytes
}
/// `StaticString` does not conform to `Hashable` natively (it doesn't
/// promise a stable hash). We hash via its UTF-8 representation so two
/// samples with the same source-literal name compare equal.
public static func == (lhs: ScarfMonSample, rhs: ScarfMonSample) -> Bool {
lhs.category == rhs.category
&& lhs.kind == rhs.kind
&& lhs.timestamp == rhs.timestamp
&& lhs.durationNanos == rhs.durationNanos
&& lhs.count == rhs.count
&& lhs.bytes == rhs.bytes
&& lhs.name.description == rhs.name.description
}
public func hash(into hasher: inout Hasher) {
hasher.combine(category)
hasher.combine(kind)
hasher.combine(timestamp)
hasher.combine(durationNanos)
hasher.combine(count)
hasher.combine(bytes)
hasher.combine(name.description)
}
}
extension ScarfMon {
public typealias Sample = ScarfMonSample
}
// MARK: - Backend protocol
/// One sink for samples. Implementations must be cheap on the hot path
/// callers hold no lock while invoking `record`, but the hot path runs from
/// every instrumented site, so allocations and disk I/O are off-limits here.
public protocol ScarfMonBackend: Sendable {
func record(_ sample: ScarfMon.Sample)
}
// MARK: - Lock
/// Tiny `os_unfair_lock` wrapper. CLAUDE.md says "Use os_unfair_lock (not
/// NSLock) for simple boolean flags accessed from multiple threads."
@usableFromInline
final class ScarfMonLock: @unchecked Sendable {
private let _lock: UnsafeMutablePointer<os_unfair_lock>
init() {
_lock = .allocate(capacity: 1)
_lock.initialize(to: os_unfair_lock())
}
deinit {
_lock.deinitialize(count: 1)
_lock.deallocate()
}
@usableFromInline func lock() { os_unfair_lock_lock(_lock) }
@usableFromInline func unlock() { os_unfair_lock_unlock(_lock) }
}
@@ -0,0 +1,76 @@
import Foundation
/// Boot-time wiring for ScarfMon. Both app targets call
/// `ScarfMonBoot.configure(...)` at launch and again whenever the user
/// flips the Diagnostics Performance toggle.
///
/// Three modes:
/// - `.off` nothing is recorded. Hot path is one branch + return.
/// - `.signpostOnly` Instruments-only. Default in the open-source build.
/// Free outside an Instruments session.
/// - `.full` signpost + ring buffer + os.Logger debug stream. Drives the
/// in-app panel and the "Copy as JSON" button. Opt-in.
public enum ScarfMonBoot {
public enum Mode: String, Sendable, CaseIterable {
case off
case signpostOnly
case full
}
/// User-defaults key for the persisted toggle. Same key on iOS + Mac
/// so `defaults read com.scarf.app ScarfMonMode` works on either.
public static let userDefaultsKey = "ScarfMonMode"
/// Read the persisted mode, defaulting to `.signpostOnly` so users
/// always get Instruments-visible signposts unless they explicitly
/// turn them off.
public static func currentMode(_ defaults: UserDefaults = .standard) -> Mode {
if let raw = defaults.string(forKey: userDefaultsKey),
let mode = Mode(rawValue: raw) {
return mode
}
return .signpostOnly
}
/// Persist a new mode and reinstall the backend set.
public static func setMode(_ mode: Mode, _ defaults: UserDefaults = .standard) {
defaults.set(mode.rawValue, forKey: userDefaultsKey)
configure(mode: mode)
}
/// Install the backend set for a given mode. Returns the active ring
/// buffer (if any) so the in-app Diagnostics panel can read from it.
@discardableResult
public static func configure(mode: Mode) -> ScarfMonRingBuffer? {
switch mode {
case .off:
ScarfMon.install([])
sharedRingBuffer = nil
return nil
case .signpostOnly:
ScarfMon.install([ScarfMonSignpostBackend()])
sharedRingBuffer = nil
return nil
case .full:
let ring = ScarfMonRingBuffer()
sharedRingBuffer = ring
ScarfMon.install([
ScarfMonSignpostBackend(),
ring,
ScarfMonLoggerBackend()
])
return ring
}
}
/// Process-wide ring buffer when running in `.full` mode. Nil otherwise.
/// Read by the Diagnostics panel; writes happen through the backend
/// dispatcher so this property is read-only.
///
/// `nonisolated(unsafe)` because the value is only mutated by
/// `configure(...)` (which itself runs on whichever actor invokes
/// the boot helper at app launch single-writer in practice) and
/// read from the panel UI on the main actor. Adding a lock here
/// would just add overhead with no real safety win.
nonisolated(unsafe) public private(set) static var sharedRingBuffer: ScarfMonRingBuffer?
}
@@ -0,0 +1,41 @@
import Foundation
#if canImport(os)
import os
#endif
/// `os.Logger`-backed sink. Off by default opt-in via the Diagnostics
/// settings toggle. Writes one `.debug` line per sample at the
/// `com.scarf.mon` subsystem, so users can stream the output via
/// `log stream --predicate 'subsystem == "com.scarf.mon"'` without
/// enabling private-data redaction overrides.
///
/// Only meaningful for users running their own debug build or with the
/// "verbose performance logging" toggle on.
public final class ScarfMonLoggerBackend: ScarfMonBackend, @unchecked Sendable {
#if canImport(os)
private let logger: Logger
public init(category: String = "perf") {
self.logger = Logger(subsystem: "com.scarf.mon", category: category)
}
public func record(_ sample: ScarfMon.Sample) {
switch sample.kind {
case .interval:
// `\(static:)` interpolation keeps the StaticString out of the
// private-data redaction path names are public, durations
// are public, the user's content never touches this channel.
logger.debug(
"\(sample.category.rawValue, privacy: .public) \(sample.name.description, privacy: .public) ms=\(Double(sample.durationNanos) / 1_000_000.0, privacy: .public)"
)
case .event:
logger.debug(
"\(sample.category.rawValue, privacy: .public) \(sample.name.description, privacy: .public) count=\(sample.count, privacy: .public) bytes=\(sample.bytes ?? -1, privacy: .public)"
)
}
}
#else
public init(category: String = "perf") {}
public func record(_ sample: ScarfMon.Sample) { /* no-op off-Apple */ }
#endif
}
@@ -0,0 +1,176 @@
import Foundation
/// Fixed-size, lock-protected ring of recent samples. Drives the in-app
/// Diagnostics panel and the export-as-JSON button.
///
/// Capacity is a compile-time choice; 4096 entries × ~80 bytes per sample =
/// ~320 KB resident. That's enough for several minutes of streaming-chat
/// activity at 200 samples/s without overwriting interesting context.
///
/// The hot path takes one `os_unfair_lock` per `record`. Aggregation (the
/// `summary(...)` reader) builds a fresh dictionary each call only invoked
/// from the panel UI, which polls at a human cadence.
public final class ScarfMonRingBuffer: ScarfMonBackend, @unchecked Sendable {
public let capacity: Int
private let lock = ScarfMonLock()
private var storage: [ScarfMon.Sample?]
/// Next write index. Wraps around `capacity` so the buffer never grows.
private var head: Int = 0
/// True once we've wrapped at least once switches the read order from
/// `[0..<head]` to `[head..<capacity] + [0..<head]`.
private var didWrap: Bool = false
public init(capacity: Int = 4096) {
precondition(capacity > 0, "ring buffer needs a positive capacity")
self.capacity = capacity
self.storage = Array(repeating: nil, count: capacity)
}
public func record(_ sample: ScarfMon.Sample) {
lock.lock()
defer { lock.unlock() }
storage[head] = sample
head += 1
if head >= capacity {
head = 0
didWrap = true
}
}
/// Snapshot of all currently-resident samples in chronological order.
public func samples() -> [ScarfMon.Sample] {
lock.lock()
defer { lock.unlock() }
if !didWrap {
return storage[0..<head].compactMap { $0 }
}
let tail = storage[head..<capacity].compactMap { $0 }
let leading = storage[0..<head].compactMap { $0 }
return tail + leading
}
/// Wipe the buffer. Used by the "Reset" button in the Diagnostics
/// panel and at the top of every test case.
public func reset() {
lock.lock()
defer { lock.unlock() }
for i in 0..<capacity { storage[i] = nil }
head = 0
didWrap = false
}
/// Aggregated stats over the current buffer. Buckets by
/// `(category, name)`; computes count, total nanos, mean, p50, p95.
public func summary() -> [ScarfMonStat] {
let snapshot = samples()
var buckets: [BucketKey: [UInt64]] = [:]
var counts: [BucketKey: Int] = [:]
var byteTotals: [BucketKey: Int] = [:]
var kinds: [BucketKey: ScarfMon.Sample.Kind] = [:]
for sample in snapshot {
let key = BucketKey(category: sample.category, name: sample.name.description)
kinds[key] = sample.kind
counts[key, default: 0] += sample.count
if let b = sample.bytes { byteTotals[key, default: 0] += b }
if sample.kind == .interval {
buckets[key, default: []].append(sample.durationNanos)
}
}
var stats: [ScarfMonStat] = []
for (key, _) in counts {
let durations = buckets[key] ?? []
let kind = kinds[key] ?? .event
stats.append(ScarfMonStat(
category: key.category,
name: key.name,
kind: kind,
count: counts[key] ?? 0,
totalNanos: durations.reduce(0, &+),
p50Nanos: percentile(durations, 0.50),
p95Nanos: percentile(durations, 0.95),
maxNanos: durations.max() ?? 0,
totalBytes: byteTotals[key] ?? 0
))
}
stats.sort { $0.p95Nanos > $1.p95Nanos }
return stats
}
private struct BucketKey: Hashable {
let category: ScarfMon.Category
let name: String
}
private func percentile(_ values: [UInt64], _ p: Double) -> UInt64 {
guard !values.isEmpty else { return 0 }
let sorted = values.sorted()
// Nearest-rank percentile good enough for triage and avoids
// interpolation edge cases on tiny samples.
let rank = max(1, min(sorted.count, Int((p * Double(sorted.count)).rounded(.up))))
return sorted[rank - 1]
}
}
/// Per-bucket stats surfaced to the in-app panel.
public struct ScarfMonStat: Sendable, Hashable, Codable {
public let category: ScarfMon.Category
public let name: String
public let kind: ScarfMon.Sample.Kind
public let count: Int
public let totalNanos: UInt64
public let p50Nanos: UInt64
public let p95Nanos: UInt64
public let maxNanos: UInt64
public let totalBytes: Int
public var totalMs: Double { Double(totalNanos) / 1_000_000.0 }
public var p50Ms: Double { Double(p50Nanos) / 1_000_000.0 }
public var p95Ms: Double { Double(p95Nanos) / 1_000_000.0 }
public var maxMs: Double { Double(maxNanos) / 1_000_000.0 }
}
// MARK: - JSON export
extension ScarfMonRingBuffer {
/// Compact JSON dump for the "Copy as JSON" button. One line per sample
/// keeps the output greppable when the user pastes it into a feedback
/// thread.
public func exportJSON() -> String {
struct Wire: Codable {
let category: String
let name: String
let kind: String
let timestampMs: Double
let durationNanos: UInt64
let count: Int
let bytes: Int?
}
let snapshot = samples()
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
var lines: [String] = []
lines.reserveCapacity(snapshot.count + 1)
lines.append("[")
for (i, s) in snapshot.enumerated() {
let wire = Wire(
category: s.category.rawValue,
name: s.name.description,
kind: s.kind.rawValue,
timestampMs: s.timestamp.timeIntervalSince1970 * 1000,
durationNanos: s.durationNanos,
count: s.count,
bytes: s.bytes
)
if let data = try? encoder.encode(wire),
let line = String(data: data, encoding: .utf8) {
let suffix = i == snapshot.count - 1 ? "" : ","
lines.append(" " + line + suffix)
}
}
lines.append("]")
return lines.joined(separator: "\n")
}
}
@@ -0,0 +1,54 @@
import Foundation
#if canImport(os)
import os
import os.signpost
#endif
/// Always-on signpost backend. Emits an `os_signpost` event per sample so
/// users can attach Instruments and see Scarf's instrumentation in the
/// Points of Interest track without a debug build.
///
/// `os_signpost` is elided by the runtime when no Instruments session is
/// recording the relevant subsystem the backend pays the cost of one
/// `OSLog` lookup per emit and nothing else.
public final class ScarfMonSignpostBackend: ScarfMonBackend, @unchecked Sendable {
#if canImport(os)
private let log: OSLog
public init(subsystem: String = "com.scarf.mon") {
self.log = OSLog(subsystem: subsystem, category: .pointsOfInterest)
}
public func record(_ sample: ScarfMon.Sample) {
// Signposts want a `StaticString` name we already require
// exactly that on the API. Format string is also static; the
// dynamic values flow as printf-style args, so no allocations
// for the event name itself.
switch sample.kind {
case .interval:
os_signpost(
.event,
log: log,
name: sample.name,
"category=%{public}@ ms=%{public}.3f count=%d",
sample.category.rawValue,
Double(sample.durationNanos) / 1_000_000.0,
sample.count
)
case .event:
os_signpost(
.event,
log: log,
name: sample.name,
"category=%{public}@ count=%d bytes=%d",
sample.category.rawValue,
sample.count,
sample.bytes ?? -1
)
}
}
#else
public init(subsystem: String = "com.scarf.mon") {}
public func record(_ sample: ScarfMon.Sample) { /* no-op off-Apple */ }
#endif
}
@@ -0,0 +1,202 @@
import Testing
import Foundation
@testable import ScarfCore
/// `.serialized` because every test that exercises the wrappers
/// (`measure`, `measureAsync`, `event`) installs and uninstalls the
/// process-wide backend set, and parallel tests would race on that
/// shared state. Tests of the ring buffer in isolation don't need
/// serialization, but the suite-level annotation is the simplest way
/// to keep the global-state ones honest.
@Suite(.serialized) struct ScarfMonTests {
/// Ring-buffer ordering fewer than capacity, no wrap.
@Test func ringBufferKeepsOrderBeforeWrap() {
let ring = ScarfMonRingBuffer(capacity: 8)
ring.record(.fixture(name: "a"))
ring.record(.fixture(name: "b"))
ring.record(.fixture(name: "c"))
let names = ring.samples().map { $0.name.description }
#expect(names == ["a", "b", "c"])
}
/// Ring-buffer wrap-around the oldest entries are dropped, the
/// newest entries appear at the end.
@Test func ringBufferWrapsCorrectly() {
let ring = ScarfMonRingBuffer(capacity: 4)
ring.record(.fixture(name: "a"))
ring.record(.fixture(name: "b"))
ring.record(.fixture(name: "c"))
ring.record(.fixture(name: "d"))
ring.record(.fixture(name: "e"))
ring.record(.fixture(name: "f"))
let names = ring.samples().map { $0.name.description }
#expect(names == ["c", "d", "e", "f"])
}
/// Reset clears the buffer and resets wrap state subsequent reads
/// see only post-reset entries.
@Test func ringBufferResetClearsState() {
let ring = ScarfMonRingBuffer(capacity: 4)
ring.record(.fixture(name: "a"))
ring.record(.fixture(name: "b"))
ring.record(.fixture(name: "c"))
ring.record(.fixture(name: "d"))
ring.record(.fixture(name: "e"))
ring.reset()
ring.record(.fixture(name: "x"))
let names = ring.samples().map { $0.name.description }
#expect(names == ["x"])
}
/// Summary aggregates per (category, name) and computes percentiles.
@Test func summaryAggregatesByCategoryAndName() {
let ring = ScarfMonRingBuffer(capacity: 16)
// Three "fast" intervals + two "slow" intervals on the same key.
for nanos: UInt64 in [1_000_000, 2_000_000, 3_000_000, 50_000_000, 100_000_000] {
ring.record(.fixture(name: "render", durationNanos: nanos))
}
let stats = ring.summary()
#expect(stats.count == 1)
let s = stats[0]
#expect(s.count == 5)
#expect(s.totalNanos == 156_000_000)
// Nearest-rank p95 with 5 samples picks the 5th sorted value
// (rank = ceil(5 * 0.95) = 5).
#expect(s.p95Nanos == 100_000_000)
// p50 with 5 samples picks the 3rd sorted value.
#expect(s.p50Nanos == 3_000_000)
}
/// Events accumulate count + bytes without contributing to interval
/// percentiles.
@Test func eventsAccumulateBytesNotDuration() {
let ring = ScarfMonRingBuffer(capacity: 16)
ring.record(ScarfMon.Sample(
category: .chatStream, name: "token", kind: .event,
timestamp: Date(), durationNanos: 0, count: 1, bytes: 256
))
ring.record(ScarfMon.Sample(
category: .chatStream, name: "token", kind: .event,
timestamp: Date(), durationNanos: 0, count: 1, bytes: 128
))
let stats = ring.summary()
#expect(stats.count == 1)
#expect(stats[0].count == 2)
#expect(stats[0].totalBytes == 384)
#expect(stats[0].p95Nanos == 0)
}
/// `isActive` flips off when the backend set is empty so the
/// hot-path short-circuit kicks in.
@Test func installEmptyBackendsDeactivates() {
ScarfMon.install([])
#expect(ScarfMon.isActive == false)
ScarfMon.install([ScarfMonRingBuffer(capacity: 4)])
#expect(ScarfMon.isActive == true)
ScarfMon.install([])
}
/// `measure` records a duration into every installed backend.
@Test func measureFlowsThroughInstalledBackends() throws {
let ring = ScarfMonRingBuffer(capacity: 8)
ScarfMon.install([ring])
defer { ScarfMon.install([]) }
let result: Int = ScarfMon.measure(.render, "unit") {
return 42
}
#expect(result == 42)
let samples = ring.samples()
#expect(samples.count == 1)
#expect(samples[0].kind == .interval)
#expect(samples[0].name.description == "unit")
}
/// `measureAsync` records duration even when the body throws the
/// `defer` in the wrapper must fire on rethrow.
@Test func measureAsyncRecordsDurationEvenOnThrow() async {
struct Boom: Error {}
let ring = ScarfMonRingBuffer(capacity: 8)
ScarfMon.install([ring])
defer { ScarfMon.install([]) }
await #expect(throws: Boom.self) {
try await ScarfMon.measureAsync(.chatStream, "throws") {
throw Boom()
}
}
let samples = ring.samples()
#expect(samples.count == 1)
#expect(samples[0].name.description == "throws")
}
/// `event(...)` records a count entry without taking a clock reading.
@Test func eventRecordsCountSample() {
let ring = ScarfMonRingBuffer(capacity: 8)
ScarfMon.install([ring])
defer { ScarfMon.install([]) }
ScarfMon.event(.chatStream, "token", count: 1, bytes: 32)
let samples = ring.samples()
#expect(samples.count == 1)
#expect(samples[0].kind == .event)
#expect(samples[0].count == 1)
#expect(samples[0].bytes == 32)
#expect(samples[0].durationNanos == 0)
}
/// Boot configure flips the active backend set without leaking
/// across tests.
@Test func bootConfigureModesInstallExpectedBackends() {
defer { ScarfMon.install([]) }
ScarfMonBoot.configure(mode: .off)
#expect(ScarfMon.currentBackends.isEmpty)
#expect(ScarfMonBoot.sharedRingBuffer == nil)
ScarfMonBoot.configure(mode: .signpostOnly)
#expect(ScarfMon.currentBackends.count == 1)
#expect(ScarfMonBoot.sharedRingBuffer == nil)
let ring = ScarfMonBoot.configure(mode: .full)
#expect(ring != nil)
#expect(ScarfMon.currentBackends.count == 3)
#expect(ScarfMonBoot.sharedRingBuffer === ring)
}
/// JSON export round-trips through `JSONSerialization` proves the
/// per-line format is valid JSON the user can paste into a feedback
/// tool.
@Test func exportJSONIsParseable() throws {
let ring = ScarfMonRingBuffer(capacity: 8)
ring.record(.fixture(name: "a", durationNanos: 1_500_000))
ring.record(ScarfMon.Sample(
category: .chatStream, name: "token", kind: .event,
timestamp: Date(), durationNanos: 0, count: 1, bytes: 64
))
let json = ring.exportJSON()
let data = json.data(using: .utf8)!
let parsed = try JSONSerialization.jsonObject(with: data, options: [])
let arr = parsed as? [[String: Any]]
#expect(arr?.count == 2)
}
}
private extension ScarfMon.Sample {
static func fixture(
category: ScarfMon.Category = .render,
name: StaticString,
durationNanos: UInt64 = 1_000_000
) -> ScarfMon.Sample {
ScarfMon.Sample(
category: category,
name: name,
kind: .interval,
timestamp: Date(),
durationNanos: durationNanos,
count: 1,
bytes: nil
)
}
}
+8
View File
@@ -14,6 +14,14 @@ struct ScarfIOSApp: App {
)
init() {
// ScarfMon open-source perf instrumentation. Reads the
// user-toggled mode from UserDefaults and installs the
// matching backend set. Default is `.signpostOnly` so
// Instruments-attached profiling works without users having
// to opt in. The Diagnostics Performance row in Settings
// flips this between off / signpost-only / full.
ScarfMonBoot.configure(mode: ScarfMonBoot.currentMode())
// Wire ScarfCore's transport factory to produce Citadel-backed
// `ServerTransport`s for every `.ssh` context. Without this,
// `ServerContext.makeTransport()` would fall back to the
@@ -0,0 +1,176 @@
import SwiftUI
import ScarfCore
import ScarfDesign
import UIKit
/// In-app Diagnostics Performance panel. Lets users flip the
/// ScarfMon backend mode, watch live aggregated stats from the ring
/// buffer, and copy a JSON dump to paste into a feedback thread.
///
/// Data never leaves the device unless the user taps "Copy as JSON"
/// no remote upload, no analytics. Same source-of-truth as the Mac
/// panel; both sides read `ScarfMonBoot.sharedRingBuffer`.
struct ScarfMonDiagnosticsView: View {
@State private var mode: ScarfMonBoot.Mode = ScarfMonBoot.currentMode()
@State private var stats: [ScarfMonStat] = []
@State private var copiedToast: Bool = false
/// Ring buffer is process-wide; we read from it on a 1s timer
/// while the panel is foregrounded. No live tail; this view only
/// re-aggregates the in-memory snapshot.
private let refreshInterval: TimeInterval = 1.0
var body: some View {
List {
modeSection
if mode == .full {
summarySection
actionsSection
} else {
Section {
Text("Switch to **Full** above to see live stats and copy a JSON dump. Off and Signpost-only modes don't keep an in-memory ring buffer.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Performance")
.navigationBarTitleDisplayMode(.inline)
.task(id: mode) {
// Re-aggregate while the view is visible. SwiftUI cancels
// this task on disappear, so the timer stops eating cycles
// when the user backs out.
guard mode == .full else { return }
while !Task.isCancelled {
refresh()
try? await Task.sleep(nanoseconds: UInt64(refreshInterval * 1_000_000_000))
}
}
.overlay(alignment: .top) {
if copiedToast {
Text("Copied to clipboard")
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.top, 8)
}
}
}
@ViewBuilder
private var modeSection: some View {
Section {
Picker("Mode", selection: $mode) {
Text("Off").tag(ScarfMonBoot.Mode.off)
Text("Signpost only").tag(ScarfMonBoot.Mode.signpostOnly)
Text("Full").tag(ScarfMonBoot.Mode.full)
}
.pickerStyle(.segmented)
.onChange(of: mode) { _, newValue in
ScarfMonBoot.setMode(newValue)
}
} header: {
Text("Recording mode")
} footer: {
Text("**Signpost only** is the default — Instruments can attach and read the Points of Interest track without any other overhead. **Full** also keeps a 4096-entry in-memory ring you can browse below and copy as JSON.")
.font(.caption)
}
}
@ViewBuilder
private var summarySection: some View {
Section {
if stats.isEmpty {
Text("No samples yet. Use the app for a few seconds and the table will populate.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(stats.prefix(20), id: \.self) { stat in
StatRow(stat: stat)
}
}
} header: {
Text("Top 20 by p95")
} footer: {
Text("Sorted by 95th-percentile duration. Counts include events; intervals are everything wrapped in `ScarfMon.measure`.")
.font(.caption)
}
}
@ViewBuilder
private var actionsSection: some View {
Section {
Button {
copyJSON()
} label: {
Label("Copy ring buffer as JSON", systemImage: "doc.on.clipboard")
}
Button(role: .destructive) {
ScarfMonBoot.sharedRingBuffer?.reset()
refresh()
} label: {
Label("Reset ring buffer", systemImage: "trash")
}
}
}
private func refresh() {
stats = ScarfMonBoot.sharedRingBuffer?.summary() ?? []
}
private func copyJSON() {
guard let json = ScarfMonBoot.sharedRingBuffer?.exportJSON() else { return }
UIPasteboard.general.string = json
copiedToast = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_500_000_000)
copiedToast = false
}
}
}
private struct StatRow: View {
let stat: ScarfMonStat
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(stat.name)
.font(.system(.body, design: .monospaced))
Spacer()
Text("p95 \(formatMs(stat.p95Ms))")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Text(stat.category.rawValue)
.font(.caption2)
.foregroundStyle(.tertiary)
Text("count \(stat.count)")
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
if stat.kind == .interval {
Text("p50 \(formatMs(stat.p50Ms))")
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
Text("max \(formatMs(stat.maxMs))")
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
}
if stat.totalBytes > 0 {
Text("bytes \(stat.totalBytes)")
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
}
}
}
}
private func formatMs(_ ms: Double) -> String {
if ms >= 100 { return String(format: "%.0fms", ms) }
if ms >= 1 { return String(format: "%.1fms", ms) }
return String(format: "%.2fms", ms)
}
}
@@ -45,6 +45,7 @@ struct SettingsView: View {
compressionSection
loggingSection
platformsSection
diagnosticsSection
rawYAMLToggleSection
}
}
@@ -257,6 +258,27 @@ struct SettingsView: View {
}
}
/// Diagnostics Performance entry point. Hidden from the
/// `quickEditsSection` flow because it doesn't touch config.yaml
/// it controls the in-process ScarfMon backend set instead. Off
/// by default users still get Instruments-visible signposts; flip
/// to Full when investigating a specific perf complaint.
@ViewBuilder
private var diagnosticsSection: some View {
Section {
NavigationLink {
ScarfMonDiagnosticsView()
} label: {
Label("Performance", systemImage: "speedometer")
}
} header: {
Text("Diagnostics")
} footer: {
Text("Performance instrumentation. Default mode emits Instruments signposts only; Full mode also keeps a 4096-entry in-memory ring you can copy as JSON.")
.font(.caption)
}
}
@ViewBuilder
private var rawYAMLToggleSection: some View {
Section {
@@ -0,0 +1,145 @@
import SwiftUI
import ScarfCore
import ScarfDesign
import AppKit
/// Mac equivalent of the iOS Diagnostics Performance panel. Embedded
/// inside the Settings Advanced tab so users investigating sluggish
/// behavior can flip ScarfMon to Full mode, watch live aggregated
/// stats, and copy the ring-buffer JSON for a feedback thread.
///
/// The panel is process-wide `ScarfMonBoot.sharedRingBuffer` is the
/// same instance the iOS panel reads. On Mac we use NSPasteboard
/// instead of UIPasteboard, otherwise the UI is the same shape.
struct ScarfMonDiagnosticsSection: View {
@State private var mode: ScarfMonBoot.Mode = ScarfMonBoot.currentMode()
@State private var stats: [ScarfMonStat] = []
@State private var copiedToast: Bool = false
private let refreshInterval: TimeInterval = 1.0
var body: some View {
SettingsSection(title: "Performance Diagnostics", icon: "speedometer") {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
modeRow
Text("Default mode emits Instruments signposts only — no measurable cost outside an active profiling session. Switch to Full to keep an in-memory ring buffer (4096 entries) you can inspect below or copy as JSON.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundMuted)
if mode == .full {
Divider()
summaryGrid
HStack {
Button("Copy as JSON") { copyJSON() }
Button("Reset", role: .destructive) {
ScarfMonBoot.sharedRingBuffer?.reset()
refresh()
}
if copiedToast {
Text("Copied")
.scarfStyle(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
.task(id: mode) {
guard mode == .full else { return }
while !Task.isCancelled {
refresh()
try? await Task.sleep(nanoseconds: UInt64(refreshInterval * 1_000_000_000))
}
}
}
@ViewBuilder
private var modeRow: some View {
HStack {
Text("Mode")
.frame(width: 80, alignment: .leading)
Picker("Mode", selection: $mode) {
Text("Off").tag(ScarfMonBoot.Mode.off)
Text("Signpost only").tag(ScarfMonBoot.Mode.signpostOnly)
Text("Full").tag(ScarfMonBoot.Mode.full)
}
.pickerStyle(.segmented)
.labelsHidden()
.onChange(of: mode) { _, newValue in
ScarfMonBoot.setMode(newValue)
}
}
}
@ViewBuilder
private var summaryGrid: some View {
if stats.isEmpty {
Text("No samples yet. Use the app for a few seconds.")
.scarfStyle(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 4) {
ForEach(stats.prefix(20), id: \.self) { stat in
statRow(stat)
}
}
}
}
@ViewBuilder
private func statRow(_ stat: ScarfMonStat) -> some View {
HStack(spacing: ScarfSpace.s3) {
Text(stat.category.rawValue)
.scarfStyle(.mono)
.foregroundStyle(.tertiary)
.frame(width: 96, alignment: .leading)
Text(stat.name)
.scarfStyle(.mono)
.frame(maxWidth: .infinity, alignment: .leading)
Text("count \(stat.count)")
.scarfStyle(.mono)
.foregroundStyle(.secondary)
.frame(width: 80, alignment: .trailing)
if stat.kind == .interval {
Text("p50 \(formatMs(stat.p50Ms))")
.scarfStyle(.mono)
.foregroundStyle(.secondary)
.frame(width: 90, alignment: .trailing)
Text("p95 \(formatMs(stat.p95Ms))")
.scarfStyle(.mono)
.foregroundStyle(.primary)
.frame(width: 90, alignment: .trailing)
Text("max \(formatMs(stat.maxMs))")
.scarfStyle(.mono)
.foregroundStyle(.secondary)
.frame(width: 90, alignment: .trailing)
} else if stat.totalBytes > 0 {
Text("bytes \(stat.totalBytes)")
.scarfStyle(.mono)
.foregroundStyle(.secondary)
.frame(width: 270, alignment: .trailing)
}
}
}
private func refresh() {
stats = ScarfMonBoot.sharedRingBuffer?.summary() ?? []
}
private func copyJSON() {
guard let json = ScarfMonBoot.sharedRingBuffer?.exportJSON() else { return }
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(json, forType: .string)
copiedToast = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_500_000_000)
copiedToast = false
}
}
private func formatMs(_ ms: Double) -> String {
if ms >= 100 { return String(format: "%.0fms", ms) }
if ms >= 1 { return String(format: "%.1fms", ms) }
return String(format: "%.2fms", ms)
}
}
@@ -108,6 +108,7 @@ struct AdvancedTab: View {
backupSection
pathsSection
ScarfMonDiagnosticsSection()
rawConfigSection
}
+8
View File
@@ -13,6 +13,14 @@ struct ScarfApp: App {
@State private var updater = UpdaterService()
init() {
// ScarfMon open-source perf instrumentation. Reads the
// user-toggled mode from UserDefaults and installs the
// matching backend set. Default `.signpostOnly` keeps
// Instruments-attached profiling working without users
// having to opt in. Settings Diagnostics Performance
// flips this between off / signpost-only / full.
ScarfMonBoot.configure(mode: ScarfMonBoot.currentMode())
let registry = ServerRegistry()
let live = ServerLiveStatusRegistry(registry: registry)
// Re-fan-out statuses whenever the user adds/removes/renames a