namespace Sonex.Client.Helpers; using Npgsql; using Sonex.Client.Dialogs; using Sonex.Data.Records; using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Runtime.ExceptionServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; public static class Logger { private static readonly object Sync = new(); private static readonly UTF8Encoding Utf8NoBom = new(false); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; private static string? _currentPage; private static string? _currentPageTitle; // Chroni przed zapętleniem, gdy wyjątek pojawi się podczas samego zapisu logu. [ThreadStatic] private static bool _isWriting; /// /// Logger nie wymaga ciezkiej inicjalizacji przy starcie. /// Plik JSON powstaje dopiero przy pierwszym faktycznie zapisanym bledzie. /// public static void Initialize() { } /// /// Zapisuje zwykly obsluzony wyjatek aplikacji i zwraca identyfikator wpisu. /// public static string Error( Exception exception, string source, string operation, IReadOnlyDictionary? context = null, string? message = null) { ArgumentNullException.ThrowIfNull(exception); return Write("Error", exception, source, operation, message, context, isUnhandled: false); } /// /// Zapisuje prosty komunikat bledu bez obiektu wyjatku. /// public static string Error( string message, string source, string operation, IReadOnlyDictionary? context = null) { return Write("Error", null, source, operation, message, context, isUnhandled: false); } /// /// Zapisuje blad krytyczny. Kazdy wpis trafia do osobnego pliku JSON. /// public static string Fatal( Exception exception, string source, string operation, bool isUnhandled = true, IReadOnlyDictionary? context = null, string? message = null) { ArgumentNullException.ThrowIfNull(exception); return Write("Fatal", exception, source, operation, message, context, isUnhandled); } /// /// Rozpoznaje oczekiwane bledy PostgreSQL, ktore w tej aplikacji sa traktowane jako komunikaty biznesowe. /// public static bool IsBusinessDatabaseException(Exception exception) { ArgumentNullException.ThrowIfNull(exception); if (exception is PostgresException) return true; return exception.InnerException != null && IsBusinessDatabaseException(exception.InnerException); } /// /// Buduje krotki komunikat dla uzytkownika z identyfikatorem bledu. /// public static string BuildUserMessage(string errorId) { return $"Wystapil nieoczekiwany blad aplikacji. Identyfikator bledu: {errorId}."; } /// /// Zwraca wspolna lokalizacje folderu logow, przeniesiona o jeden poziom wyzej niz katalog binarny aplikacji. /// public static string GetLogsDirectoryPath() { return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Logs")); } /// /// Zapamietuje aktualna strone nawigacji, aby byla automatycznie dopisywana do logow. /// public static void SetCurrentPage(string? pageName, string? pageTitle = null) { lock (Sync) { _currentPage = Clean(pageName); _currentPageTitle = Clean(pageTitle); } } /// /// Pozostawione dla zgodnosci z istniejacym kodem. /// Przy pojedynczych plikach JSON nie ma tu nic do oprozniania. /// public static void FlushAndClose() { } /// /// Globalny handler dla AppDomain.CurrentDomain.UnhandledException. /// public static void OnException(object? sender, System.UnhandledExceptionEventArgs e) { var exception = e.ExceptionObject as Exception ?? new InvalidOperationException(e.ExceptionObject?.ToString() ?? "Unknown unhandled exception."); Fatal( exception, source: sender?.GetType().Name ?? "AppDomain", operation: "AppDomain.CurrentDomain.UnhandledException"); } /// /// Globalny handler dla AppDomain.CurrentDomain.FirstChanceException. /// Jesli bedzie wlaczony, kazdy taki wpis zapisze osobny plik JSON. /// public static void OnException(object? sender, FirstChanceExceptionEventArgs e) { if (e.Exception == null) return; Write( "Warning", e.Exception, source: sender?.GetType().Name ?? "AppDomain", operation: "AppDomain.CurrentDomain.FirstChanceException", message: null, context: null, isUnhandled: false); } /// /// Globalny handler dla wyjatkow z nieobserwowanych taskow. /// public static void OnException(object? sender, UnobservedTaskExceptionEventArgs e) { Error( e.Exception, source: sender?.GetType().Name ?? nameof(TaskScheduler), operation: "TaskScheduler.UnobservedTaskException"); e.SetObserved(); } /// /// Globalny handler dla wyjatkow WinUI zglaszanych przez Application.UnhandledException. /// public static void OnException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e) { if (e.Exception == null) return; Fatal( e.Exception, source: sender.GetType().Name, operation: "Application.UnhandledException", message: e.Message); } /// /// Wspolna sciezka zapisu wszystkich wpisow. /// Kazdy wpis jest osobnym plikiem JSON w folderze Logs. /// private static string Write( string level, Exception? exception, string source, string operation, string? message, IReadOnlyDictionary? context, bool isUnhandled) { string errorId = CreateErrorId(); if (_isWriting) return errorId; _isWriting = true; try { DateTime occurredAt = DateTime.Now; AppLoggerRecord entry = BuildEntry( errorId, level, occurredAt, exception, source, operation, message, context, isUnhandled); string logDirectory = GetLogsDirectoryPath(); Directory.CreateDirectory(logDirectory); string filePath = Path.Combine( logDirectory, $"{occurredAt:yyyyMMdd-HHmmssfff}-{level.ToLowerInvariant()}-{errorId}.json"); string json = JsonSerializer.Serialize(entry, JsonOptions); File.WriteAllText(filePath, json, Utf8NoBom); } catch (Exception ex) { WriteFallback(ex); } finally { _isWriting = false; } return errorId; } /// /// Buduje obiekt, ktory potem zapisujemy bezposrednio do JSON. /// private static AppLoggerRecord BuildEntry( string errorId, string level, DateTime occurredAt, Exception? exception, string source, string operation, string? message, IReadOnlyDictionary? context, bool isUnhandled) { return new AppLoggerRecord { ErrorId = errorId, OccurredAt = occurredAt, Level = level, ApplicationName = AppSessionInfo.ApplicationName, AppVersion = AppInfo.InformationalVersion.Trim(), InstanceId = AppSessionInfo.InstanceId, UserName = GetUserName(), MachineName = Environment.MachineName, OsVersion = AppSessionInfo.OsVersion, CurrentPageTitle = GetCurrentPageTitle(), CurrentPage = GetCurrentPage(), Source = Clean(source) ?? source, Operation = Clean(operation) ?? operation, IsUnhandled = isUnhandled, Message = ResolveMessage(exception, message), ReportedMessage = GetReportedMessage(exception, message), ExceptionType = exception?.GetType().FullName, Exception = exception?.ToString(), Context = BuildContext(context), ExceptionData = BuildExceptionData(exception) }; } private static string ResolveMessage(Exception? exception, string? message) { string? text = Clean(message); if (!string.IsNullOrWhiteSpace(text)) return text; text = Clean(exception?.Message); if (!string.IsNullOrWhiteSpace(text)) return text; return exception?.GetType().FullName ?? "Application error"; } private static string? GetReportedMessage(Exception? exception, string? message) { string? cleanMessage = Clean(message); string? cleanExceptionMessage = Clean(exception?.Message); if (string.IsNullOrWhiteSpace(cleanMessage)) return null; if (string.Equals(cleanMessage, cleanExceptionMessage, StringComparison.Ordinal)) return null; return cleanMessage; } /// /// Zwraca dodatkowy kontekst przekazany z miejsca wywolania jako jeden plaski tekst. /// private static string? BuildContext(IReadOnlyDictionary? context) { if (context == null || context.Count == 0) return null; var builder = new StringBuilder(); foreach (var pair in context) { string? cleanValue = Clean(pair.Value); if (string.IsNullOrWhiteSpace(cleanValue)) continue; AppendPair(builder, pair.Key, cleanValue); } return builder.Length == 0 ? null : builder.ToString(); } /// /// Przepisuje exception.Data do tekstu JSON. /// Dzięki temu glowny dokument pozostaje plaski, a dane dodatkowe dalej sa uporzadkowane. /// private static string? BuildExceptionData(Exception? exception) { if (exception?.Data == null || exception.Data.Count == 0) return null; var result = new Dictionary(StringComparer.OrdinalIgnoreCase); int index = 0; foreach (DictionaryEntry item in exception.Data) { string key = Clean(item.Key?.ToString()) ?? $"Item{index}"; string? value = Clean(item.Value?.ToString()); if (string.IsNullOrWhiteSpace(value)) { index++; continue; } result[key] = value; index++; } return result.Count == 0 ? null : JsonSerializer.Serialize(result, JsonOptions); } private static string? GetCurrentPage() { lock (Sync) { return _currentPage; } } private static string? GetCurrentPageTitle() { lock (Sync) { return _currentPageTitle; } } private static string GetUserName() { string configured = Config.GetStringValue("USER_NAME", string.Empty); if (!string.IsNullOrWhiteSpace(configured)) return configured; return Environment.UserName; } private static void AppendPair(StringBuilder builder, string key, string value) { if (builder.Length > 0) builder.Append(" | "); builder.Append(Clean(key) ?? key); builder.Append('='); builder.Append(value); } private static string? Clean(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; return value .Replace("\r", " ") .Replace("\n", " ") .Trim(); } private static string CreateErrorId() { return Guid.NewGuid().ToString("N")[..12].ToUpperInvariant(); } /// /// Awaryjny zapis do prostego pliku JSON, gdy standardowy zapis logu sam rzuci wyjatkiem. /// private static void WriteFallback(Exception exception) { try { DateTime occurredAt = DateTime.Now; string logDirectory = GetLogsDirectoryPath(); Directory.CreateDirectory(logDirectory); string filePath = Path.Combine( logDirectory, $"{occurredAt:yyyyMMdd-HHmmssfff}-logger-fallback-{CreateErrorId()}.json"); var fallback = new { OccurredAt = AppLoggerRecord.FormatOccurredAt(occurredAt), Message = "Logger internal failure.", Exception = exception.ToString() }; string json = JsonSerializer.Serialize(fallback, JsonOptions); File.WriteAllText(filePath, json, Utf8NoBom); } catch { } } }