diff --git a/library/src/Core/Common/Common.csproj b/library/src/Core/Common/Common.csproj index 6f260d34..38f9ba7d 100644 --- a/library/src/Core/Common/Common.csproj +++ b/library/src/Core/Common/Common.csproj @@ -24,6 +24,7 @@ + diff --git a/library/src/Core/Common/Util/LogUtilities.cs b/library/src/Core/Common/Util/LogUtilities.cs new file mode 100644 index 00000000..b2cddec7 --- /dev/null +++ b/library/src/Core/Common/Util/LogUtilities.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Text; +using NLog; + +namespace ReFlex.Core.Common.Util +{ + public static class LogUtilities + { + private static readonly Logger Log = LogManager.GetCurrentClassLogger(); + private static readonly ConcurrentDictionary LoggedErrors = new ConcurrentDictionary(); + + /// + /// Logs an exception only once for the same source, method and exception signature. + /// + /// Exception to log. + /// Logical source (for example class name) used for scoping deduplication. + /// Method name used for scoping deduplication. + /// Optional custom message for the log entry. + /// Thrown when required arguments are null. + public static void LogErrorOnce( + Exception exception, + string sourceName, + string methodName, + string message = null) + { + if (sourceName == null) + throw new ArgumentNullException(nameof(sourceName)); + if (methodName == null) + throw new ArgumentNullException(nameof(methodName)); + + if (exception == null) + { + return; + } + + var errorKey = BuildErrorKey(sourceName, methodName, exception); + if (!LoggedErrors.TryAdd(errorKey, 0)) + { + return; + } + + if (string.IsNullOrWhiteSpace(message)) + { + Log.Error(exception); + return; + } + + Log.Error(exception, message); + } + + /// + /// Clears deduplication entries. + /// If is provided, only entries for this source are removed. + /// If it is null or whitespace, all entries are removed. + /// + /// Optional source name filter. + public static void ClearLoggedErrors(string sourceName = null) + { + if (string.IsNullOrWhiteSpace(sourceName)) + { + LoggedErrors.Clear(); + return; + } + + var sourcePrefix = $"{sourceName}:"; + var keys = LoggedErrors.Keys + .Where(key => key.StartsWith(sourcePrefix, StringComparison.Ordinal)) + .ToList(); + + foreach (var key in keys) + { + LoggedErrors.TryRemove(key, out _); + } + } + + /// + /// Creates a key that uniquely identifies an error context for deduplicated logging. + /// + /// Logical source (for example class name). + /// Method name where the exception occurred. + /// Exception to create the key from. + /// Deterministic key based on source, method and exception signature. + private static string BuildErrorKey(string sourceName, string methodName, Exception exception) + { + return $"{sourceName}:{methodName}:{BuildExceptionSignature(exception)}"; + } + + /// + /// Builds a deterministic signature from the exception chain. + /// This includes each exception type and message, including inner exceptions. + /// + /// Exception to convert to a signature. + /// Signature string that can be used for deduplication. + private static string BuildExceptionSignature(Exception exception) + { + var builder = new StringBuilder(); + var current = exception; + + while (current != null) + { + builder.Append(current.GetType().FullName); + builder.Append(':'); + builder.Append(current.Message); + builder.Append('|'); + current = current.InnerException; + } + + return builder.ToString(); + } + } +} diff --git a/library/src/Core/Tuio/Components/TuioSender.cs b/library/src/Core/Tuio/Components/TuioSender.cs index 4732547c..c44eb689 100644 --- a/library/src/Core/Tuio/Components/TuioSender.cs +++ b/library/src/Core/Tuio/Components/TuioSender.cs @@ -10,6 +10,7 @@ using NLog; using ReFlex.Core.Common.Adapter; using ReFlex.Core.Common.Interfaces; +using ReFlex.Core.Common.Util; using ReFlex.Core.Tuio.Interfaces; using ReFlex.Core.Tuio.Util; @@ -23,12 +24,12 @@ public class TuioSender : ITuioSender /// Client for sending TUIO Messages to Server using UDP /// protected UdpClient UdpClient; - + /// /// Client Interface for sending TUIO Messages to Server using TCP. (use for better test abilities) /// protected ITcpClient TcpClient; - + /// /// Client Interface for sending TUIO Messages to Server using WebSocket protocol. (use for better test abilities) /// @@ -78,6 +79,7 @@ public virtual void Initialize(TuioConfiguration config) _serverAddress = config.ServerAddress; _serverPort = config.ServerPort; + LogUtilities.ClearLoggedErrors(nameof(TuioSender)); IsInitialized = true; } @@ -96,7 +98,7 @@ public virtual async Task SendUdp(OscBundle bundle) } catch (Exception exc) { - Log.Error(exc, $"Error sending osc message via UDP"); + LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendUdp), "Error sending osc message via UDP"); } } @@ -115,7 +117,7 @@ public virtual async Task SendTcp(OscBundle bundle) } catch (Exception exc) { - Log.Error(exc); + LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendTcp)); } } @@ -146,7 +148,7 @@ public virtual async Task SendWebSocket(OscBundle bundle) } catch (Exception exc) { - Log.Error(exc); + LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendWebSocket)); } } @@ -165,7 +167,14 @@ public virtual async Task SendWebSocket(OscBundle bundle) /// to be transmitted protected virtual async Task SendOscMessageUdp(OscMessage msg) { - await UdpClient.SendMessageAsync(msg); + try + { + await UdpClient.SendMessageAsync(msg); + } + catch (Exception exc) + { + LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendOscMessageUdp)); + } } /// @@ -190,7 +199,7 @@ protected virtual async Task SendOscMessageTcp(OscMessage msg) } catch (Exception exc) { - Log.Error(exc); + LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendOscMessageTcp)); } } @@ -218,7 +227,7 @@ await WsClient.SendAsync(new ArraySegment(mStream.ToArray()), } catch (Exception exc) { - Log.Error(exc); + LogUtilities.LogErrorOnce(exc, nameof(TuioSender), nameof(SendOscMessageWebSocket)); } } @@ -237,8 +246,9 @@ await WsClient.CloseAsync(WebSocketCloseStatus.NormalClosure, "Close connection" UdpClient?.Dispose(); TcpClient?.Dispose(); WsClient?.Dispose(); + LogUtilities.ClearLoggedErrors(nameof(TuioSender)); IsInitialized = false; } } -} \ No newline at end of file +} diff --git a/test/Library/Tuio.Test/LogUtilitiesTest.cs b/test/Library/Tuio.Test/LogUtilitiesTest.cs new file mode 100644 index 00000000..2f9f40b7 --- /dev/null +++ b/test/Library/Tuio.Test/LogUtilitiesTest.cs @@ -0,0 +1,193 @@ +using System; +using NLog; +using NLog.Config; +using NLog.Targets; +using NUnit.Framework; +using ReFlex.Core.Common.Util; + +namespace ReFlex.Core.Tuio.Test +{ + [TestFixture] + public class LogUtilitiesTest + { + private LoggingConfiguration _previousConfiguration; + private MemoryTarget _memoryTarget; + + [SetUp] + public void Setup() + { + // Store current configuration to restore it after each test. + _previousConfiguration = LogManager.Configuration; + + // Capture all log entries in memory so assertions can validate deduplication behavior. + _memoryTarget = new MemoryTarget("LogUtilitiesMemoryTarget") + { + Layout = "${level}|${message}|${exception:format=Type,Message}" + }; + + var config = new LoggingConfiguration(); + config.AddRuleForAllLevels(_memoryTarget); + LogManager.Configuration = config; + LogManager.ReconfigExistingLoggers(); + } + + [TearDown] + public void TearDown() + { + // Ensure static deduplication state does not leak into the next test. + LogUtilities.ClearLoggedErrors(); + + // Restore original logging configuration for the rest of the test suite. + LogManager.Configuration = _previousConfiguration; + LogManager.ReconfigExistingLoggers(); + + _memoryTarget?.Dispose(); + } + + [Test] + public void TestNoAdditionalLogWhenKeyExists() + { + // Use a unique source scope so tests stay isolated even with shared static cache. + var sourceName = CreateUniqueSourceName(nameof(TestNoAdditionalLogWhenKeyExists)); + var methodName = "SendUdp"; + + // First occurrence should be logged. + LogUtilities.LogErrorOnce( + new InvalidOperationException("send failed"), + sourceName, + methodName); + + // Same exception signature in same source/method should be suppressed. + LogUtilities.LogErrorOnce( + new InvalidOperationException("send failed"), + sourceName, + methodName); + + // Exactly one log entry is expected. + Assert.That(_memoryTarget.Logs.Count, Is.EqualTo(1)); + } + + [Test] + public void TestClearAllowsLoggingAgain() + { + // Unique source keeps this test independent from other runs. + var sourceName = CreateUniqueSourceName(nameof(TestClearAllowsLoggingAgain)); + var methodName = "SendTcp"; + + // First log call should pass through. + LogUtilities.LogErrorOnce( + new InvalidOperationException("connection failed"), + sourceName, + methodName, + "custom message"); + + // Reset deduplication state for this source. + LogUtilities.ClearLoggedErrors(sourceName); + + // After reset, same error must be logged again. + LogUtilities.LogErrorOnce( + new InvalidOperationException("connection failed"), + sourceName, + methodName, + "custom message"); + + // One log before and one log after clear. + Assert.That(_memoryTarget.Logs.Count, Is.EqualTo(2)); + Assert.That(_memoryTarget.Logs[0], Does.Contain("custom message")); + Assert.That(_memoryTarget.Logs[1], Does.Contain("custom message")); + } + + [Test] + public void TestDifferentMessagesAndExceptionTypesAreLoggedOnceEach() + { + // Unique source keeps this test independent from other runs. + var sourceName = CreateUniqueSourceName(nameof(TestDifferentMessagesAndExceptionTypesAreLoggedOnceEach)); + var methodName = "SendOscMessageTcp"; + + // Distinct signatures: message, type and inner-exception chain. + LogUtilities.LogErrorOnce( + new InvalidOperationException("message A"), + sourceName, + methodName); + + LogUtilities.LogErrorOnce( + new InvalidOperationException("message B"), + sourceName, + methodName); + + LogUtilities.LogErrorOnce( + new ArgumentException("message A"), + sourceName, + methodName); + + LogUtilities.LogErrorOnce( + new InvalidOperationException("message A", new Exception("inner A")), + sourceName, + methodName); + + // Repeat all variants; duplicates should be suppressed. + LogUtilities.LogErrorOnce( + new InvalidOperationException("message A"), + sourceName, + methodName); + + LogUtilities.LogErrorOnce( + new InvalidOperationException("message B"), + sourceName, + methodName); + + LogUtilities.LogErrorOnce( + new ArgumentException("message A"), + sourceName, + methodName); + + LogUtilities.LogErrorOnce( + new InvalidOperationException("message A", new Exception("inner A")), + sourceName, + methodName); + + // Four unique signatures should produce four logs total. + Assert.That(_memoryTarget.Logs.Count, Is.EqualTo(4)); + } + + [Test] + public void TestSameErrorIsLoggedOncePerMethod() + { + // Unique source keeps this test independent from other runs. + var sourceName = CreateUniqueSourceName(nameof(TestSameErrorIsLoggedOncePerMethod)); + var firstMethod = "SendTcp"; + var secondMethod = "SendOscMessageTcp"; + + // Same error in first method: only first call logs. + LogUtilities.LogErrorOnce( + new InvalidOperationException("same error"), + sourceName, + firstMethod); + + LogUtilities.LogErrorOnce( + new InvalidOperationException("same error"), + sourceName, + firstMethod); + + // Same error in different method should log once again (method is part of the key). + LogUtilities.LogErrorOnce( + new InvalidOperationException("same error"), + sourceName, + secondMethod); + + LogUtilities.LogErrorOnce( + new InvalidOperationException("same error"), + sourceName, + secondMethod); + + // One log per method is expected. + Assert.That(_memoryTarget.Logs.Count, Is.EqualTo(2)); + } + + private static string CreateUniqueSourceName(string testName) + { + // A per-test unique source avoids cross-test interference through the static cache. + return $"{nameof(LogUtilitiesTest)}.{testName}.{Guid.NewGuid()}"; + } + } +}