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() {
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user