Skip to content
Closed
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
59db1c4
Fixed typo
zbalkan Nov 26, 2025
5439bb1
Fixed space
zbalkan Nov 26, 2025
ddad746
Added README
zbalkan Nov 26, 2025
c1754e9
Updated version
zbalkan Nov 26, 2025
8bdab01
Added responding nameserver IP to logs for cluster setups
zbalkan Nov 28, 2025
7a25db9
Added domain name enrichment
zbalkan Nov 28, 2025
98c3b4a
Replaced ConcurrentQueue with Channels
zbalkan Nov 28, 2025
2980cde
Used RecyclableMemoryStreamManager for memory management in strategies
zbalkan Nov 28, 2025
0135361
Updated README
zbalkan Nov 28, 2025
3e1d3bd
Minor refactor on SyslogExporter
zbalkan Nov 28, 2025
419d542
Added ConsoleExportStrategy for container loads and debugging
zbalkan Nov 28, 2025
ef87c9b
Updated README
zbalkan Nov 28, 2025
dbab7f6
Ensure graceful shutdown: disable logging, complete channel, and drai…
zbalkan Nov 30, 2025
6469ef9
Cleaned up packages
zbalkan Nov 30, 2025
25d511e
Fix: remove per-batch cloning and improve export safety
zbalkan Nov 30, 2025
b3c9b6a
Fix ExportManager.ImplementStrategyAsync concurrency bug
zbalkan Nov 30, 2025
f53f897
Harden EDNS Extended Error parsing (prevent remote-triggered exceptions)
zbalkan Nov 30, 2025
43a5991
Make domain parsing (PSL) fully fail-safe
zbalkan Nov 30, 2025
ffe1644
Make _enableLogging assignment race-free by moving it to the end of i…
zbalkan Nov 30, 2025
96cbbac
Ensure each export strategy becomes a no-op after disposal
zbalkan Nov 30, 2025
49358e3
Added configuration validation
zbalkan Nov 30, 2025
9318522
Stop swallowing exceptions in App.Dispose and log them
zbalkan Nov 30, 2025
ebad5f5
Fix the PSL (DomainParser) initialization so plugin startup never blo…
zbalkan Nov 30, 2025
f641750
Introduce a shared NDJSON serialization helper
zbalkan Nov 30, 2025
77d5da4
Introduce cancellation into the export pipeline
zbalkan Nov 30, 2025
587dbff
Passed CancellationTokens
zbalkan Nov 30, 2025
259e710
Simplified _parser
zbalkan Nov 30, 2025
01ca2d1
Added configure await for token
zbalkan Nov 30, 2025
1193ee0
Replace Task.Run with proper async loop + cancellation
zbalkan Nov 30, 2025
b920e53
Add ADR to Dispose explaining why we don’t dispose _stdout
zbalkan Nov 30, 2025
7fe820b
Fixed "ExportManager.Dispose() does not clear the dictionary nor set …
zbalkan Nov 30, 2025
35c9b2c
Improve batch flushing logic in App.BackgroundWorkerAsync / DrainRema…
zbalkan Nov 30, 2025
20b4a29
Batch reallocation in BackgroundWorkerAsync
zbalkan Nov 30, 2025
2ac557b
Refactor LogEntry to use SIEVE-based DomainCache for domain parsing
zbalkan Dec 4, 2025
cbf1979
Removed additional comma at the end of NDJSON line
zbalkan Dec 5, 2025
261ef81
Update Apps/LogExporterApp/LogEntry.cs
zbalkan Dec 8, 2025
6431cd0
Update Apps/LogExporterApp/App.cs
zbalkan Dec 8, 2025
7f520f2
Minor improvements
zbalkan Dec 8, 2025
c5382b1
Merge branch 'log-exporter-2' of https://github.com/zbalkan/DnsServer…
zbalkan Dec 8, 2025
cd75a91
Minor changes
zbalkan Dec 8, 2025
8b7bcab
Major rename
zbalkan Dec 8, 2025
d962238
Minor renaming
zbalkan Dec 8, 2025
9a96a91
Error handling
zbalkan Dec 8, 2025
ca25751
Fix draining logic so shutdown does not discard queued logs
zbalkan Dec 8, 2025
152bba8
Fixed async issues
zbalkan Dec 8, 2025
051f2aa
Simplifie DrainRemainingLogs
zbalkan Dec 8, 2025
24b3968
Added enrichment pipeline
zbalkan Dec 8, 2025
bc9fd89
Updated README
zbalkan Dec 8, 2025
37c1016
Incremented version
zbalkan Dec 8, 2025
4a51fad
Major refactor
zbalkan Dec 27, 2025
4599d90
Added tagging feature
zbalkan Dec 27, 2025
e113420
Minor optimizations on cache
zbalkan Jan 2, 2026
34343ea
Typo
zbalkan Jan 6, 2026
6c52654
Added ndjson flag
zbalkan Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 174 additions & 104 deletions Apps/LogExporterApp/App.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,195 +21,265 @@ You should have received a copy of the GNU General Public License
using DnsServerCore.ApplicationCommon;
using LogExporter.Strategy;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using TechnitiumLibrary;
using TechnitiumLibrary.Net.Dns;

