mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
6cf59c8a44
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>
177 lines
6.2 KiB
Swift
177 lines
6.2 KiB
Swift
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)
|
|
}
|
|
}
|