From 685f95245b3d3df6a4c0a6f676aacdd4a7aeb52d Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Wed, 25 Feb 2026 19:30:46 +0100 Subject: [PATCH] Improved logging (#678) --- .../RustAvailabilityMonitorService.cs | 2 +- .../Tools/Services/RustService.OS.cs | 33 +++++++-- .../Tools/Services/RustService.cs | 3 + .../Tools/TerminalLogger.cs | 33 ++++++--- .../wwwroot/changelog/v26.3.1.md | 4 + runtime/src/dotnet.rs | 60 ++++++++++++++- runtime/src/environment.rs | 73 ++++++++++++++----- 7 files changed, 169 insertions(+), 39 deletions(-) diff --git a/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs b/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs index 40c22f0f..e4026fd3 100644 --- a/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustAvailabilityMonitorService.cs @@ -100,7 +100,7 @@ public sealed class RustAvailabilityMonitorService : BackgroundService, IMessage { try { - await this.rustService.ReadUserLanguage(); + await this.rustService.ReadUserLanguage(forceRequest: true); } catch (Exception e) { diff --git a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs index 215b3a02..0b81ccfe 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.OS.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.OS.cs @@ -2,15 +2,34 @@ public sealed partial class RustService { - public async Task ReadUserLanguage() + public async Task ReadUserLanguage(bool forceRequest = false) { - var response = await this.http.GetAsync("/system/language"); - if (!response.IsSuccessStatusCode) + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserLanguage)) + return this.cachedUserLanguage; + + await this.userLanguageLock.WaitAsync(); + try { - this.logger!.LogError($"Failed to read the user language from Rust: '{response.StatusCode}'"); - return string.Empty; + if (!forceRequest && !string.IsNullOrWhiteSpace(this.cachedUserLanguage)) + return this.cachedUserLanguage; + + var response = await this.http.GetAsync("/system/language"); + if (!response.IsSuccessStatusCode) + { + this.logger!.LogError($"Failed to read the user language from Rust: '{response.StatusCode}'"); + return string.Empty; + } + + var userLanguage = (await response.Content.ReadAsStringAsync()).Trim(); + if (string.IsNullOrWhiteSpace(userLanguage)) + return string.Empty; + + this.cachedUserLanguage = userLanguage; + return userLanguage; + } + finally + { + this.userLanguageLock.Release(); } - - return await response.Content.ReadAsStringAsync(); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Services/RustService.cs b/app/MindWork AI Studio/Tools/Services/RustService.cs index 5d4e2b08..9f495adb 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.cs @@ -17,6 +17,7 @@ public sealed partial class RustService : BackgroundService private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(RustService).Namespace, nameof(RustService)); private readonly HttpClient http; + private readonly SemaphoreSlim userLanguageLock = new(1, 1); private readonly JsonSerializerOptions jsonRustSerializerOptions = new() { @@ -29,6 +30,7 @@ public sealed partial class RustService : BackgroundService private ILogger? logger; private Encryption? encryptor; + private string? cachedUserLanguage; private readonly string apiPort; private readonly string certificateFingerprint; @@ -88,6 +90,7 @@ public sealed partial class RustService : BackgroundService public override void Dispose() { this.http.Dispose(); + this.userLanguageLock.Dispose(); base.Dispose(); } diff --git a/app/MindWork AI Studio/Tools/TerminalLogger.cs b/app/MindWork AI Studio/Tools/TerminalLogger.cs index f6801e8a..2c20d510 100644 --- a/app/MindWork AI Studio/Tools/TerminalLogger.cs +++ b/app/MindWork AI Studio/Tools/TerminalLogger.cs @@ -13,6 +13,12 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) public const string FORMATTER_NAME = "AI Studio Terminal Logger"; private static RustService? RUST_SERVICE; + + // ReSharper disable FieldCanBeMadeReadOnly.Local + // ReSharper disable ConvertToConstant.Local + private static bool LOG_TO_STDOUT = true; + // ReSharper restore ConvertToConstant.Local + // ReSharper restore FieldCanBeMadeReadOnly.Local // Buffer for early log events before the RustService is available: private static readonly ConcurrentQueue EARLY_LOG_BUFFER = new(); @@ -44,6 +50,10 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) bufferedEvent.StackTrace ); } + + #if !DEBUG + LOG_TO_STDOUT = false; + #endif } public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) @@ -56,19 +66,22 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) var stackTrace = logEntry.Exception?.StackTrace; var colorCode = GetColorForLogLevel(logEntry.LogLevel); - textWriter.Write($"[{colorCode}{timestamp}{ANSI_RESET}] {colorCode}{logLevel}{ANSI_RESET} [{category}] {colorCode}{message}{ANSI_RESET}"); - if (logEntry.Exception is not null) + if (LOG_TO_STDOUT) { - textWriter.Write($" {colorCode}Exception: {exceptionMessage}{ANSI_RESET}"); - if (stackTrace is not null) + textWriter.Write($"[{colorCode}{timestamp}{ANSI_RESET}] {colorCode}{logLevel}{ANSI_RESET} [{category}] {colorCode}{message}{ANSI_RESET}"); + if (logEntry.Exception is not null) { - textWriter.WriteLine(); - foreach (var line in stackTrace.Split('\n')) - textWriter.WriteLine($" {colorCode}{line.TrimEnd()}{ANSI_RESET}"); + textWriter.Write($" {colorCode}Exception: {exceptionMessage}{ANSI_RESET}"); + if (stackTrace is not null) + { + textWriter.WriteLine(); + foreach (var line in stackTrace.Split('\n')) + textWriter.WriteLine($" {colorCode}{line.TrimEnd()}{ANSI_RESET}"); + } } + else + textWriter.WriteLine(); } - else - textWriter.WriteLine(); // Send log event to Rust via API (fire-and-forget): if (RUST_SERVICE is not null) @@ -90,4 +103,4 @@ public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) _ => ANSI_RESET }; -} \ No newline at end of file +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md index d0cfc3a3..840e2947 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.3.1.md @@ -1 +1,5 @@ # v26.3.1, build 235 (2026-03-xx xx:xx UTC) +- Improved the performance by caching the OS language detection and requesting the user language only once per app start. +- Improved the user-language logging by limiting language detection logs to a single entry per app start. +- Improved the logbook readability by removing non-readable special characters from log entries. +- Improved the logbook reliability by significantly reducing duplicate log entries. \ No newline at end of file diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 338074a0..11cc3db5 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -33,6 +33,59 @@ static DOTNET_INITIALIZED: Lazy> = Lazy::new(|| Mutex::new(false)); pub const PID_FILE_NAME: &str = "mindwork_ai_studio.pid"; const SIDECAR_TYPE:SidecarType = SidecarType::Dotnet; +/// Removes ANSI escape sequences and non-printable control chars from stdout lines. +fn sanitize_stdout_line(line: &str) -> String { + let mut sanitized = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\u{1B}' { + if let Some(next) = chars.peek().copied() { + // CSI sequence: ESC [ ... + if next == '[' { + chars.next(); + for csi_char in chars.by_ref() { + let code = csi_char as u32; + if (0x40..=0x7E).contains(&code) { + break; + } + } + continue; + } + + // OSC sequence: ESC ] ... (BEL or ESC \) + if next == ']' { + chars.next(); + let mut previous_was_escape = false; + for osc_char in chars.by_ref() { + if osc_char == '\u{07}' { + break; + } + + if previous_was_escape && osc_char == '\\' { + break; + } + + previous_was_escape = osc_char == '\u{1B}'; + } + continue; + } + } + + // Unknown escape sequence: ignore the escape char itself. + continue; + } + + if ch.is_control() && ch != '\t' { + continue; + } + + sanitized.push(ch); + } + + sanitized +} + /// Returns the desired port of the .NET server. Our .NET app calls this endpoint to get /// the port where the .NET server should listen to. #[get("/system/dotnet/port")] @@ -111,11 +164,12 @@ pub fn start_dotnet_server() { // NOTE: Log events are sent via structured HTTP API calls. // This loop serves for fundamental output (e.g., startup errors). while let Some(CommandEvent::Stdout(line)) = rx.recv().await { - let line = line.trim_end(); - info!(Source = ".NET Server (stdout)"; "{line}"); + let line = sanitize_stdout_line(line.trim_end()); + if !line.trim().is_empty() { + info!(Source = ".NET Server (stdout)"; "{line}"); + } } }); - } /// This endpoint is called by the .NET server to signal that the server is ready. diff --git a/runtime/src/environment.rs b/runtime/src/environment.rs index c5f0a6c7..a1477269 100644 --- a/runtime/src/environment.rs +++ b/runtime/src/environment.rs @@ -15,6 +15,9 @@ pub static DATA_DIRECTORY: OnceLock = OnceLock::new(); /// The config directory where the application stores its configuration. pub static CONFIG_DIRECTORY: OnceLock = OnceLock::new(); +/// The user language cached once per runtime process. +static USER_LANGUAGE: OnceLock = OnceLock::new(); + /// Returns the config directory. #[get("/system/directories/config")] pub fn get_config_directory(_token: APIToken) -> String { @@ -87,12 +90,11 @@ fn normalize_locale_tag(locale: &str) -> Option { } #[cfg(target_os = "linux")] -fn read_locale_from_environment() -> Option { +fn read_locale_from_environment() -> Option<(String, &'static str)> { if let Ok(language) = env::var("LANGUAGE") { for candidate in language.split(':') { if let Some(locale) = normalize_locale_tag(candidate) { - info!("Detected user language from Linux environment variable 'LANGUAGE': '{}'.", locale); - return Some(locale); + return Some((locale, "LANGUAGE")); } } } @@ -100,8 +102,7 @@ fn read_locale_from_environment() -> Option { for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { if let Ok(value) = env::var(key) { if let Some(locale) = normalize_locale_tag(&value) { - info!("Detected user language from Linux environment variable '{}': '{}'.", key, locale); - return Some(locale); + return Some((locale, key)); } } } @@ -110,10 +111,35 @@ fn read_locale_from_environment() -> Option { } #[cfg(not(target_os = "linux"))] -fn read_locale_from_environment() -> Option { +fn read_locale_from_environment() -> Option<(String, &'static str)> { None } +enum LanguageDetectionSource { + SysLocale, + LinuxEnvironmentVariable(&'static str), + DefaultLanguage, +} + +fn detect_user_language() -> (String, LanguageDetectionSource) { + if let Some(locale) = get_locale() { + if let Some(normalized_locale) = normalize_locale_tag(&locale) { + return (normalized_locale, LanguageDetectionSource::SysLocale); + } + + warn!("sys-locale returned an unusable locale value: '{}'.", locale); + } + + if let Some((locale, key)) = read_locale_from_environment() { + return (locale, LanguageDetectionSource::LinuxEnvironmentVariable(key)); + } + + ( + String::from(DEFAULT_LANGUAGE), + LanguageDetectionSource::DefaultLanguage, + ) +} + #[cfg(test)] mod tests { use super::normalize_locale_tag; @@ -137,21 +163,32 @@ mod tests { #[get("/system/language")] pub fn read_user_language(_token: APIToken) -> String { - if let Some(locale) = get_locale() { - if let Some(normalized_locale) = normalize_locale_tag(&locale) { - info!("Detected user language from sys-locale: '{}'.", normalized_locale); - return normalized_locale; - } + USER_LANGUAGE + .get_or_init(|| { + let (user_language, source) = detect_user_language(); + match source { + LanguageDetectionSource::SysLocale => { + info!("Detected user language from sys-locale: '{}'.", user_language); + }, - warn!("sys-locale returned an unusable locale value: '{}'.", locale); - } + LanguageDetectionSource::LinuxEnvironmentVariable(key) => { + info!( + "Detected user language from Linux environment variable '{}': '{}'.", + key, user_language + ); + }, - if let Some(locale) = read_locale_from_environment() { - return locale; - } + LanguageDetectionSource::DefaultLanguage => { + warn!( + "Could not determine the system language. Use default '{}'.", + DEFAULT_LANGUAGE + ); + }, + } - warn!("Could not determine the system language. Use default '{}'.", DEFAULT_LANGUAGE); - String::from(DEFAULT_LANGUAGE) + user_language + }) + .clone() } #[get("/system/enterprise/config/id")]