diff --git a/Apps/LogExporterApp/App.cs b/Apps/LogExporterApp/App.cs index 32a1356b..5030de81 100644 --- a/Apps/LogExporterApp/App.cs +++ b/Apps/LogExporterApp/App.cs @@ -41,20 +41,20 @@ public sealed class App : IDnsApplication, IDnsQueryLogger private const int QUEUE_TIMER_INTERVAL = 10000; - private readonly ExportManager _exportManager = new ExportManager(); + private readonly IReadOnlyList _emptyList = []; - private BlockingCollection _logBuffer; + private readonly ExportManager _exportManager = new ExportManager(); private BufferManagementConfig? _config; private IDnsServer _dnsServer; + private BlockingCollection _logBuffer; + private Timer _queueTimer; private bool disposedValue; - private readonly IReadOnlyList _emptyList = []; - #endregion variables #region constructor @@ -117,29 +117,8 @@ public Task InitializeAsync(IDnsServer dnsServer, string config) RegisterExportTargets(); if (_exportManager.HasStrategy()) { - _queueTimer = new Timer(async (object _) => - { - try - { - await ExportLogsAsync(); - } - catch (Exception ex) - { - _dnsServer?.WriteLog(ex); - } - finally - { - try - { - _queueTimer.Change(QUEUE_TIMER_INTERVAL, Timeout.Infinite); - } - catch (ObjectDisposedException) - { } - } - }, null, QUEUE_TIMER_INTERVAL, Timeout.Infinite); - + _queueTimer = new Timer(HandleExportLogCallback, state: null, QUEUE_TIMER_INTERVAL, Timeout.Infinite); } - return Task.CompletedTask; } @@ -176,6 +155,27 @@ private async Task ExportLogsAsync() } } + private async void HandleExportLogCallback(object? state) + { + try + { + await ExportLogsAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _dnsServer?.WriteLog(ex); + } + finally + { + try + { + _queueTimer.Change(QUEUE_TIMER_INTERVAL, Timeout.Infinite); + } + catch (ObjectDisposedException) + { } + } + } + private void RegisterExportTargets() { // Helper function to register an export strategy if the target is enabled diff --git a/Apps/LogExporterApp/GrowableBuffer.cs b/Apps/LogExporterApp/GrowableBuffer.cs new file mode 100644 index 00000000..71504ff4 --- /dev/null +++ b/Apps/LogExporterApp/GrowableBuffer.cs @@ -0,0 +1,132 @@ +using System; +using System.Buffers; + +namespace LogExporter +{ + public class GrowableBuffer : IBufferWriter, IDisposable + { + // Gets the current length of the buffer contents + public int Length => _position; + + // Initial capacity to be used in the constructor + private const int DefaultInitialCapacity = 256; + + private Memory _buffer; + + private int _position; + + private bool disposedValue; + + public GrowableBuffer(int initialCapacity = DefaultInitialCapacity) + { + _buffer = new Memory(ArrayPool.Shared.Rent(initialCapacity)); + _position = 0; + } + + // IBufferWriter implementation + public void Advance(int count) + { + if (count < 0 || _position + count > _buffer.Length) + throw new ArgumentOutOfRangeException(nameof(count)); + + _position += count; + } + + // Appends a single element to the buffer + public void Append(T item) + { + EnsureCapacity(1); + _buffer.Span[_position++] = item; + } + + // Appends a span of elements to the buffer + public void Append(ReadOnlySpan span) + { + EnsureCapacity(span.Length); + span.CopyTo(_buffer.Span[_position..]); + _position += span.Length; + } + + // Clears the buffer for reuse without reallocating + public void Clear() => _position = 0; + + public Memory GetMemory(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer[_position..]; + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + return _buffer.Span[_position..]; + } + + // Returns the buffer contents as an array + public T[] ToArray() + { + T[] result = new T[_position]; + _buffer.Span[.._position].CopyTo(result); + return result; + } + + // Returns the buffer contents as a ReadOnlySpan + public ReadOnlySpan ToSpan() => _buffer.Span[.._position]; + + public override string ToString() => _buffer.Span[.._position].ToString(); + + // Ensures the buffer has enough capacity to add more elements + private void EnsureCapacity(int additionalCapacity) + { + if (_position + additionalCapacity > _buffer.Length) + { + GrowBuffer(_position + additionalCapacity); + } + } + + // Grows the buffer to accommodate the required capacity + private void GrowBuffer(int requiredCapacity) + { + int newCapacity = Math.Max(_buffer.Length * 2, requiredCapacity); + + // Rent a larger buffer from the pool + T[] newArray = ArrayPool.Shared.Rent(newCapacity); + Memory newBuffer = new Memory(newArray); + + // Copy current contents to the new buffer + _buffer.Span[.._position].CopyTo(newBuffer.Span); + + // Return old buffer to the pool + ArrayPool.Shared.Return(_buffer.ToArray()); + + // Assign the new buffer + _buffer = newBuffer; + } + + #region IDisposable + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + ArrayPool.Shared.Return(_buffer.ToArray()); + _buffer = Memory.Empty; + _position = 0; + } + } + + disposedValue = true; + } + + #endregion IDisposable + } +} \ No newline at end of file diff --git a/Apps/LogExporterApp/LogEntry.cs b/Apps/LogExporterApp/LogEntry.cs index fe01dfff..8d185831 100644 --- a/Apps/LogExporterApp/LogEntry.cs +++ b/Apps/LogExporterApp/LogEntry.cs @@ -1,11 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; -using System.Text.Json.Serialization; +using System.Text; using System.Text.Json; using TechnitiumLibrary.Net.Dns; using TechnitiumLibrary.Net.Dns.ResourceRecords; -using System.Collections.Generic; namespace LogExporter { @@ -43,7 +43,7 @@ public LogEntry(DateTime timestamp, IPEndPoint remoteEP, DnsTransportProtocol pr QuestionName = questionRecord.Name, QuestionType = questionRecord.Type, QuestionClass = questionRecord.Class, - Size = questionRecord.UncompressedLength + Size = questionRecord.UncompressedLength, })); } @@ -58,7 +58,7 @@ public LogEntry(DateTime timestamp, IPEndPoint remoteEP, DnsTransportProtocol pr RecordClass = record.Class, RecordTtl = record.TTL, Size = record.UncompressedLength, - DnssecStatus = record.DnssecStatus + DnssecStatus = record.DnssecStatus, })); } @@ -73,11 +73,6 @@ public LogEntry(DateTime timestamp, IPEndPoint remoteEP, DnsTransportProtocol pr } } - public override string ToString() - { - return JsonSerializer.Serialize(this, DnsLogSerializerOptions.Default); - } - public class Question { public string QuestionName { get; set; } @@ -95,36 +90,74 @@ public class Answer public int Size { get; set; } public DnssecStatus DnssecStatus { get; set; } } - } - // Custom DateTime converter to handle UTC serialization in ISO 8601 format - public class JsonDateTimeConverter : JsonConverter - { - public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + public ReadOnlySpan AsSpan() { - var dts = reader.GetString(); - return dts == null ? DateTime.MinValue : DateTime.Parse(dts); - } + // Initialize a ValueStringBuilder with some initial capacity + var buffer = new GrowableBuffer(256); - public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) - { - writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")); + using var writer = new Utf8JsonWriter(buffer); + + // Manually serialize the LogEntry as JSON + writer.WriteStartObject(); + + writer.WriteString("timestamp", Timestamp.ToUniversalTime().ToString("O")); + writer.WriteString("clientIp", ClientIp); + writer.WriteNumber("clientPort", ClientPort); + writer.WriteBoolean("dnssecOk", DnssecOk); + writer.WriteString("protocol", Protocol.ToString()); + writer.WriteString("responseCode", ResponseCode.ToString()); + + // Write Questions array + writer.WriteStartArray("questions"); + foreach (var question in Questions) + { + writer.WriteStartObject(); + writer.WriteString("questionName", question.QuestionName); + writer.WriteString("questionType", question.QuestionType.ToString()); + writer.WriteString("questionClass", question.QuestionClass.ToString()); + writer.WriteNumber("size", question.Size); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + + // Write Answers array (if exists) + if (Answers != null && Answers.Count > 0) + { + writer.WriteStartArray("answers"); + foreach (var answer in Answers) + { + writer.WriteStartObject(); + writer.WriteString("recordType", answer.RecordType.ToString()); + writer.WriteString("recordData", answer.RecordData); + writer.WriteString("recordClass", answer.RecordClass.ToString()); + writer.WriteNumber("recordTtl", answer.RecordTtl); + writer.WriteNumber("size", answer.Size); + writer.WriteString("dnssecStatus", answer.DnssecStatus.ToString()); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + writer.Flush(); + + return ConvertBytesToChars(buffer.ToSpan()); } - } - // Setup reusable options with a single instance - public static class DnsLogSerializerOptions - { - public static readonly JsonSerializerOptions Default = new JsonSerializerOptions + public static Span ConvertBytesToChars(ReadOnlySpan byteSpan) { - WriteIndented = false, // Newline delimited logs should not be multiline - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // Convert properties to camelCase - Converters = { new JsonStringEnumConverter(), new JsonDateTimeConverter() }, // Handle enums and DateTime conversion - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // For safe encoding - NumberHandling = JsonNumberHandling.Strict, - AllowTrailingCommas = true, // Allow trailing commas in JSON - DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, // Convert dictionary keys to camelCase - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull // Ignore null values - }; - } + // Calculate the maximum required length for the char array + int maxCharCount = Encoding.UTF8.GetCharCount(byteSpan); + + // Allocate a char array large enough to hold the converted characters + char[] charArray = new char[maxCharCount]; + + // Decode the byteSpan into the char array + int actualCharCount = Encoding.UTF8.GetChars(byteSpan, charArray); + + // Return a span of only the relevant portion of the char array + return new Span(charArray, 0, actualCharCount); + } + }; } diff --git a/Apps/LogExporterApp/Strategy/ExportManager.cs b/Apps/LogExporterApp/Strategy/ExportManager.cs index a55c3584..df70359f 100644 --- a/Apps/LogExporterApp/Strategy/ExportManager.cs +++ b/Apps/LogExporterApp/Strategy/ExportManager.cs @@ -56,7 +56,7 @@ public async Task ImplementStrategyForAsync(List logs) { foreach (var strategy in _exportStrategies.Values) { - await strategy.ExportAsync(logs); + await strategy.ExportAsync(logs).ConfigureAwait(false); } } diff --git a/Apps/LogExporterApp/Strategy/FileExportStrategy.cs b/Apps/LogExporterApp/Strategy/FileExportStrategy.cs index 1ebfae97..605012fa 100644 --- a/Apps/LogExporterApp/Strategy/FileExportStrategy.cs +++ b/Apps/LogExporterApp/Strategy/FileExportStrategy.cs @@ -21,17 +21,15 @@ You should have received a copy of the GNU General Public License using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Text; using System.Threading; using System.Threading.Tasks; namespace LogExporter.Strategy { - public class FileExportStrategy : IExportStrategy + public partial class FileExportStrategy : IExportStrategy { - #region variables - private const int AVERAGE_LOG_SIZE = 500; + #region variables private static readonly SemaphoreSlim _fileSemaphore = new SemaphoreSlim(1, 1); @@ -39,6 +37,7 @@ public class FileExportStrategy : IExportStrategy private bool disposedValue; + #endregion variables #region constructor @@ -46,6 +45,7 @@ public class FileExportStrategy : IExportStrategy public FileExportStrategy(string filePath) { _filePath = filePath; + } #endregion constructor @@ -54,15 +54,17 @@ public FileExportStrategy(string filePath) public Task ExportAsync(List logs) { - var jsonLogs = new StringBuilder(logs.Count * AVERAGE_LOG_SIZE); + var buffer = new GrowableBuffer(); foreach (var log in logs) { - jsonLogs.AppendLine(log.ToString()); + buffer.Append(log.AsSpan()); + buffer.Append('\n'); } - return Task.Run(() => Flush(jsonLogs.ToString())); + Flush(buffer.ToSpan()); + return Task.CompletedTask; } - private void Flush(string jsonLogs) + private void Flush(ReadOnlySpan jsonLogs) { // Wait to enter the semaphore _fileSemaphore.Wait(); diff --git a/Apps/LogExporterApp/Strategy/HttpExportStrategy.cs b/Apps/LogExporterApp/Strategy/HttpExportStrategy.cs index c7100981..3967f53a 100644 --- a/Apps/LogExporterApp/Strategy/HttpExportStrategy.cs +++ b/Apps/LogExporterApp/Strategy/HttpExportStrategy.cs @@ -57,16 +57,18 @@ public HttpExportStrategy(string endpoint, string method, Dictionary logs) { - var jsonLogs = new StringBuilder(logs.Count); + var buffer = new GrowableBuffer(); foreach (var log in logs) { - jsonLogs.AppendLine(log.ToString()); + buffer.Append(log.AsSpan()); + buffer.Append('\n'); } + var content = buffer.ToString() ?? string.Empty; var request = new HttpRequestMessage { RequestUri = new Uri(_endpoint), Method = new HttpMethod(_method), - Content = new StringContent(jsonLogs.ToString(), Encoding.UTF8, "application/json") + Content = new StringContent( content, Encoding.UTF8, "application/json") }; if (_headers != null) diff --git a/Apps/LogExporterApp/Strategy/SyslogExportStrategy.cs b/Apps/LogExporterApp/Strategy/SyslogExportStrategy.cs index b45b4117..a083392c 100644 --- a/Apps/LogExporterApp/Strategy/SyslogExportStrategy.cs +++ b/Apps/LogExporterApp/Strategy/SyslogExportStrategy.cs @@ -118,7 +118,7 @@ protected virtual void Dispose(bool disposing) private SyslogMessage Convert(LogEntry log) { // Create the structured data with all key details from LogEntry - var elements = new StructuredDataElement(_sdId, new Dictionary + var elements = new StructuredDataElement(_sdId, new Dictionary(StringComparer.OrdinalIgnoreCase) { { "timestamp", log.Timestamp.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ") }, { "clientIp", log.ClientIp }, @@ -171,17 +171,16 @@ private SyslogMessage Convert(LogEntry log) string questionSummary = log.Questions?.Count > 0 ? string.Join("; ", log.Questions.Select(q => $"QNAME: {q.QuestionName}; QTYPE: {q.QuestionType?.ToString() ?? "unknown"}; QCLASS: {q.QuestionClass?.ToString() ?? "unknown"}")) - : "No Questions"; + : string.Empty; // Build the answer summary in the desired format string answerSummary = log.Answers?.Count > 0 ? string.Join(", ", log.Answers.Select(a => a.RecordData)) - : "No Answers"; + : string.Empty; // Construct the message summary string to match the desired format string messageSummary = $"{questionSummary}; RCODE: {log.ResponseCode}; ANSWER: [{answerSummary}]"; - // Create and return the syslog message return new SyslogMessage( log.Timestamp,