From f7b48e13110fec7972cb457254578c369214c513 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Sun, 28 Jun 2026 15:00:00 +0200 Subject: [PATCH 1/6] feat: deliver real Exception instances to targets via LogException --- src/Runtime/Internal/LogRecord.cs | 15 ++++++ src/Runtime/Internal/UnityLogger.cs | 1 + src/Runtime/Services/Target.cs | 5 +- src/Runtime/Targets/FileTarget.cs | 5 ++ src/Runtime/Targets/InMemoryTarget.cs | 5 ++ src/Runtime/Types/LogLevel.cs | 6 +-- src/Runtime/ULogger.Static.cs | 11 +++- src/Runtime/ULogger.Unsorted.cs | 2 +- src/Runtime/ULogger.cs | 16 ++++++ src/Tests/ExceptionDeliveryTests.cs | 68 ++++++++++++++++++++++++ src/Tests/ExceptionDeliveryTests.cs.meta | 2 + src/Tests/FileTargetTests.cs | 11 ++++ src/Tests/InMemoryTargetTests.cs | 10 ++++ 13 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 src/Tests/ExceptionDeliveryTests.cs create mode 100644 src/Tests/ExceptionDeliveryTests.cs.meta diff --git a/src/Runtime/Internal/LogRecord.cs b/src/Runtime/Internal/LogRecord.cs index 8ac9df5..a04fb25 100644 --- a/src/Runtime/Internal/LogRecord.cs +++ b/src/Runtime/Internal/LogRecord.cs @@ -15,6 +15,7 @@ internal readonly struct LogRecord public readonly Object Context; public readonly DateTime LogTime; public readonly int ThreadId; + public readonly Exception Exception; public LogRecord(IReadOnlyList tags, LogLevel logLevel, string message, string stackTrace, Color color, Object context, DateTime logTime, int threadId) { @@ -26,6 +27,20 @@ public LogRecord(IReadOnlyList tags, LogLevel logLevel, string message, Context = context; LogTime = logTime; ThreadId = threadId; + Exception = null; + } + + public LogRecord(Exception exception) + { + Exception = exception; + Tags = null; + LogLevel = default; + Message = null; + StackTrace = null; + Color = default; + Context = null; + LogTime = default; + ThreadId = 0; } } } diff --git a/src/Runtime/Internal/UnityLogger.cs b/src/Runtime/Internal/UnityLogger.cs index e19ed7e..1107016 100644 --- a/src/Runtime/Internal/UnityLogger.cs +++ b/src/Runtime/Internal/UnityLogger.cs @@ -31,6 +31,7 @@ public UnityLogger(ILogHandler defaultLogger) public void LogException(Exception exception, Object context) { Default.LogException(exception, context); + ULogger.EnqueueException(exception); } public void LogFormat(LogType logType, Object context, string format, params object[] args) diff --git a/src/Runtime/Services/Target.cs b/src/Runtime/Services/Target.cs index f81d703..53d1f73 100644 --- a/src/Runtime/Services/Target.cs +++ b/src/Runtime/Services/Target.cs @@ -1,4 +1,5 @@ -using JetBrains.Annotations; +using System; +using JetBrains.Annotations; using static Appegy.UniLogger.LogLevelExtensions; namespace Appegy.UniLogger @@ -52,6 +53,8 @@ public bool GetStackTraceEnabled(LogLevel logLevel) protected internal abstract void Log(string message, [CanBeNull] string stackTrace); + protected internal abstract void LogException([NotNull] Exception exception); + protected internal virtual void Flush() { } diff --git a/src/Runtime/Targets/FileTarget.cs b/src/Runtime/Targets/FileTarget.cs index 15fb826..d7f0bf4 100644 --- a/src/Runtime/Targets/FileTarget.cs +++ b/src/Runtime/Targets/FileTarget.cs @@ -80,6 +80,11 @@ protected internal override void Log(string message, string stackTrace) } } + protected internal override void LogException(Exception exception) + { + Log(exception.ToString(), null); + } + protected internal override void Flush() { if (_disposed || _writer == null) return; diff --git a/src/Runtime/Targets/InMemoryTarget.cs b/src/Runtime/Targets/InMemoryTarget.cs index 0fa832c..e452f90 100644 --- a/src/Runtime/Targets/InMemoryTarget.cs +++ b/src/Runtime/Targets/InMemoryTarget.cs @@ -32,6 +32,11 @@ protected internal override void Log(string message, string stackTrace) } } + protected internal override void LogException(Exception exception) + { + Log(exception.ToString(), null); + } + public string GetContent() { lock (_gate) diff --git a/src/Runtime/Types/LogLevel.cs b/src/Runtime/Types/LogLevel.cs index b500968..05d1db9 100644 --- a/src/Runtime/Types/LogLevel.cs +++ b/src/Runtime/Types/LogLevel.cs @@ -11,7 +11,6 @@ public enum LogLevel Log = 1, Warning = 2, Error = 3, - Exception = 4, } public static class LogLevelExtensions @@ -26,7 +25,7 @@ public static LogLevel ConvertToLogLevel(this LogType original) LogType.Assert => LogLevel.Error, LogType.Warning => LogLevel.Warning, LogType.Log => LogLevel.Log, - LogType.Exception => LogLevel.Exception, + LogType.Exception => LogLevel.Error, _ => throw new ArgumentOutOfRangeException(nameof(original), original, null) }; } @@ -39,7 +38,6 @@ public static LogType ConvertToLogType(this LogLevel original) LogLevel.Log => LogType.Log, LogLevel.Warning => LogType.Warning, LogLevel.Error => LogType.Error, - LogLevel.Exception => LogType.Exception, _ => throw new ArgumentOutOfRangeException(nameof(original), original, null) }; } @@ -52,7 +50,6 @@ public static string ToShortString(this LogLevel logLevel) LogLevel.Log => "LG", LogLevel.Warning => "WN", LogLevel.Error => "ER", - LogLevel.Exception => "EX", _ => throw new ArgumentOutOfRangeException(nameof(logLevel), logLevel, null) }; } @@ -65,7 +62,6 @@ public static string ToMessageColor(this LogLevel logLevel) LogLevel.Log => "white", LogLevel.Warning => "orange", LogLevel.Error => "red", - LogLevel.Exception => "#FF5349FF", // (orange red) _ => throw new ArgumentOutOfRangeException(nameof(logLevel), logLevel, null) }; } diff --git a/src/Runtime/ULogger.Static.cs b/src/Runtime/ULogger.Static.cs index f9a006c..eadab85 100644 --- a/src/Runtime/ULogger.Static.cs +++ b/src/Runtime/ULogger.Static.cs @@ -137,6 +137,7 @@ public static void Terminate() private static void OnLogMessageReceivedThreaded(string condition, string stacktrace, LogType type) { + if (type == LogType.Exception) return; if (_pending.HasValue) { var pending = _pending.Value; @@ -155,8 +156,14 @@ private static void OnLogMessageReceivedThreaded(string condition, string stackt [HideInCallstack] private static void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e) { - if (Data == null) return; - Data.LogHandler.Default.LogException(e.Exception.InnerException ?? e.Exception); + LogException(e.Exception.InnerException ?? e.Exception); + } + + internal static void EnqueueException(Exception exception) + { + var data = Data; + if (data == null) return; + data.Dispatcher.Enqueue(new LogRecord(exception)); } #region GetLogger diff --git a/src/Runtime/ULogger.Unsorted.cs b/src/Runtime/ULogger.Unsorted.cs index 83e5e51..abd164a 100644 --- a/src/Runtime/ULogger.Unsorted.cs +++ b/src/Runtime/ULogger.Unsorted.cs @@ -351,7 +351,7 @@ public static void LogException(Exception exception, Object context = null) { if (Data != null) { - Data.LogHandler.Default.LogException(exception, context); + Data.LogHandler.LogException(exception, context); } else { diff --git a/src/Runtime/ULogger.cs b/src/Runtime/ULogger.cs index a2e7b6c..f322d8e 100644 --- a/src/Runtime/ULogger.cs +++ b/src/Runtime/ULogger.cs @@ -108,6 +108,22 @@ internal void BroadcastUnobservedLog(string message, string stacktrace, LogType internal static void Deliver(ULoggerData data, in LogRecord record) { + if (record.Exception != null) + { + foreach (var target in data.Targets) + { + try + { + target.LogException(record.Exception); + } + catch + { + // just don't fail on log + } + } + return; + } + foreach (var target in data.Targets) { if (!WillBeAllowedByFilterer(target.Filterer, record.LogLevel, record.Tags)) diff --git a/src/Tests/ExceptionDeliveryTests.cs b/src/Tests/ExceptionDeliveryTests.cs new file mode 100644 index 0000000..2de1081 --- /dev/null +++ b/src/Tests/ExceptionDeliveryTests.cs @@ -0,0 +1,68 @@ +using System; +using FluentAssertions; +using NUnit.Framework; + +namespace Appegy.UniLogger +{ + public class ExceptionDeliveryTests + { + private sealed class RecordingTarget : Target + { + public string LastMessage; + public Exception LastException; + + public RecordingTarget(Formatter formatter = null, Filterer filterer = null) + : base(formatter, filterer) + { + } + + protected internal override void Log(string message, string stackTrace) + { + LastMessage = message; + } + + protected internal override void LogException(Exception exception) + { + LastException = exception; + } + } + + [Test] + public void WhenExceptionDelivered_ThanTargetReceivesSameInstance() + { + var data = new ULoggerData(); + var target = new RecordingTarget(); + data.AddTarget(target); + + var exception = new InvalidOperationException("boom"); + ULogger.Deliver(data, new LogRecord(exception)); + + target.LastException.Should().BeSameAs(exception); + } + + [Test] + public void WhenExceptionDelivered_ThanTagFilterIsBypassed() + { + var data = new ULoggerData(); + var target = new RecordingTarget(filterer: new Filterer(false)); + data.AddTarget(target); + + var exception = new InvalidOperationException("boom"); + ULogger.Deliver(data, new LogRecord(exception)); + + target.LastException.Should().BeSameAs(exception); + } + + [Test] + public void WhenExceptionDelivered_ThanRegularLogIsNotTouched() + { + var data = new ULoggerData(); + var target = new RecordingTarget(); + data.AddTarget(target); + + ULogger.Deliver(data, new LogRecord(new Exception("boom"))); + + target.LastMessage.Should().BeNull(); + } + } +} diff --git a/src/Tests/ExceptionDeliveryTests.cs.meta b/src/Tests/ExceptionDeliveryTests.cs.meta new file mode 100644 index 0000000..7a32c4d --- /dev/null +++ b/src/Tests/ExceptionDeliveryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dc3f796f37cee7829a03ec9d6f4621e2 \ No newline at end of file diff --git a/src/Tests/FileTargetTests.cs b/src/Tests/FileTargetTests.cs index 51dc176..aeedfd8 100644 --- a/src/Tests/FileTargetTests.cs +++ b/src/Tests/FileTargetTests.cs @@ -51,6 +51,17 @@ public void WhenStackTraceProvided_ThanItIsWrittenAfterMessage() File.ReadAllText(target.CurrentFilePath).Should().Be("msg\ntrace\n"); } + [Test] + public void WhenExceptionLogged_ThanItIsWrittenToFile() + { + using var target = new FileTarget(Path.Combine(_directory, "game.log")); + + target.LogException(new InvalidOperationException("boom")); + target.Flush(); + + File.ReadAllText(target.CurrentFilePath).Should().Contain("boom"); + } + [Test] public void WhenSizeLimitExceeded_ThanLogRollsToNextFile() { diff --git a/src/Tests/InMemoryTargetTests.cs b/src/Tests/InMemoryTargetTests.cs index 28db48b..340d6a9 100644 --- a/src/Tests/InMemoryTargetTests.cs +++ b/src/Tests/InMemoryTargetTests.cs @@ -46,6 +46,16 @@ public void WhenSingleEntryLongerThanCapacity_ThanOnlyTailIsKept() target.GetContent().Should().Be("efg\n"); } + [Test] + public void WhenExceptionLogged_ThanItIsWrittenAsText() + { + var target = new InMemoryTarget(256); + + target.LogException(new System.InvalidOperationException("boom")); + + target.GetContent().Should().Contain("boom"); + } + [Test] public void WhenCleared_ThanContentIsEmpty() { From 501660e14e972c051313d74996d2c7bb8068651c Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Sun, 28 Jun 2026 21:00:00 +0200 Subject: [PATCH 2/6] feat: render exception stack traces via Unity formatter with call-site fallback --- src/Runtime/Internal/LogRecord.cs | 4 +- .../Internal/UnityExceptionFormatter.cs | 68 +++++++++++++++++++ .../Internal/UnityExceptionFormatter.cs.meta | 2 + src/Runtime/Services/Target.cs | 2 +- src/Runtime/Targets/FileTarget.cs | 4 +- src/Runtime/Targets/InMemoryTarget.cs | 4 +- src/Runtime/ULogger.Static.cs | 5 +- src/Runtime/ULogger.cs | 2 +- src/Tests/ExceptionDeliveryTests.cs | 13 ++-- src/Tests/FileTargetTests.cs | 4 +- src/Tests/InMemoryTargetTests.cs | 4 +- 11 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 src/Runtime/Internal/UnityExceptionFormatter.cs create mode 100644 src/Runtime/Internal/UnityExceptionFormatter.cs.meta diff --git a/src/Runtime/Internal/LogRecord.cs b/src/Runtime/Internal/LogRecord.cs index a04fb25..3d36857 100644 --- a/src/Runtime/Internal/LogRecord.cs +++ b/src/Runtime/Internal/LogRecord.cs @@ -30,12 +30,12 @@ public LogRecord(IReadOnlyList tags, LogLevel logLevel, string message, Exception = null; } - public LogRecord(Exception exception) + public LogRecord(Exception exception, string message) { Exception = exception; + Message = message; Tags = null; LogLevel = default; - Message = null; StackTrace = null; Color = default; Context = null; diff --git a/src/Runtime/Internal/UnityExceptionFormatter.cs b/src/Runtime/Internal/UnityExceptionFormatter.cs new file mode 100644 index 0000000..243a1d0 --- /dev/null +++ b/src/Runtime/Internal/UnityExceptionFormatter.cs @@ -0,0 +1,68 @@ +using System; +using System.Diagnostics; +using System.Reflection; +using UnityEngine; + +namespace Appegy.UniLogger +{ + internal static class UnityExceptionFormatter + { + private static readonly string[] InternalFramePrefixes = + { + "UnityEngine.StackTraceUtility", + "UnityEngine.Debug", + "UnityEngine.Logger", + "UnityEngine.UnityLogger", + "Appegy.UniLogger.ULogger", + "Appegy.UniLogger.ExtendedULogger", + "Appegy.UniLogger.UnityExceptionFormatter", + "Appegy.UniLogger.LogDispatcher", + }; + + private static readonly MethodInfo ExtractFormatted = typeof(StackTraceUtility).GetMethod( + "ExtractFormattedStackTrace", + BindingFlags.Static | BindingFlags.NonPublic, + null, new[] { typeof(StackTrace) }, null); + + public static string Format(Exception exception) + { + try + { + var stack = FormatTrace(new StackTrace(exception, true)); + if (string.IsNullOrEmpty(stack)) + { + stack = StripInternalFrames(StackTraceUtility.ExtractStackTrace()); + } + return exception.GetType().Name + ": " + exception.Message + "\n" + stack; + } + catch + { + return exception.ToString(); + } + } + + private static string FormatTrace(StackTrace trace) + { + if (ExtractFormatted == null || trace.FrameCount == 0) return null; + return ExtractFormatted.Invoke(null, new object[] { trace }) as string; + } + + private static string StripInternalFrames(string stack) + { + if (string.IsNullOrEmpty(stack)) return stack; + var lines = stack.Split('\n'); + var start = 0; + while (start < lines.Length && IsInternalFrame(lines[start])) start++; + return start == 0 ? stack : string.Join("\n", lines, start, lines.Length - start); + } + + private static bool IsInternalFrame(string line) + { + foreach (var prefix in InternalFramePrefixes) + { + if (line.StartsWith(prefix, StringComparison.Ordinal)) return true; + } + return false; + } + } +} diff --git a/src/Runtime/Internal/UnityExceptionFormatter.cs.meta b/src/Runtime/Internal/UnityExceptionFormatter.cs.meta new file mode 100644 index 0000000..43f6965 --- /dev/null +++ b/src/Runtime/Internal/UnityExceptionFormatter.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d78b0d966b6a7da38455bd1feb7e0ab \ No newline at end of file diff --git a/src/Runtime/Services/Target.cs b/src/Runtime/Services/Target.cs index 53d1f73..25d4229 100644 --- a/src/Runtime/Services/Target.cs +++ b/src/Runtime/Services/Target.cs @@ -53,7 +53,7 @@ public bool GetStackTraceEnabled(LogLevel logLevel) protected internal abstract void Log(string message, [CanBeNull] string stackTrace); - protected internal abstract void LogException([NotNull] Exception exception); + protected internal abstract void LogException([NotNull] Exception exception, string message); protected internal virtual void Flush() { diff --git a/src/Runtime/Targets/FileTarget.cs b/src/Runtime/Targets/FileTarget.cs index d7f0bf4..3f4c229 100644 --- a/src/Runtime/Targets/FileTarget.cs +++ b/src/Runtime/Targets/FileTarget.cs @@ -80,9 +80,9 @@ protected internal override void Log(string message, string stackTrace) } } - protected internal override void LogException(Exception exception) + protected internal override void LogException(Exception exception, string message) { - Log(exception.ToString(), null); + Log(message, null); } protected internal override void Flush() diff --git a/src/Runtime/Targets/InMemoryTarget.cs b/src/Runtime/Targets/InMemoryTarget.cs index e452f90..fdf25de 100644 --- a/src/Runtime/Targets/InMemoryTarget.cs +++ b/src/Runtime/Targets/InMemoryTarget.cs @@ -32,9 +32,9 @@ protected internal override void Log(string message, string stackTrace) } } - protected internal override void LogException(Exception exception) + protected internal override void LogException(Exception exception, string message) { - Log(exception.ToString(), null); + Log(message, null); } public string GetContent() diff --git a/src/Runtime/ULogger.Static.cs b/src/Runtime/ULogger.Static.cs index eadab85..acf6500 100644 --- a/src/Runtime/ULogger.Static.cs +++ b/src/Runtime/ULogger.Static.cs @@ -162,8 +162,9 @@ private static void OnUnobservedTaskException(object sender, UnobservedTaskExcep internal static void EnqueueException(Exception exception) { var data = Data; - if (data == null) return; - data.Dispatcher.Enqueue(new LogRecord(exception)); + if (data == null || data.Targets.Length == 0) return; + var message = UnityExceptionFormatter.Format(exception); + data.Dispatcher.Enqueue(new LogRecord(exception, message)); } #region GetLogger diff --git a/src/Runtime/ULogger.cs b/src/Runtime/ULogger.cs index f322d8e..6ac256f 100644 --- a/src/Runtime/ULogger.cs +++ b/src/Runtime/ULogger.cs @@ -114,7 +114,7 @@ internal static void Deliver(ULoggerData data, in LogRecord record) { try { - target.LogException(record.Exception); + target.LogException(record.Exception, record.Message); } catch { diff --git a/src/Tests/ExceptionDeliveryTests.cs b/src/Tests/ExceptionDeliveryTests.cs index 2de1081..d99114d 100644 --- a/src/Tests/ExceptionDeliveryTests.cs +++ b/src/Tests/ExceptionDeliveryTests.cs @@ -10,6 +10,7 @@ private sealed class RecordingTarget : Target { public string LastMessage; public Exception LastException; + public string LastExceptionMessage; public RecordingTarget(Formatter formatter = null, Filterer filterer = null) : base(formatter, filterer) @@ -21,23 +22,25 @@ protected internal override void Log(string message, string stackTrace) LastMessage = message; } - protected internal override void LogException(Exception exception) + protected internal override void LogException(Exception exception, string message) { LastException = exception; + LastExceptionMessage = message; } } [Test] - public void WhenExceptionDelivered_ThanTargetReceivesSameInstance() + public void WhenExceptionDelivered_ThanTargetReceivesSameInstanceAndMessage() { var data = new ULoggerData(); var target = new RecordingTarget(); data.AddTarget(target); var exception = new InvalidOperationException("boom"); - ULogger.Deliver(data, new LogRecord(exception)); + ULogger.Deliver(data, new LogRecord(exception, "formatted text")); target.LastException.Should().BeSameAs(exception); + target.LastExceptionMessage.Should().Be("formatted text"); } [Test] @@ -48,7 +51,7 @@ public void WhenExceptionDelivered_ThanTagFilterIsBypassed() data.AddTarget(target); var exception = new InvalidOperationException("boom"); - ULogger.Deliver(data, new LogRecord(exception)); + ULogger.Deliver(data, new LogRecord(exception, "formatted text")); target.LastException.Should().BeSameAs(exception); } @@ -60,7 +63,7 @@ public void WhenExceptionDelivered_ThanRegularLogIsNotTouched() var target = new RecordingTarget(); data.AddTarget(target); - ULogger.Deliver(data, new LogRecord(new Exception("boom"))); + ULogger.Deliver(data, new LogRecord(new Exception("boom"), "formatted text")); target.LastMessage.Should().BeNull(); } diff --git a/src/Tests/FileTargetTests.cs b/src/Tests/FileTargetTests.cs index aeedfd8..7abda35 100644 --- a/src/Tests/FileTargetTests.cs +++ b/src/Tests/FileTargetTests.cs @@ -56,10 +56,10 @@ public void WhenExceptionLogged_ThanItIsWrittenToFile() { using var target = new FileTarget(Path.Combine(_directory, "game.log")); - target.LogException(new InvalidOperationException("boom")); + target.LogException(new InvalidOperationException("ignored"), "formatted exception text"); target.Flush(); - File.ReadAllText(target.CurrentFilePath).Should().Contain("boom"); + File.ReadAllText(target.CurrentFilePath).Should().Contain("formatted exception text"); } [Test] diff --git a/src/Tests/InMemoryTargetTests.cs b/src/Tests/InMemoryTargetTests.cs index 340d6a9..829fd3e 100644 --- a/src/Tests/InMemoryTargetTests.cs +++ b/src/Tests/InMemoryTargetTests.cs @@ -51,9 +51,9 @@ public void WhenExceptionLogged_ThanItIsWrittenAsText() { var target = new InMemoryTarget(256); - target.LogException(new System.InvalidOperationException("boom")); + target.LogException(new System.InvalidOperationException("ignored"), "formatted exception text"); - target.GetContent().Should().Contain("boom"); + target.GetContent().Should().Contain("formatted exception text"); } [Test] From e4675d3816b706d7525c3f4e2c17b8f559a134be Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Sun, 28 Jun 2026 21:05:00 +0200 Subject: [PATCH 3/6] chore: prevent inlining of sample throw helpers for clearer stack traces --- .../Assets/Scripts/Exceptions/ExceptionInMethod.cs | 2 ++ Appegy.UniLogger.Lab/Assets/Scripts/Logging/ExceptionLogging.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInMethod.cs b/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInMethod.cs index c68f9af..bb0bb43 100644 --- a/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInMethod.cs +++ b/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInMethod.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; using UnityEngine; namespace Appegy.UniLogger.Example @@ -10,6 +11,7 @@ private void Start() ThrowMethod(); } + [MethodImpl(MethodImplOptions.NoInlining)] private void ThrowMethod() { throw new Exception("Throw exception in method"); diff --git a/Appegy.UniLogger.Lab/Assets/Scripts/Logging/ExceptionLogging.cs b/Appegy.UniLogger.Lab/Assets/Scripts/Logging/ExceptionLogging.cs index 438ab87..214181c 100644 --- a/Appegy.UniLogger.Lab/Assets/Scripts/Logging/ExceptionLogging.cs +++ b/Appegy.UniLogger.Lab/Assets/Scripts/Logging/ExceptionLogging.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; using UnityEngine; namespace Appegy.UniLogger.Example @@ -12,6 +13,7 @@ private void Start() ThrowMethod(); } + [MethodImpl(MethodImplOptions.NoInlining)] private void ThrowMethod() { Debug.LogException(new Exception("Custom exception"), this); From 47391485d0605b84c2c4164e91a11047406125e7 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Sun, 28 Jun 2026 22:00:00 +0200 Subject: [PATCH 4/6] perf: optimize and harden the exception formatter (cached delegate, pooled builder, guarded init) --- .../Internal/UnityExceptionFormatter.cs | 100 +++++++++++++++--- src/Runtime/ULogger.Static.cs | 2 +- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/src/Runtime/Internal/UnityExceptionFormatter.cs b/src/Runtime/Internal/UnityExceptionFormatter.cs index 243a1d0..14d2d3e 100644 --- a/src/Runtime/Internal/UnityExceptionFormatter.cs +++ b/src/Runtime/Internal/UnityExceptionFormatter.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.Reflection; +using System.Text; using UnityEngine; namespace Appegy.UniLogger @@ -19,50 +20,119 @@ internal static class UnityExceptionFormatter "Appegy.UniLogger.LogDispatcher", }; - private static readonly MethodInfo ExtractFormatted = typeof(StackTraceUtility).GetMethod( - "ExtractFormattedStackTrace", - BindingFlags.Static | BindingFlags.NonPublic, - null, new[] { typeof(StackTrace) }, null); + private static readonly Func ExtractFormatted = CreateExtractDelegate(); + + private static Func CreateExtractDelegate() + { + try + { + var method = typeof(StackTraceUtility).GetMethod( + "ExtractFormattedStackTrace", + BindingFlags.Static | BindingFlags.NonPublic, + null, new[] { typeof(StackTrace) }, null); + return method == null + ? null + : (Func)method.CreateDelegate(typeof(Func)); + } + catch + { + return null; + } + } public static string Format(Exception exception) { + if (exception == null) return string.Empty; + + var builder = StringBuilderPool.GetBuilder(); try { var stack = FormatTrace(new StackTrace(exception, true)); + + builder.Append(exception.GetType().Name).Append(": ").Append(exception.Message).Append('\n'); + if (string.IsNullOrEmpty(stack)) { - stack = StripInternalFrames(StackTraceUtility.ExtractStackTrace()); + AppendStrippedInternalFrames(builder, StackTraceUtility.ExtractStackTrace()); } - return exception.GetType().Name + ": " + exception.Message + "\n" + stack; + else + { + builder.Append(stack); + } + + return builder.ToString(); } catch { return exception.ToString(); } + finally + { + StringBuilderPool.ReturnBuilder(builder); + } } private static string FormatTrace(StackTrace trace) { - if (ExtractFormatted == null || trace.FrameCount == 0) return null; - return ExtractFormatted.Invoke(null, new object[] { trace }) as string; + if (ExtractFormatted == null || trace.FrameCount == 0) + { + return null; + } + return ExtractFormatted(trace); } - private static string StripInternalFrames(string stack) + private static void AppendStrippedInternalFrames(StringBuilder builder, string stack) { - if (string.IsNullOrEmpty(stack)) return stack; - var lines = stack.Split('\n'); + if (string.IsNullOrEmpty(stack)) + { + return; + } + var start = 0; - while (start < lines.Length && IsInternalFrame(lines[start])) start++; - return start == 0 ? stack : string.Join("\n", lines, start, lines.Length - start); + var length = stack.Length; + while (start < length) + { + var lineEnd = stack.IndexOf('\n', start); + var lineLength = (lineEnd < 0 ? length : lineEnd) - start; + if (!IsInternalFrame(stack, start, lineLength)) + { + break; + } + if (lineEnd < 0) + { + start = length; + break; + } + start = lineEnd + 1; + } + + if (start >= length) + { + builder.Append(stack); + } + else + { + builder.Append(stack, start, length - start); + } } - private static bool IsInternalFrame(string line) + private static bool IsInternalFrame(string stack, int lineStart, int lineLength) { foreach (var prefix in InternalFramePrefixes) { - if (line.StartsWith(prefix, StringComparison.Ordinal)) return true; + if (prefix.Length <= lineLength && + string.CompareOrdinal(stack, lineStart, prefix, 0, prefix.Length) == 0 && + (prefix.Length == lineLength || IsFrameBoundary(stack[lineStart + prefix.Length]))) + { + return true; + } } return false; } + + private static bool IsFrameBoundary(char c) + { + return c == '.' || c == ':' || c == '(' || c == ' '; + } } } diff --git a/src/Runtime/ULogger.Static.cs b/src/Runtime/ULogger.Static.cs index acf6500..2813509 100644 --- a/src/Runtime/ULogger.Static.cs +++ b/src/Runtime/ULogger.Static.cs @@ -162,7 +162,7 @@ private static void OnUnobservedTaskException(object sender, UnobservedTaskExcep internal static void EnqueueException(Exception exception) { var data = Data; - if (data == null || data.Targets.Length == 0) return; + if (data == null || data.Targets.Length == 0 || exception == null) return; var message = UnityExceptionFormatter.Format(exception); data.Dispatcher.Enqueue(new LogRecord(exception, message)); } From b3ded36ea93e61ea885b014f5f8dc06b3fd2dd8b Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Sun, 28 Jun 2026 22:30:00 +0200 Subject: [PATCH 5/6] feat: unwrap single-cause AggregateException so targets get the real exception --- src/Runtime/ULogger.Static.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Runtime/ULogger.Static.cs b/src/Runtime/ULogger.Static.cs index 2813509..e7dce5f 100644 --- a/src/Runtime/ULogger.Static.cs +++ b/src/Runtime/ULogger.Static.cs @@ -163,10 +163,21 @@ internal static void EnqueueException(Exception exception) { var data = Data; if (data == null || data.Targets.Length == 0 || exception == null) return; + exception = UnwrapAggregate(exception); var message = UnityExceptionFormatter.Format(exception); data.Dispatcher.Enqueue(new LogRecord(exception, message)); } + private static Exception UnwrapAggregate(Exception exception) + { + if (exception is AggregateException aggregate) + { + var inner = aggregate.Flatten().InnerExceptions; + if (inner.Count == 1) return inner[0]; + } + return exception; + } + #region GetLogger public static ULogger GetLogger(TLoggerTag tag) From 1400e15fd5a85b2e7398cc01034d22b6b4d96844 Mon Sep 17 00:00:00 2001 From: Ivan Murashka Date: Sun, 28 Jun 2026 22:45:00 +0200 Subject: [PATCH 6/6] chore: add sample that logs an AggregateException to show unwrapping --- .../Assets/Scenes/ExampleScene.unity | 45 +++++++++++++++++++ .../Exceptions/ExceptionInAggregate.cs | 34 ++++++++++++++ .../Exceptions/ExceptionInAggregate.cs.meta | 2 + 3 files changed, 81 insertions(+) create mode 100644 Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInAggregate.cs create mode 100644 Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInAggregate.cs.meta diff --git a/Appegy.UniLogger.Lab/Assets/Scenes/ExampleScene.unity b/Appegy.UniLogger.Lab/Assets/Scenes/ExampleScene.unity index c7cf82c..c1f630d 100644 --- a/Appegy.UniLogger.Lab/Assets/Scenes/ExampleScene.unity +++ b/Appegy.UniLogger.Lab/Assets/Scenes/ExampleScene.unity @@ -119,6 +119,50 @@ NavMeshSettings: debug: m_Flags: 0 m_NavMeshData: {fileID: 0} +--- !u!1 &315014944 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 315014946} + - component: {fileID: 315014945} + m_Layer: 0 + m_Name: ExceptionInAggregate + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &315014945 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 315014944} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 52aa6e75cb376c29aa4b9a0c2a76ac86, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::Appegy.UniLogger.Example.ExceptionInAggregate +--- !u!4 &315014946 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 315014944} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} --- !u!1 &519420028 GameObject: m_ObjectHideFlags: 0 @@ -793,3 +837,4 @@ SceneRoots: - {fileID: 1449897689} - {fileID: 1073955932} - {fileID: 1464717865} + - {fileID: 315014946} diff --git a/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInAggregate.cs b/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInAggregate.cs new file mode 100644 index 0000000..ba6399f --- /dev/null +++ b/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInAggregate.cs @@ -0,0 +1,34 @@ +using System; +using System.Runtime.CompilerServices; +using UnityEngine; + +namespace Appegy.UniLogger.Example +{ + public class ExceptionInAggregate : MonoBehaviour + { + private void Start() + { + ULogger.LogException(BuildAggregate()); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private AggregateException BuildAggregate() + { + try + { + ThrowInner(); + } + catch (Exception inner) + { + return new AggregateException("Wrapped by aggregate", inner); + } + return null; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ThrowInner() + { + throw new InvalidOperationException("Real cause inside aggregate"); + } + } +} diff --git a/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInAggregate.cs.meta b/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInAggregate.cs.meta new file mode 100644 index 0000000..5b52e81 --- /dev/null +++ b/Appegy.UniLogger.Lab/Assets/Scripts/Exceptions/ExceptionInAggregate.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 52aa6e75cb376c29aa4b9a0c2a76ac86 \ No newline at end of file