namespace LogExporter
{
public sealed class App : IDnsApplication, IDnsQueryLogger
public sealed class App : IDnsApplication, IDnsQueryLogger, IDisposable
{
#region variables

IDnsServer? _dnsServer;
AppConfig? _config;

readonly ExportManager _exportManager = new ExportManager();

bool _enableLogging;

readonly ConcurrentQueue<LogEntry> _queuedLogs = new ConcurrentQueue<LogEntry>();
readonly Timer _queueTimer;
const int QUEUE_TIMER_INTERVAL = 10000;
const int BULK_INSERT_COUNT = 1000;

readonly ExportManager _exportManager = new ExportManager();
Task? _backgroundTask;
Channel<LogEntry> _channel = default!;
AppConfig? _config;
CancellationTokenSource? _cts;
bool _disposed;

#endregion
IDnsServer? _dnsServer;
volatile bool _enableLogging; // volatile to improve cross-thread visibility
long _droppedCount;
DateTime _lastDropLog = DateTime.UtcNow;
static readonly TimeSpan DropLogInterval = TimeSpan.FromSeconds(5);
#endregion variables

#region constructor

public App()
{
_queueTimer = new Timer(HandleExportLogCallback);
}
{ }

#endregion
#endregion constructor

#region IDisposable

public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
if (_disposed)
return;

private void Dispose(bool disposing)
{
if (!_disposed)
_disposed = true;

// Stop accepting new entries immediately; cannot throw.
_enableLogging = false;

// ADR: Previously Dispose swallowed all exceptions, hiding exporter or
// shutdown failures and making diagnosis impossible. We now log every
// unexpected exception without rethrowing, preserving best-effort teardown
// while ensuring operational visibility.
try
{
if (disposing)
try
{
_queueTimer?.Dispose();

ExportLogsAsync().Sync(); //flush any pending logs
_channel?.Writer.TryComplete();
}
catch (Exception ex)
{
_dnsServer?.WriteLog(ex);
}

_exportManager.Dispose();
try
{
_cts?.Cancel();
}
catch (Exception ex)
{
_dnsServer?.WriteLog(ex);
}
}
catch (Exception ex)
{
_dnsServer?.WriteLog(ex);
}

_disposed = true;
try
{
_backgroundTask?.GetAwaiter().GetResult();
}
catch (OperationCanceledException)
{
// Expected; no log needed.
}
catch (Exception ex)
{
_dnsServer?.WriteLog(ex);
}

_exportManager.Dispose();
GC.SuppressFinalize(this);
}

#endregion
#endregion IDisposable

#region public

public Task InitializeAsync(IDnsServer dnsServer, string config)
{
_dnsServer = dnsServer;
_config = AppConfig.Deserialize(config);

if (_config is null)
throw new DnsClientException("Invalid application configuration.");

if (_config.FileTarget!.Enabled)
{
_exportManager.RemoveStrategy(typeof(FileExportStrategy));
_exportManager.AddStrategy(new FileExportStrategy(_config.FileTarget!.Path));
}
else
{
_exportManager.RemoveStrategy(typeof(FileExportStrategy));
}
_config = AppConfig.Deserialize(config)
?? throw new DnsClientException("Invalid application configuration.");

if (_config.HttpTarget!.Enabled)
{
_exportManager.RemoveStrategy(typeof(HttpExportStrategy));
_exportManager.AddStrategy(new HttpExportStrategy(_config.HttpTarget.Endpoint, _config.HttpTarget.Headers));
}
else
{
_exportManager.RemoveStrategy(typeof(HttpExportStrategy));
}
ConfigureStrategies();

if (_config.SyslogTarget!.Enabled)
// If no sinks exist, never enable logging.
if (!_exportManager.HasStrategy())
{
_exportManager.RemoveStrategy(typeof(SyslogExportStrategy));
_exportManager.AddStrategy(new SyslogExportStrategy(_config.SyslogTarget.Address, _config.SyslogTarget.Port, _config.SyslogTarget.Protocol));
_enableLogging = false;
return Task.CompletedTask;
}
else
{
_exportManager.RemoveStrategy(typeof(SyslogExportStrategy));
}

_enableLogging = _exportManager.HasStrategy();

if (_enableLogging)
_queueTimer.Change(QUEUE_TIMER_INTERVAL, Timeout.Infinite);
else
_queueTimer.Change(Timeout.Infinite, Timeout.Infinite);
// Create bounded channel to avoid memory explosion
_channel = Channel.CreateBounded<LogEntry>(
new BoundedChannelOptions(_config!.MaxQueueSize)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.DropWrite
});

// Start background worker
_cts = new CancellationTokenSource();
_backgroundTask = Task.Run(() => BackgroundWorkerAsync(_cts.Token));

// ADR: _enableLogging is intentionally set last so that any caller observing
// _enableLogging == true can rely on the entire logging pipeline being fully
// constructed (channel, CTS, background worker). This prevents subtle race
// conditions where concurrent InsertLogAsync calls see "enabled" before internal
// structures are ready.
_enableLogging = true;

return Task.CompletedTask;
}

public Task InsertLogAsync(DateTime timestamp, DnsDatagram request, IPEndPoint remoteEP, DnsTransportProtocol protocol, DnsDatagram response)
public Task InsertLogAsync(DateTime timestamp, DnsDatagram request,
IPEndPoint remoteEP, DnsTransportProtocol protocol,
DnsDatagram response)
{
if (_enableLogging)
{
if (_queuedLogs.Count < _config!.MaxQueueSize)
_queuedLogs.Enqueue(new LogEntry(timestamp, remoteEP, protocol, request, response, _config.EnableEdnsLogging));
var entry = new LogEntry(timestamp, remoteEP, protocol, request, response, _config!.EnableEdnsLogging);

if (!_channel.Writer.TryWrite(entry))
{
Interlocked.Increment(ref _droppedCount);

var now = DateTime.UtcNow;
if (now - _lastDropLog >= DropLogInterval)
{
var dropped = Interlocked.Exchange(ref _droppedCount, 0);
_lastDropLog = now;
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _lastDropLog field is accessed and modified on lines 171 and 174 without synchronization. Multiple threads calling InsertLogAsync concurrently could race on reading and writing this field, potentially causing duplicate log messages or skipped logging windows. Consider using Interlocked.CompareExchange to atomically update _lastDropLog or protecting it with a lock to ensure thread-safe access.

Copilot uses AI. Check for mistakes.
_dnsServer?.WriteLog($"Log export queue full; dropped {dropped} entries over last {DropLogInterval.TotalSeconds:F0}s.");
}
}
}

return Task.CompletedTask;
}

#endregion
#endregion public

#region private

private async Task ExportLogsAsync()
private async Task BackgroundWorkerAsync(CancellationToken token)
{
// ADR: Reuse this list buffer to avoid GC churn during high-volume logging.
var batch = new List<LogEntry>(BULK_INSERT_COUNT);

try
{
List<LogEntry> logs = new List<LogEntry>(BULK_INSERT_COUNT);

while (true)
while (await _channel.Reader.WaitToReadAsync(token).ConfigureAwait(false))
{
while (logs.Count < BULK_INSERT_COUNT && _queuedLogs.TryDequeue(out LogEntry? log))
while (batch.Count < BULK_INSERT_COUNT &&
_channel.Reader.TryRead(out var entry))
{
logs.Add(log);
}

if (logs.Count < 1)
break;
if (token.IsCancellationRequested)
break;

await _exportManager.ImplementStrategyAsync(logs);
batch.Add(entry);
}

logs.Clear();
if (batch.Count > 0)
{
await _exportManager.ImplementStrategyAsync(batch, token).ConfigureAwait(false);
batch.Clear(); // REUSE — do not reassign
}
}
}
catch (OperationCanceledException)
{
await DrainRemainingLogs(batch, token).ConfigureAwait(false);
}
catch (Exception ex)
{
_dnsServer?.WriteLog(ex);
await DrainRemainingLogs(batch, token).ConfigureAwait(false);
}
}

