feat(scarfmon): chat + transport + sqlite measure points (Phase 2)

Wires ScarfMon measure points into the chat hot path on both targets,
plus the underlying SSH transport and remote-SQLite backend. All
callsites are surgical adds — no behavior change. Cost when ScarfMon
is in `.signpostOnly` (default) is one os_signpost emit per call,
elided by the runtime outside an Instruments session. In `.full` mode
the same callsites also push samples into the in-memory ring buffer.

Render counters (event):
- mac.ChatView.body / ios.ChatView.body — full transcript pane re-evals
- mac.RichMessageBubble.body / ios.MessageBubble.body — per-bubble re-evals

Stream + session (event + interval):
- mac.sendViaACP, mac.sendPrompt — user tap → first-byte
- mac.acpEvent, mac.handleACPEvent — per-event delivery + handle cost
- mac.startACPSession — session boot
- ios.send, ios.startResuming — same shape on iOS
- ios.acpEvent, ios.handleACPEvent — same per-event split on iOS

Transport + SQLite (interval, with byte counts on rows):
- ssh.streamScript (Citadel iOS) — SSH round-trip
- ssh.run (SSHScriptRunner Mac) — SSH round-trip
- sqlite.query, sqlite.queryBatch — Remote SQLite per-call
- sqlite.query.rows — row count + stdout bytes per query

Disk I/O (interval):
- diskIO.loadConfig — config.yaml read + parse
- diskIO.loadCronJobs — cron jobs.json decode

Body counters use the `let _: Void = ScarfMon.event(...)` pattern at
the top of `body` — works inside `@ViewBuilder` and fires on every
re-eval, which is exactly the signal we want.

To use:
  Mac: Settings → Advanced → Performance Diagnostics → Full
  iOS: Settings → Diagnostics → Performance → Full
