From 4f5495330d7a474700d801bce921e6a7a9744762 Mon Sep 17 00:00:00 2001 From: Alex-Wengg Date: Thu, 24 Sep 2026 14:18:19 -0400 Subject: [PATCH] feat(logger): AppLogger.minimumLevel + mirrorsToConsole (#958) Debug builds mirror every AppLogger level to stderr, and several ASR debug lines carry recognised words. Host apps handling sensitive audio (e.g. clinical dictation) had no way to keep transcript text out of the Xcode console short of not running Debug builds on real recordings. - AppLogger.minimumLevel: levels below it are dropped from os_log and console alike (default .debug, unchanged behavior). - AppLogger.mirrorsToConsole: false routes everything to os_log only, i.e. the Release path, in Debug too (default true). - Both backed by an OSAllocatedUnfairLock rather than nonisolated(unsafe). - Routing factored into an internal route(for:) so it is unit-testable. - Level gains Comparable. --- Documentation/Architecture.md | 4 +- Sources/FluidAudio/Shared/AppLogger.swift | 77 ++++++++++++++----- .../Shared/AppLoggerTests.swift | 60 +++++++++++++++ 3 files changed, 119 insertions(+), 22 deletions(-) create mode 100644 Tests/FluidAudioTests/Shared/AppLoggerTests.swift 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 + } +}