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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 22:08:21 +02:00
parent 272da6a915
commit 6cf59c8a44
12 changed files with 1186 additions and 0 deletions
+8
View File
@@ -14,6 +14,14 @@ struct ScarfIOSApp: App {
)
init() {
// ScarfMon open-source perf instrumentation. Reads the
// user-toggled mode from UserDefaults and installs the
// matching backend set. Default is `.signpostOnly` so
// Instruments-attached profiling works without users having
// to opt in. The Diagnostics Performance row in Settings
// flips this between off / signpost-only / full.
ScarfMonBoot.configure(mode: ScarfMonBoot.currentMode())
// Wire ScarfCore's transport factory to produce Citadel-backed
// `ServerTransport`s for every `.ssh` context. Without this,
// `ServerContext.makeTransport()` would fall back to the
@@ -0,0 +1,176 @@
import SwiftUI
import ScarfCore
import ScarfDesign
import UIKit
/// In-app Diagnostics Performance panel. Lets users flip the
/// ScarfMon backend mode, watch live aggregated stats from the ring
/// buffer, and copy a JSON dump to paste into a feedback thread.
///
/// Data never leaves the device unless the user taps "Copy as JSON"
/// no remote upload, no analytics. Same source-of-truth as the Mac
/// panel; both sides read `ScarfMonBoot.sharedRingBuffer`.
struct ScarfMonDiagnosticsView: View {
@State private var mode: ScarfMonBoot.Mode = ScarfMonBoot.currentMode()
@State private var stats: [ScarfMonStat] = []
@State private var copiedToast: Bool = false
/// Ring buffer is process-wide; we read from it on a 1s timer
/// while the panel is foregrounded. No live tail; this view only
/// re-aggregates the in-memory snapshot.
private let refreshInterval: TimeInterval = 1.0
var body: some View {
List {
modeSection
if mode == .full {
summarySection
actionsSection
} else {
Section {
Text("Switch to **Full** above to see live stats and copy a JSON dump. Off and Signpost-only modes don't keep an in-memory ring buffer.")
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Performance")
.navigationBarTitleDisplayMode(.inline)
.task(id: mode) {
// Re-aggregate while the view is visible. SwiftUI cancels
// this task on disappear, so the timer stops eating cycles
// when the user backs out.
guard mode == .full else { return }
while !Task.isCancelled {
refresh()
try? await Task.sleep(nanoseconds: UInt64(refreshInterval * 1_000_000_000))
}
}
.overlay(alignment: .top) {
if copiedToast {
Text("Copied to clipboard")
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.top, 8)
}
}
}
@ViewBuilder
private var modeSection: some View {
Section {
Picker("Mode", selection: $mode) {
Text("Off").tag(ScarfMonBoot.Mode.off)
Text("Signpost only").tag(ScarfMonBoot.Mode.signpostOnly)
Text("Full").tag(ScarfMonBoot.Mode.full)
}
.pickerStyle(.segmented)
.onChange(of: mode) { _, newValue in
ScarfMonBoot.setMode(newValue)
}
} header: {
Text("Recording mode")
} footer: {
Text("**Signpost only** is the default — Instruments can attach and read the Points of Interest track without any other overhead. **Full** also keeps a 4096-entry in-memory ring you can browse below and copy as JSON.")
.font(.caption)
}
}
@ViewBuilder
private var summarySection: some View {
Section {
if stats.isEmpty {
Text("No samples yet. Use the app for a few seconds and the table will populate.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
ForEach(stats.prefix(20), id: \.self) { stat in
StatRow(stat: stat)
}
}
} header: {
Text("Top 20 by p95")
} footer: {
Text("Sorted by 95th-percentile duration. Counts include events; intervals are everything wrapped in `ScarfMon.measure`.")
.font(.caption)
}
}
@ViewBuilder
private var actionsSection: some View {
Section {
Button {
copyJSON()
} label: {
Label("Copy ring buffer as JSON", systemImage: "doc.on.clipboard")
}
Button(role: .destructive) {
ScarfMonBoot.sharedRingBuffer?.reset()
refresh()
} label: {
Label("Reset ring buffer", systemImage: "trash")
}
}
}
private func refresh() {
stats = ScarfMonBoot.sharedRingBuffer?.summary() ?? []
}
private func copyJSON() {
guard let json = ScarfMonBoot.sharedRingBuffer?.exportJSON() else { return }
UIPasteboard.general.string = json
copiedToast = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_500_000_000)
copiedToast = false
}
}
}
private struct StatRow: View {
let stat: ScarfMonStat
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(stat.name)
.font(.system(.body, design: .monospaced))
Spacer()
Text("p95 \(formatMs(stat.p95Ms))")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Text(stat.category.rawValue)
.font(.caption2)
.foregroundStyle(.tertiary)
Text("count \(stat.count)")
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
if stat.kind == .interval {
Text("p50 \(formatMs(stat.p50Ms))")
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
Text("max \(formatMs(stat.maxMs))")
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
}
if stat.totalBytes > 0 {
Text("bytes \(stat.totalBytes)")
.font(.caption2.monospaced())
.foregroundStyle(.tertiary)
}
}
}
}
private func formatMs(_ ms: Double) -> String {
if ms >= 100 { return String(format: "%.0fms", ms) }
if ms >= 1 { return String(format: "%.1fms", ms) }
return String(format: "%.2fms", ms)
}
}
@@ -45,6 +45,7 @@ struct SettingsView: View {
compressionSection
loggingSection
platformsSection
diagnosticsSection
rawYAMLToggleSection
}
}
@@ -257,6 +258,27 @@ struct SettingsView: View {
}
}
/// Diagnostics Performance entry point. Hidden from the
/// `quickEditsSection` flow because it doesn't touch config.yaml
/// it controls the in-process ScarfMon backend set instead. Off
/// by default users still get Instruments-visible signposts; flip
/// to Full when investigating a specific perf complaint.
@ViewBuilder
private var diagnosticsSection: some View {
Section {
NavigationLink {
ScarfMonDiagnosticsView()
} label: {
Label("Performance", systemImage: "speedometer")
}
} header: {
Text("Diagnostics")
} footer: {
Text("Performance instrumentation. Default mode emits Instruments signposts only; Full mode also keeps a 4096-entry in-memory ring you can copy as JSON.")
.font(.caption)
}
}
@ViewBuilder
private var rawYAMLToggleSection: some View {
Section {