Skip to content

Commit 7afcbf2

Browse files
taylorarndtclaude
andcommitted
feat: Add server metrics dashboard and beta update channel
- Server metrics tracking (requests, tokens, TTFT) - Metrics dashboard in ServerDashboardView - Beta update channel via Sparkle (opt-in in Settings) - Swift 6.2 concurrency fix for StreamMetricsTracker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3396d26 commit 7afcbf2

File tree

8 files changed

+354
-9
lines changed

8 files changed

+354
-9
lines changed

Perspective Server/FoundationModelsService.swift

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -379,18 +379,30 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
379379
await inferenceSemaphore.acquire()
380380
defer { Task { await inferenceSemaphore.release() } }
381381

382+
let inferenceStart = ContinuousClock.now
383+
382384
// Always use tools for file operations - this enables the model to create/edit files
383385
// even when the client doesn't explicitly request tool support
384386
#if canImport(FoundationModels)
385387
if #available(macOS 26.0, iOS 26.0, visionOS 26.0, *) {
386388
// Use native Foundation Models with built-in file tools
387-
return try await handleChatCompletionWithBuiltInTools(request)
389+
let result = try await handleChatCompletionWithBuiltInTools(request)
390+
let elapsed = ContinuousClock.now - inferenceStart
391+
let ttft = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
392+
let outputLen = result.choices.first?.message.content.count ?? 0
393+
await ServerMetrics.shared.recordInference(tokens: max(1, outputLen / 4), timeToFirstToken: ttft)
394+
return result
388395
}
389396
#endif
390397

391398
// Fallback for older systems: If tools are provided, run the tool-calling orchestration flow.
392399
if let tools = request.tools, !tools.isEmpty {
393-
return try await handleChatCompletionWithTools(request, tools: tools)
400+
let result = try await handleChatCompletionWithTools(request, tools: tools)
401+
let elapsed = ContinuousClock.now - inferenceStart
402+
let ttft = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
403+
let outputLen = result.choices.first?.message.content.count ?? 0
404+
await ServerMetrics.shared.recordInference(tokens: max(1, outputLen / 4), timeToFirstToken: ttft)
405+
return result
394406
}
395407

396408
// Build a context-aware prompt that fits within the model's context by summarizing older content when needed.
@@ -401,6 +413,10 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
401413
let output = try await generateText(model: request.model, prompt: prompt, temperature: request.temperature, maxTokens: request.max_tokens)
402414
logger.log("[chat] outputLen=\(output.count)")
403415

416+
let elapsed = ContinuousClock.now - inferenceStart
417+
let ttft = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
418+
await ServerMetrics.shared.recordInference(tokens: max(1, output.count / 4), timeToFirstToken: ttft)
419+
404420
let response = ChatCompletionResponse(
405421
id: "chatcmpl_" + UUID().uuidString.replacingOccurrences(of: "-", with: ""),
406422
object: "chat.completion",
@@ -436,15 +452,24 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
436452

437453
let resolvedID = sessionID ?? UUID().uuidString
438454

455+
// Metrics: track TTFT and token count via a Sendable tracker
456+
let tracker = StreamMetricsTracker()
457+
458+
let wrappedEmit: @Sendable (String) async -> Void = { delta in
459+
tracker.recordDelta(delta)
460+
await emit(delta)
461+
}
462+
439463
#if canImport(FoundationModels)
440464
if #available(macOS 26.0, iOS 26.0, visionOS 26.0, *) {
441465
try await streamWithFoundationModels(
442466
messages: messages,
443467
model: model,
444468
temperature: temperature,
445469
sessionID: resolvedID,
446-
emit: emit
470+
emit: wrappedEmit
447471
)
472+
await ServerMetrics.shared.recordInference(tokens: tracker.tokenCount, timeToFirstToken: tracker.ttft)
448473
return resolvedID
449474
}
450475
#endif
@@ -459,8 +484,9 @@ nonisolated final class FoundationModelsService: @unchecked Sendable {
459484
temperature: temperature, maxTokens: nil
460485
)
461486
for chunk in StreamChunker.chunk(text: output, size: 16) {
462-
await emit(chunk)
487+
await wrappedEmit(chunk)
463488
}
489+
await ServerMetrics.shared.recordInference(tokens: tracker.tokenCount, timeToFirstToken: tracker.ttft)
464490
return resolvedID
465491
}
466492