Both panels auto-aggregate by (category, name), surface top 20 by
p95, and offer Copy as JSON for sharing in feedback threads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 22:18:06 +02:00
parent 6cf59c8a44
commit 3126c34561
8 changed files with 104 additions and 39 deletions
@@ -149,27 +149,37 @@ public actor RemoteSQLiteBackend: HermesQueryBackend {
// MARK: - Queries
public func query(_ sql: String, params: [SQLValue]) async throws -> [Row] {
guard isOpen else { throw BackendError.notOpen }
let inlined = SQLValueInliner.inline(sql, params: params)
let dbPath = context.paths.stateDB
let script = """
sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__'
\(inlined)
__SCARF_SQL__
"""
let result: ProcessResult
do {
result = try await transport.streamScript(script, timeout: queryTimeout)
} catch {
throw BackendError.transport(error.localizedDescription)
try await ScarfMon.measureAsync(.sqlite, "query") {
guard isOpen else { throw BackendError.notOpen }
let inlined = SQLValueInliner.inline(sql, params: params)
let dbPath = context.paths.stateDB
let script = """
sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__'
\(inlined)
__SCARF_SQL__
"""
let result: ProcessResult
do {
result = try await transport.streamScript(script, timeout: queryTimeout)
} catch {
throw BackendError.transport(error.localizedDescription)
}
if result.exitCode != 0 {
throw BackendError.sqlite(exitCode: result.exitCode, stderr: result.stderrString)
}
let rows = try parseSingleResultSet(result.stdoutString)
ScarfMon.event(.sqlite, "query.rows", count: rows.count, bytes: result.stdout.count)
return rows
}
if result.exitCode != 0 {
throw BackendError.sqlite(exitCode: result.exitCode, stderr: result.stderrString)
}
return try parseSingleResultSet(result.stdoutString)
}
public func queryBatch(_ statements: [(sql: String, params: [SQLValue])]) async throws -> [[Row]] {
try await ScarfMon.measureAsync(.sqlite, "queryBatch") {
try await _queryBatchImpl(statements)
}
}
private func _queryBatchImpl(_ statements: [(sql: String, params: [SQLValue])]) async throws -> [[Row]] {
guard isOpen else { throw BackendError.notOpen }
if statements.isEmpty { return [] }
// Build one sqlite3 invocation with marker SELECTs separating
@@ -46,16 +46,18 @@ public enum SSHScriptRunner {
/// cross-platform we return a connect failure on non-macOS so
/// the file compiles everywhere.
public static func run(script: String, context: ServerContext, timeout: TimeInterval = 30) async -> Outcome {
#if os(macOS)
switch context.kind {
case .local:
return await runLocally(script: script, timeout: timeout)
case .ssh(let config):
return await runOverSSH(script: script, config: config, timeout: timeout)
await ScarfMon.measureAsync(.transport, "ssh.run") {
#if os(macOS)
switch context.kind {
case .local:
return await runLocally(script: script, timeout: timeout)
case .ssh(let config):
return await runOverSSH(script: script, config: config, timeout: timeout)
}
#else
return .connectFailure("SSHScriptRunner is only available on macOS")
#endif
}
#else
return .connectFailure("SSHScriptRunner is only available on macOS")
#endif
}
// MARK: - SSH path
@@ -179,6 +179,12 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
/// untouched same correctness guarantee as `SSHScriptRunner`'s
/// stdin-pipe approach.
public func streamScript(_ script: String, timeout: TimeInterval) async throws -> ProcessResult {
try await ScarfMon.measureAsync(.transport, "ssh.streamScript") {
try await _streamScriptImpl(script, timeout: timeout)
}
}
private func _streamScriptImpl(_ script: String, timeout: TimeInterval) async throws -> ProcessResult {
let scriptBytes = Data(script.utf8)
let b64 = scriptBytes.base64EncodedString()
// Prepend the same PATH guard that `asyncRunProcess` uses so
+27 -2
View File
@@ -66,7 +66,12 @@ struct ChatView: View {
)!
var body: some View {
VStack(spacing: 0) {
// ScarfMon body-evaluation counter. Re-render churn during
// streaming is one of the load-bearing perf signals; rendering
// here costs ~one signpost emit + ring-buffer append (off the
// hot path otherwise).
let _: Void = ScarfMon.event(.chatRender, "ios.ChatView.body")
return VStack(spacing: 0) {
connectionBanner
errorBanner
projectContextBar
@@ -1254,6 +1259,12 @@ final class ChatController {
/// assistant reply streams back as ACP notifications handled by
/// the event task.
func send() async {
await ScarfMon.measureAsync(.chatStream, "ios.send") {
await _sendImpl()
}
}
private func _sendImpl() async {
guard state == .ready, let client else { return }
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
// v0.12+ allows image-only sends vision models accept "describe
@@ -1358,7 +1369,10 @@ final class ChatController {
let stream = await client.events
for await event in stream {
guard !Task.isCancelled else { break }
self?.vm.handleACPEvent(event)
ScarfMon.event(.chatStream, "ios.acpEvent", count: 1)
ScarfMon.measure(.chatStream, "ios.handleACPEvent") {
self?.vm.handleACPEvent(event)
}
}
// Stream ended if we weren't explicitly cancelled the
// channel died (EOF on stdin/out, write to dead pipe,
@@ -1788,6 +1802,12 @@ final class ChatController {
/// to `session/load` if the remote doesn't support `session/resume`
/// (Hermes < 0.9.x).
func startResuming(sessionID: String) async {
await ScarfMon.measureAsync(.sessionLoad, "ios.startResuming") {
await _startResumingImpl(sessionID: sessionID)
}
}
private func _startResumingImpl(sessionID: String) async {
guard await passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
await stop()
vm.reset()
@@ -1952,6 +1972,11 @@ private struct MessageBubble: View, Equatable {
}
var body: some View {
// Per-bubble render counter. The streaming bubble
// (`message.id == 0`) re-renders on every chunk; tracking the
// count here is what tells us if a slow chat is bottlenecked
// on body re-eval vs. event-loop delivery.
let _: Void = ScarfMon.event(.chatRender, "ios.MessageBubble.body")
if message.isToolResult {
ToolResultRow(message: message)
} else {
@@ -17,8 +17,10 @@ struct HermesFileService: Sendable {
// MARK: - Config
nonisolated func loadConfig() -> HermesConfig {
guard let content = readFile(context.paths.configYAML) else { return .empty }
return parseConfig(content)
ScarfMon.measure(.diskIO, "loadConfig") {
guard let content = readFile(context.paths.configYAML) else { return .empty }
return parseConfig(content)
}
}
/// Error-surfacing config load. Used by Dashboard to show the user a
@@ -480,13 +482,15 @@ struct HermesFileService: Sendable {
// MARK: - Cron
nonisolated func loadCronJobs() -> [HermesCronJob] {
guard let data = readFileData(context.paths.cronJobsJSON) else { return [] }
do {
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
return file.jobs
} catch {
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
return []
ScarfMon.measure(.diskIO, "loadCronJobs") {
guard let data = readFileData(context.paths.cronJobsJSON) else { return [] }
do {
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
return file.jobs
} catch {
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
return []
}
}
}
@@ -395,6 +395,7 @@ final class ChatViewModel {
}
private func sendViaACP(client: ACPClient, text: String, images: [ChatImageAttachment] = []) {
ScarfMon.event(.chatStream, "mac.sendViaACP", count: 1, bytes: text.utf8.count)
guard let sessionId = richChatViewModel.sessionId else {
clearACPErrorState()
acpError = "No session ID — cannot send"
@@ -434,7 +435,9 @@ final class ChatViewModel {
}
acpPromptTask = Task { @MainActor in
do {
let result = try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
let result = try await ScarfMon.measureAsync(.chatStream, "mac.sendPrompt") {
try await client.sendPrompt(sessionId: sessionId, text: wireText, images: images)
}
acpStatus = "Ready"
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: result)
@@ -475,6 +478,7 @@ final class ChatViewModel {
// MARK: - ACP Session Management
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
ScarfMon.event(.sessionLoad, "mac.startACPSession", count: 1)
stopACP()
clearACPErrorState()
@@ -655,7 +659,10 @@ final class ChatViewModel {
let eventStream = await client.events
for await event in eventStream {
guard !Task.isCancelled else { break }
self?.richChatViewModel.handleACPEvent(event)
ScarfMon.event(.chatStream, "mac.acpEvent", count: 1)
ScarfMon.measure(.chatStream, "mac.handleACPEvent") {
self?.richChatViewModel.handleACPEvent(event)
}
self?.acpStatus = await client.statusMessage
}
// Stream ended if we weren't cancelled, the connection died
@@ -17,6 +17,12 @@ struct ChatView: View {
private var showInspector: Bool = true
var body: some View {
// ScarfMon body-evaluation counter tracks how many times
// SwiftUI re-evaluates this view per second during streaming.
// High counts here usually mean state is fanning out further
// than necessary; pair with `mac.RichMessageBubble.body` to
// see whether the churn lives in the parent or the bubbles.
let _: Void = ScarfMon.event(.chatRender, "mac.ChatView.body")
@Bindable var vm = viewModel
@Bindable var coord = coordinator
VStack(spacing: 0) {
@@ -58,6 +58,11 @@ struct RichMessageBubble: View, Equatable {
}
var body: some View {
// Per-bubble render counter. The streaming bubble re-renders
// per token; cross-reference with `mac.ChatView.body` and
// `chatStream.handleACPEvent` to see whether streaming churn
// lives in the parent, the bubble, or the event handler.
let _: Void = ScarfMon.event(.chatRender, "mac.RichMessageBubble.body")
if message.isUser {
userBubble
} else if message.isAssistant {