mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user