private async void HandleExportLogCallback(object? state)
private void ConfigureStrategies()
{
_exportManager.RemoveStrategy(typeof(ConsoleExportStrategy));
if (_config!.ConsoleTarget != null && _config.ConsoleTarget.Enabled)
_exportManager.AddStrategy(new ConsoleExportStrategy());

_exportManager.RemoveStrategy(typeof(FileExportStrategy));
if (_config.FileTarget != null && _config.FileTarget.Enabled)
_exportManager.AddStrategy(new FileExportStrategy(_config.FileTarget.Path));

_exportManager.RemoveStrategy(typeof(HttpExportStrategy));
if (_config.HttpTarget != null && _config.HttpTarget.Enabled)
_exportManager.AddStrategy(
new HttpExportStrategy(_config.HttpTarget.Endpoint, _config.HttpTarget.Headers));

_exportManager.RemoveStrategy(typeof(SyslogExportStrategy));
if (_config.SyslogTarget != null && _config.SyslogTarget.Enabled)
_exportManager.AddStrategy(
new SyslogExportStrategy(_config.SyslogTarget.Address,
_config.SyslogTarget.Port!.Value,
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential null reference exception: _config.SyslogTarget.Port!.Value uses the null-forgiving operator, but if the user hasn't set a port in the configuration, this will throw. The code should use the null-coalescing operator to fall back to DEFAULT_PORT (514) as done in the SyslogExportStrategy constructor on line 60. Change to _config.SyslogTarget.Port ?? 514 or extract DEFAULT_PORT as a constant.

Copilot uses AI. Check for mistakes.
_config.SyslogTarget.Protocol!));
Comment thread
zbalkan marked this conversation as resolved.
Outdated
}

