From b9007dc72152e91579160923a4195dd3c64fe7c3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 20:46:12 +0000 Subject: [PATCH] feat(mac): add rolling diagnostics log --- apps/macos/Sources/Clawdis/Constants.swift | 1 + .../macos/Sources/Clawdis/DebugSettings.swift | 22 +++ .../Sources/Clawdis/DiagnosticsFileLog.swift | 134 ++++++++++++++++++ .../Sources/Clawdis/VoiceWakeChime.swift | 5 + .../Sources/Clawdis/VoiceWakeRuntime.swift | 9 ++ docs/mac/logging.md | 18 ++- 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/DiagnosticsFileLog.swift diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index dece47f1f..63cfcef6b 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -28,5 +28,6 @@ let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogReloadKey = "clawdis.modelCatalogReload" let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly" let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled" +let debugFileLogEnabledKey = "clawdis.debug.fileLogEnabled" let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26 let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"] diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index 10f26edc5..a26ee0bf0 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -25,6 +25,7 @@ struct DebugSettings: View { @State private var pendingKill: DebugActions.PortListener? @AppStorage(webChatSwiftUIEnabledKey) private var webChatSwiftUIEnabled: Bool = false @AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false + @AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false @State private var canvasSessionKey: String = "main" @State private var canvasStatus: String? @@ -57,6 +58,27 @@ struct DebugSettings: View { .foregroundStyle(.secondary) .textSelection(.enabled) } + LabeledContent("Diagnostics log") { + VStack(alignment: .leading, spacing: 6) { + Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled) + .toggleStyle(.switch) + .help("Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. Enable only while actively debugging.") + HStack(spacing: 8) { + Button("Open folder") { + NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL()) + } + .buttonStyle(.bordered) + Button("Clear") { + Task { try? await DiagnosticsFileLog.shared.clear() } + } + .buttonStyle(.bordered) + } + Text(DiagnosticsFileLog.logFileURL().path) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } LabeledContent("Binary path") { Text(Bundle.main.bundlePath).font(.footnote) } LabeledContent("Gateway status") { HStack(spacing: 6) { diff --git a/apps/macos/Sources/Clawdis/DiagnosticsFileLog.swift b/apps/macos/Sources/Clawdis/DiagnosticsFileLog.swift new file mode 100644 index 000000000..8d2773538 --- /dev/null +++ b/apps/macos/Sources/Clawdis/DiagnosticsFileLog.swift @@ -0,0 +1,134 @@ +import Foundation + +actor DiagnosticsFileLog { + static let shared = DiagnosticsFileLog() + + private let fileName = "diagnostics.jsonl" + private let maxBytes: Int64 = 5 * 1024 * 1024 + private let maxBackups = 5 + + struct Record: Codable, Sendable { + let ts: String + let pid: Int32 + let category: String + let event: String + let fields: [String: String]? + } + + nonisolated static func isEnabled() -> Bool { + UserDefaults.standard.bool(forKey: debugFileLogEnabledKey) + } + + nonisolated static func logDirectoryURL() -> URL { + let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true) + return library + .appendingPathComponent("Logs", isDirectory: true) + .appendingPathComponent("Clawdis", isDirectory: true) + } + + nonisolated static func logFileURL() -> URL { + Self.logDirectoryURL().appendingPathComponent("diagnostics.jsonl", isDirectory: false) + } + + nonisolated func log(category: String, event: String, fields: [String: String]? = nil) { + guard Self.isEnabled() else { return } + let record = Record( + ts: ISO8601DateFormatter().string(from: Date()), + pid: ProcessInfo.processInfo.processIdentifier, + category: category, + event: event, + fields: fields) + Task { await self.write(record: record) } + } + + func clear() throws { + let fm = FileManager.default + let base = Self.logFileURL() + if fm.fileExists(atPath: base.path) { + try fm.removeItem(at: base) + } + for idx in 1...self.maxBackups { + let url = self.rotatedURL(index: idx) + if fm.fileExists(atPath: url.path) { + try fm.removeItem(at: url) + } + } + } + + private func write(record: Record) { + do { + try self.ensureDirectory() + try self.rotateIfNeeded() + try self.append(record: record) + } catch { + // Best-effort only: never crash or block the app on logging. + } + } + + private func ensureDirectory() throws { + try FileManager.default.createDirectory( + at: Self.logDirectoryURL(), + withIntermediateDirectories: true) + } + + private func append(record: Record) throws { + let url = Self.logFileURL() + let data = try JSONEncoder().encode(record) + var line = Data() + line.append(data) + line.append(0x0A) // newline + + let fm = FileManager.default + if !fm.fileExists(atPath: url.path) { + fm.createFile(atPath: url.path, contents: nil) + } + + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: line) + } + + private func rotateIfNeeded() throws { + let url = Self.logFileURL() + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? NSNumber + else { return } + + if size.int64Value < self.maxBytes { return } + + let fm = FileManager.default + + let oldest = self.rotatedURL(index: self.maxBackups) + if fm.fileExists(atPath: oldest.path) { + try fm.removeItem(at: oldest) + } + + if self.maxBackups > 1 { + for idx in stride(from: self.maxBackups - 1, through: 1, by: -1) { + let src = self.rotatedURL(index: idx) + let dst = self.rotatedURL(index: idx + 1) + if fm.fileExists(atPath: src.path) { + if fm.fileExists(atPath: dst.path) { + try fm.removeItem(at: dst) + } + try fm.moveItem(at: src, to: dst) + } + } + } + + let first = self.rotatedURL(index: 1) + if fm.fileExists(atPath: first.path) { + try fm.removeItem(at: first) + } + if fm.fileExists(atPath: url.path) { + try fm.moveItem(at: url, to: first) + } + } + + private func rotatedURL(index: Int) -> URL { + Self.logDirectoryURL().appendingPathComponent("\(self.fileName).\(index)", isDirectory: false) + } +} + diff --git a/apps/macos/Sources/Clawdis/VoiceWakeChime.swift b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift index 5f64e6b77..154bc9958 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeChime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeChime.swift @@ -51,6 +51,11 @@ enum VoiceWakeChimePlayer { } else { self.logger.log(level: .info, "chime play") } + DiagnosticsFileLog.shared.log(category: "voicewake.chime", event: "play", fields: [ + "reason": reason ?? "", + "chime": chime.displayLabel, + "systemName": chime.systemName ?? "", + ]) SoundEffectPlayer.play(sound) } diff --git a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift index 2d7f4591a..303f184aa 100644 --- a/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift +++ b/apps/macos/Sources/Clawdis/VoiceWakeRuntime.swift @@ -145,6 +145,10 @@ actor VoiceWakeRuntime { } self.logger.info("voicewake runtime started") + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "started", fields: [ + "locale": config.localeID ?? "", + "micID": config.micID ?? "", + ]) } catch { self.logger.error("voicewake runtime failed to start: \(error.localizedDescription, privacy: .public)") self.stop() @@ -167,6 +171,7 @@ actor VoiceWakeRuntime { self.currentConfig = nil self.listeningState = .idle self.logger.debug("voicewake runtime stopped") + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped") let token = self.overlayToken self.overlayToken = nil @@ -255,6 +260,7 @@ actor VoiceWakeRuntime { private func beginCapture(transcript: String, config: RuntimeConfig) async { self.listeningState = .voiceWake self.isCapturing = true + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture") let trimmed = Self.trimmedAfterTrigger(transcript, triggers: config.triggers) self.capturedTranscript = trimmed self.committedTranscript = "" @@ -321,6 +327,9 @@ actor VoiceWakeRuntime { self.captureTask = nil let finalTranscript = self.capturedTranscript.trimmingCharacters(in: .whitespacesAndNewlines) + DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "finalizeCapture", fields: [ + "finalLen": "\(finalTranscript.count)", + ]) // Stop further recognition events so we don't retrigger immediately with buffered audio. self.haltRecognitionPipeline() self.capturedTranscript = "" diff --git a/docs/mac/logging.md b/docs/mac/logging.md index 93a398094..e0a5926b0 100644 --- a/docs/mac/logging.md +++ b/docs/mac/logging.md @@ -1,9 +1,23 @@ --- -summary: "Enabling verbose macOS unified logging for Clawdis with privacy flags" +summary: "Clawdis logging: rolling diagnostics file log + unified log privacy flags" read_when: - Capturing macOS logs or investigating private data logging + - Debugging voice wake/session lifecycle issues --- -# Logging private data on macOS +# Logging (macOS) + +## Rolling diagnostics file log (Debug pane) +Clawdis can write a local, rotating diagnostics log to disk (useful when macOS unified logging is impractical during iterative repros). + +- Enable: **Debug pane → Diagnostics log → “Write rolling diagnostics log (JSONL)”** +- Location: `~/Library/Logs/Clawdis/diagnostics.jsonl` (rotates automatically; old files are suffixed with `.1`, `.2`, …) +- Clear: **Debug pane → Diagnostics log → “Clear”** + +Notes: +- This is **off by default**. Enable only while actively debugging. +- Treat the file as sensitive; don’t share it without review. + +## Unified logging private data on macOS Unified logging redacts most payloads unless a subsystem opts into `privacy -off`. Per Peter's write-up on macOS [logging privacy shenanigans](https://steipete.me/posts/2025/logging-privacy-shenanigans) (2025) this is controlled by a plist in `/Library/Preferences/Logging/Subsystems/` keyed by the subsystem name. Only new log entries pick up the flag, so enable it before reproducing an issue.