mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,14 @@ struct ScarfIOSApp: App {
|
|||||||
)
|
)
|
||||||
|
|
||||||
init() {
|
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
|
// Wire ScarfCore's transport factory to produce Citadel-backed
|
||||||
// `ServerTransport`s for every `.ssh` context. Without this,
|
// `ServerTransport`s for every `.ssh` context. Without this,
|
||||||
// `ServerContext.makeTransport()` would fall back to the
|
// `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
|
compressionSection
|
||||||
loggingSection
|
loggingSection
|
||||||
platformsSection
|
platformsSection
|
||||||
|
diagnosticsSection
|
||||||
rawYAMLToggleSection
|
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
|
@ViewBuilder
|
||||||
private var rawYAMLToggleSection: some View {
|
private var rawYAMLToggleSection: some View {
|
||||||
Section {
|
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
|
backupSection
|
||||||
pathsSection
|
pathsSection
|
||||||
|
ScarfMonDiagnosticsSection()
|
||||||
rawConfigSection
|
rawConfigSection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ struct ScarfApp: App {
|
|||||||
@State private var updater = UpdaterService()
|
@State private var updater = UpdaterService()
|
||||||
|
|
||||||
init() {
|
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 registry = ServerRegistry()
|
||||||
let live = ServerLiveStatusRegistry(registry: registry)
|
let live = ServerLiveStatusRegistry(registry: registry)
|
||||||
// Re-fan-out statuses whenever the user adds/removes/renames a
|
// Re-fan-out statuses whenever the user adds/removes/renames a
|
||||||
|
|||||||
Reference in New Issue
Block a user