diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMon.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMon.swift new file mode 100644 index 0000000..7682b36 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMon.swift @@ -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( + _ 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( + _ 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 + + 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) } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonBoot.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonBoot.swift new file mode 100644 index 0000000..896c3f8 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonBoot.swift @@ -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? +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonLoggerBackend.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonLoggerBackend.swift new file mode 100644 index 0000000..579cf5d --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonLoggerBackend.swift @@ -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 +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonRingBuffer.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonRingBuffer.swift new file mode 100644 index 0000000..108bd3b --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonRingBuffer.swift @@ -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.. 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.. [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") + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonSignpostBackend.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonSignpostBackend.swift new file mode 100644 index 0000000..c95d562 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Diagnostics/ScarfMonSignpostBackend.swift @@ -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 +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfMonTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfMonTests.swift new file mode 100644 index 0000000..93570b5 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfMonTests.swift @@ -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 + ) + } +} diff --git a/scarf/Scarf iOS/App/ScarfIOSApp.swift b/scarf/Scarf iOS/App/ScarfIOSApp.swift index 20977b9..5480b7d 100644 --- a/scarf/Scarf iOS/App/ScarfIOSApp.swift +++ b/scarf/Scarf iOS/App/ScarfIOSApp.swift @@ -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 diff --git a/scarf/Scarf iOS/Settings/ScarfMonDiagnosticsView.swift b/scarf/Scarf iOS/Settings/ScarfMonDiagnosticsView.swift new file mode 100644 index 0000000..7c8ad4f --- /dev/null +++ b/scarf/Scarf iOS/Settings/ScarfMonDiagnosticsView.swift @@ -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) + } +} diff --git a/scarf/Scarf iOS/Settings/SettingsView.swift b/scarf/Scarf iOS/Settings/SettingsView.swift index 8fb647f..fc88a25 100644 --- a/scarf/Scarf iOS/Settings/SettingsView.swift +++ b/scarf/Scarf iOS/Settings/SettingsView.swift @@ -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 { diff --git a/scarf/scarf/Features/Settings/Views/Components/ScarfMonDiagnosticsSection.swift b/scarf/scarf/Features/Settings/Views/Components/ScarfMonDiagnosticsSection.swift new file mode 100644 index 0000000..9b1a6b1 --- /dev/null +++ b/scarf/scarf/Features/Settings/Views/Components/ScarfMonDiagnosticsSection.swift @@ -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) + } +} diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift index 0f43597..d7109ab 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift @@ -108,6 +108,7 @@ struct AdvancedTab: View { backupSection pathsSection + ScarfMonDiagnosticsSection() rawConfigSection } diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index 8094cef..b2a9b5d 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -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