diff --git a/app/MindWork AI Studio/Program.cs b/app/MindWork AI Studio/Program.cs index fa7927b1..e87dc0ca 100644 --- a/app/MindWork AI Studio/Program.cs +++ b/app/MindWork AI Studio/Program.cs @@ -185,6 +185,7 @@ internal sealed class Program var rustLogger = app.Services.GetRequiredService>(); rust.SetLogger(rustLogger); rust.SetEncryptor(encryption); + TerminalLogger.SetRustService(rust); RUST_SERVICE = rust; ENCRYPTION = encryption; diff --git a/app/MindWork AI Studio/Tools/Rust/LogEventRequest.cs b/app/MindWork AI Studio/Tools/Rust/LogEventRequest.cs new file mode 100644 index 00000000..c3f8fe89 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/LogEventRequest.cs @@ -0,0 +1,10 @@ +namespace AIStudio.Tools.Rust; + +public readonly record struct LogEventRequest( + string Timestamp, + string Level, + string Category, + string Message, + string? Exception, + string? StackTrace +); diff --git a/app/MindWork AI Studio/Tools/Rust/LogEventResponse.cs b/app/MindWork AI Studio/Tools/Rust/LogEventResponse.cs new file mode 100644 index 00000000..1c8c1055 --- /dev/null +++ b/app/MindWork AI Studio/Tools/Rust/LogEventResponse.cs @@ -0,0 +1,3 @@ +namespace AIStudio.Tools.Rust; + +public readonly record struct LogEventResponse(bool Success, string Issue); diff --git a/app/MindWork AI Studio/Tools/Services/RustService.Log.cs b/app/MindWork AI Studio/Tools/Services/RustService.Log.cs index e7b52438..b8542ddd 100644 --- a/app/MindWork AI Studio/Tools/Services/RustService.Log.cs +++ b/app/MindWork AI Studio/Tools/Services/RustService.Log.cs @@ -12,4 +12,28 @@ public sealed partial class RustService { return await this.http.GetFromJsonAsync("/log/paths", this.jsonRustSerializerOptions); } + + /// + /// Sends a log event to the Rust runtime. + /// + /// The timestamp of the log event. + /// The log level. + /// The category of the log event. + /// The log message. + /// Optional exception message. + /// Optional exception stack trace. + public void LogEvent(string timestamp, string level, string category, string message, string? exception = null, string? stackTrace = null) + { + try + { + // Fire-and-forget the log event to avoid blocking: + var request = new LogEventRequest(timestamp, level, category, message, exception, stackTrace); + _ = this.http.PostAsJsonAsync("/log/event", request, this.jsonRustSerializerOptions); + } + catch + { + Console.WriteLine("Failed to send log event to Rust service."); + // Ignore errors to avoid log loops + } + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/TerminalLogger.cs b/app/MindWork AI Studio/Tools/TerminalLogger.cs index ce87feb4..bafd5fa1 100644 --- a/app/MindWork AI Studio/Tools/TerminalLogger.cs +++ b/app/MindWork AI Studio/Tools/TerminalLogger.cs @@ -1,3 +1,8 @@ +using System.Collections.Concurrent; + +using AIStudio.Tools.Rust; +using AIStudio.Tools.Services; + using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Console; @@ -6,18 +11,83 @@ namespace AIStudio.Tools; public sealed class TerminalLogger() : ConsoleFormatter(FORMATTER_NAME) { public const string FORMATTER_NAME = "AI Studio Terminal Logger"; - + + private static RustService? RUST_SERVICE; + + // Buffer for early log events before the RustService is available: + private static readonly ConcurrentQueue EARLY_LOG_BUFFER = new(); + + // ANSI color codes for log levels: + private const string ANSI_RESET = "\x1b[0m"; + private const string ANSI_GRAY = "\x1b[90m"; // Trace, Debug + private const string ANSI_GREEN = "\x1b[32m"; // Information + private const string ANSI_YELLOW = "\x1b[33m"; // Warning + private const string ANSI_RED = "\x1b[91m"; // Error, Critical + + /// + /// Sets the Rust service for logging events and flushes any buffered early log events. + /// + /// The Rust service instance. + public static void SetRustService(RustService service) + { + RUST_SERVICE = service; + + // Flush all buffered early log events to Rust in the original order: + while (EARLY_LOG_BUFFER.TryDequeue(out var bufferedEvent)) + { + service.LogEvent( + bufferedEvent.Timestamp, + bufferedEvent.Level, + bufferedEvent.Category, + bufferedEvent.Message, + bufferedEvent.Exception, + bufferedEvent.StackTrace + ); + } + } + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) { var message = logEntry.Formatter(logEntry.State, logEntry.Exception); var timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff"); var logLevel = logEntry.LogLevel.ToString(); var category = logEntry.Category; - - textWriter.Write($"=> {timestamp} [{logLevel}] {category}: {message}"); + var exceptionMessage = logEntry.Exception?.Message; + 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) - textWriter.Write($" Exception was = {logEntry.Exception}"); + { + textWriter.Write($" {colorCode}Exception: {exceptionMessage}{ANSI_RESET}"); + if (stackTrace is not null) + { + textWriter.WriteLine(); + foreach (var line in stackTrace.Split('\n')) + textWriter.WriteLine($" {line.TrimEnd()}"); + } + } + else + textWriter.WriteLine(); + + // Send log event to Rust via API (fire-and-forget): + if (RUST_SERVICE is not null) + RUST_SERVICE.LogEvent(timestamp, logLevel, category, message, exceptionMessage, stackTrace); - textWriter.WriteLine(); + // Buffer early log events until the RustService is available: + else + EARLY_LOG_BUFFER.Enqueue(new LogEventRequest(timestamp, logLevel, category, message, exceptionMessage, stackTrace)); } + + private static string GetColorForLogLevel(LogLevel logLevel) => logLevel switch + { + LogLevel.Trace => ANSI_GRAY, + LogLevel.Debug => ANSI_GRAY, + LogLevel.Information => ANSI_GREEN, + LogLevel.Warning => ANSI_YELLOW, + LogLevel.Error => ANSI_RED, + LogLevel.Critical => ANSI_RED, + + _ => ANSI_RESET + }; } \ No newline at end of file diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md index b05871b0..a861ccb0 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.1.2.md @@ -1,2 +1,3 @@ # v26.1.2, build 232 (2026-01-xx xx:xx UTC) -- Added the option to hide specific assistants by configuration plugins. This is useful for enterprise environments in organizations. \ No newline at end of file +- Added the option to hide specific assistants by configuration plugins. This is useful for enterprise environments in organizations. +- Fixed a logging bug that prevented log events from being recorded in some cases. \ No newline at end of file diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 26b793f5..5d3f4d71 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use base64::Engine; use base64::prelude::BASE64_STANDARD; -use log::{debug, error, info, warn}; +use log::{error, info, warn}; use once_cell::sync::Lazy; use rocket::get; use tauri::api::process::{Command, CommandChild, CommandEvent}; @@ -101,43 +101,11 @@ pub fn start_dotnet_server() { *server_spawn_clone.lock().unwrap() = Some(child); // Log the output of the .NET 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 { - - // Remove newline characters from the end: let line = line.trim_end(); - - // Starts the line with '=>'? - if line.starts_with("=>") { - // Yes. This means that the line is a log message from the .NET server. - // The format is: ' [] : '. - // We try to parse this line and log it with the correct log level: - let line = line.trim_start_matches("=>").trim(); - let parts = line.split_once(": ").unwrap(); - let left_part = parts.0.trim(); - let message = parts.1.trim(); - let parts = left_part.split_once("] ").unwrap(); - let level = parts.0.split_once("[").unwrap().1.trim(); - let source = parts.1.trim(); - match level { - "Trace" => debug!(Source = ".NET Server", Comp = source; "{message}"), - "Debug" => debug!(Source = ".NET Server", Comp = source; "{message}"), - "Information" => info!(Source = ".NET Server", Comp = source; "{message}"), - "Warning" => warn!(Source = ".NET Server", Comp = source; "{message}"), - "Error" => error!(Source = ".NET Server", Comp = source; "{message}"), - "Critical" => error!(Source = ".NET Server", Comp = source; "{message}"), - - _ => error!(Source = ".NET Server", Comp = source; "{message} (unknown log level '{level}')"), - } - } else { - let lower_line = line.to_lowercase(); - if lower_line.contains("error") { - error!(Source = ".NET Server"; "{line}"); - } else if lower_line.contains("warning") { - warn!(Source = ".NET Server"; "{line}"); - } else { - info!(Source = ".NET Server"; "{line}"); - } - } + info!(Source = ".NET Server (stdout)"; "{line}"); } }); } diff --git a/runtime/src/log.rs b/runtime/src/log.rs index b626639a..a38d942c 100644 --- a/runtime/src/log.rs +++ b/runtime/src/log.rs @@ -6,11 +6,11 @@ use std::path::{absolute, PathBuf}; use std::sync::OnceLock; use flexi_logger::{DeferredNow, Duplicate, FileSpec, Logger, LoggerHandle}; use flexi_logger::writers::FileLogWriter; -use log::kv; +use log::{kv, Level}; use log::kv::{Key, Value, VisitSource}; -use rocket::get; +use rocket::{get, post}; use rocket::serde::json::Json; -use rocket::serde::Serialize; +use rocket::serde::{Deserialize, Serialize}; use crate::api_token::APIToken; use crate::environment::is_dev; @@ -65,6 +65,7 @@ pub fn init_logging() { .duplicate_to_stdout(Duplicate::All) .use_utc() .format_for_files(file_logger_format) + .set_palette("196;208;34;7;8".to_string()) // error, warn, info, debug, trace .format_for_stderr(terminal_colored_logger_format) .format_for_stdout(terminal_colored_logger_format) .start().expect("Cannot start logging"); @@ -231,9 +232,88 @@ pub async fn get_log_paths(_token: APIToken) -> Json { }) } +/// Converts a .NET log level string to a Rust log::Level. +fn parse_dotnet_log_level(level: &str) -> Level { + match level { + "Trace" | "Debug" => Level::Debug, + "Information" => Level::Info, + "Warning" => Level::Warn, + "Error" | "Critical" => Level::Error, + + _ => Level::Error, // Fallback for unknown levels + } +} + +/// Logs a message with the specified level, including optional exception and stack trace. +fn log_with_level( + level: Level, + category: &str, + message: &str, + exception: Option<&String>, + stack_trace: Option<&String> +) { + // Log the main message: + log::log!(level, Source = ".NET Server", Comp = category; "{message}"); + + // Log exception if present: + if let Some(ex) = exception { + log::log!(level, Source = ".NET Server", Comp = category; " Exception: {ex}"); + } + + // Log stack trace if present: + if let Some(stack_trace) = stack_trace { + for line in stack_trace.lines() { + log::log!(level, Source = ".NET Server", Comp = category; " {line}"); + } + } +} + +/// Logs an event from the .NET server. +#[post("/log/event", data = "")] +pub fn log_event(_token: APIToken, event: Json) -> Json { + let event = event.into_inner(); + let level = parse_dotnet_log_level(&event.level); + let message = event.message.as_str(); + let category = event.category.as_str(); + + log_with_level( + level, + category, + message, + event.exception.as_ref(), + event.stack_trace.as_ref() + ); + + // Log warning for unknown levels: + if !matches!(event.level.as_str(), "Trace" | "Debug" | "Information" | "Warning" | "Error" | "Critical") { + log::warn!(Source = ".NET Server", Comp = category; "Unknown log level '{}' received.", event.level); + } + + Json(LogEventResponse { success: true, issue: String::new() }) +} + /// The response the get log paths request. #[derive(Serialize)] pub struct LogPathsResponse { log_startup_path: String, log_app_path: String, +} + +/// A log event from the .NET server. +#[derive(Deserialize)] +#[allow(unused)] +pub struct LogEvent { + timestamp: String, + level: String, + category: String, + message: String, + exception: Option, + stack_trace: Option, +} + +/// The response to a log event request. +#[derive(Serialize)] +pub struct LogEventResponse { + success: bool, + issue: String, } \ No newline at end of file diff --git a/runtime/src/runtime_api.rs b/runtime/src/runtime_api.rs index 745b82c4..0b1cc8c1 100644 --- a/runtime/src/runtime_api.rs +++ b/runtime/src/runtime_api.rs @@ -86,6 +86,7 @@ pub fn start_runtime_api() { crate::environment::read_enterprise_env_config_server_url, crate::file_data::extract_data, crate::log::get_log_paths, + crate::log::log_event, ]) .ignite().await.unwrap() .launch().await.unwrap();