mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
+27
-17
@@ -149,27 +149,37 @@ public actor RemoteSQLiteBackend: HermesQueryBackend {
|
|||||||
// MARK: - Queries
|
// MARK: - Queries
|
||||||
|
|
||||||
public func query(_ sql: String, params: [SQLValue]) async throws -> [Row] {
|
public func query(_ sql: String, params: [SQLValue]) async throws -> [Row] {
|
||||||
guard isOpen else { throw BackendError.notOpen }
|
try await ScarfMon.measureAsync(.sqlite, "query") {
|
||||||
let inlined = SQLValueInliner.inline(sql, params: params)
|
guard isOpen else { throw BackendError.notOpen }
|
||||||
let dbPath = context.paths.stateDB
|
let inlined = SQLValueInliner.inline(sql, params: params)
|
||||||
let script = """
|
let dbPath = context.paths.stateDB
|
||||||
sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__'
|
let script = """
|
||||||
\(inlined)
|
sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__'
|
||||||
__SCARF_SQL__
|
\(inlined)
|
||||||
"""
|
__SCARF_SQL__
|
||||||
let result: ProcessResult
|
"""
|
||||||
do {
|
let result: ProcessResult
|
||||||
result = try await transport.streamScript(script, timeout: queryTimeout)
|
do {
|
||||||
} catch {
|
result = try await transport.streamScript(script, timeout: queryTimeout)
|
||||||
throw BackendError.transport(error.localizedDescription)
|
} 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]] {
|
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 }
|
guard isOpen else { throw BackendError.notOpen }
|
||||||
if statements.isEmpty { return [] }
|
if statements.isEmpty { return [] }
|
||||||
// Build one sqlite3 invocation with marker SELECTs separating
|
// 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
|
/// cross-platform we return a connect failure on non-macOS so
|
||||||
/// the file compiles everywhere.
|
/// the file compiles everywhere.
|
||||||
public static func run(script: String, context: ServerContext, timeout: TimeInterval = 30) async -> Outcome {
|
public static func run(script: String, context: ServerContext, timeout: TimeInterval = 30) async -> Outcome {
|
||||||
#if os(macOS)
|
await ScarfMon.measureAsync(.transport, "ssh.run") {
|
||||||
switch context.kind {
|
#if os(macOS)
|
||||||
case .local:
|
switch context.kind {
|
||||||
return await runLocally(script: script, timeout: timeout)
|
case .local:
|
||||||
case .ssh(let config):
|
return await runLocally(script: script, timeout: timeout)
|
||||||
return await runOverSSH(script: script, config: config, 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
|
// MARK: - SSH path
|
||||||
|
|||||||
@@ -179,6 +179,12 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
|||||||
/// untouched — same correctness guarantee as `SSHScriptRunner`'s
|
/// untouched — same correctness guarantee as `SSHScriptRunner`'s
|
||||||
/// stdin-pipe approach.
|
/// stdin-pipe approach.
|
||||||
public func streamScript(_ script: String, timeout: TimeInterval) async throws -> ProcessResult {
|
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 scriptBytes = Data(script.utf8)
|
||||||
let b64 = scriptBytes.base64EncodedString()
|
let b64 = scriptBytes.base64EncodedString()
|
||||||
// Prepend the same PATH guard that `asyncRunProcess` uses so
|
// Prepend the same PATH guard that `asyncRunProcess` uses so
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ struct ChatView: View {
|
|||||||
)!
|
)!
|
||||||
|
|
||||||
var body: some 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
|
connectionBanner
|
||||||
errorBanner
|
errorBanner
|
||||||
projectContextBar
|
projectContextBar
|
||||||
@@ -1254,6 +1259,12 @@ final class ChatController {
|
|||||||
/// assistant reply streams back as ACP notifications handled by
|
/// assistant reply streams back as ACP notifications handled by
|
||||||
/// the event task.
|
/// the event task.
|
||||||
func send() async {
|
func send() async {
|
||||||
|
await ScarfMon.measureAsync(.chatStream, "ios.send") {
|
||||||
|
await _sendImpl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _sendImpl() async {
|
||||||
guard state == .ready, let client else { return }
|
guard state == .ready, let client else { return }
|
||||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
// v0.12+ allows image-only sends — vision models accept "describe
|
// v0.12+ allows image-only sends — vision models accept "describe
|
||||||
@@ -1358,7 +1369,10 @@ final class ChatController {
|
|||||||
let stream = await client.events
|
let stream = await client.events
|
||||||
for await event in stream {
|
for await event in stream {
|
||||||
guard !Task.isCancelled else { break }
|
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
|
// Stream ended — if we weren't explicitly cancelled the
|
||||||
// channel died (EOF on stdin/out, write to dead pipe,
|
// 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`
|
/// to `session/load` if the remote doesn't support `session/resume`
|
||||||
/// (Hermes < 0.9.x).
|
/// (Hermes < 0.9.x).
|
||||||
func startResuming(sessionID: String) async {
|
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 }
|
guard await passModelPreflight(intent: .resume(sessionID: sessionID)) else { return }
|
||||||
await stop()
|
await stop()
|
||||||
vm.reset()
|
vm.reset()
|
||||||
@@ -1952,6 +1972,11 @@ private struct MessageBubble: View, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
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 {
|
if message.isToolResult {
|
||||||
ToolResultRow(message: message)
|
ToolResultRow(message: message)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - Config
|
// MARK: - Config
|
||||||
|
|
||||||
nonisolated func loadConfig() -> HermesConfig {
|
nonisolated func loadConfig() -> HermesConfig {
|
||||||
guard let content = readFile(context.paths.configYAML) else { return .empty }
|
ScarfMon.measure(.diskIO, "loadConfig") {
|
||||||
return parseConfig(content)
|
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
|
/// Error-surfacing config load. Used by Dashboard to show the user a
|
||||||
@@ -480,13 +482,15 @@ struct HermesFileService: Sendable {
|
|||||||
// MARK: - Cron
|
// MARK: - Cron
|
||||||
|
|
||||||
nonisolated func loadCronJobs() -> [HermesCronJob] {
|
nonisolated func loadCronJobs() -> [HermesCronJob] {
|
||||||
guard let data = readFileData(context.paths.cronJobsJSON) else { return [] }
|
ScarfMon.measure(.diskIO, "loadCronJobs") {
|
||||||
do {
|
guard let data = readFileData(context.paths.cronJobsJSON) else { return [] }
|
||||||
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
do {
|
||||||
return file.jobs
|
let file = try JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||||
} catch {
|
return file.jobs
|
||||||
print("[Scarf] Failed to decode cron jobs: \(error.localizedDescription)")
|
} catch {
|
||||||
return []
|
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] = []) {
|
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 {
|
guard let sessionId = richChatViewModel.sessionId else {
|
||||||
clearACPErrorState()
|
clearACPErrorState()
|
||||||
acpError = "No session ID — cannot send"
|
acpError = "No session ID — cannot send"
|
||||||
@@ -434,7 +435,9 @@ final class ChatViewModel {
|
|||||||
}
|
}
|
||||||
acpPromptTask = Task { @MainActor in
|
acpPromptTask = Task { @MainActor in
|
||||||
do {
|
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"
|
acpStatus = "Ready"
|
||||||
richChatViewModel.handleACPEvent(
|
richChatViewModel.handleACPEvent(
|
||||||
.promptComplete(sessionId: sessionId, response: result)
|
.promptComplete(sessionId: sessionId, response: result)
|
||||||
@@ -475,6 +478,7 @@ final class ChatViewModel {
|
|||||||
// MARK: - ACP Session Management
|
// MARK: - ACP Session Management
|
||||||
|
|
||||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||||
|
ScarfMon.event(.sessionLoad, "mac.startACPSession", count: 1)
|
||||||
stopACP()
|
stopACP()
|
||||||
clearACPErrorState()
|
clearACPErrorState()
|
||||||
|
|
||||||
@@ -655,7 +659,10 @@ final class ChatViewModel {
|
|||||||
let eventStream = await client.events
|
let eventStream = await client.events
|
||||||
for await event in eventStream {
|
for await event in eventStream {
|
||||||
guard !Task.isCancelled else { break }
|
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
|
self?.acpStatus = await client.statusMessage
|
||||||
}
|
}
|
||||||
// Stream ended — if we weren't cancelled, the connection died
|
// Stream ended — if we weren't cancelled, the connection died
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ struct ChatView: View {
|
|||||||
private var showInspector: Bool = true
|
private var showInspector: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
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 vm = viewModel
|
||||||
@Bindable var coord = coordinator
|
@Bindable var coord = coordinator
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ struct RichMessageBubble: View, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
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 {
|
if message.isUser {
|
||||||
userBubble
|
userBubble
|
||||||
} else if message.isAssistant {
|
} else if message.isAssistant {
|
||||||
|
|||||||
Reference in New Issue
Block a user