private async Task DrainRemainingLogs(List<LogEntry> batch, CancellationToken token)
{
try
{
// Process logs within the timer interval, then let the timer reschedule
await ExportLogsAsync();
while (_channel!.Reader.TryRead(out var item))
{
if (token.IsCancellationRequested)
break;

batch.Add(item);

if (batch.Count >= BULK_INSERT_COUNT)
{
await _exportManager.ImplementStrategyAsync(batch, token).ConfigureAwait(false);
batch.Clear(); // reuse instead of creating new list
}
}

if (batch.Count > 0 && !token.IsCancellationRequested)
{
await _exportManager.ImplementStrategyAsync(batch, token).ConfigureAwait(false);
batch.Clear();
}
}
catch (Exception ex)
{
_dnsServer?.WriteLog(ex);
}
finally
{
try
{
_queueTimer?.Change(QUEUE_TIMER_INTERVAL, Timeout.Infinite);
}
catch (ObjectDisposedException)
{ }
}
}

#endregion
#endregion private

#region properties

public string Description
{
get { return "Allows exporting query logs to third party sinks. It supports exporting to File, HTTP endpoint, and Syslog (UDP, TCP, TLS, and Local protocols)."; }
}
public string Description =>
"Allows exporting query logs to third party sinks. Supports exporting to File, HTTP endpoint, and Syslog.";

#endregion
#endregion properties
}
}
}
Loading