feat: add swift-log app logging controls
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
- UI: add optional `ui.seamColor` accent to tint the Talk Mode side bubble (macOS/iOS/Android).
|
||||
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
||||
- Tests: add a Z.AI live test gate for smoke validation when keys are present.
|
||||
- macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs.
|
||||
|
||||
### Fixes
|
||||
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
|
||||
|
||||
@@ -15,6 +15,7 @@ let package = Package(
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"),
|
||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||
.package(path: "../shared/ClawdisKit"),
|
||||
.package(path: "../../Swabble"),
|
||||
@@ -45,6 +46,7 @@ let package = Package(
|
||||
.product(name: "SwabbleKit", package: "swabble"),
|
||||
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import AppKit
|
||||
import OSLog
|
||||
|
||||
let canvasWindowLogger = Logger(subsystem: "com.steipete.clawdis", category: "Canvas")
|
||||
|
||||
|
||||
@@ -32,5 +32,6 @@ let modelCatalogReloadKey = "clawdis.modelCatalogReload"
|
||||
let attachExistingGatewayOnlyKey = "clawdis.gateway.attachExistingOnly"
|
||||
let heartbeatsEnabledKey = "clawdis.heartbeatsEnabled"
|
||||
let debugFileLogEnabledKey = "clawdis.debug.fileLogEnabled"
|
||||
let appLogLevelKey = "clawdis.debug.appLogLevel"
|
||||
let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||
let cliHelperSearchPaths = ["/usr/local/bin", "/opt/homebrew/bin"]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import ClawdisProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
struct ControlHeartbeatEvent: Codable {
|
||||
|
||||
@@ -30,6 +30,7 @@ struct DebugSettings: View {
|
||||
@State private var pendingKill: DebugActions.PortListener?
|
||||
@AppStorage(attachExistingGatewayOnlyKey) private var attachExistingGatewayOnly: Bool = false
|
||||
@AppStorage(debugFileLogEnabledKey) private var diagnosticsFileLogEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
||||
|
||||
@State private var canvasSessionKey: String = "main"
|
||||
@State private var canvasStatus: String?
|
||||
@@ -232,13 +233,23 @@ struct DebugSettings: View {
|
||||
}
|
||||
|
||||
GridRow {
|
||||
self.gridLabel("Diagnostics")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.gridLabel("App logging")
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Picker("Verbosity", selection: self.$appLogLevelRaw) {
|
||||
ForEach(AppLogLevel.allCases) { level in
|
||||
Text(level.title).tag(level.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
.help("Controls the macOS app log verbosity.")
|
||||
|
||||
Toggle("Write rolling diagnostics log (JSONL)", isOn: self.$diagnosticsFileLogEnabled)
|
||||
.toggleStyle(.checkbox)
|
||||
.help(
|
||||
"Writes a rotating, local-only diagnostics log under ~/Library/Logs/Clawdis/. " +
|
||||
"Writes a rotating, local-only log under ~/Library/Logs/Clawdis/. " +
|
||||
"Enable only while actively debugging.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Open folder") {
|
||||
NSWorkspace.shared.open(DiagnosticsFileLog.logDirectoryURL())
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import AppKit
|
||||
import OSLog
|
||||
|
||||
/// Central manager for Dock icon visibility.
|
||||
/// Shows the Dock icon while any windows are visible, regardless of user preference.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
struct HealthSnapshot: Codable, Sendable {
|
||||
|
||||
229
apps/macos/Sources/Clawdis/Logging/ClawdisLogging.swift
Normal file
229
apps/macos/Sources/Clawdis/Logging/ClawdisLogging.swift
Normal file
@@ -0,0 +1,229 @@
|
||||
@_exported import Logging
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
typealias Logger = Logging.Logger
|
||||
|
||||
enum AppLogSettings {
|
||||
static let logLevelKey = appLogLevelKey
|
||||
|
||||
static func logLevel() -> Logger.Level {
|
||||
if let raw = UserDefaults.standard.string(forKey: self.logLevelKey),
|
||||
let level = Logger.Level(rawValue: raw)
|
||||
{
|
||||
return level
|
||||
}
|
||||
return .info
|
||||
}
|
||||
|
||||
static func setLogLevel(_ level: Logger.Level) {
|
||||
UserDefaults.standard.set(level.rawValue, forKey: self.logLevelKey)
|
||||
}
|
||||
|
||||
static func fileLoggingEnabled() -> Bool {
|
||||
UserDefaults.standard.bool(forKey: debugFileLogEnabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppLogLevel: String, CaseIterable, Identifiable {
|
||||
case trace
|
||||
case debug
|
||||
case info
|
||||
case notice
|
||||
case warning
|
||||
case error
|
||||
case critical
|
||||
|
||||
static let `default`: AppLogLevel = .info
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .trace: "Trace"
|
||||
case .debug: "Debug"
|
||||
case .info: "Info"
|
||||
case .notice: "Notice"
|
||||
case .warning: "Warning"
|
||||
case .error: "Error"
|
||||
case .critical: "Critical"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClawdisLogging {
|
||||
private static let labelSeparator = "::"
|
||||
|
||||
private static let didBootstrap: Void = {
|
||||
LoggingSystem.bootstrap { label in
|
||||
let (subsystem, category) = Self.parseLabel(label)
|
||||
let osHandler = ClawdisOSLogHandler(subsystem: subsystem, category: category)
|
||||
let fileHandler = ClawdisFileLogHandler(label: label)
|
||||
return MultiplexLogHandler([osHandler, fileHandler])
|
||||
}
|
||||
}()
|
||||
|
||||
static func bootstrapIfNeeded() {
|
||||
_ = Self.didBootstrap
|
||||
}
|
||||
|
||||
static func makeLabel(subsystem: String, category: String) -> String {
|
||||
"\(subsystem)\(Self.labelSeparator)\(category)"
|
||||
}
|
||||
|
||||
static func parseLabel(_ label: String) -> (String, String) {
|
||||
guard let range = label.range(of: Self.labelSeparator) else {
|
||||
return ("com.steipete.clawdis", label)
|
||||
}
|
||||
let subsystem = String(label[..<range.lowerBound])
|
||||
let category = String(label[range.upperBound...])
|
||||
return (subsystem, category)
|
||||
}
|
||||
}
|
||||
|
||||
extension Logging.Logger {
|
||||
init(subsystem: String, category: String) {
|
||||
ClawdisLogging.bootstrapIfNeeded()
|
||||
let label = ClawdisLogging.makeLabel(subsystem: subsystem, category: category)
|
||||
self.init(label: label)
|
||||
}
|
||||
}
|
||||
|
||||
extension Logger.Message.StringInterpolation {
|
||||
mutating func appendInterpolation<T>(_ value: T, privacy: OSLogPrivacy) {
|
||||
self.appendInterpolation(String(describing: value))
|
||||
}
|
||||
}
|
||||
|
||||
struct ClawdisOSLogHandler: LogHandler {
|
||||
private let osLogger: OSLog.Logger
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
var logLevel: Logger.Level {
|
||||
get { AppLogSettings.logLevel() }
|
||||
set { AppLogSettings.setLogLevel(newValue) }
|
||||
}
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.osLogger = OSLog.Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get { self.metadata[key] }
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
metadata: Logger.Metadata?,
|
||||
source: String,
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt)
|
||||
{
|
||||
let merged = Self.mergeMetadata(self.metadata, metadata)
|
||||
let rendered = Self.renderMessage(message, metadata: merged)
|
||||
self.osLogger.log(level: Self.osLogType(for: level), "\(rendered, privacy: .public)")
|
||||
}
|
||||
|
||||
private static func osLogType(for level: Logger.Level) -> OSLogType {
|
||||
switch level {
|
||||
case .trace, .debug:
|
||||
return .debug
|
||||
case .info, .notice:
|
||||
return .info
|
||||
case .warning:
|
||||
return .default
|
||||
case .error:
|
||||
return .error
|
||||
case .critical:
|
||||
return .fault
|
||||
}
|
||||
}
|
||||
|
||||
private static func mergeMetadata(
|
||||
_ base: Logger.Metadata,
|
||||
_ extra: Logger.Metadata?) -> Logger.Metadata
|
||||
{
|
||||
guard let extra else { return base }
|
||||
return base.merging(extra, uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
|
||||
private static func renderMessage(_ message: Logger.Message, metadata: Logger.Metadata) -> String {
|
||||
guard !metadata.isEmpty else { return message.description }
|
||||
let meta = metadata
|
||||
.sorted(by: { $0.key < $1.key })
|
||||
.map { "\($0.key)=\(stringify($0.value))" }
|
||||
.joined(separator: " ")
|
||||
return "\(message.description) [\(meta)]"
|
||||
}
|
||||
|
||||
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ClawdisFileLogHandler: LogHandler {
|
||||
let label: String
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
var logLevel: Logger.Level {
|
||||
get { AppLogSettings.logLevel() }
|
||||
set { AppLogSettings.setLogLevel(newValue) }
|
||||
}
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get { self.metadata[key] }
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
metadata: Logger.Metadata?,
|
||||
source: String,
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt)
|
||||
{
|
||||
guard AppLogSettings.fileLoggingEnabled() else { return }
|
||||
let (subsystem, category) = ClawdisLogging.parseLabel(self.label)
|
||||
var fields: [String: String] = [
|
||||
"subsystem": subsystem,
|
||||
"category": category,
|
||||
"level": level.rawValue,
|
||||
"source": source,
|
||||
"file": file,
|
||||
"function": function,
|
||||
"line": "\(line)",
|
||||
]
|
||||
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
|
||||
for (key, value) in merged {
|
||||
fields["meta.\(key)"] = Self.stringify(value)
|
||||
}
|
||||
DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields)
|
||||
}
|
||||
|
||||
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import Darwin
|
||||
import Foundation
|
||||
import MenuBarExtraAccess
|
||||
import Observation
|
||||
import OSLog
|
||||
import Security
|
||||
import SwiftUI
|
||||
|
||||
@@ -30,6 +29,7 @@ struct ClawdisApp: App {
|
||||
}
|
||||
|
||||
init() {
|
||||
ClawdisLogging.bootstrapIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ struct MenuContent: View {
|
||||
@State private var loadingMics = false
|
||||
@State private var browserControlEnabled = true
|
||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||
@AppStorage(appLogLevelKey) private var appLogLevelRaw: String = AppLogLevel.default.rawValue
|
||||
@AppStorage(debugFileLogEnabledKey) private var appFileLoggingEnabled: Bool = false
|
||||
|
||||
init(state: AppState, updater: UpdaterProviding?) {
|
||||
self._state = Bindable(wrappedValue: state)
|
||||
@@ -182,6 +184,20 @@ struct MenuContent: View {
|
||||
: "Verbose Logging (Main): Off",
|
||||
systemImage: "text.alignleft")
|
||||
}
|
||||
Menu("App Logging") {
|
||||
Picker("Verbosity", selection: self.$appLogLevelRaw) {
|
||||
ForEach(AppLogLevel.allCases) { level in
|
||||
Text(level.title).tag(level.rawValue)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: self.$appFileLoggingEnabled) {
|
||||
Label(
|
||||
self.appFileLoggingEnabled
|
||||
? "File Logging: On"
|
||||
: "File Logging: Off",
|
||||
systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
}
|
||||
Button {
|
||||
DebugActions.openSessionStore()
|
||||
} label: {
|
||||
|
||||
@@ -5,7 +5,6 @@ import ClawdisIPC
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
import Speech
|
||||
import UserNotifications
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
/// Lightweight, borderless panel that shows the current voice wake transcript near the menu bar.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Speech
|
||||
import SwabbleKit
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ read_when:
|
||||
# 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).
|
||||
Clawdis routes macOS app logs through swift-log (unified logging by default) and can write a local, rotating file log to disk when you need a durable capture.
|
||||
|
||||
- Enable: **Debug pane → Diagnostics log → “Write rolling diagnostics log (JSONL)”**
|
||||
- Verbosity: **Debug pane → Logs → App logging → Verbosity**
|
||||
- Enable: **Debug pane → Logs → App logging → “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”**
|
||||
- Clear: **Debug pane → Logs → App logging → “Clear”**
|
||||
|
||||
Notes:
|
||||
- This is **off by default**. Enable only while actively debugging.
|
||||
|
||||
Reference in New Issue
Block a user