diff --git a/Documentation/Architecture.md b/Documentation/Architecture.md index b4cc78f0c..44248e764 100644 --- a/Documentation/Architecture.md +++ b/Documentation/Architecture.md @@ -426,7 +426,9 @@ characteristics in ways the diarizer's clustering wasn't tuned for. by anything that hits ANE in a hot loop (TDT decoder, VAD, segmentation, embedding). - `AppLogger` — category-based `os.Logger` wrapper. One logger per - component. + component. Host apps can set `AppLogger.minimumLevel` and + `AppLogger.mirrorsToConsole` at startup (e.g. to keep transcript-bearing + debug lines off stderr in Debug builds). - `MLModelConfigurationUtils` — default `MLComputeUnits` selection, CI overrides. - `ModelHub` (+ `Shared/Download/` primitives: HFClient, RetryPolicy, HFTreeLister, FileDownloader, ModelCache, ProgressReporter) — HuggingFace fetch + caching + token resolution. diff --git a/Sources/FluidAudio/Shared/AppLogger.swift b/Sources/FluidAudio/Shared/AppLogger.swift index df44699fa..35a4415cb 100644 --- a/Sources/FluidAudio/Shared/AppLogger.swift +++ b/Sources/FluidAudio/Shared/AppLogger.swift @@ -9,13 +9,55 @@ public struct AppLogger: Sendable { /// Note: Set this before creating any logger instances. nonisolated(unsafe) public static var defaultSubsystem: String = "com.fluidinference" - public enum Level: Int, Sendable { + public enum Level: Int, Sendable, Comparable { case debug = 0 case info case notice case warning case error case fault + + public static func < (lhs: Level, rhs: Level) -> Bool { + lhs.rawValue < rhs.rawValue + } + } + + struct Configuration: Sendable, Equatable { + var minimumLevel: Level = .debug + var mirrorsToConsole: Bool = true + } + + private static let configuration = OSAllocatedUnfairLock(initialState: Configuration()) + + /// Messages below this level are dropped from every sink (console and unified log). + /// Defaults to `.debug`. Set once at startup, e.g. `.warning` to keep ASR debug + /// lines that contain transcript text out of Debug-build output. + public static var minimumLevel: Level { + get { configuration.withLock { $0.minimumLevel } } + set { configuration.withLock { $0.minimumLevel = newValue } } + } + + /// When `false`, nothing is written to stderr; messages go to the unified log only + /// (message text is private there unless a debugger is attached). Defaults to `true`. + public static var mirrorsToConsole: Bool { + get { configuration.withLock { $0.mirrorsToConsole } } + set { configuration.withLock { $0.mirrorsToConsole = newValue } } + } + + struct Route: Equatable { + var osLog: Bool + var console: Bool + } + + static func route(for level: Level) -> Route { + let config = configuration.withLock { $0 } + guard level >= config.minimumLevel else { return Route(osLog: false, console: false) } + #if DEBUG + // Debug builds mirror everything to the console instead of os_log. + return config.mirrorsToConsole ? Route(osLog: false, console: true) : Route(osLog: true, console: false) + #else + return Route(osLog: true, console: config.mirrorsToConsole && level >= .warning) + #endif } private let osLogger: Logger @@ -62,27 +104,20 @@ public struct AppLogger: Sendable { // MARK: - Console Mirroring private func log(_ level: Level, _ message: String) { - #if DEBUG - logToConsole(level, message) - #else - switch level { - case .debug: - osLogger.debug("\(message)") - case .info: - osLogger.info("\(message)") - case .notice: - osLogger.notice("\(message)") - case .warning: - osLogger.warning("\(message)") - logToConsole(level, message) // Also log warnings to console in release - case .error: - osLogger.error("\(message)") - logToConsole(level, message) // Also log errors to console in release - case .fault: - osLogger.fault("\(message)") - logToConsole(level, message) // Also log faults to console in release + let route = AppLogger.route(for: level) + if route.osLog { + switch level { + case .debug: osLogger.debug("\(message)") + case .info: osLogger.info("\(message)") + case .notice: osLogger.notice("\(message)") + case .warning: osLogger.warning("\(message)") + case .error: osLogger.error("\(message)") + case .fault: osLogger.fault("\(message)") + } + } + if route.console { + logToConsole(level, message) } - #endif } private func logToConsole(_ level: Level, _ message: String) { diff --git a/Tests/FluidAudioTests/Shared/AppLoggerTests.swift b/Tests/FluidAudioTests/Shared/AppLoggerTests.swift new file mode 100644 index 000000000..a0f64ea41 --- /dev/null +++ b/Tests/FluidAudioTests/Shared/AppLoggerTests.swift @@ -0,0 +1,60 @@ +import XCTest + +@testable import FluidAudio + +final class AppLoggerTests: XCTestCase { + + private var savedMinimumLevel: AppLogger.Level = .debug + private var savedMirrorsToConsole = true + + override func setUp() { + super.setUp() + savedMinimumLevel = AppLogger.minimumLevel + savedMirrorsToConsole = AppLogger.mirrorsToConsole + } + + override func tearDown() { + AppLogger.minimumLevel = savedMinimumLevel + AppLogger.mirrorsToConsole = savedMirrorsToConsole + super.tearDown() + } + + func testLevelOrdering() { + XCTAssertLessThan(AppLogger.Level.debug, .info) + XCTAssertLessThan(AppLogger.Level.info, .notice) + XCTAssertLessThan(AppLogger.Level.notice, .warning) + XCTAssertLessThan(AppLogger.Level.warning, .error) + XCTAssertLessThan(AppLogger.Level.error, .fault) + } + + func testMinimumLevelDropsLowerLevelsFromAllSinks() { + AppLogger.minimumLevel = .warning + let dropped = AppLogger.Route(osLog: false, console: false) + XCTAssertEqual(AppLogger.route(for: .debug), dropped) + XCTAssertEqual(AppLogger.route(for: .info), dropped) + XCTAssertEqual(AppLogger.route(for: .notice), dropped) + XCTAssertNotEqual(AppLogger.route(for: .warning), dropped) + XCTAssertNotEqual(AppLogger.route(for: .fault), dropped) + } + + func testMirrorsToConsoleOffNeverWritesConsole() { + AppLogger.mirrorsToConsole = false + for level: AppLogger.Level in [.debug, .info, .notice, .warning, .error, .fault] { + let route = AppLogger.route(for: level) + XCTAssertFalse(route.console, "\(level) reached console") + XCTAssertTrue(route.osLog, "\(level) missing from os_log") + } + } + + func testDefaultRouting() { + AppLogger.minimumLevel = .debug + AppLogger.mirrorsToConsole = true + #if DEBUG + XCTAssertEqual(AppLogger.route(for: .debug), AppLogger.Route(osLog: false, console: true)) + XCTAssertEqual(AppLogger.route(for: .error), AppLogger.Route(osLog: false, console: true)) + #else + XCTAssertEqual(AppLogger.route(for: .debug), AppLogger.Route(osLog: true, console: false)) + XCTAssertEqual(AppLogger.route(for: .error), AppLogger.Route(osLog: true, console: true)) + #endif + } +}