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