Perspective Server/LocalHTTPServer.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ actor LocalHTTPServer {
100100
let _ = await self.incrementActiveRequests()
101101
defer { Task { let _ = await self.decrementActiveRequests() } }
102102

103+
await ServerMetrics.shared.recordRequest()
104+
103105
// CORS preflight support
104106
if request.method == "OPTIONS" {
105107
return .normal(HTTPResponse(status: 204, headers: [
@@ -156,6 +158,7 @@ actor LocalHTTPServer {
156158
let serverPort = await self.port
157159
let activeReqs = await self.getActiveRequestCount()
158160
let inferenceStats = await FoundationModelsService.shared.inferenceSemaphore.stats
161+
let metricsSnap = await ServerMetrics.shared.snapshot
159162
let obj: [String: Any] = [
160163
"status": "ok",
161164
"running": serverIsRunning,
@@ -167,7 +170,15 @@ actor LocalHTTPServer {
167170
"max_concurrent": inferenceStats.maxConcurrent,
168171
"total_completed": inferenceStats.totalCompleted,
169172
"total_queued": inferenceStats.totalQueued,
170-
]
173+
],
174+
"metrics": [
175+
"total_requests": metricsSnap.totalRequests,
176+
"total_inference_requests": metricsSnap.totalInferenceRequests,
177+
"total_tokens": metricsSnap.totalTokens,
178+
"requests_last_5min": metricsSnap.requestsLast5Min,
179+
"avg_ttft_seconds": metricsSnap.averageTTFT ?? -1,
180+
"last_ttft_seconds": metricsSnap.lastTTFT ?? -1,
181+
] as [String: Any]
171182
]
172183
let data = (try? JSONSerialization.data(withJSONObject: obj, options: [])) ?? Data()
173184
let resp = HTTPResponse(status: 200, headers: [

Perspective Server/Perspective_ServerApp.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,17 @@ import AppKit
1212
import Sparkle
1313

1414
@MainActor
15-
class AppDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegate {
15+
class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate, SPUStandardUserDriverDelegate {
1616
private(set) var updaterController: SPUStandardUpdaterController!
1717

1818
override init() {
1919
super.init()
20-
self.updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: self)
20+
self.updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: self, userDriverDelegate: self)
21+
}
22+
23+
nonisolated func allowedChannels(for updater: SPUUpdater) -> Set<String> {
24+
let betaEnabled = UserDefaults.standard.bool(forKey: "enableBetaUpdates")
25+
return betaEnabled ? ["beta"] : []
2126
}
2227

2328
var supportsGentleScheduledUpdateReminders: Bool { true }

Perspective Server/ServerApp.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ final class ServerController: ObservableObject {
3838
func start() {
3939
errorMessage = nil
4040
Task {
41+
await ServerMetrics.shared.reset()
4142
await LocalHTTPServer.shared.setPort(port)
4243
await LocalHTTPServer.shared.start()
4344
// Wait a moment for the listener to become ready, then sync state

Perspective Server/ServerDashboardView.swift

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ struct ServerDashboardView: View {
1818
@State private var isTesting: Bool = false
1919
@State private var logMessages: [LogMessage] = []
2020
@State private var autoStart: Bool = true
21+
@State private var metrics: MetricsSnapshot = MetricsSnapshot(
22+
totalRequests: 0, totalInferenceRequests: 0,
23+
totalTokens: 0, requestsLast5Min: 0,
24+
averageTTFT: nil, lastTTFT: nil
25+
)
26+
@State private var metricsTimer: Timer? = nil
2127

2228
// Native system colors
2329
private let successColor = Color.green
@@ -32,7 +38,12 @@ struct ServerDashboardView: View {
3238

3339
// Main Status Card
3440
mainStatusCard
35-
41+
42+
// Server Stats Card
43+
if serverController.isRunning {
44+
serverStatsCard
45+
}
46+
3647
// Server Controls Card
3748
serverControlsCard
3849

@@ -67,6 +78,16 @@ struct ServerDashboardView: View {
6778
.frame(minWidth: 550, minHeight: 700)
6879
.onAppear {
6980
syncServerState()
81+
refreshMetrics()
82+
metricsTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
83+
Task { @MainActor in
84+
await refreshMetrics()
85+
}
86+
}
87+
}
88+
.onDisappear {
89+
metricsTimer?.invalidate()
90+
metricsTimer = nil
7091
}
7192
.animation(.easeInOut(duration: 0.25), value: showCopiedToast)
7293
}
@@ -747,8 +768,120 @@ struct ServerDashboardView: View {
747768
)
748769
}
749770

771+
// MARK: - Server Stats Card
772+
773+
private var serverStatsCard: some View {
774+
VStack(alignment: .leading, spacing: 16) {
775+
HStack {
776+
Label("Server Stats", systemImage: "chart.bar.fill")
777+
.font(.headline)
778+
.foregroundColor(.primary)
779+
Spacer()
780+
781+
Text("Updates every 2s")
782+
.font(.caption2)
783+
.foregroundColor(.secondary)
784+
}
785+
786+
Divider()
787+
788+
HStack(spacing: 12) {
789+
statBox(
790+
title: "Requests",
791+
value: "\(metrics.totalRequests)",
792+
subtitle: "\(metrics.requestsLast5Min) in last 5 min",
793+
icon: "arrow.up.arrow.down"
794+
)
795+
796+
statBox(
797+
title: "Tokens",
798+
value: formatNumber(metrics.totalTokens),
799+
subtitle: "generated",
800+
icon: "textformat.abc"
801+
)
802+
803+
statBox(
804+
title: "Avg TTFT",
805+
value: formatTTFT(metrics.averageTTFT),
806+
subtitle: "time to first token",
807+
icon: "clock"
808+
)
809+
810+
statBox(
811+
title: "Last TTFT",
812+
value: formatTTFT(metrics.lastTTFT),
813+
subtitle: "most recent",
814+
icon: "clock.arrow.circlepath"
815+
)
816+
}
817+
}
818+
.padding(20)
819+
.background(Color(NSColor.controlBackgroundColor))
820+
.cornerRadius(16)
821+
.overlay(
822+
RoundedRectangle(cornerRadius: 16)
823+
.stroke(Color(NSColor.separatorColor), lineWidth: 1)
824+
)
825+
.accessibilityElement(children: .contain)
826+
.accessibilityLabel("Server statistics")
827+
}
828+
829+
private func statBox(title: String, value: String, subtitle: String, icon: String) -> some View {
830+
VStack(spacing: 6) {
831+
Image(systemName: icon)
832+
.font(.title3)
833+
.foregroundColor(.accentColor)
834+
.accessibilityHidden(true)
835+
Text(value)
836+
.font(.system(size: 18, weight: .bold, design: .monospaced))
837+
.foregroundColor(.primary)
838+
.lineLimit(1)
839+
.minimumScaleFactor(0.6)
840+
Text(title)
841+
.font(.caption.weight(.medium))
842+
.foregroundColor(.primary)
843+
Text(subtitle)
844+
.font(.caption2)
845+
.foregroundColor(.secondary)
846+
.lineLimit(1)
847+
.minimumScaleFactor(0.7)
848+
}
849+
.frame(maxWidth: .infinity)
850+
.padding(.vertical, 14)
851+
.background(Color(NSColor.textBackgroundColor))
852+
.cornerRadius(10)
853+
.accessibilityElement(children: .combine)
854+
.accessibilityLabel("\(title): \(value), \(subtitle)")
855+
}
856+
857+
private func formatTTFT(_ value: Double?) -> String {
858+
guard let v = value, v >= 0 else { return "--" }
859+
if v < 1 {
860+
return String(format: "%.0fms", v * 1000)
861+
} else {
862+
return String(format: "%.1fs", v)
863+
}
864+
}
865+
866+
private func formatNumber(_ n: Int) -> String {
867+
if n >= 1_000_000 {
868+
return String(format: "%.1fM", Double(n) / 1_000_000)
869+
} else if n >= 1_000 {
870+
return String(format: "%.1fK", Double(n) / 1_000)
871+
}
872+
return "\(n)"
873+
}
874+
875+
@MainActor
876+
private func refreshMetrics() {
877+
Task {
878+
let snap = await ServerMetrics.shared.snapshot
879+
metrics = snap
880+
}
881+
}
882+
750883
// MARK: - Helper Methods
751-
884+
752885
private func syncServerState() {
753886
Task {
754887
let running = await LocalHTTPServer.shared.getIsRunning()

0 commit comments

Comments
 (0)