diff --git a/Serilog.Sinks.LogtailSys.Tests/Serilog.Sinks.LogtailSys.Tests.csproj b/Serilog.Sinks.LogtailSys.Tests/Serilog.Sinks.LogtailSys.Tests.csproj new file mode 100644 index 0000000..f52aa27 --- /dev/null +++ b/Serilog.Sinks.LogtailSys.Tests/Serilog.Sinks.LogtailSys.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/Serilog.Sinks.LogtailSys.Tests/StringTests.cs b/Serilog.Sinks.LogtailSys.Tests/StringTests.cs new file mode 100644 index 0000000..47d0da7 --- /dev/null +++ b/Serilog.Sinks.LogtailSys.Tests/StringTests.cs @@ -0,0 +1,139 @@ +using System.Text.RegularExpressions; +using FluentAssertions; +using Serilog.Sinks.Logtail; + +namespace Serilog.Sinks.LogtailSys.Tests; + +public partial class StringTests +{ + [Theory] + [InlineData("hello world", '"', "hello world")] + [InlineData("\"hello world", '"', "hello world")] + [InlineData("hello world\"", '"', "hello world")] + [InlineData("\"hello world\"", '"', "hello world")] + [InlineData("\"hell\"o\" world\"", '"', "hell\"o\" world")] + [InlineData("\"hell\\\"o\\\" world\"", '"', "hell\"o\" world")] + [InlineData("", '"', "")] + [InlineData("\"", '"', "")] + [InlineData("\"\"", '"', "")] + public void CanTrimString(string source, char trimChar, string expected) + { + var cleaned = new StringCleaner(source) + .WithTrimed(trimChar) + .WithUnescapeQuotes() + .Build(); + cleaned.Should().Be(expected); + var previousCleaned = source.TrimAndUnescapeQuotes(); + cleaned.Should().Be(previousCleaned, because: "Should match previous"); + } + + [Theory] + [InlineData("Hello world", "Hello world", '"', '\\', ']')] + [InlineData("[]Hello world", @"[\]Hello world", '"', '\\', ']')] + [InlineData("[]H\"e\"llo world", "[\\]H\\\"e\\\"llo world", '"', '\\', ']')] + public void CanEscapeString(string source, string expected, params char[] charsToReplace) + { + var cleaned = new StringCleaner(source) + .WithEscapedChars(charsToReplace) + .Build(); + cleaned.Should().Be(expected); + var previousCleaned = PropertyValueRx().Replace(source, match => $@"\{match}"); + cleaned.Should().Be(previousCleaned, because: "Should match previous"); + } + + [Fact] + public void CanGetMaxLength() + { + for (var i = 0; i < 100; i++) + { + var str = new string('*', i) + " "; + var strCopy = new string('*', i) + " "; + var cleaned = new StringCleaner(str) + .WithMaxLength(32) + .Build(); + var previous = strCopy.WithMaxLength(32); + + var expectedLen = str.Length switch + { + <= 32 => str.Length, + _ => 32 + }; + cleaned.Should().Be(previous, because: "Should match previous"); + cleaned.Should().HaveLength(expectedLen); + } + } + + [Theory] + [InlineData("", "")] + [InlineData("Hej!", "Hej!")] + [InlineData("Hej!^", "Hej!^")] + [InlineData("Hej!^~", "Hej!^~")] + [InlineData("Hej!^~-", "Hej!^~-")] + [InlineData("Hej!^@", "Hej!^@")] + [InlineData("Hej 💦", "Hej")] + [InlineData("Hej ", "Hej")] + public void AsPrintableAscii(string source, string expected) + { + var cleaned = new StringCleaner(source) + .WithAsciiPrintable() + .Build(); + var previousClean = source.AsPrintableAscii(); + + cleaned.Should().Be(expected); + cleaned.Should().Be(previousClean, because: "Should match previous"); + } + + [GeneratedRegex("[\\]\\\\\"]")] + private static partial Regex PropertyValueRx(); +} + +public static partial class StringExtensions +{ + /// + /// Truncates a string so that it is no longer than the specified number of characters. + /// If the truncated string ends with a space, it will be removed + /// + /// String to be truncated + /// Maximum string length before truncation will occur + /// Original string, or a truncated to the specified length if too long + public static string WithMaxLength(this string source, int maxLength) + { + if (string.IsNullOrEmpty(source)) + return source; + return source.Length > maxLength + ? source[..maxLength].TrimEnd() + : source; + } + + public static string AsPrintableAscii(this string source) + { + if (string.IsNullOrEmpty(source)) + return source; + return PrintableAsciiRx().Replace(source, string.Empty); + } + + /// + /// Remove any surrounding quotes, and unescape all others + /// + /// String to be processed + /// The string, with surrounding quotes removed and all others unescapes + public static string TrimAndUnescapeQuotes(this string source) + { + if (string.IsNullOrEmpty(source)) + return source; + + return source + .Trim('"') + .Replace(@"\""", @""""); + } + + public static int ToInt(this string source) + => Convert.ToInt32(source); + + [GeneratedRegex(@"[^\u0021-\u007E]", RegexOptions.Compiled | RegexOptions.Singleline)] + private static partial Regex PrintableAsciiRx(); + + + [GeneratedRegex("[=\\\"\\]]")] + private static partial Regex PropertyKeyRx(); +} \ No newline at end of file diff --git a/Serilog.Sinks.LogtailSys.sln b/Serilog.Sinks.LogtailSys.sln index fa4819c..4a4efb0 100644 --- a/Serilog.Sinks.LogtailSys.sln +++ b/Serilog.Sinks.LogtailSys.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.Sinks.LogtailSys", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.Sinks.LogtailSys.Example", "Serilog.Sinks.LogtailSys.Example\Serilog.Sinks.LogtailSys.Example.csproj", "{E01C60E8-9A08-4085-9F76-5068E98CA229}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serilog.Sinks.LogtailSys.Tests", "Serilog.Sinks.LogtailSys.Tests\Serilog.Sinks.LogtailSys.Tests.csproj", "{CD0E3355-D7EB-485E-AD0B-782380DC7532}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {E01C60E8-9A08-4085-9F76-5068E98CA229}.Debug|Any CPU.Build.0 = Debug|Any CPU {E01C60E8-9A08-4085-9F76-5068E98CA229}.Release|Any CPU.ActiveCfg = Release|Any CPU {E01C60E8-9A08-4085-9F76-5068E98CA229}.Release|Any CPU.Build.0 = Release|Any CPU + {CD0E3355-D7EB-485E-AD0B-782380DC7532}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD0E3355-D7EB-485E-AD0B-782380DC7532}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD0E3355-D7EB-485E-AD0B-782380DC7532}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD0E3355-D7EB-485E-AD0B-782380DC7532}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Serilog.Sinks.LogtailSys/Formatters/LogtailFormatter.cs b/Serilog.Sinks.LogtailSys/Formatters/LogtailFormatter.cs index c302d87..35114b7 100644 --- a/Serilog.Sinks.LogtailSys/Formatters/LogtailFormatter.cs +++ b/Serilog.Sinks.LogtailSys/Formatters/LogtailFormatter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using Serilog.Events; using Serilog.Formatting; @@ -57,14 +58,16 @@ public LogtailFormatter( this.applicationName = applicationName ?? ProcessName; // Conform to the RFC - this.applicationName = this.applicationName - .AsPrintableAscii() - .WithMaxLength(48); + this.applicationName = new StringCleaner(this.applicationName) + .WithAsciiPrintable() + .WithMaxLength(48) + .Build(); // Conform to the RFC - this.messageIdPropertyName = (messageIdPropertyName ?? DefaultMessageIdPropertyName) - .AsPrintableAscii() - .WithMaxLength(32); + this.messageIdPropertyName = new StringCleaner(messageIdPropertyName ?? DefaultMessageIdPropertyName) + .WithAsciiPrintable() + .WithMaxLength(32) + .Build(); this.tokenKey = tokenKey; this.token = token; @@ -92,18 +95,17 @@ private string GetMessageId(LogEvent logEvent) { var hasMsgId = logEvent.Properties.TryGetValue(messageIdPropertyName, out var propertyValue); - if (!hasMsgId) + if (!hasMsgId || propertyValue == null) return NILVALUE; - var result = propertyValue? - .ToString() - .TrimAndUnescapeQuotes(); + var result = new StringCleaner(propertyValue.ToString()) + .WithTrimed('"') + .WithUnescapeQuotes() + .WithAsciiPrintable() + .WithMaxLength(32) + .Build(); // Conform to the RFC's restrictions - result = result? - .AsPrintableAscii() - .WithMaxLength(32); - return result is { Length: >= 1 } ? result : NILVALUE; @@ -111,30 +113,33 @@ private string GetMessageId(LogEvent logEvent) private string RenderStructuredData(LogEvent logEvent) { - var tokenPart = $"{tokenKey}=\"{token}\""; - var structuredDataKvps = string.Join(" ", logEvent.Properties.Select(t => $""" - {RenderPropertyKey(t.Key)}="{RenderPropertyValue(t.Value)}" - """)); - var structuredData = string.IsNullOrEmpty(structuredDataKvps) ? $"[{tokenPart}]" : $"[{tokenPart}][{dataName} {structuredDataKvps}]"; - - return structuredData; + var builder = new StringBuilder(); + builder.Append($"{tokenKey}=\"{token}\""); + if (logEvent.Properties.Count is 0) + return builder.ToString(); + + builder.Append($"[{dataName} "); + foreach (var property in logEvent.Properties) + { + var kvp = $""" + {RenderPropertyKey(property.Key)}="{RenderPropertyValue(property.Value)}" + """; + builder.Append(kvp); + } + builder.Append(']'); + return builder.ToString(); } private static string RenderPropertyKey(string propertyKey) { // Conform to the RFC's restrictions - var result = propertyKey.AsPrintableAscii(); - // Also remove any '=', ']', and '"", as these are also not permitted in structured data parameter names // Unescaped regex pattern: [=\"\]] - -#if NET7_0 - result = PropertyKeyRx().Replace(result, string.Empty); -#else - result = Regex.Replace(result, "[=\\\"\\]]", string.Empty); -#endif - - return result.WithMaxLength(32); + return new StringCleaner(propertyKey) + .WithAsciiPrintable() + .WithEscapedChars('=', '"', ']') + .WithMaxLength(32) + .Build(); } /// @@ -145,23 +150,15 @@ private static string RenderPropertyKey(string propertyKey) private static string RenderPropertyValue(LogEventPropertyValue propertyValue) { // Trim surrounding quotes, and unescape all others - var result = propertyValue - .ToString() - .TrimAndUnescapeQuotes(); - // Use a backslash to escape backslashes, double quotes and closing square brackets -#if NET7_0 - return PropertyValueRx().Replace(result, match => $@"\{match}"); -#else - return Regex.Replace(result, @"[\]\\""]", match => $@"\{match}"); -#endif + return new StringCleaner(propertyValue + .ToString()) + .WithTrimed('"') + .WithUnescapeQuotes() + .WithEscapedChars('"', '\\', ']') + .Build(); + } -#if NET7_0 - [GeneratedRegex("[\\]\\\\\"]")] - private static partial Regex PropertyValueRx(); - [GeneratedRegex("[=\\\"\\]]")] - private static partial Regex PropertyKeyRx(); -#endif } } diff --git a/Serilog.Sinks.LogtailSys/LogtailLoggerConfigurationExtensions.cs b/Serilog.Sinks.LogtailSys/LogtailLoggerConfigurationExtensions.cs index 9a73ae2..3c1c89a 100644 --- a/Serilog.Sinks.LogtailSys/LogtailLoggerConfigurationExtensions.cs +++ b/Serilog.Sinks.LogtailSys/LogtailLoggerConfigurationExtensions.cs @@ -134,7 +134,7 @@ private static IPEndPoint ResolveIP(string host, int port) { var addr = Dns.GetHostAddresses(host) .First(x => x.AddressFamily is AddressFamily.InterNetwork or AddressFamily.InterNetworkV6); - + return new IPEndPoint(addr, port); } } diff --git a/Serilog.Sinks.LogtailSys/Serilog.Sinks.LogtailSys.csproj b/Serilog.Sinks.LogtailSys/Serilog.Sinks.LogtailSys.csproj index 261b0b3..197d4e9 100644 --- a/Serilog.Sinks.LogtailSys/Serilog.Sinks.LogtailSys.csproj +++ b/Serilog.Sinks.LogtailSys/Serilog.Sinks.LogtailSys.csproj @@ -1,7 +1,7 @@ - netstandard2.1;netstandard2.0;net7.0 + netstandard2.1;netstandard2.0;net8.0 Fully-featured Serilog sink that logs events to logtail using UDP (rSyslog). Nicholas Brostrom (Nickztar) Effectsoft AB @@ -23,8 +23,8 @@ - - + + diff --git a/Serilog.Sinks.LogtailSys/Sinks/StringCleaner.cs b/Serilog.Sinks.LogtailSys/Sinks/StringCleaner.cs new file mode 100644 index 0000000..adb3480 --- /dev/null +++ b/Serilog.Sinks.LogtailSys/Sinks/StringCleaner.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Serilog.Sinks.Logtail +{ + public class StringCleaner(string source) + { + private StringBuilder _builder = new(source); + + public StringCleaner WithTrimed(char toTrim) + { + if (_builder.Length != 0 && _builder[0] == toTrim) + _builder.Remove(0, 1); + if (_builder.Length != 0 && _builder[_builder.Length - 1] == toTrim) + _builder.Remove(_builder.Length - 1, 1); + return this; + } + + public StringCleaner WithUnescapeQuotes() + { + _builder.Replace(@"\""", @""""); + return this; + } + + public StringCleaner WithEscapedChar(char toEscape) + { + return WithEscapedChars(toEscape); + } + + public StringCleaner WithEscapedChars(params char[] illegalChars) + { + var newBuilder = new StringBuilder(_builder.Length); + for (var i = 0; i < _builder.Length; i++) + { + var c = _builder[i]; + if (!illegalChars.Contains(c)) + { + newBuilder.Append(c); + continue; + } + newBuilder.Append('\\'); + newBuilder.Append(c); + } + + _builder = newBuilder; + return this; + } + + /// + /// Truncates the string so that it is no longer than the specified number of characters. + /// If the truncated string ends with a space, it will be removed + /// + /// Maximum string length before truncation will occur + /// StringCleaner + public StringCleaner WithMaxLength(int maxLength) + { + if (_builder.Length <= maxLength) + return this; + _builder.Remove(maxLength, _builder.Length - maxLength); + if (_builder.Length != 0 && _builder[_builder.Length - 1] == ' ') + _builder.Remove(_builder.Length - 1, 1); + return this; + } + + public StringCleaner WithAsciiPrintable() + { + var newBuilder = new StringBuilder(_builder.Length); + for (var i = 0; i < _builder.Length; i++) + { + var c = _builder[i]; + if (IsNonPrintableAscii(c)) continue; + newBuilder.Append(c); + } + _builder = newBuilder; + return this; + } + + public string Build() + { + return _builder.ToString(); + } + + /// + /// Due to this only existing in .NET. We have vendored this. + /// + /// Char + /// True if is ascii + private static bool IsNonPrintableAscii(char c) => c is < '\u0021' or > '\u007E'; + } +} diff --git a/Serilog.Sinks.LogtailSys/Sinks/StringExtensions.cs b/Serilog.Sinks.LogtailSys/Sinks/StringExtensions.cs index 69dc3ac..90aa38e 100644 --- a/Serilog.Sinks.LogtailSys/Sinks/StringExtensions.cs +++ b/Serilog.Sinks.LogtailSys/Sinks/StringExtensions.cs @@ -1,69 +1,29 @@ -using System; -using System.Linq; -using System.Text.RegularExpressions; +using System; -namespace Serilog.Sinks.Logtail +namespace Serilog.Sinks.Logtail; + +public static class StringExtensions { - public static partial class StringExtensions + /// + /// + /// Truncates a string so that it is no longer than the specified number of characters. + /// If the truncated string ends with a space, it will be removed + /// + /// String to be truncated + /// Maximum string length before truncation will occur + /// Original string, or a truncated to the specified length if too long + public static string WithMaxLength(this string source, int maxLength) { -#if !NET7_0 - private static readonly Regex printableAsciiRegex = new(@"[^\u0021-\u007E]", RegexOptions.Compiled | RegexOptions.Singleline); -#endif - - /// - /// Truncates a string so that it is no longer than the specified number of characters. - /// If the truncated string ends with a space, it will be removed - /// - /// String to be truncated - /// Maximum string length before truncation will occur - /// Original string, or a truncated to the specified length if too long - public static string WithMaxLength(this string source, int maxLength) - { - if (string.IsNullOrEmpty(source)) - return source; - #if NETSTANDARD2_0 + if (string.IsNullOrEmpty(source)) + return source; +#if NETSTANDARD2_0 return source.Length > maxLength ? source.Substring(0, maxLength).TrimEnd() : source; - #else - return source.Length > maxLength - ? source[..maxLength].TrimEnd() - : source; - #endif - } - - public static string AsPrintableAscii(this string source) - { - if (string.IsNullOrEmpty(source)) - return source; -#if NET7_0 - return PrintableAsciiRx().Replace(source, string.Empty); #else - return printableAsciiRegex.Replace(source, string.Empty); -#endif - } - - /// - /// Remove any surrounding quotes, and unescape all others - /// - /// String to be processed - /// The string, with surrounding quotes removed and all others unescapes - public static string TrimAndUnescapeQuotes(this string source) - { - if (string.IsNullOrEmpty(source)) - return source; - - return source - .Trim('"') - .Replace(@"\""", @""""); - } - - public static int ToInt(this string source) - => Convert.ToInt32(source); - -#if NET7_0 - [GeneratedRegex(@"[^\u0021-\u007E]", RegexOptions.Compiled | RegexOptions.Singleline)] - private static partial Regex PrintableAsciiRx(); + return source.Length > maxLength + ? source[..maxLength].TrimEnd() + : source; #endif } -} +} \ No newline at end of file